Development
This commit is contained in:
parent
59ac3c5973
commit
3d0dc265ba
31 changed files with 1093 additions and 2794 deletions
|
|
@ -16,6 +16,7 @@ DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
|
||||||
DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending'
|
DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending'
|
||||||
DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots'
|
DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots'
|
||||||
HEALTH_FILE = f'{CONFIGS_DIR}/.health'
|
HEALTH_FILE = f'{CONFIGS_DIR}/.health'
|
||||||
|
BLOCKLISTS_DIR = f'{CONFIGS_DIR}/blocklists'
|
||||||
PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin')
|
PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin')
|
||||||
DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue'
|
DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue'
|
||||||
DDNS_TIMER_NAME = f'{PRODUCT_NAME}-ddns-update'
|
DDNS_TIMER_NAME = f'{PRODUCT_NAME}-ddns-update'
|
||||||
|
|
@ -578,3 +579,229 @@ def run_update_blocklists():
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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> ••••••</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'))),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
# factory.py: JSON content-type renderer
|
# factory.py: HTML renderer and shared utilities
|
||||||
# Converts content.json item trees into HTML strings.
|
# Builds HTML from content.json item trees. Pure rendering - no data fetching.
|
||||||
# Pure type processing: no data loading, no routing, no layout.
|
|
||||||
from flask import session
|
from flask import session
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
import json, re, sys, html as html_mod
|
import json, re, sys, html as html_mod, os, subprocess
|
||||||
from config_utils import config_hash
|
from config_utils import (
|
||||||
|
config_hash, load_config, CONFIGS_DIR, DATA_DIR, WWW_DIR, APP_DIR,
|
||||||
|
ACCOUNTS_FILE, HEALTH_FILE, BLOCKLISTS_DIR,
|
||||||
|
fmt_timestamp, relative_time, fmt_bytes, resolve_iface,
|
||||||
|
WEB_APP_DISPLAY_NAME,
|
||||||
|
)
|
||||||
|
from config_utils import (
|
||||||
|
get_pending_entries, get_dashboard_pending, _find_cmd_in_queues,
|
||||||
|
_apply_changes_immediately, _seconds_until_next_run, _format_timing,
|
||||||
|
_is_locked, _lock_mtime, _entry_ts_from_queue,
|
||||||
|
)
|
||||||
|
|
||||||
# Injected by view_page at startup ====================================
|
PAGES_DIR = os.path.join(APP_DIR, 'pages')
|
||||||
# view_page sets this after defining load_datasource so that build_table
|
NAVBAR_FILE = os.path.join(APP_DIR, 'navbar.json')
|
||||||
# can load row data without creating a circular import.
|
CSS_FILE = os.path.join(DATA_DIR, 'styles.css')
|
||||||
load_datasource = None
|
COMMON_JS_FILE = os.path.join(DATA_DIR, 'common.js')
|
||||||
|
|
||||||
# Constants ===========================================================
|
# Constants ===========================================================
|
||||||
|
|
||||||
|
|
@ -34,6 +43,60 @@ VALIDATION_FLAGS = {
|
||||||
'VALIDATION_IPV4_CIDR': 1 << 13,
|
'VALIDATION_IPV4_CIDR': 1 << 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# File / shell helpers ================================================
|
||||||
|
|
||||||
|
def load_json(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as ex:
|
||||||
|
print(f'[factory] ERROR loading {path}: {ex}', file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def load_ddns():
|
||||||
|
return load_config().get('ddns', {})
|
||||||
|
|
||||||
|
def load_accounts():
|
||||||
|
return load_json(ACCOUNTS_FILE)
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
|
||||||
|
return r.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def load_css():
|
||||||
|
try:
|
||||||
|
with open(CSS_FILE) as f:
|
||||||
|
return f.read()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def load_icon(name):
|
||||||
|
try:
|
||||||
|
with open(f'{WWW_DIR}/icons/{name}.svg') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def inline_js(page_name=None):
|
||||||
|
big_validate_js = build_big_validate()
|
||||||
|
try:
|
||||||
|
with open(COMMON_JS_FILE) as f:
|
||||||
|
app_js = f.read()
|
||||||
|
except Exception:
|
||||||
|
app_js = ''
|
||||||
|
page_js = ''
|
||||||
|
if page_name:
|
||||||
|
page_js_path = os.path.join(PAGES_DIR, page_name, 'page.js')
|
||||||
|
try:
|
||||||
|
with open(page_js_path) as f:
|
||||||
|
page_js = f.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return big_validate_js + '\n' + app_js + ('\n' + page_js if page_js else '')
|
||||||
|
|
||||||
# Utilities ===========================================================
|
# Utilities ===========================================================
|
||||||
|
|
||||||
def e(text):
|
def e(text):
|
||||||
|
|
@ -191,6 +254,16 @@ def get_worker_id(datasource):
|
||||||
return datasource[len(prefix):]
|
return datasource[len(prefix):]
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def table_token_key(spec):
|
||||||
|
return 'TABLE_' + re.sub(r'[^A-Z0-9]', '_', spec.upper())
|
||||||
|
|
||||||
|
def iter_table_items(items):
|
||||||
|
for item in items:
|
||||||
|
if item.get('type') == 'table':
|
||||||
|
yield item
|
||||||
|
for sub in (item.get('items') or [], (item.get('toolbar') or {}).get('items') or []):
|
||||||
|
yield from iter_table_items(sub)
|
||||||
|
|
||||||
# Access control ======================================================
|
# Access control ======================================================
|
||||||
|
|
||||||
def client_level():
|
def client_level():
|
||||||
|
|
@ -215,17 +288,17 @@ def passes(req, level):
|
||||||
|
|
||||||
# Snapshot helpers ====================================================
|
# Snapshot helpers ====================================================
|
||||||
|
|
||||||
def _flatten_json(val, prefix):
|
def flatten_json(val, prefix):
|
||||||
"""Recursively flatten a parsed JSON value into [(path, leaf_str)] pairs."""
|
"""Recursively flatten a parsed JSON value into [(path, leaf_str)] pairs."""
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
out = []
|
out = []
|
||||||
for k, v in val.items():
|
for k, v in val.items():
|
||||||
out.extend(_flatten_json(v, f'{prefix}.{k}'))
|
out.extend(flatten_json(v, f'{prefix}.{k}'))
|
||||||
return out
|
return out
|
||||||
if isinstance(val, list):
|
if isinstance(val, list):
|
||||||
out = []
|
out = []
|
||||||
for i, v in enumerate(val):
|
for i, v in enumerate(val):
|
||||||
out.extend(_flatten_json(v, f'{prefix}[{i}]'))
|
out.extend(flatten_json(v, f'{prefix}[{i}]'))
|
||||||
return out
|
return out
|
||||||
if val is None:
|
if val is None:
|
||||||
return [(prefix, None)]
|
return [(prefix, None)]
|
||||||
|
|
@ -260,8 +333,8 @@ def snap_expand_row(changes, colspan):
|
||||||
bval = json.loads(before_text) if before_text is not None else None
|
bval = json.loads(before_text) if before_text is not None else None
|
||||||
aval = json.loads(after_text) if after_text is not None else None
|
aval = json.loads(after_text) if after_text is not None else None
|
||||||
if isinstance(bval, (dict, list)) or isinstance(aval, (dict, list)):
|
if isinstance(bval, (dict, list)) or isinstance(aval, (dict, list)):
|
||||||
bflat = dict(_flatten_json(bval, field)) if isinstance(bval, (dict, list)) else {}
|
bflat = dict(flatten_json(bval, field)) if isinstance(bval, (dict, list)) else {}
|
||||||
aflat = dict(_flatten_json(aval, field)) if isinstance(aval, (dict, list)) else {}
|
aflat = dict(flatten_json(aval, field)) if isinstance(aval, (dict, list)) else {}
|
||||||
if bflat or aflat:
|
if bflat or aflat:
|
||||||
seen = set()
|
seen = set()
|
||||||
for k in list(aflat) + list(bflat):
|
for k in list(aflat) + list(bflat):
|
||||||
|
|
@ -1007,10 +1080,9 @@ def build_table_cell(value, render_fn, col_class='', field='', row_idx=None,
|
||||||
|
|
||||||
# Table renderer ======================================================
|
# Table renderer ======================================================
|
||||||
|
|
||||||
def build_table(item, tokens, inherited_req=None):
|
def build_table(item, tokens, rows, inherited_req=None):
|
||||||
level = client_level()
|
level = client_level()
|
||||||
columns = item.get('columns', [])
|
columns = item.get('columns', [])
|
||||||
rows = load_datasource(item.get('datasource', ''))
|
|
||||||
empty = e(item.get('empty_message', 'No data.'))
|
empty = e(item.get('empty_message', 'No data.'))
|
||||||
row_actions = item.get('row_actions', [])
|
row_actions = item.get('row_actions', [])
|
||||||
hash_val = config_hash()
|
hash_val = config_hash()
|
||||||
|
|
@ -1515,9 +1587,217 @@ def build_item(item, tokens, inherited_req=None):
|
||||||
return f'<div class="button-row"{style_attr}>{inner}</div>'
|
return f'<div class="button-row"{style_attr}>{inner}</div>'
|
||||||
|
|
||||||
if t == 'table':
|
if t == 'table':
|
||||||
return build_table(item, tokens, req)
|
ds = item.get('datasource', '')
|
||||||
|
key = table_token_key(ds)
|
||||||
|
if key in tokens:
|
||||||
|
return Markup(tokens[key])
|
||||||
|
return build_table(item, tokens, [], req)
|
||||||
|
|
||||||
if t == 'raw_html':
|
if t == 'raw_html':
|
||||||
return Markup(apply_tokens(item.get('html', ''), tokens))
|
return Markup(apply_tokens(item.get('html', ''), tokens))
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# Layout renderer =====================================================
|
||||||
|
|
||||||
|
def render_layout(view_id, content_html, tokens, page_name=None):
|
||||||
|
css = load_css()
|
||||||
|
level = client_level()
|
||||||
|
has_pending_alert = not _apply_changes_immediately() and bool(get_dashboard_pending())
|
||||||
|
titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{WEB_APP_DISPLAY_NAME}</span></div>'
|
||||||
|
navbar_html = build_navbar(view_id, level, tokens, pending_alert=has_pending_alert)
|
||||||
|
footer_html = f'<footer class="footer">{WEB_APP_DISPLAY_NAME}</footer>'
|
||||||
|
|
||||||
|
page_hash = config_hash()
|
||||||
|
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
|
||||||
|
vpn_count = tokens.get('VPN_VLAN_COUNT', '0')
|
||||||
|
current_user = session.get('email_address', '')
|
||||||
|
pending = get_pending_entries()
|
||||||
|
my_uuid = next((u for u, t, c, usr in pending if usr == current_user and c != 'fix problems'), None)
|
||||||
|
|
||||||
|
secs = _seconds_until_next_run()
|
||||||
|
locked = _is_locked()
|
||||||
|
lock_mtime = _lock_mtime()
|
||||||
|
other_bars = ''
|
||||||
|
seen_other_users = set()
|
||||||
|
for o_uuid, o_ts, o_cmd, o_user in pending:
|
||||||
|
if o_user == current_user:
|
||||||
|
continue
|
||||||
|
if o_user in seen_other_users:
|
||||||
|
continue
|
||||||
|
seen_other_users.add(o_user)
|
||||||
|
display_user = 'Another user' if o_user in ('unknown', '') else e(o_user)
|
||||||
|
if locked and lock_mtime and o_ts < lock_mtime:
|
||||||
|
text = f'{display_user}\'s changes are being applied now...'
|
||||||
|
cls = 'info-bar-warning info-bar-running'
|
||||||
|
else:
|
||||||
|
timing = _format_timing(secs)
|
||||||
|
text = (
|
||||||
|
f'{display_user} has pending changes which will be applied {timing}.'
|
||||||
|
if timing else
|
||||||
|
f'{display_user} has pending changes. The processing service is not running.'
|
||||||
|
)
|
||||||
|
cls = 'info-bar-warning'
|
||||||
|
other_bars += f'<div class="info-bar {cls}" data-apply-uuid="{e(o_uuid)}" data-apply-user="{e(o_user)}"><span>{text}</span></div>\n'
|
||||||
|
|
||||||
|
problem_bars = ''
|
||||||
|
if level >= LEVEL_RANK['viewer']:
|
||||||
|
try:
|
||||||
|
st = json.load(open(HEALTH_FILE))
|
||||||
|
problems = []
|
||||||
|
for section in ('configurations', 'logs'):
|
||||||
|
for item in st.get(section, []):
|
||||||
|
if item.get('status') == 'problem':
|
||||||
|
problems.append(e(item.get('detail', item.get('name', ''))))
|
||||||
|
for item in st.get('services', []):
|
||||||
|
if item.get('status') == 'problem':
|
||||||
|
name = item.get('name', '')
|
||||||
|
utype = 'timer' if name.endswith('.timer') else 'service' if name.endswith('.service') else 'unit'
|
||||||
|
exp_parts, act_parts = [], []
|
||||||
|
if not item.get('active_ok'):
|
||||||
|
exp_parts.append(item.get('expected_active', 'active'))
|
||||||
|
act_parts.append(item.get('active', 'unknown'))
|
||||||
|
if not item.get('enabled_ok'):
|
||||||
|
exp_parts.append(item.get('expected_enabled', 'enabled'))
|
||||||
|
act_parts.append(item.get('enabled', 'unknown'))
|
||||||
|
problems.append(e(
|
||||||
|
f"The {utype} `{name}` is expected to be "
|
||||||
|
f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}."
|
||||||
|
))
|
||||||
|
has_problems = bool(problems)
|
||||||
|
fix_suffix = ''
|
||||||
|
fix_uuid = None
|
||||||
|
if has_problems:
|
||||||
|
if level < LEVEL_RANK['administrator']:
|
||||||
|
fix_suffix = 'Please contact an administrator.'
|
||||||
|
else:
|
||||||
|
fix_uuid, fix_ts = _find_cmd_in_queues('fix problems')
|
||||||
|
if _apply_changes_immediately():
|
||||||
|
if _is_locked():
|
||||||
|
mtime = _lock_mtime()
|
||||||
|
fix_suffix = (
|
||||||
|
'Fix is being applied now...'
|
||||||
|
if fix_ts and mtime and fix_ts < mtime
|
||||||
|
else 'Fix will be applied on the next run.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
timing = _format_timing(_seconds_until_next_run())
|
||||||
|
fix_suffix = (
|
||||||
|
f'Fix will be applied {timing}.'
|
||||||
|
if timing else
|
||||||
|
'Fix pending. The processing service is not running.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fix_suffix = (
|
||||||
|
'Fix pending. Click <strong>Apply Now</strong> below to fix.'
|
||||||
|
if view_id == 'actions' else
|
||||||
|
'Fix pending. Visit the <strong>Actions</strong> page ASAP to apply fix.'
|
||||||
|
)
|
||||||
|
if problems:
|
||||||
|
problems_list = (
|
||||||
|
'<ul style="margin:0.25em 0;padding-left:1.25em">'
|
||||||
|
+ ''.join(f'<li>{d}</li>' for d in problems)
|
||||||
|
+ '</ul>'
|
||||||
|
)
|
||||||
|
uuid_attr = (
|
||||||
|
f' data-health-uuid="{e(fix_uuid)}"'
|
||||||
|
if fix_uuid and _entry_ts_from_queue(fix_uuid) is not None else ''
|
||||||
|
)
|
||||||
|
fix_html = (
|
||||||
|
f'<div style="margin-top:0.5em"{uuid_attr}>{fix_suffix}</div>'
|
||||||
|
if fix_suffix else ''
|
||||||
|
)
|
||||||
|
content = (
|
||||||
|
'<div style="width:100%">'
|
||||||
|
'<div style="font-weight:600;margin-bottom:0.25em">Health check - problems found:</div>'
|
||||||
|
+ problems_list + fix_html
|
||||||
|
+ '</div>'
|
||||||
|
)
|
||||||
|
problem_bars += f'<div class="info-bar info-bar-danger">{content}</div>\n'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pending_bar = ''
|
||||||
|
if has_pending_alert and not problem_bars and view_id != 'actions':
|
||||||
|
pending_bar = (
|
||||||
|
'<div class="info-bar info-bar-warning">'
|
||||||
|
'<span>You have actions pending. Please visit the <strong>Actions</strong> page.</span>'
|
||||||
|
'</div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
||||||
|
' <meta charset="UTF-8"/>\n'
|
||||||
|
' <meta name="viewport" content="width=device-width, initial-scale=1.0"/>\n'
|
||||||
|
f' <title>{WEB_APP_DISPLAY_NAME}</title>\n'
|
||||||
|
f' <style>{css}</style>\n'
|
||||||
|
'</head>\n<body>\n'
|
||||||
|
f'{titlebar_html}\n'
|
||||||
|
f'{navbar_html}\n'
|
||||||
|
f'<main class="main-content">\n{pending_bar}{problem_bars}{other_bars}{content_html}\n</main>\n'
|
||||||
|
f'{footer_html}\n'
|
||||||
|
f'<script>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var APPLY_UUID={json.dumps(my_uuid)};</script>\n'
|
||||||
|
f'<script>{inline_js(page_name)}</script>\n'
|
||||||
|
'</body>\n</html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_navbar(active_view, level, tokens, pending_alert=False):
|
||||||
|
navbar_data = load_json(NAVBAR_FILE)
|
||||||
|
left, right = [], []
|
||||||
|
for item in navbar_data.get('items', []):
|
||||||
|
req = item.get('client_requirement')
|
||||||
|
align = item.get('align', 'left')
|
||||||
|
if not passes(req, level):
|
||||||
|
continue
|
||||||
|
frag = build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=req, pending_alert=pending_alert)
|
||||||
|
(right if align == 'right' else left).append(frag)
|
||||||
|
return (
|
||||||
|
'<nav class="nav-bar">'
|
||||||
|
f'<div class="nav-left">{"".join(left)}</div>'
|
||||||
|
f'<div class="nav-right">{"".join(right)}</div>'
|
||||||
|
'</nav>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None, pending_alert=False):
|
||||||
|
req = item.get('client_requirement', inherited_req)
|
||||||
|
t = item.get('type', '')
|
||||||
|
|
||||||
|
if t in ('nav_item', 'nav_action'):
|
||||||
|
label = e(item.get('label', ''))
|
||||||
|
map_to = item.get('map_to', '')
|
||||||
|
action = item.get('action', '')
|
||||||
|
is_active = ' active' if map_to and map_to == active_view else ''
|
||||||
|
pending = ' nav-item-pending' if pending_alert and map_to == 'actions' else ''
|
||||||
|
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}'
|
||||||
|
if action:
|
||||||
|
return (
|
||||||
|
f'<form method="post" action="/action/{e(action)}" class="form-inline">'
|
||||||
|
f'<button type="submit" class="{cls}">{label}</button></form>'
|
||||||
|
)
|
||||||
|
if map_to:
|
||||||
|
return f'<a href="/{e(map_to)}" class="{cls}">{label}</a>'
|
||||||
|
return f'<span class="{cls}">{label}</span>'
|
||||||
|
|
||||||
|
if t == 'nav_menu':
|
||||||
|
raw_label = item.get('label', '')
|
||||||
|
if raw_label == '%MENU_LABEL%':
|
||||||
|
raw_label = 'Configure' if level >= LEVEL_RANK['administrator'] else 'View'
|
||||||
|
label = e(raw_label)
|
||||||
|
children = ''
|
||||||
|
for child in item.get('items', []):
|
||||||
|
child_req = child.get('client_requirement', req)
|
||||||
|
if not passes(child_req, level):
|
||||||
|
continue
|
||||||
|
children += build_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req, pending_alert=pending_alert)
|
||||||
|
if not children:
|
||||||
|
return ''
|
||||||
|
return (
|
||||||
|
'<div class="nav-menu">'
|
||||||
|
f'<button class="nav-item nav-menu-trigger" aria-haspopup="true">{label}</button>'
|
||||||
|
f'<div class="nav-dropdown">{children}</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
return ''
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import os, json, sys
|
import os, json, sys, importlib.util as _importlib_util
|
||||||
from flask import Flask
|
from flask import Flask, Blueprint, session, redirect, get_flashed_messages
|
||||||
from config_utils import ACCOUNTS_FILE
|
from markupsafe import Markup
|
||||||
from view_common import bp as view_page_bp
|
from config_utils import (
|
||||||
|
ACCOUNTS_FILE, APP_DIR, CONFIGS_DIR, HEALTH_FILE,
|
||||||
|
load_config, queue_command, _find_cmd_in_queues,
|
||||||
|
)
|
||||||
|
from factory import (
|
||||||
|
LEVEL_RANK, PAGES_DIR, e, client_level, passes, build_items,
|
||||||
|
load_json, render_layout,
|
||||||
|
)
|
||||||
from pages.actions.action import bp as actions_bp
|
from pages.actions.action import bp as actions_bp
|
||||||
from pages.bannedips.action import bp as bannedips_bp
|
from pages.bannedips.action import bp as bannedips_bp
|
||||||
from pages.ddns.action import bp as ddns_bp
|
from pages.ddns.action import bp as ddns_bp
|
||||||
|
|
@ -27,7 +34,82 @@ from api_apply_health import bp as api_apply_health_bp
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||||
app.register_blueprint(view_page_bp)
|
|
||||||
|
# View blueprint ======================================================
|
||||||
|
|
||||||
|
bp = Blueprint('view_page', __name__)
|
||||||
|
|
||||||
|
page_view_cache = {}
|
||||||
|
|
||||||
|
def load_page_view(page_name):
|
||||||
|
if page_name not in page_view_cache:
|
||||||
|
path = os.path.join(PAGES_DIR, page_name, 'view.py')
|
||||||
|
if not os.path.exists(path):
|
||||||
|
page_view_cache[page_name] = None
|
||||||
|
else:
|
||||||
|
spec = _importlib_util.spec_from_file_location(f'page_view_{page_name}', path)
|
||||||
|
mod = _importlib_util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
page_view_cache[page_name] = mod
|
||||||
|
return page_view_cache[page_name]
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
return serve_view('overview')
|
||||||
|
|
||||||
|
@bp.route('/<page_name>')
|
||||||
|
def view(page_name):
|
||||||
|
return serve_view(page_name)
|
||||||
|
|
||||||
|
def serve_view(page_name):
|
||||||
|
view_def = load_json(os.path.join(PAGES_DIR, page_name, 'content.json'))
|
||||||
|
if not view_def:
|
||||||
|
from flask import abort
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
view_req = view_def.get('client_requirement')
|
||||||
|
level = client_level()
|
||||||
|
if not passes(view_req, level):
|
||||||
|
return redirect('/overview' if level > 0 else '/accountlogin')
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
|
||||||
|
if level >= LEVEL_RANK['administrator']:
|
||||||
|
try:
|
||||||
|
st = json.load(open(HEALTH_FILE))
|
||||||
|
has_problems = any(
|
||||||
|
item.get('status') == 'problem'
|
||||||
|
for section in ('configurations', 'logs', 'services')
|
||||||
|
for item in st.get(section, [])
|
||||||
|
)
|
||||||
|
if has_problems:
|
||||||
|
fix_uuid, _ = _find_cmd_in_queues('fix problems')
|
||||||
|
if fix_uuid is None:
|
||||||
|
queue_command('fix problems', user=session.get('email_address', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
page_view = load_page_view(page_name)
|
||||||
|
tokens = {}
|
||||||
|
if page_view and hasattr(page_view, 'collect_tokens'):
|
||||||
|
tokens.update(page_view.collect_tokens(cfg))
|
||||||
|
|
||||||
|
if page_name == 'radius' and not os.path.exists(f'{CONFIGS_DIR}/.radius-secret'):
|
||||||
|
queue_command('gen radius')
|
||||||
|
|
||||||
|
flash_html = ''
|
||||||
|
for category, message in get_flashed_messages(with_categories=True):
|
||||||
|
variant = {'error': 'danger', 'warning': 'warning', 'success': 'success'}.get(category, 'info')
|
||||||
|
msg_html = message if isinstance(message, Markup) else e(message)
|
||||||
|
flash_html += f'<div class="info-bar info-bar-{variant} info-bar-flash"><span>{msg_html}</span></div>'
|
||||||
|
|
||||||
|
content_html = flash_html + build_items(view_def.get('items', []), tokens, view_req)
|
||||||
|
return render_layout(page_name, content_html, tokens, page_name=page_name)
|
||||||
|
|
||||||
|
# Register blueprints =================================================
|
||||||
|
|
||||||
|
app.register_blueprint(bp)
|
||||||
app.register_blueprint(actions_bp)
|
app.register_blueprint(actions_bp)
|
||||||
app.register_blueprint(bannedips_bp)
|
app.register_blueprint(bannedips_bp)
|
||||||
app.register_blueprint(ddns_bp)
|
app.register_blueprint(ddns_bp)
|
||||||
|
|
@ -51,6 +133,7 @@ app.register_blueprint(mdns_bp)
|
||||||
app.register_blueprint(radius_bp)
|
app.register_blueprint(radius_bp)
|
||||||
app.register_blueprint(api_apply_health_bp)
|
app.register_blueprint(api_apply_health_bp)
|
||||||
|
|
||||||
|
|
||||||
def _seed_initial_account():
|
def _seed_initial_account():
|
||||||
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
|
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
|
||||||
if not email:
|
if not email:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import json
|
import json
|
||||||
import sanitize
|
import sanitize
|
||||||
|
from config_utils import collect_layout_tokens
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
blank = [{'value': '', 'label': '-- Select timezone --'}]
|
blank = [{'value': '', 'label': '-- Select timezone --'}]
|
||||||
return {
|
tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES])
|
||||||
'TIMEZONE_OPTIONS': json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]),
|
return tokens
|
||||||
}
|
|
||||||
|
|
|
||||||
5
docker/routlin-dash/app/pages/accountlogin/view.py
Normal file
5
docker/routlin-dash/app/pages/accountlogin/view.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from config_utils import collect_layout_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tokens(cfg):
|
||||||
|
return collect_layout_tokens(cfg)
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
return {
|
tokens = collect_layout_tokens(cfg)
|
||||||
'ACCOUNT_LEVEL_OPTIONS': json.dumps([
|
tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([
|
||||||
{'value': 'viewer', 'label': 'Viewer (read-only access to live data)'},
|
{'value': 'viewer', 'label': 'Viewer (read-only access to live data)'},
|
||||||
{'value': 'administrator', 'label': 'Administrator (can modify configuration)'},
|
{'value': 'administrator', 'label': 'Administrator (can modify configuration)'},
|
||||||
{'value': 'manager', 'label': 'Manager (full access including account management)'},
|
{'value': 'manager', 'label': 'Manager (full access including account management)'},
|
||||||
]),
|
])
|
||||||
}
|
content = load_json(f'{PAGES_DIR}/accountmanage/content.json')
|
||||||
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
5
docker/routlin-dash/app/pages/accountverifyemail/view.py
Normal file
5
docker/routlin-dash/app/pages/accountverifyemail/view.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from config_utils import collect_layout_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tokens(cfg):
|
||||||
|
return collect_layout_tokens(cfg)
|
||||||
|
|
@ -3,15 +3,14 @@ from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import session
|
from flask import session
|
||||||
from config_utils import (
|
from config_utils import (
|
||||||
get_dashboard_pending, load_all_groups, get_done_timestamps,
|
collect_layout_tokens, get_dashboard_pending, load_all_groups, get_done_timestamps,
|
||||||
_apply_changes_immediately, _find_cmd_in_queues, WEB_APP_DISPLAY_NAME,
|
_apply_changes_immediately, _find_cmd_in_queues, WEB_APP_DISPLAY_NAME,
|
||||||
)
|
)
|
||||||
from factory import LEVEL_RANK, e, client_level, build_snap_val, snap_expand_row
|
from factory import LEVEL_RANK, e, client_level, build_snap_val, snap_expand_row, load_icon
|
||||||
from view_common import _load_icon
|
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
tokens = {}
|
tokens = collect_layout_tokens(cfg)
|
||||||
tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if session.get('apply_changes_immediately', False) else 'false'
|
tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if session.get('apply_changes_immediately', False) else 'false'
|
||||||
|
|
||||||
all_groups = load_all_groups()
|
all_groups = load_all_groups()
|
||||||
|
|
@ -60,7 +59,7 @@ def collect_tokens(cfg):
|
||||||
tokens['NO_PENDING'] = 'true' if not pending_items else ''
|
tokens['NO_PENDING'] = 'true' if not pending_items else ''
|
||||||
tokens['NO_DISMISSIBLE_PENDING'] = 'true' if not any(c != 'fix problems' for _, _, c, _ in pending_items) else ''
|
tokens['NO_DISMISSIBLE_PENDING'] = 'true' if not any(c != 'fix problems' for _, _, c, _ in pending_items) else ''
|
||||||
tokens['APPLY_WARNING'] = (
|
tokens['APPLY_WARNING'] = (
|
||||||
f'<span style="color:var(--warning)"><p>{_load_icon("arrow-left")} <strong>Applying actions will briefly disrupt connections as network services are restarted.</strong></p></span>'
|
f'<span style="color:var(--warning)"><p>{load_icon("arrow-left")} <strong>Applying actions will briefly disrupt connections as network services are restarted.</strong></p></span>'
|
||||||
if pending_items else ''
|
if pending_items else ''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
11
docker/routlin-dash/app/pages/bannedips/view.py
Normal file
11
docker/routlin-dash/app/pages/bannedips/view.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
|
content = load_json(f'{PAGES_DIR}/bannedips/content.json')
|
||||||
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
from view_common import load_ddns, public_ip_info, ddns_last_checked, CONFIGS_DIR
|
from config_utils import (
|
||||||
|
collect_layout_tokens, load_datasource, CONFIGS_DIR, relative_time,
|
||||||
|
)
|
||||||
|
from factory import load_ddns, load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
||||||
DDNS_LOG_MAX = 50
|
DDNS_LOG_MAX = 50
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,8 +46,47 @@ def _ddns_log_tail():
|
||||||
return '(error reading log)', ''
|
return '(error reading log)', ''
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cached_ip():
|
||||||
|
try:
|
||||||
|
best_ip, best_mtime = '', 0.0
|
||||||
|
for fname in os.listdir(CONFIGS_DIR):
|
||||||
|
if fname.startswith('.ddns-last-ip-'):
|
||||||
|
path = f'{CONFIGS_DIR}/{fname}'
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
if mtime > best_mtime:
|
||||||
|
ip = open(path).read().strip()
|
||||||
|
if ip:
|
||||||
|
best_ip, best_mtime = ip, mtime
|
||||||
|
return best_ip, (best_mtime if best_ip else None)
|
||||||
|
except Exception:
|
||||||
|
return '', None
|
||||||
|
|
||||||
|
|
||||||
|
def public_ip_info(ddns_cfg):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
enabled_p = [p for p in ddns_cfg.get('providers', []) if p.get('enabled', True)]
|
||||||
|
all_hosts = []
|
||||||
|
for p in enabled_p:
|
||||||
|
all_hosts.extend(p.get('hostnames', p.get('subdomains', [])))
|
||||||
|
domains_sub = ', '.join(all_hosts)
|
||||||
|
ip, mtime = _read_cached_ip()
|
||||||
|
last_obtained = f'Obtained: {relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago' if mtime else ''
|
||||||
|
if ip:
|
||||||
|
return ip, domains_sub, last_obtained
|
||||||
|
return 'Offline', domains_sub, ''
|
||||||
|
|
||||||
|
|
||||||
|
def ddns_last_checked():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(f'{CONFIGS_DIR}/.ddns-last-service')
|
||||||
|
return f'Last checked: {relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago'
|
||||||
|
except OSError:
|
||||||
|
return 'Last checked: ---'
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
tokens = {}
|
tokens = collect_layout_tokens(cfg)
|
||||||
ddns = load_ddns()
|
ddns = load_ddns()
|
||||||
ddns_gen = ddns.get('general', {})
|
ddns_gen = ddns.get('general', {})
|
||||||
tokens['DDNS_TIMER_INTERVAL'] = ddns_gen.get('timer_interval', '-')
|
tokens['DDNS_TIMER_INTERVAL'] = ddns_gen.get('timer_interval', '-')
|
||||||
|
|
@ -68,4 +111,8 @@ def collect_tokens(cfg):
|
||||||
tokens['STAT_PUBLIC_IP_LAST_OBTAINED'] = last_obtained
|
tokens['STAT_PUBLIC_IP_LAST_OBTAINED'] = last_obtained
|
||||||
tokens['STAT_PUBLIC_IP_LAST_CHECKED'] = ddns_last_checked()
|
tokens['STAT_PUBLIC_IP_LAST_CHECKED'] = ddns_last_checked()
|
||||||
tokens['DDNS_LOG_TAIL'], tokens['DDNS_LOG_SUMMARY'] = _ddns_log_tail()
|
tokens['DDNS_LOG_TAIL'], tokens['DDNS_LOG_SUMMARY'] = _ddns_log_tail()
|
||||||
|
content = load_json(f'{PAGES_DIR}/ddns/content.json')
|
||||||
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
return tokens
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
import copy
|
|
||||||
import ipaddress
|
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
|
||||||
from auth import require_level
|
|
||||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
|
||||||
import sanitize
|
|
||||||
import validation as validate
|
|
||||||
|
|
||||||
_PAGE = Path(__file__).parent.name
|
|
||||||
|
|
||||||
bp = Blueprint(_PAGE, __name__)
|
|
||||||
|
|
||||||
def _row_index():
|
|
||||||
try:
|
|
||||||
return int(request.form.get('row_index', ''))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_ok():
|
|
||||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
|
||||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ip():
|
|
||||||
raw = request.form.get('ip', '').strip()
|
|
||||||
if not raw:
|
|
||||||
return 'dynamic'
|
|
||||||
ip = validate.ip(raw)
|
|
||||||
if not ip:
|
|
||||||
flash(f'The configuration has not been saved because "{raw}" is not a valid IP address.', 'error')
|
|
||||||
return None
|
|
||||||
return ip
|
|
||||||
|
|
||||||
|
|
||||||
def _check_ip_in_vlan_subnet(ip, vlan):
|
|
||||||
if not ip or ip == 'dynamic':
|
|
||||||
return None
|
|
||||||
subnet = vlan.get('subnet')
|
|
||||||
prefix = vlan.get('subnet_mask')
|
|
||||||
if not subnet or prefix is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
network = ipaddress.IPv4Network(f'{subnet}/{prefix}', strict=False)
|
|
||||||
addr = ipaddress.IPv4Address(ip)
|
|
||||||
if addr == network.network_address:
|
|
||||||
return f'{ip} is the network address and cannot be assigned.'
|
|
||||||
if addr == network.broadcast_address:
|
|
||||||
return f'{ip} is the broadcast address and cannot be assigned.'
|
|
||||||
if addr not in network:
|
|
||||||
return f'{ip} is not within the {vlan["name"]} subnet ({subnet}/{prefix}).'
|
|
||||||
except ValueError:
|
|
||||||
return f'{ip} is not a valid IP address.'
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/action/dhcp/addreservation_add', methods=['POST'])
|
|
||||||
@require_level('administrator')
|
|
||||||
def addreservation_add():
|
|
||||||
vlan_name = sanitize.name(request.form.get('vlan_name', ''))
|
|
||||||
description = sanitize.text(request.form.get('description', ''))
|
|
||||||
hostname = validate.domainname(request.form.get('hostname', ''))
|
|
||||||
mac = sanitize.mac(request.form.get('mac', ''))
|
|
||||||
ip = _parse_ip()
|
|
||||||
radius_client = 'radius_client' in request.form
|
|
||||||
|
|
||||||
if ip is None:
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
if not vlan_name:
|
|
||||||
flash('The configuration has not been saved because a VLAN is required.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
if not mac:
|
|
||||||
flash('The configuration has not been saved because a MAC address is required.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
if not _hash_ok():
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
cfg = load_config()
|
|
||||||
vlans = cfg.get('vlans', [])
|
|
||||||
vlan = next((v for v in vlans if v.get('name') == vlan_name), None)
|
|
||||||
if vlan is None:
|
|
||||||
flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
subnet_err = _check_ip_in_vlan_subnet(ip, vlan)
|
|
||||||
if subnet_err:
|
|
||||||
flash(f'The configuration has not been saved because {subnet_err}', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
conflict = validate.check_reservation_ip_conflicts(ip, vlan)
|
|
||||||
if conflict:
|
|
||||||
flash(f'The configuration has not been saved because {conflict}', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
'description': description,
|
|
||||||
'hostname': hostname,
|
|
||||||
'mac': mac,
|
|
||||||
'ip': ip,
|
|
||||||
'radius_client': radius_client,
|
|
||||||
'enabled': True,
|
|
||||||
'vlan': vlan_name,
|
|
||||||
}
|
|
||||||
cfg.setdefault('dhcp_reservations', []).append(entry)
|
|
||||||
errors = validate.validate_config(cfg)
|
|
||||||
if errors:
|
|
||||||
for msg in errors:
|
|
||||||
flash(msg, 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
changes = diff_fields(None, entry)
|
|
||||||
flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/action/dhcp/reservations_toggle', methods=['POST'])
|
|
||||||
@require_level('administrator')
|
|
||||||
def reservations_toggle():
|
|
||||||
idx = _row_index()
|
|
||||||
if idx is None:
|
|
||||||
flash('Invalid request.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
if not _hash_ok():
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
cfg = load_config()
|
|
||||||
items = cfg.get('dhcp_reservations', [])
|
|
||||||
if idx < 0 or idx >= len(items):
|
|
||||||
flash('Entry not found.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
res = items[idx]
|
|
||||||
old_enabled = res.get('enabled', True)
|
|
||||||
before = copy.deepcopy(res)
|
|
||||||
res['enabled'] = not old_enabled
|
|
||||||
errors = validate.validate_config(cfg)
|
|
||||||
if errors:
|
|
||||||
for msg in errors:
|
|
||||||
flash(msg, 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
changes = diff_fields(before, res)
|
|
||||||
flash(record_group(cfg, 'dhcp_reservations', 'mac', res['mac'], changes, 'core apply'), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/action/dhcp/reservations_edit', methods=['POST'])
|
|
||||||
@require_level('administrator')
|
|
||||||
def reservations_edit():
|
|
||||||
idx = _row_index()
|
|
||||||
if idx is None:
|
|
||||||
flash('Invalid request.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
description = sanitize.text(request.form.get('description', ''))
|
|
||||||
hostname = validate.domainname(request.form.get('hostname', ''))
|
|
||||||
mac = sanitize.mac(request.form.get('mac', ''))
|
|
||||||
ip = _parse_ip()
|
|
||||||
radius_client = 'radius_client' in request.form
|
|
||||||
|
|
||||||
if ip is None:
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
if not mac:
|
|
||||||
flash('The configuration has not been saved because a MAC address is required.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
if not _hash_ok():
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
cfg = load_config()
|
|
||||||
items = cfg.get('dhcp_reservations', [])
|
|
||||||
if idx < 0 or idx >= len(items):
|
|
||||||
flash('Entry not found.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
res = items[idx]
|
|
||||||
vlan_name = res.get('vlan', '')
|
|
||||||
vlan = next((v for v in cfg.get('vlans', []) if v.get('name') == vlan_name), None)
|
|
||||||
if vlan:
|
|
||||||
subnet_err = _check_ip_in_vlan_subnet(ip, vlan)
|
|
||||||
if subnet_err:
|
|
||||||
flash(f'The configuration has not been saved because {subnet_err}', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
conflict = validate.check_reservation_ip_conflicts(ip, vlan)
|
|
||||||
if conflict:
|
|
||||||
flash(f'The configuration has not been saved because {conflict}', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
before = copy.deepcopy(res)
|
|
||||||
res.update({
|
|
||||||
'description': description,
|
|
||||||
'hostname': hostname,
|
|
||||||
'mac': mac,
|
|
||||||
'ip': ip,
|
|
||||||
'radius_client': radius_client,
|
|
||||||
'enabled': 'enabled' in request.form,
|
|
||||||
})
|
|
||||||
errors = validate.validate_config(cfg)
|
|
||||||
if errors:
|
|
||||||
for msg in errors:
|
|
||||||
flash(msg, 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
changes = diff_fields(before, res)
|
|
||||||
flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/action/dhcp/reservations_delete', methods=['POST'])
|
|
||||||
@require_level('administrator')
|
|
||||||
def reservations_delete():
|
|
||||||
idx = _row_index()
|
|
||||||
if idx is None:
|
|
||||||
flash('Invalid request.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
if not _hash_ok():
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
cfg = load_config()
|
|
||||||
items = cfg.get('dhcp_reservations', [])
|
|
||||||
if idx < 0 or idx >= len(items):
|
|
||||||
flash('Entry not found.', 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
removed = items.pop(idx)
|
|
||||||
errors = validate.validate_config(cfg)
|
|
||||||
if errors:
|
|
||||||
for msg in errors:
|
|
||||||
flash(msg, 'error')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
||||||
changes = diff_fields(removed, None)
|
|
||||||
flash(record_group(cfg, 'dhcp_reservations', 'mac', removed['mac'], changes, 'core apply'), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
{
|
|
||||||
"client_requirement": "client_is_viewer+",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "header_page_title",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "h1",
|
|
||||||
"text": "DHCP"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "p",
|
|
||||||
"text": "Active leases, IP reservations, and VLAN authorizations."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "table",
|
|
||||||
"datasource": "live:dhcp_leases",
|
|
||||||
"empty_message": "No active DHCP leases found.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"label": "Hostname",
|
|
||||||
"field": "hostname"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "IP Address",
|
|
||||||
"field": "ip_address",
|
|
||||||
"class": "col-mono"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "MAC Address",
|
|
||||||
"field": "mac_address",
|
|
||||||
"class": "col-mono"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "VLAN",
|
|
||||||
"field": "vlan_name"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Expires",
|
|
||||||
"field": "expires"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "table",
|
|
||||||
"datasource": "config:dhcp_reservations",
|
|
||||||
"empty_message": "No DHCP reservations configured.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"label": "Description",
|
|
||||||
"field": "description"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Hostname",
|
|
||||||
"field": "hostname",
|
|
||||||
"class": "col-mono"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "MAC",
|
|
||||||
"field": "mac",
|
|
||||||
"class": "col-mono"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "IP",
|
|
||||||
"field": "ip",
|
|
||||||
"class": "col-mono"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "VLAN",
|
|
||||||
"field": "vlan_name"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "RADIUS",
|
|
||||||
"field": "radius_client",
|
|
||||||
"render": "badge_yes_no"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Status",
|
|
||||||
"field": "enabled",
|
|
||||||
"render": "badge_enabled_disabled"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"toolbar": {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "select",
|
|
||||||
"name": "vlan_filter",
|
|
||||||
"value": "all",
|
|
||||||
"options": "%VLAN_FILTER_OPTIONS%",
|
|
||||||
"filter_col": "vlan_name"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"row_actions": [
|
|
||||||
{
|
|
||||||
"client_requirement": "client_is_administrator+",
|
|
||||||
"action": "/action/dhcp/reservations_edit",
|
|
||||||
"method": "inline_edit",
|
|
||||||
"text": "Edit",
|
|
||||||
"class": "btn-ghost btn-sm",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"col": "description",
|
|
||||||
"input_type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"col": "hostname",
|
|
||||||
"input_type": "text",
|
|
||||||
"validate": "VALIDATION_NETWORK_NAME"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"col": "mac",
|
|
||||||
"input_type": "text",
|
|
||||||
"validate": "VALIDATION_MAC"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"col": "ip",
|
|
||||||
"input_type": "text",
|
|
||||||
"validate": "VALIDATION_IPV4_FORMAT|VALIDATION_ADDRESS",
|
|
||||||
"attrs": {
|
|
||||||
"data-vlan-subnets": "%VLAN_SUBNET_INFO_JSON%"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"col": "radius_client",
|
|
||||||
"input_type": "checkbox",
|
|
||||||
"checkbox_label": "Enabled"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"col": "enabled",
|
|
||||||
"input_type": "checkbox",
|
|
||||||
"checkbox_label": "Enabled"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client_requirement": "client_is_administrator+",
|
|
||||||
"action": "/action/dhcp/reservations_delete",
|
|
||||||
"method": "post",
|
|
||||||
"text": "Delete",
|
|
||||||
"class": "btn-danger btn-sm"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "card",
|
|
||||||
"id": "add-form",
|
|
||||||
"label": "Add Reservation/Authorization",
|
|
||||||
"client_requirement": "client_is_administrator+",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "form",
|
|
||||||
"action": "/action/dhcp/addreservation_add",
|
|
||||||
"method": "post",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "field",
|
|
||||||
"label": "VLAN",
|
|
||||||
"name": "vlan_name",
|
|
||||||
"input_type": "select",
|
|
||||||
"options": "%VLAN_NAMES_AS_OPTIONS%",
|
|
||||||
"hint": "VLAN this reservation belongs to."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "field",
|
|
||||||
"label": "Description",
|
|
||||||
"name": "description",
|
|
||||||
"input_type": "text",
|
|
||||||
"placeholder": "e.g. NAS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "field",
|
|
||||||
"label": "Hostname",
|
|
||||||
"name": "hostname",
|
|
||||||
"input_type": "text",
|
|
||||||
"validate": "VALIDATION_NETWORK_NAME",
|
|
||||||
"optional": true,
|
|
||||||
"placeholder": "e.g. nas",
|
|
||||||
"attrs": {
|
|
||||||
"data-res-hosts-by-vlan": "%RESERVATION_HOSTNAMES_BY_VLAN_JSON%"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "field",
|
|
||||||
"label": "MAC Address",
|
|
||||||
"name": "mac",
|
|
||||||
"input_type": "text",
|
|
||||||
"validate": "VALIDATION_MAC",
|
|
||||||
"placeholder": "e.g. aa:bb:cc:dd:ee:ff"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "field",
|
|
||||||
"label": "IP Address",
|
|
||||||
"name": "ip",
|
|
||||||
"input_type": "text",
|
|
||||||
"validate": "VALIDATION_IPV4_FORMAT|VALIDATION_ADDRESS",
|
|
||||||
"optional": true,
|
|
||||||
"placeholder": "e.g. 192.168.10.50",
|
|
||||||
"hint": "Leave blank to authorize device on this VLAN dynamically.",
|
|
||||||
"attrs": {
|
|
||||||
"data-res-ips-by-vlan": "%RESERVATION_IPS_BY_VLAN_JSON%",
|
|
||||||
"data-vlan-subnets": "%VLAN_SUBNET_INFO_JSON%",
|
|
||||||
"data-vlan-select": "vlan_name"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "field",
|
|
||||||
"label": "RADIUS Client",
|
|
||||||
"name": "radius_client",
|
|
||||||
"input_type": "checkbox",
|
|
||||||
"hint": "This device acts as a RADIUS authenticator, verifying credentials of other devices on the network."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "button_row",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "button_primary",
|
|
||||||
"action": "/action/dhcp/addreservation_add",
|
|
||||||
"method": "post",
|
|
||||||
"text": "Add"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "button_cancel",
|
|
||||||
"text": "Cancel"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
|
||||||
vlans = cfg.get('vlans', [])
|
|
||||||
vlan_names = [v.get('name', '') for v in vlans]
|
|
||||||
res_ips_by_vlan, res_hosts_by_vlan = {}, {}
|
|
||||||
for v in vlans:
|
|
||||||
vn = v.get('name', '')
|
|
||||||
if not vn:
|
|
||||||
continue
|
|
||||||
vlan_res = [r for r in cfg.get('dhcp_reservations', []) if r.get('vlan') == vn]
|
|
||||||
res_ips_by_vlan[vn] = [r['ip'] for r in vlan_res if r.get('ip') and r['ip'] != 'dynamic']
|
|
||||||
res_hosts_by_vlan[vn] = [r['hostname'] for r in vlan_res if r.get('hostname')]
|
|
||||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
|
||||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'VLAN_FILTER_OPTIONS': filter_opts,
|
|
||||||
'VLAN_NAMES_AS_OPTIONS': json.dumps([{'value': n, 'label': n} for n in vlan_names]),
|
|
||||||
'VLAN_SUBNET_INFO_JSON': json.dumps({
|
|
||||||
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
|
||||||
for v in vlans if v.get('name') and v.get('subnet')
|
|
||||||
}),
|
|
||||||
'RESERVATION_IPS_BY_VLAN_JSON': json.dumps(res_ips_by_vlan),
|
|
||||||
'RESERVATION_HOSTNAMES_BY_VLAN_JSON': json.dumps(res_hosts_by_vlan),
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,133 @@
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from config_utils import collect_layout_tokens, load_config, relative_time
|
||||||
|
from factory import (
|
||||||
|
load_json, build_table, table_token_key, iter_table_items, PAGES_DIR, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import manuf as _manuf_mod
|
||||||
|
_mac_parser = _manuf_mod.MacParser()
|
||||||
|
except Exception:
|
||||||
|
_mac_parser = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from mac_vendor_lookup import MacLookup as _MacLookup
|
||||||
|
_mac_lookup = _MacLookup()
|
||||||
|
except Exception:
|
||||||
|
_mac_lookup = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vendor(mac):
|
||||||
|
short, long = '', ''
|
||||||
|
if _mac_parser:
|
||||||
|
try:
|
||||||
|
short = _mac_parser.get_manuf(mac) or ''
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _mac_lookup:
|
||||||
|
try:
|
||||||
|
long = _mac_lookup.lookup(mac) or ''
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return (short, long)
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_cell(vendor):
|
||||||
|
short, long = vendor
|
||||||
|
display = short if short else (long[:8] if long else '')
|
||||||
|
if not display:
|
||||||
|
return '-'
|
||||||
|
if long:
|
||||||
|
return f'<span data-vendor-long="{e(long)}">{e(display)}</span>'
|
||||||
|
return e(display)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lease_secs(s):
|
||||||
|
s = str(s).strip().lower()
|
||||||
|
try:
|
||||||
|
if s.endswith('h'): return int(s[:-1]) * 3600
|
||||||
|
if s.endswith('m'): return int(s[:-1]) * 60
|
||||||
|
if s.endswith('d'): return int(s[:-1]) * 86400
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def live_dhcp_leases():
|
||||||
|
rows = []
|
||||||
|
now = int(datetime.now(tz=timezone.utc).timestamp())
|
||||||
|
cfg = load_config()
|
||||||
|
vlans = cfg.get('vlans', [])
|
||||||
|
vlan_lease_secs = {
|
||||||
|
v['name']: _parse_lease_secs(v.get('dhcp_information', {}).get('lease_time', ''))
|
||||||
|
for v in vlans if v.get('name')
|
||||||
|
}
|
||||||
|
mac_to_res = {
|
||||||
|
r['mac'].lower(): r['hostname']
|
||||||
|
for r in cfg.get('dhcp_reservations', [])
|
||||||
|
if r.get('mac') and r.get('hostname')
|
||||||
|
}
|
||||||
|
for leases_file in glob.glob('/var/lib/misc/dnsmasq-routlin-*.leases'):
|
||||||
|
stem = os.path.basename(leases_file)
|
||||||
|
vlan_name = stem[len('dnsmasq-routlin-'):-len('.leases')]
|
||||||
|
lease_secs = vlan_lease_secs.get(vlan_name)
|
||||||
|
try:
|
||||||
|
with open(leases_file) as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
expiry = int(parts[0])
|
||||||
|
if expiry < now:
|
||||||
|
continue
|
||||||
|
obtained_ts = (expiry - lease_secs) if lease_secs else None
|
||||||
|
renews_ts = (expiry - lease_secs // 2) if lease_secs else None
|
||||||
|
if obtained_ts is None:
|
||||||
|
last_active = '-'
|
||||||
|
elif obtained_ts <= now:
|
||||||
|
last_active = relative_time(obtained_ts, now, short=True) + ' ago'
|
||||||
|
elif renews_ts and renews_ts > now:
|
||||||
|
last_active = 'ETA ' + relative_time(renews_ts, now, short=True)
|
||||||
|
else:
|
||||||
|
last_active = 'ETA soon'
|
||||||
|
mac_norm = parts[1].lower()
|
||||||
|
device_h = parts[3] if parts[3] != '*' else None
|
||||||
|
res_h = mac_to_res.get(mac_norm)
|
||||||
|
if res_h and device_h and device_h.lower() != res_h.lower():
|
||||||
|
hostname_html = f'<strong>{e(res_h)}</strong><br/>({e(device_h)})'
|
||||||
|
elif res_h:
|
||||||
|
hostname_html = f'<strong>{e(res_h)}</strong>'
|
||||||
|
elif device_h:
|
||||||
|
hostname_html = e(device_h)
|
||||||
|
else:
|
||||||
|
hostname_html = '-'
|
||||||
|
rows.append({
|
||||||
|
'hostname': hostname_html,
|
||||||
|
'ip_address': parts[2],
|
||||||
|
'mac_address': parts[1],
|
||||||
|
'vendor': _vendor_cell(_get_vendor(parts[1])),
|
||||||
|
'vlan_name': vlan_name,
|
||||||
|
'last_active': last_active,
|
||||||
|
'renews': 'in ' + relative_time(renews_ts or expiry, now, short=True),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
vlans = cfg.get('vlans', [])
|
vlans = cfg.get('vlans', [])
|
||||||
vlan_names = [v.get('name', '') for v in vlans]
|
vlan_names = [v.get('name', '') for v in vlans]
|
||||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
||||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
f'<option value="{n}">{n}</option>' for n in vlan_names
|
||||||
)
|
)
|
||||||
return {'VLAN_FILTER_OPTIONS': filter_opts}
|
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
|
||||||
|
content = load_json(f'{PAGES_DIR}/dhcpleases/content.json')
|
||||||
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
rows = live_dhcp_leases() if ds == 'live:dhcp_leases' else []
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, rows)
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
vlans = cfg.get('vlans', [])
|
vlans = cfg.get('vlans', [])
|
||||||
vlan_names = [v.get('name', '') for v in vlans]
|
vlan_names = [v.get('name', '') for v in vlans]
|
||||||
res_ips_by_vlan, res_hosts_by_vlan = {}, {}
|
res_ips_by_vlan, res_hosts_by_vlan = {}, {}
|
||||||
|
|
@ -15,13 +18,16 @@ def collect_tokens(cfg):
|
||||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
||||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
f'<option value="{n}">{n}</option>' for n in vlan_names
|
||||||
)
|
)
|
||||||
return {
|
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
|
||||||
'VLAN_FILTER_OPTIONS': filter_opts,
|
tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
|
||||||
'VLAN_NAMES_AS_OPTIONS': json.dumps([{'value': n, 'label': n} for n in vlan_names]),
|
tokens['VLAN_SUBNET_INFO_JSON'] = json.dumps({
|
||||||
'VLAN_SUBNET_INFO_JSON': json.dumps({
|
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
||||||
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
for v in vlans if v.get('name') and v.get('subnet')
|
||||||
for v in vlans if v.get('name') and v.get('subnet')
|
})
|
||||||
}),
|
tokens['RESERVATION_IPS_BY_VLAN_JSON'] = json.dumps(res_ips_by_vlan)
|
||||||
'RESERVATION_IPS_BY_VLAN_JSON': json.dumps(res_ips_by_vlan),
|
tokens['RESERVATION_HOSTNAMES_BY_VLAN_JSON'] = json.dumps(res_hosts_by_vlan)
|
||||||
'RESERVATION_HOSTNAMES_BY_VLAN_JSON': json.dumps(res_hosts_by_vlan),
|
content = load_json(f'{PAGES_DIR}/dhcpreservations/content.json')
|
||||||
}
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from view_common import fmt_bytes, relative_time, BLOCKLISTS_DIR
|
from config_utils import collect_layout_tokens, load_datasource, fmt_bytes, relative_time, BLOCKLISTS_DIR
|
||||||
from factory import e
|
from factory import e, load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
def _blocklist_stats_html(cfg):
|
def blocklist_stats_html(cfg):
|
||||||
rows = ''
|
rows = ''
|
||||||
for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
|
for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
|
||||||
name = e(bl.get('name', ''))
|
name = e(bl.get('name', ''))
|
||||||
|
|
@ -44,14 +44,18 @@ def _blocklist_stats_html(cfg):
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
dns_blk_gen = cfg.get('dns_blocking', {}).get('general', {})
|
dns_blk_gen = cfg.get('dns_blocking', {}).get('general', {})
|
||||||
return {
|
tokens['GENERAL_LOG_MAX_KB'] = str(dns_blk_gen.get('log_max_kb', '-'))
|
||||||
'GENERAL_LOG_MAX_KB': str(dns_blk_gen.get('log_max_kb', '-')),
|
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if dns_blk_gen.get('log_errors_only') else 'false'
|
||||||
'GENERAL_LOG_ERRORS_ONLY': 'true' if dns_blk_gen.get('log_errors_only') else 'false',
|
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-'))
|
||||||
'GENERAL_DAILY_EXECUTE_TIME': str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')),
|
tokens['BLOCKLIST_STATS_HTML'] = blocklist_stats_html(cfg)
|
||||||
'BLOCKLIST_STATS_HTML': _blocklist_stats_html(cfg),
|
tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([
|
||||||
'BLOCKLIST_FORMAT_OPTIONS': json.dumps([
|
{'value': 'hosts', 'label': 'hosts (hosts file format)'},
|
||||||
{'value': 'hosts', 'label': 'hosts (hosts file format)'},
|
{'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'},
|
||||||
{'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'},
|
])
|
||||||
]),
|
content = load_json(f'{PAGES_DIR}/dnsblocking/content.json')
|
||||||
}
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
dns = cfg.get('upstream_dns', {})
|
dns = cfg.get('upstream_dns', {})
|
||||||
servers = dns.get('upstream_servers', [])
|
servers = dns.get('upstream_servers', [])
|
||||||
return {
|
tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'
|
||||||
'DNS_STRICT_ORDER': 'true' if dns.get('strict_order') else 'false',
|
tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-'))
|
||||||
'DNS_CACHE_SIZE': str(dns.get('cache_size', '-')),
|
tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers)
|
||||||
'DNS_UPSTREAM_SERVERS_JSON': json.dumps(servers),
|
return tokens
|
||||||
}
|
|
||||||
|
|
|
||||||
11
docker/routlin-dash/app/pages/hostoverrides/view.py
Normal file
11
docker/routlin-dash/app/pages/hostoverrides/view.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
|
content = load_json(f'{PAGES_DIR}/hostoverrides/content.json')
|
||||||
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
return {
|
tokens = collect_layout_tokens(cfg)
|
||||||
'PROTOCOL_OPTIONS': json.dumps([
|
tokens['PROTOCOL_OPTIONS'] = json.dumps([
|
||||||
{'value': 'tcp', 'label': 'TCP'},
|
{'value': 'tcp', 'label': 'TCP'},
|
||||||
{'value': 'udp', 'label': 'UDP'},
|
{'value': 'udp', 'label': 'UDP'},
|
||||||
{'value': 'both', 'label': 'TCP/UDP'},
|
{'value': 'both', 'label': 'TCP/UDP'},
|
||||||
]),
|
])
|
||||||
}
|
content = load_json(f'{PAGES_DIR}/intervlan/content.json')
|
||||||
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
5
docker/routlin-dash/app/pages/mdns/view.py
Normal file
5
docker/routlin-dash/app/pages/mdns/view.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from config_utils import collect_layout_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tokens(cfg):
|
||||||
|
return collect_layout_tokens(cfg)
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
vlans = cfg.get('vlans', [])
|
vlans = cfg.get('vlans', [])
|
||||||
dv = next((v for v in vlans if v.get('radius_default')), None)
|
dv = next((v for v in vlans if v.get('radius_default')), None)
|
||||||
return {
|
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans])
|
||||||
'EXISTING_VLAN_IDS_JSON': json.dumps([v.get('vlan_id') for v in vlans]),
|
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name') for v in vlans])
|
||||||
'EXISTING_VLAN_NAMES_JSON': json.dumps([v.get('name') for v in vlans]),
|
tokens['RADIUS_DEFAULT_VLAN'] = f'"{dv["name"]}" (VLAN {dv["vlan_id"]})' if dv else 'none set'
|
||||||
'RADIUS_DEFAULT_VLAN': f'"{dv["name"]}" (VLAN {dv["vlan_id"]})' if dv else 'none set',
|
tokens['BLOCKLIST_NAME_OPTIONS'] = json.dumps([
|
||||||
'BLOCKLIST_NAME_OPTIONS': json.dumps([
|
{'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))}
|
||||||
{'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))}
|
for bl in cfg.get('dns_blocking', {}).get('blocklists', [])
|
||||||
for bl in cfg.get('dns_blocking', {}).get('blocklists', [])
|
])
|
||||||
]),
|
content = load_json(f'{PAGES_DIR}/networklayout/content.json')
|
||||||
}
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
from view_common import run, load_ddns, public_ip_info, live_dhcp_leases, fmt_timestamp, BLOCKLISTS_DIR
|
from config_utils import collect_layout_tokens, fmt_timestamp, BLOCKLISTS_DIR
|
||||||
|
from factory import run, load_ddns
|
||||||
|
from pages.ddns.view import public_ip_info
|
||||||
|
from pages.dhcpleases.view import live_dhcp_leases
|
||||||
|
|
||||||
|
|
||||||
def get_dnsmasq_stats():
|
def get_dnsmasq_stats():
|
||||||
|
|
@ -32,12 +35,12 @@ def get_dnsmasq_stats():
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def _count_blocked_today():
|
def count_blocked_today():
|
||||||
out = run("journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c 'is NXDOMAIN'")
|
out = run("journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c 'is NXDOMAIN'")
|
||||||
return out or '0'
|
return out or '0'
|
||||||
|
|
||||||
|
|
||||||
def _count_blocked_domains():
|
def count_blocked_domains():
|
||||||
try:
|
try:
|
||||||
total = sum(
|
total = sum(
|
||||||
int(run(f'wc -l < "{BLOCKLISTS_DIR}/{f}"') or 0)
|
int(run(f'wc -l < "{BLOCKLISTS_DIR}/{f}"') or 0)
|
||||||
|
|
@ -48,7 +51,7 @@ def _count_blocked_domains():
|
||||||
return '-'
|
return '-'
|
||||||
|
|
||||||
|
|
||||||
def _bl_last_update():
|
def bl_last_update():
|
||||||
try:
|
try:
|
||||||
mtime = max(
|
mtime = max(
|
||||||
os.path.getmtime(f'{BLOCKLISTS_DIR}/{f}')
|
os.path.getmtime(f'{BLOCKLISTS_DIR}/{f}')
|
||||||
|
|
@ -60,6 +63,7 @@ def _bl_last_update():
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
vlans = cfg.get('vlans', [])
|
vlans = cfg.get('vlans', [])
|
||||||
non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')]
|
non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')]
|
||||||
vlan_names = [v.get('name', '') for v in vlans]
|
vlan_names = [v.get('name', '') for v in vlans]
|
||||||
|
|
@ -69,26 +73,25 @@ def collect_tokens(cfg):
|
||||||
ddns = load_ddns()
|
ddns = load_ddns()
|
||||||
ip_str, domains_sub, last_obtained = public_ip_info(ddns)
|
ip_str, domains_sub, last_obtained = public_ip_info(ddns)
|
||||||
|
|
||||||
return {
|
tokens['GENERAL_WAN_INTERFACE'] = str(net.get('wan_interface', '-'))
|
||||||
'GENERAL_WAN_INTERFACE': str(net.get('wan_interface', '-')),
|
tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-'
|
||||||
'OVERVIEW_VLAN_NAMES': ', '.join(vlan_names) or '-',
|
tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
|
||||||
'STAT_VLAN_COUNT': str(len(non_vpn_vlans)),
|
tokens['STAT_LEASE_COUNT'] = str(len(live_dhcp_leases()))
|
||||||
'STAT_LEASE_COUNT': str(len(live_dhcp_leases())),
|
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True)))
|
||||||
'STAT_BANNED_IP_COUNT': str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True))),
|
tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', [])))
|
||||||
'STAT_BLOCKLIST_COUNT': str(len(cfg.get('dns_blocking', {}).get('blocklists', []))),
|
tokens['STAT_BLOCKED_TODAY'] = count_blocked_today()
|
||||||
'STAT_BLOCKED_TODAY': _count_blocked_today(),
|
tokens['STAT_BLOCKED_DOMAINS'] = count_blocked_domains()
|
||||||
'STAT_BLOCKED_DOMAINS': _count_blocked_domains(),
|
tokens['STAT_BL_LAST_UPDATE'] = bl_last_update()
|
||||||
'STAT_BL_LAST_UPDATE': _bl_last_update(),
|
tokens['STAT_UPTIME'] = run('uptime -p') or '-'
|
||||||
'STAT_UPTIME': run('uptime -p') or '-',
|
tokens['STAT_NFTABLES_STATUS'] = 'Active' if run('nft list tables 2>/dev/null').strip() else 'Inactive'
|
||||||
'STAT_NFTABLES_STATUS': 'Active' if run('nft list tables 2>/dev/null').strip() else 'Inactive',
|
tokens['STAT_PUBLIC_IP'] = ip_str
|
||||||
'STAT_PUBLIC_IP': ip_str,
|
tokens['STAT_DDNS_HOSTNAME'] = domains_sub
|
||||||
'STAT_DDNS_HOSTNAME': domains_sub,
|
tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-'))
|
||||||
'DNS_CACHE_SIZE': str(dns.get('cache_size', '-')),
|
tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(dns.get('upstream_servers', [])) or '-'
|
||||||
'OVERVIEW_UPSTREAM_SERVERS': ', '.join(dns.get('upstream_servers', [])) or '-',
|
tokens['DNS_STAT_QUERIES'] = dns_stats['queries']
|
||||||
'DNS_STAT_QUERIES': dns_stats['queries'],
|
tokens['DNS_STAT_HITS'] = dns_stats['hits']
|
||||||
'DNS_STAT_HITS': dns_stats['hits'],
|
tokens['DNS_STAT_HIT_RATE'] = dns_stats['hit_rate']
|
||||||
'DNS_STAT_HIT_RATE': dns_stats['hit_rate'],
|
tokens['DNS_STAT_FORWARDED'] = dns_stats['forwarded']
|
||||||
'DNS_STAT_FORWARDED': dns_stats['forwarded'],
|
tokens['DNS_STAT_AUTH'] = dns_stats['auth']
|
||||||
'DNS_STAT_AUTH': dns_stats['auth'],
|
tokens['DNS_STAT_TCP_PEAK'] = dns_stats['tcp_peak']
|
||||||
'DNS_STAT_TCP_PEAK': dns_stats['tcp_peak'],
|
return tokens
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,74 @@
|
||||||
import json
|
import json
|
||||||
from view_common import get_system_interfaces, iface_info
|
import os
|
||||||
|
from config_utils import collect_layout_tokens
|
||||||
|
|
||||||
|
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', 'tun', 'tap', 'ppp', 'virbr', 'podman', 'vnet', 'macvtap', 'fc-')
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_interfaces():
|
||||||
|
try:
|
||||||
|
return sorted(
|
||||||
|
n for n in os.listdir('/sys/class/net')
|
||||||
|
if not n.startswith(_EXCLUDE_PREFIXES)
|
||||||
|
and os.path.exists(f'/sys/class/net/{n}/device')
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def iface_info(iface):
|
||||||
|
base = f'/sys/class/net/{iface}'
|
||||||
|
|
||||||
|
def rd(path):
|
||||||
|
try:
|
||||||
|
with open(f'{base}/{path}') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def int_val(val):
|
||||||
|
try:
|
||||||
|
return int(val) if val else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
wireless = os.path.isdir(f'{base}/wireless')
|
||||||
|
state = (rd('operstate') or 'unknown').upper()
|
||||||
|
if state == 'UNKNOWN':
|
||||||
|
state = 'UP'
|
||||||
|
carrier_raw = rd('carrier')
|
||||||
|
carrier = (carrier_raw == '1') if carrier_raw is not None else None
|
||||||
|
speed_raw = rd('speed')
|
||||||
|
try:
|
||||||
|
mbps = int(speed_raw)
|
||||||
|
if mbps <= 0:
|
||||||
|
speed = None
|
||||||
|
elif mbps >= 1000 and mbps % 1000 == 0:
|
||||||
|
speed = f'{mbps // 1000} Gbps'
|
||||||
|
else:
|
||||||
|
speed = f'{mbps} Mbps'
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
speed = None
|
||||||
|
mac = rd('address')
|
||||||
|
perm_mac = rd('perm_address')
|
||||||
|
if perm_mac and perm_mac == '00:00:00:00:00:00':
|
||||||
|
perm_mac = None
|
||||||
|
return {
|
||||||
|
'name': iface,
|
||||||
|
'wireless': wireless,
|
||||||
|
'state': state,
|
||||||
|
'carrier': carrier,
|
||||||
|
'speed': speed,
|
||||||
|
'mtu': rd('mtu'),
|
||||||
|
'min_mtu': int_val(rd('min_mtu')),
|
||||||
|
'max_mtu': int_val(rd('max_mtu')),
|
||||||
|
'mac': mac,
|
||||||
|
'perm_mac': perm_mac,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
net = cfg.get('network_interfaces', {})
|
net = cfg.get('network_interfaces', {})
|
||||||
wan = net.get('wan_interface', '')
|
wan = net.get('wan_interface', '')
|
||||||
lan = net.get('lan_interface', '')
|
lan = net.get('lan_interface', '')
|
||||||
|
|
@ -12,8 +78,6 @@ def collect_tokens(cfg):
|
||||||
sys_ifaces.append(configured)
|
sys_ifaces.append(configured)
|
||||||
sys_ifaces.sort()
|
sys_ifaces.sort()
|
||||||
iface_data = [iface_info(i) for i in sys_ifaces]
|
iface_data = [iface_info(i) for i in sys_ifaces]
|
||||||
return {
|
tokens['GENERAL_WAN_INTERFACE'] = str(wan or '-')
|
||||||
'GENERAL_WAN_INTERFACE': str(wan or '-'),
|
tokens['NETWORK_INTERFACE_DATA_JSON'] = json.dumps(iface_data)
|
||||||
'GENERAL_LAN_INTERFACE': str(lan or '-'),
|
return tokens
|
||||||
'NETWORK_INTERFACE_DATA_JSON': json.dumps(iface_data),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
return {
|
tokens = collect_layout_tokens(cfg)
|
||||||
'PROTOCOL_OPTIONS': json.dumps([
|
tokens['PROTOCOL_OPTIONS'] = json.dumps([
|
||||||
{'value': 'tcp', 'label': 'TCP'},
|
{'value': 'tcp', 'label': 'TCP'},
|
||||||
{'value': 'udp', 'label': 'UDP'},
|
{'value': 'udp', 'label': 'UDP'},
|
||||||
{'value': 'both', 'label': 'TCP/UDP'},
|
{'value': 'both', 'label': 'TCP/UDP'},
|
||||||
]),
|
])
|
||||||
}
|
content = load_json(f'{PAGES_DIR}/portforwarding/content.json')
|
||||||
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource
|
||||||
|
from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
vlans = cfg.get('vlans', [])
|
vlans = cfg.get('vlans', [])
|
||||||
vlan_names = [v.get('name', '') for v in vlans]
|
vlan_names = [v.get('name', '') for v in vlans]
|
||||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
||||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
f'<option value="{n}">{n}</option>' for n in vlan_names
|
||||||
)
|
)
|
||||||
return {
|
tokens['PROTOCOL_OPTIONS'] = json.dumps([
|
||||||
'PROTOCOL_OPTIONS': json.dumps([
|
{'value': 'tcp', 'label': 'TCP'},
|
||||||
{'value': 'tcp', 'label': 'TCP'},
|
{'value': 'udp', 'label': 'UDP'},
|
||||||
{'value': 'udp', 'label': 'UDP'},
|
{'value': 'both', 'label': 'TCP/UDP'},
|
||||||
{'value': 'both', 'label': 'TCP/UDP'},
|
])
|
||||||
]),
|
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
|
||||||
'VLAN_FILTER_OPTIONS': filter_opts,
|
tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
|
||||||
'VLAN_NAMES_AS_OPTIONS': json.dumps([{'value': n, 'label': n} for n in vlan_names]),
|
tokens['VLAN_SUBNET_INFO_JSON'] = json.dumps({
|
||||||
'VLAN_SUBNET_INFO_JSON': json.dumps({
|
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
||||||
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
for v in vlans if v.get('name') and v.get('subnet')
|
||||||
for v in vlans if v.get('name') and v.get('subnet')
|
})
|
||||||
}),
|
content = load_json(f'{PAGES_DIR}/portwrangling/content.json')
|
||||||
}
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, load_datasource(ds))
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import json
|
import json
|
||||||
from flask import session
|
from flask import session
|
||||||
import sanitize
|
import sanitize
|
||||||
|
from config_utils import collect_layout_tokens
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
blank = [{'value': '', 'label': '-- Select timezone --'}]
|
blank = [{'value': '', 'label': '-- Select timezone --'}]
|
||||||
return {
|
tokens['PREF_EMAIL'] = session.get('email_address', '')
|
||||||
'PREF_EMAIL': session.get('email_address', ''),
|
tokens['PREF_TIMEZONE'] = session.get('timezone', '')
|
||||||
'PREF_TIMEZONE': session.get('timezone', ''),
|
tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES])
|
||||||
'TIMEZONE_OPTIONS': json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]),
|
return tokens
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import os
|
import os
|
||||||
from view_common import CONFIGS_DIR
|
from config_utils import collect_layout_tokens, CONFIGS_DIR
|
||||||
|
|
||||||
RADIUS_LOG_MAX = 50
|
RADIUS_LOG_MAX = 50
|
||||||
RADIUS_LOG_FILE = '/var/log/freeradius/radius.log'
|
RADIUS_LOG_FILE = '/var/log/freeradius/radius.log'
|
||||||
|
|
||||||
|
|
||||||
def _radius_log_tail(cfg):
|
def radius_log_tail(cfg):
|
||||||
try:
|
try:
|
||||||
log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024)
|
log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024)
|
||||||
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
|
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
|
||||||
|
|
@ -32,7 +32,7 @@ def _radius_log_tail(cfg):
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
tokens = {}
|
tokens = collect_layout_tokens(cfg)
|
||||||
try:
|
try:
|
||||||
tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip()
|
tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|
@ -44,5 +44,5 @@ def collect_tokens(cfg):
|
||||||
tokens['RADIUS_APPLY_TO'] = fr_opts.get('apply_to', 'all')
|
tokens['RADIUS_APPLY_TO'] = fr_opts.get('apply_to', 'all')
|
||||||
tokens['RADIUS_LOGGING'] = 'true' if fr_gen.get('logging', False) else ''
|
tokens['RADIUS_LOGGING'] = 'true' if fr_gen.get('logging', False) else ''
|
||||||
tokens['RADIUS_GEN_LOG_MAX_KB'] = str(fr_gen.get('log_max_kb', 1024))
|
tokens['RADIUS_GEN_LOG_MAX_KB'] = str(fr_gen.get('log_max_kb', 1024))
|
||||||
tokens['RADIUS_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = _radius_log_tail(cfg)
|
tokens['RADIUS_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = radius_log_tail(cfg)
|
||||||
return tokens
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,29 @@
|
||||||
import json
|
import json
|
||||||
|
from config_utils import collect_layout_tokens, load_datasource, fmt_timestamp, fmt_bytes
|
||||||
|
from factory import run, load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def live_vpn_sessions():
|
||||||
|
rows = []
|
||||||
|
out = run('wg show all dump 2>/dev/null')
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split('\t')
|
||||||
|
if len(parts) == 9:
|
||||||
|
interface, pubkey, _psk, endpoint, allowed_ips, last_hs, rx, tx, _ka = parts
|
||||||
|
rows.append({
|
||||||
|
'peer_name': pubkey[:16] + '...',
|
||||||
|
'interface': interface,
|
||||||
|
'tunnel_ip': allowed_ips.split(',')[0].split('/')[0] if allowed_ips else '-',
|
||||||
|
'endpoint': endpoint if endpoint != '(none)' else '-',
|
||||||
|
'last_handshake': fmt_timestamp(int(last_hs)) if last_hs.isdigit() and last_hs != '0' else 'Never',
|
||||||
|
'rx_bytes': fmt_bytes(int(rx)) if rx.isdigit() else '-',
|
||||||
|
'tx_bytes': fmt_bytes(int(tx)) if tx.isdigit() else '-',
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def collect_tokens(cfg):
|
def collect_tokens(cfg):
|
||||||
|
tokens = collect_layout_tokens(cfg)
|
||||||
vlans = cfg.get('vlans', [])
|
vlans = cfg.get('vlans', [])
|
||||||
wg_vlans_list = sorted(
|
wg_vlans_list = sorted(
|
||||||
[v for v in vlans if v.get('is_vpn')],
|
[v for v in vlans if v.get('is_vpn')],
|
||||||
|
|
@ -23,15 +45,19 @@ def collect_tokens(cfg):
|
||||||
except Exception:
|
except Exception:
|
||||||
vpn_gateway = ''
|
vpn_gateway = ''
|
||||||
|
|
||||||
return {
|
tokens['VPN_VLAN_OPTIONS'] = json.dumps([
|
||||||
'VPN_VLAN_OPTIONS': json.dumps([
|
{'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id") or "?"})'}
|
||||||
{'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id") or "?"})'}
|
for i, v in enumerate(wg_vlans_list)
|
||||||
for i, v in enumerate(wg_vlans_list)
|
])
|
||||||
]),
|
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
|
||||||
'VPN_LISTEN_PORT': str(vpn.get('listen_port', '')),
|
tokens['VPN_SERVER_ENDPOINT'] = str(vpn.get('server_endpoint', ''))
|
||||||
'VPN_SERVER_ENDPOINT': str(vpn.get('server_endpoint', '')),
|
tokens['VPN_DOMAIN'] = str(vpn.get('domain', ''))
|
||||||
'VPN_DOMAIN': str(vpn.get('domain', '')),
|
tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_servers', ''))
|
||||||
'VPN_DNS_SERVER': str(overrides.get('dns_servers', '')),
|
tokens['VPN_MTU'] = str(overrides.get('mtu', ''))
|
||||||
'VPN_MTU': str(overrides.get('mtu', '')),
|
tokens['VPN_GATEWAY'] = vpn_gateway
|
||||||
'VPN_GATEWAY': vpn_gateway,
|
content = load_json(f'{PAGES_DIR}/vpn/content.json')
|
||||||
}
|
for table_item in iter_table_items(content.get('items', [])):
|
||||||
|
ds = table_item.get('datasource', '')
|
||||||
|
rows = live_vpn_sessions() if ds == 'live:vpn_sessions' else load_datasource(ds)
|
||||||
|
tokens[table_token_key(ds)] = build_table(table_item, tokens, rows)
|
||||||
|
return tokens
|
||||||
|
|
|
||||||
|
|
@ -1,841 +0,0 @@
|
||||||
from flask import Blueprint, session, redirect, get_flashed_messages
|
|
||||||
from markupsafe import Markup
|
|
||||||
import json, re, subprocess, os, sys, glob, importlib.util as _importlib_util
|
|
||||||
import validation as validate
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from config_utils import (
|
|
||||||
config_hash, get_pending_entries, get_dashboard_pending, load_all_groups,
|
|
||||||
get_done_timestamps, queue_command, _find_cmd_in_queues, _entry_ts_from_queue,
|
|
||||||
_apply_changes_immediately, _seconds_until_next_run, _format_timing,
|
|
||||||
_is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME,
|
|
||||||
CONFIGS_DIR, DATA_DIR, WWW_DIR, ACCOUNTS_FILE, APP_DIR,
|
|
||||||
)
|
|
||||||
import factory
|
|
||||||
from factory import LEVEL_RANK, e, client_level, passes, build_items
|
|
||||||
|
|
||||||
PAGES_DIR = os.path.join(APP_DIR, 'pages')
|
|
||||||
NAVBAR_FILE = os.path.join(APP_DIR, 'navbar.json')
|
|
||||||
CSS_FILE = os.path.join(DATA_DIR, 'styles.css')
|
|
||||||
COMMON_JS_FILE = os.path.join(DATA_DIR, 'common.js')
|
|
||||||
BLOCKLISTS_DIR = os.path.join(CONFIGS_DIR, 'blocklists')
|
|
||||||
HEALTH_FILE = os.path.join(CONFIGS_DIR, '.health')
|
|
||||||
|
|
||||||
bp = Blueprint('view_page', __name__)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import manuf as _manuf_mod
|
|
||||||
_mac_parser = _manuf_mod.MacParser()
|
|
||||||
except Exception:
|
|
||||||
_mac_parser = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from mac_vendor_lookup import MacLookup as _MacLookup
|
|
||||||
_mac_lookup = _MacLookup()
|
|
||||||
except Exception:
|
|
||||||
_mac_lookup = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_vendor(mac):
|
|
||||||
short, long = '', ''
|
|
||||||
if _mac_parser:
|
|
||||||
try:
|
|
||||||
short = _mac_parser.get_manuf(mac) or ''
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if _mac_lookup:
|
|
||||||
try:
|
|
||||||
long = _mac_lookup.lookup(mac) or ''
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return (short, long)
|
|
||||||
|
|
||||||
|
|
||||||
def _vendor_cell(vendor):
|
|
||||||
short, long = vendor
|
|
||||||
display = short if short else (long[:8] if long else '')
|
|
||||||
if not display:
|
|
||||||
return '-'
|
|
||||||
if long:
|
|
||||||
return f'<span data-vendor-long="{e(long)}">{e(display)}</span>'
|
|
||||||
return e(display)
|
|
||||||
|
|
||||||
|
|
||||||
# File loaders ======================================================
|
|
||||||
|
|
||||||
def load_json(path):
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception as ex:
|
|
||||||
print(f'[view_common] ERROR loading {path}: {ex}', file=sys.stderr)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def load_config(): return load_json(f'{CONFIGS_DIR}/config.json')
|
|
||||||
def load_ddns(): return load_config().get('ddns', {})
|
|
||||||
def load_accounts(): return load_json(ACCOUNTS_FILE)
|
|
||||||
|
|
||||||
def _load_css():
|
|
||||||
try:
|
|
||||||
with open(CSS_FILE) as f:
|
|
||||||
return f.read()
|
|
||||||
except Exception:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def _load_icon(name):
|
|
||||||
try:
|
|
||||||
with open(f'{WWW_DIR}/icons/{name}.svg') as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except Exception:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
# Shell helpers =====================================================
|
|
||||||
|
|
||||||
def run(cmd):
|
|
||||||
try:
|
|
||||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
|
|
||||||
return r.stdout.strip()
|
|
||||||
except Exception:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def get_system_interfaces():
|
|
||||||
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
|
|
||||||
'tun', 'tap', 'ppp', 'virbr',
|
|
||||||
'podman', 'vnet', 'macvtap', 'fc-')
|
|
||||||
try:
|
|
||||||
return sorted(
|
|
||||||
n for n in os.listdir('/sys/class/net')
|
|
||||||
if not n.startswith(_EXCLUDE_PREFIXES)
|
|
||||||
and os.path.exists(f'/sys/class/net/{n}/device')
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def iface_info(iface):
|
|
||||||
base = f'/sys/class/net/{iface}'
|
|
||||||
def _rd(path):
|
|
||||||
try:
|
|
||||||
with open(f'{base}/{path}') as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
wireless = os.path.isdir(f'{base}/wireless')
|
|
||||||
state = (_rd('operstate') or 'unknown').upper()
|
|
||||||
if state == 'UNKNOWN':
|
|
||||||
state = 'UP'
|
|
||||||
carrier_raw = _rd('carrier')
|
|
||||||
carrier = (carrier_raw == '1') if carrier_raw is not None else None
|
|
||||||
speed_raw = _rd('speed')
|
|
||||||
try:
|
|
||||||
mbps = int(speed_raw)
|
|
||||||
if mbps <= 0:
|
|
||||||
speed = None
|
|
||||||
elif mbps >= 1000 and mbps % 1000 == 0:
|
|
||||||
speed = f'{mbps // 1000} Gbps'
|
|
||||||
else:
|
|
||||||
speed = f'{mbps} Mbps'
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
speed = None
|
|
||||||
mac = _rd('address')
|
|
||||||
perm_mac = _rd('perm_address')
|
|
||||||
if perm_mac and perm_mac == '00:00:00:00:00:00':
|
|
||||||
perm_mac = None
|
|
||||||
def _int(val):
|
|
||||||
try: return int(val) if val else None
|
|
||||||
except ValueError: return None
|
|
||||||
return {
|
|
||||||
'name': iface,
|
|
||||||
'wireless': wireless,
|
|
||||||
'state': state,
|
|
||||||
'carrier': carrier,
|
|
||||||
'speed': speed,
|
|
||||||
'mtu': _rd('mtu'),
|
|
||||||
'min_mtu': _int(_rd('min_mtu')),
|
|
||||||
'max_mtu': _int(_rd('max_mtu')),
|
|
||||||
'mac': mac,
|
|
||||||
'perm_mac': perm_mac,
|
|
||||||
}
|
|
||||||
|
|
||||||
def iface_status(iface):
|
|
||||||
"""Return link state for iface by reading /sys/class/net/<iface>/operstate.
|
|
||||||
Returns INVALID if the interface does not exist, otherwise UP/DOWN/UNKNOWN/etc."""
|
|
||||||
if not iface:
|
|
||||||
return 'INVALID'
|
|
||||||
safe = re.sub(r'[^A-Za-z0-9._-]', '', iface)
|
|
||||||
if not safe:
|
|
||||||
return 'INVALID'
|
|
||||||
try:
|
|
||||||
with open(f'/sys/class/net/{safe}/operstate') as f:
|
|
||||||
state = f.read().strip().upper()
|
|
||||||
return state if state else 'UP'
|
|
||||||
except OSError:
|
|
||||||
return 'INVALID'
|
|
||||||
|
|
||||||
def resolve_iface(vlan, cfg):
|
|
||||||
"""Compute interface name from is_vpn + stored vlan_id + general.lan_interface."""
|
|
||||||
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}'
|
|
||||||
|
|
||||||
|
|
||||||
# Time and 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'
|
|
||||||
|
|
||||||
|
|
||||||
# Live data loaders =================================================
|
|
||||||
|
|
||||||
def _parse_lease_secs(s):
|
|
||||||
s = str(s).strip().lower()
|
|
||||||
try:
|
|
||||||
if s.endswith('h'): return int(s[:-1]) * 3600
|
|
||||||
if s.endswith('m'): return int(s[:-1]) * 60
|
|
||||||
if s.endswith('d'): return int(s[:-1]) * 86400
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def live_dhcp_leases():
|
|
||||||
rows = []
|
|
||||||
now = int(datetime.now(tz=timezone.utc).timestamp())
|
|
||||||
cfg = load_config()
|
|
||||||
vlans = cfg.get('vlans', [])
|
|
||||||
vlan_lease_secs = {
|
|
||||||
v['name']: _parse_lease_secs(v.get('dhcp_information', {}).get('lease_time', ''))
|
|
||||||
for v in vlans if v.get('name')
|
|
||||||
}
|
|
||||||
mac_to_res = {
|
|
||||||
r['mac'].lower(): r['hostname']
|
|
||||||
for r in cfg.get('dhcp_reservations', [])
|
|
||||||
if r.get('mac') and r.get('hostname')
|
|
||||||
}
|
|
||||||
for leases_file in glob.glob('/var/lib/misc/dnsmasq-routlin-*.leases'):
|
|
||||||
stem = os.path.basename(leases_file)
|
|
||||||
vlan_name = stem[len('dnsmasq-routlin-'):-len('.leases')]
|
|
||||||
lease_secs = vlan_lease_secs.get(vlan_name)
|
|
||||||
try:
|
|
||||||
with open(leases_file) as f:
|
|
||||||
for line in f:
|
|
||||||
parts = line.strip().split()
|
|
||||||
if len(parts) < 4:
|
|
||||||
continue
|
|
||||||
expiry = int(parts[0])
|
|
||||||
if expiry < now:
|
|
||||||
continue
|
|
||||||
obtained_ts = (expiry - lease_secs) if lease_secs else None
|
|
||||||
renews_ts = (expiry - lease_secs // 2) if lease_secs else None
|
|
||||||
if obtained_ts is None:
|
|
||||||
last_active = '-'
|
|
||||||
elif obtained_ts <= now:
|
|
||||||
last_active = relative_time(obtained_ts, now, short=True) + ' ago'
|
|
||||||
elif renews_ts and renews_ts > now:
|
|
||||||
last_active = 'ETA ' + relative_time(renews_ts, now, short=True)
|
|
||||||
else:
|
|
||||||
last_active = 'ETA soon'
|
|
||||||
mac_norm = parts[1].lower()
|
|
||||||
device_h = parts[3] if parts[3] != '*' else None
|
|
||||||
res_h = mac_to_res.get(mac_norm)
|
|
||||||
if res_h and device_h and device_h.lower() != res_h.lower():
|
|
||||||
hostname_html = f'<strong>{e(res_h)}</strong><br/>({e(device_h)})'
|
|
||||||
elif res_h:
|
|
||||||
hostname_html = f'<strong>{e(res_h)}</strong>'
|
|
||||||
elif device_h:
|
|
||||||
hostname_html = e(device_h)
|
|
||||||
else:
|
|
||||||
hostname_html = '-'
|
|
||||||
rows.append({
|
|
||||||
'hostname': hostname_html,
|
|
||||||
'ip_address': parts[2],
|
|
||||||
'mac_address': parts[1],
|
|
||||||
'vendor': _vendor_cell(_get_vendor(parts[1])),
|
|
||||||
'vlan_name': vlan_name,
|
|
||||||
'last_active': last_active,
|
|
||||||
'renews': 'in ' + relative_time(renews_ts or expiry, now, short=True),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def live_vpn_sessions():
|
|
||||||
rows = []
|
|
||||||
out = run('wg show all dump 2>/dev/null')
|
|
||||||
for line in out.splitlines():
|
|
||||||
parts = line.split('\t')
|
|
||||||
if len(parts) == 9:
|
|
||||||
interface, _pubkey, _psk, endpoint, allowed_ips, last_hs, rx, tx, _ka = parts
|
|
||||||
rows.append({
|
|
||||||
'peer_name': _pubkey[:16] + '...',
|
|
||||||
'interface': interface,
|
|
||||||
'tunnel_ip': allowed_ips.split(',')[0].split('/')[0] if allowed_ips else '-',
|
|
||||||
'endpoint': endpoint if endpoint != '(none)' else '-',
|
|
||||||
'last_handshake': fmt_timestamp(int(last_hs)) if last_hs.isdigit() and last_hs != '0' else 'Never',
|
|
||||||
'rx_bytes': fmt_bytes(int(rx)) if rx.isdigit() else '-',
|
|
||||||
'tx_bytes': fmt_bytes(int(tx)) if tx.isdigit() else '-',
|
|
||||||
})
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
# Config datasource =================================================
|
|
||||||
|
|
||||||
def config_datasource(name):
|
|
||||||
cfg = load_config()
|
|
||||||
vlans = cfg.get('vlans', [])
|
|
||||||
|
|
||||||
if name == 'interfaces':
|
|
||||||
gen = cfg.get('network_interfaces', {})
|
|
||||||
wan = gen.get('wan_interface', '')
|
|
||||||
lan = gen.get('lan_interface', '')
|
|
||||||
return [
|
|
||||||
{'iface_type': 'WAN', 'interface': wan, 'status': iface_status(wan)},
|
|
||||||
{'iface_type': 'LAN', 'interface': lan, 'status': iface_status(lan)},
|
|
||||||
]
|
|
||||||
|
|
||||||
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':
|
|
||||||
ddns = load_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> ••••••</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':
|
|
||||||
rows = []
|
|
||||||
for acct in load_accounts().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('live:'):
|
|
||||||
name = spec[5:]
|
|
||||||
if name == 'dhcp_leases': return live_dhcp_leases()
|
|
||||||
if name == 'vpn_sessions': return live_vpn_sessions()
|
|
||||||
return []
|
|
||||||
if spec.startswith('config:'):
|
|
||||||
return config_datasource(spec[7:])
|
|
||||||
return []
|
|
||||||
|
|
||||||
factory.load_datasource = load_datasource
|
|
||||||
|
|
||||||
|
|
||||||
# Shared IP/DDNS helpers ============================================
|
|
||||||
|
|
||||||
def _read_cached_ip():
|
|
||||||
"""Return (ip, mtime) from the most recent .ddns-last-ip-* file, or ('', None)."""
|
|
||||||
try:
|
|
||||||
best_ip, best_mtime = '', 0.0
|
|
||||||
for fname in os.listdir(CONFIGS_DIR):
|
|
||||||
if fname.startswith('.ddns-last-ip-'):
|
|
||||||
path = f'{CONFIGS_DIR}/{fname}'
|
|
||||||
mtime = os.path.getmtime(path)
|
|
||||||
if mtime > best_mtime:
|
|
||||||
ip = open(path).read().strip()
|
|
||||||
if ip:
|
|
||||||
best_ip, best_mtime = ip, mtime
|
|
||||||
return best_ip, (best_mtime if best_ip else None)
|
|
||||||
except Exception:
|
|
||||||
return '', None
|
|
||||||
|
|
||||||
def public_ip_info(ddns_cfg):
|
|
||||||
"""Return (ip_str, domains_sub, last_obtained_str) for stat cards."""
|
|
||||||
enabled_p = [p for p in ddns_cfg.get('providers', []) if p.get('enabled', True)]
|
|
||||||
all_hosts = []
|
|
||||||
for p in enabled_p:
|
|
||||||
all_hosts.extend(p.get('hostnames', p.get('subdomains', [])))
|
|
||||||
domains_sub = ', '.join(all_hosts)
|
|
||||||
ip, mtime = _read_cached_ip()
|
|
||||||
last_obtained = f'Obtained: {relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago' if mtime else ''
|
|
||||||
if ip:
|
|
||||||
return ip, domains_sub, last_obtained
|
|
||||||
return 'Offline', domains_sub, ''
|
|
||||||
|
|
||||||
def ddns_last_checked():
|
|
||||||
try:
|
|
||||||
mtime = os.path.getmtime(f'{CONFIGS_DIR}/.ddns-last-service')
|
|
||||||
return f'Last checked: {relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago'
|
|
||||||
except OSError:
|
|
||||||
return 'Last checked: ---'
|
|
||||||
|
|
||||||
|
|
||||||
# Layout tokens =====================================================
|
|
||||||
|
|
||||||
def collect_layout_tokens(cfg):
|
|
||||||
vlans = cfg.get('vlans', [])
|
|
||||||
net = cfg.get('network_interfaces', {})
|
|
||||||
return {
|
|
||||||
'GENERAL_LAN_INTERFACE': str(net.get('lan_interface', '-')),
|
|
||||||
'VPN_VLAN_COUNT': str(sum(1 for v in vlans if v.get('is_vpn'))),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Layout renderer ===================================================
|
|
||||||
|
|
||||||
def render_layout(view_id, content_html, tokens, page_name=None):
|
|
||||||
css = _load_css()
|
|
||||||
level = client_level()
|
|
||||||
has_pending_alert = not _apply_changes_immediately() and bool(get_dashboard_pending())
|
|
||||||
titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{WEB_APP_DISPLAY_NAME}</span></div>'
|
|
||||||
navbar_html = build_navbar(view_id, level, tokens, pending_alert=has_pending_alert)
|
|
||||||
footer_html = f'<footer class="footer">{WEB_APP_DISPLAY_NAME}</footer>'
|
|
||||||
|
|
||||||
page_hash = config_hash()
|
|
||||||
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
|
|
||||||
vpn_count = tokens.get('VPN_VLAN_COUNT', '0')
|
|
||||||
current_user = session.get('email_address', '')
|
|
||||||
pending = get_pending_entries()
|
|
||||||
my_uuid = next((u for u, t, c, usr in pending if usr == current_user and c != 'fix problems'), None)
|
|
||||||
|
|
||||||
secs = _seconds_until_next_run()
|
|
||||||
locked = _is_locked()
|
|
||||||
lock_mtime = _lock_mtime()
|
|
||||||
other_bars = ''
|
|
||||||
seen_other_users = set()
|
|
||||||
for o_uuid, o_ts, o_cmd, o_user in pending:
|
|
||||||
if o_user == current_user:
|
|
||||||
continue
|
|
||||||
if o_user in seen_other_users:
|
|
||||||
continue
|
|
||||||
seen_other_users.add(o_user)
|
|
||||||
display_user = 'Another user' if o_user in ('unknown', '') else e(o_user)
|
|
||||||
if locked and lock_mtime and o_ts < lock_mtime:
|
|
||||||
text = f'{display_user}\'s changes are being applied now...'
|
|
||||||
cls = 'info-bar-warning info-bar-running'
|
|
||||||
else:
|
|
||||||
timing = _format_timing(secs)
|
|
||||||
text = (
|
|
||||||
f'{display_user} has pending changes which will be applied {timing}.'
|
|
||||||
if timing else
|
|
||||||
f'{display_user} has pending changes. The processing service is not running.'
|
|
||||||
)
|
|
||||||
cls = 'info-bar-warning'
|
|
||||||
other_bars += f'<div class="info-bar {cls}" data-apply-uuid="{e(o_uuid)}" data-apply-user="{e(o_user)}"><span>{text}</span></div>\n'
|
|
||||||
|
|
||||||
problem_bars = ''
|
|
||||||
if level >= LEVEL_RANK['viewer']:
|
|
||||||
try:
|
|
||||||
st = json.load(open(HEALTH_FILE))
|
|
||||||
problems = []
|
|
||||||
for section in ('configurations', 'logs'):
|
|
||||||
for item in st.get(section, []):
|
|
||||||
if item.get('status') == 'problem':
|
|
||||||
problems.append(e(item.get('detail', item.get('name', ''))))
|
|
||||||
for item in st.get('services', []):
|
|
||||||
if item.get('status') == 'problem':
|
|
||||||
name = item.get('name', '')
|
|
||||||
utype = 'timer' if name.endswith('.timer') else 'service' if name.endswith('.service') else 'unit'
|
|
||||||
exp_parts, act_parts = [], []
|
|
||||||
if not item.get('active_ok'):
|
|
||||||
exp_parts.append(item.get('expected_active', 'active'))
|
|
||||||
act_parts.append(item.get('active', 'unknown'))
|
|
||||||
if not item.get('enabled_ok'):
|
|
||||||
exp_parts.append(item.get('expected_enabled', 'enabled'))
|
|
||||||
act_parts.append(item.get('enabled', 'unknown'))
|
|
||||||
problems.append(e(
|
|
||||||
f"The {utype} `{name}` is expected to be "
|
|
||||||
f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}."
|
|
||||||
))
|
|
||||||
has_problems = bool(problems)
|
|
||||||
fix_suffix = ''
|
|
||||||
fix_uuid = None
|
|
||||||
if has_problems:
|
|
||||||
if level < LEVEL_RANK['administrator']:
|
|
||||||
fix_suffix = 'Please contact an administrator.'
|
|
||||||
else:
|
|
||||||
fix_uuid, fix_ts = _find_cmd_in_queues('fix problems')
|
|
||||||
if _apply_changes_immediately():
|
|
||||||
if _is_locked():
|
|
||||||
mtime = _lock_mtime()
|
|
||||||
fix_suffix = (
|
|
||||||
'Fix is being applied now...'
|
|
||||||
if fix_ts and mtime and fix_ts < mtime
|
|
||||||
else 'Fix will be applied on the next run.'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
timing = _format_timing(_seconds_until_next_run())
|
|
||||||
fix_suffix = (
|
|
||||||
f'Fix will be applied {timing}.'
|
|
||||||
if timing else
|
|
||||||
'Fix pending. The processing service is not running.'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
fix_suffix = (
|
|
||||||
'Fix pending. Click <strong>Apply Now</strong> below to fix.'
|
|
||||||
if view_id == 'actions' else
|
|
||||||
'Fix pending. Visit the <strong>Actions</strong> page ASAP to apply fix.'
|
|
||||||
)
|
|
||||||
if problems:
|
|
||||||
problems_list = (
|
|
||||||
'<ul style="margin:0.25em 0;padding-left:1.25em">'
|
|
||||||
+ ''.join(f'<li>{d}</li>' for d in problems)
|
|
||||||
+ '</ul>'
|
|
||||||
)
|
|
||||||
uuid_attr = (
|
|
||||||
f' data-health-uuid="{e(fix_uuid)}"'
|
|
||||||
if fix_uuid and _entry_ts_from_queue(fix_uuid) is not None else ''
|
|
||||||
)
|
|
||||||
fix_html = (
|
|
||||||
f'<div style="margin-top:0.5em"{uuid_attr}>{fix_suffix}</div>'
|
|
||||||
if fix_suffix else ''
|
|
||||||
)
|
|
||||||
content = (
|
|
||||||
'<div style="width:100%">'
|
|
||||||
'<div style="font-weight:600;margin-bottom:0.25em">Health check - problems found:</div>'
|
|
||||||
+ problems_list + fix_html
|
|
||||||
+ '</div>'
|
|
||||||
)
|
|
||||||
problem_bars += f'<div class="info-bar info-bar-danger">{content}</div>\n'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
pending_bar = ''
|
|
||||||
if has_pending_alert and not problem_bars and view_id != 'actions':
|
|
||||||
pending_bar = (
|
|
||||||
'<div class="info-bar info-bar-warning">'
|
|
||||||
'<span>You have actions pending. Please visit the <strong>Actions</strong> page.</span>'
|
|
||||||
'</div>\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
|
||||||
' <meta charset="UTF-8"/>\n'
|
|
||||||
' <meta name="viewport" content="width=device-width, initial-scale=1.0"/>\n'
|
|
||||||
f' <title>{WEB_APP_DISPLAY_NAME}</title>\n'
|
|
||||||
f' <style>{css}</style>\n'
|
|
||||||
'</head>\n<body>\n'
|
|
||||||
f'{titlebar_html}\n'
|
|
||||||
f'{navbar_html}\n'
|
|
||||||
f'<main class="main-content">\n{pending_bar}{problem_bars}{other_bars}{content_html}\n</main>\n'
|
|
||||||
f'{footer_html}\n'
|
|
||||||
f'<script>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var APPLY_UUID={json.dumps(my_uuid)};</script>\n'
|
|
||||||
f'<script>{_inline_js(page_name)}</script>\n'
|
|
||||||
'</body>\n</html>'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_navbar(active_view, level, tokens, pending_alert=False):
|
|
||||||
navbar_data = load_json(NAVBAR_FILE)
|
|
||||||
left, right = [], []
|
|
||||||
for item in navbar_data.get('items', []):
|
|
||||||
req = item.get('client_requirement')
|
|
||||||
align = item.get('align', 'left')
|
|
||||||
if not passes(req, level):
|
|
||||||
continue
|
|
||||||
frag = build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=req, pending_alert=pending_alert)
|
|
||||||
(right if align == 'right' else left).append(frag)
|
|
||||||
return (
|
|
||||||
'<nav class="nav-bar">'
|
|
||||||
f'<div class="nav-left">{"".join(left)}</div>'
|
|
||||||
f'<div class="nav-right">{"".join(right)}</div>'
|
|
||||||
'</nav>'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None, pending_alert=False):
|
|
||||||
req = item.get('client_requirement', inherited_req)
|
|
||||||
t = item.get('type', '')
|
|
||||||
|
|
||||||
if t in ('nav_item', 'nav_action'):
|
|
||||||
label = e(item.get('label', ''))
|
|
||||||
map_to = item.get('map_to', '')
|
|
||||||
action = item.get('action', '')
|
|
||||||
is_active = ' active' if map_to and map_to == active_view else ''
|
|
||||||
pending = ' nav-item-pending' if pending_alert and map_to == 'actions' else ''
|
|
||||||
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}'
|
|
||||||
if action:
|
|
||||||
return (
|
|
||||||
f'<form method="post" action="/action/{e(action)}" class="form-inline">'
|
|
||||||
f'<button type="submit" class="{cls}">{label}</button></form>'
|
|
||||||
)
|
|
||||||
if map_to:
|
|
||||||
return f'<a href="/{e(map_to)}" class="{cls}">{label}</a>'
|
|
||||||
return f'<span class="{cls}">{label}</span>'
|
|
||||||
|
|
||||||
if t == 'nav_menu':
|
|
||||||
raw_label = item.get('label', '')
|
|
||||||
if raw_label == '%MENU_LABEL%':
|
|
||||||
raw_label = 'Configure' if level >= LEVEL_RANK['administrator'] else 'View'
|
|
||||||
label = e(raw_label)
|
|
||||||
children = ''
|
|
||||||
for child in item.get('items', []):
|
|
||||||
child_req = child.get('client_requirement', req)
|
|
||||||
if not passes(child_req, level):
|
|
||||||
continue
|
|
||||||
children += build_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req, pending_alert=pending_alert)
|
|
||||||
if not children:
|
|
||||||
return ''
|
|
||||||
return (
|
|
||||||
'<div class="nav-menu">'
|
|
||||||
f'<button class="nav-item nav-menu-trigger" aria-haspopup="true">{label}</button>'
|
|
||||||
f'<div class="nav-dropdown">{children}</div>'
|
|
||||||
'</div>'
|
|
||||||
)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
# Inline JavaScript =================================================
|
|
||||||
|
|
||||||
def _inline_js(page_name=None):
|
|
||||||
big_validate_js = factory.build_big_validate()
|
|
||||||
try:
|
|
||||||
with open(COMMON_JS_FILE) as f:
|
|
||||||
app_js = f.read()
|
|
||||||
except Exception:
|
|
||||||
app_js = ''
|
|
||||||
page_js = ''
|
|
||||||
if page_name:
|
|
||||||
page_js_path = os.path.join(PAGES_DIR, page_name, 'page.js')
|
|
||||||
try:
|
|
||||||
with open(page_js_path) as f:
|
|
||||||
page_js = f.read()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return big_validate_js + '\n' + app_js + ('\n' + page_js if page_js else '')
|
|
||||||
|
|
||||||
|
|
||||||
# Dynamic page view loader ==========================================
|
|
||||||
|
|
||||||
_page_view_cache = {}
|
|
||||||
|
|
||||||
def _load_page_view(page_name):
|
|
||||||
if page_name not in _page_view_cache:
|
|
||||||
path = os.path.join(PAGES_DIR, page_name, 'view.py')
|
|
||||||
if not os.path.exists(path):
|
|
||||||
_page_view_cache[page_name] = None
|
|
||||||
else:
|
|
||||||
spec = _importlib_util.spec_from_file_location(f'page_view_{page_name}', path)
|
|
||||||
mod = _importlib_util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(mod)
|
|
||||||
_page_view_cache[page_name] = mod
|
|
||||||
return _page_view_cache[page_name]
|
|
||||||
|
|
||||||
|
|
||||||
# Routes ============================================================
|
|
||||||
|
|
||||||
@bp.route('/')
|
|
||||||
def index():
|
|
||||||
return serve_view('overview')
|
|
||||||
|
|
||||||
@bp.route('/<page_name>')
|
|
||||||
def view(page_name):
|
|
||||||
return serve_view(page_name)
|
|
||||||
|
|
||||||
def serve_view(page_name):
|
|
||||||
view_def = load_json(os.path.join(PAGES_DIR, page_name, 'content.json'))
|
|
||||||
if not view_def:
|
|
||||||
from flask import abort
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
view_req = view_def.get('client_requirement')
|
|
||||||
level = client_level()
|
|
||||||
if not passes(view_req, level):
|
|
||||||
return redirect('/overview' if level > 0 else '/accountlogin')
|
|
||||||
|
|
||||||
cfg = load_config()
|
|
||||||
tokens = collect_layout_tokens(cfg)
|
|
||||||
|
|
||||||
# Auto-queue health fix for every administrator page load
|
|
||||||
if level >= LEVEL_RANK['administrator']:
|
|
||||||
try:
|
|
||||||
st = json.load(open(HEALTH_FILE))
|
|
||||||
has_problems = any(
|
|
||||||
item.get('status') == 'problem'
|
|
||||||
for section in ('configurations', 'logs', 'services')
|
|
||||||
for item in st.get(section, [])
|
|
||||||
)
|
|
||||||
if has_problems:
|
|
||||||
fix_uuid, _ = _find_cmd_in_queues('fix problems')
|
|
||||||
if fix_uuid is None:
|
|
||||||
queue_command('fix problems', user=session.get('email_address', ''))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
page_view = _load_page_view(page_name)
|
|
||||||
if page_view and hasattr(page_view, 'collect_tokens'):
|
|
||||||
tokens.update(page_view.collect_tokens(cfg))
|
|
||||||
|
|
||||||
if page_name == 'radius' and not os.path.exists(f'{CONFIGS_DIR}/.radius-secret'):
|
|
||||||
queue_command('gen radius')
|
|
||||||
|
|
||||||
flash_html = ''
|
|
||||||
for category, message in get_flashed_messages(with_categories=True):
|
|
||||||
variant = {'error': 'danger', 'warning': 'warning', 'success': 'success'}.get(category, 'info')
|
|
||||||
msg_html = message if isinstance(message, Markup) else e(message)
|
|
||||||
flash_html += f'<div class="info-bar info-bar-{variant} info-bar-flash"><span>{msg_html}</span></div>'
|
|
||||||
|
|
||||||
content_html = flash_html + build_items(view_def.get('items', []), tokens, view_req)
|
|
||||||
return render_layout(page_name, content_html, tokens, page_name=page_name)
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue