Development

This commit is contained in:
Matthew Grotke 2026-06-02 12:49:39 -04:00
parent 59ac3c5973
commit 3d0dc265ba
31 changed files with 1093 additions and 2794 deletions

View file

@ -16,6 +16,7 @@ DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending'
DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots'
HEALTH_FILE = f'{CONFIGS_DIR}/.health'
BLOCKLISTS_DIR = f'{CONFIGS_DIR}/blocklists'
PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin')
DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue'
DDNS_TIMER_NAME = f'{PRODUCT_NAME}-ddns-update'
@ -578,3 +579,229 @@ def run_update_blocklists():
)
except Exception:
pass
# Format helpers ====================================================
def fmt_timestamp(ts):
try:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
except Exception:
return '-'
def relative_time(ts1, ts2, short=False):
try:
diff = abs(int(ts1) - int(ts2))
if diff < 60:
return f'{diff}s' if short else f'{diff} second{"s" if diff != 1 else ""}'
m = diff // 60
if m < 60:
return f'{m}m' if short else f'{m} minute{"s" if m != 1 else ""}'
h, rem_m = divmod(m, 60)
if h < 24:
if short:
return f'{h}h {rem_m}m' if rem_m else f'{h}h'
return f'{h}h {rem_m}m' if rem_m else f'{h} hour{"s" if h != 1 else ""}'
d = h // 24
if d < 365:
return f'{d}d' if short else f'{d} day{"s" if d != 1 else ""}'
y = d // 365
return f'{y}y' if short else f'{y} year{"s" if y != 1 else ""}'
except Exception:
return ''
def fmt_bytes(n):
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024:
return f'{n:.1f} {unit}'
n /= 1024
return f'{n:.1f} TB'
def resolve_iface(vlan, cfg):
if vlan.get('is_vpn'):
wg_vlans = [v for v in cfg.get('vlans', []) if v.get('is_vpn')]
wg_sorted = sorted(wg_vlans, key=lambda v: (v.get('vlan_id') is None, v.get('vlan_id') or 0))
idx = next((i for i, v in enumerate(wg_sorted) if v is vlan), 0)
return f'wg{idx}'
lan = cfg.get('network_interfaces', {}).get('lan_interface', 'eth0')
vid = vlan.get('vlan_id') or 1
return lan if vid == 1 else f'{lan}.{vid}'
# Config datasources ================================================
def config_datasource(name):
cfg = load_config()
vlans = cfg.get('vlans', [])
if name == 'banned_ips':
return cfg.get('banned_ips', [])
if name == 'host_overrides':
return cfg.get('host_overrides', [])
if name == 'blocklists':
rows = []
for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
row = dict(bl)
bl_path = os.path.join(BLOCKLISTS_DIR, bl.get('save_as', ''))
try:
with open(bl_path) as f:
row['domain_count'] = str(sum(1 for _ in f))
row['last_updated'] = fmt_timestamp(int(os.path.getmtime(bl_path)))
except Exception:
row['domain_count'] = '-'
row['last_updated'] = '-'
rows.append(row)
return rows
if name == 'vlans':
bl_desc = {
b['name']: b.get('description', b['name'])
for b in cfg.get('dns_blocking', {}).get('blocklists', [])
if 'name' in b
}
rows = []
for v in sorted(vlans, key=lambda x: x.get('vlan_id') or 0):
row = {k: v.get(k) for k in (
'name', 'subnet', 'subnet_mask', 'radius_default',
'mdns_reflection', 'is_vpn', 'dnsmasq_log_queries'
)}
row['vlan_id'] = v.get('vlan_id')
row['interface'] = resolve_iface(v, cfg)
row['use_blocklists'] = json.dumps([
{'n': bl, 'd': bl_desc.get(bl, bl)} for bl in v.get('use_blocklists', [])
])
prefix = v.get('subnet_mask', 24)
n_octets = 1 if prefix >= 24 else 2 if prefix >= 16 else 3 if prefix >= 8 else 4
row['server_identity_ips'] = json.dumps([
{
'n': s['ip'],
'd': ' | '.join(filter(None, [s['ip'], s.get('description'), s.get('hostname')])),
'short': '.' + '.'.join(s['ip'].split('.')[-n_octets:]),
'mini': '.' + '.'.join(s['ip'].split('.')[-n_octets:]),
}
for s in v.get('server_identities', []) if s.get('ip')
])
row['server_identity_descriptions'] = json.dumps([
s.get('description', '') for s in v.get('server_identities', []) if s.get('ip')
])
row['server_identity_hostnames'] = json.dumps([
s.get('hostname', '') for s in v.get('server_identities', []) if s.get('ip')
])
row['server_identity_gateway'] = (
v.get('dhcp_information', {}).get('explicit_overrides', {}).get('gateway', '')
)
dns = v.get('dhcp_information', {}).get('explicit_overrides', {}).get('dns_servers', [])
row['server_identity_dns_servers'] = '\n'.join(dns) if isinstance(dns, list) else str(dns or '')
ntp = v.get('dhcp_information', {}).get('explicit_overrides', {}).get('ntp_servers', [])
row['server_identity_ntp_servers'] = '\n'.join(ntp) if isinstance(ntp, list) else str(ntp or '')
row['gateway'] = row['server_identity_gateway']
row['dns_servers'] = row['server_identity_dns_servers']
row['ntp_servers'] = row['server_identity_ntp_servers']
row['dns_servers_override'] = 1 if row['server_identity_dns_servers'] else 0
row['ntp_servers_override'] = 1 if row['server_identity_ntp_servers'] else 0
dhi = v.get('dhcp_information', {})
row['dhcp_pool_start'] = dhi.get('dynamic_pool_start', '')
row['dhcp_pool_end'] = dhi.get('dynamic_pool_end', '')
lt = dhi.get('lease_time', '')
if lt and len(lt) > 1 and lt[:-1].isdigit() and lt[-1] in 'mhd':
row['dhcp_lease_time'] = lt[:-1]
row['dhcp_lease_unit'] = {'m': 'minutes', 'h': 'hours', 'd': 'days'}[lt[-1]]
else:
row['dhcp_lease_time'] = ''
row['dhcp_lease_unit'] = ''
row['dhcp_domain'] = dhi.get('domain', '')
row['server_identities_json'] = json.dumps(v.get('server_identities', []))
rows.append(row)
return rows
if name == 'inter_vlan_exceptions':
return cfg.get('inter_vlan_exceptions', [])
if name == 'port_forwarding':
return cfg.get('port_forwarding', [])
if name == 'port_wrangling':
rows = []
for r in cfg.get('port_wrangling', []):
row = dict(r)
row['vlan_name'] = r.get('vlan', '-')
rows.append(row)
return rows
if name == 'dhcp_reservations':
rows = []
for res in cfg.get('dhcp_reservations', []):
row = dict(res)
row['vlan_name'] = res.get('vlan', '-')
rows.append(row)
return rows
if name == 'ddns_providers':
from factory import e
ddns = load_config().get('ddns', {})
rows = []
for p in ddns.get('providers', []):
row = dict(p)
ptype = p.get('provider', '').lower()
if ptype == 'noip':
row['credentials'] = (
'<div style="line-height:1.3">'
f'<b>U:</b> {e(p.get("username", "-"))}<br/>'
'<b>P:</b> &bull;&bull;&bull;&bull;&bull;&bull;</div>'
)
elif ptype in ('cloudflare', 'duckdns'):
tok = p.get('api_token', '')
row['credentials'] = f'<b>API Token:</b> {e(tok[:20])}...' if tok else '(not set)'
else:
row['credentials'] = '-'
row['hostnames'] = json.dumps(p.get('hostnames', p.get('subdomains', [])))
rows.append(row)
return rows
if name == 'accounts':
try:
with open(ACCOUNTS_FILE) as f:
data = json.load(f)
except Exception:
data = {}
rows = []
for acct in data.get('accounts', []):
row = dict(acct)
row['account_status'] = 'active' if acct.get('hashed_password') else 'pending'
rows.append(row)
return rows
if name == 'vpn_peers':
rows = []
wg_sorted = sorted(
[v for v in vlans if v.get('is_vpn')],
key=lambda v: (v.get('vlan_id') is None, v.get('vlan_id') or 0)
)
for i, vlan in enumerate(wg_sorted):
iface = f'wg{i}'
vlan_display = f'{iface} (VLAN {vlan.get("vlan_id") or "?"})'
for peer in vlan.get('peers', []):
row = dict(peer)
row['vlan_display'] = vlan_display
row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no'
row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-'
rows.append(row)
return rows
return []
def load_datasource(spec):
if spec.startswith('config:'):
return config_datasource(spec[7:])
return []
def collect_layout_tokens(cfg):
net = cfg.get('network_interfaces', {})
return {
'GENERAL_LAN_INTERFACE': str(net.get('lan_interface', '-')),
'VPN_VLAN_COUNT': str(sum(1 for v in cfg.get('vlans', []) if v.get('is_vpn'))),
}