diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 0019dd3..63430db 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -16,6 +16,7 @@ DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock' DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending' DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots' HEALTH_FILE = f'{CONFIGS_DIR}/.health' +BLOCKLISTS_DIR = f'{CONFIGS_DIR}/blocklists' PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin') DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue' DDNS_TIMER_NAME = f'{PRODUCT_NAME}-ddns-update' @@ -578,3 +579,229 @@ def run_update_blocklists(): ) except Exception: pass + + +# Format helpers ==================================================== + +def fmt_timestamp(ts): + try: + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC') + except Exception: + return '-' + +def relative_time(ts1, ts2, short=False): + try: + diff = abs(int(ts1) - int(ts2)) + if diff < 60: + return f'{diff}s' if short else f'{diff} second{"s" if diff != 1 else ""}' + m = diff // 60 + if m < 60: + return f'{m}m' if short else f'{m} minute{"s" if m != 1 else ""}' + h, rem_m = divmod(m, 60) + if h < 24: + if short: + return f'{h}h {rem_m}m' if rem_m else f'{h}h' + return f'{h}h {rem_m}m' if rem_m else f'{h} hour{"s" if h != 1 else ""}' + d = h // 24 + if d < 365: + return f'{d}d' if short else f'{d} day{"s" if d != 1 else ""}' + y = d // 365 + return f'{y}y' if short else f'{y} year{"s" if y != 1 else ""}' + except Exception: + return '' + +def fmt_bytes(n): + for unit in ('B', 'KB', 'MB', 'GB'): + if n < 1024: + return f'{n:.1f} {unit}' + n /= 1024 + return f'{n:.1f} TB' + +def resolve_iface(vlan, cfg): + if vlan.get('is_vpn'): + wg_vlans = [v for v in cfg.get('vlans', []) if v.get('is_vpn')] + wg_sorted = sorted(wg_vlans, key=lambda v: (v.get('vlan_id') is None, v.get('vlan_id') or 0)) + idx = next((i for i, v in enumerate(wg_sorted) if v is vlan), 0) + return f'wg{idx}' + lan = cfg.get('network_interfaces', {}).get('lan_interface', 'eth0') + vid = vlan.get('vlan_id') or 1 + return lan if vid == 1 else f'{lan}.{vid}' + + +# Config datasources ================================================ + +def config_datasource(name): + cfg = load_config() + vlans = cfg.get('vlans', []) + + if name == 'banned_ips': + return cfg.get('banned_ips', []) + + if name == 'host_overrides': + return cfg.get('host_overrides', []) + + if name == 'blocklists': + rows = [] + for bl in cfg.get('dns_blocking', {}).get('blocklists', []): + row = dict(bl) + bl_path = os.path.join(BLOCKLISTS_DIR, bl.get('save_as', '')) + try: + with open(bl_path) as f: + row['domain_count'] = str(sum(1 for _ in f)) + row['last_updated'] = fmt_timestamp(int(os.path.getmtime(bl_path))) + except Exception: + row['domain_count'] = '-' + row['last_updated'] = '-' + rows.append(row) + return rows + + if name == 'vlans': + bl_desc = { + b['name']: b.get('description', b['name']) + for b in cfg.get('dns_blocking', {}).get('blocklists', []) + if 'name' in b + } + rows = [] + for v in sorted(vlans, key=lambda x: x.get('vlan_id') or 0): + row = {k: v.get(k) for k in ( + 'name', 'subnet', 'subnet_mask', 'radius_default', + 'mdns_reflection', 'is_vpn', 'dnsmasq_log_queries' + )} + row['vlan_id'] = v.get('vlan_id') + row['interface'] = resolve_iface(v, cfg) + row['use_blocklists'] = json.dumps([ + {'n': bl, 'd': bl_desc.get(bl, bl)} for bl in v.get('use_blocklists', []) + ]) + prefix = v.get('subnet_mask', 24) + n_octets = 1 if prefix >= 24 else 2 if prefix >= 16 else 3 if prefix >= 8 else 4 + row['server_identity_ips'] = json.dumps([ + { + 'n': s['ip'], + 'd': ' | '.join(filter(None, [s['ip'], s.get('description'), s.get('hostname')])), + 'short': '.' + '.'.join(s['ip'].split('.')[-n_octets:]), + 'mini': '.' + '.'.join(s['ip'].split('.')[-n_octets:]), + } + for s in v.get('server_identities', []) if s.get('ip') + ]) + row['server_identity_descriptions'] = json.dumps([ + s.get('description', '') for s in v.get('server_identities', []) if s.get('ip') + ]) + row['server_identity_hostnames'] = json.dumps([ + s.get('hostname', '') for s in v.get('server_identities', []) if s.get('ip') + ]) + row['server_identity_gateway'] = ( + v.get('dhcp_information', {}).get('explicit_overrides', {}).get('gateway', '') + ) + dns = v.get('dhcp_information', {}).get('explicit_overrides', {}).get('dns_servers', []) + row['server_identity_dns_servers'] = '\n'.join(dns) if isinstance(dns, list) else str(dns or '') + ntp = v.get('dhcp_information', {}).get('explicit_overrides', {}).get('ntp_servers', []) + row['server_identity_ntp_servers'] = '\n'.join(ntp) if isinstance(ntp, list) else str(ntp or '') + row['gateway'] = row['server_identity_gateway'] + row['dns_servers'] = row['server_identity_dns_servers'] + row['ntp_servers'] = row['server_identity_ntp_servers'] + row['dns_servers_override'] = 1 if row['server_identity_dns_servers'] else 0 + row['ntp_servers_override'] = 1 if row['server_identity_ntp_servers'] else 0 + dhi = v.get('dhcp_information', {}) + row['dhcp_pool_start'] = dhi.get('dynamic_pool_start', '') + row['dhcp_pool_end'] = dhi.get('dynamic_pool_end', '') + lt = dhi.get('lease_time', '') + if lt and len(lt) > 1 and lt[:-1].isdigit() and lt[-1] in 'mhd': + row['dhcp_lease_time'] = lt[:-1] + row['dhcp_lease_unit'] = {'m': 'minutes', 'h': 'hours', 'd': 'days'}[lt[-1]] + else: + row['dhcp_lease_time'] = '' + row['dhcp_lease_unit'] = '' + row['dhcp_domain'] = dhi.get('domain', '') + row['server_identities_json'] = json.dumps(v.get('server_identities', [])) + rows.append(row) + return rows + + if name == 'inter_vlan_exceptions': + return cfg.get('inter_vlan_exceptions', []) + + if name == 'port_forwarding': + return cfg.get('port_forwarding', []) + + if name == 'port_wrangling': + rows = [] + for r in cfg.get('port_wrangling', []): + row = dict(r) + row['vlan_name'] = r.get('vlan', '-') + rows.append(row) + return rows + + if name == 'dhcp_reservations': + rows = [] + for res in cfg.get('dhcp_reservations', []): + row = dict(res) + row['vlan_name'] = res.get('vlan', '-') + rows.append(row) + return rows + + if name == 'ddns_providers': + from factory import e + ddns = load_config().get('ddns', {}) + rows = [] + for p in ddns.get('providers', []): + row = dict(p) + ptype = p.get('provider', '').lower() + if ptype == 'noip': + row['credentials'] = ( + '
' + f'U: {e(p.get("username", "-"))}
' + 'P: ••••••
' + ) + elif ptype in ('cloudflare', 'duckdns'): + tok = p.get('api_token', '') + row['credentials'] = f'API Token: {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'))), + } diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py index cfb905c..f127862 100644 --- a/docker/routlin-dash/app/factory.py +++ b/docker/routlin-dash/app/factory.py @@ -1,15 +1,24 @@ -# factory.py: JSON content-type renderer -# Converts content.json item trees into HTML strings. -# Pure type processing: no data loading, no routing, no layout. +# factory.py: HTML renderer and shared utilities +# Builds HTML from content.json item trees. Pure rendering - no data fetching. from flask import session from markupsafe import Markup -import json, re, sys, html as html_mod -from config_utils import config_hash +import json, re, sys, html as html_mod, os, subprocess +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 ==================================== -# view_page sets this after defining load_datasource so that build_table -# can load row data without creating a circular import. -load_datasource = None +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') # Constants =========================================================== @@ -34,6 +43,60 @@ VALIDATION_FLAGS = { '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 =========================================================== def e(text): @@ -191,6 +254,16 @@ def get_worker_id(datasource): return datasource[len(prefix):] 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 ====================================================== def client_level(): @@ -215,17 +288,17 @@ def passes(req, level): # Snapshot helpers ==================================================== -def _flatten_json(val, prefix): +def flatten_json(val, prefix): """Recursively flatten a parsed JSON value into [(path, leaf_str)] pairs.""" if isinstance(val, dict): out = [] for k, v in val.items(): - out.extend(_flatten_json(v, f'{prefix}.{k}')) + out.extend(flatten_json(v, f'{prefix}.{k}')) return out if isinstance(val, list): out = [] for i, v in enumerate(val): - out.extend(_flatten_json(v, f'{prefix}[{i}]')) + out.extend(flatten_json(v, f'{prefix}[{i}]')) return out if val is 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 aval = json.loads(after_text) if after_text is not None else None if isinstance(bval, (dict, list)) or isinstance(aval, (dict, list)): - bflat = dict(_flatten_json(bval, field)) if isinstance(bval, (dict, list)) else {} - aflat = dict(_flatten_json(aval, field)) if isinstance(aval, (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 {} if bflat or aflat: seen = set() 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 ====================================================== -def build_table(item, tokens, inherited_req=None): +def build_table(item, tokens, rows, inherited_req=None): level = client_level() columns = item.get('columns', []) - rows = load_datasource(item.get('datasource', '')) empty = e(item.get('empty_message', 'No data.')) row_actions = item.get('row_actions', []) hash_val = config_hash() @@ -1515,9 +1587,217 @@ def build_item(item, tokens, inherited_req=None): return f'
{inner}
' 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': return Markup(apply_tokens(item.get('html', ''), tokens)) 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'
{WEB_APP_DISPLAY_NAME}
' + navbar_html = build_navbar(view_id, level, tokens, pending_alert=has_pending_alert) + footer_html = f'' + + 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'
{text}
\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 Apply Now below to fix.' + if view_id == 'actions' else + 'Fix pending. Visit the Actions page ASAP to apply fix.' + ) + if problems: + problems_list = ( + '' + ) + 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'
{fix_suffix}
' + if fix_suffix else '' + ) + content = ( + '
' + '
Health check - problems found:
' + + problems_list + fix_html + + '
' + ) + problem_bars += f'
{content}
\n' + except Exception: + pass + + pending_bar = '' + if has_pending_alert and not problem_bars and view_id != 'actions': + pending_bar = ( + '
' + 'You have actions pending. Please visit the Actions page.' + '
\n' + ) + + return ( + '\n\n\n' + ' \n' + ' \n' + f' {WEB_APP_DISPLAY_NAME}\n' + f' \n' + '\n\n' + f'{titlebar_html}\n' + f'{navbar_html}\n' + f'
\n{pending_bar}{problem_bars}{other_bars}{content_html}\n
\n' + f'{footer_html}\n' + f'\n' + f'\n' + '\n' + ) + + +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 ( + '' + ) + + +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'
' + f'
' + ) + if map_to: + return f'{label}' + return f'{label}' + + 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 ( + '' + ) + return '' diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 57b7469..8db0b9c 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -1,7 +1,14 @@ -import os, json, sys -from flask import Flask -from config_utils import ACCOUNTS_FILE -from view_common import bp as view_page_bp +import os, json, sys, importlib.util as _importlib_util +from flask import Flask, Blueprint, session, redirect, get_flashed_messages +from markupsafe import Markup +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.bannedips.action import bp as bannedips_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.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('/') +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'
{msg_html}
' + + 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(bannedips_bp) app.register_blueprint(ddns_bp) @@ -51,6 +133,7 @@ app.register_blueprint(mdns_bp) app.register_blueprint(radius_bp) app.register_blueprint(api_apply_health_bp) + def _seed_initial_account(): email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() if not email: diff --git a/docker/routlin-dash/app/pages/accountcreate/view.py b/docker/routlin-dash/app/pages/accountcreate/view.py index 1871e93..30c0a53 100644 --- a/docker/routlin-dash/app/pages/accountcreate/view.py +++ b/docker/routlin-dash/app/pages/accountcreate/view.py @@ -1,9 +1,10 @@ import json import sanitize +from config_utils import collect_layout_tokens def collect_tokens(cfg): + tokens = collect_layout_tokens(cfg) blank = [{'value': '', 'label': '-- Select timezone --'}] - return { - 'TIMEZONE_OPTIONS': json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]), - } + tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) + return tokens diff --git a/docker/routlin-dash/app/pages/accountlogin/view.py b/docker/routlin-dash/app/pages/accountlogin/view.py new file mode 100644 index 0000000..0e98c4e --- /dev/null +++ b/docker/routlin-dash/app/pages/accountlogin/view.py @@ -0,0 +1,5 @@ +from config_utils import collect_layout_tokens + + +def collect_tokens(cfg): + return collect_layout_tokens(cfg) diff --git a/docker/routlin-dash/app/pages/accountmanage/view.py b/docker/routlin-dash/app/pages/accountmanage/view.py index 8b50175..37a2390 100644 --- a/docker/routlin-dash/app/pages/accountmanage/view.py +++ b/docker/routlin-dash/app/pages/accountmanage/view.py @@ -1,11 +1,17 @@ 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): - return { - 'ACCOUNT_LEVEL_OPTIONS': json.dumps([ - {'value': 'viewer', 'label': 'Viewer (read-only access to live data)'}, - {'value': 'administrator', 'label': 'Administrator (can modify configuration)'}, - {'value': 'manager', 'label': 'Manager (full access including account management)'}, - ]), - } + tokens = collect_layout_tokens(cfg) + tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([ + {'value': 'viewer', 'label': 'Viewer (read-only access to live data)'}, + {'value': 'administrator', 'label': 'Administrator (can modify configuration)'}, + {'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 diff --git a/docker/routlin-dash/app/pages/accountverifyemail/view.py b/docker/routlin-dash/app/pages/accountverifyemail/view.py new file mode 100644 index 0000000..0e98c4e --- /dev/null +++ b/docker/routlin-dash/app/pages/accountverifyemail/view.py @@ -0,0 +1,5 @@ +from config_utils import collect_layout_tokens + + +def collect_tokens(cfg): + return collect_layout_tokens(cfg) diff --git a/docker/routlin-dash/app/pages/actions/view.py b/docker/routlin-dash/app/pages/actions/view.py index f518aeb..edbb09c 100644 --- a/docker/routlin-dash/app/pages/actions/view.py +++ b/docker/routlin-dash/app/pages/actions/view.py @@ -3,15 +3,14 @@ from collections import defaultdict from datetime import datetime from flask import session 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, ) -from factory import LEVEL_RANK, e, client_level, build_snap_val, snap_expand_row -from view_common import _load_icon +from factory import LEVEL_RANK, e, client_level, build_snap_val, snap_expand_row, load_icon 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' all_groups = load_all_groups() @@ -60,7 +59,7 @@ def collect_tokens(cfg): 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['APPLY_WARNING'] = ( - f'

