From 58ab569e42087db2d421e5cd7e667a4c19593526 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Fri, 5 Jun 2026 01:48:27 -0400 Subject: [PATCH] Development --- .../app/pages/bannedips/action.py | 2 +- docker/routlin-dash/app/pages/ddns/action.py | 2 +- docker/routlin-dash/app/pages/ddns/view.py | 2 +- .../app/pages/dhcpreservations/action.py | 2 +- .../app/pages/dnsblocking/action.py | 2 +- .../app/pages/dnsserver/action.py | 2 +- .../app/pages/hostoverrides/action.py | 2 +- .../app/pages/intervlan/action.py | 2 +- docker/routlin-dash/app/pages/mdns/action.py | 2 +- .../app/pages/networklayout/action.py | 2 +- .../app/pages/physicalinterfaces/action.py | 2 +- .../app/pages/portforwarding/action.py | 2 +- .../app/pages/portwrangling/action.py | 2 +- .../routlin-dash/app/pages/radius/action.py | 2 +- docker/routlin-dash/app/pages/vpn/action.py | 2 +- routlin/core.py | 2562 +---------------- routlin/health.py | 265 +- routlin/mod_avahi.py | 136 + routlin/mod_dnsmasq.py | 626 ++++ routlin/mod_metrics.py | 206 ++ routlin/mod_networkd.py | 149 + routlin/mod_nftables.py | 696 +++++ routlin/mod_radius.py | 321 +++ routlin/mod_shared.py | 137 + routlin/mod_timers.py | 214 ++ routlin/{validation.py => mod_validation.py} | 0 routlin/mod_wireguard.py | 157 + 27 files changed, 2894 insertions(+), 2605 deletions(-) create mode 100644 routlin/mod_avahi.py create mode 100644 routlin/mod_dnsmasq.py create mode 100644 routlin/mod_metrics.py create mode 100644 routlin/mod_networkd.py create mode 100644 routlin/mod_nftables.py create mode 100644 routlin/mod_radius.py create mode 100644 routlin/mod_shared.py create mode 100644 routlin/mod_timers.py rename routlin/{validation.py => mod_validation.py} (100%) create mode 100644 routlin/mod_wireguard.py diff --git a/docker/routlin-dash/app/pages/bannedips/action.py b/docker/routlin-dash/app/pages/bannedips/action.py index 1a2767d..1178e0f 100644 --- a/docker/routlin-dash/app/pages/bannedips/action.py +++ b/docker/routlin-dash/app/pages/bannedips/action.py @@ -5,7 +5,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/ddns/action.py b/docker/routlin-dash/app/pages/ddns/action.py index e4c6c22..db109c5 100644 --- a/docker/routlin-dash/app/pages/ddns/action.py +++ b/docker/routlin-dash/app/pages/ddns/action.py @@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash, send_file, abort from auth import require_level from config_utils import load_config, verify_config_hash, record_group, diff_fields, CONFIGS_DIR import sanitize -import validation as validate +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/ddns/view.py b/docker/routlin-dash/app/pages/ddns/view.py index 7299c2d..f880db9 100644 --- a/docker/routlin-dash/app/pages/ddns/view.py +++ b/docker/routlin-dash/app/pages/ddns/view.py @@ -5,7 +5,7 @@ from config_utils import ( collect_layout_tokens, load_datasource, CONFIGS_DIR, relative_time, ) from factory import load_ddns, load_json, build_table, table_token_key, iter_table_items, PAGES_DIR -import validation as validate +import mod_validation as validate DDNS_LOG_MAX = 50 diff --git a/docker/routlin-dash/app/pages/dhcpreservations/action.py b/docker/routlin-dash/app/pages/dhcpreservations/action.py index 3e1d1e7..1324b72 100644 --- a/docker/routlin-dash/app/pages/dhcpreservations/action.py +++ b/docker/routlin-dash/app/pages/dhcpreservations/action.py @@ -6,7 +6,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/dnsblocking/action.py b/docker/routlin-dash/app/pages/dnsblocking/action.py index c4ec5c9..37fb76c 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/action.py +++ b/docker/routlin-dash/app/pages/dnsblocking/action.py @@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash, send_file from auth import require_level from config_utils import load_config, record_group, diff_fields, verify_config_hash, queued_msg, CONFIGS_DIR import sanitize -import validation as validate +import mod_validation as validate DNS_LOG_FILE = Path(CONFIGS_DIR) / 'dns-blocklists.log' diff --git a/docker/routlin-dash/app/pages/dnsserver/action.py b/docker/routlin-dash/app/pages/dnsserver/action.py index 122d8da..0ff5bfc 100644 --- a/docker/routlin-dash/app/pages/dnsserver/action.py +++ b/docker/routlin-dash/app/pages/dnsserver/action.py @@ -4,7 +4,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/hostoverrides/action.py b/docker/routlin-dash/app/pages/hostoverrides/action.py index 8561657..b080499 100644 --- a/docker/routlin-dash/app/pages/hostoverrides/action.py +++ b/docker/routlin-dash/app/pages/hostoverrides/action.py @@ -5,7 +5,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/intervlan/action.py b/docker/routlin-dash/app/pages/intervlan/action.py index dd1a10f..521022f 100644 --- a/docker/routlin-dash/app/pages/intervlan/action.py +++ b/docker/routlin-dash/app/pages/intervlan/action.py @@ -5,7 +5,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/mdns/action.py b/docker/routlin-dash/app/pages/mdns/action.py index c2bb9b1..4e1c270 100644 --- a/docker/routlin-dash/app/pages/mdns/action.py +++ b/docker/routlin-dash/app/pages/mdns/action.py @@ -5,7 +5,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py index d0a0da7..9ae8010 100644 --- a/docker/routlin-dash/app/pages/networklayout/action.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -7,7 +7,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/action.py b/docker/routlin-dash/app/pages/physicalinterfaces/action.py index 89cafca..f9a8471 100644 --- a/docker/routlin-dash/app/pages/physicalinterfaces/action.py +++ b/docker/routlin-dash/app/pages/physicalinterfaces/action.py @@ -6,7 +6,7 @@ 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, queued_msg, queue_command import sanitize -import validation as validate +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/portforwarding/action.py b/docker/routlin-dash/app/pages/portforwarding/action.py index 37fc237..a03fa67 100644 --- a/docker/routlin-dash/app/pages/portforwarding/action.py +++ b/docker/routlin-dash/app/pages/portforwarding/action.py @@ -5,7 +5,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/portwrangling/action.py b/docker/routlin-dash/app/pages/portwrangling/action.py index a689110..6561330 100644 --- a/docker/routlin-dash/app/pages/portwrangling/action.py +++ b/docker/routlin-dash/app/pages/portwrangling/action.py @@ -5,7 +5,7 @@ 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 +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/radius/action.py b/docker/routlin-dash/app/pages/radius/action.py index feaeb11..69b4917 100644 --- a/docker/routlin-dash/app/pages/radius/action.py +++ b/docker/routlin-dash/app/pages/radius/action.py @@ -8,7 +8,7 @@ from pathlib import Path from flask import Blueprint, request, redirect, flash, send_file, abort, jsonify from auth import require_level from config_utils import CONFIGS_DIR, load_config, record_group, diff_fields -import validation as validate +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/docker/routlin-dash/app/pages/vpn/action.py b/docker/routlin-dash/app/pages/vpn/action.py index afdc185..b277a6a 100644 --- a/docker/routlin-dash/app/pages/vpn/action.py +++ b/docker/routlin-dash/app/pages/vpn/action.py @@ -8,7 +8,7 @@ from flask import Blueprint, make_response, redirect, flash, request from auth import require_level from config_utils import load_config, record_group, diff_fields, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME import sanitize -import validation as validate +import mod_validation as validate _PAGE = Path(__file__).parent.name diff --git a/routlin/core.py b/routlin/core.py index 30506f5..debf1ff 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -94,166 +94,36 @@ import time import argparse from datetime import datetime from pathlib import Path -from validation import ( - VALID_PROTOCOLS, VALID_BLOCKLIST_FORMATS, - int_range, domainname, - is_wg, is_dynamic_ip, - derive_interface, validate_config, -) -PRODUCT_NAME = "routlin" +import health as health +import mod_avahi as avahi +import mod_dnsmasq as dnsmasq +import mod_metrics as metrics +import mod_networkd as networkd +import mod_nftables as nftables +import mod_radius as radius +import mod_shared as shared +import mod_timers as timers +import mod_wireguard as wireguard +import mod_validation as validation -SCRIPT_DIR = Path(__file__).parent -CONFIG_FILE = SCRIPT_DIR / "config.json" -DASHBOARD_PENDING = SCRIPT_DIR / ".dashboard-pending" -BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" -METRICS_FILE = SCRIPT_DIR / ".dns-metrics" -DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}") -LEASES_DIR = Path("/var/lib/misc") -NETWORKD_DIR = Path("/etc/systemd/network") -SYSTEMD_DIR = Path("/etc/systemd/system") -BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update" -BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer" -BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service" -MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance" -MAINT_TIMER_FILE = SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.timer" -MAINT_TIMER_SVC_FILE = SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.service" -HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check" -HEALTH_TIMER_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer" -HEALTH_TIMER_SVC_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service" -HEALTH_TIMER_INTERVAL_SEC = 300 -HEALTH_FILE = SCRIPT_DIR / ".health" -RESOLV_CONF = Path("/etc/resolv.conf") -NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat" -NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service" -WG_DIR = Path("/etc/wireguard") -WG_KEEPALIVE = 25 - -# =================================================================== -# Helpers -# =================================================================== - -def chown_to_script_dir_owner(path): - try: - stat = SCRIPT_DIR.stat() - os.chown(path, stat.st_uid, stat.st_gid) - except OSError: - pass - -def service_warning(action, svc, stderr): - """Print a service start/restart warning, adding install hint if unit not found.""" - msg = stderr.strip() - print(f"WARNING: Failed to {action} {svc}: {msg}") - if "not found" in msg.lower() or "not-found" in msg.lower(): - print(f" -> Package may not be installed. Run: sudo python3 install.py") +PRODUCT_NAME = shared.PRODUCT_NAME +SCRIPT_DIR = shared.SCRIPT_DIR +DNSMASQ_CONF_DIR = shared.DNSMASQ_CONF_DIR +LEASES_DIR = shared.LEASES_DIR +SYSTEMD_DIR = shared.SYSTEMD_DIR def die(msg): print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) -def check_root(): - if os.geteuid() != 0: - die("This script must be run as root (sudo).") -def prefix_to_dotted(n): - mask = (0xFFFFFFFF << (32 - int(n))) & 0xFFFFFFFF - return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0)) +CONFIG_FILE = SCRIPT_DIR / "config.json" +DASHBOARD_PENDING = SCRIPT_DIR / ".dashboard-pending" +HEALTH_FILE = SCRIPT_DIR / ".health" +RESOLV_CONF = Path("/etc/resolv.conf") -def network_for(vlan): - return ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False) - -def lowest_quartet_ip(vlan): - """Return the server_identity IP with the lowest value in the last octet.""" - identities = vlan.get("server_identities", []) - ips = [] - for s in identities: - try: - ips.append(ipaddress.IPv4Address(s["ip"])) - except (KeyError, ValueError): - pass - if not ips: - return None - return str(min(ips, key=lambda ip: ip.packed[-1])) - -def resolve_vlan_options(vlan): - """ - Resolve gateway, dns_server, and ntp_server for a VLAN. - - For both WG and non-WG VLANs: gateway defaults to the lowest-last-octet - server_identity IP unless overridden in explicit_overrides. The gateway - override must be one of the server_identity IPs. - - WG VLANs: ntp_server is None (WireGuard has no DHCP so NTP cannot be - advertised to peers). Overrides live in vpn_information.explicit_overrides. - - Non-WG VLANs: overrides live in dhcp_information.explicit_overrides. - Returns a dict with keys: gateway, dns_server, ntp_server. - """ - if is_wg(vlan): - vpi = vlan["vpn_information"] - overrides = vpi.get("explicit_overrides", {}) - default = lowest_quartet_ip(vlan) or str(next(network_for(vlan).hosts())) - gateway = overrides.get("gateway", "") or default - dns = overrides.get("dns_servers", "") or gateway - return { - "gateway": gateway, - "dns_servers": dns, - "ntp_servers": None, - } - overrides = vlan.get("dhcp_information", {}).get("explicit_overrides", {}) - default = lowest_quartet_ip(vlan) - def _resolve(key): - v = overrides.get(key, "") - if isinstance(v, list): - return ','.join(v) if v else default - return v or default - return { - "gateway": overrides.get("gateway", "") or default, - "dns_servers": _resolve("dns_servers"), - "ntp_servers": _resolve("ntp_servers"), - } - -def is_physical(vlan): - return vlan.get("vlan_id") == 1 - -def networkd_stem(vlan): - return f"10-{PRODUCT_NAME}-{vlan['name']}" - -def vlan_service_name(vlan, iface): - if is_wg(vlan): - return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{iface}" - return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}" - -def vlan_service_file(vlan, iface): - return SYSTEMD_DIR / f"{vlan_service_name(vlan, iface)}.service" - -def vlan_conf_file(vlan): - return DNSMASQ_CONF_DIR / f"{vlan['name']}.conf" - -def vlan_leases_file(vlan): - return LEASES_DIR / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.leases" - -def vlan_pid_file(vlan): - return Path("/run") / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.pid" - -# nftables rule list helpers -def rule_enabled(rules): - return [r for r in rules if r.get("enabled") is True] - -def rule_disabled(rules): - return [r for r in rules if r.get("enabled") is not True] - -def expand_protocols(rule): - """Return list of (protocol, rule, comment_suffix) tuples. - When protocol is 'both', expands into tcp and udp with suffixes - ' (tcp)' and ' (udp)' so generated comments are unambiguous. - When protocol is a single value, suffix is empty string. - """ - proto = rule["protocol"] - if proto == "both": - return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")] - return [(proto, rule, "")] # =================================================================== # Load @@ -268,1917 +138,6 @@ def load_config(): die("No vlans defined in config.json.") return data -# =================================================================== -# Build systemd-networkd files -# =================================================================== - -def build_netdev(vlan, vid, iface): - return "\n".join([ - "# Generated by core.py -- do not edit manually.", - "# Edit config.json and re-run: sudo python3 core.py --apply", - "", - "[NetDev]", - f"Name={iface}", - "Kind=vlan", - "", - "[VLAN]", - f"Id={vid}", - "", - ]) - -def build_network(vlan, vid, iface, all_vlan_ids): - network = network_for(vlan) - prefix = network.prefixlen - lines = [ - "# Generated by core.py -- do not edit manually.", - "# Edit config.json and re-run: sudo python3 core.py --apply", - "", - "[Match]", - f"Name={iface}", - "", - "[Network]", - "DHCP=no", - "LinkLocalAddressing=no", - ] - for ident in vlan["server_identities"]: - lines.append(f"# {ident['description']}") - lines.append(f"Address={ident['ip']}/{prefix}") - - if is_physical(vlan): - lines.append("") - for v in all_vlan_ids: - if v != 1: - lines.append(f"VLAN={iface}.{v}") - - lines.append("") - return "\n".join(lines) - -def find_legacy_files(managed_interfaces): - to_remove = [] - for pattern in ("*.network", "*.netdev"): - for f in NETWORKD_DIR.glob(pattern): - if f.name.startswith(f"10-{PRODUCT_NAME}-"): - continue - try: - content = f.read_text() - except OSError: - continue - for iface in managed_interfaces: - if f"Name={iface}" in content: - to_remove.append(f) - break - return to_remove - -def apply_networkd(data, dry_run=False, only_if_changed=False): - """Write systemd-networkd files and reload. - If only_if_changed=True, write files only when content differs from disk - and skip the networkd reload if nothing changed. Used by --apply mode. - """ - all_vlan_ids = [v.get('vlan_id') for v in data["vlans"] if not is_wg(v)] - managed_ifaces = [derive_interface(v, data) for v in data["vlans"]] - changed = False - - legacy = find_legacy_files(managed_ifaces) - if legacy: - print("Removing legacy networkd files:") - for f in legacy: - if not dry_run: - f.unlink() - changed = True - print(f" {'[dry-run] would remove' if dry_run else 'Removed'}: {f}") - print() - - for vlan in data["vlans"]: - if is_wg(vlan): - continue - iface = derive_interface(vlan, data) - vid = vlan.get('vlan_id') - stem = networkd_stem(vlan) - - if not is_physical(vlan): - netdev_path = NETWORKD_DIR / f"{stem}.netdev" - netdev_content = build_netdev(vlan, vid, iface) - if dry_run: - print(f"# -- {netdev_path} (dry-run) --") - print(netdev_content) - else: - existing = netdev_path.read_text() if netdev_path.exists() else None - if existing != netdev_content: - netdev_path.write_text(netdev_content) - print(f"Written: {netdev_path}") - changed = True - elif not only_if_changed: - print(f"Unchanged: {netdev_path}") - - network_path = NETWORKD_DIR / f"{stem}.network" - network_content = build_network(vlan, vid, iface, all_vlan_ids) - if dry_run: - print(f"# -- {network_path} (dry-run) --") - print(network_content) - else: - existing = network_path.read_text() if network_path.exists() else None - if existing != network_content: - network_path.write_text(network_content) - print(f"Written: {network_path}") - changed = True - elif not only_if_changed: - print(f"Unchanged: {network_path}") - - if not dry_run: - if changed: - print("Reloading systemd-networkd...") - result = subprocess.run( - ["networkctl", "reload"], capture_output=True, text=True - ) - if result.returncode != 0: - print(f"WARNING: networkctl reload returned non-zero:\n{result.stderr.strip()}") - else: - print("systemd-networkd reloaded.") - elif only_if_changed: - print("systemd-networkd: no changes. Good.") - - -# =================================================================== -# Blocklist management -# =================================================================== - -def combo_hash(names): - """Return a stable 8-char hex hash for a list/set of blocklist names.""" - key = ",".join(sorted(names)) - return hashlib.sha256(key.encode()).hexdigest()[:8] - -def merged_path(h): - return BLOCKLIST_DIR / f"merged-{h}.conf" - -def blocklists_available(data): - """Return True if at least one merged blocklist file exists on disk.""" - combos = set() - for vlan in data.get("vlans", []): - names = vlan.get("use_blocklists", []) - if names: - combos.add(combo_hash(names)) - return any(merged_path(h).exists() for h in combos) - -# =================================================================== -# Build per-VLAN dnsmasq config -# =================================================================== - -def _wan_has_ipv6(iface): - """Return True if the WAN interface has a non-link-local IPv6 address.""" - try: - result = subprocess.run( - ["ip", "-6", "addr", "show", iface, "scope", "global"], - capture_output=True, text=True - ) - return bool(result.stdout.strip()) - except Exception: - return False - - -def build_vlan_dnsmasq_conf(vlan, data, iface): - """Generate the complete dnsmasq config for one VLAN instance.""" - dns_cfg = data.get("upstream_dns", {}) - general = data.get("network_interfaces", {}) - overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True] - name = vlan["name"] - d = vlan.get("dhcp_information", {}) - opts = resolve_vlan_options(vlan) - gateway = opts["gateway"] - - bl_names = vlan.get("use_blocklists", []) - bl_file = None - if bl_names: - p = merged_path(combo_hash(bl_names)) - if p.exists(): - bl_file = p - - L = [] - def line(s=""): - L.append(s) - - line("# Generated by core.py -- do not edit manually.") - line("# Edit config.json and re-run: sudo python3 core.py --apply") - line(f"# VLAN: {name} (vlan_id={vlan.get('vlan_id')})") - line() - line(f"pid-file={vlan_pid_file(vlan)}") - if not is_wg(vlan): - line(f"dhcp-leasefile={vlan_leases_file(vlan)}") - line("except-interface=lo") - line("bind-interfaces") - line(f"listen-address={gateway}") - line(f"interface={iface}") - if is_physical(vlan): - bridge_ips = get_container_bridge_ips() - for bridge, ip in bridge_ips.items(): - line(f"interface={bridge}") - line(f"listen-address={ip}") - line() - - if not is_wg(vlan): - line("# -- DHCP -----------------------------------------------------------") - dotted_mask = prefix_to_dotted(vlan['subnet_mask']) - line(f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{dotted_mask},{d['lease_time']}") - line(f"domain={d.get('domain', 'local')}") - line() - line(f"dhcp-option=tag:{name},option:router,{gateway}") - line(f"dhcp-option=tag:{name},option:dns-server,{opts['dns_servers']}") - line(f"dhcp-option=tag:{name},option:ntp-server,{opts['ntp_servers']}") - line() - - identity_hosts = [s for s in vlan.get("server_identities", []) if s.get("hostname")] - if identity_hosts: - line("# -- Server identity hostnames ----------------------------------") - for s in identity_hosts: - line(f"# {s['description']}") - line(f"dhcp-host={s['ip']},{s['hostname']}") - line() - - vlan_res = [r for r in data.get("dhcp_reservations", []) if r.get("vlan") == name] - active_res = [r for r in vlan_res if r.get("enabled") is True] - inactive_res = [r for r in vlan_res if r.get("enabled") is not True] - - if active_res: - line("# -- Reservations -----------------------------------------------") - - # Group reservations sharing a static IP into single dhcp-host lines - # (multiple MACs for the same device e.g. wired + WiFi interfaces) - # Dynamic reservations are always emitted individually. - seen_ips = {} # ip -> list of reservations - ordered = [] # preserves insertion order for output - for r in active_res: - if is_dynamic_ip(r): - ordered.append([r]) # always individual - else: - ip = r.get("ip", "") - if ip in seen_ips: - seen_ips[ip].append(r) - else: - seen_ips[ip] = [r] - ordered.append(seen_ips[ip]) - - for group in ordered: - if len(group) == 1: - r = group[0] - h = r.get('hostname', '') - line(f"# {r['description']}") - if is_dynamic_ip(r): - line(f"dhcp-host=set:{name},{r['mac']},{h},{d['lease_time']}" if h else - f"dhcp-host=set:{name},{r['mac']},{d['lease_time']}") - else: - line(f"dhcp-host=set:{name},{r['mac']},{r['ip']},{h},{d['lease_time']}" if h else - f"dhcp-host=set:{name},{r['mac']},{r['ip']},{d['lease_time']}") - else: - # Multiple MACs share the same IP -- combine into one dhcp-host line - descs = ", ".join(r['description'] for r in group) - macs = ",".join(r['mac'] for r in group) - ip = group[0]['ip'] - hostname = group[0].get('hostname', '') - line(f"# {descs}") - line(f"dhcp-host=set:{name},{macs},{ip},{hostname},{d['lease_time']}" if hostname else - f"dhcp-host=set:{name},{macs},{ip},{d['lease_time']}") - line() - - if inactive_res: - line("# -- Skipped reservations (enabled: false) ----------------------") - for r in inactive_res: - line(f"# SKIPPED: {r['description']} ({r.get('mac', '?')} -> {r.get('ip', '?')})") - line() - - line("# -- DNS ------------------------------------------------------------") - line("no-resolv") - if dns_cfg.get("strict_order"): - line("strict-order") - wan = data["network_interfaces"]["wan_interface"] - wan_has_ipv6 = _wan_has_ipv6(wan) - for srv in dns_cfg.get("upstream_servers", []): - if ":" in srv and not wan_has_ipv6: - continue # skip IPv6 upstream -- WAN has no IPv6 address - line(f"server={srv}") - line(f"cache-size={dns_cfg.get('cache_size', 1000)}") - if vlan.get("dnsmasq_log_queries", False): - line("log-queries") - line() - - if overrides: - line("# -- Host overrides -------------------------------------------------") - for o in overrides: - line(f"# {o['description']}") - line(f"address=/{o['host']}/{o['ip']}") - line() - - if bl_file: - line("# -- Blocklist ------------------------------------------------------") - line(f"conf-file={bl_file}") - line() - elif bl_names: - line("# Blocklist not yet downloaded -- run: sudo python3 dns-blocklists.py") - line() - - return "\n".join(L) - -# =================================================================== -# Build per-VLAN systemd service unit -# =================================================================== - -def build_vlan_service(vlan, iface): - name = vlan["name"] - conf = vlan_conf_file(vlan) - - if is_wg(vlan): - after = f"network-online.target wg-quick@{iface}.service" - wants = "network-online.target" - bindsto = f"wg-quick@{iface}.service" - else: - after = "network-online.target" - wants = "network-online.target" - bindsto = None - - lines = [ - "# Generated by core.py -- do not edit manually.", - "", - "[Unit]", - f"Description=dnsmasq for VLAN {name}", - f"After={after}", - f"Wants={wants}", - ] - if bindsto: - lines.append(f"BindsTo={bindsto}") - - lines += [ - "", - "[Service]", - "Type=forking", - f"PIDFile={vlan_pid_file(vlan)}", - f"ExecStart=/usr/sbin/dnsmasq --conf-file={conf}", - "ExecReload=/bin/kill -HUP $MAINPID", - "Restart=on-failure", - "RestartSec=5s", - "", - "[Install]", - "WantedBy=multi-user.target", - "", - ] - - return "\n".join(lines) - -# =================================================================== -# System dnsmasq / resolv.conf -# =================================================================== - -def ensure_resolv_conf(data): - """Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1).""" - physical = next((v for v in data["vlans"] if is_physical(v)), None) - if physical is None: - return - nameserver = resolve_vlan_options(physical)["gateway"] - wanted = f"nameserver {nameserver}\n" - # A symlink (e.g. to systemd-resolved stub) must be replaced with a plain file. - if RESOLV_CONF.is_symlink(): - RESOLV_CONF.unlink() - print("Removed /etc/resolv.conf symlink (was pointing to systemd-resolved stub).") - current = RESOLV_CONF.read_text() if RESOLV_CONF.exists() else "" - if wanted in current: - print(f"/etc/resolv.conf already points to {nameserver}. Good.") - return - RESOLV_CONF.write_text(wanted) - print(f"Updated /etc/resolv.conf: nameserver {nameserver}") - - -def disable_systemd_resolved(): - """Stop and disable systemd-resolved if it is active.""" - result = subprocess.run( - ["systemctl", "is-active", "systemd-resolved"], - capture_output=True, text=True - ) - if result.stdout.strip() == "active": - subprocess.run(["systemctl", "disable", "--now", "systemd-resolved"], - capture_output=True, text=True) - print("Disabled systemd-resolved.") - else: - print("systemd-resolved is not active. Good.") - -def disable_systemd_timesyncd(): - """Stop and disable systemd-timesyncd if it is active.""" - result = subprocess.run( - ["systemctl", "is-active", "systemd-timesyncd"], - capture_output=True, text=True - ) - if result.stdout.strip() == "active": - subprocess.run(["systemctl", "disable", "--now", "systemd-timesyncd"], - capture_output=True, text=True) - print("Disabled systemd-timesyncd.") - else: - print("systemd-timesyncd is not active. Good.") - -def ensure_chrony(data): - """Add VLAN allow directives to chrony.conf and start the service.""" - chrony_conf = Path("/etc/chrony/chrony.conf") - if chrony_conf.exists(): - content = chrony_conf.read_text() - subnets = [] - for v in data["vlans"]: - subnets.append(str(network_for(v))) - added = [] - for subnet in subnets: - line = f"allow {subnet}" - if line not in content: - content += f"\n{line}" - added.append(subnet) - if added: - chrony_conf.write_text(content) - print(f"Updated /etc/chrony/chrony.conf: added allow for {', '.join(added)}") - else: - print("chrony.conf already has required allow directives. Good.") - - subprocess.run(["systemctl", "enable", "--now", "chrony"], - capture_output=True, text=True) - subprocess.run(["systemctl", "restart", "chrony"], - capture_output=True, text=True) - print("chrony enabled and running. Good.") - -def disable_ufw(): - """Disable ufw (without removing it) if it is installed.""" - if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode != 0: - print("ufw is not installed. Good.") - return - status = subprocess.run(["ufw", "status"], capture_output=True, text=True) - if "Status: active" in status.stdout: - subprocess.run(["ufw", "disable"], capture_output=True, text=True) - print("ufw rules cleared.") - else: - print("ufw is not active. Good.") - # Disable the systemd unit regardless, to prevent it starting at boot. - svc = subprocess.run(["systemctl", "is-enabled", "ufw"], - capture_output=True, text=True) - if svc.stdout.strip() in ("enabled", "enabled-runtime"): - subprocess.run(["systemctl", "disable", "ufw"], capture_output=True, text=True) - print("Disabled ufw.service.") - -def disable_system_dnsmasq(data): - """Stop and disable the system dnsmasq.service if it is enabled.""" - disable_systemd_resolved() - result = subprocess.run( - ["systemctl", "is-enabled", "dnsmasq"], - capture_output=True, text=True - ) - if result.stdout.strip() in ("enabled", "enabled-runtime"): - subprocess.run(["systemctl", "disable", "--now", "dnsmasq"], - capture_output=True, text=True) - print("Disabled system dnsmasq.service.") - else: - print("System dnsmasq.service is already disabled. Good.") - ensure_resolv_conf(data) - -def restore_ntp(): - """Disable chrony and re-enable systemd-timesyncd for plain client NTP.""" - result = subprocess.run( - ["systemctl", "is-active", "chrony"], capture_output=True, text=True - ) - if result.stdout.strip() == "active": - subprocess.run(["systemctl", "disable", "--now", "chrony"], - capture_output=True, text=True) - print("Disabled chrony.") - else: - print("chrony is not active.") - - result = subprocess.run( - ["systemctl", "cat", "systemd-timesyncd"], capture_output=True, text=True - ) - if result.returncode == 0: - subprocess.run(["systemctl", "enable", "--now", "systemd-timesyncd"], - capture_output=True, text=True) - print("Enabled systemd-timesyncd.") - else: - print("systemd-timesyncd is not available on this system.") - -# =================================================================== -# Apply dnsmasq instances -# =================================================================== - -def wg_interface_up(iface): - """Return True if the WireGuard interface exists and is up.""" - result = subprocess.run(["ip", "link", "show", iface], - capture_output=True, text=True) - return result.returncode == 0 - -def wg_server_key_path(iface): - return WG_DIR / f"{iface}.key" - -def wg_server_pubkey_path(iface): - """Public key written to the configs dir so the Flask app can read it.""" - return SCRIPT_DIR / f".{iface}.pub" - -def wg_conf_path_for(iface): - return WG_DIR / f"{iface}.conf" - -def generate_wg_server_key(iface): - WG_DIR.mkdir(exist_ok=True) - result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True) - private = result.stdout.strip() - kf = wg_server_key_path(iface) - kf.write_text(private + "\n") - kf.chmod(0o600) - return private - -def build_wg_server_conf(vlan, server_private_key, iface): - """Build the /etc/wireguard/.conf content from config.json peers.""" - info = vlan["vpn_information"] - gateway = resolve_vlan_options(vlan)["gateway"] - network = network_for(vlan) - server_ip = f"{gateway}/{network.prefixlen}" - listen_port = info["listen_port"] - domain = info.get("domain", "local") - - L = [ - "# Generated by core.py -- do not edit manually.", - "# Run: sudo python3 core.py --apply", - "", - "[Interface]", - f"PrivateKey = {server_private_key}", - f"Address = {server_ip}", - f"ListenPort = {listen_port}", - "", - ] - - for peer in vlan.get("peers", []): - if not peer.get("enabled", True): - L += [f"# DISABLED: {peer['name']}", ""] - continue - L += [ - f"# {peer['name']}", - "[Peer]", - f"PublicKey = {peer['public_key']}", - f"AllowedIPs = {peer['ip']}/32", - f"PersistentKeepalive = {WG_KEEPALIVE}", - "", - ] - - return "\n".join(L) - -def ensure_wg_interfaces(data): - """Generate WireGuard server confs and bring up / sync all WG interfaces.""" - wg_vlans = [v for v in data.get("vlans", []) if is_wg(v)] - if not wg_vlans: - return - - for vlan in wg_vlans: - iface = derive_interface(vlan, data) - print(f" [{iface}]") - - kf = wg_server_key_path(iface) - if not kf.exists(): - print(f" Generating server private key...") - private = generate_wg_server_key(iface) - else: - private = kf.read_text().strip() - - pub_result = subprocess.run( - ["wg", "pubkey"], input=private, capture_output=True, text=True, check=True - ) - public = pub_result.stdout.strip() - pubkey_file = wg_server_pubkey_path(iface) - pubkey_file.write_text(public + "\n") - chown_to_script_dir_owner(pubkey_file) - print(f" Server public key: {public[:20]}...") - - WG_DIR.mkdir(exist_ok=True) - conf_file = wg_conf_path_for(iface) - new_conf = build_wg_server_conf(vlan, private, iface) - listen_port = vlan["vpn_information"]["listen_port"] - - port_changed = False - if conf_file.exists(): - m = re.search(r'ListenPort\s*=\s*(\d+)', conf_file.read_text()) - if m and int(m.group(1)) != listen_port: - port_changed = True - - conf_file.write_text(new_conf) - conf_file.chmod(0o600) - - peer_count = len([p for p in vlan.get("peers", []) if p.get("enabled", True)]) - print(f" Wrote {conf_file} ({peer_count} enabled peer(s))") - - if not wg_interface_up(iface): - print(f" Bringing up {iface}...") - r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True) - if r.returncode != 0: - print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}") - else: - print(f" {iface} is up.") - elif port_changed: - print(f" Listen port changed -- restarting {iface}...") - subprocess.run(["wg-quick", "down", iface], capture_output=True, text=True) - r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True) - if r.returncode != 0: - print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}") - else: - print(f" {iface} restarted.") - else: - print(f" Syncing peers to live {iface}...") - subprocess.run(["wg", "syncconf", iface, str(conf_file)], capture_output=True, text=True) - -def get_container_bridges(): - """Return all active bridge interfaces not managed by our VLAN config. - Works universally for Docker, Podman, LXC, libvirt, etc. -- anything - that creates a Linux bridge interface. - """ - try: - result = subprocess.run( - ["ip", "-j", "link", "show", "type", "bridge"], - capture_output=True, text=True, timeout=5 - ) - if result.returncode != 0: - return [] - import json as _json - links = _json.loads(result.stdout) - return [l["ifname"] for l in links if l.get("operstate") == "UP"] - except Exception: - return [] - -def get_container_bridge_ips(): - """Return {ifname: ip} for all active container bridge interfaces. - Used to add listen-address directives to the physical VLAN's dnsmasq - instance so containers can reach the local DNS resolver. - Works universally for Docker, Podman, LXC, libvirt, etc. - """ - try: - result = subprocess.run( - ["ip", "-j", "addr", "show", "type", "bridge"], - capture_output=True, text=True, timeout=5 - ) - if result.returncode != 0: - return {} - import json as _json - links = _json.loads(result.stdout) - out = {} - for l in links: - if l.get("operstate") != "UP": - continue - for addr in l.get("addr_info", []): - if addr.get("family") == "inet": - out[l["ifname"]] = addr["local"] - break - return out - except Exception: - return {} - -def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True): - """Write per-VLAN dnsmasq configs and service units. - - start_if_needed=True: enable and start all instances. - start_if_needed=False (--apply): only restart instances already running; - skip with a warning if not running. - """ - active_service_stems = {vlan_service_name(vlan, derive_interface(vlan, data)) for vlan in data["vlans"]} - - if not dry_run: - DNSMASQ_CONF_DIR.mkdir(exist_ok=True) - disable_system_dnsmasq(data) - print() - - for vlan in data["vlans"]: - iface = derive_interface(vlan, data) - if is_wg(vlan) and not dry_run and not wg_interface_up(iface): - print(f"Skipped VLAN '{vlan['name']}': {iface} is not up. Run --apply again after WireGuard is up.") - continue - - conf_content = build_vlan_dnsmasq_conf(vlan, data, iface) - svc_content = build_vlan_service(vlan, iface) - conf_path = vlan_conf_file(vlan) - svc_path = vlan_service_file(vlan, iface) - - if dry_run: - print(f"# -- {conf_path} (dry-run) --") - print(conf_content) - print(f"# -- {svc_path} (dry-run) --") - print(svc_content) - continue - - conf_path.write_text(conf_content) - print(f"Written: {conf_path}") - - if not svc_path.exists() or svc_path.read_text() != svc_content: - svc_path.write_text(svc_content) - print(f"Written: {svc_path}") - - if dry_run: - return - - print() - subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) - - # Remove stale service units (VLANs removed from config) - for f in SYSTEMD_DIR.glob(f"dnsmasq-{PRODUCT_NAME}-*.service"): - if f.stem not in active_service_stems: - subprocess.run(["systemctl", "disable", "--now", f.stem], - capture_output=True, text=True) - f.unlink() - n = f.stem.removeprefix(f"dnsmasq-{PRODUCT_NAME}-") - stale_conf = DNSMASQ_CONF_DIR / f"{n}.conf" - if stale_conf.exists(): - stale_conf.unlink() - print(f"Removed stale VLAN: {f.stem}") - - if start_if_needed: - print("Starting dnsmasq instances...") - for vlan in data["vlans"]: - iface = derive_interface(vlan, data) - if is_wg(vlan) and not wg_interface_up(iface): - continue - svc = vlan_service_name(vlan, iface) - subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) - result = subprocess.run(["systemctl", "restart", svc], - capture_output=True, text=True) - if result.returncode == 0: - print(f" Started: {svc}") - else: - service_warning("start", svc, result.stderr) - else: - print("Reloading dnsmasq instances...") - for vlan in data["vlans"]: - iface = derive_interface(vlan, data) - if is_wg(vlan) and not wg_interface_up(iface): - continue - svc = vlan_service_name(vlan, iface) - state = subprocess.run( - ["systemctl", "is-active", svc], - capture_output=True, text=True - ).stdout.strip() - if state == "active": - result = subprocess.run(["systemctl", "restart", svc], - capture_output=True, text=True) - if result.returncode == 0: - print(f" Restarted: {svc}") - else: - service_warning("restart", svc, result.stderr) - elif is_wg(vlan): - # WG interface is up but dnsmasq isn't running -- start it now - subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) - result = subprocess.run(["systemctl", "start", svc], - capture_output=True, text=True) - if result.returncode == 0: - print(f" Started: {svc}") - else: - service_warning("start", svc, result.stderr) - else: - print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)") - -# =================================================================== -# Timer management -# =================================================================== - -def parse_time_to_calendar(time_str): - parts = time_str.strip().split(":") - if len(parts) != 2: - die(f"Invalid daily_execute_time_24hr_local: '{time_str}'. Expected HH:MM.") - hh, mm = parts - return f"*-*-* {hh.zfill(2)}:{mm.zfill(2)}:00" - -def install_timer(data): - general = data.get("dns_blocking", {}).get("general", {}) - execute_time = general.get("daily_execute_time_24hr_local", "02:30") - on_calendar = parse_time_to_calendar(execute_time) - - timer_content = "\n".join([ - "# Generated by core.py -- do not edit manually.", - "", - "[Unit]", - "Description=Daily blocklist refresh", - "", - "[Timer]", - f"OnCalendar={on_calendar}", - "Persistent=true", - "", - "[Install]", - "WantedBy=timers.target", - "", - ]) - - blocklist_script = SCRIPT_DIR / "dns-blocklists.py" - service_content = "\n".join([ - "# Generated by core.py -- do not edit manually.", - "", - "[Unit]", - "Description=Daily blocklist refresh", - "After=network-online.target", - "Wants=network-online.target", - "", - "[Service]", - "Type=oneshot", - f"ExecStart=/usr/bin/python3 {blocklist_script}", - "", - ]) - - for path, content in ((BLIST_TIMER_FILE, timer_content), (BLIST_TIMER_SVC_FILE, service_content)): - if not path.exists() or path.read_text() != content: - path.write_text(content) - print(f"Written: {path}") - - subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) - subprocess.run(["systemctl", "enable", "--now", f"{BLIST_TIMER_NAME}.timer"], - capture_output=True, text=True) - print(f"Timer {BLIST_TIMER_NAME}.timer enabled (runs daily at {execute_time}).") - -def _install_interval_timers(names, timer_files, svc_files, descriptions, exec_starts, interval_secs): - for name, timer_file, svc_file, description, exec_start, interval_sec in zip( - names, timer_files, svc_files, descriptions, exec_starts, interval_secs): - timer_content = "\n".join([ - "# Generated by core.py -- do not edit manually.", - "", - "[Unit]", - f"Description={description}", - "", - "[Timer]", - f"OnActiveSec={interval_sec}s", - f"OnUnitActiveSec={interval_sec}s", - "AccuracySec=10s", - "", - "[Install]", - "WantedBy=timers.target", - "", - ]) - service_content = "\n".join([ - "# Generated by core.py -- do not edit manually.", - "", - "[Unit]", - f"Description={description}", - "", - "[Service]", - "Type=oneshot", - f"ExecStart={exec_start}", - "", - ]) - for path, content in ((timer_file, timer_content), (svc_file, service_content)): - if not path.exists() or path.read_text() != content: - path.write_text(content) - print(f"Written: {path}") - - subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) - for name, interval_sec in zip(names, interval_secs): - subprocess.run(["systemctl", "enable", f"{name}.timer"], capture_output=True, text=True) - active = subprocess.run( - ["systemctl", "is-active", f"{name}.timer"], - capture_output=True, text=True - ).stdout.strip() == "active" - verb = "restart" if active else "start" - subprocess.run(["systemctl", verb, f"{name}.timer"], capture_output=True, text=True) - print(f"Timer {name}.timer enabled (runs every {interval_sec}s).") - -def _remove_timers(names, timer_files, svc_files, daemon_reload=False): - for name, timer_file, svc_file in zip(names, timer_files, svc_files): - subprocess.run(["systemctl", "disable", "--now", f"{name}.timer"], - capture_output=True, text=True) - for f in (timer_file, svc_file): - if f.exists(): - f.unlink() - print(f"Removed: {f}") - else: - print(f"Not found, skipping: {f}") - if daemon_reload: - subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) - - -def _parse_ddns_interval(interval_str): - """Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value.""" - s = interval_str.strip() - if s.endswith("m"): return f"{s[:-1]}min" - if s.endswith("h"): return f"{s[:-1]}h" - if s.endswith("d"): return f"{s[:-1]}day" - raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.") - - -def install_maint_timer(data): - ddns = data.get("ddns", {}) - interval = ddns.get("general", {}).get("timer_interval", "10m") - script_path = SCRIPT_DIR / "maintenance.py" - try: - systemd_unit = _parse_ddns_interval(interval) - except ValueError as e: - print(f"DDNS timer: {e}") - return - - service_content = "\n".join([ - "# Generated by core.py -- do not edit manually.", - "", - "[Unit]", - "Description=DDNS IP update", - "After=network-online.target", - "Wants=network-online.target", - "", - "[Service]", - "Type=oneshot", - f"ExecStart=/usr/bin/python3 {script_path} --update", - "", - ]) - timer_content = "\n".join([ - "# Generated by core.py -- do not edit manually.", - "", - "[Unit]", - "Description=DDNS IP update timer", - "", - "[Timer]", - f"OnActiveSec={systemd_unit}", - f"OnUnitActiveSec={systemd_unit}", - "OnBootSec=1min", - "AccuracySec=10s", - "", - "[Install]", - "WantedBy=timers.target", - "", - ]) - for path, content in ((MAINT_TIMER_SVC_FILE, service_content), (MAINT_TIMER_FILE, timer_content)): - if not path.exists() or path.read_text() != content: - path.write_text(content) - print(f"Written: {path}") - subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) - active = subprocess.run( - ["systemctl", "is-active", f"{MAINT_TIMER_NAME}.timer"], - capture_output=True, text=True - ).stdout.strip() == "active" - verb = "restart" if active else "enable --now" - subprocess.run(["systemctl"] + verb.split() + [f"{MAINT_TIMER_NAME}.timer"], - capture_output=True, text=True) - print(f"Timer {MAINT_TIMER_NAME}.timer enabled (runs every {interval}).") - -# =================================================================== -# banned_ips expansion -# =================================================================== - -def _expand_banned_ipv4(ip_str): - """Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements.""" - if '/' in ip_str: - ipaddress.IPv4Network(ip_str, strict=False) # validate - return [ip_str] - - parts = ip_str.split('.') - if len(parts) != 4: - raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} - expected 4 octets") - - def parse_octet(s, pos): - if s == '*': - return (0, 255) - if '-' in s: - a, b = s.split('-', 1) - lo, hi = int(a), int(b) - if not (0 <= lo <= hi <= 255): - raise ValueError(f"Invalid octet range {s!r} in {ip_str!r}") - return (lo, hi) - v = int(s) - if not 0 <= v <= 255: - raise ValueError(f"Octet value {v} out of range in {ip_str!r}") - return (v, v) - - ranges = [parse_octet(p, i) for i, p in enumerate(parts)] - - # Count trailing full-wildcard octets to determine CIDR suffix length - trailing = 0 - for lo, hi in reversed(ranges): - if lo == 0 and hi == 255: - trailing += 1 - else: - break - - prefix_len = 32 - 8 * trailing - prefix_ranges = ranges[:4 - trailing] - - # Guard against combinatorial explosion - total = 1 - for lo, hi in prefix_ranges: - total *= (hi - lo + 1) - if total > 1024: - raise ValueError( - f"Pattern {ip_str!r} would expand to {total} entries (limit 1024). " - f"Use CIDR notation instead." - ) - - results = [] - - if trailing > 0: - # Enumerate prefix octets; each combination yields a CIDR - def _enum_cidr(idx, chosen): - if idx == len(prefix_ranges): - base = '.'.join(str(v) for v in chosen) + '.0' * trailing - if prefix_len == 32: - results.append(base) - else: - results.append(f"{base}/{prefix_len}") - return - lo, hi = prefix_ranges[idx] - for v in range(lo, hi + 1): - _enum_cidr(idx + 1, chosen + [v]) - _enum_cidr(0, []) - else: - # No trailing wildcards - enumerate outer 3 octets, express last as range - outer_ranges = ranges[:3] - lo4, hi4 = ranges[3] - - def _enum_range(idx, chosen): - if idx == 3: - base = '.'.join(str(v) for v in chosen) - if lo4 == hi4: - results.append(f"{base}.{lo4}") - else: - results.append(f"{base}.{lo4}-{base}.{hi4}") - return - lo, hi = outer_ranges[idx] - for v in range(lo, hi + 1): - _enum_range(idx + 1, chosen + [v]) - _enum_range(0, []) - - return results - - -def _expand_banned_ipv6(ip_str): - """Convert an IPv6 pattern (CIDR, single IP, or trailing-wildcard) to nftables set elements. - - Supported formats: - Single address : "2a01:4f8:c17:b0f::2" -- passed through as-is - CIDR : "2a01:4f8::/32" -- passed through as-is - Wildcard : "2a01:4f8:c17:*" -- prefix:* expands to a CIDR - "2a01:4f8:c17:b00::*" -- :: compression is supported - - Range notation (e.g. "b00-bff") is not supported for IPv6. Use CIDR instead. - """ - if '/' in ip_str: - ipaddress.IPv6Network(ip_str, strict=False) # validate - return [ip_str] - - if '*' not in ip_str: - ipaddress.IPv6Address(ip_str) # validate single address - return [ip_str] - - if not ip_str.endswith(':*'): - raise ValueError( - f"Unsupported IPv6 wildcard pattern {ip_str!r}. " - f"Use 'prefix:*' (e.g. '2a01:4f8:c17:*') or CIDR notation. " - f"Range notation (e.g. 'b00-bff') is not supported for IPv6." - ) - - prefix_part = ip_str[:-2] # strip trailing ':*' - - # Expand '::' compression if present. - # IPv6 has 8 groups total. The wildcard occupies one slot, so the prefix - # may have at most 7 explicit groups. We know exactly how many zero groups - # '::' represents: 8 - len(left_groups) - len(right_groups) - 1 (for wildcard). - if '::' in prefix_part: - left, right = prefix_part.split('::', 1) - left_groups = [g for g in left.split(':') if g] if left else [] - right_groups = [g for g in right.split(':') if g] if right else [] - zero_count = 8 - len(left_groups) - len(right_groups) - 1 - if zero_count < 0: - raise ValueError(f"IPv6 wildcard pattern {ip_str!r} has too many groups.") - groups = left_groups + ['0000'] * zero_count + right_groups - else: - groups = [g for g in prefix_part.split(':') if g] - - num_groups = len(groups) - prefix_bits = num_groups * 16 - if num_groups < 1 or num_groups > 7: - raise ValueError( - f"IPv6 wildcard pattern {ip_str!r} must have between 1 and 7 " - f"prefix groups before the wildcard." - ) - base = ':'.join(groups) + ':' + ':'.join(['0000'] * (8 - num_groups)) - addr = ipaddress.IPv6Address(base) - return [f"{addr}/{prefix_bits}"] - - -def expand_banned_ip(ip_str): - """Return (family, [nftables_elements]) for a banned_ips entry. - family is 'ipv4' or 'ipv6'.""" - if ':' in ip_str: - return ('ipv6', _expand_banned_ipv6(ip_str)) - return ('ipv4', _expand_banned_ipv4(ip_str)) - - -def banned_ip_sets(data): - """Return (v4_elements, v6_elements) as flat lists of nftables set element strings.""" - v4, v6 = [], [] - for entry in rule_enabled(data.get("banned_ips", [])): - family, elements = expand_banned_ip(entry["ip"]) - if family == 'ipv4': - v4.extend(elements) - else: - v6.extend(elements) - return v4, v6 - - -# =================================================================== -# nftables config generation -# =================================================================== - -def build_nft_config(data, dry_run=False): - wan = data["network_interfaces"]["wan_interface"] - # Exclude WG VLANs whose interface is not up -- nft rejects rules that - # reference non-existent interfaces, which would leave no firewall at all. - vlans = [v for v in data["vlans"] - if not is_wg(v) or dry_run or wg_interface_up(derive_interface(v, data))] - all_fwd = list(rule_enabled(data.get("port_forwarding", []))) - _wrngl_vlan_by_name = {v["name"]: v for v in vlans} - all_wrngl = [(_wrngl_vlan_by_name[r["vlan"]], r) - for r in rule_enabled(data.get("port_wrangling", [])) - if r.get("vlan") in _wrngl_vlan_by_name] - # Interfaces that are active (WG interfaces only included if up) - active_ifaces = {derive_interface(v, data) for v in vlans} - - # Build interface -> network map for nat_ip -> iface lookup in forward chain - vlan_networks = {} - for v in vlans: - try: - net = network_for(v) - vlan_networks[derive_interface(v, data)] = net - except (KeyError, ValueError): - pass - - all_except = rule_enabled(data.get("inter_vlan_exceptions", [])) - banned_v4, banned_v6 = banned_ip_sets(data) - container_bridges = get_container_bridges() - - L = [] - def line(s=""): - L.append(s) - - line("# Generated by core.py -- do not edit manually.") - line("# Edit config.json and re-run: sudo python3 core.py --apply") - line() - - # ========================================================================== - # {PRODUCT_NAME}-nat table - # ========================================================================== - - line(f"table ip {PRODUCT_NAME}-nat {{") - line() - line(" chain prerouting {") - line(" type nat hook prerouting priority dstnat - 10; policy accept;") - line() - - if all_fwd: - line(" # -- Port forwarding (inbound WAN -> LAN host) ---------------") - line() - for rule in all_fwd: - for proto, r, suffix in expand_protocols(rule): - line(f" # {r['description']}{suffix}") - line(f" iif \"{wan}\" {proto} dport {r['dest_port']} dnat to {r['nat_ip']}:{r['nat_port']}") - line() - - if all_wrngl: - line(" # -- Port wrangling (redirect VLAN traffic to local host) ----") - line() - for vlan, rule in all_wrngl: - iface = derive_interface(vlan, data) - for proto, r, suffix in expand_protocols(rule): - line(f" # {r['description']}{suffix}") - line(f" iif \"{iface}\" {proto} dport {r['dest_port']} ip daddr != {r['redirect_to']} dnat to {r['redirect_to']}") - line() - - line(" }") - line() - line(" chain postrouting {") - line(" type nat hook postrouting priority srcnat; policy accept;") - line() - - line(" # Masquerade all outbound traffic through WAN") - line(f" oif \"{wan}\" masquerade") - line() - - line(" }") - line() - line("}") - line() - - # ========================================================================== - # {PRODUCT_NAME}-filter table - # ========================================================================== - - line(f"table ip {PRODUCT_NAME}-filter {{") - line() - - if banned_v4: - line(" set banned_ipv4 {") - line(" type ipv4_addr") - line(" flags interval") - elements = ", ".join(banned_v4) - line(f" elements = {{ {elements} }}") - line(" }") - line() - - # INPUT chain - line(" # INPUT -- traffic destined for this machine itself") - line(" chain input {") - line(" type filter hook input priority filter; policy drop;") - line() - if banned_v4: - line(" # Drop banned IPs on WAN inbound") - line(f" iif \"{wan}\" ip saddr @banned_ipv4 drop") - line() - line(" # Allow loopback") - line(" iif \"lo\" accept") - line() - line(" # Allow established/related return traffic") - line(" ct state established,related accept") - line() - line(" # Allow ICMP (ping) from anywhere") - line(" ip protocol icmp accept") - line() - - # mDNS -- allow avahi-daemon to receive mDNS on reflection interfaces - if avahi_enabled(data): - mdns_ifaces = avahi_interfaces(data) - if mdns_ifaces: - iface_set = ", ".join(f'"{i}"' for i in mdns_ifaces) - line(" # mDNS (port 5353) -- allow on reflection interfaces for avahi") - line(f" iif {{ {iface_set} }} udp dport 5353 accept") - line() - - # RADIUS -- must come BEFORE the broad VLAN accept rules below, - # otherwise the broad accept fires first and the drop is never reached. - r_clients = radius_clients(data) - if r_clients: - allowed_ips = ", ".join(r["ip"] for r, _ in r_clients) - line(" # RADIUS (port 1812) -- allow only designated authenticators") - line(f" ip saddr {{ {allowed_ips} }} udp dport 1812 accept") - line(" udp dport 1812 drop") - line() - - if container_bridges: - iface_set = ", ".join(f'"{b}"' for b in container_bridges) - line(" # Allow DNS from container bridge networks (Docker, Podman, etc.)") - line(f" iif {{ {iface_set} }} meta l4proto {{ tcp, udp }} th dport 53 accept") - line() - - line(" # Allow all traffic inbound from any VLAN interface") - for vlan in vlans: - line(f" iif \"{derive_interface(vlan, data)}\" accept # {vlan['name']}") - line() - - if all_fwd: - line(" # Allow inbound WAN access for port-forwarded services") - line() - for rule in all_fwd: - for proto, r, suffix in expand_protocols(rule): - line(f" # {r['description']}{suffix}") - line(f" iif \"{wan}\" {proto} dport {r['dest_port']} accept") - line() - - line(" # Drop all other inbound WAN traffic") - line(" }") - line() - - # FORWARD chain - line(" # FORWARD -- traffic being routed through this machine") - line(" chain forward {") - line(" type filter hook forward priority filter; policy drop;") - line() - if banned_v4: - line(" # Drop banned IPs on WAN inbound") - line(f" iif \"{wan}\" ip saddr @banned_ipv4 drop") - line() - line(" # Allow established/related return traffic") - line(" ct state established,related accept") - line() - - line(" # Allow each VLAN -> WAN (outbound internet)") - for vlan in vlans: - line(f" iif \"{derive_interface(vlan, data)}\" oif \"{wan}\" accept # {vlan['name']} -> WAN") - line() - - if container_bridges: - line(" # Allow VLAN -> Docker bridge forwarding") - for vlan in vlans: - for bridge in container_bridges: - line(f" iif \"{derive_interface(vlan, data)}\" oif \"{bridge}\" ct state new accept" - f" # {vlan['name']} -> {bridge}") - line() - - line(" # Allow Docker containers -> WAN (outbound internet access)") - line(f" iif != \"{wan}\" oif \"{wan}\" ct state new accept") - line() - - if avahi_enabled(data): - mdns_ifaces = avahi_interfaces(data) - if len(mdns_ifaces) > 1: - iface_set = ", ".join(f'"{i}"' for i in mdns_ifaces) - line(" # mDNS forwarding between reflection interfaces for avahi") - line(f" iif {{ {iface_set} }} oif {{ {iface_set} }} udp dport 5353 accept") - line() - - if all_except: - line(" # -- Inter-VLAN exceptions ------------------------------------------") - line() - for r in all_except: - src = r["src_ip_or_subnet"] - dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") - min_p = r.get("dest_port_start") or r.get("dst_port") - max_p = r.get("dest_port_end") - if min_p and max_p and str(min_p) != str(max_p): - port_spec = f"{min_p}-{max_p}" - elif min_p: - port_spec = str(min_p) - else: - port_spec = None - for proto, _, suffix in expand_protocols(r): - line(f" # {r['description']}{suffix}") - if port_spec is not None: - line(f" ip saddr {src} ip daddr {dst} {proto} dport {port_spec} ct state new accept") - else: - line(f" ip saddr {src} ip daddr {dst} ip protocol {proto} ct state new accept") - line() - - if all_fwd: - line(" # Allow inbound WAN -> VLAN for active port forwarding rules") - line() - for rule in all_fwd: - try: - nat_addr = ipaddress.IPv4Address(rule["nat_ip"]) - iface = wan # fallback - for iface_key, net in vlan_networks.items(): - if nat_addr in net: - iface = iface_key - break - except ValueError: - iface = wan - for proto, r, suffix in expand_protocols(rule): - line(f" # {r['description']}{suffix}") - line(f" iif \"{wan}\" oif \"{iface}\" {proto} dport {r['nat_port']} ip daddr {r['nat_ip']} ct state new accept") - line() - - line(" }") - line() - line(" chain output {") - line(" type filter hook output priority filter; policy accept;") - line(" }") - line() - line("}") - - if banned_v6: - line() - line(f"table ip6 {PRODUCT_NAME}-ban {{") - line() - line(" set banned_ipv6 {") - line(" type ipv6_addr") - line(" flags interval") - elements = ", ".join(banned_v6) - line(f" elements = {{ {elements} }}") - line(" }") - line() - line(" chain input {") - line(" type filter hook input priority filter; policy accept;") - line(f" iif \"{wan}\" ip6 saddr @banned_ipv6 drop") - line(" }") - line() - line(" chain forward {") - line(" type filter hook forward priority filter; policy accept;") - line(f" iif \"{wan}\" ip6 saddr @banned_ipv6 drop") - line(" }") - line() - line("}") - - return "\n".join(L) - -# =================================================================== -# nftables apply / disable / status -# =================================================================== - -def table_exists(family, name): - result = subprocess.run( - ["nft", "list", "table", family, name], - capture_output=True, text=True - ) - return result.returncode == 0 - -def delete_our_tables(): - for family, table in [("ip", f"{PRODUCT_NAME}-nat"), ("ip", f"{PRODUCT_NAME}-filter"), ("ip6", f"{PRODUCT_NAME}-ban")]: - if table_exists(family, table): - result = subprocess.run( - ["nft", "delete", "table", family, table], - capture_output=True, text=True - ) - if result.returncode != 0: - die(f"Failed to delete table {family} {table}: {result.stderr.strip()}") - print(f"Removed existing table: {family} {table}") - else: - print(f"Table not present, skipping delete: {family} {table}") - -def apply_nft_config(config_text): - result = subprocess.run( - ["nft", "-f", "-"], - input=config_text, - capture_output=True, text=True - ) - if result.returncode != 0: - print("ERROR: nft rejected the ruleset:", file=sys.stderr) - print(result.stderr, file=sys.stderr) - sys.exit(1) - -def apply_nftables(data, dry_run=False): - config = build_nft_config(data, dry_run=dry_run) - if dry_run: - print(config) - return - - active_ifaces = {derive_interface(v, data) for v in data["vlans"] - if not is_wg(v) or wg_interface_up(derive_interface(v, data))} - active_vlans = [v for v in data["vlans"] if derive_interface(v, data) in active_ifaces] - - all_fwd = list(rule_enabled(data.get("port_forwarding", []))) - all_dis_fwd = list(rule_disabled(data.get("port_forwarding", []))) - _active_vlan_by_name = {v["name"]: v for v in active_vlans} - all_wrngl = [(_active_vlan_by_name[r["vlan"]], r) - for r in rule_enabled(data.get("port_wrangling", [])) - if r.get("vlan") in _active_vlan_by_name] - all_dis_wrngl = rule_disabled(data.get("port_wrangling", [])) - all_except = rule_enabled(data.get("inter_vlan_exceptions", [])) - - print(f"Applying {len(all_fwd)} port forwarding rule(s), {len(all_dis_fwd)} skipped.") - print(f"Applying {len(all_wrngl)} port wrangling rule(s), {len(all_dis_wrngl)} skipped.") - print(f"Applying {len(all_except)} inter-VLAN exception(s).") - container_bridges = get_container_bridges() - if container_bridges: - print(f"Container bridges: {', '.join(container_bridges)}") - print() - - delete_our_tables() - apply_nft_config(config) - print("nftables rules applied successfully.") - - # Build set of active subnets for filtering exception display - active_subnets = [] - for v in data["vlans"]: - if is_wg(v) and not wg_interface_up(derive_interface(v, data)): - continue - try: - active_subnets.append(network_for(v)) - except (KeyError, ValueError): - pass - - def dst_is_active(r): - dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") - try: - # Single IP -- check if it's in an active subnet - addr = ipaddress.IPv4Address(dst) - return any(addr in net for net in active_subnets) - except ValueError: - try: - # Subnet -- check if it overlaps with any active subnet - net = ipaddress.IPv4Network(dst, strict=False) - return any(net.overlaps(s) for s in active_subnets) - except ValueError: - return True - - if all_fwd: - print() - print("Active port forwarding:") - for r in all_fwd: - print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['nat_ip']}:{r['nat_port']} ({r['description']})") - - if all_dis_fwd: - print() - print("Skipped port forwarding (disabled):") - for r in all_dis_fwd: - print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['nat_ip']}:{r['nat_port']} ({r['description']})") - - if all_wrngl: - print() - print("Active port wrangling:") - for vlan, r in all_wrngl: - print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['redirect_to']} ({r['description']}) [{vlan['name']}]") - - active_except = [r for r in all_except if dst_is_active(r)] - if active_except: - print() - print("Active inter-VLAN exceptions:") - for r in active_except: - src = r["src_ip_or_subnet"] - dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") - min_p = r.get("dest_port_start") or r.get("dst_port") - max_p = r.get("dest_port_end") - if min_p and max_p and str(min_p) != str(max_p): - port_str = f":{min_p}-{max_p}" - elif min_p: - port_str = f":{min_p}" - else: - port_str = "" - dst_str = f"{dst}{port_str}" - print(f" [{r['protocol'].upper():<4}] {src} -> {dst_str} ({r['description']})") - -def show_rules(): - for table in (f"{PRODUCT_NAME}-nat", f"{PRODUCT_NAME}-filter"): - result = subprocess.run( - ["nft", "list", "table", "ip", table], - capture_output=True, text=True - ) - if result.returncode != 0: - print(f"[{table}] not found (not yet applied)") - else: - print(result.stdout) - -# =================================================================== -# nftables boot service -# =================================================================== - -def install_nat_service(): - script_path = Path(__file__).resolve() - - service_content = f"""[Unit] -Description=Apply {PRODUCT_NAME} NAT and firewall rules -After=network-online.target docker.service -Wants=network-online.target docker.service - -[Service] -Type=oneshot -ExecStart=/usr/bin/python3 {script_path} --apply -RemainAfterExit=yes -Restart=on-failure -RestartSec=5s - -[Install] -WantedBy=multi-user.target -""" - - existing = NAT_SERVICE_FILE.read_text() if NAT_SERVICE_FILE.exists() else None - if existing == service_content: - print(f"Boot service already up to date: {NAT_SERVICE_FILE}") - return - - NAT_SERVICE_FILE.write_text(service_content) - subprocess.run(["systemctl", "daemon-reload"], check=True) - subprocess.run(["systemctl", "enable", NAT_SERVICE_NAME], check=True) - if existing is None: - print(f"Boot service installed and enabled: {NAT_SERVICE_FILE}") - else: - print(f"Boot service updated: {NAT_SERVICE_FILE}") - -def remove_nat_service(): - if NAT_SERVICE_FILE.exists(): - subprocess.run(["systemctl", "disable", "--now", NAT_SERVICE_NAME], - capture_output=True, text=True) - NAT_SERVICE_FILE.unlink() - subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) - print(f"Removed boot service: {NAT_SERVICE_NAME}.service") - else: - print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service") - -# =================================================================== -# Status -# =================================================================== - -# =================================================================== -# RADIUS -# =================================================================== - -RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret" -RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf") -RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users") -RADIUS_CONF_FILE = Path("/etc/freeradius/3.0/radiusd.conf") -RADIUS_EAP_FILE = Path("/etc/freeradius/3.0/mods-available/eap") -RADIUS_HUNTGROUPS = Path("/etc/freeradius/3.0/huntgroups") -RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log") -RADIUS_HUNTGROUP_NAME = "routlin-aps" - -def radius_clients(data): - """Return list of (reservation, vlan) tuples where radius_client is True.""" - vlan_by_name = {v["name"]: v for v in data.get("vlans", [])} - return [ - (r, vlan_by_name[r["vlan"]]) - for r in data.get("dhcp_reservations", []) - if r.get("radius_client") is True and r.get("vlan") in vlan_by_name - ] - -def radius_enabled(data): - """Return True if any reservation has radius_client: true.""" - return len(radius_clients(data)) > 0 - -def ensure_radius_secret(): - """Generate a random RADIUS shared secret if .radius-secret does not exist.""" - if RADIUS_SECRET_FILE.exists(): - return RADIUS_SECRET_FILE.read_text().strip() - import secrets as _secrets - secret = _secrets.token_urlsafe(32) - RADIUS_SECRET_FILE.write_text(secret + "\n") - RADIUS_SECRET_FILE.chmod(0o644) - print(f"Generated RADIUS shared secret: {RADIUS_SECRET_FILE}") - print(f" ACTION REQUIRED: enter this shared secret into your managed switch's RADIUS configuration:") - print(f" {secret}") - return secret - -def build_radius_clients_conf(data, secret): - """Generate freeradius clients.conf from reservations with radius_client: true.""" - lines = [ - "# Generated by core.py -- do not edit manually.", - "# Edit config.json and re-run: sudo python3 core.py --apply", - "", - "# localhost (required)", - "client localhost {", - " ipaddr = 127.0.0.1", - f" secret = {secret}", - " shortname = localhost", - "}", - "", - ] - for r, vlan in radius_clients(data): - name = r.get("hostname") or r.get("description", "unknown").replace(" ", "-").lower() - lines += [ - f"# {r['description']}", - f"client {name} {{", - f" ipaddr = {r['ip']}", - f" secret = {secret}", - f" shortname = {name}", - "}", - "", - ] - return "\n".join(lines) - -def _fmt_mac(raw, fmt): - c = raw.replace(':', '').replace('-', '').lower() - pairs = [c[i:i+2] for i in range(0, 12, 2)] - upper = fmt[0].isupper() - if fmt in ('aabbccddeeff', 'AABBCCDDEEFF'): - sep = '' - elif fmt in ('aa-bb-cc-dd-ee-ff', 'AA-BB-CC-DD-EE-FF'): - sep = '-' - else: - sep = ':' - joined = sep.join(pairs) - return joined.upper() if upper else joined - - -def build_radius_users(data): - """ - Generate freeradius users file. - Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID. - Unknown MACs fall through to DEFAULT which returns the radius_default VLAN. - MAC format and DEFAULT rule scope are read from radius.options in config. - """ - default_vlan = next( - (v for v in data["vlans"] if v.get("radius_default") is True), None - ) - if default_vlan is None: - die("No VLAN has radius_default: true. Cannot generate RADIUS users file.") - - fr_opts = data.get('radius', {}).get('options', {}) - mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff') - apply_to = fr_opts.get('apply_to', 'all') - - lines = [ - "# Generated by core.py -- do not edit manually.", - "# Edit config.json and re-run: sudo python3 core.py --apply", - "", - ] - - vlan_by_name = {v["name"]: v for v in data.get("vlans", [])} - for r in data.get("dhcp_reservations", []): - if r.get("enabled") is not True: - continue - raw_mac = r.get("mac", "") - if not raw_mac: - continue - vlan = vlan_by_name.get(r.get("vlan", "")) - if not vlan: - continue - mac = _fmt_mac(raw_mac, mac_fmt) - vlan_id = vlan.get('vlan_id') - lines += [ - f"# {r['description']} -> VLAN {vlan_id} ({vlan['name']})", - f"{mac} Cleartext-Password := \"{mac}\"", - f" Tunnel-Type = VLAN,", - f" Tunnel-Medium-Type = IEEE-802,", - f" Tunnel-Private-Group-Id = \"{vlan_id}\"", - "", - ] - - default_id = default_vlan.get('vlan_id') - ap_ips = fr_opts.get('ap_ips', []) - if apply_to == 'wireless': - default_check = "DEFAULT NAS-Port-Type = Wireless-802.11, Auth-Type := Accept" - elif apply_to == 'huntgroup' and ap_ips: - default_check = f'DEFAULT Huntgroup-Name == "{RADIUS_HUNTGROUP_NAME}", Auth-Type := Accept' - else: - default_check = "DEFAULT Auth-Type := Accept" - lines += [ - f"# Default -- unknown MACs land on VLAN {default_id} ({default_vlan['name']})", - default_check, - f" Tunnel-Type = VLAN,", - f" Tunnel-Medium-Type = IEEE-802,", - f" Tunnel-Private-Group-Id = \"{default_id}\"", - "", - ] - - return "\n".join(lines) - -def _set_freeradius_log(enabled): - """Enable or disable auth logging lines in radiusd.conf.""" - if not RADIUS_CONF_FILE.exists(): - return False - import re - value = 'yes' if enabled else 'no' - content = RADIUS_CONF_FILE.read_text() - updated = re.sub(r'(?m)^(\s*auth\s*=\s*)(yes|no)', rf'\g<1>{value}', content) - updated = re.sub(r'(?m)^(\s*auth_accept\s*=\s*)(yes|no)', rf'\g<1>{value}', updated) - updated = re.sub(r'(?m)^(\s*auth_reject\s*=\s*)(yes|no)', rf'\g<1>{value}', updated) - if updated == content: - print(f"radiusd.conf: auth logging already {'enabled' if enabled else 'disabled'}.") - return False - RADIUS_CONF_FILE.write_text(updated) - print(f"radiusd.conf: auth logging {'enabled' if enabled else 'disabled'}.") - return True - - -def _write_huntgroups(data): - opts = data.get('radius', {}).get('options', {}) - apply_to = opts.get('apply_to', 'all') - ap_ips = opts.get('ap_ips', []) - if apply_to != 'huntgroup' or not ap_ips: - return False - lines = [ - "# Generated by core.py -- do not edit manually.", - "# Edit config.json and re-run: sudo python3 core.py --apply", - "", - ] - for ip in ap_ips: - lines.append(f"{RADIUS_HUNTGROUP_NAME} NAS-IP-Address == {ip}") - content = "\n".join(lines) + "\n" - existing = RADIUS_HUNTGROUPS.read_text() if RADIUS_HUNTGROUPS.exists() else None - if existing == content: - return False - RADIUS_HUNTGROUPS.write_text(content) - print(f"Written: {RADIUS_HUNTGROUPS}") - return True - - -def _set_freeradius_eap(data): - if not RADIUS_EAP_FILE.exists(): - return False - eap_cfg = data.get('radius', {}).get('eap', {}) - tunneled_reply = eap_cfg.get('tunneled_reply', False) - import re - tr_val = 'yes' if tunneled_reply else 'no' - content = RADIUS_EAP_FILE.read_text() - updated = re.sub(r'(?m)^(\s*use_tunneled_reply\s*=\s*)(yes|no)', rf'\g<1>{tr_val}', content) - if updated == content: - return False - RADIUS_EAP_FILE.write_text(updated) - print(f"EAP: tunneled_reply={tr_val}") - return True - - -def apply_radius(data): - """Write FreeRADIUS config files and restart the service.""" - secret = ensure_radius_secret() - - clients_content = build_radius_clients_conf(data, secret) - users_content = build_radius_users(data) - - logging = data.get('radius', {}).get('general', {}).get('logging', False) - - changed = _set_freeradius_log(logging) - changed |= _write_huntgroups(data) - changed |= _set_freeradius_eap(data) - for path, content in [(RADIUS_CLIENTS_CONF, clients_content), - (RADIUS_USERS_FILE, users_content)]: - existing = path.read_text() if path.exists() else None - if existing != content: - path.write_text(content) - print(f"Written: {path}") - changed = True - else: - print(f"Unchanged: {path}") - - # Always ensure service is running; restart only if config changed - svc = "freeradius" - state = subprocess.run( - ["systemctl", "is-active", svc], capture_output=True, text=True - ).stdout.strip() - if state == "active": - if changed: - result = subprocess.run(["systemctl", "restart", svc], - capture_output=True, text=True) - if result.returncode == 0: - print("freeradius restarted.") - else: - service_warning("restart", "freeradius", result.stderr) - else: - print("freeradius: running, config unchanged.") - else: - subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) - result = subprocess.run(["systemctl", "start", svc], - capture_output=True, text=True) - if result.returncode == 0: - print("freeradius started.") - else: - service_warning("start", "freeradius", result.stderr) - - -# =================================================================== -# Avahi mDNS Reflector -# =================================================================== - -AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf") - -def avahi_enabled(data): - """Return True if at least one non-WireGuard VLAN has mdns_reflection enabled.""" - return any(v.get("mdns_reflection") is True for v in data.get("vlans", []) if not is_wg(v)) - -def avahi_interfaces(data): - """Return list of interface names for VLANs with mdns_reflection enabled.""" - return [derive_interface(v, data) for v in data.get("vlans", []) if v.get("mdns_reflection") is True and not is_wg(v)] - -def build_avahi_conf(data): - """Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection. - Reads the existing file (default or previously patched) and modifies only - the specific directives we need, leaving everything else untouched. - """ - ifaces = avahi_interfaces(data) - - if not AVAHI_CONF_FILE.exists(): - return None - - content = AVAHI_CONF_FILE.read_text() - - def set_directive(text, directive, value): - """Enable and set a directive, whether it is commented out or already set.""" - import re - # Match the directive commented out (#directive=...) or set (directive=...) - pattern = re.compile( - rf"^#?\s*{re.escape(directive)}\s*=.*$", re.MULTILINE - ) - replacement = f"{directive}={value}" - if pattern.search(text): - return pattern.sub(replacement, text) - # Not present at all - this shouldn't happen with a standard avahi install - # but append it to the relevant section if needed - return text + f"\n{replacement}\n" - - content = set_directive(content, "use-ipv6", "no") - content = set_directive(content, "disallow-other-stacks", "yes") - content = set_directive(content, "allow-interfaces", ",".join(ifaces)) - content = set_directive(content, "enable-reflector", "yes") - content = set_directive(content, "disable-publishing", "yes") - - return content - -def apply_avahi(data): - """Write avahi-daemon.conf and ensure service is running.""" - import shutil - if not shutil.which("avahi-daemon"): - print("avahi-daemon is not installed.") - print(" -> Run: sudo python3 install.py") - return - - ifaces = avahi_interfaces(data) - - if len(ifaces) < 2: - print("mDNS reflection requires at least two VLANs in reflect_vlans. Skipping.") - return - - if not AVAHI_CONF_FILE.exists(): - print(f"WARNING: {AVAHI_CONF_FILE} not found. Run: sudo python3 install.py") - return - - content = build_avahi_conf(data) - existing = AVAHI_CONF_FILE.read_text() - changed = existing != content - if changed: - AVAHI_CONF_FILE.write_text(content) - print(f"Written: {AVAHI_CONF_FILE}") - print(f" Reflecting mDNS across: {', '.join(ifaces)}") - else: - print(f"Unchanged: {AVAHI_CONF_FILE}") - - svc = "avahi-daemon" - state = subprocess.run( - ["systemctl", "is-active", svc], capture_output=True, text=True - ).stdout.strip() - - if state == "active": - if changed: - result = subprocess.run(["systemctl", "restart", svc], - capture_output=True, text=True) - if result.returncode == 0: - print("avahi-daemon restarted.") - else: - service_warning("restart", "avahi-daemon", result.stderr) - else: - print("avahi-daemon: running, config unchanged.") - else: - subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) - result = subprocess.run(["systemctl", "start", svc], - capture_output=True, text=True) - if result.returncode == 0: - print("avahi-daemon started.") - else: - service_warning("start", "avahi-daemon", result.stderr) - -def disable_avahi(): - """Stop and disable avahi-daemon.""" - result = subprocess.run( - ["systemctl", "is-active", "avahi-daemon"], capture_output=True, text=True - ) - if result.stdout.strip() == "active": - subprocess.run(["systemctl", "disable", "--now", "avahi-daemon"], - capture_output=True, text=True) - print("avahi-daemon stopped and disabled.") - else: - print("avahi-daemon: not running, skipping.") def _remove_pending_cmd(cmd): @@ -2193,333 +152,33 @@ def _remove_pending_cmd(cmd): def show_status(data): - import health as _health - _, _status = _health.run_and_write(data) - _health.print_table(_status) + _, status = health.run_and_write(data) + health.print_table(status) def show_configs(data): for vlan in data["vlans"]: - cf = vlan_conf_file(vlan) + cf = shared.vlan_conf_file(vlan) if cf.exists(): print(f"# -- {cf} --") print(cf.read_text()) else: print(f"No config found at {cf} (not yet applied).") -# =================================================================== -# Leases -# =================================================================== - -def reset_leases(data, vlan_name=None): - """Stop dnsmasq instances, delete lease files, restart instances. - If vlan_name is given, only reset that VLAN. Otherwise reset all. - """ - check_root() - vlans = [v for v in data["vlans"] if not is_wg(v)] - if vlan_name: - vlans = [v for v in vlans if v["name"] == vlan_name] - if not vlans: - die(f"Unknown VLAN name '{vlan_name}'. " - f"Valid names: {', '.join(v['name'] for v in data['vlans'] if not is_wg(v))}") - - print(f"Resetting leases for: {', '.join(v['name'] for v in vlans)}") - print() - - # Stop - for vlan in vlans: - svc = vlan_service_name(vlan, derive_interface(vlan, data)) - result = subprocess.run(["systemctl", "stop", svc], - capture_output=True, text=True) - if result.returncode == 0: - print(f" Stopped: {svc}") - else: - print(f" WARNING: Could not stop {svc}: {result.stderr.strip()}") - - # Delete lease files - print() - for vlan in vlans: - lf = vlan_leases_file(vlan) - if lf.exists(): - lf.unlink() - print(f" Deleted: {lf}") - else: - print(f" No lease file: {lf}") - - # Restart - print() - for vlan in vlans: - svc = vlan_service_name(vlan, derive_interface(vlan, data)) - result = subprocess.run(["systemctl", "start", svc], - capture_output=True, text=True) - if result.returncode == 0: - print(f" Started: {svc}") - else: - print(f" WARNING: Could not start {svc}: {result.stderr.strip()}") - - print() - print("Done. Devices will get fresh leases on their next DHCP request.") - - -def show_leases(data): - # Build MAC -> reservation lookup across all VLANs - vlan_by_name = {v["name"]: v for v in data.get("vlans", [])} - res_by_mac = {} - for r in data.get("dhcp_reservations", []): - if r.get("enabled") is True: - mac = r.get("mac", "").lower().strip() - if mac: - res_by_mac[mac] = (r, vlan_by_name.get(r.get("vlan", ""), {})) - - now = int(datetime.now().timestamp()) - any_leases = False - - for vlan in data["vlans"]: - if is_wg(vlan): - continue - lf = vlan_leases_file(vlan) - if not lf.exists(): - continue - lines = lf.read_text().strip().splitlines() - if not lines: - continue - - if not any_leases: - print(f"{'IP':<18} {'MAC':<20} {'HOSTNAME':<26} {'VLAN':<8} {'EXPIRES':<22} {'TIME LEFT':<18} {'TYPE':<8} {'DESCRIPTION'}") - print("-" * 145) - any_leases = True - - for entry in lines: - parts = entry.split() - if len(parts) < 4: - continue - expire_ts = parts[0] - mac = parts[1] - ip = parts[2] - hostname = parts[3] if parts[3] != "*" else "(unknown)" - hostname = hostname[:26] - - if expire_ts == "0": - expires_str = "permanent" - time_left = "" - else: - expire_int = int(expire_ts) - seconds = expire_int - now - if seconds <= 0: - expires_str = "expired" - time_left = "" - else: - hours, rem = divmod(seconds, 3600) - mins, _ = divmod(rem, 60) - expire_dt = datetime.fromtimestamp(expire_int) - expires_str = expire_dt.strftime("%Y-%m-%d %H:%M:%S") - time_left = f"{hours}h {mins}m" - - match = res_by_mac.get(mac.lower()) - lease_type = "static" if (match and not is_dynamic_ip(match[0])) else "dynamic" - description = match[0].get("description", "") if match else "" - print(f"{ip:<18} {mac:<20} {hostname:<26} {vlan['name']:<8} {expires_str:<22} {time_left:<18} {lease_type:<8} {description}") - - if not any_leases: - print("No active leases found.") - -# =================================================================== -# Metrics -# =================================================================== - -def collect_metrics(data): - """ - Send SIGUSR1 to each running dnsmasq instance and parse stats from - journalctl. Returns a combined metrics dict, or None if unavailable. - """ - metrics = { - "queries_forwarded": 0, - "queries_answered_locally": 0, - "queries_authoritative": 0, - "cache_reused": 0, - "tcp_hwm": 0, - "tcp_max_allowed": 0, - "pool_memory_max": 0, - "dnssec_subqueries_hwm": 0, - "dnssec_crypto_hwm": 0, - "dnssec_sig_fails_hwm": 0, - "servers": [] - } - - any_running = False - for vlan in data["vlans"]: - svc = vlan_service_name(vlan, derive_interface(vlan, data)) - result = subprocess.run( - ["systemctl", "kill", "--signal=SIGUSR1", svc], - capture_output=True, text=True - ) - if result.returncode != 0: - continue - any_running = True - - if not any_running: - print("No dnsmasq instances are running.") - return None - - time.sleep(1) - - server_map = {} - for vlan in data["vlans"]: - svc = vlan_service_name(vlan, derive_interface(vlan, data)) - result = subprocess.run( - ["journalctl", "-u", svc, "--since", "5 seconds ago", - "--no-pager", "-o", "cat"], - capture_output=True, text=True - ) - for line in result.stdout.splitlines(): - m = re.search(r"cache size \d+, (\d+)/\d+ cache insertions re-used", line) - if m: - metrics["cache_reused"] += int(m.group(1)) - - m = re.search(r"queries forwarded (\d+), queries answered locally (\d+)", line) - if m: - metrics["queries_forwarded"] += int(m.group(1)) - metrics["queries_answered_locally"] += int(m.group(2)) - - m = re.search(r"queries for authoritative zones (\d+)", line) - if m: - metrics["queries_authoritative"] += int(m.group(1)) - - m = re.search(r"highest since last SIGUSR1 (\d+), max allowed (\d+)", line) - if m: - metrics["tcp_hwm"] = max(metrics["tcp_hwm"], int(m.group(1))) - metrics["tcp_max_allowed"] = max(metrics["tcp_max_allowed"], int(m.group(2))) - - m = re.search(r"pool memory in use \d+, max (\d+)", line) - if m: - metrics["pool_memory_max"] = max(metrics["pool_memory_max"], int(m.group(1))) - - m = re.search( - r"server (\S+): queries sent (\d+), retried (\d+), failed (\d+), " - r"nxdomain replies (\d+), avg\. latency (\d+)ms", - line - ) - if m: - addr = m.group(1) - if addr not in server_map: - server_map[addr] = { - "address": addr, "queries_sent": 0, "retried": 0, - "failed": 0, "nxdomain": 0, "avg_latency_ms": 0 - } - server_map[addr]["queries_sent"] += int(m.group(2)) - server_map[addr]["retried"] += int(m.group(3)) - server_map[addr]["failed"] += int(m.group(4)) - server_map[addr]["nxdomain"] += int(m.group(5)) - server_map[addr]["avg_latency_ms"] = int(m.group(6)) - - metrics["servers"] = list(server_map.values()) - return metrics - -def update_metrics_file(new_metrics): - now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - if METRICS_FILE.exists(): - with open(METRICS_FILE) as f: - stored = json.load(f) - else: - stored = { - "metadata": {"first_recorded": now_str, "last_recorded": now_str, "total_updates": 0}, - "totals": { - "queries_forwarded": 0, "queries_answered_locally": 0, - "queries_authoritative": 0, "cache_reused": 0, - "tcp_hwm": 0, "tcp_max_allowed": 0, "pool_memory_max": 0, - "dnssec_subqueries_hwm": 0, "dnssec_crypto_hwm": 0, - "dnssec_sig_fails_hwm": 0, "servers": [] - } - } - - t = stored["totals"] - t["queries_forwarded"] += new_metrics["queries_forwarded"] - t["queries_answered_locally"] += new_metrics["queries_answered_locally"] - t["queries_authoritative"] += new_metrics["queries_authoritative"] - t["cache_reused"] += new_metrics["cache_reused"] - t["tcp_hwm"] = max(t["tcp_hwm"], new_metrics["tcp_hwm"]) - t["pool_memory_max"] = max(t["pool_memory_max"], new_metrics["pool_memory_max"]) - t["dnssec_subqueries_hwm"] = max(t["dnssec_subqueries_hwm"], new_metrics["dnssec_subqueries_hwm"]) - t["dnssec_crypto_hwm"] = max(t["dnssec_crypto_hwm"], new_metrics["dnssec_crypto_hwm"]) - t["dnssec_sig_fails_hwm"] = max(t["dnssec_sig_fails_hwm"], new_metrics["dnssec_sig_fails_hwm"]) - if new_metrics["tcp_max_allowed"]: - t["tcp_max_allowed"] = new_metrics["tcp_max_allowed"] - - existing = {s["address"]: s for s in t["servers"]} - for srv in new_metrics["servers"]: - addr = srv["address"] - if addr in existing: - existing[addr]["queries_sent"] += srv["queries_sent"] - existing[addr]["retried"] += srv["retried"] - existing[addr]["failed"] += srv["failed"] - existing[addr]["nxdomain"] += srv["nxdomain"] - existing[addr]["avg_latency_ms"] = srv["avg_latency_ms"] - else: - existing[addr] = srv.copy() - t["servers"] = list(existing.values()) - - stored["metadata"]["last_recorded"] = now_str - stored["metadata"]["total_updates"] += 1 - - with open(METRICS_FILE, "w") as f: - json.dump(stored, f, indent=2) - chown_to_script_dir_owner(METRICS_FILE) - -def show_metrics(data): - check_root() - new = collect_metrics(data) - if new is None: - return - update_metrics_file(new) - - with open(METRICS_FILE) as f: - data_m = json.load(f) - - m = data_m["metadata"] - t = data_m["totals"] - - print("DNS Metrics (lifetime totals across all VLAN instances)") - print(f" First recorded : {m['first_recorded']}") - print(f" Last recorded : {m['last_recorded']}") - print(f" Total updates : {m['total_updates']}") - print() - print("Queries") - print(f" Forwarded to upstream : {t['queries_forwarded']:,}") - print(f" Answered from cache : {t['queries_answered_locally']:,}") - print(f" Authoritative : {t['queries_authoritative']:,}") - print(f" Cache reused : {t['cache_reused']:,}") - print() - print("TCP") - print(f" Peak concurrent (HWM) : {t['tcp_hwm']}") - print(f" Max allowed : {t['tcp_max_allowed']}") - print() - print(f"Pool memory peak : {t['pool_memory_max']} bytes") - if t["servers"]: - print() - print("Upstream servers") - for s in t["servers"]: - print(f" {s['address']}") - print(f" Sent : {s['queries_sent']:,}") - print(f" Retried : {s['retried']:,}") - print(f" Failed : {s['failed']:,}") - print(f" NXDOMAIN : {s['nxdomain']:,}") - print(f" Latency : {s['avg_latency_ms']}ms (last recorded)") - # =================================================================== # Stop / disable # =================================================================== def stop_instances(data): """Remove timers and stop all per-VLAN instances (config files preserved).""" - _remove_timers( - names=[BLIST_TIMER_NAME, HEALTH_TIMER_NAME, MAINT_TIMER_NAME], - timer_files=[BLIST_TIMER_FILE, HEALTH_TIMER_FILE, MAINT_TIMER_FILE], - svc_files=[BLIST_TIMER_SVC_FILE, HEALTH_TIMER_SVC_FILE, MAINT_TIMER_SVC_FILE], + timers.remove_timers( + names=[timers.BLIST_TIMER_NAME, timers.HEALTH_TIMER_NAME, timers.MAINT_TIMER_NAME], + timer_files=[timers.BLIST_TIMER_FILE, timers.HEALTH_TIMER_FILE, timers.MAINT_TIMER_FILE], + svc_files=[timers.BLIST_TIMER_SVC_FILE, timers.HEALTH_TIMER_SVC_FILE, timers.MAINT_TIMER_SVC_FILE], daemon_reload=True, ) print() for vlan in data["vlans"]: - svc = vlan_service_name(vlan, derive_interface(vlan, data)) + svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) subprocess.run(["systemctl", "disable", "--now", svc], capture_output=True, text=True) print(f"Stopped and disabled: {svc}") @@ -2529,7 +188,7 @@ def disable_all(data): stop_instances(data) print() for vlan in data["vlans"]: - for f in (vlan_conf_file(vlan), vlan_service_file(vlan, derive_interface(vlan, data))): + for f in (shared.vlan_conf_file(vlan), shared.vlan_service_file(vlan, validation.derive_interface(vlan, data))): if f.exists(): f.unlink() print(f"Removed: {f}") @@ -2537,23 +196,25 @@ def disable_all(data): print("systemd daemon reloaded.") print() print("Removing nftables rules =============================================") - delete_our_tables() - remove_nat_service() - if radius_enabled(data): + err = nftables.delete_our_tables() + if err: + die(err) + nftables.remove_nat_service() + if radius.radius_enabled(data): print() print("Stopping RADIUS =====================================================") subprocess.run(["systemctl", "disable", "--now", "freeradius"], capture_output=True, text=True) print("freeradius stopped and disabled.") - if avahi_enabled(data): + if avahi.avahi_enabled(data): print() print("Stopping mDNS Reflector =============================================") - disable_avahi() + avahi.disable_avahi() def _write_client_network(iface, dhcp, static_cidr=None): """Remove all router networkd files and write a plain client .network file.""" for pattern in (f"10-{PRODUCT_NAME}-*.network", f"10-{PRODUCT_NAME}-*.netdev"): - for f in NETWORKD_DIR.glob(pattern): + for f in networkd.NETWORKD_DIR.glob(pattern): f.unlink() print(f"Removed: {f}") @@ -2572,7 +233,7 @@ def _write_client_network(iface, dhcp, static_cidr=None): lines.append(f"Address={static_cidr}") lines.append("") - path = NETWORKD_DIR / f"10-client-{iface}.network" + path = networkd.NETWORKD_DIR / f"10-client-{iface}.network" path.write_text("\n".join(lines)) print(f"Written: {path}") @@ -2611,7 +272,7 @@ def _suggest_static_ip(physical_vlan): Falls back to a random unused IP in the subnet if all are .1. """ import random - network = network_for(physical_vlan) + network = shared.network_for(physical_vlan) prefix = network.prefixlen identities = physical_vlan.get("server_identities", []) @@ -2665,7 +326,7 @@ def _dry_run_conflicting_services(data): chrony_conf = Path("/etc/chrony/chrony.conf") if chrony_conf.exists(): content = chrony_conf.read_text() - subnets = [str(network_for(v)) for v in data["vlans"]] + subnets = [str(shared.network_for(v)) for v in data["vlans"]] missing = [s for s in subnets if f"allow {s}" not in content] if missing: print(f" Would add chrony allow directives for: {', '.join(missing)}") @@ -2693,9 +354,9 @@ def _dry_run_conflicting_services(data): else: print(" system dnsmasq.service: not enabled - no action needed") - physical = next((v for v in data["vlans"] if is_physical(v)), None) + physical = next((v for v in data["vlans"] if shared.is_physical(v)), None) if physical: - gw = resolve_vlan_options(physical)["gateway"] + gw = shared.resolve_vlan_options(physical)["gateway"] if RESOLV_CONF.is_symlink(): print(f" Would replace /etc/resolv.conf symlink with plain file: nameserver {gw}") else: @@ -2715,10 +376,10 @@ def _dry_run_blocklists(data): for vlan in data["vlans"]: names = vlan.get("use_blocklists", []) if names: - h = combo_hash(names) + h = dnsmasq.combo_hash(names) if h not in seen: seen[h] = sorted(names) - path = merged_path(h) + path = dnsmasq.merged_path(h) action = "update" if path.exists() else "create" print(f" Would {action} merged blocklist: {path}") print(f" Sources: {', '.join(sorted(names))}") @@ -2727,7 +388,7 @@ def _dry_run_timer(data): print("Timer (dry-run) =====================================================") general = data.get("dns_blocking", {}).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")]: + for path, label in [(timers.BLIST_TIMER_FILE, "timer unit"), (timers.BLIST_TIMER_SVC_FILE, "service unit")]: action = "update" if path.exists() else "create and enable" print(f" Would {action}: {path}") print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)") @@ -2735,8 +396,8 @@ def _dry_run_timer(data): def _dry_run_boot_service(): 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}") + action = "update" if nftables.NAT_SERVICE_FILE.exists() else "create and enable" + print(f" Would {action}: {nftables.NAT_SERVICE_FILE}") print(f" ExecStart: /usr/bin/python3 {script_path} --apply") print(f" After: network-online.target docker.service") print(f" WantedBy: multi-user.target (runs on every boot)") @@ -2747,12 +408,12 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, print() print(f"-- Stopping {PRODUCT_NAME} services (dry-run) --------------------------------") - print(f" Would disable and stop: {BLIST_TIMER_NAME}.timer") + print(f" Would disable and stop: {timers.BLIST_TIMER_NAME}.timer") for vlan in data["vlans"]: - iface = derive_interface(vlan, data) - svc = vlan_service_name(vlan, iface) - conf = vlan_conf_file(vlan) - svc_f = vlan_service_file(vlan, iface) + iface = validation.derive_interface(vlan, data) + svc = shared.vlan_service_name(vlan, iface) + conf = shared.vlan_conf_file(vlan) + svc_f = shared.vlan_service_file(vlan, iface) print(f" Would stop and disable: {svc}") if conf.exists(): print(f" Would remove: {conf}") @@ -2766,10 +427,10 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, print(f" Would flush nftables table: {table}") else: print(f" nftables table {table}: not present - no action needed") - if NAT_SERVICE_FILE.exists(): - print(f" Would stop, disable, and remove: {NAT_SERVICE_NAME}.service") + if nftables.NAT_SERVICE_FILE.exists(): + print(f" Would stop, disable, and remove: {nftables.NAT_SERVICE_NAME}.service") else: - print(f" {NAT_SERVICE_NAME}.service: not installed - no action needed") + print(f" {nftables.NAT_SERVICE_NAME}.service: not installed - no action needed") print() print("Restoring NTP client (dry-run) ======================================") @@ -2787,9 +448,9 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, print() 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" + router_net = list(networkd.NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.network")) + router_dev = list(networkd.NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.netdev")) + client_file = networkd.NETWORKD_DIR / f"10-client-{iface}.network" for f in router_net + router_dev: print(f" Would remove: {f}") print(f" Would write: {client_file}") @@ -2851,11 +512,11 @@ def cmd_disable(data, dry_run=False): # ------------------------------------------------------------------ # Step 2 - IP configuration # ------------------------------------------------------------------ - physical = next((v for v in data["vlans"] if is_physical(v)), None) + physical = next((v for v in data["vlans"] if shared.is_physical(v)), None) if physical is None: die("No physical VLAN (vlan_id=1) found in config. Cannot determine interface.") - iface = derive_interface(physical, data) + iface = validation.derive_interface(physical, data) print(" How should this machine obtain its IP address after reversion?") print() @@ -2909,7 +570,7 @@ def cmd_disable(data, dry_run=False): # ------------------------------------------------------------------ # If resolv.conf is already a plain file with no router gateway IPs, leave it alone. - gateway_ips = {resolve_vlan_options(v)["gateway"] for v in data["vlans"] if not is_wg(v)} + gateway_ips = {shared.resolve_vlan_options(v)["gateway"] for v in data["vlans"] if not validation.is_wg(v)} resolv_ok = False if not RESOLV_CONF.is_symlink() and RESOLV_CONF.exists(): current_servers = { @@ -2979,7 +640,7 @@ def cmd_disable(data, dry_run=False): print() print("Restoring NTP client ================================================") - restore_ntp() + dnsmasq.restore_ntp() print() print("Configuring network interface =======================================") @@ -3025,108 +686,113 @@ def cmd_apply(data, dry_run=False): _dry_run_conflicting_services(data) print() print("systemd-networkd (dry-run) ==========================================") - apply_networkd(data, dry_run=True) + networkd.apply_networkd(data, dry_run=True) print() print("dnsmasq instances (dry-run) =========================================") - apply_dnsmasq_instances(data, dry_run=True, start_if_needed=True) + dnsmasq.apply_dnsmasq_instances(data, dry_run=True, start_if_needed=True) print() print("nftables (dry-run) ==================================================") - apply_nftables(data, dry_run=True) + nftables.apply_nftables(data, dry_run=True) print() _dry_run_timer(data) print() _dry_run_boot_service() - if radius_enabled(data): + if radius.radius_enabled(data): print() print("RADIUS (dry-run) ====================================================") - num_clients = len(radius_clients(data)) + num_clients = len(radius.radius_clients(data)) default_vlan = next((v for v in data["vlans"] if v.get("radius_default") is True), None) total_macs = len([r for r in data.get("dhcp_reservations", []) if r.get("enabled") is True]) - print(f" Would write: {RADIUS_CLIENTS_CONF}") + print(f" Would write: {radius.RADIUS_CLIENTS_CONF}") print(f" {num_clients} RADIUS client(s)") - print(f" Would write: {RADIUS_USERS_FILE}") + print(f" Would write: {radius.RADIUS_USERS_FILE}") print(f" {total_macs} MAC reservation(s)") if default_vlan: print(f" DEFAULT -> VLAN {default_vlan.get('vlan_id')} ({default_vlan['name']})") print(f" Would ensure freeradius is running") - if avahi_enabled(data): + if avahi.avahi_enabled(data): print() print("mDNS Reflection (dry-run) ===========================================") - ifaces = avahi_interfaces(data) - print(f" Would write: {AVAHI_CONF_FILE}") + ifaces = avahi.avahi_interfaces(data) + print(f" Would write: {avahi.AVAHI_CONF_FILE}") print(f" Reflecting across: {', '.join(ifaces)}") print(f" Would ensure avahi-daemon is running") return - check_root() + if not shared.is_root(): + die("This script must be run as root (sudo).") - wg_names = {v["name"] for v in data["vlans"] if is_wg(v)} + wg_names = {v["name"] for v in data["vlans"] if validation.is_wg(v)} non_wg_res = [r for r in data.get("dhcp_reservations", []) if r.get("vlan") not in wg_names] total_enabled = len([r for r in non_wg_res if r.get("enabled") is True]) total_disabled = len([r for r in non_wg_res if r.get("enabled") is not True]) - total_wg_peers = sum(len(v.get("peers", [])) for v in data["vlans"] if is_wg(v)) + total_wg_peers = sum(len(v.get("peers", [])) for v in data["vlans"] if validation.is_wg(v)) wg_part = f", {total_wg_peers} WG peer(s)" if total_wg_peers else "" print(f"Applying config: {len(data['vlans'])} VLAN(s), " f"{total_enabled} reservation(s), {total_disabled} skipped{wg_part}.") print() print("Conflicting services ================================================") - disable_systemd_timesyncd() - ensure_chrony(data) - disable_ufw() + dnsmasq.disable_systemd_timesyncd() + dnsmasq.ensure_chrony(data) + dnsmasq.disable_ufw() print() print("systemd-networkd ====================================================") - apply_networkd(data, only_if_changed=True) + networkd.apply_networkd(data, only_if_changed=True) print() - if any(is_wg(v) for v in data["vlans"]): + if any(validation.is_wg(v) for v in data["vlans"]): print("WireGuard interfaces ================================================") - ensure_wg_interfaces(data) + wireguard.ensure_wg_interfaces(data) print() print("dnsmasq instances ===================================================") - if not blocklists_available(data): + if not dnsmasq.blocklists_available(data): print(" NOTE: No merged blocklist files found -- blocklist rules will be absent.") print(" Run: sudo python3 dns-blocklists.py") - apply_dnsmasq_instances(data, start_if_needed=True) + dnsmasq.apply_dnsmasq_instances(data, start_if_needed=True) print() print("nftables ============================================================") - apply_nftables(data) + nftables.apply_nftables(data) print() print("Timer ===============================================================") - install_timer(data) + err = timers.install_timer(data) + if err: + die(err) print() print("Interval timers =====================================================") - _install_interval_timers( - names=[HEALTH_TIMER_NAME], - timer_files=[HEALTH_TIMER_FILE], - svc_files=[HEALTH_TIMER_SVC_FILE], + timers.install_interval_timers( + names=[timers.HEALTH_TIMER_NAME], + timer_files=[timers.HEALTH_TIMER_FILE], + svc_files=[timers.HEALTH_TIMER_SVC_FILE], descriptions=["Router status health check"], exec_starts=[f"/usr/bin/python3 {SCRIPT_DIR / 'health.py'}"], - interval_secs=[HEALTH_TIMER_INTERVAL_SEC], + interval_secs=[timers.HEALTH_TIMER_INTERVAL_SEC], ) print() print("DDNS timer ==========================================================") enabled_ddns = [p for p in data.get("ddns", {}).get("providers", []) if p.get("enabled")] if enabled_ddns: - install_maint_timer(data) + timers.install_maint_timer(data) else: - _remove_timers([MAINT_TIMER_NAME], [MAINT_TIMER_FILE], [MAINT_TIMER_SVC_FILE]) + timers.remove_timers([timers.MAINT_TIMER_NAME], [timers.MAINT_TIMER_FILE], [timers.MAINT_TIMER_SVC_FILE]) print("No enabled DDNS providers — timer not installed.") print() print("Boot service ========================================================") - install_nat_service() + nftables.install_nat_service() print() - if radius_enabled(data): + if radius.radius_enabled(data): print("RADIUS ==============================================================") - apply_radius(data) + err = radius.apply_radius(data) + if err: + die(err) print() else: svc = "freeradius" @@ -3138,26 +804,25 @@ def cmd_apply(data, dry_run=False): print("freeradius stopped and disabled (no radius_client reservations).") print() - if avahi_enabled(data): + if avahi.avahi_enabled(data): print("mDNS Reflection =====================================================") - apply_avahi(data) + avahi.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 =====================================================") - disable_avahi() + avahi.disable_avahi() print() print("Done.") - import health as _health - _healthy, _status = _health.run_and_write(data) - _health.print_table(_status) + healthy, status = health.run_and_write(data) + health.print_table(status) _remove_pending_cmd('core apply') - if _healthy: + if healthy: _remove_pending_cmd('fix problems') @@ -3207,7 +872,7 @@ def main(): sys.exit(1) data = load_config() - errors = validate_config(data) + errors = validation.validate_config(data) if errors: print("Validation failed:", file=sys.stderr) for e in errors: @@ -3224,25 +889,28 @@ def main(): return if args.view_leases: - show_leases(data) + dnsmasq.show_leases(data) return if args.reset_leases: vlan_name = None if args.reset_leases == "__all__" else args.reset_leases - reset_leases(data, vlan_name) + err = dnsmasq.reset_leases(data, vlan_name) + if err: + die(err) return if args.view_rules: - show_rules() + nftables.show_rules() return if args.view_metrics: - show_metrics(data) + metrics.show_metrics(data) return if args.disable: if not args.dry_run: - check_root() + if not shared.is_root(): + die("This script must be run as root (sudo).") cmd_disable(data, dry_run=args.dry_run) return diff --git a/routlin/health.py b/routlin/health.py index 1965e48..c6f0ae3 100644 --- a/routlin/health.py +++ b/routlin/health.py @@ -11,7 +11,6 @@ Public API: import hashlib import ipaddress import json -import os import re import shutil import socket @@ -20,66 +19,57 @@ import sys from datetime import datetime, timezone from pathlib import Path -from validation import derive_interface, is_wg +import mod_shared as shared +import mod_validation as validation # =================================================================== -# Constants (mirror core.py - no import to avoid circular dependency) +# Constants # =================================================================== -PRODUCT_NAME = "routlin" -SCRIPT_DIR = Path(__file__).parent -HEALTH_FILE = SCRIPT_DIR / ".health" -CONFIG_FILE = SCRIPT_DIR / "config.json" -BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" -DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}") -LEASES_DIR = Path("/var/lib/misc") +HEALTH_FILE = shared.SCRIPT_DIR / ".health" +CONFIG_FILE = shared.SCRIPT_DIR / "config.json" +BLOCKLIST_DIR = shared.SCRIPT_DIR / "blocklists" NETWORKD_DIR = Path("/etc/systemd/network") -SYSTEMD_DIR = Path("/etc/systemd/system") WG_DIR = Path("/etc/wireguard") RESOLV_CONF = Path("/etc/resolv.conf") AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf") CHRONY_CONF_FILE = Path("/etc/chrony/chrony.conf") -RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret" +RADIUS_SECRET_FILE = shared.SCRIPT_DIR / ".radius-secret" RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf") RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users") -BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update" -DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue" -HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check" -MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance" -DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue" -NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat" +BLIST_TIMER_NAME = f"{shared.PRODUCT_NAME}-dns-blocklist-update" +DASHB_TIMER_NAME = f"{shared.PRODUCT_NAME}-dashboard-queue" +HEALTH_TIMER_NAME = f"{shared.PRODUCT_NAME}-health-check" +MAINT_TIMER_NAME = f"{shared.PRODUCT_NAME}-maintenance" +DASHB_QUEUE_FILE = shared.SCRIPT_DIR / ".dashboard-queue" +NAT_SERVICE_NAME = f"{shared.PRODUCT_NAME}-nat" BLOCKLIST_STALE_SECS = 36 * 3600 DISK_WARN_PCT = 90 DHCP_WARN_PCT = 90 DNS_TIMEOUT_SECS = 2 # =================================================================== -# Small helpers replicated from core.py (no import) +# Small helpers # =================================================================== -def _vlan_service_name(vlan, iface): - if is_wg(vlan): - return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{iface}" - return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}" - -def _radius_enabled(data): +def radius_enabled(data): return any( r.get("radius_client") is True for r in data.get("dhcp_reservations", []) ) -def _avahi_enabled(data): +def avahi_enabled(data): return any( v.get("mdns_reflection") is True for v in data.get("vlans", []) - if not is_wg(v) + if not validation.is_wg(v) ) def _avahi_interfaces(data): return [ - derive_interface(v, data) + validation.derive_interface(v, data) for v in data.get("vlans", []) - if v.get("mdns_reflection") is True and not is_wg(v) + if v.get("mdns_reflection") is True and not validation.is_wg(v) ] def _combo_hash(names): @@ -89,25 +79,16 @@ def _combo_hash(names): def _merged_path(h): return BLOCKLIST_DIR / f"merged-{h}.conf" -def _lowest_quartet_ip(vlan): - ips = [] - for s in vlan.get("server_identities", []): - try: - ips.append(ipaddress.IPv4Address(s["ip"])) - except (KeyError, ValueError): - pass - return str(min(ips, key=lambda ip: ip.packed[-1])) if ips else None - def _gateway_ips(data): """Return set of all gateway IPs across all VLANs.""" gws = set() for vlan in data.get("vlans", []): - ip = _lowest_quartet_ip(vlan) + ip = shared.lowest_quartet_ip(vlan) if ip: gws.add(ip) return gws -def _iface_operstate(iface): +def iface_operstate(iface): """Read operstate from sysfs. Returns 'up', 'down', 'unknown', or None.""" try: return Path(f"/sys/class/net/{iface}/operstate").read_text().strip() @@ -125,13 +106,13 @@ def _sysctl_query(unit): # Result builders # =================================================================== -def _ok(id_, name, detail=""): +def ok(id_, name, detail=""): r = {"id": id_, "name": name, "status": "ok"} if detail: r["detail"] = detail return r -def _problem(id_, name, severity, detail, suggestion=""): +def problem(id_, name, severity, detail, suggestion=""): r = {"id": id_, "name": name, "status": "problem", "severity": severity, "detail": detail} if suggestion: @@ -148,8 +129,8 @@ def check_services(data): units = [] for vlan in vlans: - iface = derive_interface(vlan, data) - name = _vlan_service_name(vlan, iface) + iface = validation.derive_interface(vlan, data) + name = shared.vlan_service_name(vlan, iface) units.append({"id": name, "name": name, "expected_active": "active", "expected_enabled": "enabled", "severity": "error"}) @@ -184,15 +165,15 @@ def check_services(data): "expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled, "severity": "warning"}) - exp_fr_active = "active" if _radius_enabled(data) else "inactive" - exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled" + exp_fr_active = "active" if radius_enabled(data) else "inactive" + exp_fr_enabled = "enabled" if radius_enabled(data) else "disabled" units.append({"id": "freeradius", "name": "freeradius", "expected_active": exp_fr_active, "expected_enabled": exp_fr_enabled, "severity": "error"}) - exp_av_active = "active" if _avahi_enabled(data) else "inactive" - exp_av_enabled = "enabled" if _avahi_enabled(data) else "disabled" + exp_av_active = "active" if avahi_enabled(data) else "inactive" + exp_av_enabled = "enabled" if avahi_enabled(data) else "disabled" units.append({"id": "avahi-daemon", "name": "avahi-daemon", "expected_active": exp_av_active, "expected_enabled": exp_av_enabled, @@ -211,7 +192,7 @@ def check_services(data): exp_enabled = u["expected_enabled"] active_ok = active == exp_active enabled_ok = enabled == exp_enabled - status = "ok" if (active_ok and enabled_ok) else "problem" + svc_status = "ok" if (active_ok and enabled_ok) else "problem" results.append({ "id": u["id"], "name": u["name"], @@ -222,7 +203,7 @@ def check_services(data): "active_ok": active_ok, "enabled_ok": enabled_ok, "severity": u.get("severity", "error"), - "status": status, + "status": svc_status, }) return results @@ -234,19 +215,19 @@ def check_services(data): def check_configurations(data): results = [] vlans = data.get("vlans", []) - non_wg = [v for v in vlans if not is_wg(v)] - wg_vlans = [v for v in vlans if is_wg(v)] + non_wg = [v for v in vlans if not validation.is_wg(v)] + wg_vlans = [v for v in vlans if validation.is_wg(v)] def file_ok(id_, name, path, severity="error", suggestion=""): try: exists = path.exists() except PermissionError: - return _problem(id_, name, "warning", - f"{path}: permission denied — run with sudo for accurate status.") + return problem(id_, name, "warning", + f"{path}: permission denied - run with sudo for accurate status.") if not exists: - return _problem(id_, name, severity, - f"{path} does not exist.", - suggestion or f"Run `sudo python3 core.py --apply` to create it.") - return _ok(id_, name) + return problem(id_, name, severity, + f"{path} does not exist.", + suggestion or f"Run `sudo python3 core.py --apply` to create it.") + return ok(id_, name) # --- nftables tables --- try: @@ -255,25 +236,25 @@ def check_configurations(data): ).stdout for tbl in ("ip routlin-nat", "ip routlin-filter"): if tbl in tables_out: - results.append(_ok(f"nft_{tbl.replace(' ', '_')}", - f"nftables table {tbl}")) + results.append(ok(f"nft_{tbl.replace(' ', '_')}", + f"nftables table {tbl}")) else: - results.append(_problem( + results.append(problem( f"nft_{tbl.replace(' ', '_')}", f"nftables table {tbl}", "error", f"nftables table '{tbl}' is missing.", "Run `sudo python3 core.py --apply` to rebuild firewall rules.")) except Exception: - results.append(_problem("nft_tables", "nftables tables", "error", - "Could not query nftables (nft not available or failed).")) + results.append(problem("nft_tables", "nftables tables", "error", + "Could not query nftables (nft not available or failed).")) # --- Docker bridge rules --- try: bridges = [ p.parent.name for p in Path("/sys/class/net").glob("*/bridge") - if _iface_operstate(p.parent.name) == "up" + if iface_operstate(p.parent.name) == "up" ] if bridges: fwd_out = subprocess.run( @@ -282,49 +263,48 @@ def check_configurations(data): ).stdout missing = [b for b in bridges if b not in fwd_out] if missing: - results.append(_problem( + results.append(problem( "nft_docker_bridges", "nftables Docker bridge rules", "warning", f"Container bridge(s) {', '.join(missing)} have no nftables forward rules.", "Run `sudo python3 core.py --apply` to add the missing rules.")) else: - results.append(_ok("nft_docker_bridges", "nftables Docker bridge rules")) + results.append(ok("nft_docker_bridges", "nftables Docker bridge rules")) except Exception: pass # --- VLAN sub-interfaces --- for vlan in non_wg: - iface = derive_interface(vlan, data) - vid = vlan.get("vlan_id") - state = _iface_operstate(iface) + iface = validation.derive_interface(vlan, data) + state = iface_operstate(iface) id_ = f"iface_{vlan['name']}" name = f"interface {iface}" if state is None: - results.append(_problem(id_, name, "error", - f"Interface {iface} does not exist in /sys/class/net/.", - "Run `sudo python3 core.py --apply` to configure network interfaces.")) + results.append(problem(id_, name, "error", + f"Interface {iface} does not exist in /sys/class/net/.", + "Run `sudo python3 core.py --apply` to configure network interfaces.")) elif state != "up": - results.append(_problem(id_, name, "error", - f"Interface {iface} operstate is '{state}' (expected 'up').", - "Check systemd-networkd: `sudo systemctl status systemd-networkd`")) + results.append(problem(id_, name, "error", + f"Interface {iface} operstate is '{state}' (expected 'up').", + "Check systemd-networkd: `sudo systemctl status systemd-networkd`")) else: - results.append(_ok(id_, name)) + results.append(ok(id_, name)) # --- WireGuard interfaces --- for vlan in wg_vlans: - iface = derive_interface(vlan, data) - state = _iface_operstate(iface) + iface = validation.derive_interface(vlan, data) + state = iface_operstate(iface) id_ = f"iface_wg_{vlan['name']}" name = f"WireGuard interface {iface}" if state is None: - results.append(_problem(id_, name, "error", - f"WireGuard interface {iface} does not exist.", - "Run `sudo python3 core.py --apply` to bring up WireGuard.")) + results.append(problem(id_, name, "error", + f"WireGuard interface {iface} does not exist.", + "Run `sudo python3 core.py --apply` to bring up WireGuard.")) elif state in ("up", "unknown"): # WireGuard interfaces normally report 'unknown' - results.append(_ok(id_, name)) + results.append(ok(id_, name)) else: - results.append(_problem(id_, name, "error", - f"WireGuard interface {iface} operstate is '{state}'.", - f"Try: sudo wg-quick up {iface}")) + results.append(problem(id_, name, "error", + f"WireGuard interface {iface} operstate is '{state}'.", + f"Try: sudo wg-quick up {iface}")) # --- Stale WG interfaces when no WG VLANs configured --- if not wg_vlans: @@ -333,41 +313,41 @@ def check_configurations(data): if p.name.startswith("wg") and re.match(r"^wg\d+$", p.name) ] if stale_wg: - results.append(_problem( + results.append(problem( "stale_wg_ifaces", "Stale WireGuard interfaces", "warning", f"WireGuard interface(s) {', '.join(stale_wg)} exist but no VPN VLANs are configured.", f"Bring them down manually: sudo wg-quick down {stale_wg[0]}")) # --- dnsmasq config files --- for vlan in vlans: - path = DNSMASQ_CONF_DIR / f"{vlan['name']}.conf" + path = shared.DNSMASQ_CONF_DIR / f"{vlan['name']}.conf" results.append(file_ok(f"dnsmasq_conf_{vlan['name']}", f"dnsmasq config {path.name}", path)) # --- systemd-networkd files --- for vlan in non_wg: - iface = derive_interface(vlan, data) + iface = validation.derive_interface(vlan, data) vid = vlan.get("vlan_id") - net = NETWORKD_DIR / f"10-{PRODUCT_NAME}-{vlan['name']}.network" + net = NETWORKD_DIR / f"10-{shared.PRODUCT_NAME}-{vlan['name']}.network" results.append(file_ok(f"networkd_net_{vlan['name']}", f"networkd {net.name}", net)) if vid != 1: # non-physical VLANs have a .netdev too - netdev = NETWORKD_DIR / f"10-{PRODUCT_NAME}-{vlan['name']}.netdev" + netdev = NETWORKD_DIR / f"10-{shared.PRODUCT_NAME}-{vlan['name']}.netdev" results.append(file_ok(f"networkd_netdev_{vlan['name']}", f"networkd {netdev.name}", netdev)) # --- systemd unit files --- - for path in (SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service", - SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer", - SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"): + for path in (shared.SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service", + shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer", + shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"): results.append(file_ok(f"unit_{path.stem}", f"systemd unit {path.name}", path)) # --- WireGuard config and key files --- for vlan in wg_vlans: - iface = derive_interface(vlan, data) + iface = validation.derive_interface(vlan, data) conf = WG_DIR / f"{iface}.conf" key = WG_DIR / f"{iface}.key" - pub = SCRIPT_DIR / f".{iface}.pub" + pub = shared.SCRIPT_DIR / f".{iface}.pub" results.append(file_ok(f"wg_conf_{iface}", f"WireGuard {conf.name}", conf)) results.append(file_ok(f"wg_key_{iface}", f"WireGuard {key.name}", key)) results.append(file_ok(f"wg_pubkey_{iface}", f"WireGuard {pub.name}", pub)) @@ -379,13 +359,13 @@ def check_configurations(data): if p.read_text().startswith("# Generated by") ] if stale: - results.append(_problem( + results.append(problem( "stale_wg_conf", "Stale WireGuard config files", "warning", f"{', '.join(p.name for p in stale)} exist but no VPN VLANs are configured.", "Remove with: sudo rm " + " ".join(str(p) for p in stale))) # --- RADIUS files and secret check --- - if _radius_enabled(data): + if radius_enabled(data): results.append(file_ok("radius_secret_file", ".radius-secret file", RADIUS_SECRET_FILE, "error")) results.append(file_ok("radius_clients_conf", "FreeRADIUS clients.conf", @@ -403,9 +383,9 @@ def check_configurations(data): if "secret" in line and not line.strip().startswith("#") ) if secret_ok: - results.append(_ok("radius_secret_match", "FreeRADIUS shared secret")) + results.append(ok("radius_secret_match", "FreeRADIUS shared secret")) else: - results.append(_problem( + results.append(problem( "radius_secret_match", "FreeRADIUS shared secret", "error", "clients.conf secret does not match .radius-secret. " "Access points will reject all authentication requests.", @@ -418,7 +398,7 @@ def check_configurations(data): if RADIUS_CLIENTS_CONF.exists(): try: if "# Generated by" in RADIUS_CLIENTS_CONF.read_text(): - results.append(_problem( + results.append(problem( "radius_conf_orphan", "FreeRADIUS config", "warning", "FreeRADIUS clients.conf contains routlin-generated content " "but RADIUS is not enabled.", @@ -428,7 +408,7 @@ def check_configurations(data): pass # --- Avahi config --- - if _avahi_enabled(data): + if avahi_enabled(data): results.append(file_ok("avahi_conf", "avahi-daemon.conf", AVAHI_CONF_FILE, "warning")) if AVAHI_CONF_FILE.exists(): @@ -441,14 +421,14 @@ def check_configurations(data): missing = expected_ifaces - actual_ifaces extra = actual_ifaces - expected_ifaces if missing or extra: - results.append(_problem( + results.append(problem( "avahi_ifaces", "avahi-daemon interface list", "warning", f"avahi-daemon.conf interface list does not match config " f"(missing: {missing or 'none'}, extra: {extra or 'none'}).", "Run `sudo python3 core.py --apply` to update.")) else: - results.append(_ok("avahi_ifaces", - "avahi-daemon interface list")) + results.append(ok("avahi_ifaces", + "avahi-daemon interface list")) except OSError: pass @@ -462,17 +442,17 @@ def check_configurations(data): if line.startswith("nameserver") and len(line.split()) >= 2 } if ns_ips & gateway_ips: - results.append(_ok("resolv_conf", "/etc/resolv.conf")) + results.append(ok("resolv_conf", "/etc/resolv.conf")) else: - results.append(_problem( + results.append(problem( "resolv_conf", "/etc/resolv.conf", "warning", f"/etc/resolv.conf nameserver(s) {ns_ips} do not include any VLAN gateway. " f"Expected one of: {gateway_ips}.", "Run `sudo python3 core.py --apply` to update /etc/resolv.conf.")) except OSError: - results.append(_problem("resolv_conf", "/etc/resolv.conf", "warning", - "/etc/resolv.conf is not readable.", - "Run `sudo python3 core.py --apply`.")) + results.append(problem("resolv_conf", "/etc/resolv.conf", "warning", + "/etc/resolv.conf is not readable.", + "Run `sudo python3 core.py --apply`.")) # --- chrony.conf --- if CHRONY_CONF_FILE.exists(): @@ -489,21 +469,19 @@ def check_configurations(data): except Exception: pass if missing_subnets: - results.append(_problem( + results.append(problem( "chrony_conf", "/etc/chrony/chrony.conf", "warning", f"chrony.conf is missing allow directives for: {', '.join(missing_subnets)}.", "Run `sudo python3 core.py --apply` to update chrony.conf.")) else: - results.append(_ok("chrony_conf", "/etc/chrony/chrony.conf")) + results.append(ok("chrony_conf", "/etc/chrony/chrony.conf")) except OSError: - results.append(_problem("chrony_conf", "/etc/chrony/chrony.conf", "warning", - "/etc/chrony/chrony.conf is not readable.")) + results.append(problem("chrony_conf", "/etc/chrony/chrony.conf", "warning", + "/etc/chrony/chrony.conf is not readable.")) else: - results.append(_problem("chrony_conf", "/etc/chrony/chrony.conf", "warning", - "/etc/chrony/chrony.conf does not exist.", - "Install chrony: sudo apt-get install chrony")) - - # --- Stale WG conf when no WG VLANs (already handled above) --- + results.append(problem("chrony_conf", "/etc/chrony/chrony.conf", "warning", + "/etc/chrony/chrony.conf does not exist.", + "Install chrony: sudo apt-get install chrony")) # --- DHCP pool utilization --- for vlan in non_wg: @@ -517,7 +495,7 @@ def check_configurations(data): - int(ipaddress.IPv4Address(start)) + 1) if pool_size <= 0: continue - lease_file = LEASES_DIR / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.leases" + lease_file = shared.LEASES_DIR / f"dnsmasq-{shared.PRODUCT_NAME}-{vlan['name']}.leases" if not lease_file.exists(): continue leases = [ @@ -526,7 +504,7 @@ def check_configurations(data): ] pct = len(leases) * 100 // pool_size if pct >= DHCP_WARN_PCT: - results.append(_problem( + results.append(problem( f"dhcp_pool_{vlan['name']}", f"DHCP pool ({vlan['name']})", "warning", f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full " @@ -534,9 +512,9 @@ def check_configurations(data): "Expand the pool range in config.json or clean up stale leases " f"with: `sudo python3 core.py --reset-leases {vlan['name']}`")) else: - results.append(_ok(f"dhcp_pool_{vlan['name']}", - f"DHCP pool ({vlan['name']})", - f"{pct}% used ({len(leases)}/{pool_size})")) + results.append(ok(f"dhcp_pool_{vlan['name']}", + f"DHCP pool ({vlan['name']})", + f"{pct}% used ({len(leases)}/{pool_size})")) except Exception: pass @@ -550,35 +528,35 @@ def check_configurations(data): combos[_combo_hash(names)] = names now = datetime.now(timezone.utc).timestamp() for h, names in combos.items(): - path = _merged_path(h) + path = _merged_path(h) label = ", ".join(names) if not path.exists(): - results.append(_problem( + results.append(problem( f"blocklist_{h}", f"blocklist ({label})", "warning", f"Merged blocklist file for '{label}' does not exist.", "Run `sudo python3 dns-blocklists.py` to download blocklists.")) elif now - path.stat().st_mtime > BLOCKLIST_STALE_SECS: age_h = int((now - path.stat().st_mtime) / 3600) - results.append(_problem( + results.append(problem( f"blocklist_{h}", f"blocklist ({label})", "warning", f"Merged blocklist for '{label}' is {age_h}h old (threshold 36h).", "Run `sudo python3 dns-blocklists.py` to refresh.")) else: - results.append(_ok(f"blocklist_{h}", f"blocklist ({label})")) + results.append(ok(f"blocklist_{h}", f"blocklist ({label})")) # --- Disk space --- try: usage = shutil.disk_usage("/") pct = usage.used * 100 // usage.total if pct >= DISK_WARN_PCT: - results.append(_problem( + results.append(problem( "disk_space", "Disk space", "warning", f"Root filesystem is {pct}% full " f"({usage.used // 1_073_741_824}G of {usage.total // 1_073_741_824}G used).", "Free up disk space to avoid service disruption.")) else: - results.append(_ok("disk_space", "Disk space", - f"{pct}% used")) + results.append(ok("disk_space", "Disk space", + f"{pct}% used")) except Exception: pass @@ -592,12 +570,12 @@ def check_configurations(data): except OSError: unreachable.append(srv) if unreachable: - results.append(_problem( + results.append(problem( "upstream_dns", "Upstream DNS reachability", "warning", f"Upstream DNS server(s) unreachable on port 53: {', '.join(unreachable)}.", "Check WAN connectivity and upstream DNS server addresses in config.json.")) elif servers: - results.append(_ok("upstream_dns", "Upstream DNS reachability")) + results.append(ok("upstream_dns", "Upstream DNS reachability")) return results @@ -639,7 +617,7 @@ def check_logs(data): aps = sorted({m.group(1) for l in failures for m in ap_re.finditer(l)}) ap_str = ", ".join(aps) if aps else f"{len(failures)} request(s)" - results.append(_problem( + results.append(problem( "freeradius_auth_failures", "FreeRADIUS auth failures", "error", f"FreeRADIUS is rejecting requests from {ap_str} with " @@ -647,20 +625,20 @@ def check_logs(data): "Restore .radius-secret from backup and run `sudo python3 core.py --apply`, " "or update the shared secret in your AP controller to match .radius-secret.")) else: - results.append(_ok("freeradius_auth_failures", - "FreeRADIUS auth failures")) + results.append(ok("freeradius_auth_failures", + "FreeRADIUS auth failures")) # High rejection rate (>50% of recent activity is failures) if recent and len(failures) > len(recent) * 0.5 and not failures: - results.append(_problem( + results.append(problem( "freeradius_high_reject_rate", "FreeRADIUS rejection rate", "warning", f"Over half of recent FreeRADIUS activity ({len(failures)}/{len(recent)}) " f"are auth failures.", "Investigate FreeRADIUS config and shared secrets.")) elif recent: - results.append(_ok("freeradius_high_reject_rate", - "FreeRADIUS rejection rate")) + results.append(ok("freeradius_high_reject_rate", + "FreeRADIUS rejection rate")) except OSError: pass @@ -668,19 +646,19 @@ def check_logs(data): # --- dnsmasq errors --- try: r = subprocess.run( - ["journalctl", f"-u", f"dnsmasq-{PRODUCT_NAME}-*", + ["journalctl", f"-u", f"dnsmasq-{shared.PRODUCT_NAME}-*", "--since", "-1h", "--priority=err", "--no-pager", "-q"], capture_output=True, text=True, timeout=5 ) err_lines = [l for l in r.stdout.splitlines() if l.strip()] if err_lines: - results.append(_problem( + results.append(problem( "dnsmasq_errors", "dnsmasq errors", "error", f"{len(err_lines)} dnsmasq error(s) in the last hour: " f"{err_lines[0][:120]}{'...' if len(err_lines) > 1 else ''}", "Check dnsmasq logs: `sudo journalctl -u 'dnsmasq-routlin-*' --since -1h`")) else: - results.append(_ok("dnsmasq_errors", "dnsmasq errors")) + results.append(ok("dnsmasq_errors", "dnsmasq errors")) except Exception: pass @@ -691,6 +669,7 @@ def check_logs(data): # =================================================================== def _next_blocklist_update(): + """Return the next scheduled trigger time for the blocklist timer as a string, or None if unavailable.""" try: r = subprocess.run( ["systemctl", "status", f"{BLIST_TIMER_NAME}.timer", "--no-pager"], @@ -781,8 +760,8 @@ def print_table(status): if problems: print(f"\n Problems {'=' * (col - 12)}") for p in problems: - sev = p.get("severity", "error") - tag = f"[{sev}]" + sev = p.get("severity", "error") + tag = f"[{sev}]" detail = p.get("detail", p.get("name", "")) print(f" {tag:<10} {detail}") tip = p.get("suggestion", "") diff --git a/routlin/mod_avahi.py b/routlin/mod_avahi.py new file mode 100644 index 0000000..c6cd079 --- /dev/null +++ b/routlin/mod_avahi.py @@ -0,0 +1,136 @@ +""" +mod_avahi.py -- Avahi mDNS reflector configuration and management. + +Patches avahi-daemon.conf with the correct interface list and manages +the avahi-daemon service lifecycle. +""" + +import re +import shutil +import subprocess +from pathlib import Path + +import mod_shared as shared +import mod_validation as validation + +AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf") + + +# =================================================================== +# State helpers +# =================================================================== + +def avahi_enabled(data): + """Return True if at least one non-WireGuard VLAN has mdns_reflection enabled.""" + return any(v.get("mdns_reflection") is True + for v in data.get("vlans", []) + if not validation.is_wg(v)) + +def avahi_interfaces(data): + """Return list of interface names for VLANs with mdns_reflection enabled.""" + return [ + validation.derive_interface(v, data) + for v in data.get("vlans", []) + if v.get("mdns_reflection") is True and not validation.is_wg(v) + ] + + +# =================================================================== +# Config generation +# =================================================================== + +def build_avahi_conf(data): + """Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection. + Reads the existing file (default or previously patched) and modifies only + the specific directives we need, leaving everything else untouched. + Returns the patched config as a string. Caller is responsible for ensuring + the file exists before calling (apply_avahi guards this). + """ + ifaces = avahi_interfaces(data) + content = AVAHI_CONF_FILE.read_text() + + def set_directive(text, directive, value): + """Enable and set a directive, whether it is commented out or already set.""" + pattern = re.compile( + rf"^#?\s*{re.escape(directive)}\s*=.*$", re.MULTILINE + ) + replacement = f"{directive}={value}" + if pattern.search(text): + return pattern.sub(replacement, text) + return text + f"\n{replacement}\n" + + content = set_directive(content, "use-ipv6", "no") + content = set_directive(content, "disallow-other-stacks", "yes") + content = set_directive(content, "allow-interfaces", ",".join(ifaces)) + content = set_directive(content, "enable-reflector", "yes") + content = set_directive(content, "disable-publishing", "yes") + + return content + + +# =================================================================== +# Apply +# =================================================================== + +def apply_avahi(data): + """Write avahi-daemon.conf and ensure service is running.""" + if not shutil.which("avahi-daemon"): + print("avahi-daemon is not installed.") + print(" -> Run: sudo python3 install.py") + return + + ifaces = avahi_interfaces(data) + + if len(ifaces) < 2: + print("mDNS reflection requires at least two VLANs in reflect_vlans. Skipping.") + return + + if not AVAHI_CONF_FILE.exists(): + print(f"WARNING: {AVAHI_CONF_FILE} not found. Run: sudo python3 install.py") + return + + content = build_avahi_conf(data) + existing = AVAHI_CONF_FILE.read_text() + changed = existing != content + if changed: + AVAHI_CONF_FILE.write_text(content) + print(f"Written: {AVAHI_CONF_FILE}") + print(f" Reflecting mDNS across: {', '.join(ifaces)}") + else: + print(f"Unchanged: {AVAHI_CONF_FILE}") + + svc = "avahi-daemon" + state = subprocess.run( + ["systemctl", "is-active", svc], capture_output=True, text=True + ).stdout.strip() + + if state == "active": + if changed: + result = subprocess.run(["systemctl", "restart", svc], + capture_output=True, text=True) + if result.returncode == 0: + print("avahi-daemon restarted.") + else: + shared.service_warning("restart", "avahi-daemon", result.stderr) + else: + print("avahi-daemon: running, config unchanged.") + else: + subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) + result = subprocess.run(["systemctl", "start", svc], + capture_output=True, text=True) + if result.returncode == 0: + print("avahi-daemon started.") + else: + shared.service_warning("start", "avahi-daemon", result.stderr) + +def disable_avahi(): + """Stop and disable avahi-daemon.""" + result = subprocess.run( + ["systemctl", "is-active", "avahi-daemon"], capture_output=True, text=True + ) + if result.stdout.strip() == "active": + subprocess.run(["systemctl", "disable", "--now", "avahi-daemon"], + capture_output=True, text=True) + print("avahi-daemon stopped and disabled.") + else: + print("avahi-daemon: not running, skipping.") diff --git a/routlin/mod_dnsmasq.py b/routlin/mod_dnsmasq.py new file mode 100644 index 0000000..d9a5333 --- /dev/null +++ b/routlin/mod_dnsmasq.py @@ -0,0 +1,626 @@ +""" +mod_dnsmasq.py -- dnsmasq instance configuration, service management, and leases. + +Handles blocklist merging, per-VLAN dnsmasq config and systemd service unit +generation, applying/reloading instances, system service conflict resolution +(systemd-resolved, dnsmasq, chrony, ufw), and DHCP lease display. +""" + +import hashlib +import json +import subprocess +from datetime import datetime +from pathlib import Path + +import mod_shared as shared +import mod_wireguard as wireguard +import mod_validation as validation + +BLOCKLIST_DIR = shared.SCRIPT_DIR / "blocklists" +RESOLV_CONF = Path("/etc/resolv.conf") + + +# =================================================================== +# Blocklist management +# =================================================================== + +def combo_hash(names): + """Return a stable 8-char hex hash for a list/set of blocklist names.""" + key = ",".join(sorted(names)) + return hashlib.sha256(key.encode()).hexdigest()[:8] + +def merged_path(h): + return BLOCKLIST_DIR / f"merged-{h}.conf" + +def blocklists_available(data): + """Return True if at least one merged blocklist file exists on disk.""" + combos = set() + for vlan in data.get("vlans", []): + names = vlan.get("use_blocklists", []) + if names: + combos.add(combo_hash(names)) + return any(merged_path(h).exists() for h in combos) + + +# =================================================================== +# Build per-VLAN dnsmasq config +# =================================================================== + +def _wan_has_ipv6(iface): + """Return True if the WAN interface has a non-link-local IPv6 address.""" + try: + result = subprocess.run( + ["ip", "-6", "addr", "show", iface, "scope", "global"], + capture_output=True, text=True + ) + return bool(result.stdout.strip()) + except Exception: + return False + + +def get_container_bridge_ips(): + """Return {ifname: ip} for all active container bridge interfaces. + Used to add listen-address directives to the physical VLAN's dnsmasq + instance so containers can reach the local DNS resolver. + Works universally for Docker, Podman, LXC, libvirt, etc. + """ + try: + result = subprocess.run( + ["ip", "-j", "addr", "show", "type", "bridge"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode != 0: + return {} + links = json.loads(result.stdout) + out = {} + for l in links: + if l.get("operstate") != "UP": + continue + for addr in l.get("addr_info", []): + if addr.get("family") == "inet": + out[l["ifname"]] = addr["local"] + break + return out + except Exception: + return {} + + +def build_vlan_dnsmasq_conf(vlan, data, iface): + """Generate the complete dnsmasq config for one VLAN instance.""" + dns_cfg = data.get("upstream_dns", {}) + general = data.get("network_interfaces", {}) + overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True] + name = vlan["name"] + d = vlan.get("dhcp_information", {}) + opts = shared.resolve_vlan_options(vlan) + gateway = opts["gateway"] + + bl_names = vlan.get("use_blocklists", []) + bl_file = None + if bl_names: + p = merged_path(combo_hash(bl_names)) + if p.exists(): + bl_file = p + + L = [ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + f"# VLAN: {name} (vlan_id={vlan.get('vlan_id')})", + "", + ] + + L.append(f"pid-file={shared.vlan_pid_file(vlan)}") + if not validation.is_wg(vlan): + L.append(f"dhcp-leasefile={shared.vlan_leases_file(vlan)}") + L += [ + "except-interface=lo", + "bind-interfaces", + f"listen-address={gateway}", + f"interface={iface}", + ] + if shared.is_physical(vlan): + bridge_ips = get_container_bridge_ips() + for bridge, ip in bridge_ips.items(): + L += [f"interface={bridge}", f"listen-address={ip}"] + L.append("") + + if not validation.is_wg(vlan): + dotted_mask = shared.prefix_to_dotted(vlan['subnet_mask']) + L += [ + "# -- DHCP -----------------------------------------------------------", + f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{dotted_mask},{d['lease_time']}", + f"domain={d.get('domain', 'local')}", + "", + f"dhcp-option=tag:{name},option:router,{gateway}", + f"dhcp-option=tag:{name},option:dns-server,{opts['dns_servers']}", + f"dhcp-option=tag:{name},option:ntp-server,{opts['ntp_servers']}", + "", + ] + + identity_hosts = [s for s in vlan.get("server_identities", []) if s.get("hostname")] + if identity_hosts: + L.append("# -- Server identity hostnames ----------------------------------") + for s in identity_hosts: + L += [f"# {s['description']}", f"dhcp-host={s['ip']},{s['hostname']}", ""] + + vlan_res = [r for r in data.get("dhcp_reservations", []) if r.get("vlan") == name] + active_res = [r for r in vlan_res if r.get("enabled") is True] + inactive_res = [r for r in vlan_res if r.get("enabled") is not True] + + if active_res: + L.append("# -- Reservations -----------------------------------------------") + + # Group reservations sharing a static IP into single dhcp-host lines + # (multiple MACs for the same device e.g. wired + WiFi interfaces) + # Dynamic reservations are always emitted individually. + seen_ips = {} # ip -> list of reservations + ordered = [] # preserves insertion order for output + for r in active_res: + if validation.is_dynamic_ip(r): + ordered.append([r]) # always individual + else: + ip = r.get("ip", "") + if ip in seen_ips: + seen_ips[ip].append(r) + else: + seen_ips[ip] = [r] + ordered.append(seen_ips[ip]) + + for group in ordered: + if len(group) == 1: + r = group[0] + h = r.get('hostname', '') + L.append(f"# {r['description']}") + if validation.is_dynamic_ip(r): + L.append(f"dhcp-host=set:{name},{r['mac']},{h},{d['lease_time']}" if h else + f"dhcp-host=set:{name},{r['mac']},{d['lease_time']}") + else: + L.append(f"dhcp-host=set:{name},{r['mac']},{r['ip']},{h},{d['lease_time']}" if h else + f"dhcp-host=set:{name},{r['mac']},{r['ip']},{d['lease_time']}") + else: + # Multiple MACs share the same IP -- combine into one dhcp-host line + descs = ", ".join(r['description'] for r in group) + macs = ",".join(r['mac'] for r in group) + ip = group[0]['ip'] + hostname = group[0].get('hostname', '') + L.append(f"# {descs}") + L.append(f"dhcp-host=set:{name},{macs},{ip},{hostname},{d['lease_time']}" if hostname else + f"dhcp-host=set:{name},{macs},{ip},{d['lease_time']}") + L.append("") + + if inactive_res: + L.append("# -- Skipped reservations (enabled: false) ----------------------") + for r in inactive_res: + L.append(f"# SKIPPED: {r['description']} ({r.get('mac', '?')} -> {r.get('ip', '?')})") + L.append("") + + L += [ + "# -- DNS ------------------------------------------------------------", + "no-resolv", + ] + if dns_cfg.get("strict_order"): + L.append("strict-order") + wan = data["network_interfaces"]["wan_interface"] + wan_has_ipv6 = _wan_has_ipv6(wan) + for srv in dns_cfg.get("upstream_servers", []): + if ":" in srv and not wan_has_ipv6: + continue # skip IPv6 upstream -- WAN has no IPv6 address + L.append(f"server={srv}") + L.append(f"cache-size={dns_cfg.get('cache_size', 1000)}") + if vlan.get("dnsmasq_log_queries", False): + L.append("log-queries") + L.append("") + + if overrides: + L.append("# -- Host overrides -------------------------------------------------") + for o in overrides: + L += [f"# {o['description']}", f"address=/{o['host']}/{o['ip']}", ""] + + if bl_file: + L += [ + "# -- Blocklist ------------------------------------------------------", + f"conf-file={bl_file}", + "", + ] + elif bl_names: + L += ["# Blocklist not yet downloaded -- run: sudo python3 dns-blocklists.py", ""] + + return "\n".join(L) + + +# =================================================================== +# Build per-VLAN systemd service unit +# =================================================================== + +def build_vlan_service(vlan, iface): + name = vlan["name"] + conf = shared.vlan_conf_file(vlan) + + if validation.is_wg(vlan): + after = f"network-online.target wg-quick@{iface}.service" + wants = "network-online.target" + bindsto = f"wg-quick@{iface}.service" + else: + after = "network-online.target" + wants = "network-online.target" + bindsto = None + + lines = [ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + f"Description=dnsmasq for VLAN {name}", + f"After={after}", + f"Wants={wants}", + ] + if bindsto: + lines.append(f"BindsTo={bindsto}") + + lines += [ + "", + "[Service]", + "Type=forking", + f"PIDFile={shared.vlan_pid_file(vlan)}", + f"ExecStart=/usr/sbin/dnsmasq --conf-file={conf}", + "ExecReload=/bin/kill -HUP $MAINPID", + "Restart=on-failure", + "RestartSec=5s", + "", + "[Install]", + "WantedBy=multi-user.target", + "", + ] + + return "\n".join(lines) + + +# =================================================================== +# System service helpers +# =================================================================== + +def ensure_resolv_conf(data): + """Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1).""" + physical = next((v for v in data["vlans"] if shared.is_physical(v)), None) + if physical is None: + return + nameserver = shared.resolve_vlan_options(physical)["gateway"] + wanted = f"nameserver {nameserver}\n" + # A symlink (e.g. to systemd-resolved stub) must be replaced with a plain file. + if RESOLV_CONF.is_symlink(): + RESOLV_CONF.unlink() + print("Removed /etc/resolv.conf symlink (was pointing to systemd-resolved stub).") + current = RESOLV_CONF.read_text() if RESOLV_CONF.exists() else "" + if wanted in current: + print(f"/etc/resolv.conf already points to {nameserver}. Good.") + return + RESOLV_CONF.write_text(wanted) + print(f"Updated /etc/resolv.conf: nameserver {nameserver}") + + +def disable_systemd_resolved(): + """Stop and disable systemd-resolved if it is active.""" + result = subprocess.run( + ["systemctl", "is-active", "systemd-resolved"], + capture_output=True, text=True + ) + if result.stdout.strip() == "active": + subprocess.run(["systemctl", "disable", "--now", "systemd-resolved"], + capture_output=True, text=True) + print("Disabled systemd-resolved.") + else: + print("systemd-resolved is not active. Good.") + +def disable_systemd_timesyncd(): + """Stop and disable systemd-timesyncd if it is active.""" + result = subprocess.run( + ["systemctl", "is-active", "systemd-timesyncd"], + capture_output=True, text=True + ) + if result.stdout.strip() == "active": + subprocess.run(["systemctl", "disable", "--now", "systemd-timesyncd"], + capture_output=True, text=True) + print("Disabled systemd-timesyncd.") + else: + print("systemd-timesyncd is not active. Good.") + +def ensure_chrony(data): + """Add VLAN allow directives to chrony.conf and start the service.""" + chrony_conf = Path("/etc/chrony/chrony.conf") + if chrony_conf.exists(): + content = chrony_conf.read_text() + subnets = [] + for v in data["vlans"]: + subnets.append(str(shared.network_for(v))) + added = [] + for subnet in subnets: + line = f"allow {subnet}" + if line not in content: + content += f"\n{line}" + added.append(subnet) + if added: + chrony_conf.write_text(content) + print(f"Updated /etc/chrony/chrony.conf: added allow for {', '.join(added)}") + else: + print("chrony.conf already has required allow directives. Good.") + + subprocess.run(["systemctl", "enable", "--now", "chrony"], + capture_output=True, text=True) + subprocess.run(["systemctl", "restart", "chrony"], + capture_output=True, text=True) + print("chrony enabled and running. Good.") + +def disable_ufw(): + """Disable ufw (without removing it) if it is installed.""" + if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode != 0: + print("ufw is not installed. Good.") + return + status = subprocess.run(["ufw", "status"], capture_output=True, text=True) + if "Status: active" in status.stdout: + subprocess.run(["ufw", "disable"], capture_output=True, text=True) + print("ufw rules cleared.") + else: + print("ufw is not active. Good.") + svc = subprocess.run(["systemctl", "is-enabled", "ufw"], + capture_output=True, text=True) + if svc.stdout.strip() in ("enabled", "enabled-runtime"): + subprocess.run(["systemctl", "disable", "ufw"], capture_output=True, text=True) + print("Disabled ufw.service.") + +def disable_system_dnsmasq(data): + """Stop and disable the system dnsmasq.service if it is enabled.""" + disable_systemd_resolved() + result = subprocess.run( + ["systemctl", "is-enabled", "dnsmasq"], + capture_output=True, text=True + ) + if result.stdout.strip() in ("enabled", "enabled-runtime"): + subprocess.run(["systemctl", "disable", "--now", "dnsmasq"], + capture_output=True, text=True) + print("Disabled system dnsmasq.service.") + else: + print("System dnsmasq.service is already disabled. Good.") + ensure_resolv_conf(data) + +def restore_ntp(): + """Disable chrony and re-enable systemd-timesyncd for plain client NTP.""" + result = subprocess.run( + ["systemctl", "is-active", "chrony"], capture_output=True, text=True + ) + if result.stdout.strip() == "active": + subprocess.run(["systemctl", "disable", "--now", "chrony"], + capture_output=True, text=True) + print("Disabled chrony.") + else: + print("chrony is not active.") + + result = subprocess.run( + ["systemctl", "cat", "systemd-timesyncd"], capture_output=True, text=True + ) + if result.returncode == 0: + subprocess.run(["systemctl", "enable", "--now", "systemd-timesyncd"], + capture_output=True, text=True) + print("Enabled systemd-timesyncd.") + else: + print("systemd-timesyncd is not available on this system.") + + +# =================================================================== +# Apply dnsmasq instances +# =================================================================== + +def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True): + """Write per-VLAN dnsmasq configs and service units. + + start_if_needed=True: enable and start all instances. + start_if_needed=False (--apply): only restart instances already running; + skip with a warning if not running. + """ + active_service_stems = {shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) for vlan in data["vlans"]} + + if not dry_run: + shared.DNSMASQ_CONF_DIR.mkdir(exist_ok=True) + disable_system_dnsmasq(data) + print() + + for vlan in data["vlans"]: + iface = validation.derive_interface(vlan, data) + if validation.is_wg(vlan) and not dry_run and not wireguard.wg_interface_up(iface): + print(f"Skipped VLAN '{vlan['name']}': {iface} is not up. Run --apply again after WireGuard is up.") + continue + + conf_content = build_vlan_dnsmasq_conf(vlan, data, iface) + svc_content = build_vlan_service(vlan, iface) + conf_path = shared.vlan_conf_file(vlan) + svc_path = shared.vlan_service_file(vlan, iface) + + if dry_run: + print(f"# -- {conf_path} (dry-run) --") + print(conf_content) + print(f"# -- {svc_path} (dry-run) --") + print(svc_content) + continue + + conf_path.write_text(conf_content) + print(f"Written: {conf_path}") + + if not svc_path.exists() or svc_path.read_text() != svc_content: + svc_path.write_text(svc_content) + print(f"Written: {svc_path}") + + if dry_run: + return + + print() + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + + # Remove stale service units (VLANs removed from config) + for f in shared.SYSTEMD_DIR.glob(f"dnsmasq-{shared.PRODUCT_NAME}-*.service"): + if f.stem not in active_service_stems: + subprocess.run(["systemctl", "disable", "--now", f.stem], + capture_output=True, text=True) + f.unlink() + n = f.stem.removeprefix(f"dnsmasq-{shared.PRODUCT_NAME}-") + stale_conf = shared.DNSMASQ_CONF_DIR / f"{n}.conf" + if stale_conf.exists(): + stale_conf.unlink() + print(f"Removed stale VLAN: {f.stem}") + + if start_if_needed: + print("Starting dnsmasq instances...") + for vlan in data["vlans"]: + iface = validation.derive_interface(vlan, data) + if validation.is_wg(vlan) and not wireguard.wg_interface_up(iface): + continue + svc = shared.vlan_service_name(vlan, iface) + subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) + result = subprocess.run(["systemctl", "restart", svc], + capture_output=True, text=True) + if result.returncode == 0: + print(f" Started: {svc}") + else: + shared.service_warning("start", svc, result.stderr) + else: + print("Reloading dnsmasq instances...") + for vlan in data["vlans"]: + iface = validation.derive_interface(vlan, data) + if validation.is_wg(vlan) and not wireguard.wg_interface_up(iface): + continue + svc = shared.vlan_service_name(vlan, iface) + state = subprocess.run( + ["systemctl", "is-active", svc], + capture_output=True, text=True + ).stdout.strip() + if state == "active": + result = subprocess.run(["systemctl", "restart", svc], + capture_output=True, text=True) + if result.returncode == 0: + print(f" Restarted: {svc}") + else: + shared.service_warning("restart", svc, result.stderr) + elif validation.is_wg(vlan): + # WG interface is up but dnsmasq isn't running -- start it now + subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) + result = subprocess.run(["systemctl", "start", svc], + capture_output=True, text=True) + if result.returncode == 0: + print(f" Started: {svc}") + else: + shared.service_warning("start", svc, result.stderr) + else: + print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)") + + +# =================================================================== +# Leases +# =================================================================== + +def reset_leases(data, vlan_name=None): + """Stop dnsmasq instances, delete lease files, restart instances. + If vlan_name is given, only reset that VLAN. Otherwise reset all. + """ + vlans = [v for v in data["vlans"] if not validation.is_wg(v)] + if vlan_name: + vlans = [v for v in vlans if v["name"] == vlan_name] + if not vlans: + valid = ', '.join(v['name'] for v in data['vlans'] if not validation.is_wg(v)) + return f"Unknown VLAN name '{vlan_name}'. Valid names: {valid}" + + print(f"Resetting leases for: {', '.join(v['name'] for v in vlans)}") + print() + + # Stop + for vlan in vlans: + svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) + result = subprocess.run(["systemctl", "stop", svc], + capture_output=True, text=True) + if result.returncode == 0: + print(f" Stopped: {svc}") + else: + print(f" WARNING: Could not stop {svc}: {result.stderr.strip()}") + + # Delete lease files + print() + for vlan in vlans: + lf = shared.vlan_leases_file(vlan) + if lf.exists(): + lf.unlink() + print(f" Deleted: {lf}") + else: + print(f" No lease file: {lf}") + + # Restart + print() + for vlan in vlans: + svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) + result = subprocess.run(["systemctl", "start", svc], + capture_output=True, text=True) + if result.returncode == 0: + print(f" Started: {svc}") + else: + print(f" WARNING: Could not start {svc}: {result.stderr.strip()}") + + print() + print("Done. Devices will get fresh leases on their next DHCP request.") + + +def show_leases(data): + # Build MAC -> reservation lookup across all VLANs + vlan_by_name = {v["name"]: v for v in data.get("vlans", [])} + res_by_mac = {} + for r in data.get("dhcp_reservations", []): + if r.get("enabled") is True: + mac = r.get("mac", "").lower().strip() + if mac: + res_by_mac[mac] = (r, vlan_by_name.get(r.get("vlan", ""), {})) + + now = int(datetime.now().timestamp()) + any_leases = False + + for vlan in data["vlans"]: + if validation.is_wg(vlan): + continue + lf = shared.vlan_leases_file(vlan) + if not lf.exists(): + continue + lines = lf.read_text().strip().splitlines() + if not lines: + continue + + if not any_leases: + print(f"{'IP':<18} {'MAC':<20} {'HOSTNAME':<26} {'VLAN':<8} {'EXPIRES':<22} {'TIME LEFT':<18} {'TYPE':<8} {'DESCRIPTION'}") + print("-" * 145) + any_leases = True + + for entry in lines: + parts = entry.split() + if len(parts) < 4: + continue + expire_ts = parts[0] + mac = parts[1] + ip = parts[2] + hostname = parts[3] if parts[3] != "*" else "(unknown)" + hostname = hostname[:26] + + if expire_ts == "0": + expires_str = "permanent" + time_left = "" + else: + expire_int = int(expire_ts) + seconds = expire_int - now + if seconds <= 0: + expires_str = "expired" + time_left = "" + else: + hours, rem = divmod(seconds, 3600) + mins, _ = divmod(rem, 60) + expire_dt = datetime.fromtimestamp(expire_int) + expires_str = expire_dt.strftime("%Y-%m-%d %H:%M:%S") + time_left = f"{hours}h {mins}m" + + match = res_by_mac.get(mac.lower()) + lease_type = "static" if (match and not validation.is_dynamic_ip(match[0])) else "dynamic" + description = match[0].get("description", "") if match else "" + print(f"{ip:<18} {mac:<20} {hostname:<26} {vlan['name']:<8} {expires_str:<22} {time_left:<18} {lease_type:<8} {description}") + + if not any_leases: + print("No active leases found.") diff --git a/routlin/mod_metrics.py b/routlin/mod_metrics.py new file mode 100644 index 0000000..fb1a1d3 --- /dev/null +++ b/routlin/mod_metrics.py @@ -0,0 +1,206 @@ +""" +mod_metrics.py -- DNS metrics collection and display. + +Sends SIGUSR1 to running dnsmasq instances, parses stats from journalctl, +and accumulates lifetime totals in a JSON file. +""" + +import json +import re +import subprocess +import time +from datetime import datetime + +import mod_shared as shared +import mod_validation as validation + +METRICS_FILE = shared.SCRIPT_DIR / ".dns-metrics" + + +# =================================================================== +# Collect and store +# =================================================================== + +def collect_metrics(data): + """ + Send SIGUSR1 to each running dnsmasq instance and parse stats from + journalctl. Returns a combined metrics dict, or None if unavailable. + """ + metrics = { + "queries_forwarded": 0, + "queries_answered_locally": 0, + "queries_authoritative": 0, + "cache_reused": 0, + "tcp_hwm": 0, + "tcp_max_allowed": 0, + "pool_memory_max": 0, + "dnssec_subqueries_hwm": 0, + "dnssec_crypto_hwm": 0, + "dnssec_sig_fails_hwm": 0, + "servers": [] + } + + any_running = False + for vlan in data["vlans"]: + svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) + result = subprocess.run( + ["systemctl", "kill", "--signal=SIGUSR1", svc], + capture_output=True, text=True + ) + if result.returncode != 0: + continue + any_running = True + + if not any_running: + print("No dnsmasq instances are running.") + return None + + time.sleep(1) + + server_map = {} + for vlan in data["vlans"]: + svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) + result = subprocess.run( + ["journalctl", "-u", svc, "--since", "5 seconds ago", + "--no-pager", "-o", "cat"], + capture_output=True, text=True + ) + for line in result.stdout.splitlines(): + m = re.search(r"cache size \d+, (\d+)/\d+ cache insertions re-used", line) + if m: + metrics["cache_reused"] += int(m.group(1)) + + m = re.search(r"queries forwarded (\d+), queries answered locally (\d+)", line) + if m: + metrics["queries_forwarded"] += int(m.group(1)) + metrics["queries_answered_locally"] += int(m.group(2)) + + m = re.search(r"queries for authoritative zones (\d+)", line) + if m: + metrics["queries_authoritative"] += int(m.group(1)) + + m = re.search(r"highest since last SIGUSR1 (\d+), max allowed (\d+)", line) + if m: + metrics["tcp_hwm"] = max(metrics["tcp_hwm"], int(m.group(1))) + metrics["tcp_max_allowed"] = max(metrics["tcp_max_allowed"], int(m.group(2))) + + m = re.search(r"pool memory in use \d+, max (\d+)", line) + if m: + metrics["pool_memory_max"] = max(metrics["pool_memory_max"], int(m.group(1))) + + m = re.search( + r"server (\S+): queries sent (\d+), retried (\d+), failed (\d+), " + r"nxdomain replies (\d+), avg\. latency (\d+)ms", + line + ) + if m: + addr = m.group(1) + if addr not in server_map: + server_map[addr] = { + "address": addr, "queries_sent": 0, "retried": 0, + "failed": 0, "nxdomain": 0, "avg_latency_ms": 0 + } + server_map[addr]["queries_sent"] += int(m.group(2)) + server_map[addr]["retried"] += int(m.group(3)) + server_map[addr]["failed"] += int(m.group(4)) + server_map[addr]["nxdomain"] += int(m.group(5)) + server_map[addr]["avg_latency_ms"] = int(m.group(6)) + + metrics["servers"] = list(server_map.values()) + return metrics + + +def update_metrics_file(new_metrics): + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + if METRICS_FILE.exists(): + with open(METRICS_FILE) as f: + stored = json.load(f) + else: + stored = { + "metadata": {"first_recorded": now_str, "last_recorded": now_str, "total_updates": 0}, + "totals": { + "queries_forwarded": 0, "queries_answered_locally": 0, + "queries_authoritative": 0, "cache_reused": 0, + "tcp_hwm": 0, "tcp_max_allowed": 0, "pool_memory_max": 0, + "dnssec_subqueries_hwm": 0, "dnssec_crypto_hwm": 0, + "dnssec_sig_fails_hwm": 0, "servers": [] + } + } + + t = stored["totals"] + t["queries_forwarded"] += new_metrics["queries_forwarded"] + t["queries_answered_locally"] += new_metrics["queries_answered_locally"] + t["queries_authoritative"] += new_metrics["queries_authoritative"] + t["cache_reused"] += new_metrics["cache_reused"] + t["tcp_hwm"] = max(t["tcp_hwm"], new_metrics["tcp_hwm"]) + t["pool_memory_max"] = max(t["pool_memory_max"], new_metrics["pool_memory_max"]) + t["dnssec_subqueries_hwm"] = max(t["dnssec_subqueries_hwm"], new_metrics["dnssec_subqueries_hwm"]) + t["dnssec_crypto_hwm"] = max(t["dnssec_crypto_hwm"], new_metrics["dnssec_crypto_hwm"]) + t["dnssec_sig_fails_hwm"] = max(t["dnssec_sig_fails_hwm"], new_metrics["dnssec_sig_fails_hwm"]) + if new_metrics["tcp_max_allowed"]: + t["tcp_max_allowed"] = new_metrics["tcp_max_allowed"] + + existing = {s["address"]: s for s in t["servers"]} + for srv in new_metrics["servers"]: + addr = srv["address"] + if addr in existing: + existing[addr]["queries_sent"] += srv["queries_sent"] + existing[addr]["retried"] += srv["retried"] + existing[addr]["failed"] += srv["failed"] + existing[addr]["nxdomain"] += srv["nxdomain"] + existing[addr]["avg_latency_ms"] = srv["avg_latency_ms"] + else: + existing[addr] = srv.copy() + t["servers"] = list(existing.values()) + + stored["metadata"]["last_recorded"] = now_str + stored["metadata"]["total_updates"] += 1 + + with open(METRICS_FILE, "w") as f: + json.dump(stored, f, indent=2) + shared.chown_to_script_dir_owner(METRICS_FILE) + + +# =================================================================== +# Display +# =================================================================== + +def show_metrics(data): + new = collect_metrics(data) + if new is None: + return + update_metrics_file(new) + + with open(METRICS_FILE) as f: + data_m = json.load(f) + + m = data_m["metadata"] + t = data_m["totals"] + + print("DNS Metrics (lifetime totals across all VLAN instances)") + print(f" First recorded : {m['first_recorded']}") + print(f" Last recorded : {m['last_recorded']}") + print(f" Total updates : {m['total_updates']}") + print() + print("Queries") + print(f" Forwarded to upstream : {t['queries_forwarded']:,}") + print(f" Answered from cache : {t['queries_answered_locally']:,}") + print(f" Authoritative : {t['queries_authoritative']:,}") + print(f" Cache reused : {t['cache_reused']:,}") + print() + print("TCP") + print(f" Peak concurrent (HWM) : {t['tcp_hwm']}") + print(f" Max allowed : {t['tcp_max_allowed']}") + print() + print(f"Pool memory peak : {t['pool_memory_max']} bytes") + if t["servers"]: + print() + print("Upstream servers") + for s in t["servers"]: + print(f" {s['address']}") + print(f" Sent : {s['queries_sent']:,}") + print(f" Retried : {s['retried']:,}") + print(f" Failed : {s['failed']:,}") + print(f" NXDOMAIN : {s['nxdomain']:,}") + print(f" Latency : {s['avg_latency_ms']}ms (last recorded)") diff --git a/routlin/mod_networkd.py b/routlin/mod_networkd.py new file mode 100644 index 0000000..6c4d4df --- /dev/null +++ b/routlin/mod_networkd.py @@ -0,0 +1,149 @@ +""" +mod_networkd.py -- systemd-networkd configuration for VLAN interfaces. + +Generates .netdev and .network files for each VLAN, removes legacy +conflicting files, and triggers a networkd reload when content changes. +""" + +import subprocess +from pathlib import Path + +import mod_shared as shared +import mod_validation as validation + +NETWORKD_DIR = Path("/etc/systemd/network") + + +# =================================================================== +# Build +# =================================================================== + +def build_netdev(vlan, vid, iface): + return "\n".join([ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + "", + "[NetDev]", + f"Name={iface}", + "Kind=vlan", + "", + "[VLAN]", + f"Id={vid}", + "", + ]) + +def build_network(vlan, vid, iface, all_vlan_ids): + network = shared.network_for(vlan) + prefix = network.prefixlen + lines = [ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + "", + "[Match]", + f"Name={iface}", + "", + "[Network]", + "DHCP=no", + "LinkLocalAddressing=no", + ] + for ident in vlan["server_identities"]: + lines.append(f"# {ident['description']}") + lines.append(f"Address={ident['ip']}/{prefix}") + + if shared.is_physical(vlan): + lines.append("") + for v in all_vlan_ids: + if v != 1: + lines.append(f"VLAN={iface}.{v}") + + lines.append("") + return "\n".join(lines) + + +# =================================================================== +# Apply +# =================================================================== + +def find_legacy_files(managed_interfaces): + to_remove = [] + for pattern in ("*.network", "*.netdev"): + for f in NETWORKD_DIR.glob(pattern): + if f.name.startswith(f"10-{shared.PRODUCT_NAME}-"): + continue + try: + content = f.read_text() + except OSError: + continue + for iface in managed_interfaces: + if f"Name={iface}" in content: + to_remove.append(f) + break + return to_remove + +def apply_networkd(data, dry_run=False, only_if_changed=False): + """Write systemd-networkd files and reload. + If only_if_changed=True, write files only when content differs from disk + and skip the networkd reload if nothing changed. Used by --apply mode. + """ + all_vlan_ids = [v.get('vlan_id') for v in data["vlans"] if not validation.is_wg(v)] + managed_ifaces = [validation.derive_interface(v, data) for v in data["vlans"]] + changed = False + + legacy = find_legacy_files(managed_ifaces) + if legacy: + print("Removing legacy networkd files:") + for f in legacy: + if not dry_run: + f.unlink() + changed = True + print(f" {'[dry-run] would remove' if dry_run else 'Removed'}: {f}") + print() + + for vlan in data["vlans"]: + if validation.is_wg(vlan): + continue + iface = validation.derive_interface(vlan, data) + vid = vlan.get('vlan_id') + stem = shared.networkd_stem(vlan) + + if not shared.is_physical(vlan): + netdev_path = NETWORKD_DIR / f"{stem}.netdev" + netdev_content = build_netdev(vlan, vid, iface) + if dry_run: + print(f"# -- {netdev_path} (dry-run) --") + print(netdev_content) + else: + existing = netdev_path.read_text() if netdev_path.exists() else None + if existing != netdev_content: + netdev_path.write_text(netdev_content) + print(f"Written: {netdev_path}") + changed = True + elif not only_if_changed: + print(f"Unchanged: {netdev_path}") + + network_path = NETWORKD_DIR / f"{stem}.network" + network_content = build_network(vlan, vid, iface, all_vlan_ids) + if dry_run: + print(f"# -- {network_path} (dry-run) --") + print(network_content) + else: + existing = network_path.read_text() if network_path.exists() else None + if existing != network_content: + network_path.write_text(network_content) + print(f"Written: {network_path}") + changed = True + elif not only_if_changed: + print(f"Unchanged: {network_path}") + + if not dry_run: + if changed: + print("Reloading systemd-networkd...") + result = subprocess.run( + ["networkctl", "reload"], capture_output=True, text=True + ) + if result.returncode != 0: + print(f"WARNING: networkctl reload returned non-zero:\n{result.stderr.strip()}") + else: + print("systemd-networkd reloaded.") + elif only_if_changed: + print("systemd-networkd: no changes. Good.") diff --git a/routlin/mod_nftables.py b/routlin/mod_nftables.py new file mode 100644 index 0000000..d6f283e --- /dev/null +++ b/routlin/mod_nftables.py @@ -0,0 +1,696 @@ +""" +mod_nftables.py -- nftables config generation and management. + +Generates and applies the routlin-nat and routlin-filter tables, manages the +NAT boot service, and handles the banned_ips IP expansion logic. +""" + +import ipaddress +import json +import subprocess +import sys +from pathlib import Path + +import mod_avahi as avahi +import mod_radius as radius +import mod_shared as shared +import mod_wireguard as wireguard +import mod_validation as validation + +NAT_SERVICE_NAME = f"{shared.PRODUCT_NAME}-nat" +NAT_SERVICE_FILE = shared.SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service" + +# =================================================================== +# Rule list helpers +# =================================================================== + +def rule_enabled(rules): + return [r for r in rules if r.get("enabled") is True] + +def rule_disabled(rules): + return [r for r in rules if r.get("enabled") is not True] + +def expand_protocols(rule): + """Return list of (protocol, rule, comment_suffix) tuples. + When protocol is 'both', expands into tcp and udp with suffixes + ' (tcp)' and ' (udp)' so generated comments are unambiguous. + """ + proto = rule["protocol"] + if proto == "both": + return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")] + return [(proto, rule, "")] + +# =================================================================== +# Container bridge detection +# =================================================================== + +def get_container_bridges(): + """Return all active bridge interfaces. + Works universally for Docker, Podman, LXC, libvirt, etc. + """ + try: + result = subprocess.run( + ["ip", "-j", "link", "show", "type", "bridge"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode != 0: + return [] + links = json.loads(result.stdout) + return [l["ifname"] for l in links if l.get("operstate") == "UP"] + except Exception: + return [] + +# =================================================================== +# banned_ips expansion +# =================================================================== + +def _expand_banned_ipv4(ip_str): + """Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements.""" + if '/' in ip_str: + ipaddress.IPv4Network(ip_str, strict=False) # validate + return [ip_str] + + parts = ip_str.split('.') + if len(parts) != 4: + raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} - expected 4 octets") + + def parse_octet(s, pos): + if s == '*': + return (0, 255) + if '-' in s: + a, b = s.split('-', 1) + lo, hi = int(a), int(b) + if not (0 <= lo <= hi <= 255): + raise ValueError(f"Invalid octet range {s!r} in {ip_str!r}") + return (lo, hi) + v = int(s) + if not 0 <= v <= 255: + raise ValueError(f"Octet value {v} out of range in {ip_str!r}") + return (v, v) + + ranges = [parse_octet(p, i) for i, p in enumerate(parts)] + + # Count trailing full-wildcard octets to determine CIDR suffix length + trailing = 0 + for lo, hi in reversed(ranges): + if lo == 0 and hi == 255: + trailing += 1 + else: + break + + prefix_len = 32 - 8 * trailing + prefix_ranges = ranges[:4 - trailing] + + # Guard against combinatorial explosion + total = 1 + for lo, hi in prefix_ranges: + total *= (hi - lo + 1) + if total > 1024: + raise ValueError( + f"Pattern {ip_str!r} would expand to {total} entries (limit 1024). " + f"Use CIDR notation instead." + ) + + results = [] + + if trailing > 0: + def _enum_cidr(idx, chosen): + if idx == len(prefix_ranges): + base = '.'.join(str(v) for v in chosen) + '.0' * trailing + if prefix_len == 32: + results.append(base) + else: + results.append(f"{base}/{prefix_len}") + return + lo, hi = prefix_ranges[idx] + for v in range(lo, hi + 1): + _enum_cidr(idx + 1, chosen + [v]) + _enum_cidr(0, []) + else: + outer_ranges = ranges[:3] + lo4, hi4 = ranges[3] + + def _enum_range(idx, chosen): + if idx == 3: + base = '.'.join(str(v) for v in chosen) + if lo4 == hi4: + results.append(f"{base}.{lo4}") + else: + results.append(f"{base}.{lo4}-{base}.{hi4}") + return + lo, hi = outer_ranges[idx] + for v in range(lo, hi + 1): + _enum_range(idx + 1, chosen + [v]) + _enum_range(0, []) + + return results + + +def _expand_banned_ipv6(ip_str): + """Convert an IPv6 pattern (CIDR, single IP, or trailing-wildcard) to nftables set elements. + + Supported formats: + Single address : "2a01:4f8:c17:b0f::2" -- passed through as-is + CIDR : "2a01:4f8::/32" -- passed through as-is + Wildcard : "2a01:4f8:c17:*" -- prefix:* expands to a CIDR + "2a01:4f8:c17:b00::*" -- :: compression is supported + + Range notation (e.g. "b00-bff") is not supported for IPv6. Use CIDR instead. + """ + if '/' in ip_str: + ipaddress.IPv6Network(ip_str, strict=False) # validate + return [ip_str] + + if '*' not in ip_str: + ipaddress.IPv6Address(ip_str) # validate single address + return [ip_str] + + if not ip_str.endswith(':*'): + raise ValueError( + f"Unsupported IPv6 wildcard pattern {ip_str!r}. " + f"Use 'prefix:*' (e.g. '2a01:4f8:c17:*') or CIDR notation. " + f"Range notation (e.g. 'b00-bff') is not supported for IPv6." + ) + + prefix_part = ip_str[:-2] # strip trailing ':*' + + if '::' in prefix_part: + left, right = prefix_part.split('::', 1) + left_groups = [g for g in left.split(':') if g] if left else [] + right_groups = [g for g in right.split(':') if g] if right else [] + zero_count = 8 - len(left_groups) - len(right_groups) - 1 + if zero_count < 0: + raise ValueError(f"IPv6 wildcard pattern {ip_str!r} has too many groups.") + groups = left_groups + ['0000'] * zero_count + right_groups + else: + groups = [g for g in prefix_part.split(':') if g] + + num_groups = len(groups) + prefix_bits = num_groups * 16 + if num_groups < 1 or num_groups > 7: + raise ValueError( + f"IPv6 wildcard pattern {ip_str!r} must have between 1 and 7 " + f"prefix groups before the wildcard." + ) + base = ':'.join(groups) + ':' + ':'.join(['0000'] * (8 - num_groups)) + addr = ipaddress.IPv6Address(base) + return [f"{addr}/{prefix_bits}"] + + +def expand_banned_ip(ip_str): + """Return (family, [nftables_elements]) for a banned_ips entry. + family is 'ipv4' or 'ipv6'.""" + if ':' in ip_str: + return ('ipv6', _expand_banned_ipv6(ip_str)) + return ('ipv4', _expand_banned_ipv4(ip_str)) + + +def banned_ip_sets(data): + """Return (v4_elements, v6_elements) as flat lists of nftables set element strings.""" + v4, v6 = [], [] + for entry in rule_enabled(data.get("banned_ips", [])): + family, elements = expand_banned_ip(entry["ip"]) + if family == 'ipv4': + v4.extend(elements) + else: + v6.extend(elements) + return v4, v6 + +# =================================================================== +# nftables config generation +# =================================================================== + +def build_nft_config(data, dry_run=False): + wan = data["network_interfaces"]["wan_interface"] + vlans = [v for v in data["vlans"] + if not validation.is_wg(v) or dry_run or wireguard.wg_interface_up(validation.derive_interface(v, data))] + all_fwd = list(rule_enabled(data.get("port_forwarding", []))) + wrngl_vlan_by_name = {v["name"]: v for v in vlans} + all_wrngl = [(wrngl_vlan_by_name[r["vlan"]], r) + for r in rule_enabled(data.get("port_wrangling", [])) + if r.get("vlan") in wrngl_vlan_by_name] + active_ifaces = {validation.derive_interface(v, data) for v in vlans} + + vlan_networks = {} + for v in vlans: + try: + net = shared.network_for(v) + vlan_networks[validation.derive_interface(v, data)] = net + except (KeyError, ValueError): + pass + + all_except = rule_enabled(data.get("inter_vlan_exceptions", [])) + banned_v4, banned_v6 = banned_ip_sets(data) + container_bridges = get_container_bridges() + + L = [ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + "", + ] + + # ========================================================================== + # routlin-nat table + # ========================================================================== + + L += [ + f"table ip {shared.PRODUCT_NAME}-nat {{", + "", + " chain prerouting {", + " type nat hook prerouting priority dstnat - 10; policy accept;", + "", + ] + + if all_fwd: + L += [" # -- Port forwarding (inbound WAN -> LAN host) ---------------", ""] + for rule in all_fwd: + for proto, r, suffix in expand_protocols(rule): + L += [ + f" # {r['description']}{suffix}", + f" iif \"{wan}\" {proto} dport {r['dest_port']} dnat to {r['nat_ip']}:{r['nat_port']}", + "", + ] + + if all_wrngl: + L += [" # -- Port wrangling (redirect VLAN traffic to local host) ----", ""] + for vlan, rule in all_wrngl: + iface = validation.derive_interface(vlan, data) + for proto, r, suffix in expand_protocols(rule): + L += [ + f" # {r['description']}{suffix}", + f" iif \"{iface}\" {proto} dport {r['dest_port']} ip daddr != {r['redirect_to']} dnat to {r['redirect_to']}", + "", + ] + + L += [ + " }", + "", + " chain postrouting {", + " type nat hook postrouting priority srcnat; policy accept;", + "", + " # Masquerade all outbound traffic through WAN", + f" oif \"{wan}\" masquerade", + "", + " }", + "", + "}", + "", + ] + + # ========================================================================== + # routlin-filter table + # ========================================================================== + + L += [f"table ip {shared.PRODUCT_NAME}-filter {{", ""] + + if banned_v4: + elements = ", ".join(banned_v4) + L += [ + " set banned_ipv4 {", + " type ipv4_addr", + " flags interval", + f" elements = {{ {elements} }}", + " }", + "", + ] + + # INPUT chain + L += [ + " # INPUT -- traffic destined for this machine itself", + " chain input {", + " type filter hook input priority filter; policy drop;", + "", + ] + if banned_v4: + L += [ + " # Drop banned IPs on WAN inbound", + f" iif \"{wan}\" ip saddr @banned_ipv4 drop", + "", + ] + L += [ + " # Allow loopback", + " iif \"lo\" accept", + "", + " # Allow established/related return traffic", + " ct state established,related accept", + "", + " # Allow ICMP (ping) from anywhere", + " ip protocol icmp accept", + "", + ] + + if avahi.avahi_enabled(data): + mdns_ifaces = avahi.avahi_interfaces(data) + if mdns_ifaces: + iface_set = ", ".join(f'"{i}"' for i in mdns_ifaces) + L += [ + " # mDNS (port 5353) -- allow on reflection interfaces for avahi", + f" iif {{ {iface_set} }} udp dport 5353 accept", + "", + ] + + # RADIUS -- must come BEFORE the broad VLAN accept rules below + r_clients = radius.radius_clients(data) + if r_clients: + allowed_ips = ", ".join(r["ip"] for r, _ in r_clients) + L += [ + " # RADIUS (port 1812) -- allow only designated authenticators", + f" ip saddr {{ {allowed_ips} }} udp dport 1812 accept", + " udp dport 1812 drop", + "", + ] + + if container_bridges: + iface_set = ", ".join(f'"{b}"' for b in container_bridges) + L += [ + " # Allow DNS from container bridge networks (Docker, Podman, etc.)", + f" iif {{ {iface_set} }} meta l4proto {{ tcp, udp }} th dport 53 accept", + "", + ] + + L.append(" # Allow all traffic inbound from any VLAN interface") + for vlan in vlans: + L.append(f" iif \"{validation.derive_interface(vlan, data)}\" accept # {vlan['name']}") + L.append("") + + if all_fwd: + L += [" # Allow inbound WAN access for port-forwarded services", ""] + for rule in all_fwd: + for proto, r, suffix in expand_protocols(rule): + L += [ + f" # {r['description']}{suffix}", + f" iif \"{wan}\" {proto} dport {r['dest_port']} accept", + "", + ] + + L += [" # Drop all other inbound WAN traffic", " }", ""] + + # FORWARD chain + L += [ + " # FORWARD -- traffic being routed through this machine", + " chain forward {", + " type filter hook forward priority filter; policy drop;", + "", + ] + if banned_v4: + L += [ + " # Drop banned IPs on WAN inbound", + f" iif \"{wan}\" ip saddr @banned_ipv4 drop", + "", + ] + L += [ + " # Allow established/related return traffic", + " ct state established,related accept", + "", + ] + + L.append(" # Allow each VLAN -> WAN (outbound internet)") + for vlan in vlans: + L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{wan}\" accept # {vlan['name']} -> WAN") + L.append("") + + if container_bridges: + L.append(" # Allow VLAN -> Docker bridge forwarding") + for vlan in vlans: + for bridge in container_bridges: + L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{bridge}\" ct state new accept" + f" # {vlan['name']} -> {bridge}") + L.append("") + + L += [ + " # Allow Docker containers -> WAN (outbound internet access)", + f" iif != \"{wan}\" oif \"{wan}\" ct state new accept", + "", + ] + + if avahi.avahi_enabled(data): + mdns_ifaces = avahi.avahi_interfaces(data) + if len(mdns_ifaces) > 1: + iface_set = ", ".join(f'"{i}"' for i in mdns_ifaces) + L += [ + " # mDNS forwarding between reflection interfaces for avahi", + f" iif {{ {iface_set} }} oif {{ {iface_set} }} udp dport 5353 accept", + "", + ] + + all_except = rule_enabled(data.get("inter_vlan_exceptions", [])) + if all_except: + L += [" # -- Inter-VLAN exceptions ------------------------------------------", ""] + for r in all_except: + src = r["src_ip_or_subnet"] + dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") + min_p = r.get("dest_port_start") or r.get("dst_port") + max_p = r.get("dest_port_end") + if min_p and max_p and str(min_p) != str(max_p): + port_spec = f"{min_p}-{max_p}" + elif min_p: + port_spec = str(min_p) + else: + port_spec = None + for proto, _, suffix in expand_protocols(r): + L.append(f" # {r['description']}{suffix}") + if port_spec is not None: + L.append(f" ip saddr {src} ip daddr {dst} {proto} dport {port_spec} ct state new accept") + else: + L.append(f" ip saddr {src} ip daddr {dst} ip protocol {proto} ct state new accept") + L.append("") + + if all_fwd: + L += [" # Allow inbound WAN -> VLAN for active port forwarding rules", ""] + for rule in all_fwd: + try: + nat_addr = ipaddress.IPv4Address(rule["nat_ip"]) + iface = wan # fallback + for iface_key, net in vlan_networks.items(): + if nat_addr in net: + iface = iface_key + break + except ValueError: + iface = wan + for proto, r, suffix in expand_protocols(rule): + L += [ + f" # {r['description']}{suffix}", + f" iif \"{wan}\" oif \"{iface}\" {proto} dport {r['nat_port']} ip daddr {r['nat_ip']} ct state new accept", + "", + ] + + L += [ + " }", + "", + " chain output {", + " type filter hook output priority filter; policy accept;", + " }", + "", + "}", + ] + + if banned_v6: + elements = ", ".join(banned_v6) + L += [ + "", + f"table ip6 {shared.PRODUCT_NAME}-ban {{", + "", + " set banned_ipv6 {", + " type ipv6_addr", + " flags interval", + f" elements = {{ {elements} }}", + " }", + "", + " chain input {", + " type filter hook input priority filter; policy accept;", + f" iif \"{wan}\" ip6 saddr @banned_ipv6 drop", + " }", + "", + " chain forward {", + " type filter hook forward priority filter; policy accept;", + f" iif \"{wan}\" ip6 saddr @banned_ipv6 drop", + " }", + "", + "}", + ] + + return "\n".join(L) + +# =================================================================== +# nftables apply / disable / status +# =================================================================== + +def table_exists(family, name): + result = subprocess.run( + ["nft", "list", "table", family, name], + capture_output=True, text=True + ) + return result.returncode == 0 + +def delete_our_tables(): + """Delete all routlin-owned nftables tables. Returns error string on failure, None on success.""" + for family, table in [("ip", f"{shared.PRODUCT_NAME}-nat"), + ("ip", f"{shared.PRODUCT_NAME}-filter"), + ("ip6", f"{shared.PRODUCT_NAME}-ban")]: + if table_exists(family, table): + result = subprocess.run( + ["nft", "delete", "table", family, table], + capture_output=True, text=True + ) + if result.returncode != 0: + return f"Failed to delete table {family} {table}: {result.stderr.strip()}" + print(f"Removed existing table: {family} {table}") + else: + print(f"Table not present, skipping delete: {family} {table}") + return None + +def apply_nft_config(config_text): + result = subprocess.run( + ["nft", "-f", "-"], + input=config_text, + capture_output=True, text=True + ) + if result.returncode != 0: + print("ERROR: nft rejected the ruleset:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + sys.exit(1) + +def apply_nftables(data, dry_run=False): + config = build_nft_config(data, dry_run=dry_run) + if dry_run: + print(config) + return + + active_ifaces = {validation.derive_interface(v, data) for v in data["vlans"] + if not validation.is_wg(v) or wireguard.wg_interface_up(validation.derive_interface(v, data))} + active_vlans = [v for v in data["vlans"] if validation.derive_interface(v, data) in active_ifaces] + + all_fwd = list(rule_enabled(data.get("port_forwarding", []))) + all_dis_fwd = list(rule_disabled(data.get("port_forwarding", []))) + active_vlan_by_name = {v["name"]: v for v in active_vlans} + all_wrngl = [(active_vlan_by_name[r["vlan"]], r) + for r in rule_enabled(data.get("port_wrangling", [])) + if r.get("vlan") in active_vlan_by_name] + all_dis_wrngl = rule_disabled(data.get("port_wrangling", [])) + all_except = rule_enabled(data.get("inter_vlan_exceptions", [])) + + print(f"Applying {len(all_fwd)} port forwarding rule(s), {len(all_dis_fwd)} skipped.") + print(f"Applying {len(all_wrngl)} port wrangling rule(s), {len(all_dis_wrngl)} skipped.") + print(f"Applying {len(all_except)} inter-VLAN exception(s).") + container_bridges = get_container_bridges() + if container_bridges: + print(f"Container bridges: {', '.join(container_bridges)}") + print() + + delete_our_tables() + apply_nft_config(config) + print("nftables rules applied successfully.") + + active_subnets = [] + for v in data["vlans"]: + if validation.is_wg(v) and not wireguard.wg_interface_up(validation.derive_interface(v, data)): + continue + try: + active_subnets.append(shared.network_for(v)) + except (KeyError, ValueError): + pass + + def dst_is_active(r): + dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") + try: + addr = ipaddress.IPv4Address(dst) + return any(addr in net for net in active_subnets) + except ValueError: + try: + net = ipaddress.IPv4Network(dst, strict=False) + return any(net.overlaps(s) for s in active_subnets) + except ValueError: + return True + + if all_fwd: + print() + print("Active port forwarding:") + for r in all_fwd: + print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['nat_ip']}:{r['nat_port']} ({r['description']})") + + if all_dis_fwd: + print() + print("Skipped port forwarding (disabled):") + for r in all_dis_fwd: + print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['nat_ip']}:{r['nat_port']} ({r['description']})") + + if all_wrngl: + print() + print("Active port wrangling:") + for vlan, r in all_wrngl: + print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['redirect_to']} ({r['description']}) [{vlan['name']}]") + + active_except = [r for r in all_except if dst_is_active(r)] + if active_except: + print() + print("Active inter-VLAN exceptions:") + for r in active_except: + src = r["src_ip_or_subnet"] + dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") + min_p = r.get("dest_port_start") or r.get("dst_port") + max_p = r.get("dest_port_end") + if min_p and max_p and str(min_p) != str(max_p): + port_str = f":{min_p}-{max_p}" + elif min_p: + port_str = f":{min_p}" + else: + port_str = "" + dst_str = f"{dst}{port_str}" + print(f" [{r['protocol'].upper():<4}] {src} -> {dst_str} ({r['description']})") + +def show_rules(): + for table in (f"{shared.PRODUCT_NAME}-nat", f"{shared.PRODUCT_NAME}-filter"): + result = subprocess.run( + ["nft", "list", "table", "ip", table], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f"[{table}] not found (not yet applied)") + else: + print(result.stdout) + +# =================================================================== +# NAT boot service +# =================================================================== + +def install_nat_service(): + script_path = shared.SCRIPT_DIR / "core.py" + + service_content = f"""[Unit] +Description=Apply {shared.PRODUCT_NAME} NAT and firewall rules +After=network-online.target docker.service +Wants=network-online.target docker.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 {script_path} --apply +RemainAfterExit=yes +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +""" + + existing = NAT_SERVICE_FILE.read_text() if NAT_SERVICE_FILE.exists() else None + if existing == service_content: + print(f"Boot service already up to date: {NAT_SERVICE_FILE}") + return + + NAT_SERVICE_FILE.write_text(service_content) + subprocess.run(["systemctl", "daemon-reload"], check=True) + subprocess.run(["systemctl", "enable", NAT_SERVICE_NAME], check=True) + if existing is None: + print(f"Boot service installed and enabled: {NAT_SERVICE_FILE}") + else: + print(f"Boot service updated: {NAT_SERVICE_FILE}") + +def remove_nat_service(): + if NAT_SERVICE_FILE.exists(): + subprocess.run(["systemctl", "disable", "--now", NAT_SERVICE_NAME], + capture_output=True, text=True) + NAT_SERVICE_FILE.unlink() + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + print(f"Removed boot service: {NAT_SERVICE_NAME}.service") + else: + print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service") diff --git a/routlin/mod_radius.py b/routlin/mod_radius.py new file mode 100644 index 0000000..fb245db --- /dev/null +++ b/routlin/mod_radius.py @@ -0,0 +1,321 @@ +""" +mod_radius.py -- FreeRADIUS configuration management. + +Generates clients.conf, users, and huntgroups files from config.json, and +manages EAP settings (use_tunneled_reply, md5 block) in the freeradius EAP +module config. +""" + +import re +import subprocess +from pathlib import Path +import mod_shared as shared + +RADIUS_SECRET_FILE = shared.SCRIPT_DIR / ".radius-secret" +RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf") +RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users") +RADIUS_CONF_FILE = Path("/etc/freeradius/3.0/radiusd.conf") +RADIUS_EAP_FILE = Path("/etc/freeradius/3.0/mods-available/eap") +RADIUS_HUNTGROUPS = Path("/etc/freeradius/3.0/huntgroups") +RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log") +RADIUS_HUNTGROUP_NAME = "routlin-aps" + + +# =================================================================== +# Data helpers +# =================================================================== + +def radius_clients(data): + """Return list of (reservation, vlan) tuples where radius_client is True.""" + vlan_by_name = {v["name"]: v for v in data.get("vlans", [])} + return [ + (r, vlan_by_name[r["vlan"]]) + for r in data.get("dhcp_reservations", []) + if r.get("radius_client") is True and r.get("vlan") in vlan_by_name + ] + + +def radius_enabled(data): + """Return True if any reservation has radius_client: true.""" + return len(radius_clients(data)) > 0 + + +# =================================================================== +# Secret +# =================================================================== + +def ensure_radius_secret(): + """Generate a random RADIUS shared secret if .radius-secret does not exist.""" + if RADIUS_SECRET_FILE.exists(): + return RADIUS_SECRET_FILE.read_text().strip() + import secrets as _secrets + secret = _secrets.token_urlsafe(32) + RADIUS_SECRET_FILE.write_text(secret + "\n") + RADIUS_SECRET_FILE.chmod(0o644) + print(f"Generated RADIUS shared secret: {RADIUS_SECRET_FILE}") + print(f" ACTION REQUIRED: enter this shared secret into your managed switch's RADIUS configuration:") + print(f" {secret}") + return secret + + +# =================================================================== +# Config file builders +# =================================================================== + +def build_radius_clients_conf(data, secret): + """Generate freeradius clients.conf from reservations with radius_client: true.""" + lines = [ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + "", + "# localhost (required)", + "client localhost {", + " ipaddr = 127.0.0.1", + f" secret = {secret}", + " shortname = localhost", + "}", + "", + ] + for r, vlan in radius_clients(data): + name = r.get("hostname") or r.get("description", "unknown").replace(" ", "-").lower() + lines += [ + f"# {r['description']}", + f"client {name} {{", + f" ipaddr = {r['ip']}", + f" secret = {secret}", + f" shortname = {name}", + "}", + "", + ] + return "\n".join(lines) + + +def fmt_mac(raw, fmt): + c = raw.replace(':', '').replace('-', '').lower() + pairs = [c[i:i+2] for i in range(0, 12, 2)] + upper = fmt[0].isupper() + if fmt in ('aabbccddeeff', 'AABBCCDDEEFF'): + sep = '' + elif fmt in ('aa-bb-cc-dd-ee-ff', 'AA-BB-CC-DD-EE-FF'): + sep = '-' + else: + sep = ':' + joined = sep.join(pairs) + return joined.upper() if upper else joined + + +def build_radius_users(data): + """ + Generate freeradius users file content. + Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID. + Unknown MACs fall through to DEFAULT which returns the radius_default VLAN. + MAC format and DEFAULT rule scope are read from radius.options in config. + Returns the file content as a string, or None if no VLAN has radius_default: true. + """ + default_vlan = next( + (v for v in data["vlans"] if v.get("radius_default") is True), None + ) + if default_vlan is None: + return None + + fr_opts = data.get('radius', {}).get('options', {}) + mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff') + apply_to = fr_opts.get('apply_to', 'all') + + lines = [ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + "", + ] + + vlan_by_name = {v["name"]: v for v in data.get("vlans", [])} + for r in data.get("dhcp_reservations", []): + if r.get("enabled") is not True: + continue + raw_mac = r.get("mac", "") + if not raw_mac: + continue + vlan = vlan_by_name.get(r.get("vlan", "")) + if not vlan: + continue + mac = fmt_mac(raw_mac, mac_fmt) + vlan_id = vlan.get('vlan_id') + lines += [ + f"# {r['description']} -> VLAN {vlan_id} ({vlan['name']})", + f"{mac} Cleartext-Password := \"{mac}\"", + f" Tunnel-Type = VLAN,", + f" Tunnel-Medium-Type = IEEE-802,", + f" Tunnel-Private-Group-Id = \"{vlan_id}\"", + "", + ] + + default_id = default_vlan.get('vlan_id') + ap_ips = fr_opts.get('ap_ips', []) + if apply_to == 'wireless': + default_check = "DEFAULT NAS-Port-Type = Wireless-802.11, Auth-Type := Accept" + elif apply_to == 'huntgroup' and ap_ips: + default_check = f'DEFAULT Huntgroup-Name == "{RADIUS_HUNTGROUP_NAME}", Auth-Type := Accept' + else: + default_check = "DEFAULT Auth-Type := Accept" + lines += [ + f"# Default -- unknown MACs land on VLAN {default_id} ({default_vlan['name']})", + default_check, + f" Tunnel-Type = VLAN,", + f" Tunnel-Medium-Type = IEEE-802,", + f" Tunnel-Private-Group-Id = \"{default_id}\"", + "", + ] + + return "\n".join(lines) + + +# =================================================================== +# freeradius config file patching +# =================================================================== + +def set_freeradius_log(enabled): + """Enable or disable auth logging lines in radiusd.conf. Returns True if the file was modified.""" + if not RADIUS_CONF_FILE.exists(): + return False + value = 'yes' if enabled else 'no' + content = RADIUS_CONF_FILE.read_text() + updated = re.sub(r'(?m)^(\s*auth\s*=\s*)(yes|no)', rf'\g<1>{value}', content) + updated = re.sub(r'(?m)^(\s*auth_accept\s*=\s*)(yes|no)', rf'\g<1>{value}', updated) + updated = re.sub(r'(?m)^(\s*auth_reject\s*=\s*)(yes|no)', rf'\g<1>{value}', updated) + if updated == content: + print(f"radiusd.conf: auth logging already {'enabled' if enabled else 'disabled'}.") + return False + RADIUS_CONF_FILE.write_text(updated) + print(f"radiusd.conf: auth logging {'enabled' if enabled else 'disabled'}.") + return True + + +def write_huntgroups(data): + """Write the huntgroups file if apply_to=huntgroup and ap_ips are configured. + Returns True if the file was written, False if not applicable or already up to date. + """ + opts = data.get('radius', {}).get('options', {}) + apply_to = opts.get('apply_to', 'all') + ap_ips = opts.get('ap_ips', []) + if apply_to != 'huntgroup' or not ap_ips: + return False + lines = [ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + "", + ] + for ip in ap_ips: + lines.append(f"{RADIUS_HUNTGROUP_NAME} NAS-IP-Address == {ip}") + content = "\n".join(lines) + "\n" + existing = RADIUS_HUNTGROUPS.read_text() if RADIUS_HUNTGROUPS.exists() else None + if existing == content: + return False + RADIUS_HUNTGROUPS.write_text(content) + print(f"Written: {RADIUS_HUNTGROUPS}") + return True + + +def toggle_freeradius_block(content, block_name, enable): + """Comment out or uncomment a named brace block in a freeradius config file. + + Finds the block by name (whether currently commented or not), locates its + matching closing brace via depth counting, then comments or uncomments the + entire range as a unit. + """ + lines = content.splitlines(keepends=True) + i = 0 + while i < len(lines): + effective = re.sub(r'^(\s*)#', r'\1', lines[i]) + if re.match(r'\s*' + re.escape(block_name) + r'\s*\{', effective): + depth = 0 + j = i + while j < len(lines): + eff = re.sub(r'^(\s*)#', r'\1', lines[j]) + depth += eff.count('{') - eff.count('}') + if depth == 0: + break + j += 1 + for k in range(i, j + 1): + line = lines[k] + if enable: + lines[k] = re.sub(r'^(\s*)#', r'\1', line, count=1) + else: + if line.strip() and not re.match(r'\s*#', line): + lines[k] = re.sub(r'^(\s*)', r'\1#', line, count=1) + return ''.join(lines) + i += 1 + return content + + +def set_freeradius_eap(data): + """Patch EAP config for tunneled_reply and allow_weak_eap settings. + Returns True if the file was modified, False if unchanged or not found. + """ + if not RADIUS_EAP_FILE.exists(): + return False + eap_cfg = data.get('radius', {}).get('eap', {}) + tunneled_reply = eap_cfg.get('tunneled_reply', False) + allow_weak_eap = eap_cfg.get('allow_weak_eap', False) + content = RADIUS_EAP_FILE.read_text() + tr_val = 'yes' if tunneled_reply else 'no' + content2 = re.sub(r'(?m)^(\s*use_tunneled_reply\s*=\s*)(yes|no)', rf'\g<1>{tr_val}', content) + content3 = toggle_freeradius_block(content2, 'md5', allow_weak_eap) + if content3 == content: + return False + RADIUS_EAP_FILE.write_text(content3) + print(f"EAP: tunneled_reply={tr_val}, allow_weak_eap={allow_weak_eap}") + return True + + +# =================================================================== +# Apply +# =================================================================== + +def apply_radius(data): + """Write FreeRADIUS config files and restart the service. + Returns error string on failure, None on success. + """ + secret = ensure_radius_secret() + + clients_content = build_radius_clients_conf(data, secret) + users_content = build_radius_users(data) + if users_content is None: + return "No VLAN has radius_default: true. Cannot generate RADIUS users file." + + logging = data.get('radius', {}).get('general', {}).get('logging', False) + + changed = set_freeradius_log(logging) + changed |= write_huntgroups(data) + changed |= set_freeradius_eap(data) + for path, content in [(RADIUS_CLIENTS_CONF, clients_content), + (RADIUS_USERS_FILE, users_content)]: + existing = path.read_text() if path.exists() else None + if existing != content: + path.write_text(content) + print(f"Written: {path}") + changed = True + else: + print(f"Unchanged: {path}") + + svc = "freeradius" + state = subprocess.run( + ["systemctl", "is-active", svc], capture_output=True, text=True + ).stdout.strip() + if state == "active": + if changed: + result = subprocess.run(["systemctl", "restart", svc], + capture_output=True, text=True) + if result.returncode == 0: + print("freeradius restarted.") + else: + shared.service_warning("restart", "freeradius", result.stderr) + else: + print("freeradius: running, config unchanged.") + else: + subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True) + result = subprocess.run(["systemctl", "start", svc], + capture_output=True, text=True) + if result.returncode == 0: + print("freeradius started.") + else: + shared.service_warning("start", "freeradius", result.stderr) diff --git a/routlin/mod_shared.py b/routlin/mod_shared.py new file mode 100644 index 0000000..cc8b858 --- /dev/null +++ b/routlin/mod_shared.py @@ -0,0 +1,137 @@ +""" +mod_shared.py -- Constants and helpers shared across all routlin modules. +""" + +import ipaddress +import os +from pathlib import Path +import mod_validation as validation + +PRODUCT_NAME = "routlin" +SCRIPT_DIR = Path(__file__).parent +DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}") +LEASES_DIR = Path("/var/lib/misc") +SYSTEMD_DIR = Path("/etc/systemd/system") + + +# =================================================================== +# Process / error helpers +# =================================================================== + +def is_root(): + return os.geteuid() == 0 + + +def service_warning(action, svc, stderr): + msg = stderr.strip() + print(f"WARNING: Failed to {action} {svc}: {msg}") + if "not found" in msg.lower() or "not-found" in msg.lower(): + print(f" -> Package may not be installed. Run: sudo python3 install.py") + + +def chown_to_script_dir_owner(path): + try: + stat = SCRIPT_DIR.stat() + os.chown(path, stat.st_uid, stat.st_gid) + except OSError: + pass + + +# =================================================================== +# Network / VLAN utilities +# =================================================================== + +def prefix_to_dotted(n): + mask = (0xFFFFFFFF << (32 - int(n))) & 0xFFFFFFFF + return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0)) + + +def network_for(vlan): + return ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False) + + +def lowest_quartet_ip(vlan): + """Return the server_identity IP with the lowest last-octet value, or None if there are no valid identities.""" + identities = vlan.get("server_identities", []) + ips = [] + for s in identities: + try: + ips.append(ipaddress.IPv4Address(s["ip"])) + except (KeyError, ValueError): + pass + if not ips: + return None + return str(min(ips, key=lambda ip: ip.packed[-1])) + + +def resolve_vlan_options(vlan): + """ + Resolve gateway, dns_server, and ntp_server for a VLAN. + + For both WG and non-WG VLANs: gateway defaults to the lowest-last-octet + server_identity IP unless overridden in explicit_overrides. The gateway + override must be one of the server_identity IPs. + + WG VLANs: ntp_server is None (WireGuard has no DHCP so NTP cannot be + advertised to peers). Overrides live in vpn_information.explicit_overrides. + + Non-WG VLANs: overrides live in dhcp_information.explicit_overrides. + Returns a dict with keys: gateway, dns_server, ntp_server. + """ + if validation.is_wg(vlan): + vpi = vlan["vpn_information"] + overrides = vpi.get("explicit_overrides", {}) + default = lowest_quartet_ip(vlan) or str(next(network_for(vlan).hosts())) + gateway = overrides.get("gateway", "") or default + dns = overrides.get("dns_servers", "") or gateway + return { + "gateway": gateway, + "dns_servers": dns, + "ntp_servers": None, + } + overrides = vlan.get("dhcp_information", {}).get("explicit_overrides", {}) + default = lowest_quartet_ip(vlan) + def _resolve(key): + v = overrides.get(key, "") + if isinstance(v, list): + return ','.join(v) if v else default + return v or default + return { + "gateway": overrides.get("gateway", "") or default, + "dns_servers": _resolve("dns_servers"), + "ntp_servers": _resolve("ntp_servers"), + } + + +def is_physical(vlan): + return vlan.get("vlan_id") == 1 + + +# =================================================================== +# VLAN naming / path helpers +# =================================================================== + +def networkd_stem(vlan): + return f"10-{PRODUCT_NAME}-{vlan['name']}" + + +def vlan_service_name(vlan, iface): + if validation.is_wg(vlan): + return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{iface}" + return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}" + + +def vlan_service_file(vlan, iface): + return SYSTEMD_DIR / f"{vlan_service_name(vlan, iface)}.service" + + +def vlan_conf_file(vlan): + return DNSMASQ_CONF_DIR / f"{vlan['name']}.conf" + + +def vlan_leases_file(vlan): + return LEASES_DIR / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.leases" + + +def vlan_pid_file(vlan): + return Path("/run") / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.pid" diff --git a/routlin/mod_timers.py b/routlin/mod_timers.py new file mode 100644 index 0000000..3e44e0b --- /dev/null +++ b/routlin/mod_timers.py @@ -0,0 +1,214 @@ +""" +mod_timers.py -- systemd timer installation and removal. + +Manages the blocklist refresh timer, DDNS maintenance timer, and +generic interval timers (health check, etc.). +""" + +import subprocess + +import mod_shared as shared + +BLIST_TIMER_NAME = f"{shared.PRODUCT_NAME}-dns-blocklist-update" +BLIST_TIMER_FILE = shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer" +BLIST_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service" + +MAINT_TIMER_NAME = f"{shared.PRODUCT_NAME}-maintenance" +MAINT_TIMER_FILE = shared.SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.timer" +MAINT_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.service" + +HEALTH_TIMER_NAME = f"{shared.PRODUCT_NAME}-health-check" +HEALTH_TIMER_FILE = shared.SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer" +HEALTH_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service" +HEALTH_TIMER_INTERVAL_SEC = 300 + + +# =================================================================== +# Blocklist timer +# =================================================================== + +def _parse_time_to_calendar(time_str): + """Convert HH:MM string to a systemd OnCalendar value. Raises ValueError on bad format.""" + parts = time_str.strip().split(":") + if len(parts) != 2: + raise ValueError(f"Invalid daily_execute_time_24hr_local: '{time_str}'. Expected HH:MM.") + hh, mm = parts + return f"*-*-* {hh.zfill(2)}:{mm.zfill(2)}:00" + +def install_timer(data): + """Install the daily blocklist refresh timer. Returns error string on failure, None on success.""" + general = data.get("dns_blocking", {}).get("general", {}) + execute_time = general.get("daily_execute_time_24hr_local", "02:30") + try: + on_calendar = _parse_time_to_calendar(execute_time) + except ValueError as e: + return str(e) + + timer_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=Daily blocklist refresh", + "", + "[Timer]", + f"OnCalendar={on_calendar}", + "Persistent=true", + "", + "[Install]", + "WantedBy=timers.target", + "", + ]) + + blocklist_script = shared.SCRIPT_DIR / "dns-blocklists.py" + service_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=Daily blocklist refresh", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=oneshot", + f"ExecStart=/usr/bin/python3 {blocklist_script}", + "", + ]) + + for path, content in ((BLIST_TIMER_FILE, timer_content), (BLIST_TIMER_SVC_FILE, service_content)): + if not path.exists() or path.read_text() != content: + path.write_text(content) + print(f"Written: {path}") + + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + subprocess.run(["systemctl", "enable", "--now", f"{BLIST_TIMER_NAME}.timer"], + capture_output=True, text=True) + print(f"Timer {BLIST_TIMER_NAME}.timer enabled (runs daily at {execute_time}).") + + +# =================================================================== +# Interval timers +# =================================================================== + +def install_interval_timers(names, timer_files, svc_files, descriptions, exec_starts, interval_secs): + for name, timer_file, svc_file, description, exec_start, interval_sec in zip( + names, timer_files, svc_files, descriptions, exec_starts, interval_secs): + timer_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + f"Description={description}", + "", + "[Timer]", + f"OnActiveSec={interval_sec}s", + f"OnUnitActiveSec={interval_sec}s", + "AccuracySec=10s", + "", + "[Install]", + "WantedBy=timers.target", + "", + ]) + service_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + f"Description={description}", + "", + "[Service]", + "Type=oneshot", + f"ExecStart={exec_start}", + "", + ]) + for path, content in ((timer_file, timer_content), (svc_file, service_content)): + if not path.exists() or path.read_text() != content: + path.write_text(content) + print(f"Written: {path}") + + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + for name, interval_sec in zip(names, interval_secs): + subprocess.run(["systemctl", "enable", f"{name}.timer"], capture_output=True, text=True) + active = subprocess.run( + ["systemctl", "is-active", f"{name}.timer"], + capture_output=True, text=True + ).stdout.strip() == "active" + verb = "restart" if active else "start" + subprocess.run(["systemctl", verb, f"{name}.timer"], capture_output=True, text=True) + print(f"Timer {name}.timer enabled (runs every {interval_sec}s).") + +def remove_timers(names, timer_files, svc_files, daemon_reload=False): + for name, timer_file, svc_file in zip(names, timer_files, svc_files): + subprocess.run(["systemctl", "disable", "--now", f"{name}.timer"], + capture_output=True, text=True) + for f in (timer_file, svc_file): + if f.exists(): + f.unlink() + print(f"Removed: {f}") + else: + print(f"Not found, skipping: {f}") + if daemon_reload: + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + + +# =================================================================== +# DDNS maintenance timer +# =================================================================== + +def _parse_ddns_interval(interval_str): + """Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value.""" + s = interval_str.strip() + if s.endswith("m"): return f"{s[:-1]}min" + if s.endswith("h"): return f"{s[:-1]}h" + if s.endswith("d"): return f"{s[:-1]}day" + raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.") + +def install_maint_timer(data): + ddns = data.get("ddns", {}) + interval = ddns.get("general", {}).get("timer_interval", "10m") + script_path = shared.SCRIPT_DIR / "maintenance.py" + try: + systemd_unit = _parse_ddns_interval(interval) + except ValueError as e: + print(f"DDNS timer: {e}") + return + + service_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=DDNS IP update", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=oneshot", + f"ExecStart=/usr/bin/python3 {script_path} --update", + "", + ]) + timer_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=DDNS IP update timer", + "", + "[Timer]", + f"OnActiveSec={systemd_unit}", + f"OnUnitActiveSec={systemd_unit}", + "OnBootSec=1min", + "AccuracySec=10s", + "", + "[Install]", + "WantedBy=timers.target", + "", + ]) + for path, content in ((MAINT_TIMER_SVC_FILE, service_content), (MAINT_TIMER_FILE, timer_content)): + if not path.exists() or path.read_text() != content: + path.write_text(content) + print(f"Written: {path}") + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + active = subprocess.run( + ["systemctl", "is-active", f"{MAINT_TIMER_NAME}.timer"], + capture_output=True, text=True + ).stdout.strip() == "active" + verb = "restart" if active else "enable --now" + subprocess.run(["systemctl"] + verb.split() + [f"{MAINT_TIMER_NAME}.timer"], + capture_output=True, text=True) + print(f"Timer {MAINT_TIMER_NAME}.timer enabled (runs every {interval}).") diff --git a/routlin/validation.py b/routlin/mod_validation.py similarity index 100% rename from routlin/validation.py rename to routlin/mod_validation.py diff --git a/routlin/mod_wireguard.py b/routlin/mod_wireguard.py new file mode 100644 index 0000000..a0d2bb4 --- /dev/null +++ b/routlin/mod_wireguard.py @@ -0,0 +1,157 @@ +""" +mod_wireguard.py -- WireGuard server configuration and interface management. + +Generates /etc/wireguard/.conf from config.json peers, manages server +key generation, and brings WireGuard interfaces up or syncs peer changes. +""" + +import re +import subprocess +from pathlib import Path + +import mod_shared as shared +import mod_validation as validation + +WG_DIR = Path("/etc/wireguard") +WG_KEEPALIVE = 25 + + +# =================================================================== +# Interface state +# =================================================================== + +def wg_interface_up(iface): + """Return True if the WireGuard interface exists and is up.""" + result = subprocess.run(["ip", "link", "show", iface], + capture_output=True, text=True) + return result.returncode == 0 + + +# =================================================================== +# Key and path helpers +# =================================================================== + +def wg_server_key_path(iface): + return WG_DIR / f"{iface}.key" + +def wg_server_pubkey_path(iface): + """Public key written to the configs dir so the Flask app can read it.""" + return shared.SCRIPT_DIR / f".{iface}.pub" + +def wg_conf_path_for(iface): + return WG_DIR / f"{iface}.conf" + +def generate_wg_server_key(iface): + WG_DIR.mkdir(exist_ok=True) + result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True) + private = result.stdout.strip() + kf = wg_server_key_path(iface) + kf.write_text(private + "\n") + kf.chmod(0o600) + return private + + +# =================================================================== +# Config generation +# =================================================================== + +def build_wg_server_conf(vlan, server_private_key, iface): + """Build the /etc/wireguard/.conf content from config.json peers.""" + info = vlan["vpn_information"] + gateway = shared.resolve_vlan_options(vlan)["gateway"] + network = shared.network_for(vlan) + server_ip = f"{gateway}/{network.prefixlen}" + listen_port = info["listen_port"] + + L = [ + "# Generated by core.py -- do not edit manually.", + "# Run: sudo python3 core.py --apply", + "", + "[Interface]", + f"PrivateKey = {server_private_key}", + f"Address = {server_ip}", + f"ListenPort = {listen_port}", + "", + ] + + for peer in vlan.get("peers", []): + if not peer.get("enabled", True): + L += [f"# DISABLED: {peer['name']}", ""] + continue + L += [ + f"# {peer['name']}", + "[Peer]", + f"PublicKey = {peer['public_key']}", + f"AllowedIPs = {peer['ip']}/32", + f"PersistentKeepalive = {WG_KEEPALIVE}", + "", + ] + + return "\n".join(L) + + +# =================================================================== +# Apply +# =================================================================== + +def ensure_wg_interfaces(data): + """Generate WireGuard server confs and bring up / sync all WG interfaces.""" + wg_vlans = [v for v in data.get("vlans", []) if validation.is_wg(v)] + if not wg_vlans: + return + + for vlan in wg_vlans: + iface = validation.derive_interface(vlan, data) + print(f" [{iface}]") + + kf = wg_server_key_path(iface) + if not kf.exists(): + print(f" Generating server private key...") + private = generate_wg_server_key(iface) + else: + private = kf.read_text().strip() + + pub_result = subprocess.run( + ["wg", "pubkey"], input=private, capture_output=True, text=True, check=True + ) + public = pub_result.stdout.strip() + pubkey_file = wg_server_pubkey_path(iface) + pubkey_file.write_text(public + "\n") + shared.chown_to_script_dir_owner(pubkey_file) + print(f" Server public key: {public[:20]}...") + + WG_DIR.mkdir(exist_ok=True) + conf_file = wg_conf_path_for(iface) + new_conf = build_wg_server_conf(vlan, private, iface) + listen_port = vlan["vpn_information"]["listen_port"] + + port_changed = False + if conf_file.exists(): + m = re.search(r'ListenPort\s*=\s*(\d+)', conf_file.read_text()) + if m and int(m.group(1)) != listen_port: + port_changed = True + + conf_file.write_text(new_conf) + conf_file.chmod(0o600) + + peer_count = len([p for p in vlan.get("peers", []) if p.get("enabled", True)]) + print(f" Wrote {conf_file} ({peer_count} enabled peer(s))") + + if not wg_interface_up(iface): + print(f" Bringing up {iface}...") + r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True) + if r.returncode != 0: + print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}") + else: + print(f" {iface} is up.") + elif port_changed: + print(f" Listen port changed - restarting {iface}...") + subprocess.run(["wg-quick", "down", iface], capture_output=True, text=True) + r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True) + if r.returncode != 0: + print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}") + else: + print(f" {iface} restarted.") + else: + print(f" Syncing peers to live {iface}...") + subprocess.run(["wg", "syncconf", iface, str(conf_file)], capture_output=True, text=True)