From 91d8b950b7262427ee631dc0307f325a6d86a308 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Mon, 25 May 2026 21:49:47 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/config_utils.py | 4 ++-- docker/routlin-dash/app/view_page.py | 7 ++++-- routlin/create_vpn_peer.py | 14 +++++------ routlin/install.py | 16 ++++++------- routlin/validation.py | 32 ++++++++++++------------- 5 files changed, 38 insertions(+), 35 deletions(-) diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index eeb7ed8..568212c 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -290,7 +290,7 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'): return _build_timing_msg(entry_ts, cmd, action_label) -# ── Snapshot system ─────────────────────────────────────────────────────────── +# Snapshot system =================================================== def _pending_uuid_set(): return {item[0] for item in _read_dashboard_pending()} @@ -437,7 +437,7 @@ def save_config_with_snapshot(new_core, path, key, operation, before, after, return _build_timing_msg(entry_ts, cmd) -# ── Misc ────────────────────────────────────────────────────────────────────── +# Misc ============================================================== def run_apply(): try: diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 8cba09a..8434c95 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -931,7 +931,8 @@ def _render_item(item, tokens, inherited_req=None): if extra: cls = f'{cls} {extra}' text = e(apply_tokens(item.get('text', ''), tokens)) - action = e(apply_tokens(item.get('action', '#'), tokens)) + action_raw = item.get('action', '') + action = e(apply_tokens(action_raw, tokens)) disabled_val = apply_tokens(str(item.get('disabled', '')), tokens) disabled = ' disabled' if disabled_val and disabled_val not in ('false', '0') else '' formaction = item.get('formaction', '') @@ -941,7 +942,9 @@ def _render_item(item, tokens, inherited_req=None): if item.get('method', '').lower() == 'post': return (f'
' f'
') - return f'{text}' + if action_raw: + return f'{text}' + return f'' if t == 'button_cancel': text = e(apply_tokens(item.get('text', 'Cancel'), tokens)) diff --git a/routlin/create_vpn_peer.py b/routlin/create_vpn_peer.py index f05f6d5..76a2f6a 100644 --- a/routlin/create_vpn_peer.py +++ b/routlin/create_vpn_peer.py @@ -165,19 +165,19 @@ def main(): help="VLAN ID of the WireGuard VLAN to add the peer to (e.g. 40)") args = parser.parse_args() - # -- Validate IP ----------------------------------------------------------- + # Validate IP ======================================================= try: peer_ip = str(ipaddress.IPv4Address(args.ip)) except ValueError: die(f"'{args.ip}' is not a valid IPv4 address.") - # -- Load config and find WG VLAN ------------------------------------------ + # Load config and find WG VLAN ========================================== data = load_config() vlan = find_wg_vlan(data, iface=args.iface, vlan_id=args.vlan_id) iface = resolve_wg_iface(vlan, data) - # -- Validate peer IP is within subnet ------------------------------------- + # Validate peer IP is within subnet ================================= try: network = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False) except (KeyError, ValueError) as e: @@ -186,19 +186,19 @@ def main(): if ipaddress.IPv4Address(peer_ip) not in network: die(f"IP {peer_ip} is not within the VPN subnet {network}.") - # -- Check for duplicates -------------------------------------------------- + # Check for duplicates ============================================== peers = vlan.setdefault("peers", []) if any(p.get("name") == args.name for p in peers): die(f"A peer named '{args.name}' already exists.") if any(p.get("ip") == peer_ip for p in peers): die(f"IP {peer_ip} is already assigned to another peer.") - # -- Generate keypair and read server public key --------------------------- + # Generate keypair and read server public key ======================= print(f"Generating keypair for '{args.name}'...") private_key, public_key = generate_keypair() srv_pub = server_pubkey(iface) - # -- Update config.json ------------------------------------------------------ + # Update config.json ================================================ peers.append({ "name": args.name, "ip": peer_ip, @@ -209,7 +209,7 @@ def main(): save_config(data) print(f"Added peer '{args.name}' to config.json.") - # -- Write client conf ----------------------------------------------------- + # Write client conf ================================================= conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel) if args.output: out_path = Path(args.output) diff --git a/routlin/install.py b/routlin/install.py index cb9fb97..0135f46 100644 --- a/routlin/install.py +++ b/routlin/install.py @@ -545,7 +545,7 @@ def main(): print(" This wizard installs required packages and optionally") print(" sets up the web dashboard and external HTTPS access.") - # -- Package manager ------------------------------------------- + # Package manager =================================================== pm = detect_pm() if pm is None: print() @@ -557,11 +557,11 @@ def main(): print(f"\n Detected package manager: {pm}") pm_ok = True - # -- Core packages --------------------------------------------- + # Core packages ===================================================== if pm_ok: install_core_packages(pm) - # -- Dashboard ------------------------------------------------- + # Dashboard ========================================================= header("Dashboard (optional)") print(" The Routlin Dashboard is a web UI for managing the router.") print(" It runs as a Docker container. Without it, config.json must") @@ -596,7 +596,7 @@ def main(): default="y" ) - # -- Docker ---------------------------------------------------- + # Docker ============================================================ header("Docker") if pm_ok: install_docker(pm) @@ -605,15 +605,15 @@ def main(): else: print(" Docker is already installed.") - # -- docker-compose.yml ---------------------------------------- + # docker-compose.yml ================================================ setup_docker_compose(reuse_config=reuse_config) create_dotfiles() - # -- Dashboard timer ------------------------------------------- + # Dashboard timer =================================================== header("Dashboard Timer") install_dashboard_timer() - # -- External access ------------------------------------------- + # External access =================================================== header("External Access (optional)") ext_domain = _external_access_domain() if ext_domain: @@ -643,7 +643,7 @@ def main(): print(next_step) return - # -- Caddy ----------------------------------------------------- + # Caddy ============================================================= header("Caddy (HTTPS)") if pm_ok: install_caddy(pm) diff --git a/routlin/validation.py b/routlin/validation.py index a55657b..3e1139b 100644 --- a/routlin/validation.py +++ b/routlin/validation.py @@ -331,11 +331,11 @@ def validate_config(data): _vid = _derived_ids[i] vlan_ifaces.append(_lan if _vid == 1 else f"{_lan}.{_vid}") - # -- upstream_dns block ---------------------------------------------------- + # upstream_dns block ============================================ if not data.get("upstream_dns", {}).get("upstream_servers"): errors.append("upstream_dns.upstream_servers is missing or empty.") - # -- WAN / LAN interfaces -------------------------------------------------- + # WAN / LAN interfaces ========================================== gen = data.get("network_interfaces", {}) wan = gen.get("wan_interface", "") lan = gen.get("lan_interface", "") @@ -357,7 +357,7 @@ def validate_config(data): if wan == lan: errors.append(f"network_interfaces.wan_interface and network_interfaces.lan_interface must be different (both set to '{wan}').") - # -- Blocklist library ----------------------------------------------------- + # Blocklist library ============================================= blocklists_by_name = {} for idx, bl in enumerate(data.get("dns_blocking", {}).get("blocklists", [])): name = bl.get("name", "") @@ -373,7 +373,7 @@ def validate_config(data): else: blocklists_by_name[name] = bl - # -- Per-VLAN validation --------------------------------------------------- + # Per-VLAN validation =========================================== vlan_networks = {} # iface -> IPv4Network (used for NAT section) for i, (vlan, iface) in enumerate(zip(_all_vlans, vlan_ifaces)): @@ -403,7 +403,7 @@ def validate_config(data): errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.") if is_wg(vlan): - # -- vpn_information ----------------------------------------------- + # vpn_information ======================================= vpi = vlan.get("vpn_information") if not isinstance(vpi, dict): errors.append(f"{label}: vpn_information must be a plain object.") @@ -418,7 +418,7 @@ def validate_config(data): else: seen_listen_ports[lp] = name - # -- subnet/subnet_mask -------------------------------------------- + # subnet/subnet_mask ==================================== for field in ("subnet", "subnet_mask"): if not vlan.get(field): errors.append(f"{label}: missing required field '{field}'.") @@ -430,7 +430,7 @@ def validate_config(data): except ValueError as e: errors.append(f"{label}: invalid subnet/subnet_mask: {e}") - # -- server_identities --------------------------------------------- + # server_identities ===================================== if not vlan.get("server_identities"): errors.append(f"{label}: server_identities is empty or missing.") identity_ips = [] @@ -449,7 +449,7 @@ def validate_config(data): else: identity_ips.append(ip_addr) - # -- vpn_information.explicit_overrides ---------------------------- + # vpn_information.explicit_overrides ==================== eo = vpi.get("explicit_overrides", {}) if isinstance(vpi, dict) else {} if not isinstance(eo, dict): errors.append(f"{label}: vpn_information.explicit_overrides must be a plain object.") @@ -476,7 +476,7 @@ def validate_config(data): if domain_val and not domainname(domain_val): errors.append(f"{label}: vpn_information.domain '{domain_val}' is not a valid domain name.") - # -- peers --------------------------------------------------------- + # peers ================================================= seen_peer_names = {} seen_peer_ips = {} for pidx, peer in enumerate(vlan.get("peers", [])): @@ -556,7 +556,7 @@ def validate_config(data): if ip: identity_ips.append(ip) - # -- Validate explicit_overrides --------------------------------------- + # Validate explicit_overrides =============================== eo = d.get("explicit_overrides", {}) if not isinstance(eo, dict): errors.append(f"{label}: explicit_overrides must be a plain object.") @@ -642,7 +642,7 @@ def validate_config(data): if bl_name not in blocklists_by_name: errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.") - # -- NAT / firewall validation --------------------------------------------- + # NAT / firewall validation ===================================== valid_protos = VALID_PROTOCOLS known_interfaces = set(seen_interfaces.keys()) @@ -675,7 +675,7 @@ def validate_config(data): if net: nat_check_ip_in_network(f"{label} redirect_to", r.get("redirect_to", ""), net) - # -- port_forwarding validation (top-level) -------------------------------- + # port_forwarding validation (top-level) ======================== for idx, r in enumerate(data.get("port_forwarding", [])): desc = r.get("description", "?") label = f"port_forwarding[{idx}] '{desc}'" @@ -709,13 +709,13 @@ def validate_config(data): if r.get("dst_port") is not None: nat_check_port(f"{label} dst_port", r.get("dst_port")) - # -- radius_default uniqueness check --------------------------------------- + # radius_default uniqueness check =============================== defaults = [v["name"] for v in data.get("vlans", []) if v.get("radius_default") is True] if len(defaults) > 1: errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). " f"Only one VLAN may be the RADIUS default.") - # -- RADIUS requires multiple VLANs ---------------------------------------- + # RADIUS requires multiple VLANs ================================ non_wg_vlans = [v for v in data.get("vlans", []) if not is_wg(v)] has_radius_clients = any( r.get("radius_client") @@ -728,7 +728,7 @@ def validate_config(data): "Dynamic VLAN assignment requires at least two VLANs." ) - # -- host_overrides validation --------------------------------------------- + # host_overrides validation ===================================== all_vlan_nets = list(vlan_networks.values()) for idx, entry in enumerate(data.get("host_overrides", [])): lbl = f"host_overrides[{idx}] '{entry.get('host', '?')}'" @@ -744,7 +744,7 @@ def validate_config(data): if all_vlan_nets and not any(ip_addr in net for net in all_vlan_nets): errors.append(f"{lbl}: '{ip_str}' does not fall within any configured VLAN subnet.") - # -- banned_ips validation ------------------------------------------------- + # banned_ips validation ========================================= for idx, entry in enumerate(data.get("banned_ips", [])): ip_val = entry.get("ip", "") lbl = f"banned_ips[{idx}] '{entry.get('description', '')}'"