{_load_icon("arrow-left")} Applying actions will briefly disrupt connections as network services are restarted.

' + f'

{load_icon("arrow-left")} Applying actions will briefly disrupt connections as network services are restarted.

' if pending_items else '' ) diff --git a/docker/routlin-dash/app/pages/bannedips/view.py b/docker/routlin-dash/app/pages/bannedips/view.py new file mode 100644 index 0000000..c3529b6 --- /dev/null +++ b/docker/routlin-dash/app/pages/bannedips/view.py @@ -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 diff --git a/docker/routlin-dash/app/pages/ddns/view.py b/docker/routlin-dash/app/pages/ddns/view.py index 2e70986..7299c2d 100644 --- a/docker/routlin-dash/app/pages/ddns/view.py +++ b/docker/routlin-dash/app/pages/ddns/view.py @@ -1,9 +1,13 @@ import json import re 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 + DDNS_LOG_MAX = 50 @@ -42,8 +46,47 @@ def _ddns_log_tail(): 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): - tokens = {} + tokens = collect_layout_tokens(cfg) ddns = load_ddns() ddns_gen = ddns.get('general', {}) 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_CHECKED'] = ddns_last_checked() 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 diff --git a/docker/routlin-dash/app/pages/dhcp/__init__.py b/docker/routlin-dash/app/pages/dhcp/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docker/routlin-dash/app/pages/dhcp/action.py b/docker/routlin-dash/app/pages/dhcp/action.py deleted file mode 100644 index 7fa0208..0000000 --- a/docker/routlin-dash/app/pages/dhcp/action.py +++ /dev/null @@ -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}') diff --git a/docker/routlin-dash/app/pages/dhcp/content.json b/docker/routlin-dash/app/pages/dhcp/content.json deleted file mode 100644 index 9bdef24..0000000 --- a/docker/routlin-dash/app/pages/dhcp/content.json +++ /dev/null @@ -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" - } - ] - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/dhcp/view.py b/docker/routlin-dash/app/pages/dhcp/view.py deleted file mode 100644 index 458bce8..0000000 --- a/docker/routlin-dash/app/pages/dhcp/view.py +++ /dev/null @@ -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 = '' + ''.join( - f'' 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), - } diff --git a/docker/routlin-dash/app/pages/dhcpleases/view.py b/docker/routlin-dash/app/pages/dhcpleases/view.py index c992a04..2838790 100644 --- a/docker/routlin-dash/app/pages/dhcpleases/view.py +++ b/docker/routlin-dash/app/pages/dhcpleases/view.py @@ -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'{e(display)}' + 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'{e(res_h)}
({e(device_h)})' + elif res_h: + hostname_html = f'{e(res_h)}' + 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): + tokens = collect_layout_tokens(cfg) vlans = cfg.get('vlans', []) vlan_names = [v.get('name', '') for v in vlans] filter_opts = '' + ''.join( f'' 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 diff --git a/docker/routlin-dash/app/pages/dhcpreservations/view.py b/docker/routlin-dash/app/pages/dhcpreservations/view.py index 458bce8..15f6e99 100644 --- a/docker/routlin-dash/app/pages/dhcpreservations/view.py +++ b/docker/routlin-dash/app/pages/dhcpreservations/view.py @@ -1,7 +1,10 @@ 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): + tokens = collect_layout_tokens(cfg) vlans = cfg.get('vlans', []) vlan_names = [v.get('name', '') for v in vlans] res_ips_by_vlan, res_hosts_by_vlan = {}, {} @@ -15,13 +18,16 @@ def collect_tokens(cfg): filter_opts = '' + ''.join( f'' 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), - } + tokens['VLAN_FILTER_OPTIONS'] = filter_opts + tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names]) + tokens['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') + }) + tokens['RESERVATION_IPS_BY_VLAN_JSON'] = json.dumps(res_ips_by_vlan) + tokens['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 diff --git a/docker/routlin-dash/app/pages/dnsblocking/view.py b/docker/routlin-dash/app/pages/dnsblocking/view.py index c0d2c54..941b4fa 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/view.py +++ b/docker/routlin-dash/app/pages/dnsblocking/view.py @@ -1,11 +1,11 @@ import json import os from datetime import datetime, timezone -from view_common import fmt_bytes, relative_time, BLOCKLISTS_DIR -from factory import e +from config_utils import collect_layout_tokens, load_datasource, fmt_bytes, relative_time, BLOCKLISTS_DIR +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 = '' for bl in cfg.get('dns_blocking', {}).get('blocklists', []): name = e(bl.get('name', '')) @@ -44,14 +44,18 @@ def _blocklist_stats_html(cfg): def collect_tokens(cfg): + tokens = collect_layout_tokens(cfg) dns_blk_gen = cfg.get('dns_blocking', {}).get('general', {}) - return { - 'GENERAL_LOG_MAX_KB': str(dns_blk_gen.get('log_max_kb', '-')), - 'GENERAL_LOG_ERRORS_ONLY': 'true' if dns_blk_gen.get('log_errors_only') else 'false', - 'GENERAL_DAILY_EXECUTE_TIME': str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')), - 'BLOCKLIST_STATS_HTML': _blocklist_stats_html(cfg), - 'BLOCKLIST_FORMAT_OPTIONS': json.dumps([ - {'value': 'hosts', 'label': 'hosts (hosts file format)'}, - {'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'}, - ]), - } + tokens['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' + tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) + tokens['BLOCKLIST_STATS_HTML'] = blocklist_stats_html(cfg) + tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([ + {'value': 'hosts', 'label': 'hosts (hosts file format)'}, + {'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 diff --git a/docker/routlin-dash/app/pages/dnsserver/view.py b/docker/routlin-dash/app/pages/dnsserver/view.py index 6ac981e..46090dd 100644 --- a/docker/routlin-dash/app/pages/dnsserver/view.py +++ b/docker/routlin-dash/app/pages/dnsserver/view.py @@ -1,11 +1,12 @@ import json +from config_utils import collect_layout_tokens def collect_tokens(cfg): + tokens = collect_layout_tokens(cfg) dns = cfg.get('upstream_dns', {}) servers = dns.get('upstream_servers', []) - return { - 'DNS_STRICT_ORDER': 'true' if dns.get('strict_order') else 'false', - 'DNS_CACHE_SIZE': str(dns.get('cache_size', '-')), - 'DNS_UPSTREAM_SERVERS_JSON': json.dumps(servers), - } + tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false' + tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-')) + tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers) + return tokens diff --git a/docker/routlin-dash/app/pages/hostoverrides/view.py b/docker/routlin-dash/app/pages/hostoverrides/view.py new file mode 100644 index 0000000..e852160 --- /dev/null +++ b/docker/routlin-dash/app/pages/hostoverrides/view.py @@ -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 diff --git a/docker/routlin-dash/app/pages/intervlan/view.py b/docker/routlin-dash/app/pages/intervlan/view.py index 0fecb7f..ed3875d 100644 --- a/docker/routlin-dash/app/pages/intervlan/view.py +++ b/docker/routlin-dash/app/pages/intervlan/view.py @@ -1,11 +1,17 @@ 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): - return { - 'PROTOCOL_OPTIONS': json.dumps([ - {'value': 'tcp', 'label': 'TCP'}, - {'value': 'udp', 'label': 'UDP'}, - {'value': 'both', 'label': 'TCP/UDP'}, - ]), - } + tokens = collect_layout_tokens(cfg) + tokens['PROTOCOL_OPTIONS'] = json.dumps([ + {'value': 'tcp', 'label': 'TCP'}, + {'value': 'udp', 'label': '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 diff --git a/docker/routlin-dash/app/pages/mdns/view.py b/docker/routlin-dash/app/pages/mdns/view.py new file mode 100644 index 0000000..0e98c4e --- /dev/null +++ b/docker/routlin-dash/app/pages/mdns/view.py @@ -0,0 +1,5 @@ +from config_utils import collect_layout_tokens + + +def collect_tokens(cfg): + return collect_layout_tokens(cfg) diff --git a/docker/routlin-dash/app/pages/networklayout/view.py b/docker/routlin-dash/app/pages/networklayout/view.py index f1e40af..d0b647a 100644 --- a/docker/routlin-dash/app/pages/networklayout/view.py +++ b/docker/routlin-dash/app/pages/networklayout/view.py @@ -1,15 +1,21 @@ 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): + tokens = collect_layout_tokens(cfg) vlans = cfg.get('vlans', []) dv = next((v for v in vlans if v.get('radius_default')), None) - return { - 'EXISTING_VLAN_IDS_JSON': json.dumps([v.get('vlan_id') for v in vlans]), - 'EXISTING_VLAN_NAMES_JSON': json.dumps([v.get('name') for v in vlans]), - 'RADIUS_DEFAULT_VLAN': f'"{dv["name"]}" (VLAN {dv["vlan_id"]})' if dv else 'none set', - 'BLOCKLIST_NAME_OPTIONS': json.dumps([ - {'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))} - for bl in cfg.get('dns_blocking', {}).get('blocklists', []) - ]), - } + tokens['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]) + tokens['RADIUS_DEFAULT_VLAN'] = f'"{dv["name"]}" (VLAN {dv["vlan_id"]})' if dv else 'none set' + tokens['BLOCKLIST_NAME_OPTIONS'] = json.dumps([ + {'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))} + 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 diff --git a/docker/routlin-dash/app/pages/overview/view.py b/docker/routlin-dash/app/pages/overview/view.py index 8851bb5..561109d 100644 --- a/docker/routlin-dash/app/pages/overview/view.py +++ b/docker/routlin-dash/app/pages/overview/view.py @@ -1,6 +1,9 @@ import re 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(): @@ -32,12 +35,12 @@ def get_dnsmasq_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'") return out or '0' -def _count_blocked_domains(): +def count_blocked_domains(): try: total = sum( int(run(f'wc -l < "{BLOCKLISTS_DIR}/{f}"') or 0) @@ -48,7 +51,7 @@ def _count_blocked_domains(): return '-' -def _bl_last_update(): +def bl_last_update(): try: mtime = max( os.path.getmtime(f'{BLOCKLISTS_DIR}/{f}') @@ -60,6 +63,7 @@ def _bl_last_update(): def collect_tokens(cfg): + tokens = collect_layout_tokens(cfg) vlans = cfg.get('vlans', []) non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')] vlan_names = [v.get('name', '') for v in vlans] @@ -69,26 +73,25 @@ def collect_tokens(cfg): ddns = load_ddns() ip_str, domains_sub, last_obtained = public_ip_info(ddns) - return { - 'GENERAL_WAN_INTERFACE': str(net.get('wan_interface', '-')), - 'OVERVIEW_VLAN_NAMES': ', '.join(vlan_names) or '-', - 'STAT_VLAN_COUNT': str(len(non_vpn_vlans)), - 'STAT_LEASE_COUNT': str(len(live_dhcp_leases())), - 'STAT_BANNED_IP_COUNT': str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True))), - 'STAT_BLOCKLIST_COUNT': str(len(cfg.get('dns_blocking', {}).get('blocklists', []))), - 'STAT_BLOCKED_TODAY': _count_blocked_today(), - 'STAT_BLOCKED_DOMAINS': _count_blocked_domains(), - 'STAT_BL_LAST_UPDATE': _bl_last_update(), - 'STAT_UPTIME': run('uptime -p') or '-', - 'STAT_NFTABLES_STATUS': 'Active' if run('nft list tables 2>/dev/null').strip() else 'Inactive', - 'STAT_PUBLIC_IP': ip_str, - 'STAT_DDNS_HOSTNAME': domains_sub, - 'DNS_CACHE_SIZE': str(dns.get('cache_size', '-')), - 'OVERVIEW_UPSTREAM_SERVERS': ', '.join(dns.get('upstream_servers', [])) or '-', - 'DNS_STAT_QUERIES': dns_stats['queries'], - 'DNS_STAT_HITS': dns_stats['hits'], - 'DNS_STAT_HIT_RATE': dns_stats['hit_rate'], - 'DNS_STAT_FORWARDED': dns_stats['forwarded'], - 'DNS_STAT_AUTH': dns_stats['auth'], - 'DNS_STAT_TCP_PEAK': dns_stats['tcp_peak'], - } + tokens['GENERAL_WAN_INTERFACE'] = str(net.get('wan_interface', '-')) + tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-' + tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans)) + tokens['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))) + tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', []))) + tokens['STAT_BLOCKED_TODAY'] = count_blocked_today() + tokens['STAT_BLOCKED_DOMAINS'] = count_blocked_domains() + tokens['STAT_BL_LAST_UPDATE'] = bl_last_update() + tokens['STAT_UPTIME'] = run('uptime -p') or '-' + tokens['STAT_NFTABLES_STATUS'] = 'Active' if run('nft list tables 2>/dev/null').strip() else 'Inactive' + tokens['STAT_PUBLIC_IP'] = ip_str + tokens['STAT_DDNS_HOSTNAME'] = domains_sub + tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-')) + tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(dns.get('upstream_servers', [])) or '-' + tokens['DNS_STAT_QUERIES'] = dns_stats['queries'] + tokens['DNS_STAT_HITS'] = dns_stats['hits'] + tokens['DNS_STAT_HIT_RATE'] = dns_stats['hit_rate'] + tokens['DNS_STAT_FORWARDED'] = dns_stats['forwarded'] + tokens['DNS_STAT_AUTH'] = dns_stats['auth'] + tokens['DNS_STAT_TCP_PEAK'] = dns_stats['tcp_peak'] + return tokens diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/view.py b/docker/routlin-dash/app/pages/physicalinterfaces/view.py index f6eff1f..16576ea 100644 --- a/docker/routlin-dash/app/pages/physicalinterfaces/view.py +++ b/docker/routlin-dash/app/pages/physicalinterfaces/view.py @@ -1,8 +1,74 @@ 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): + tokens = collect_layout_tokens(cfg) net = cfg.get('network_interfaces', {}) wan = net.get('wan_interface', '') lan = net.get('lan_interface', '') @@ -12,8 +78,6 @@ def collect_tokens(cfg): sys_ifaces.append(configured) sys_ifaces.sort() iface_data = [iface_info(i) for i in sys_ifaces] - return { - 'GENERAL_WAN_INTERFACE': str(wan or '-'), - 'GENERAL_LAN_INTERFACE': str(lan or '-'), - 'NETWORK_INTERFACE_DATA_JSON': json.dumps(iface_data), - } + tokens['GENERAL_WAN_INTERFACE'] = str(wan or '-') + tokens['NETWORK_INTERFACE_DATA_JSON'] = json.dumps(iface_data) + return tokens diff --git a/docker/routlin-dash/app/pages/portforwarding/view.py b/docker/routlin-dash/app/pages/portforwarding/view.py index 0fecb7f..ce773fb 100644 --- a/docker/routlin-dash/app/pages/portforwarding/view.py +++ b/docker/routlin-dash/app/pages/portforwarding/view.py @@ -1,11 +1,17 @@ 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): - return { - 'PROTOCOL_OPTIONS': json.dumps([ - {'value': 'tcp', 'label': 'TCP'}, - {'value': 'udp', 'label': 'UDP'}, - {'value': 'both', 'label': 'TCP/UDP'}, - ]), - } + tokens = collect_layout_tokens(cfg) + tokens['PROTOCOL_OPTIONS'] = json.dumps([ + {'value': 'tcp', 'label': 'TCP'}, + {'value': 'udp', 'label': '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 diff --git a/docker/routlin-dash/app/pages/portwrangling/view.py b/docker/routlin-dash/app/pages/portwrangling/view.py index 9f75945..b0d3ed5 100644 --- a/docker/routlin-dash/app/pages/portwrangling/view.py +++ b/docker/routlin-dash/app/pages/portwrangling/view.py @@ -1,22 +1,28 @@ 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): + tokens = collect_layout_tokens(cfg) vlans = cfg.get('vlans', []) vlan_names = [v.get('name', '') for v in vlans] filter_opts = '' + ''.join( f'' for n in vlan_names ) - return { - 'PROTOCOL_OPTIONS': json.dumps([ - {'value': 'tcp', 'label': 'TCP'}, - {'value': 'udp', 'label': 'UDP'}, - {'value': 'both', 'label': 'TCP/UDP'}, - ]), - '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') - }), - } + tokens['PROTOCOL_OPTIONS'] = json.dumps([ + {'value': 'tcp', 'label': 'TCP'}, + {'value': 'udp', 'label': 'UDP'}, + {'value': 'both', 'label': 'TCP/UDP'}, + ]) + tokens['VLAN_FILTER_OPTIONS'] = filter_opts + tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names]) + tokens['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') + }) + 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 diff --git a/docker/routlin-dash/app/pages/preferences/view.py b/docker/routlin-dash/app/pages/preferences/view.py index aface8d..fccd95e 100644 --- a/docker/routlin-dash/app/pages/preferences/view.py +++ b/docker/routlin-dash/app/pages/preferences/view.py @@ -1,12 +1,13 @@ import json from flask import session import sanitize +from config_utils import collect_layout_tokens def collect_tokens(cfg): + tokens = collect_layout_tokens(cfg) blank = [{'value': '', 'label': '-- Select timezone --'}] - return { - 'PREF_EMAIL': session.get('email_address', ''), - 'PREF_TIMEZONE': session.get('timezone', ''), - 'TIMEZONE_OPTIONS': json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]), - } + tokens['PREF_EMAIL'] = session.get('email_address', '') + tokens['PREF_TIMEZONE'] = session.get('timezone', '') + tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) + return tokens diff --git a/docker/routlin-dash/app/pages/radius/view.py b/docker/routlin-dash/app/pages/radius/view.py index ace5815..8b1b882 100644 --- a/docker/routlin-dash/app/pages/radius/view.py +++ b/docker/routlin-dash/app/pages/radius/view.py @@ -1,11 +1,11 @@ import os -from view_common import CONFIGS_DIR +from config_utils import collect_layout_tokens, CONFIGS_DIR RADIUS_LOG_MAX = 50 RADIUS_LOG_FILE = '/var/log/freeradius/radius.log' -def _radius_log_tail(cfg): +def radius_log_tail(cfg): try: log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024) size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024 @@ -32,7 +32,7 @@ def _radius_log_tail(cfg): def collect_tokens(cfg): - tokens = {} + tokens = collect_layout_tokens(cfg) try: tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip() except OSError: @@ -44,5 +44,5 @@ def collect_tokens(cfg): tokens['RADIUS_APPLY_TO'] = fr_opts.get('apply_to', 'all') 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_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = _radius_log_tail(cfg) + tokens['RADIUS_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = radius_log_tail(cfg) return tokens diff --git a/docker/routlin-dash/app/pages/vpn/view.py b/docker/routlin-dash/app/pages/vpn/view.py index 8f9e05e..935d88b 100644 --- a/docker/routlin-dash/app/pages/vpn/view.py +++ b/docker/routlin-dash/app/pages/vpn/view.py @@ -1,7 +1,29 @@ 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): + tokens = collect_layout_tokens(cfg) vlans = cfg.get('vlans', []) wg_vlans_list = sorted( [v for v in vlans if v.get('is_vpn')], @@ -23,15 +45,19 @@ def collect_tokens(cfg): except Exception: vpn_gateway = '' - return { - 'VPN_VLAN_OPTIONS': json.dumps([ - {'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id") or "?"})'} - for i, v in enumerate(wg_vlans_list) - ]), - 'VPN_LISTEN_PORT': str(vpn.get('listen_port', '')), - 'VPN_SERVER_ENDPOINT': str(vpn.get('server_endpoint', '')), - 'VPN_DOMAIN': str(vpn.get('domain', '')), - 'VPN_DNS_SERVER': str(overrides.get('dns_servers', '')), - 'VPN_MTU': str(overrides.get('mtu', '')), - 'VPN_GATEWAY': vpn_gateway, - } + tokens['VPN_VLAN_OPTIONS'] = json.dumps([ + {'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id") or "?"})'} + for i, v in enumerate(wg_vlans_list) + ]) + tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', '')) + tokens['VPN_SERVER_ENDPOINT'] = str(vpn.get('server_endpoint', '')) + tokens['VPN_DOMAIN'] = str(vpn.get('domain', '')) + tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_servers', '')) + tokens['VPN_MTU'] = str(overrides.get('mtu', '')) + tokens['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 diff --git a/docker/routlin-dash/app/view_common.py b/docker/routlin-dash/app/view_common.py deleted file mode 100644 index 7c1dc7d..0000000 --- a/docker/routlin-dash/app/view_common.py +++ /dev/null @@ -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'{e(display)}' - 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//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'{e(res_h)}
({e(device_h)})' - elif res_h: - hostname_html = f'{e(res_h)}' - 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'] = ( - '
' - f'U: {e(p.get("username", "-"))}
' - 'P: ••••••
' - ) - elif ptype in ('cloudflare', 'duckdns'): - tok = p.get('api_token', '') - row['credentials'] = f'API Token: {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'
{WEB_APP_DISPLAY_NAME}
' - navbar_html = build_navbar(view_id, level, tokens, pending_alert=has_pending_alert) - footer_html = f'
{WEB_APP_DISPLAY_NAME}
' - - 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'
{text}
\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 Apply Now below to fix.' - if view_id == 'actions' else - 'Fix pending. Visit the Actions page ASAP to apply fix.' - ) - if problems: - problems_list = ( - '' - ) - 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'
{fix_suffix}
' - if fix_suffix else '' - ) - content = ( - '
' - '
Health check - problems found:
' - + problems_list + fix_html - + '
' - ) - problem_bars += f'
{content}
\n' - except Exception: - pass - - pending_bar = '' - if has_pending_alert and not problem_bars and view_id != 'actions': - pending_bar = ( - '
' - 'You have actions pending. Please visit the Actions page.' - '
\n' - ) - - return ( - '\n\n\n' - ' \n' - ' \n' - f' {WEB_APP_DISPLAY_NAME}\n' - f' \n' - '\n\n' - f'{titlebar_html}\n' - f'{navbar_html}\n' - f'
\n{pending_bar}{problem_bars}{other_bars}{content_html}\n
\n' - f'{footer_html}\n' - f'\n' - f'\n' - '\n' - ) - - -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 ( - '' - ) - - -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'
' - f'
' - ) - if map_to: - return f'{label}' - return f'{label}' - - 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 ( - '' - ) - 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('/') -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'
{msg_html}
' - - 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) diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py deleted file mode 100644 index 505eaea..0000000 --- a/docker/routlin-dash/app/view_page.py +++ /dev/null @@ -1,1296 +0,0 @@ -from flask import Blueprint, session, redirect, get_flashed_messages -from markupsafe import Markup -import json, re, subprocess, os, sys, glob -import sanitize -import validation as validate -from datetime import datetime, timezone -from config_utils import config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_all_groups, revert_group, 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, build_snap_val, snap_expand_row -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'{e(display)}' - 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_page] 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 helper ====================================================== - -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 - # DEBUG - # if not perm_mac: perm_mac = 'de:ad:be:ef:f0:0d' - 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//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}' - - -# 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'{e(res_h)}
({e(device_h)})' - elif res_h: - hostname_html = f'{e(res_h)}' - 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 _vlan_name_for_ip(ip): - import ipaddress - for vlan in load_config().get('vlans', []): - subnet = vlan.get('subnet', '') - mask = vlan.get('subnet_mask', 24) - if not subnet: - continue - try: - if ipaddress.ip_address(ip) in ipaddress.ip_network(f'{subnet}/{mask}', strict=False): - return vlan.get('name', '-') - except Exception: - pass - return '-' - -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 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 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' - - -# Config data loaders =============================================== - -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'] = ( - '
' - f'U: {e(p.get("username", "-"))}
' - 'P: ••••••
' - ) - elif ptype in ('cloudflare', 'duckdns'): - tok = p.get('api_token', '') - row['credentials'] = f'API Token: {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 - -# Live stat helpers ================================================= - -def get_dnsmasq_stats(): - stats = {'queries': '-', 'hits': '-', 'hit_rate': '-', - 'forwarded': '-', 'auth': '-', 'tcp_peak': '-'} - out = run('journalctl -u dnsmasq -n 200 --no-pager 2>/dev/null') - for line in reversed(out.splitlines()): - if 'queries forwarded' in line: - m = re.search(r'queries forwarded (\d+)', line) - if m: stats['forwarded'] = m.group(1) - m = re.search(r'queries answered locally (\d+)', line) - if m: stats['hits'] = m.group(1) - fwd = int(stats['forwarded']) if stats['forwarded'] != '-' else 0 - hit = int(stats['hits']) if stats['hits'] != '-' else 0 - total = fwd + hit - stats['queries'] = str(total) if total else '-' - if total > 0: - stats['hit_rate'] = f'{hit / total * 100:.0f}%' - break - if 'auth answered' in line: - m = re.search(r'auth answered (\d+)', line) - if m and stats['auth'] == '-': - stats['auth'] = m.group(1) - if 'max TCP connections' in line: - m = re.search(r'max TCP connections (\d+)', line) - if m and stats['tcp_peak'] == '-': - stats['tcp_peak'] = m.group(1) - return stats - -def _count_blocked_today(): - out = run("journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c 'is NXDOMAIN'") - return out or '0' - -def _count_blocked_domains(): - bl_dir = BLOCKLISTS_DIR - try: - total = sum( - int(run(f'wc -l < "{bl_dir}/{f}"') or 0) - for f in os.listdir(bl_dir) if f.endswith('.con') - ) - return str(total) - except Exception: - return '-' - -def _bl_last_update(): - bl_dir = BLOCKLISTS_DIR - try: - mtime = max( - os.path.getmtime(f'{bl_dir}/{f}') - for f in os.listdir(bl_dir) if f.endswith('.con') - ) - return fmt_timestamp(int(mtime)) - except Exception: - return '-' - -def _blocklist_stats_html(cfg): - bl_dir = BLOCKLISTS_DIR - rows = '' - for bl in cfg.get('dns_blocking', {}).get('blocklists', []): - name = e(bl.get('name', '')) - save_as = bl.get('save_as', '') - bl_path = f'{bl_dir}/{save_as}' if save_as else '' - try: - with open(bl_path) as f: - entries = sum(1 for _ in f) - mtime = int(os.path.getmtime(bl_path)) - size_str = fmt_bytes(os.path.getsize(bl_path)) - last_refreshed = f'{datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")} ({relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago)' - except Exception: - entries, size_str, last_refreshed = '-', '-', 'Never' - rows += ( - '' - f'{name}' - f'{entries}' - f'{size_str}' - f'{e(last_refreshed)}' - '' - ) - if not rows: - return '' - return ( - '' - '' - '' - '' - '' - '' - '' - f'{rows}' - '
BlocklistEntriesSizeLast Refreshed
' - ) - - -DDNS_LOG_MAX = 50 -RADIUS_LOG_MAX = 50 -RADIUS_LOG_FILE = '/var/log/freeradius/radius.log' - -def _radius_log_tail(): - try: - cfg = load_config() - log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024) - size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024 - with open(RADIUS_LOG_FILE) as f: - lines = f.readlines() - if not lines: - return '(log is empty)', '' - total = len(lines) - tail = lines[-RADIUS_LOG_MAX:] - shown = len(tail) - hidden = total - shown - pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0 - left = f'Showing {shown} of {total} lines ({hidden} not shown)' if hidden > 0 else f'Showing {shown} of {total} lines' - right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)' - summary = ( - '
' - f'{left}{right}
' - ) - return ''.join(tail).strip(), summary - except FileNotFoundError: - return '(log file not found)', '' - except Exception: - return '(error reading log)', '' - - -def _ddns_log_tail(): - log_path = f'{CONFIGS_DIR}/ddns.log' - try: - log_max_kb = load_ddns().get('general', {}).get('log_max_kb', 1024) - size_kb = os.path.getsize(log_path) / 1024 - with open(log_path) as f: - lines = f.readlines() - if not lines: - return '(log is empty)', '' - total = len(lines) - tail = lines[-DDNS_LOG_MAX:] - shown = len(tail) - hidden = total - shown - pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0 - left = f'Showing {shown} of {total} lines ({hidden} not shown)' if hidden > 0 else f'Showing {shown} of {total} lines' - right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)' - summary = ( - '
' - f'{left}{right}
' - ) - return ''.join(tail).strip(), summary - except FileNotFoundError: - return '(log file not found)', '' - except Exception: - return '(error reading log)', '' - -def _fmt_seconds(secs): - secs = int(secs) - if secs < 60: - return f'{secs}s' - m, s = divmod(secs, 60) - if m < 60: - return f'{m}m {s}s' if s else f'{m}m' - h, m = divmod(m, 60) - return f'{h}h {m}m' if m else f'{h}h' - -def _parse_interval_to_seconds(s): - m = re.match(r'^(\d+)([mhd])$', str(s).strip()) - if not m: - return None - val, unit = int(m.group(1)), m.group(2) - return val * {'m': 60, 'h': 3600, 'd': 86400}[unit] - -def _parse_time_remaining(text): - for line in text.splitlines(): - if 'Trigger:' in line: - total, found = 0, False - for amt, unit in re.findall(r'(\d+)\s*(day|h|min|s)\b', line): - total += int(amt) * {'day': 86400, 'h': 3600, 'min': 60, 's': 1}[unit] - found = True - if found: - return total - return None - -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, next_interval_str, 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: ---' - -def _vpn_info(): - for vlan in load_config().get('vlans', []): - if 'vpn_information' in vlan: - return vlan['vpn_information'] - return {} - - -# Token collection ================================================== - -def collect_tokens(): - tokens = {} - cfg = load_config() - net = cfg.get('network_interfaces', {}) - dns_blk_gen = cfg.get('dns_blocking', {}).get('general', {}) - dns = cfg.get('upstream_dns', {}) - vlans = cfg.get('vlans', []) - tokens['GENERAL_WAN_INTERFACE'] = str(net.get('wan_interface', '-')) - tokens['GENERAL_LAN_INTERFACE'] = str(net.get('lan_interface', '-')) - tokens['GENERAL_WAN_STATUS'] = iface_status(net.get('wan_interface', '')) - tokens['GENERAL_LAN_STATUS'] = iface_status(net.get('lan_interface', '')) - tokens['GENERAL_LOG_MAX_KB'] = str(dns_blk_gen.get('log_max_kb', '-')) - sys_ifaces = get_system_interfaces() - - # Always include currently-configured values so dropdowns are never blank. - for configured in [net.get('wan_interface', ''), net.get('lan_interface', '')]: - if configured and configured not in sys_ifaces: - sys_ifaces.append(configured) - sys_ifaces.sort() - tokens['NETWORK_INTERFACE_OPTIONS'] = json.dumps( - [{'value': i, 'label': i} for i in sys_ifaces] - ) - tokens['NETWORK_INTERFACE_STATUS_OPTIONS'] = json.dumps( - [{'value': i, 'label': f'{i} - {iface_status(i).title()}'} for i in sys_ifaces] - ) - iface_data = [iface_info(i) for i in sys_ifaces] - tokens['NETWORK_INTERFACE_DATA_JSON'] = json.dumps(iface_data) - max_speed_len = max( - (len(str(d.get('speed') or '')) for d in iface_data), - default=len('Speed') - ) - tokens['NETWORK_INTERFACE_STATS_SPEED_PAD'] = str(max(max_speed_len, len('Speed'))) - tokens['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', '-')) - tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if session.get('apply_changes_immediately', False) else 'false' - - # Queue health fix before building the pending table so it appears immediately. - _level = client_level() - if _level >= LEVEL_RANK['administrator']: - try: - import json as _hj - _st = _hj.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 - - all_groups = load_all_groups() # [(group_dict, [change_dicts])] - _group_uuid_set = {g['uuid'] for g, _ in all_groups} - pending_items = get_dashboard_pending() - if pending_items: - from collections import defaultdict - _pgroups = defaultdict(list) - for _uuid, _ts, cmd, user in pending_items: - _pgroups[cmd].append((_uuid, user)) - rows = '' - for cmd, entries in _pgroups.items(): - users = ', '.join(sorted({u for _, u in entries if u and u != 'unknown'})) - snap_uuids = [_uuid for _uuid, _ in entries if _uuid in _group_uuid_set] - if snap_uuids: - req_tags = ''.join( - f'' - f'{_uuid[:8]}' - f'{_uuid[:8]}' - f'{_uuid[:8]}' - '' - for _uuid in snap_uuids - ) - req_cell = f'
{req_tags}
' - else: - req_cell = '-' - rows += ( - '' - f'{e(cmd)}' - f'{e(users)}' - f'{req_cell}' - '' - ) - pending_html = ( - '' - '' - '' - '' - '' - '' - f'{rows}' - '
CommandUserRequired By
' - ) - else: - pending_html = '

No pending actions.

' - - tokens['PENDING_ACTIONS_HTML'] = pending_html - 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['APPLY_WARNING'] = ( - f'

{_load_icon("arrow-left")} Applying actions will briefly disrupt connections as network services are restarted.

' - if pending_items else '' - ) - done_ts_map = get_done_timestamps() - if all_groups: - _no_revert = set() - for g, _ in all_groups: - if g['reverts_group']: - _no_revert.add(g['uuid']) - _no_revert.add(g['reverts_group']) - hist_rows = '' - _hist_onclick = ( - 'onclick="if(event.target.type!==\'checkbox\')' - 'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"' - ) - for g, changes in all_groups: - _uuid = g['uuid'] - applied_ts = done_ts_map.get(_uuid) - dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') if applied_ts else '-' - all_before_null = all(c['before'] is None for c in changes) - all_after_null = all(c['after'] is None for c in changes) - if g['reverts_group']: - verb = 'Reverted' - elif all_before_null: - verb = 'Added' - elif all_after_null: - verb = 'Deleted' - else: - verb = 'Edited' - item = g.get('item_value') or '' - summary = f'{verb} {g["parent_path"]}: {item}' if item else f'{verb} {g["parent_path"]}' - snap_tag = ( - f'
' - f'{e(_uuid[:8])}' - f'{e(_uuid[:8])}' - f'{e(_uuid[:8])}' - '
' - ) - snap_user = e(g.get('user', '')) - _cb_attrs = 'disabled title="Cannot revert"' if _uuid in _no_revert else '' - hist_rows += ( - f'' - f'' - f'{e(dt_str)}' - f'{e(summary)}' - f'{build_snap_val(changes)}' - f'{snap_tag}' - f'{snap_user}' - '' - f'{snap_expand_row(changes, 6)}' - ) - select_all = ( - '' - ) - history_html = ( - '' - '' - f'' - '' - '' - '' - '' - '' - '' - f'{hist_rows}' - '
{select_all}AppliedChangeFieldsChange IDUser
' - ) - else: - history_html = '

No change history.

' - - tokens['CHANGE_HISTORY_HTML'] = history_html - tokens['NO_HISTORY'] = 'true' if not all_groups else '' - - servers = dns.get('upstream_servers', []) - tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false' - tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-')) - tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers) - tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(servers) or '-' - - non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')] - vlan_names = [v.get('name', '') for v in vlans] - tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-' - tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans)) - tokens['STAT_LEASE_COUNT'] = str(len(live_dhcp_leases())) - filter_opts = '' + ''.join( - f'' for n in vlan_names - ) - tokens['VLAN_FILTER_OPTIONS'] = filter_opts - tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names]) - tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn'))) - _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')] - tokens['RESERVATION_IPS_BY_VLAN_JSON'] = json.dumps(_res_ips_by_vlan) - tokens['RESERVATION_HOSTNAMES_BY_VLAN_JSON'] = json.dumps(_res_hosts_by_vlan) - tokens['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') - }) - tokens['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]) - _dv = next((v for v in vlans if v.get('radius_default')), None) - tokens['RADIUS_DEFAULT_VLAN'] = f'"{_dv["name"]}" (VLAN {_dv["vlan_id"]})' if _dv else 'none set' - try: - tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip() - except OSError: - tokens['RADIUS_SECRET'] = '(Generation is pending - visit Actions to apply generation command)' - _fr = cfg.get('radius', {}) - _fr_opts = _fr.get('options', {}) - _fr_gen = _fr.get('general', {}) - tokens['RADIUS_MAC_FORMAT'] = _fr_opts.get('mac_format', 'aabbccddeeff') - tokens['RADIUS_APPLY_TO'] = _fr_opts.get('apply_to', 'all') - 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_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = _radius_log_tail() - tokens['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', []))) - tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(cfg) - - ddns = load_ddns() - ddns_gen = ddns.get('general', {}) - tokens['DDNS_TIMER_INTERVAL'] = ddns_gen.get('timer_interval', '-') - _interval_secs = _parse_interval_to_seconds(ddns_gen.get('timer_interval', '')) or 600 - tokens['DDNS_TIMER_INTERVAL_MINS'] = str(_interval_secs // 60) - tokens['DDNS_GEN_LOG_MAX_KB'] = str(ddns_gen.get('log_max_kb', 1024)) - tokens['DDNS_GEN_LOG_ERRORS_ONLY'] = 'true' if ddns_gen.get('log_errors_only') else 'false' - enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)] - tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p)) - _ip_check = ddns.get('ip_check_services', []) - _http_svc = [s['url'] for s in _ip_check if s.get('type') == 'http'] - _dig_svc = [s['url'] for s in _ip_check if s.get('type') == 'dig'] - tokens['STAT_IP_CHECK_TOTAL'] = str(len(_ip_check)) - tokens['STAT_IP_CHECK_SUB'] = f'{len(_http_svc)} http and {len(_dig_svc)} dig' - tokens['IP_CHECK_HTTP_JSON'] = json.dumps(_http_svc) - tokens['IP_CHECK_DIG_JSON'] = json.dumps(_dig_svc) - _ddns_labels = {'noip': 'No-IP', 'cloudflare': 'Cloudflare', 'duckdns': 'DuckDNS'} - tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([ - {'value': p, 'label': _ddns_labels.get(p, p.title())} - for p in validate.VALID_DDNS_PROVIDERS - ]) - - wg_vlans_list = 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) - ) - tokens['VPN_VLAN_OPTIONS'] = json.dumps([ - {'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id") or "?"})'} - for i, v in enumerate(wg_vlans_list) - ]) - wg_vlan = wg_vlans_list[0] if wg_vlans_list else {} - vpn = wg_vlan.get('vpn_information', {}) - overrides = vpn.get('explicit_overrides', {}) - tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', '')) - tokens['VPN_SERVER_ENDPOINT'] = str(vpn.get('server_endpoint', '')) - tokens['VPN_DOMAIN'] = str(vpn.get('domain', '')) - tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_servers', '')) - tokens['VPN_MTU'] = str(overrides.get('mtu', '')) - - # Compute gateway from server_identities (lowest last-octet), fallback to first subnet host - try: - import ipaddress as _ipaddress - ident_ips = [s['ip'] for s in wg_vlan.get('server_identities', []) if s.get('ip')] - if ident_ips: - default_gw = str(min((_ipaddress.IPv4Address(ip) for ip in ident_ips), - key=lambda x: x.packed[-1])) - else: - wg_net = _ipaddress.IPv4Network( - f"{wg_vlan['subnet']}/{wg_vlan['subnet_mask']}", strict=False) - default_gw = str(next(wg_net.hosts())) - tokens['VPN_GATEWAY'] = overrides.get('gateway') or default_gw - except Exception: - tokens['VPN_GATEWAY'] = '' - - ip_str, sub_str, next_interval, last_obtained = _public_ip_info(ddns) - tokens['STAT_PUBLIC_IP'] = ip_str - tokens['STAT_DDNS_HOSTNAME'] = sub_str - tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval - tokens['STAT_PUBLIC_IP_LAST_OBTAINED'] = last_obtained - tokens['STAT_PUBLIC_IP_LAST_CHECKED'] = _ddns_last_checked() - tokens['DDNS_LOG_TAIL'], tokens['DDNS_LOG_SUMMARY'] = _ddns_log_tail() - tokens['STAT_UPTIME'] = run('uptime -p') or '-' - tokens['STAT_NFTABLES_STATUS'] = 'Active' if run('nft list tables 2>/dev/null').strip() else 'Inactive' - - dns_stats = get_dnsmasq_stats() - tokens['DNS_STAT_QUERIES'] = dns_stats['queries'] - tokens['DNS_STAT_HITS'] = dns_stats['hits'] - tokens['DNS_STAT_HIT_RATE'] = dns_stats['hit_rate'] - tokens['DNS_STAT_FORWARDED'] = dns_stats['forwarded'] - tokens['DNS_STAT_AUTH'] = dns_stats['auth'] - tokens['DNS_STAT_TCP_PEAK'] = dns_stats['tcp_peak'] - tokens['STAT_BLOCKED_TODAY'] = _count_blocked_today() - tokens['STAT_BLOCKED_DOMAINS'] = _count_blocked_domains() - tokens['STAT_BL_LAST_UPDATE'] = _bl_last_update() - tokens['PREF_EMAIL'] = session.get('email_address', '') - tokens['PREF_TIMEZONE'] = session.get('timezone', '') - - blank = [{'value': '', 'label': '-- Select timezone --'}] - tokens['TIMEZONE_OPTIONS'] = json.dumps( - blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES] - ) - tokens['PROTOCOL_OPTIONS'] = json.dumps([ - {'value': 'tcp', 'label': 'TCP'}, - {'value': 'udp', 'label': 'UDP'}, - {'value': 'both', 'label': 'TCP/UDP'}, - ]) - tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([ - {'value': 'hosts', 'label': 'hosts (hosts file format)'}, - {'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'}, - ]) - tokens['BLOCKLIST_NAME_OPTIONS'] = json.dumps([ - {'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))} - for bl in cfg.get('dns_blocking', {}).get('blocklists', []) - ]) - tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([ - {'value': 'viewer', 'label': 'Viewer (read-only access to live data)'}, - {'value': 'administrator', 'label': 'Administrator (can modify configuration)'}, - {'value': 'manager', 'label': 'Manager (full access including account management)'}, - ]) - - return tokens - - -# 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'
{WEB_APP_DISPLAY_NAME}
' - navbar_html = build_navbar(view_id, level, tokens, pending_alert=has_pending_alert) - footer_html = f'
{WEB_APP_DISPLAY_NAME}
' - - 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'
{text}
\n' - - problem_bars = '' - if level >= LEVEL_RANK['viewer']: - try: - import json as _j - st = _j.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 Apply Now below to fix.' - if view_id == 'actions' else - 'Fix pending. Visit the Actions page ASAP to apply fix.') - if problems: - problems_list = ('
    ' - + ''.join(f'
  • {d}
  • ' for d in problems) - + '
') - 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'
{fix_suffix}
' - if fix_suffix else '') - content = ('
' - '
Health check - problems found:
' - + problems_list + fix_html - + '
') - problem_bars += f'
{content}
\n' - except Exception: - pass - - pending_bar = '' - if has_pending_alert and not problem_bars and view_id != 'actions': - pending_bar = ( - '
' - 'You have actions pending. Please visit the Actions page.' - '
\n' - ) - - return ( - '\n\n\n' - ' \n' - ' \n' - f' {WEB_APP_DISPLAY_NAME}\n' - f' \n' - '\n\n' - f'{titlebar_html}\n' - f'{navbar_html}\n' - f'
\n{pending_bar}{problem_bars}{other_bars}{content_html}\n
\n' - f'{footer_html}\n' - f'\n' - f'\n' - '\n' - ) - - -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 ( - '' - ) - - -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'
' - f'
' - ) - if map_to: - return f'{label}' - return f'{label}' - - 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 ( - '' - ) - 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 '') - - -# Routes ============================================================ - -@bp.route('/') -def index(): - return serve_view('overview') - -@bp.route('/') -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 view_def is None: - 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') - - tokens = collect_tokens() - - if page_name == 'radius' and not os.path.exists(f'{CONFIGS_DIR}/.radius-secret'): - from config_utils import queue_command - 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'
{msg_html}
' - - 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)