diff --git a/docker/routlin-dash/app/action_apply_dhcp_reservations.py b/docker/routlin-dash/app/action_apply_dhcp_reservations.py index 512e278..dddba22 100644 --- a/docker/routlin-dash/app/action_apply_dhcp_reservations.py +++ b/docker/routlin-dash/app/action_apply_dhcp_reservations.py @@ -57,7 +57,7 @@ def _check_ip_conflicts(ip, vlan): try: if (ipaddress.IPv4Address(pool_start) <= ipaddress.IPv4Address(ip) <= ipaddress.IPv4Address(pool_end)): - return f'{ip} falls within the dynamic pool range ({pool_start}–{pool_end}).' + return f'{ip} falls within the dynamic pool range ({pool_start}-{pool_end}).' except Exception: pass identity_ips = {s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')} diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index dd134ce..401d1af 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -14,7 +14,7 @@ CONFIGS_DIR = '/configs' LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3} -# -- Access level -------------------------------------------------------------- +# Access level ====================================================== def _client_level(): return LEVEL_RANK.get(session.get('access_level', 'nothing'), 0) @@ -36,7 +36,7 @@ def _passes(req, level): return False -# -- File loaders -------------------------------------------------------------- +# File loaders ====================================================== def _load_json(path): try: @@ -58,7 +58,7 @@ def _load_css(): return '' -# -- Shell helper -------------------------------------------------------------- +# Shell helper ====================================================== def _run(cmd): try: @@ -166,7 +166,7 @@ def _resolve_iface(vlan, core): return lan if vid == 1 else f'{lan}.{vid}' -# -- Live data loaders --------------------------------------------------------- +# Live data loaders ================================================= def _live_dhcp_leases(): rows = [] @@ -233,7 +233,7 @@ def _fmt_bytes(n): return f'{n:.1f} TB' -# -- Config data loaders ------------------------------------------------------- +# Config data loaders =============================================== def _config_datasource(name): core = _load_core() @@ -307,7 +307,7 @@ def _config_datasource(name): row['credentials'] = f"U: {p.get('username', '-')}" elif ptype in ('cloudflare', 'duckdns'): tok = p.get('api_token', '') - row['credentials'] = f'API Token: {tok[:8]}…' if tok else '(not set)' + row['credentials'] = f'API Token: {tok[:8]}...' if tok else '(not set)' else: row['credentials'] = '-' row['hostnames'] = json.dumps(p.get('hostnames', p.get('subdomains', []))) @@ -343,7 +343,7 @@ def _config_datasource(name): return [] -# -- Live stat helpers --------------------------------------------------------- +# Live stat helpers ================================================= def _get_dnsmasq_stats(): stats = {'queries': '-', 'hits': '-', 'hit_rate': '-', @@ -492,7 +492,7 @@ def _vpn_info(): return {} -# -- Token collection ---------------------------------------------------------- +# Token collection ================================================== def collect_tokens(): tokens = {} @@ -650,7 +650,7 @@ def collect_tokens(): return tokens -# -- HTML helpers -------------------------------------------------------------- +# HTML helpers ====================================================== def e(text): return html_mod.escape(str(text)) @@ -686,7 +686,7 @@ def _expand_fields(obj, tokens): return obj -# -- Content item renderers ---------------------------------------------------- +# Content item renderers ============================================ def render_items(items, tokens, inherited_req=None): level = _client_level() @@ -1325,7 +1325,7 @@ def _load_datasource(spec): return [] -# -- Layout renderer ----------------------------------------------------------- +# Layout renderer =================================================== def render_layout(view_id, content_html, tokens): css = _load_css() @@ -1433,7 +1433,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req= return '' -# -- Inline JavaScript --------------------------------------------------------- +# Inline JavaScript ================================================= def _inline_js(): return r""" @@ -1988,7 +1988,7 @@ var validateEl; range: 'Octet out of range' }, url: { invalid_char: 'Invalid character', invalid_struct: 'Invalid URL format' }, port: { invalid_char: 'Digits only', out_of_range: 'Must be between 1 and 65535' }, - ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 0–32', + ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 0-32', invalid_range: 'Octet out of range' }, endpoint: { invalid_char: 'Invalid character', invalid_struct: 'Invalid hostname or IP', invalid_range: 'Octet out of range', invalid: 'Invalid IP address' }, @@ -2027,7 +2027,7 @@ var validateEl; } }; - // Regular fields (not inside editable lists) — initial state + expose _triggerValidate + // Regular fields (not inside editable lists) - initial state + expose _triggerValidate document.querySelectorAll('input[data-validate]').forEach(function(el) { if (el.closest('.editable-list')) return; el._triggerValidate = function() { validateEl(el); }; @@ -2072,7 +2072,7 @@ var validateEl; submitBtns.forEach(function(b) { b.disabled = true; }); cancelBtns.forEach(function(b) { b.disabled = true; }); - // Only track fields named in original — naturally excludes config_hash, + // Only track fields named in original - naturally excludes config_hash, // row_index, etc., while including hidden inputs (e.g. picker values). function snapshot() { var state = {}; @@ -2342,7 +2342,7 @@ function startApplyPoller(uuid, bar, mine) { """ -# -- Routes -------------------------------------------------------------------- +# Routes ============================================================ @bp.route('/') def index(): diff --git a/routlin/core.py b/routlin/core.py index 3e1e229..9bad2ef 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -2556,18 +2556,18 @@ def disable_all(data): subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) print("systemd daemon reloaded.") print() - print("-- Removing nftables rules -------------------------------------------") + print("Removing nftables rules =============================================") delete_our_tables() remove_nat_service() if radius_enabled(data): print() - print("-- Stopping RADIUS ---------------------------------------------------") + print("Stopping RADIUS =====================================================") subprocess.run(["systemctl", "disable", "--now", "freeradius"], capture_output=True, text=True) print("freeradius stopped and disabled.") if avahi_enabled(data): print() - print("-- Stopping mDNS Reflector -------------------------------------------") + print("Stopping mDNS Reflector =============================================") disable_avahi() def _write_client_network(iface, dhcp, static_cidr=None): @@ -2667,7 +2667,7 @@ def _svc_enabled(unit): return r.stdout.strip() in ("enabled", "enabled-runtime") def _dry_run_conflicting_services(data): - print("-- Conflicting services (dry-run) ------------------------------------") + print("Conflicting services (dry-run) ======================================") for unit, label in [("systemd-resolved", "systemd-resolved"), ("systemd-timesyncd", "systemd-timesyncd")]: @@ -2727,7 +2727,7 @@ def _dry_run_conflicting_services(data): print(f" /etc/resolv.conf already points to {gw} - no change needed") def _dry_run_blocklists(data): - print("-- Blocklists (dry-run) ----------------------------------------------") + print("Blocklists (dry-run) ================================================") for entry in data.get("blocklists", []): print(f" Would download: {entry['description']}") print(f" URL: {entry['url']}") @@ -2744,7 +2744,7 @@ def _dry_run_blocklists(data): print(f" Sources: {', '.join(sorted(names))}") def _dry_run_timer(data): - print("-- Timer (dry-run) ---------------------------------------------------") + print("Timer (dry-run) =====================================================") general = data.get("general", {}) execute_time = general.get("daily_execute_time_24hr_local", "02:30") for path, label in [(BLIST_TIMER_FILE, "timer unit"), (BLIST_TIMER_SVC_FILE, "service unit")]: @@ -2753,7 +2753,7 @@ def _dry_run_timer(data): print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)") def _dry_run_boot_service(): - print("-- Boot service (dry-run) --------------------------------------------") + print("Boot service (dry-run) ==============================================") script_path = Path(__file__).resolve() action = "update" if NAT_SERVICE_FILE.exists() else "create and enable" print(f" Would {action}: {NAT_SERVICE_FILE}") @@ -2791,7 +2791,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, print(f" {NAT_SERVICE_NAME}.service: not installed - no action needed") print() - print("-- Restoring NTP client (dry-run) ------------------------------------") + print("Restoring NTP client (dry-run) ======================================") state = _svc_state("chrony") if state == "active": print(f" Would stop and disable: chrony (currently: active)") @@ -2805,7 +2805,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, print(f" systemd-timesyncd: not available on this system") print() - print("-- Network interface (dry-run) ----------------------------------------") + print("Network interface (dry-run) =========================================") router_net = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.network")) router_dev = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.netdev")) client_file = NETWORKD_DIR / f"10-client-{iface}.network" @@ -2822,7 +2822,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, print() if not resolv_ok: - print("-- DNS (dry-run) -----------------------------------------------------") + print("DNS (dry-run) =======================================================") if dns_choice == "resolved": print(" Would enable: systemd-resolved") print(" Would restore: /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf") @@ -2998,16 +2998,16 @@ def cmd_disable(data, dry_run=False): disable_all(data) print() - print("-- Restoring NTP client ----------------------------------------------") + print("Restoring NTP client ================================================") restore_ntp() print() - print("-- Configuring network interface -------------------------------------") + print("Configuring network interface =======================================") _write_client_network(iface, dhcp=use_dhcp, static_cidr=static_cidr) print() if not resolv_ok: - print("-- Configuring DNS ---------------------------------------------------") + print("Configuring DNS =====================================================") if dns_choice == "static": _configure_dns_static(static_nameserver) else: @@ -3045,13 +3045,13 @@ def cmd_apply(data, dry_run=False): print() _dry_run_conflicting_services(data) print() - print("-- systemd-networkd (dry-run) ----------------------------------------") + print("systemd-networkd (dry-run) ==========================================") apply_networkd(data, dry_run=True) print() - print("-- dnsmasq instances (dry-run) ---------------------------------------") + print("dnsmasq instances (dry-run) =========================================") apply_dnsmasq_instances(data, dry_run=True, start_if_needed=True) print() - print("-- nftables (dry-run) ------------------------------------------------") + print("nftables (dry-run) ==================================================") apply_nftables(data, dry_run=True) print() _dry_run_timer(data) @@ -3059,7 +3059,7 @@ def cmd_apply(data, dry_run=False): _dry_run_boot_service() if radius_enabled(data): print() - print("-- RADIUS (dry-run) --------------------------------------------------") + print("RADIUS (dry-run) ====================================================") num_clients = len(radius_clients(data)) default_vlan = next((v for v in data["vlans"] if v.get("radius_default") is True), None) total_macs = sum( @@ -3075,7 +3075,7 @@ def cmd_apply(data, dry_run=False): print(f" Would ensure freeradius is running") if avahi_enabled(data): print() - print("-- mDNS Reflection (dry-run) -----------------------------------------") + print("mDNS Reflection (dry-run) ===========================================") ifaces = avahi_interfaces(data) print(f" Would write: {AVAHI_CONF_FILE}") print(f" Reflecting across: {', '.join(ifaces)}") @@ -3098,67 +3098,67 @@ def cmd_apply(data, dry_run=False): f"{total_enabled} reservation(s), {total_disabled} skipped{wg_part}.") print() - print("-- Conflicting services ----------------------------------------------") + print("Conflicting services ================================================") disable_systemd_timesyncd() ensure_chrony(data) disable_ufw() print() - print("-- systemd-networkd --------------------------------------------------") + print("systemd-networkd ====================================================") apply_networkd(data, only_if_changed=True) print() if any(is_wg(v) for v in data["vlans"]): - print("-- WireGuard interfaces ----------------------------------------------") + print("WireGuard interfaces ================================================") ensure_wg_interfaces(data) print() - print("-- dnsmasq instances -------------------------------------------------") + print("dnsmasq instances ===================================================") if not blocklists_available(data): print(" NOTE: No merged blocklist files found -- blocklist rules will be absent.") print(" Run --update-blocklists to download and merge blocklists.") apply_dnsmasq_instances(data, start_if_needed=True) print() - print("-- nftables ----------------------------------------------------------") + print("nftables ============================================================") apply_nftables(data) print() - print("-- Timer -------------------------------------------------------------") + print("Timer ===============================================================") install_timer(data) print() - print("-- Dashboard timer ---------------------------------------------------") + print("Dashboard timer =====================================================") install_dashboard_timer() print() - print("-- Boot service ------------------------------------------------------") + print("Boot service ========================================================") install_nat_service() print() if radius_enabled(data): - print("-- RADIUS ------------------------------------------------------------") + print("RADIUS ==============================================================") apply_radius(data) print() else: svc = "freeradius" if subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True).stdout.strip() == "active": - print("-- RADIUS ------------------------------------------------------------") + print("RADIUS ==============================================================") subprocess.run(["systemctl", "disable", "--now", svc], capture_output=True, text=True) print("freeradius stopped and disabled (no radius_client reservations).") print() if avahi_enabled(data): - print("-- mDNS Reflection ---------------------------------------------------") + print("mDNS Reflection =====================================================") apply_avahi(data) print() else: svc = "avahi-daemon" if subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True).stdout.strip() == "active": - print("-- mDNS Reflection ---------------------------------------------------") + print("mDNS Reflection =====================================================") disable_avahi() print() @@ -3170,11 +3170,11 @@ def cmd_update_blocklists(data): cmd_apply to reload dnsmasq instances with the new blocklists. """ check_root() - print("-- Updating blocklists -----------------------------------------------") + print("Updating blocklists =================================================") success = update_blocklists(data) print() if success: - print("-- Applying updated configs ------------------------------------------") + print("Applying updated configs ============================================") cmd_apply(data) else: print("WARNING: Blocklist update had errors -- skipping --apply.") diff --git a/routlin/install.py b/routlin/install.py index bebd3ae..1b98ed5 100644 --- a/routlin/install.py +++ b/routlin/install.py @@ -228,7 +228,7 @@ def _set_env_var(content, key, value): replacement = rf"\g<1>{value}" new, count = re.subn(pattern, replacement, content, flags=re.MULTILINE) if count == 0: - # Key not found — shouldn't happen with our template, warn and move on + # Key not found; shouldn't happen with our template, warn and move on print(f" WARNING: could not find {key} in docker-compose.yml") return new @@ -242,6 +242,21 @@ def setup_docker_compose(): content = COMPOSE_FILE.read_text() + existing_key = re.search(r"^\s*- SECRET_KEY=(.+)$", content, re.MULTILINE) + if existing_key and existing_key.group(1).strip(): + print(" Dashboard is already configured.") + if not prompt_yn("Reconfigure? (generates a new SECRET_KEY, invalidates existing sessions)", default="n"): + print() + print(" Starting dashboard container...") + result = subprocess.run( + ["docker", "compose", "up", "-d", "--build"], + cwd=COMPOSE_FILE.parent, check=False + ) + if result.returncode != 0: + die("docker compose up failed. Check the output above.") + print(" Dashboard container started.") + return + print(" Generating SECRET_KEY...") secret_key = secrets.token_urlsafe(96) # ~128 chars @@ -363,8 +378,8 @@ def setup_caddy(domain, email): if CADDYFILE.exists(): existing = CADDYFILE.read_text() - if domain in existing: - print(f" {domain} is already present in {CADDYFILE} — skipping block.") + if f"127.0.0.1:{FLASK_PORT}" in existing: + print(f" Routlin block already present in {CADDYFILE}, skipping.") else: CADDYFILE.write_text(existing + block) print(f" Appended {domain} block to {CADDYFILE}")