commit 9c043ae30c78d46e7e2720d6a915003696b35177 Author: Matthew Grotke Date: Thu Apr 9 23:50:42 2026 -0400 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..1653d51 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# Linux Home Router Suite + +A collection of Python scripts that transform an existing Linux server (with at least two Ethernet NICs) into a fully featured home router - eliminating the need for a separate router appliance. + +## Why Replace Your Router? + +Consumer and prosumer router appliances are constrained by OEM firmware. Security patches depend entirely on the vendor's release schedule, features and functionality are often limited, and devices that reach end of life receive no vendor support at all, leaving gaping security vulnerabilities exposed on your network indefinitely. Running your router on a general-purpose Linux machine gives you: + +- **Faster speeds** - Utilize full fledged computer hardware (typically exceeds that of consumer appliances) +- **Full flexibility** - Any configuration that Linux and its tooling support +- **Better security** - Patch your own kernel and packages on your own schedule, with no dependency on a vendor who may have abandoned your hardware + +--- + +## Summary + +These scripts do not run continuously in the background. They simply install and facilitate the configuration of battle-hardened software (`dnsmasq`, for DHCP and DNS, `nftables` for firewall and NAT, `chrony` for NTP, `freeradius` for RADIUS, `avahi` for mDNS discovery, and `wireguard` for VPN) by using intuitive JSON files that you can edit. The scripts also install systemd timers to run periodic activities: updating the DNS blocklist(s) of your choice (default once per day), optionally checking if your external IP address changed (default every 5 mins) and if so, updating a DDNS provider. + +--- + +## Capabilities + +The suite is organized into three independent but complementary scripts, each managing one layer of the stack: + +### Core: DHCP, DNS, Blocklists, Firewall, RADIUS, and mDNS (`core.py`) + +- Configures VLAN sub-interfaces via `systemd-networkd` +- Assigns static or dynamic DHCP reservations by MAC address and hostname +- Defines dynamic IP pools per VLAN +- Manages per-VLAN gateway, DNS, and NTP settings derived from `server_identities` +- Runs one `dnsmasq` instance per VLAN, each bound exclusively to its gateway IP, giving true per-VLAN DNS filtering +- Downloads and merges blocklists from upstream providers you choose (e.g. OISD, Hagezi) +- Applies per-VLAN content filtering - VLANs with different blocklist sets each get their own merged blocklist +- Supports local hostname overrides (split DNS for DDNS hostnames) +- Installs a daily `systemd` timer to refresh blocklists +- Tracks lifetime DNS metrics (queries forwarded, cache hits, authoritative, TCP peaks, pool usage) +- Builds `nftables` tables atomically - safe to re-apply without service disruption +- Handles port forwarding (DNAT/SNAT) for externally accessible services +- Handles port wrangling - redirects DNS and NTP requests to the local resolver regardless of what the client device may have hardcoded +- Blocks traffic from specific IPs or subnets via `banned_ips` - supports single IPs, CIDR notation, wildcards, and ranges for both IPv4 and IPv6 +- Enforces inter-VLAN isolation by default (forward chain policy drop); specific cross-VLAN traffic is permitted via `inter_vlan_exceptions` +- Masquerades outbound traffic for all non-WireGuard VLANs automatically +- Auto-detects active container bridge interfaces (Docker, Podman, libvirt, etc.) and adds forward rules so VLAN clients can reach containerized services +- Installs a `systemd` boot service (`core-nat.service`) to re-apply firewall rules on every boot +- Co-exists with Docker (does not touch Docker-managed `nat`/`filter` tables) +- Generates FreeRADIUS `clients.conf` and `users` files from `core.json` reservations, enabling dynamic VLAN assignment via MAC Authentication Bypass (MAB) for both wired (802.1X) and wireless clients +- Manages a `.radius-secret` shared secret file (generated automatically on first `--apply` if RADIUS is enabled) +- Configures `avahi-daemon` as an mDNS reflector to forward service discovery announcements (AirPrint, AirPlay, Chromecast, etc.) across VLANs + +### Optional: VPN (`vpn.py`) + +- Supports any number of WireGuard interfaces defined in `core.json` (any VLAN with an interface name starting with `wg`) +- Allocates IP addresses to remote peers automatically from the VPN VLAN subnet +- Generates per-peer client config files ready for import into any WireGuard client, with per-peer choice of split tunnel or full tunnel routing +- Resolves the server's public endpoint from the DDNS config or manual entry +- Stores peer data in per-interface dotfiles (`.vpn-wg0`, etc.) alongside the scripts +- Reports per-peer handshake times and RX/TX byte counts + +### Optional: DDNS (`ddns.py`) + +- Detects the current public IP by rotating through multiple IP-check services +- Updates the specified DNS providers (currently supporting No-IP and DuckDNS), supporting multiple hostnames and subdomains per provider +- Caches the last known IP per provider to avoid unnecessary API calls +- Installs a `systemd` timer that runs every 5 minutes by default +- Logs all updates and errors to `ddns.log` + +--- + +## Software Dependencies + +These packages are required. `core.py --install` checks that they are installed and will prompt you to install any that are missing. + +| Dependency | Purpose | Required By | +|---|---|---| +| `python3` | Runs all scripts | All | +| `systemd` | Service, timer, networkd, and timesyncd management | All | +| `dnsmasq` | DHCP server and DNS resolver/forwarder | `core.py` | +| `nftables` | Firewall, NAT, port forwarding, and port wrangling | `core.py` | +| `chrony` | NTP server - synchronizes system clock and serves time to VLAN clients | `core.py` | +| `freeradius` | RADIUS server for dynamic VLAN assignment via MAC auth | `core.py` | +| `avahi-daemon` | mDNS reflector for cross-VLAN service discovery | `core.py` | +| `wireguard-tools` | WireGuard VPN (`wg`, `wg-quick`) | `vpn.py` | + +--- + +## Conflicting Software + +The following services conflict with this suite. No manual action is required: `core.py` disables them automatically on `--apply`. `core.py` re-enables them on `--disable`. + +- **systemd-resolved** - DNS stub resolver that conflicts with `dnsmasq` on port 53. Disabled on `--apply`; re-enabled on `--disable`. +- **systemd-timesyncd** - Basic SNTP client that cannot serve time to LAN clients. Disabled on `--apply` and replaced by `chrony`; re-enabled on `--disable`. +- **ufw** - Firewall manager that conflicts with the `nftables` ruleset. Disabled on `--apply` without removal. + +--- + +## Hardware Requirements + +- A Linux server with **at least two Ethernet NICs**: + - One NIC facing your ISP modem/ONT (WAN) + - One NIC facing your internal switch (LAN) + +--- + +## Configuration Files + +All configuration lives in two JSON files. Edit these to match your network before running any scripts. + +| File | Controls | +|---|---| +| `core.json` | VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface and listen port, upstream DNS servers, blocklist sources, per-VLAN blocklist assignments, host overrides, banned IPs, WAN interface, port forwarding rules, port wrangling, inter-VLAN exceptions | +| `ddns.json` | DDNS provider credentials, hostnames/subdomains, update interval, IP-check services | + +### Dotfiles (auto-generated, do not edit) + +| File | Purpose | +|---|---| +| `.radius-secret` | Shared secret between FreeRADIUS and RADIUS clients (APs, switches). Generated automatically on first `--apply` when RADIUS is enabled. Root-owned intentionally. | +| `.vpn-wg0` (etc.) | WireGuard peer data per interface. Managed by `vpn.py`. | +| `.ddns-last-ip-*` | Cached public IP per DDNS provider. Managed by `ddns.py`. | +| `.ddns-last-service` | Tracks IP-check service rotation. Managed by `ddns.py`. | + +--- + +## Initial Configuration + +### 1. Edit Core Configuration (`core.json`) + +Edit the top-level `general` block: + +- Set `wan_interface` to the name of your WAN-facing NIC (e.g. `eno2`). Run `ip link` to find it. + +Edit the top-level blocks: + +- Set `upstream_dns.upstream_servers` to your preferred DNS resolvers (e.g. `1.1.1.1`, `8.8.8.8`) +- Add blocklist sources under `blocklists` with a name, URL, and format for each (e.g. OISD, Hagezi) +- Add entries to `host_overrides` for any local hostnames that should resolve to a specific IP (e.g. a DDNS hostname pointing to an internal server) +- Add entries to `port_forwarding` for any services that should be reachable from the internet (specify protocol, external port, destination IP, and destination port) +- Add entries to `banned_ips` to block traffic from specific IPs or networks (see below) + +Edit the `vlans` array to match your network topology. For each VLAN: + +- Set `vlan_id` to a unique integer (`1` = untagged physical interface, all others are 802.1Q tagged) +- Set `interface` to the NIC name for VLAN 1 (e.g. `enp6s0`); sub-interfaces are named automatically (e.g. `enp6s0.10`). For WireGuard VLANs, use `wg0`, `wg1`, etc. +- Set `radius_default` to `true` on exactly one VLAN - unknown MACs will be placed here (typically guest). All other VLANs set this to `false`. +- Set `use_blocklists` to a list of blocklist names for this VLAN - leave empty for unfiltered DNS +- Set `server_identities` to the IPs the router itself will hold on this VLAN. The lowest last-octet IP is auto-used as gateway, DNS, and NTP server unless overridden in `dhcp.explicit_overrides`. +- Set `dhcp` fields: `subnet`, `subnet_mask`, pool start/end, `lease_time`, and optionally `explicit_overrides` for gateway, dns_server, or ntp_server +- Add `reservations` for devices that need a known VLAN assignment by MAC address. The `ip` field is optional: + - Omit `ip`, set it to `""`, or set it to `"dynamic"` to let DHCP assign from the pool (hostname is still set) + - Set `ip` to a specific address outside the dynamic pool to pin the device to that IP + - Set `radius_client: true` on any device (AP, switch) that will authenticate other devices via RADIUS +- Add per-VLAN `port_wrangling` entries to redirect DNS or NTP requests to the local resolver +- For WireGuard VLANs, include a `vpn_information` block instead of `dhcp` and `server_identities`: + +```json +{ + "vlan_id": 40, + "name": "vpn", + "interface": "wg0", + "radius_default": false, + "use_blocklists": ["oisd-big"], + "vpn_information": { + "listen_port": 51820, + "gateway": "192.168.40.1", + "domain": "local", + "explicit_overrides": { "dns_server": "", "mtu": "" } + }, + "reservations": [], + "port_wrangling": [...] +} +``` + +### Banned IPs + +The top-level `banned_ips` array blocks inbound and outbound traffic to/from specific IPs or networks at the firewall level. This is useful for blocking known malicious hosts, entire ASNs, or geographic ranges. Entries support a flexible address syntax: + +```json +"banned_ips": [ + { "description": "Single IP", "enabled": true, "ip": "94.130.52.18" }, + { "description": "IPv4 /24 wildcard", "enabled": true, "ip": "94.130.52.*" }, + { "description": "IPv4 /16 wildcard", "enabled": true, "ip": "94.130.*.*" }, + { "description": "IPv4 CIDR", "enabled": true, "ip": "94.130.0.0/16" }, + { "description": "IPv4 range", "enabled": true, "ip": "94.130.52.1-20" }, + { "description": "IPv4 range+wildcard", "enabled": true, "ip": "94.130-133.52.*" }, + { "description": "Single IPv6", "enabled": true, "ip": "2a01:4f8:c17:b0f::2" }, + { "description": "IPv6 /48 wildcard", "enabled": true, "ip": "2a01:4f8:c17:*" }, + { "description": "IPv6 CIDR", "enabled": true, "ip": "2a01:4f8::/32" } +] +``` + +- `ip` - the address or range to block; supports single IPs, CIDR notation, wildcard octets (`*`), and numeric ranges within a quartet (e.g. `1-20`) +- `enabled` - set to `false` to disable without removing the entry +- Bans apply to both IPv4 and IPv6 traffic + +### Inter-VLAN Firewall + +All cross-VLAN traffic is blocked by default (nftables forward chain policy drop). To permit specific traffic between VLANs, add entries to the top-level `inter_vlan_exceptions` array: + +```json +{ + "description": "Kids -> Plex", + "enabled": true, + "protocol": "both", + "src_ip_or_subnet": "192.168.30.0/24", + "dst_ip_or_subnet": "192.168.1.20", + "dst_port": 32400 +} +``` + +- `src_ip_or_subnet` - single IP or CIDR subnet +- `dst_ip_or_subnet` - single IP or CIDR subnet +- `dst_port` - optional; omit to allow all ports to the destination +- `protocol` - `tcp`, `udp`, or `both` +- `enabled` - set to `false` to disable without removing + +### RADIUS / Dynamic VLAN Assignment + +When at least one reservation has `radius_client: true`, RADIUS is automatically enabled: + +- FreeRADIUS is configured to accept authentication requests from those devices (APs, switches) +- Every MAC reservation across all VLANs is mapped to its VLAN ID in the FreeRADIUS `users` file +- Unknown MACs are assigned to the `radius_default` VLAN +- The shared secret is stored in `.radius-secret` and generated on first `--apply` +- Port 1812 is restricted in nftables to accept connections only from `radius_client` IPs + +Point your AP/switch RADIUS configuration at `:1812` using the secret from `.radius-secret`. + +### mDNS Reflection + +mDNS (Multicast DNS) is the protocol devices use to advertise and discover services on a local network - it powers AirPrint (printer discovery), AirPlay, Chromecast, and similar zero-configuration protocols. mDNS uses the multicast address `224.0.0.251:5353`, which is intentionally scoped to a single subnet and does not cross VLAN boundaries on its own. + +**Single-VLAN networks:** mDNS works without any configuration - all devices share the same subnet and can hear each other's announcements directly. The `mdns_reflection` feature is unnecessary and should be left disabled or omitted entirely. + +**Multi-VLAN networks:** A device on the IoT VLAN (e.g. a network printer) advertising via mDNS is invisible to devices on the Kids or Trusted VLANs, because the multicast packets never leave the IoT subnet. The `mdns_reflection` feature solves this by running `avahi-daemon` as an mDNS proxy on the router, which has an interface on every VLAN. Avahi listens for mDNS announcements arriving on any of the designated reflection interfaces and re-broadcasts them on all the others, making services discoverable across VLANs without requiring any changes on the devices themselves. + +Configure mDNS reflection with the top-level `mdns_reflection` block in `core.json`: + +```json +"mdns_reflection": { + "enabled": true, + "reflect_vlans": ["iot", "guest", "kids"] +} +``` + +- `enabled` - set to `false` to disable entirely; avahi-daemon will be stopped and disabled on the next `--apply` +- `reflect_vlans` - list of VLAN names to participate in reflection; must contain at least two names; WireGuard VLANs are not supported + +**Important:** mDNS reflection makes services *discoverable* across VLANs, but the actual service traffic still requires appropriate `inter_vlan_exceptions` rules to pass through the firewall. For example, to print from the Kids VLAN to a printer on the IoT VLAN, you need both mDNS reflection (so the printer is discovered) and firewall exceptions for ports 9100/TCP and 631/TCP (so the print job can actually reach it). + +### 2. Edit DDNS Configuration (`ddns.json`) + +- Set `provider` to `noip` or `duckdns` +- For No-IP: set `username`, `password`, and the `hostnames` array +- For DuckDNS: set `token` and the `subdomains` array +- Set `timer_interval` to how often the IP should be checked (default: `5m`) +- The `ip_check_services` list is used in rotation to detect your current public IP - the defaults can be left as-is + +--- + +## Initial Deployment + +```bash +sudo ./core.py --install # Check and install required packages +sudo ./core.py --apply # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers +sudo ./core.py --update-blocklists # Download and apply blocklists +``` + +Optional (if DDNS is desired): + +```bash +sudo ./ddns.py --start # Run an immediate IP update and install the update timer +``` + +Optional (if VPN is desired): + +```bash +sudo ./vpn.py --add-peer # Add a VPN peer interactively +sudo ./vpn.py --apply # Write WireGuard config and start the interface +sudo ./core.py --apply # Run again after VPN to start dnsmasq for the VPN VLAN(s) +``` + +After adding VPN peers, transfer `vpn-client-.conf` to the peer device by secure means, then delete it from this server. + +--- + +## Usage Reference + +All scripts are designed to be run multiple times - re-running `--apply` replaces the previous configuration safely. + +### core.py + +Commands that modify system state require `sudo`. Read-only commands do not. + +``` +sudo ./core.py --install # Check and interactively install required packages +sudo ./core.py --apply # Apply full config: networkd, dnsmasq, nftables, RADIUS, mDNS, timer, boot service +sudo ./core.py --apply --dry-run # Preview --apply actions without making changes +sudo ./core.py --update-blocklists # Download and merge blocklists, then --apply +sudo ./core.py --disable # Revert to network client (interactive wizard) +sudo ./core.py --disable --dry-run # Preview --disable wizard without making changes +sudo ./core.py --reset-leases # Stop dnsmasq, delete all lease files, restart (forces devices to re-acquire) +sudo ./core.py --reset-leases VLAN # Reset leases for a specific VLAN only (e.g. trusted, iot, guest) + +./core.py --status # Per-VLAN dnsmasq, freeradius, avahi-daemon, timer, and boot service status +./core.py --view-configs # Active per-VLAN dnsmasq config files +./core.py --view-leases # Active DHCP leases across all VLANs with VLAN, type, and description +./core.py --view-rules # Active nftables ruleset +./core.py --view-metrics # Lifetime DNS metrics across all VLAN instances +``` + +### vpn.py + +All `vpn.py` commands require `sudo`. + +``` +sudo ./vpn.py --add-peer # Add a VPN peer interactively +sudo ./vpn.py --manage-peers # Rename, regenerate keys, or delete a peer +sudo ./vpn.py --apply # Write WireGuard config and start/restart the interface +sudo ./vpn.py --disable # Stop WireGuard on all interfaces +sudo ./vpn.py --status # WireGuard service and interface status +sudo ./vpn.py --view-peers # Per-peer handshake times and traffic stats +``` + +### ddns.py + +Only `--start` and `--disable` require `sudo` as they install/remove systemd timer files. All other commands run as a normal user. + +``` +sudo ./ddns.py --start # Run update and install systemd timer +sudo ./ddns.py --disable # Stop updates and remove systemd timer + +./ddns.py --apply # Run one immediate DDNS update (used by timer) +./ddns.py --force # Force update regardless of cached IP +./ddns.py --status # Timer/service status +``` + +--- + +## Disabling / Uninstalling Components + +```bash +sudo ./core.py --disable # Revert to network client (interactive wizard) +sudo ./vpn.py --disable # Stop WireGuard on all interfaces +sudo ./ddns.py --disable # Stop and remove DDNS timer +``` diff --git a/core.json b/core.json new file mode 100644 index 0000000..74e992d --- /dev/null +++ b/core.json @@ -0,0 +1,250 @@ +{ + "general": { + "wan_interface": "eno2", + "log_max_kb": 1024, + "log_errors_only": false, + "dnsmasq_log_queries": false, + "daily_execute_time_24hr_local": "02:30" + }, + + "upstream_dns": { + "strict_order": false, + "cache_size": 10000, + "upstream_servers": [ + "1.1.1.1", + "1.0.0.1", + "2606:4700:4700::1111", + "2606:4700:4700::1001" + ] + }, + + "banned_ips": [ + { "description": "Example: single IPv4 ban", "enabled": false, "ip": "94.130.52.18" }, + { "description": "Example: ban IPv4 /24 by wildcard", "enabled": false, "ip": "94.130.52.*" }, + { "description": "Example: ban IPv4 /16 by wildcard", "enabled": false, "ip": "94.130.*.*" }, + { "description": "Example: ban IPv4 CIDR", "enabled": false, "ip": "94.130.0.0/16" }, + { "description": "Example: ban IPv4 range in one quartet", "enabled": false, "ip": "94.130.52.1-20" }, + { "description": "Example: ban IPv4 range and wildcard", "enabled": false, "ip": "94.130-133.52.*" }, + { "description": "Example: single IPv6 ban", "enabled": false, "ip": "2a01:4f8:c17:b0f::2" }, + { "description": "Example: ban IPv6 /48 by wildcard", "enabled": false, "ip": "2a01:4f8:c17:*" }, + { "description": "Example: ban IPv6 CIDR", "enabled": false, "ip": "2a01:4f8::/32" } + ], + + "host_overrides": [ + { + "description": "LAN DNS override for home server DDNS hostname", + "enabled": true, + "host": "myhome.ddns.net", + "ip": "192.168.1.20" + } + ], + + "blocklists": [ + { + "name": "oisd-big", + "description": "OISD Big - ads, phishing, malware, telemetry", + "save_as": "oisd-big.conf", + "url": "https://big.oisd.nl/dnsmasq2", + "format": "dnsmasq" + }, + { + "name": "hagezi-light", + "description": "Hagezi Light - ads, tracking, metrics, badware", + "save_as": "hagezi-light.conf", + "url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/light.txt", + "format": "dnsmasq" + }, + { + "name": "hagezi-pro-plus", + "description": "Hagezi Pro Plus - ads, tracking, porn, gambling combined", + "save_as": "hagezi-pro-plus.conf", + "url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/pro.plus.txt", + "format": "dnsmasq" + } + ], + + "inter_vlan_exceptions": [ + { "description": "IoT TV -> Plex", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.10.3", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 32400 }, + { "description": "IoT Streaming Box -> Plex", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.10.4", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 32400 }, + { "description": "Kids -> Plex", "enabled": true, "protocol": "both", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 32400 }, + { "description": "Kids -> SMB", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 445 }, + { "description": "Kids -> Game Server", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 25565 }, + { "description": "Kids -> Web Server HTTP", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 80 }, + { "description": "Kids -> Web Server HTTPS", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 443 }, + { "description": "Trusted -> Printer (RAW)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.1.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 9100 }, + { "description": "Trusted -> Printer (IPP)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.1.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 631 }, + { "description": "Kids -> Printer (RAW)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 9100 }, + { "description": "Kids -> Printer (IPP)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 631 }, + { "description": "Guest -> Printer (RAW)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.20.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 9100 }, + { "description": "Guest -> Printer (IPP)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.20.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 631 }, + { "description": "VPN -> SSH + Rsync", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.40.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 22 }, + { "description": "VPN -> SMB", "enabled": false, "protocol": "tcp", "src_ip_or_subnet": "192.168.40.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 445 }, + { "description": "Trusted -> Kids (LAN Gaming)", "enabled": false, "protocol": "both", "src_ip_or_subnet": "192.168.1.0/24", "dst_ip_or_subnet": "192.168.30.0/24" }, + { "description": "Parent PC -> Kids (LAN Gaming)", "enabled": false, "protocol": "both", "src_ip_or_subnet": "192.168.1.50", "dst_ip_or_subnet": "192.168.30.0/24" }, + { "description": "Kids -> Parent PC (LAN Gaming)", "enabled": false, "protocol": "both", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.50" } + ], + + "port_forwarding": [ + { "description": "WireGuard VPN", "enabled": true, "protocol": "udp", "dest_port": 51820, "nat_ip": "192.168.1.20", "nat_port": 51820 }, + { "description": "Plex Server", "enabled": true, "protocol": "both", "dest_port": 32400, "nat_ip": "192.168.1.20", "nat_port": 32400 }, + { "description": "Web Server HTTP", "enabled": true, "protocol": "tcp", "dest_port": 80, "nat_ip": "192.168.1.20", "nat_port": 80 }, + { "description": "Web Server HTTPS", "enabled": true, "protocol": "tcp", "dest_port": 443, "nat_ip": "192.168.1.20", "nat_port": 443 }, + { "description": "Game Server", "enabled": true, "protocol": "tcp", "dest_port": 25565, "nat_ip": "192.168.1.20", "nat_port": 25565 }, + { "description": "SSH", "enabled": false, "protocol": "tcp", "dest_port": 22, "nat_ip": "192.168.1.20", "nat_port": 22 } + ], + + "vlans": [ + + { + "vlan_id": 1, + "name": "trusted", + "interface": "enp6s0", + "radius_default": false, + "use_blocklists": ["oisd-big", "hagezi-light"], + "server_identities": [ + { "description": "Router/Gateway", "ip": "192.168.1.1" }, + { "description": "Home Server", "ip": "192.168.1.20", "hostname": "homeserver" }, + { "description": "UniFi Controller Inform Host", "ip": "192.168.1.10", "hostname": "unifi-controller" } + ], + "dhcp": { + "subnet": "192.168.1.0", + "subnet_mask": "255.255.255.0", + "dynamic_pool_start": "192.168.1.100", + "dynamic_pool_end": "192.168.1.245", + "lease_time": "24h", + "domain": "local", + "explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" } + }, + "reservations": [ + { "enabled": true, "description": "UniFi Switch", "hostname": "unifi-switch", "mac": "aa:bb:cc:dd:ee:01", "ip": "192.168.1.2", "radius_client": true }, + { "enabled": true, "description": "UniFi AP (Kitchen)", "hostname": "unifi-ap-kitchen", "mac": "aa:bb:cc:dd:ee:02", "ip": "192.168.1.3", "radius_client": true }, + { "enabled": true, "description": "UniFi AP (Lounge)", "hostname": "unifi-ap-lounge", "mac": "aa:bb:cc:dd:ee:03", "ip": "192.168.1.4", "radius_client": true }, + { "enabled": true, "description": "UniFi AP (Upstairs)", "hostname": "unifi-ap-upstairs", "mac": "aa:bb:cc:dd:ee:04", "ip": "192.168.1.5", "radius_client": true }, + { "enabled": true, "description": "Home Server", "hostname": "homeserver", "mac": "aa:bb:cc:dd:ee:05", "ip": "192.168.1.20" }, + { "enabled": true, "description": "Desktop PC", "hostname": "desktop-pc", "mac": "aa:bb:cc:dd:ee:06", "ip": "192.168.1.50" } + ], + "port_wrangling": [ + { "description": "DNS wrangling - redirect Trusted DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.1.1" }, + { "description": "NTP wrangling - redirect Trusted NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.1.1" } + ] + }, + + { + "vlan_id": 10, + "name": "iot", + "interface": "enp6s0.10", + "radius_default": false, + "use_blocklists": ["oisd-big", "hagezi-light"], + "server_identities": [ + { "description": "Router/Gateway", "ip": "192.168.10.1" } + ], + "dhcp": { + "subnet": "192.168.10.0", + "subnet_mask": "255.255.255.0", + "dynamic_pool_start": "192.168.10.100", + "dynamic_pool_end": "192.168.10.245", + "lease_time": "24h", + "domain": "local", + "explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" } + }, + "reservations": [ + { "enabled": true, "description": "Network Printer", "hostname": "printer", "mac": "aa:bb:cc:dd:ee:10", "ip": "192.168.10.2" }, + { "enabled": true, "description": "Smart TV", "hostname": "smart-tv", "mac": "aa:bb:cc:dd:ee:11", "ip": "192.168.10.3" }, + { "enabled": true, "description": "Streaming Box (Eth)", "hostname": "streaming-box-eth", "mac": "aa:bb:cc:dd:ee:12", "ip": "192.168.10.4" }, + { "enabled": true, "description": "Streaming Box (Wifi)", "hostname": "streaming-box-wifi", "mac": "aa:bb:cc:dd:ee:13", "ip": "192.168.10.4" }, + { "enabled": true, "description": "Raspberry Pi", "hostname": "rpi", "mac": "aa:bb:cc:dd:ee:14", "ip": "192.168.10.12" }, + { "enabled": true, "description": "NAS", "hostname": "nas", "mac": "aa:bb:cc:dd:ee:15", "ip": "192.168.10.14" }, + { "enabled": true, "description": "Doorbell Camera", "hostname": "doorbell-camera", "mac": "aa:bb:cc:dd:ee:16", "ip": "dynamic" }, + { "enabled": true, "description": "Smart Speaker", "hostname": "smart-speaker", "mac": "aa:bb:cc:dd:ee:17", "ip": "dynamic" } + ], + "port_wrangling": [ + { "description": "DNS wrangling - redirect IoT DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.10.1" }, + { "description": "NTP wrangling - redirect IoT NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.10.1" } + ] + }, + + { + "vlan_id": 20, + "name": "guest", + "interface": "enp6s0.20", + "radius_default": true, + "use_blocklists": ["oisd-big", "hagezi-light"], + "server_identities": [ + { "description": "Router/Gateway", "ip": "192.168.20.1" } + ], + "dhcp": { + "subnet": "192.168.20.0", + "subnet_mask": "255.255.255.0", + "dynamic_pool_start": "192.168.20.100", + "dynamic_pool_end": "192.168.20.245", + "lease_time": "4h", + "domain": "local", + "explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" } + }, + "reservations": [ + { "enabled": true, "description": "Family Member Phone 1", "hostname": "phone-1", "mac": "aa:bb:cc:dd:ee:20", "ip": "dynamic" }, + { "enabled": true, "description": "Family Member Phone 2", "hostname": "phone-2", "mac": "aa:bb:cc:dd:ee:21", "ip": "dynamic" } + ], + "port_wrangling": [ + { "description": "DNS wrangling - redirect Guest DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.20.1" }, + { "description": "NTP wrangling - redirect Guest NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.20.1" } + ] + }, + + { + "vlan_id": 30, + "name": "kids", + "interface": "enp6s0.30", + "radius_default": false, + "use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"], + "server_identities": [ + { "description": "Router/Gateway", "ip": "192.168.30.1" } + ], + "dhcp": { + "subnet": "192.168.30.0", + "subnet_mask": "255.255.255.0", + "dynamic_pool_start": "192.168.30.100", + "dynamic_pool_end": "192.168.30.245", + "lease_time": "24h", + "domain": "local", + "explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" } + }, + "reservations": [ + { "enabled": true, "description": "Child 1 Laptop", "hostname": "child1-laptop", "mac": "aa:bb:cc:dd:ee:30", "ip": "dynamic" }, + { "enabled": true, "description": "Child 2 Laptop", "hostname": "child2-laptop", "mac": "aa:bb:cc:dd:ee:31", "ip": "dynamic" }, + { "enabled": true, "description": "Child 3 Laptop", "hostname": "child3-laptop", "mac": "aa:bb:cc:dd:ee:32", "ip": "dynamic" }, + { "enabled": true, "description": "Child Tablet", "hostname": "child-tablet", "mac": "aa:bb:cc:dd:ee:33", "ip": "dynamic" } + ], + "port_wrangling": [ + { "description": "DNS wrangling - redirect Kids DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.30.1" }, + { "description": "NTP wrangling - redirect Kids NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.30.1" } + ] + }, + + { + "vlan_id": 40, + "name": "vpn", + "interface": "wg0", + "radius_default": false, + "use_blocklists": ["oisd-big", "hagezi-light"], + "vpn_information": { + "listen_port": 51820, + "gateway": "192.168.40.1", + "domain": "local", + "explicit_overrides": { "dns_server": "", "mtu": "" } + }, + "reservations": [], + "port_wrangling": [ + { "description": "DNS wrangling - redirect VPN DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.40.1" }, + { "description": "NTP wrangling - redirect VPN NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.40.1" } + ] + } + + ], + + "mdns_reflection": { + "enabled": true, + "reflect_vlans": ["iot", "guest", "kids"] + } + +} diff --git a/core.py b/core.py new file mode 100644 index 0000000..784f274 --- /dev/null +++ b/core.py @@ -0,0 +1,3477 @@ +#!/usr/bin/env python3 +""" +core.py -- Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables. + +Each VLAN defined in core.json gets its own dnsmasq instance that handles +both DHCP and DNS for that VLAN. WireGuard VLANs get a DNS-only instance +(no DHCP, since WireGuard peers get IPs from vpn.py). + +Each instance binds exclusively to its VLAN gateway IP on port 53, so +instances do not conflict with each other or with the system dnsmasq.service, +which is stopped and disabled on --apply. + +Blocklists are downloaded, parsed into unique domain sets, and merged per +unique blocklist combination (identified by a stable SHA256 hash). Each +VLAN's dnsmasq instance loads the merged file for its specific combination, +giving true per-VLAN DNS filtering. Blocked domains and all their subdomains +return NXDOMAIN via dnsmasq's local=/ syntax. + +nftables rules are applied atomically into dedicated tables (router-nat, +router-filter) that do not touch Docker-managed tables. A systemd boot +service (core-nat.service) re-applies the rules on every boot. + +File layout: + blocklists/ + -- raw downloaded blocklist files + merged-.conf -- merged file per unique blocklist combo + + /etc/dnsmasq-router/ + .conf -- per-VLAN dnsmasq config + + /etc/systemd/system/ + dnsmasq-router-.service -- per-VLAN dnsmasq service unit + dns-blocklists-update.timer -- daily blocklist refresh timer + dns-blocklists-update.service -- timer service unit + core-nat.service -- boot service to re-apply nftables rules + + /var/lib/misc/ + dnsmasq-router-.leases -- per-VLAN DHCP lease files + + .dns-metrics -- cumulative lifetime DNS metrics + +Validation: + gateway -- Must exactly match one of the server_identities IPs. + dns_server -- Must be a valid IPv4 within the VLAN subnet. + ntp_server -- Must be a valid IPv4 within the VLAN subnet if specified. + pool range -- dynamic_pool_start must be <= dynamic_pool_end. Both must + fall within the VLAN subnet. + identities -- All server_identity IPs must fall within the VLAN subnet + and must not fall inside the dynamic pool range. + reservations -- All reservation IPs must fall within the VLAN subnet, must + not fall inside the dynamic pool range, must not duplicate + another reservation IP or MAC within the same VLAN, and + must not conflict with any server_identity IP. + vlan_id -- Must be unique across all VLAN blocks. + name -- Must be unique across all VLAN blocks. + interface -- Must be unique across all VLAN blocks. + blocklists -- Each entry must have: name, description, save_as, url, + format. Names must be unique. Format must be 'dnsmasq' or + 'hosts'. + use_blocklists -- Each name must exist in the blocklists library. An empty + list is allowed (VLAN receives unfiltered DNS). + wan_interface -- Must exist on the system. + port_forwarding -- top-level array. nat_ip must be a valid IPv4. dest_port and + nat_port must be valid (1-65535). Protocol must be + tcp, udp, or both. + port_wrangling -- redirect_to must be within the VLAN subnet. dest_port + must be valid. Protocol must be tcp, udp, or both. + Generates DNAT rules only; no forward chain rules needed + since redirect_to is always a local IP (INPUT handles it). + inter_vlan_exceptions -- src_ip_or_subnet and dst_ip_or_subnet must be valid IPv4 addresses declared in the vlans array. + inter_vlan_exceptions -- src_ip_or_subnet and dst_ip_or_subnet must be valid IPv4 addresses + or networks. dst_port must be valid (1-65535). Protocol + must be tcp, udp, or both. + +Usage: + sudo python3 core.py --install Check and interactively install required packages + sudo python3 core.py --apply Apply config fast: restart running services only + sudo python3 core.py --update-blocklists Refresh blocklists and apply (used by timer) + sudo python3 core.py --status Show service and timer status + sudo python3 core.py --view-configs Show active per-VLAN dnsmasq config files + sudo python3 core.py --view-leases Show active DHCP leases + sudo python3 core.py --view-rules Show active nftables ruleset + sudo python3 core.py --disable Run the interactive disable wizard to stop instances, remove nftables, remove all generated config files + sudo python3 core.py --apply [--dry-run] Preview --apply without making changes + sudo python3 core.py --disable [--dry-run] Preview --disable without making changes + python3 core.py --view-metrics Show lifetime DNS metrics across all instances +""" + +import hashlib +import ipaddress +import json +import logging +import os +import re +import subprocess +import sys +import time +import urllib.request +import urllib.error +import argparse +from datetime import datetime +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +CONFIG_FILE = SCRIPT_DIR / "core.json" +BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" +LOG_FILE = SCRIPT_DIR / "core.log" +METRICS_FILE = SCRIPT_DIR / ".dns-metrics" +DNSMASQ_CONF_DIR = Path("/etc/dnsmasq-router") +LEASES_DIR = Path("/var/lib/misc") +NETWORKD_DIR = Path("/etc/systemd/network") +SYSTEMD_DIR = Path("/etc/systemd/system") +TIMER_NAME = "dns-blocklists-update" +TIMER_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.timer" +TIMER_SVC_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.service" +RESOLV_CONF = Path("/etc/resolv.conf") +NAT_SERVICE_NAME = "core-nat" +NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service" + +log = None + +# ------------------------------------------------------------------------------ +# Logging +# ------------------------------------------------------------------------------ + +def chown_to_script_dir_owner(path): + """Chown a file to the owner of the script directory. + This works correctly whether invoked via sudo, directly as root (e.g. systemd timer), + or as a normal user — the script directory owner is always the right target. + """ + try: + stat = SCRIPT_DIR.stat() + os.chown(path, stat.st_uid, stat.st_gid) + except OSError: + pass # non-fatal + +def setup_logging(max_kb, errors_only): + global log + try: + if LOG_FILE.exists() and LOG_FILE.stat().st_size > max_kb * 1024: + LOG_FILE.write_text("") + if not LOG_FILE.exists(): + LOG_FILE.touch() + chown_to_script_dir_owner(LOG_FILE) + file_handler = logging.FileHandler(LOG_FILE) + except PermissionError: + print(f"WARNING: Cannot write to {LOG_FILE} (permission denied). " + f"Run with sudo or fix ownership: sudo chown $USER {LOG_FILE}") + file_handler = None + level = logging.ERROR if errors_only else logging.INFO + handlers = [logging.StreamHandler(sys.stdout)] + if file_handler: + handlers.insert(0, file_handler) + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=handlers, + ) + log = logging.getLogger("dns-dhcp") + +# ------------------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------------------ + +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 ./core.py --install") + + +def die(msg): + print(f"ERROR: {msg}") + sys.exit(1) + +def check_root(): + if os.geteuid() != 0: + die("This script must be run as root (sudo).") + +def network_for(vlan): + d = vlan["dhcp"] + return ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False) + +def lowest_quartet_ip(vlan): + """Return the server_identity IP with the lowest value in the last octet. + Only called for non-WG VLANs which have a server_identities list.""" + 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 WG VLANs: gateway comes directly from vpn_information.gateway. + dns_server defaults to gateway unless explicit_overrides.dns_server + is set. ntp_server is None -- WireGuard has no DHCP so NTP cannot + be advertised to peers. + + For non-WG VLANs: all three default to the lowest-last-octet + server_identity IP unless overridden in dhcp.explicit_overrides. + Returns a dict with keys: gateway, dns_server, ntp_server. + """ + if is_wg(vlan): + vpi = vlan["vpn_information"] + gateway = vpi["gateway"] + overrides = vpi.get("explicit_overrides", {}) + dns = overrides.get("dns_server", "") or gateway + return { + "gateway": gateway, + "dns_server": dns, + "ntp_server": None, + } + overrides = vlan.get("dhcp", {}).get("explicit_overrides", {}) + default = lowest_quartet_ip(vlan) + return { + "gateway": overrides.get("gateway", "") or default, + "dns_server": overrides.get("dns_server", "") or default, + "ntp_server": overrides.get("ntp_server", "") or default, + } + +def is_physical(vlan): + return vlan["vlan_id"] == 1 + +def is_wg(vlan): + return vlan.get("interface", "").startswith("wg") + +def networkd_stem(vlan): + return f"10-router-{vlan['name']}" + +def vlan_service_name(vlan): + if is_wg(vlan): + return f"dnsmasq-router-{vlan['name']}-{vlan['interface']}" + return f"dnsmasq-router-{vlan['name']}" + +def vlan_service_file(vlan): + return SYSTEMD_DIR / f"{vlan_service_name(vlan)}.service" + +def vlan_conf_file(vlan): + return DNSMASQ_CONF_DIR / f"{vlan['name']}.conf" + +def vlan_leases_file(vlan): + return LEASES_DIR / f"dnsmasq-router-{vlan['name']}.leases" + +def vlan_pid_file(vlan): + return Path("/run") / f"dnsmasq-router-{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 is_dynamic_ip(r): + """Return True if a reservation has no pinned IP -- DHCP assigns from pool. + Triggered by: ip field absent, empty string, or the keyword 'dynamic'. + """ + ip = r.get("ip", "dynamic") + return ip in ("", "dynamic") or ip is None + +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 +# ------------------------------------------------------------------------------ + +def load_config(): + if not CONFIG_FILE.exists(): + die(f"Config file not found: {CONFIG_FILE}") + with open(CONFIG_FILE) as f: + data = json.load(f) + if not data.get("vlans"): + die("No vlans defined in core.json.") + return data + +# ------------------------------------------------------------------------------ +# Validate +# ------------------------------------------------------------------------------ + +def validate_config(data): + errors = [] + seen_vlan_ids = {} + seen_interfaces = {} + seen_names = {} + seen_listen_ports = {} + + # -- upstream_dns block ---------------------------------------------------- + if not data.get("upstream_dns", {}).get("upstream_servers"): + errors.append("upstream_dns.upstream_servers is missing or empty.") + + # -- WAN interface --------------------------------------------------------- + wan = data.get("general", {}).get("wan_interface", "") + if not wan: + errors.append("general.wan_interface is missing or empty.") + else: + available_interfaces = set() + try: + result = subprocess.run(["ip", "link", "show"], capture_output=True, text=True) + available_interfaces = set(re.findall(r"^\d+:\s+(\S+):", result.stdout, re.MULTILINE)) + available_interfaces = {i.split("@")[0] for i in available_interfaces} + except Exception: + pass + if available_interfaces and wan not in available_interfaces: + errors.append(f"general.wan_interface: '{wan}' does not exist on this system.") + + # -- Blocklist library ----------------------------------------------------- + blocklists_by_name = {} + for idx, bl in enumerate(data.get("blocklists", [])): + name = bl.get("name", "") + label = f"blocklists[{idx}] '{name}'" + for field in ("name", "description", "save_as", "url", "format"): + if not bl.get(field): + errors.append(f"{label}: missing or empty field '{field}'.") + if bl.get("format") and bl["format"] not in ("dnsmasq", "hosts"): + errors.append(f"{label}: format must be 'dnsmasq' or 'hosts'.") + if name: + if name in blocklists_by_name: + errors.append(f"{label}: duplicate blocklist name '{name}'.") + else: + blocklists_by_name[name] = bl + + # -- Per-VLAN validation --------------------------------------------------- + vlan_networks = {} # interface -> IPv4Network (built for nat validation) + + for vlan in data["vlans"]: + vlan_id = vlan.get("vlan_id") + name = vlan.get("name", "?") + iface = vlan.get("interface", "") + label = f"vlan '{name}' (id={vlan_id})" + + if name in seen_names: + errors.append(f"{label}: duplicate vlan name '{name}' " + f"(also used by id={seen_names[name]}).") + else: + seen_names[name] = vlan_id + + if vlan_id in seen_vlan_ids: + errors.append(f"{label}: duplicate vlan_id {vlan_id} " + f"(also used by '{seen_vlan_ids[vlan_id]}').") + else: + seen_vlan_ids[vlan_id] = name + + if iface in seen_interfaces: + errors.append(f"{label}: duplicate interface '{iface}' " + f"(also used by '{seen_interfaces[iface]}').") + else: + seen_interfaces[iface] = name + + if is_wg(vlan): + vpi = vlan.get("vpn_information") + if not isinstance(vpi, dict): + errors.append(f"{label}: vpn_information must be a plain object.") + else: + lp = vpi.get("listen_port") + if not isinstance(lp, int) or not (1 <= lp <= 65535): + errors.append(f"{label}: vpn_information.listen_port must be an integer 1-65535.") + elif lp in seen_listen_ports: + errors.append(f"{label}: vpn_information.listen_port {lp} is already used by " + f"'{seen_listen_ports[lp]}'.") + else: + seen_listen_ports[lp] = name + gw = vpi.get("gateway", "") + if not gw: + errors.append(f"{label}: vpn_information.gateway is required.") + else: + try: + ipaddress.IPv4Address(gw) + except ValueError: + errors.append(f"{label}: vpn_information.gateway '{gw}' is not a valid IPv4 address.") + eo = vpi.get("explicit_overrides", {}) + if not isinstance(eo, dict): + errors.append(f"{label}: vpn_information.explicit_overrides must be a plain object.") + else: + dns = eo.get("dns_server", "") + if dns: + try: + ipaddress.IPv4Address(dns) + except ValueError: + errors.append(f"{label}: vpn_information.explicit_overrides.dns_server '{dns}' is not a valid IPv4 address.") + mtu = eo.get("mtu", "") + if mtu: + try: + m = int(mtu) + if not (576 <= m <= 9000): + errors.append(f"{label}: vpn_information.explicit_overrides.mtu {mtu} is out of valid range (576-9000).") + except (ValueError, TypeError): + errors.append(f"{label}: vpn_information.explicit_overrides.mtu '{mtu}' is not a valid integer.") + # WG VLANs have no server_identities or dhcp block -- skip remaining validation + continue + + if not vlan.get("server_identities"): + errors.append(f"{label}: server_identities is empty or missing.") + continue + + d = vlan.get("dhcp", {}) + required_dhcp = {"subnet", "subnet_mask", "dynamic_pool_start", + "dynamic_pool_end", "lease_time"} + missing = required_dhcp - set(d.keys()) + if missing: + errors.append(f"{label}: missing dhcp fields: {missing}") + continue + + if not is_wg(vlan): + try: + network = ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False) + vlan_networks[iface] = network + except ValueError as e: + errors.append(f"{label}: invalid subnet/subnet_mask: {e}") + continue + + def check_ip(field_label, ip_str, allow_none=False): + if ip_str is None: + if not allow_none: + errors.append(f"{label}: {field_label} is null/missing.") + return None + try: + ip = ipaddress.IPv4Address(ip_str) + except ValueError: + errors.append(f"{label}: {field_label} '{ip_str}' is not a valid IPv4 address.") + return None + if ip not in network: + errors.append(f"{label}: {field_label} '{ip_str}' is not within subnet {network}.") + return ip + + identity_ips = [] + for idx, ident in enumerate(vlan["server_identities"]): + ip = check_ip( + f"server_identities[{idx}] '{ident.get('description', '?')}'", + ident.get("ip") + ) + if ip: + identity_ips.append(ip) + + # -- Validate explicit_overrides ----------------------------------- + eo = d.get("explicit_overrides", {}) + if not isinstance(eo, dict): + errors.append(f"{label}: explicit_overrides must be a plain object.") + else: + gw = eo.get("gateway", "") + if gw: + gw_ip = check_ip("explicit_overrides.gateway", gw) + if gw_ip and gw_ip not in identity_ips: + errors.append( + f"{label}: explicit_overrides.gateway '{gw}' does not match " + f"any server_identity IP. Must be one of: " + f"{[str(ip) for ip in identity_ips]}." + ) + dns = eo.get("dns_server", "") + if dns: + check_ip("explicit_overrides.dns_server", dns) + ntp = eo.get("ntp_server", "") + if ntp: + check_ip("explicit_overrides.ntp_server", ntp) + + pool_start = check_ip("dynamic_pool_start", d["dynamic_pool_start"]) + pool_end = check_ip("dynamic_pool_end", d["dynamic_pool_end"]) + + if pool_start and pool_end and pool_start > pool_end: + errors.append( + f"{label}: dynamic_pool_start '{pool_start}' is greater than " + f"dynamic_pool_end '{pool_end}'." + ) + + if pool_start and pool_end: + for ip in identity_ips: + if pool_start <= ip <= pool_end: + errors.append( + f"{label}: server_identity '{ip}' falls inside the dynamic " + f"pool ({pool_start} - {pool_end})." + ) + + seen_res_ips = {} + seen_res_macs = {} + for r in vlan.get("reservations", []): + rdesc = r.get("description", "?") + rmac = r.get("mac", "").lower().strip() + + if is_dynamic_ip(r): + rip = None # no pinned IP -- skip all IP validation + else: + rip = check_ip(f"reservation '{rdesc}' ip", r.get("ip")) + + if rip: + if pool_start and pool_end and pool_start <= rip <= pool_end: + errors.append( + f"{label}: reservation '{rdesc}' ip '{rip}' falls inside " + f"the dynamic pool ({pool_start} - {pool_end})." + ) + rip_str = str(rip) + if rip_str in seen_res_ips: + # Allow same IP for different MACs (multi-interface device) + # Only flag if same MAC is also duplicated (caught below) + if rmac and rmac in seen_res_ips[rip_str]: + errors.append( + f"{label}: reservation '{rdesc}' ip '{rip}' and MAC '{rmac}' " + f"duplicates '{seen_res_ips[rip_str][rmac]}'." + ) + else: + seen_res_ips[rip_str][rmac] = rdesc + else: + seen_res_ips[rip_str] = {rmac: rdesc} + if rip in identity_ips: + errors.append( + f"{label}: reservation '{rdesc}' ip '{rip}' conflicts " + f"with a server_identity." + ) + + if rmac: + if rmac in seen_res_macs: + errors.append( + f"{label}: reservation '{rdesc}' MAC '{rmac}' duplicates " + f"'{seen_res_macs[rmac]}'." + ) + else: + seen_res_macs[rmac] = rdesc + + for bl_name in vlan.get("use_blocklists", []): + if bl_name not in blocklists_by_name: + errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.") + + # -- NAT / firewall validation --------------------------------------------- + valid_protos = {"tcp", "udp", "both"} + known_interfaces = set(seen_interfaces.keys()) + + def nat_check_port(label, port): + try: + p = int(port) + if not (1 <= p <= 65535): + errors.append(f"{label}: port {port} is out of valid range (1-65535).") + except (TypeError, ValueError): + errors.append(f"{label}: '{port}' is not a valid port number.") + + def nat_check_ip(label, ip_str): + try: + return ipaddress.IPv4Address(ip_str) + except ValueError: + errors.append(f"{label}: '{ip_str}' is not a valid IPv4 address.") + return None + + def nat_check_ip_in_network(label, ip_str, network): + ip = nat_check_ip(label, ip_str) + if ip and ip not in network: + errors.append(f"{label}: '{ip_str}' is not within subnet {network}.") + + for vlan in data["vlans"]: + name = vlan.get("name", "?") + iface = vlan.get("interface", "") + net = vlan_networks.get(iface) + + for r in vlan.get("port_wrangling", []): + desc = r.get("description", "?") + label = f"vlan '{name}' port_wrangling '{desc}'" + if r.get("protocol") not in valid_protos: + errors.append(f"{label}: invalid protocol '{r.get('protocol')}'. " + f"Must be tcp, udp, or both.") + nat_check_port(f"{label} dest_port", r.get("dest_port")) + if net: + nat_check_ip_in_network(f"{label} redirect_to", r.get("redirect_to", ""), net) + + # -- port_forwarding validation (top-level) -------------------------------- + for idx, r in enumerate(data.get("port_forwarding", [])): + desc = r.get("description", "?") + label = f"port_forwarding[{idx}] '{desc}'" + if r.get("protocol") not in valid_protos: + errors.append(f"{label}: invalid protocol '{r.get('protocol')}'. " + f"Must be tcp, udp, or both.") + nat_check_port(f"{label} dest_port", r.get("dest_port")) + nat_check_port(f"{label} nat_port", r.get("nat_port")) + nat_check_ip(f"{label} nat_ip", r.get("nat_ip", "")) + + for r in data.get("inter_vlan_exceptions", []): + desc = r.get("description", "?") + label = f"inter_vlan_exceptions '{desc}'" + if r.get("protocol") not in valid_protos: + errors.append(f"{label}: invalid protocol '{r.get('protocol')}'. " + f"Must be tcp, udp, or both.") + if "src_ip_or_subnet" not in r: + errors.append(f"{label}: missing field 'src_ip_or_subnet'.") + else: + val = r["src_ip_or_subnet"] + try: + ipaddress.IPv4Address(val) + except ValueError: + try: + ipaddress.IPv4Network(val, strict=False) + except ValueError: + errors.append(f"{label}: src_ip_or_subnet '{val}' is not a valid " + f"IPv4 address or network.") + # Support both dst_ip (legacy, single IP) and dst_ip_or_subnet (IP or subnet) + dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") + if not dst: + errors.append(f"{label}: missing field 'dst_ip_or_subnet'.") + else: + try: + ipaddress.IPv4Address(dst) + except ValueError: + try: + ipaddress.IPv4Network(dst, strict=False) + except ValueError: + errors.append(f"{label}: dst_ip_or_subnet '{dst}' is not a valid " + f"IPv4 address or network.") + if r.get("dst_port") is not None: + nat_check_port(f"{label} dst_port", r.get("dst_port")) + + # -- mdns_reflection validation -------------------------------------------- + mdns = data.get("mdns_reflection", {}) + if mdns.get("enabled") is True: + known_vlan_names = {v["name"] for v in data["vlans"]} + reflect_vlans = mdns.get("reflect_vlans", []) + for vname in reflect_vlans: + if vname not in known_vlan_names: + errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is not a known VLAN name.") + else: + vlan = next(v for v in data["vlans"] if v["name"] == vname) + if is_wg(vlan): + errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is a WireGuard VLAN " + f"and cannot participate in mDNS reflection.") + if not reflect_vlans: + errors.append("mdns_reflection.reflect_vlans is empty. " + "Add at least two VLAN names or set enabled: false.") + elif len(reflect_vlans) < 2: + errors.append("mdns_reflection.reflect_vlans must contain at least two VLANs — " + "reflecting mDNS on a single VLAN has no effect.") + + # -- banned_ips validation ------------------------------------------------- + for idx, entry in enumerate(data.get("banned_ips", [])): + ip = entry.get("ip", "") + lbl = f"banned_ips[{idx}] '{entry.get('description', '')}'" + if not ip: + errors.append(f"{lbl}: missing 'ip' field.") + continue + try: + expand_banned_ip(ip) + except ValueError as e: + errors.append(f"{lbl}: {e}") + + if errors: + print("Validation failed:") + for e in errors: + print(f" - {e}") + sys.exit(1) + +# ------------------------------------------------------------------------------ +# Build systemd-networkd files +# ------------------------------------------------------------------------------ + +def build_netdev(vlan): + return "\n".join([ + "# Generated by core.py -- do not edit manually.", + "# Edit core.json and re-run: sudo python3 core.py --apply", + "", + "[NetDev]", + f"Name={vlan['interface']}", + "Kind=vlan", + "", + "[VLAN]", + f"Id={vlan['vlan_id']}", + "", + ]) + +def build_network(vlan, all_vlan_ids): + network = network_for(vlan) + prefix = network.prefixlen + lines = [ + "# Generated by core.py -- do not edit manually.", + "# Edit core.json and re-run: sudo python3 core.py --apply", + "", + "[Match]", + f"Name={vlan['interface']}", + "", + "[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 vid in all_vlan_ids: + if vid != 1: + lines.append(f"VLAN={vlan['interface']}.{vid}") + + 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("10-router-"): + 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["vlan_id"] for v in data["vlans"] if not is_wg(v)] + managed_ifaces = [v["interface"] 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 + stem = networkd_stem(vlan) + + if not is_physical(vlan): + netdev_path = NETWORKD_DIR / f"{stem}.netdev" + netdev_content = build_netdev(vlan) + 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, 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) + +def parse_dnsmasq_format(content): + domains = set() + for ln in content.splitlines(): + ln = ln.strip() + if not ln or ln.startswith("#"): + continue + if ln.startswith("local=/"): + domain = ln.removeprefix("local=/").rstrip("/") + if domain: + domains.add(domain) + elif ln.startswith("address=/"): + parts = ln.removeprefix("address=/").split("/") + if parts: + domains.add(parts[0]) + return domains + +def parse_hosts_format(content): + domains = set() + for ln in content.splitlines(): + ln = ln.strip() + if not ln or ln.startswith("#"): + continue + parts = ln.split() + if len(parts) >= 2: + domains.add(parts[1]) + return domains + +def parse_blocklist(content, fmt): + if fmt == "dnsmasq": + return parse_dnsmasq_format(content) + return parse_hosts_format(content) + +def build_merged_conf(domains, bl_names): + """Build a merged dnsmasq conf blocking all domains and their subdomains.""" + lines = [ + "# Generated by core.py -- do not edit manually.", + f"# Blocklist combination: {', '.join(sorted(bl_names))}", + f"# Merged: {len(domains):,} unique domains.", + "#", + "# Blocks domain and all subdomains via local=/domain/ syntax.", + "", + ] + for domain in sorted(domains): + lines.append(f"local=/{domain}/") + return "\n".join(lines) + +def download_all_blocklists(data): + """ + Download every blocklist referenced by at least one VLAN. + Returns dict: name -> (content_str, entry) or (None, entry) on failure. + """ + bl_library = {bl["name"]: bl for bl in data.get("blocklists", [])} + needed = set() + for vlan in data["vlans"]: + needed.update(vlan.get("use_blocklists", [])) + + results = {} + for name in needed: + entry = bl_library[name] + url = entry["url"] + try: + req = urllib.request.Request(url, headers={"User-Agent": "dns-dhcp.py/1.0"}) + with urllib.request.urlopen(req, timeout=30) as r: + content = r.read().decode("utf-8", errors="ignore") + log.info(f"Downloaded: {entry['description']} ({len(content):,} bytes)") + results[name] = (content, entry) + except Exception as e: + log.error(f"Failed to download '{entry['description']}' from {url}: {e}") + results[name] = (None, entry) + return results + +def update_blocklists(data): + """ + Download all referenced blocklists, build per-combo merged files, + and clean up stale merged files. Returns active hashes set. + """ + BLOCKLIST_DIR.mkdir(exist_ok=True) + + log.info("Downloading blocklists...") + downloaded = download_all_blocklists(data) + + # Parse domains per blocklist name; save raw files + domains_by_name = {} + for name, (content, entry) in downloaded.items(): + if content is None: + log.error(f"Blocklist '{name}' failed to download -- it will be skipped.") + domains_by_name[name] = set() + else: + (BLOCKLIST_DIR / entry["save_as"]).write_text(content) + domains = parse_blocklist(content, entry.get("format", "dnsmasq")) + log.info(f"Parsed {len(domains):,} domains from '{name}'") + domains_by_name[name] = domains + + # Build one merged file per unique combo + active_hashes = set() + combos = {} + for vlan in data["vlans"]: + names = frozenset(vlan.get("use_blocklists", [])) + if names: + h = combo_hash(names) + combos[h] = names + + for h, names in combos.items(): + combo_domains = set() + for name in names: + combo_domains.update(domains_by_name.get(name, set())) + + merged = build_merged_conf(combo_domains, names) + merged_path(h).write_text(merged) + active_hashes.add(h) + log.info( + f"Merged [{h}] ({', '.join(sorted(names))}): " + f"{len(combo_domains):,} unique domains." + ) + + # Remove stale merged files (hashes no longer in active combos) + for f in BLOCKLIST_DIR.glob("merged-*.conf"): + h = f.stem.removeprefix("merged-") + if h not in active_hashes: + f.unlink() + log.info(f"Removed stale merged file: {f.name}") + + # Return True if all blocklists downloaded successfully + any_failed = any(content is None for content, _ in downloaded.values()) + return not any_failed + +# ------------------------------------------------------------------------------ +# 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): + """Generate the complete dnsmasq config for one VLAN instance.""" + dns_cfg = data.get("upstream_dns", {}) + general = data.get("general", {}) + overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True] + name = vlan["name"] + iface = vlan["interface"] + d = vlan.get("dhcp", {}) + 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 core.json and re-run: sudo python3 core.py --apply") + line(f"# VLAN: {name} (vlan_id={vlan['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}") + line() + + if not is_wg(vlan): + line("# -- DHCP -----------------------------------------------------------") + line(f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{d['subnet_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_server']}") + line(f"dhcp-option=tag:{name},option:ntp-server,{opts['ntp_server']}") + 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() + + active_res = [r for r in vlan.get("reservations", []) if r.get("enabled") is True] + inactive_res = [r for r in vlan.get("reservations", []) 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] + line(f"# {r['description']}") + if is_dynamic_ip(r): + line(f"dhcp-host=set:{name},{r['mac']},{r['hostname']},{d['lease_time']}") + else: + line(f"dhcp-host=set:{name},{r['mac']},{r['ip']},{r['hostname']},{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'] + # Use first entry's hostname; all share the same IP anyway + hostname = group[0]['hostname'] + line(f"# {descs}") + line(f"dhcp-host=set:{name},{macs},{ip},{hostname},{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["general"]["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 general.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 --update-blocklists to fetch") + line() + + return "\n".join(L) + +# ------------------------------------------------------------------------------ +# Build per-VLAN systemd service unit +# ------------------------------------------------------------------------------ + +def build_vlan_service(vlan): + name = vlan["name"] + iface = vlan["interface"] + 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 check_dependencies(): + """Check required packages are installed; prompt to install missing ones via apt.""" + import shutil + checks = [ + ("dnsmasq", "dnsmasq", "DHCP server and DNS resolver"), + ("nft", "nftables", "firewall and NAT"), + ("chronyd", "chrony", "NTP server"), + ("freeradius", "freeradius", "RADIUS server for dynamic VLAN assignment"), + ("avahi-daemon", "avahi-daemon", "mDNS reflector for cross-VLAN service discovery"), + ] + missing = [(pkg, purpose) for binary, pkg, purpose in checks if not shutil.which(binary)] + if not missing: + return + + print("The following required packages are not installed:") + for pkg, purpose in missing: + print(f" {pkg:<16} {purpose}") + print() + while True: + choice = input("Install them now via apt? [y/N]: ").strip().lower() + if choice in ("y", "yes"): + break + if choice in ("n", "no", ""): + die("Cannot continue without required packages. Install them and retry.") + + result = subprocess.run(["apt-get", "install", "-y"] + [p for p, _ in missing]) + if result.returncode != 0: + die("Package installation failed. Install manually and retry.") + +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"]: + if is_wg(v): + # Derive subnet from gateway IP -- always a /24 + gw = v["vpn_information"]["gateway"] + net = ipaddress.IPv4Network(f"{gw}/24", strict=False) + subnets.append(str(net)) + else: + 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 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 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) 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"]: + if is_wg(vlan) and not dry_run and not wg_interface_up(vlan["interface"]): + print(f"Skipped VLAN '{vlan['name']}': {vlan['interface']} is not up (WireGuard not running).") + print(" To enable the VPN VLAN, start WireGuard with vpn.py --apply") + print(" (core.py --apply will be called again automatically).") + continue + + conf_content = build_vlan_dnsmasq_conf(vlan, data) + svc_content = build_vlan_service(vlan) + conf_path = vlan_conf_file(vlan) + svc_path = vlan_service_file(vlan) + + 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("dnsmasq-router-*.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("dnsmasq-router-") + 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"]: + if is_wg(vlan) and not wg_interface_up(vlan["interface"]): + continue + svc = vlan_service_name(vlan) + 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"]: + if is_wg(vlan) and not wg_interface_up(vlan["interface"]): + continue + svc = vlan_service_name(vlan) + 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("general", {}) + execute_time = general.get("daily_execute_time_24hr_local", "02:30") + on_calendar = parse_time_to_calendar(execute_time) + script_path = Path(__file__).resolve() + + 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", + "", + ]) + + service_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=core.py daily blocklist refresh", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=oneshot", + f"ExecStart=/usr/bin/python3 {script_path} --update-blocklists", + "", + ]) + + for path, content in ((TIMER_FILE, timer_content), (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"{TIMER_NAME}.timer"], + capture_output=True, text=True) + print(f"Timer {TIMER_NAME}.timer enabled (runs daily at {execute_time}).") + +def remove_timer(): + subprocess.run(["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"], + capture_output=True, text=True) + for f in (TIMER_FILE, TIMER_SVC_FILE): + if f.exists(): + f.unlink() + print(f"Removed: {f}") + else: + print(f"Not found, skipping: {f}") + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + +# ------------------------------------------------------------------------------ +# 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["general"]["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(v["interface"])] + all_fwd = list(rule_enabled(data.get("port_forwarding", []))) + all_wrngl = [(v, r) for v in vlans for r in rule_enabled(v.get("port_wrangling", []))] + # Interfaces that are active (WG interfaces only included if up) + active_ifaces = {v["interface"] for v in vlans} + + # Build interface -> network map for nat_ip -> iface lookup in forward chain + vlan_networks = {} + for v in vlans: + if not is_wg(v): + d = v.get("dhcp", {}) + try: + net = ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False) + vlan_networks[v["interface"]] = net + except (KeyError, ValueError): + pass + + all_except = rule_enabled(data.get("inter_vlan_exceptions", [])) + banned_v4, banned_v6 = banned_ip_sets(data) + + L = [] + def line(s=""): + L.append(s) + + line("# Generated by core.py -- do not edit manually.") + line("# Edit core.json and re-run: sudo python3 core.py --apply") + line() + + # ========================================================================== + # router-nat table + # ========================================================================== + + line("table ip router-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 = vlan["interface"] + 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() + + # ========================================================================== + # router-filter table + # ========================================================================== + + line("table ip router-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() + + line(" # Allow all traffic inbound from any VLAN interface") + for vlan in vlans: + line(f" iif \"{vlan['interface']}\" 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 \"{vlan['interface']}\" oif \"{wan}\" accept # {vlan['name']} -> WAN") + line() + + container_bridges = get_container_bridges() + if container_bridges: + line(" # Allow VLAN -> Docker bridge forwarding") + for vlan in vlans: + for bridge in container_bridges: + line(f" iif \"{vlan['interface']}\" 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", "") + port = r.get("dst_port") + for proto, _, suffix in expand_protocols(r): + line(f" # {r['description']}{suffix}") + if port is not None: + line(f" ip saddr {src} ip daddr {dst} {proto} dport {port} ct state new accept") + else: + line(f" ip saddr {src} ip daddr {dst} 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("table ip6 router-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", "router-nat"), ("ip", "router-filter"), ("ip6", "router-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:") + print(result.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 = {v["interface"] for v in data["vlans"] + if not is_wg(v) or wg_interface_up(v["interface"])} + active_vlans = [v for v in data["vlans"] if v["interface"] in active_ifaces] + + all_fwd = list(rule_enabled(data.get("port_forwarding", []))) + all_dis_fwd = list(rule_disabled(data.get("port_forwarding", []))) + all_wrngl = [(v, r) for v in active_vlans for r in rule_enabled(v.get("port_wrangling", []))] + all_dis_wrngl = [(v, r) for v in data["vlans"] for r in rule_disabled(v.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 + import ipaddress as _ipaddress + active_subnets = [] + for v in data["vlans"]: + if is_wg(v): + if wg_interface_up(v["interface"]): + gw = v["vpn_information"]["gateway"] + active_subnets.append(_ipaddress.IPv4Network(f"{gw}/24", strict=False)) + else: + d = v["dhcp"] + active_subnets.append(_ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False)) + + 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", "") + port = r.get("dst_port") + dst_str = f"{dst}:{port}" if port is not None else dst + print(f" [{r['protocol'].upper():<4}] {src} -> {dst_str} ({r['description']})") + +def show_rules(): + for table in ("router-nat", "router-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 router 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") + +def radius_clients(data): + """Return list of (reservation, vlan) tuples where radius_client is True.""" + result = [] + for vlan in data["vlans"]: + for r in vlan.get("reservations", []): + if r.get("radius_client") is True: + result.append((r, vlan)) + return result + +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(0o600) + print(f"Generated RADIUS shared secret: {RADIUS_SECRET_FILE}") + 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 core.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 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. + MACs are formatted without colons (FreeRADIUS MAB format). + """ + 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.") + + lines = [ + "# Generated by core.py -- do not edit manually.", + "# Edit core.json and re-run: sudo python3 core.py --apply", + "", + ] + + for vlan in data["vlans"]: + vlan_id = vlan["vlan_id"] + for r in vlan.get("reservations", []): + if r.get("enabled") is not True: + continue + mac = r.get("mac", "").replace(":", "").lower() + if not mac: + continue + 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["vlan_id"] + lines += [ + f"# Default -- unknown MACs land on VLAN {default_id} ({default_vlan['name']})", + "DEFAULT Auth-Type := Accept", + f" Tunnel-Type = VLAN,", + f" Tunnel-Medium-Type = IEEE-802,", + f" Tunnel-Private-Group-Id = \"{default_id}\"", + "", + ] + + return "\n".join(lines) + +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) + + changed = False + 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 mdns_reflection is enabled with at least two VLANs configured.""" + mdns = data.get("mdns_reflection", {}) + return mdns.get("enabled") is True + +def avahi_interfaces(data): + """Return list of interface names for mDNS reflection based on reflect_vlans.""" + reflect = data.get("mdns_reflection", {}).get("reflect_vlans", []) + ifaces = [] + for vlan in data["vlans"]: + if vlan["name"] in reflect and not is_wg(vlan): + ifaces.append(vlan["interface"]) + return ifaces + +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 ./core.py --install") + 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 ./core.py --install") + 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 show_status(data): + import shutil + col = shutil.get_terminal_size((80, 24)).columns + + def svc_row(unit, expected_active="active"): + r_active = subprocess.run(["systemctl", "is-active", unit], capture_output=True, text=True) + r_enabled = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True) + active = r_active.stdout.strip() + enabled = r_enabled.stdout.strip() + active_sym = "✓" if active == "active" else "✗" + enabled_sym = "✓" if enabled == "enabled" else "✗" + active_ok = "(OK) " if active == expected_active else "(BAD)" + enabled_ok = "(OK) " if enabled == "enabled" else "(BAD)" + return active_sym, active, active_ok, enabled_sym, enabled, enabled_ok + + units = [] + for vlan in data["vlans"]: + if is_wg(vlan) and not wg_interface_up(vlan["interface"]): + units.append((vlan_service_name(vlan), "(wg0 not up)", "active")) + else: + units.append((vlan_service_name(vlan), None, "active")) + units.append((f"{TIMER_NAME}.timer", None, "active")) + units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot — exits after running + units.append(("freeradius", None, "active")) + units.append(("avahi-daemon", None, "active")) + + print(f" {'UNIT':<45} {'ACTIVE':<18} {'ENABLED'}") + print(f" {'-'*45} {'-'*18} {'-'*15}") + for unit, note, expected_active in units: + if note: + print(f" {unit:<45} {note}") + else: + active_sym, active, active_ok, enabled_sym, enabled, enabled_ok = svc_row(unit, expected_active) + print(f" {unit:<45} {active_sym} {active:<10} {active_ok} {enabled_sym} {enabled:<10} {enabled_ok}") + + # Timer next trigger + r = subprocess.run( + ["systemctl", "show", f"{TIMER_NAME}.timer", "--property=NextElapseUSecRealtime,NextElapseUSecMonotonic"], + capture_output=True, text=True + ) + # Fall back to human-readable 'Trigger' field from status output + r2 = subprocess.run( + ["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"], + capture_output=True, text=True + ) + for line in r2.stdout.splitlines(): + line = line.strip() + if line.startswith("Trigger:"): + trigger = line.split("Trigger:", 1)[1].strip() + if trigger and trigger != "n/a": + print(f"\n Next blocklist update: {trigger}") + break + +def show_configs(data): + for vlan in data["vlans"]: + cf = 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) + 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) + 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 + res_by_mac = {} + for vlan in data["vlans"]: + for r in vlan.get("reservations", []): + if r.get("enabled") is True: + mac = r.get("mac", "").lower().strip() + if mac: + res_by_mac[mac] = (r, 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) + 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) + 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 timer and stop all per-VLAN instances (config files preserved).""" + remove_timer() + print() + for vlan in data["vlans"]: + svc = vlan_service_name(vlan) + subprocess.run(["systemctl", "disable", "--now", svc], + capture_output=True, text=True) + print(f"Stopped and disabled: {svc}") + +def disable_all(data): + """Full teardown: stop dnsmasq instances, remove nftables, remove all generated config files.""" + stop_instances(data) + print() + for vlan in data["vlans"]: + for f in (vlan_conf_file(vlan), vlan_service_file(vlan)): + if f.exists(): + f.unlink() + print(f"Removed: {f}") + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + print("systemd daemon reloaded.") + print() + print("-- Removing nftables rules -------------------------------------------") + delete_our_tables() + remove_nat_service() + if 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): + print() + print("-- Stopping mDNS Reflector -------------------------------------------") + 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 ("10-router-*.network", "10-router-*.netdev"): + for f in NETWORKD_DIR.glob(pattern): + f.unlink() + print(f"Removed: {f}") + + lines = [ + "# Generated by core.py --disable -- do not edit manually.", + "", + "[Match]", + f"Name={iface}", + "", + "[Network]", + ] + if dhcp: + lines.append("DHCP=yes") + else: + lines.append("DHCP=no") + lines.append(f"Address={static_cidr}") + lines.append("") + + path = NETWORKD_DIR / f"10-client-{iface}.network" + path.write_text("\n".join(lines)) + print(f"Written: {path}") + + 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.") + +def _configure_dns_resolved(): + """Re-enable systemd-resolved and restore the resolv.conf symlink.""" + result = subprocess.run( + ["systemctl", "enable", "--now", "systemd-resolved"], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f"ERROR: Failed to enable systemd-resolved:\n{result.stderr.strip()}") + return False + + RESOLV_CONF.unlink(missing_ok=True) + RESOLV_CONF.symlink_to("/run/systemd/resolve/stub-resolv.conf") + print("systemd-resolved enabled. /etc/resolv.conf restored as symlink to stub resolver.") + return True + +def _configure_dns_static(nameserver): + """Write a plain /etc/resolv.conf with a single user-specified nameserver.""" + RESOLV_CONF.unlink(missing_ok=True) + RESOLV_CONF.write_text(f"nameserver {nameserver}\n") + print(f"Updated /etc/resolv.conf: nameserver {nameserver}") + +def _suggest_static_ip(physical_vlan): + """ + Suggest a client static IP from the physical VLAN's subnet. + + Prefers server_identity IPs whose last octet is not 1 (highest wins). + Falls back to a random unused IP in the subnet if all are .1. + """ + import random + network = network_for(physical_vlan) + prefix = network.prefixlen + + identities = physical_vlan.get("server_identities", []) + known_ips = {ipaddress.IPv4Address(i["ip"]) for i in identities} + non_gateway = [ip for ip in known_ips if ip.packed[-1] != 1] + + if non_gateway: + chosen = max(non_gateway, key=lambda ip: ip.packed[-1]) + return f"{chosen}/{prefix}" + + # All identities end in .1 — pick a random unused host in the subnet + hosts = list(network.hosts()) + candidates = [h for h in hosts if h not in known_ips and h.packed[-1] != 1] + if candidates: + chosen = random.choice(candidates) + return f"{chosen}/{prefix}" + + # Degenerate fallback — extremely small subnet + return f"{list(network.hosts())[0]}/{prefix}" + +# ------------------------------------------------------------------------------ +# Dry-run helpers +# ------------------------------------------------------------------------------ + +def _svc_state(unit): + """Return 'active', 'inactive', or 'unknown' for a systemd unit.""" + r = subprocess.run(["systemctl", "is-active", unit], capture_output=True, text=True) + return r.stdout.strip() or "unknown" + +def _svc_enabled(unit): + """Return True if the systemd unit is enabled.""" + r = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True) + return r.stdout.strip() in ("enabled", "enabled-runtime") + +def _dry_run_conflicting_services(data): + print("-- Conflicting services (dry-run) ------------------------------------") + + for unit, label in [("systemd-resolved", "systemd-resolved"), + ("systemd-timesyncd", "systemd-timesyncd")]: + state = _svc_state(unit) + if state == "active": + print(f" Would stop and disable: {label} (currently: active)") + else: + print(f" {label}: not active — no action needed") + + chrony_ok = subprocess.run(["systemctl", "cat", "chrony"], + capture_output=True, text=True).returncode == 0 + if not chrony_ok: + print(" chrony: not installed — dependency check would have prompted to install it") + else: + chrony_conf = Path("/etc/chrony/chrony.conf") + if chrony_conf.exists(): + content = chrony_conf.read_text() + subnets = [] + for v in data["vlans"]: + if is_wg(v): + gw = v["vpn_information"]["gateway"] + net = ipaddress.IPv4Network(f"{gw}/24", strict=False) + subnets.append(str(net)) + else: + subnets.append(str(network_for(v))) + 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)}") + else: + print(" chrony.conf already has required allow directives — no change needed") + print(f" Would enable and restart: chrony") + + if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode == 0: + status = subprocess.run(["ufw", "status"], capture_output=True, text=True) + if "Status: active" in status.stdout: + print(" Would disable: ufw (currently: active)") + else: + print(" ufw: not active — no rule action needed") + if _svc_enabled("ufw"): + print(" Would disable: ufw.service (currently: enabled at boot)") + else: + print(" ufw.service: not enabled at boot — no action needed") + else: + print(" ufw: not installed — no action needed") + + r = subprocess.run(["systemctl", "is-enabled", "dnsmasq"], + capture_output=True, text=True) + if r.stdout.strip() in ("enabled", "enabled-runtime"): + print(f" Would stop and disable: system dnsmasq.service (currently: enabled)") + else: + print(" system dnsmasq.service: not enabled — no action needed") + + physical = next((v for v in data["vlans"] if is_physical(v)), None) + if physical: + gw = resolve_vlan_options(physical)["gateway"] + if RESOLV_CONF.is_symlink(): + print(f" Would replace /etc/resolv.conf symlink with plain file: nameserver {gw}") + else: + wanted = f"nameserver {gw}\n" + current = RESOLV_CONF.read_text() if RESOLV_CONF.exists() else "" + if wanted not in current: + print(f" Would update /etc/resolv.conf: nameserver {gw}") + else: + print(f" /etc/resolv.conf already points to {gw} — no change needed") + +def _dry_run_blocklists(data): + print("-- Blocklists (dry-run) ----------------------------------------------") + for entry in data.get("blocklists", []): + print(f" Would download: {entry['description']}") + print(f" URL: {entry['url']}") + seen = {} + for vlan in data["vlans"]: + names = vlan.get("use_blocklists", []) + if names: + h = combo_hash(names) + if h not in seen: + seen[h] = sorted(names) + path = merged_path(h) + action = "update" if path.exists() else "create" + print(f" Would {action} merged blocklist: {path}") + print(f" Sources: {', '.join(sorted(names))}") + +def _dry_run_timer(data): + print("-- Timer (dry-run) ---------------------------------------------------") + general = data.get("general", {}) + execute_time = general.get("daily_execute_time_24hr_local", "02:30") + for path, label in [(TIMER_FILE, "timer unit"), (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)") + +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}") + 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)") + +def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver): + print() + print("[DRY RUN] Based on your selections, --disable would perform the following:") + print() + + print("-- Stopping router services (dry-run) --------------------------------") + print(f" Would disable and stop: {TIMER_NAME}.timer") + for vlan in data["vlans"]: + svc = vlan_service_name(vlan) + conf = vlan_conf_file(vlan) + svc_f = vlan_service_file(vlan) + print(f" Would stop and disable: {svc}") + if conf.exists(): + print(f" Would remove: {conf}") + if svc_f.exists(): + print(f" Would remove: {svc_f}") + print(f" Would reload: systemd daemon") + for table in ("router-nat", "router-filter"): + r = subprocess.run(["nft", "list", "table", "ip", table], + capture_output=True, text=True) + if r.returncode == 0: + 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") + else: + print(f" {NAT_SERVICE_NAME}.service: not installed — no action needed") + print() + + print("-- Restoring NTP client (dry-run) ------------------------------------") + state = _svc_state("chrony") + if state == "active": + print(f" Would stop and disable: chrony (currently: active)") + else: + print(f" chrony: not active — no action needed") + r = subprocess.run(["systemctl", "cat", "systemd-timesyncd"], + capture_output=True, text=True) + if r.returncode == 0: + print(f" Would enable and start: systemd-timesyncd") + else: + print(f" systemd-timesyncd: not available on this system") + print() + + print("-- Network interface (dry-run) ----------------------------------------") + router_net = list(NETWORKD_DIR.glob("10-router-*.network")) + router_dev = list(NETWORKD_DIR.glob("10-router-*.netdev")) + client_file = 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}") + if use_dhcp: + print(f" [Match] Name={iface}") + print(f" [Network] DHCP=yes") + else: + print(f" [Match] Name={iface}") + print(f" [Network] DHCP=no Address={static_cidr}") + print(f" Would reload: systemd-networkd") + print() + + if not resolv_ok: + print("-- DNS (dry-run) -----------------------------------------------------") + if dns_choice == "resolved": + print(" Would enable: systemd-resolved") + print(" Would restore: /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf") + else: + print(f" Would write: /etc/resolv.conf") + print(f" nameserver {static_nameserver}") + print() + +# ------------------------------------------------------------------------------ +# Disable wizard +# ------------------------------------------------------------------------------ + +def cmd_disable(data, dry_run=False): + """Interactive wizard to revert the machine from router to plain network client.""" + import readline + + print() + print("=" * 70) + print(" REVERT TO NETWORK CLIENT" + (" [DRY RUN]" if dry_run else "")) + print("=" * 70) + print() + print(" You are reverting this machine from a gateway/router back to being") + print(" a plain network client. All router services, firewall rules, and") + print(" VLAN configuration will be removed.") + if dry_run: + print() + print(" DRY RUN: No changes will be made. This shows what would happen.") + print() + + # ------------------------------------------------------------------ + # Step 1 — Confirmation + # ------------------------------------------------------------------ + while True: + print(" [1] Proceed with reversion") + print(" [2] Cancel") + choice = input(" Choice [1/2]: ").strip() + if choice == "2": + print("Cancelled.") + return + if choice == "1": + break + print(" Invalid choice. Enter 1 or 2.") + print() + + # ------------------------------------------------------------------ + # Step 2 — IP configuration + # ------------------------------------------------------------------ + physical = next((v for v in data["vlans"] if is_physical(v)), None) + if physical is None: + die("No physical VLAN (vlan_id=1) found in config. Cannot determine interface.") + + iface = physical["interface"] + + print(" How should this machine obtain its IP address after reversion?") + print() + print(" [1] Obtain IP via DHCP (recommended — let the new router assign one)") + print(" [2] Use a static IP") + print() + + use_dhcp = None + static_cidr = None + + while True: + choice = input(" Choice [1/2]: ").strip() + if choice == "1": + use_dhcp = True + break + if choice == "2": + use_dhcp = False + break + print(" Invalid choice. Enter 1 or 2.") + + if not use_dhcp: + print() + print(" WARNING: Do not assign an IP that will conflict with another") + print(" LAN device, especially the new gateway/router.") + print() + + suggested = _suggest_static_ip(physical) + print(f" Suggested IP (edit as needed): {suggested}") + print() + + while True: + try: + readline.set_startup_hook(lambda: readline.insert_text(suggested)) + entry = input(" Static IP/prefix: ").strip() + finally: + readline.set_startup_hook(None) + + if not entry: + print(" Cannot be empty.") + continue + try: + ipaddress.IPv4Interface(entry) + static_cidr = entry + break + except ValueError: + print(f" '{entry}' is not a valid IPv4 address/prefix (e.g. 192.168.1.50/24).") + print() + + # ------------------------------------------------------------------ + # Step 3 — DNS resolver + # ------------------------------------------------------------------ + + # 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)} + resolv_ok = False + if not RESOLV_CONF.is_symlink() and RESOLV_CONF.exists(): + current_servers = { + parts[1] for line in RESOLV_CONF.read_text().splitlines() + if (parts := line.strip().split()) and parts[0] == "nameserver" + } + if current_servers and not current_servers.intersection(gateway_ips): + resolv_ok = True + + static_nameserver = None # set if user chooses manual entry + dns_choice = None # "resolved" or "static" + + if resolv_ok: + print(" /etc/resolv.conf already contains client-appropriate DNS settings.") + print(" Leaving it as-is.") + print() + else: + resolved_available = subprocess.run( + ["systemctl", "cat", "systemd-resolved"], + capture_output=True, text=True + ).returncode == 0 + + print(" How should DNS resolution be handled after reversion?") + print() + + if resolved_available: + print(" [1] Re-enable systemd-resolved (recommended — adapts to any network)") + print(" [2] Enter a static nameserver IP") + while True: + choice = input(" Choice [1/2]: ").strip() + if choice == "1": + dns_choice = "resolved" + break + if choice == "2": + dns_choice = "static" + break + print(" Invalid choice. Enter 1 or 2.") + else: + print(" systemd-resolved is not installed on this system.") + print(" A static nameserver IP will be used.") + dns_choice = "static" + + if dns_choice == "static": + print() + while True: + entry = input(" Nameserver IP: ").strip() + if not entry: + print(" Cannot be empty.") + continue + try: + ipaddress.IPv4Address(entry) + static_nameserver = entry + break + except ValueError: + print(f" '{entry}' is not a valid IPv4 address.") + print() + + # ------------------------------------------------------------------ + # Step 4 — Execute (or dry-run summary) + # ------------------------------------------------------------------ + if dry_run: + _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver) + return + + print("-- Stopping router services ------------------------------------------") + disable_all(data) + print() + + print("-- Restoring NTP client ----------------------------------------------") + restore_ntp() + print() + + print("-- Configuring network interface -------------------------------------") + _write_client_network(iface, dhcp=use_dhcp, static_cidr=static_cidr) + print() + + if not resolv_ok: + print("-- Configuring DNS ---------------------------------------------------") + if dns_choice == "static": + _configure_dns_static(static_nameserver) + else: + if not _configure_dns_resolved(): + print("Failed to re-enable systemd-resolved. Please enter a nameserver IP.") + while True: + entry = input(" Nameserver IP: ").strip() + try: + ipaddress.IPv4Address(entry) + _configure_dns_static(entry) + break + except ValueError: + print(f" '{entry}' is not a valid IPv4 address.") + print() + + print("Done. This machine is now configured as a network client.") + if use_dhcp: + print(f" Interface {iface} will obtain its IP via DHCP.") + else: + print(f" Interface {iface} will use static IP: {static_cidr}") + +# ------------------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------------------ + + +def cmd_install(data): + """--install: check and interactively install required packages.""" + check_root() + check_dependencies() + print("All required packages are installed.") + + +def cmd_apply(data, dry_run=False): + """--apply: full apply. Handles conflicting services, networkd (if changed), + dnsmasq confs, start/restart all services whose interface is up, nftables, + timer, and boot service. Safe to run repeatedly. + """ + if dry_run: + print("[DRY RUN] --apply would perform the following actions:") + print() + _dry_run_conflicting_services(data) + print() + print("-- systemd-networkd (dry-run) ----------------------------------------") + apply_networkd(data, dry_run=True) + print() + print("-- dnsmasq instances (dry-run) ---------------------------------------") + apply_dnsmasq_instances(data, dry_run=True, start_if_needed=True) + print() + print("-- nftables (dry-run) ------------------------------------------------") + apply_nftables(data, dry_run=True) + print() + _dry_run_timer(data) + print() + _dry_run_boot_service() + if radius_enabled(data): + print() + print("-- RADIUS (dry-run) --------------------------------------------------") + num_clients = len(radius_clients(data)) + default_vlan = next((v for v in data["vlans"] if v.get("radius_default") is True), None) + total_macs = sum( + len([r for r in v.get("reservations", []) if r.get("enabled") is True]) + for v in data["vlans"] + ) + print(f" Would write: {RADIUS_CLIENTS_CONF}") + print(f" {num_clients} RADIUS client(s)") + print(f" Would write: {RADIUS_USERS_FILE}") + print(f" {total_macs} MAC reservation(s)") + if default_vlan: + print(f" DEFAULT -> VLAN {default_vlan['vlan_id']} ({default_vlan['name']})") + print(f" Would ensure freeradius is running") + if avahi_enabled(data): + print() + print("-- mDNS Reflection (dry-run) -----------------------------------------") + ifaces = avahi_interfaces(data) + print(f" Would write: {AVAHI_CONF_FILE}") + print(f" Reflecting across: {', '.join(ifaces)}") + print(f" Would ensure avahi-daemon is running") + return + + check_root() + + total_enabled = sum( + len([r for r in v.get("reservations", []) if r.get("enabled") is True]) + for v in data["vlans"] + ) + total_disabled = sum( + len([r for r in v.get("reservations", []) if r.get("enabled") is not True]) + for v in data["vlans"] + ) + print(f"Applying config: {len(data['vlans'])} VLAN(s), " + f"{total_enabled} reservation(s), {total_disabled} skipped.") + print() + + print("-- Conflicting services ----------------------------------------------") + disable_systemd_timesyncd() + ensure_chrony(data) + disable_ufw() + print() + + print("-- systemd-networkd --------------------------------------------------") + apply_networkd(data, only_if_changed=True) + print() + + print("-- dnsmasq instances -------------------------------------------------") + if not blocklists_available(data): + print(" NOTE: No merged blocklist files found -- blocklist rules will be absent.") + print(" Run --update-blocklists to download and merge blocklists.") + apply_dnsmasq_instances(data, start_if_needed=True) + print() + + print("-- nftables ----------------------------------------------------------") + apply_nftables(data) + print() + + print("-- Timer -------------------------------------------------------------") + install_timer(data) + print() + + print("-- Boot service ------------------------------------------------------") + install_nat_service() + print() + + if radius_enabled(data): + print("-- RADIUS ------------------------------------------------------------") + apply_radius(data) + print() + else: + svc = "freeradius" + if subprocess.run(["systemctl", "is-active", svc], + capture_output=True, text=True).stdout.strip() == "active": + print("-- RADIUS ------------------------------------------------------------") + subprocess.run(["systemctl", "disable", "--now", svc], + capture_output=True, text=True) + print("freeradius stopped and disabled (no radius_client reservations).") + print() + + if avahi_enabled(data): + print("-- mDNS Reflection ---------------------------------------------------") + 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() + print() + + print("Done.") + + +def cmd_update_blocklists(data): + """--update-blocklists: download and merge blocklists. On success, call + cmd_apply to reload dnsmasq instances with the new blocklists. + """ + check_root() + print("-- Updating blocklists -----------------------------------------------") + success = update_blocklists(data) + print() + if success: + print("-- Applying updated configs ------------------------------------------") + cmd_apply(data) + else: + print("WARNING: Blocklist update had errors -- skipping --apply.") + print(" Existing merged files (if any) are unchanged.") + + +def main(): + parser = argparse.ArgumentParser( + description="Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "examples:\n" + " sudo python3 core.py --install Install required packages\n" + " sudo python3 core.py --apply Apply full config (idempotent, safe to re-run)\n" + " sudo python3 core.py --update-blocklists Refresh blocklists and apply\n" + " sudo python3 core.py --status Show service and timer status\n" + " sudo python3 core.py --view-configs Show active per-VLAN dnsmasq config files\n" + " sudo python3 core.py --view-leases Show active DHCP leases\n" + " sudo python3 core.py --view-rules Show active nftables ruleset\n" + " sudo python3 core.py --disable Stop instances, remove nftables, remove all config files\n" + " python3 core.py --view-metrics Show lifetime DNS metrics\n" + "\n" + " [--dry-run] may be combined with --apply or --disable\n" + " to preview all actions verbosely without making any changes:\n" + " sudo python3 core.py --apply --dry-run\n" + " sudo python3 core.py --disable --dry-run\n" + ) + ) + parser.add_argument("--install", action="store_true", help="Check and interactively install required packages") + parser.add_argument("--apply", action="store_true", help="Apply full config: services, networkd, dnsmasq, nftables, timer, boot service") + parser.add_argument("--update-blocklists", action="store_true", help="Refresh blocklists and apply (used by timer)") + parser.add_argument("--dry-run", action="store_true", help="Preview all actions without making changes (combine with --apply or --disable)") + parser.add_argument("--status", action="store_true", help="Show service and timer status") + parser.add_argument("--view-configs", action="store_true", help="Show active per-VLAN dnsmasq config files") + parser.add_argument("--view-leases", action="store_true", help="Show active DHCP leases") + parser.add_argument("--reset-leases", nargs="?", const="__all__", metavar="VLAN", + help="Reset DHCP leases (stop dnsmasq, delete lease files, restart). " + "Optionally specify a VLAN name to reset only that VLAN.") + parser.add_argument("--view-rules", action="store_true", help="Show active nftables ruleset") + parser.add_argument("--disable", action="store_true", help="Stop instances, remove nftables, remove all config files") + parser.add_argument("--view-metrics", action="store_true", help="Show lifetime DNS metrics across all instances") + + args = parser.parse_args() + + update_blocklists_flag = getattr(args, "update_blocklists", False) + + if not any([args.install, args.apply, update_blocklists_flag, + args.dry_run, args.status, args.view_configs, args.view_leases, + args.view_rules, args.disable, args.view_metrics, + args.reset_leases]): + parser.print_help() + sys.exit(0) + + if args.dry_run and not any([args.apply, args.disable]): + print("ERROR: --dry-run must be combined with --apply or --disable.") + sys.exit(1) + + data = load_config() + validate_config(data) + + general = data.get("general", {}) + setup_logging( + general.get("log_max_kb", 1024), + general.get("log_errors_only", False) + ) + + if args.status: + show_status(data) + return + + if args.view_configs: + show_configs(data) + return + + if args.view_leases: + 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) + return + + if args.view_rules: + show_rules() + return + + if args.view_metrics: + show_metrics(data) + return + + if args.disable: + if not args.dry_run: + check_root() + cmd_disable(data, dry_run=args.dry_run) + return + + if args.install: + cmd_install(data) + return + + if update_blocklists_flag: + cmd_update_blocklists(data) + return + + if args.apply: + cmd_apply(data, dry_run=args.dry_run) + return + + +if __name__ == "__main__": + main() diff --git a/ddns.json b/ddns.json new file mode 100644 index 0000000..1319827 --- /dev/null +++ b/ddns.json @@ -0,0 +1,52 @@ +{ + "general": { + "log_max_kb": 512, + "log_errors_only": false + }, + + "providers": [ + { + "description": "No-IP Main Account", + "enabled": true, + "type": "noip", + "username": "your-noip-username", + "password": "your-noip-password", + "hostnames": [ + "myhome.ddns.net" + ], + "timer_interval": "5m", + "ip_check_services": [ + "https://api.ipify.org", + "https://ipv4.icanhazip.com", + "https://checkip.amazonaws.com", + "https://myip.dnsomatic.com", + "https://api4.my-ip.io/ip", + "https://ipinfo.io/ip", + "https://ip4.seeip.org", + "https://ipv4bot.whatismyipaddress.com", + "http://checkip.dyndns.org/" + ] + }, + { + "description": "DuckDNS Account", + "enabled": false, + "type": "duckdns", + "token": "your-duckdns-token", + "subdomains": [ + "myhome" + ], + "timer_interval": "5m", + "ip_check_services": [ + "https://api.ipify.org", + "https://ipv4.icanhazip.com", + "https://checkip.amazonaws.com", + "https://myip.dnsomatic.com", + "https://api4.my-ip.io/ip", + "https://ipinfo.io/ip", + "https://ip4.seeip.org", + "https://ipv4bot.whatismyipaddress.com", + "http://checkip.dyndns.org/" + ] + } + ] +} diff --git a/ddns.py b/ddns.py new file mode 100644 index 0000000..f22f21c --- /dev/null +++ b/ddns.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +""" +ddns.py -- Update DDNS provider(s) with current public IP. + +Reads ddns.json, fetches the current public IP, and updates +each enabled provider block only if the IP has changed since the +last successful update for that provider. +Designed to be run on a systemd timer. + +IP check services are rotated each run using .ddns-last-service so +no single provider is spammed. If the selected service fails, the +script falls back through the remaining services in order. + +Per-provider cache files are named .ddns-last-ip-. +Logs to ddns.log in the same directory as this script. +Log is cleared when it exceeds general.log_max_kb from config. + +Usage: + sudo python3 ddns.py --start Run update and install systemd timer + sudo python3 ddns.py --disable Stop updates and remove systemd timer + sudo python3 ddns.py --apply Run update once (used by timer) + sudo python3 ddns.py --force Force update regardless of cached IP + sudo python3 ddns.py --status Show timer/service status +""" + +import json +import os +import subprocess +import re +import urllib.request +import urllib.error +import sys +import logging +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +CONFIG_FILE = SCRIPT_DIR / "ddns.json" +CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service" +LOG_FILE = SCRIPT_DIR / "ddns.log" +TIMER_NAME = "ddns-update" +SERVICE_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.service") +TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer") + +# log is assigned in setup_logging() after config is loaded +log = None + +# ------------------------------------------------------------------------------ +# Load config +# ------------------------------------------------------------------------------ + +def load_config(): + if not CONFIG_FILE.exists(): + print(f"ERROR: Config file not found: {CONFIG_FILE}") + sys.exit(1) + with open(CONFIG_FILE) as f: + data = json.load(f) + + # Validate general block + required_general = {"log_max_kb", "log_errors_only", "ip_check_services"} + missing = required_general - set(data.get("general", {}).keys()) + if missing: + print(f"ERROR: Missing keys in general block: {missing}") + sys.exit(1) + if not data["general"]["ip_check_services"]: + print("ERROR: ip_check_services list is empty.") + sys.exit(1) + + # Validate providers block + if not data.get("providers"): + print("ERROR: No providers defined in config.") + sys.exit(1) + for p in data["providers"]: + base_required = {"description", "provider", "enabled"} + missing = base_required - set(p.keys()) + if missing: + print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}") + sys.exit(1) + ptype = p.get("provider", "").lower() + if ptype == "noip": + extra = {"username", "password", "hostnames"} + elif ptype == "duckdns": + extra = {"token", "subdomains"} + else: + print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'") + sys.exit(1) + missing = extra - set(p.keys()) + if missing: + print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}") + sys.exit(1) + + return data + +# ------------------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------------------ + +def chown_to_script_dir_owner(path): + """Chown a file to the owner of the script directory. + This works correctly whether invoked via sudo, directly as root (e.g. systemd timer), + or as a normal user — the script directory owner is always the right target. + """ + try: + stat = SCRIPT_DIR.stat() + os.chown(path, stat.st_uid, stat.st_gid) + except OSError: + pass # non-fatal + +# ------------------------------------------------------------------------------ +# Logging +# ------------------------------------------------------------------------------ + +def setup_logging(max_kb, errors_only): + """Clear log if oversized, then initialise logger. Must be called before log is used.""" + global log + max_bytes = int(max_kb * 1024) + try: + if LOG_FILE.exists() and LOG_FILE.stat().st_size > max_bytes: + LOG_FILE.write_text("") + if not LOG_FILE.exists(): + LOG_FILE.touch() + chown_to_script_dir_owner(LOG_FILE) + file_handler = logging.FileHandler(LOG_FILE) + except PermissionError: + print(f"WARNING: Cannot write to {LOG_FILE} (permission denied). " + f"Run with sudo or fix ownership: sudo chown $USER {LOG_FILE}") + file_handler = None + level = logging.ERROR if errors_only else logging.INFO + handlers = [logging.StreamHandler(sys.stdout)] + if file_handler: + handlers.insert(0, file_handler) + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=handlers, + ) + log = logging.getLogger("ddns") + +# ------------------------------------------------------------------------------ +# Per-provider IP cache +# ------------------------------------------------------------------------------ + +def cache_file_for(description): + """Return the cache file path for a given provider description.""" + safe_name = description.replace(" ", "-") + return SCRIPT_DIR / f".ddns-last-ip-{safe_name}" + +def get_cached_ip(description): + f = cache_file_for(description) + if f.exists(): + return f.read_text().strip() + return None + +def save_cached_ip(description, ip): + f = cache_file_for(description) + f.write_text(ip) + chown_to_script_dir_owner(f) + +# ------------------------------------------------------------------------------ +# Service rotation +# ------------------------------------------------------------------------------ + +def get_next_service_index(total): + """Read last used index, increment, wrap around, return next index.""" + if CACHE_SERVICE_FILE.exists(): + try: + last = int(CACHE_SERVICE_FILE.read_text().strip()) + except ValueError: + last = -1 + else: + last = -1 + return (last + 1) % total + +def save_service_index(index): + CACHE_SERVICE_FILE.write_text(str(index)) + chown_to_script_dir_owner(CACHE_SERVICE_FILE) + +# ------------------------------------------------------------------------------ +# Public IP detection + +def extract_ip(body): + """ + Extract an IP address from a service response. + Handles both plain text responses (most services) and HTML responses + such as checkip.dyndns.org which returns: + ...Current IP Address: 1.2.3.4 + """ + # Try plain text first (strip and validate) + plain = body.strip() + if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', plain): + return plain + # Fall back to extracting from HTML + match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', body) + if match: + return match.group(1) + return None + + +# ------------------------------------------------------------------------------ + +def get_public_ip(services): + """ + Start at the next service in rotation. If it fails, fall through + the remaining services in order. Saves the index of the service + that succeeded so the next run starts with the following one. + """ + total = len(services) + start = get_next_service_index(total) + ordered = [services[(start + i) % total] for i in range(total)] + + for i, url in enumerate(ordered): + try: + req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"}) + with urllib.request.urlopen(req, timeout=10) as r: + body = r.read().decode().strip() + ip = extract_ip(body) + if ip: + used_index = (start + i) % total + save_service_index(used_index) + log.info(f"Public IP retrieved from {url}: {ip}") + return ip + except Exception as e: + log.warning(f"IP check failed for {url}: {e}") + continue + + log.error("Could not determine public IP from any configured service.") + sys.exit(1) + +# ------------------------------------------------------------------------------ +# No-IP update +# ------------------------------------------------------------------------------ + +def update_noip(provider, ip): + """ + No-IP HTTP update API. + Docs: https://www.noip.com/integrate/request + Uses HTTP Basic Auth. Supports comma-separated list of hostnames. + """ + username = provider["username"] + password = provider["password"] + hostnames = ",".join(provider["hostnames"]) + + url = f"https://dynupdate.no-ip.com/nic/update?hostname={hostnames}&myip={ip}" + + password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() + password_mgr.add_password(None, url, username, password) + handler = urllib.request.HTTPBasicAuthHandler(password_mgr) + opener = urllib.request.build_opener(handler) + + req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"}) + + try: + with opener.open(req, timeout=10) as r: + return r.read().decode().strip() + except urllib.error.URLError as e: + log.error(f"Network error contacting No-IP: {e}") + return None + +def interpret_noip_response(response, hostnames, ip): + """ + No-IP response codes: + good -- update successful + nochg -- IP already set to this value (no change needed) + nohost -- hostname not found in account + badauth -- invalid credentials + badagent -- client blocked + !donator -- feature requires paid account + abuse -- account blocked for abuse + 911 -- server-side error, retry later + """ + if response is None: + return False + if response.startswith("good"): + log.info(f"No-IP updated successfully: {hostnames} -> {ip}") + return True + elif response.startswith("nochg"): + log.info(f"No-IP: no change needed ({hostnames} already set to {ip})") + return True + elif response == "nohost": + log.error(f"No-IP: hostname '{hostnames}' not found in account.") + elif response == "badauth": + log.error(f"No-IP: authentication failed for '{hostnames}'. Check username and password.") + elif response == "badagent": + log.error("No-IP: client blocked by No-IP.") + elif response == "!donator": + log.error("No-IP: this feature requires a paid account.") + elif response == "abuse": + log.error("No-IP: account blocked for abuse.") + elif response == "911": + log.error("No-IP: server error. Will retry on next run.") + else: + log.error(f"No-IP: unexpected response: {response}") + return False + + +# ------------------------------------------------------------------------------ +# DuckDNS update +# ------------------------------------------------------------------------------ + +def update_duckdns(provider, ip): + """ + DuckDNS HTTP update API. + Docs: https://www.duckdns.org/spec.jsp + Token-based, no username/password. Subdomains are the short name only + (e.g. "myhome", not "myhome.duckdns.org"). Supports multiple subdomains + as a comma-separated list. + Returns True on success, False on failure. + """ + token = provider["token"] + subdomains = ",".join(provider["subdomains"]) + description = provider["description"] + + url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}" + + try: + req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"}) + with urllib.request.urlopen(req, timeout=10) as r: + response = r.read().decode().strip() + if response == "OK": + log.info(f"DuckDNS updated successfully: {subdomains} -> {ip}") + return True + else: + log.error(f"DuckDNS update failed for '{description}': response was '{response}'") + return False + except urllib.error.URLError as e: + log.error(f"Network error contacting DuckDNS: {e}") + return False + +# ------------------------------------------------------------------------------ +# Process a single provider block +# ------------------------------------------------------------------------------ + +def process_provider(provider, current_ip, force=False): + description = provider["description"] + + if not provider.get("enabled") is True: + log.info(f"Provider '{description}' is disabled, skipping.") + return + + cached_ip = get_cached_ip(description) + + if not force and current_ip == cached_ip: + log.info(f"[{description}] IP unchanged ({current_ip}), skipping update.") + return + + if force: + log.info(f"[{description}] Force update requested. Updating with {current_ip}...") + elif cached_ip: + log.info(f"[{description}] IP changed: {cached_ip} -> {current_ip}. Updating...") + else: + log.info(f"[{description}] No cached IP found. Updating with {current_ip}...") + + ptype = provider["provider"].lower() + + if ptype == "noip": + hostnames = ",".join(provider["hostnames"]) + response = update_noip(provider, current_ip) + success = interpret_noip_response(response, hostnames, current_ip) + elif ptype == "duckdns": + success = update_duckdns(provider, current_ip) + else: + log.error(f"[{description}] Unknown provider type: '{ptype}'") + return + + if success: + save_cached_ip(description, current_ip) + + +# ------------------------------------------------------------------------------ +# Timer management +# ------------------------------------------------------------------------------ + +def parse_interval(interval_str): + """ + Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value. + Supported units: m (minutes), h (hours), d (days). + """ + interval_str = interval_str.strip() + if interval_str.endswith("m"): + return f"{interval_str[:-1]}min" + elif interval_str.endswith("h"): + return f"{interval_str[:-1]}h" + elif interval_str.endswith("d"): + return f"{interval_str[:-1]}day" + else: + print(f"ERROR: Invalid timer_interval format: '{interval_str}'. Use e.g. 5m, 2h, 1d.") + sys.exit(1) + +def get_current_timer_interval(): + """Read the current OnUnitActiveSec value from the timer file, or None if not present.""" + if not TIMER_FILE.exists(): + return None + for line in TIMER_FILE.read_text().splitlines(): + if line.strip().startswith("OnUnitActiveSec="): + return line.strip().split("=", 1)[1] + return None + +def install_timer(cfg): + interval = cfg["general"].get("timer_interval", "5m") + systemd_unit = parse_interval(interval) + script_path = Path(__file__).resolve() + current_interval = get_current_timer_interval() + + if current_interval == systemd_unit: + log.info(f"Timer already set to {interval}, no update needed.") + return + + service_content = f"""[Unit] +Description=DDNS update service +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 {script_path} --apply +""" + + timer_content = f"""[Unit] +Description=DDNS update timer + +[Timer] +OnUnitActiveSec={systemd_unit} +OnBootSec=1min + +[Install] +WantedBy=timers.target +""" + + SERVICE_FILE.write_text(service_content) + TIMER_FILE.write_text(timer_content) + + subprocess.run(["systemctl", "daemon-reload"], check=True) + subprocess.run(["systemctl", "enable", "--now", f"{TIMER_NAME}.timer"], check=True) + + if current_interval is None: + log.info(f"Timer installed: runs every {interval}.") + else: + log.info(f"Timer updated: was {current_interval}, now runs every {interval}.") + +def remove_timer(): + if TIMER_FILE.exists() or SERVICE_FILE.exists(): + subprocess.run( + ["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"], + capture_output=True + ) + for f in (TIMER_FILE, SERVICE_FILE): + if f.exists(): + f.unlink() + print(f"Removed: {f}") + subprocess.run(["systemctl", "daemon-reload"], capture_output=True) + print("Timer removed.") + else: + print("No timer found, nothing to remove.") + +# ------------------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------------------ + +def run_update(cfg, force=False): + """Perform a single DDNS update pass. Called by both timer and --start. + If force=True, bypasses the cached IP check and always updates.""" + general = cfg["general"] + current_ip = get_public_ip(general["ip_check_services"]) + enabled = [p for p in cfg["providers"] if p.get("enabled") is True] + + if not enabled: + log.error("No enabled providers found in config.") + sys.exit(1) + + for provider in enabled: + process_provider(provider, current_ip, force=force) + +def show_status(): + """Show status of managed timer.""" + result = subprocess.run( + ["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"], + capture_output=True, text=True + ) + print(result.stdout) + + + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Update DDNS provider(s) with current public IP", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "examples:\n" + " sudo python3 ddns.py --start Run update and install systemd timer\n" + " sudo python3 ddns.py --disable Stop updates and remove systemd timer\n" + " sudo python3 ddns.py --apply Run update once (used by timer)\n" + " sudo python3 ddns.py --force Force update regardless of cached IP\n" + " sudo python3 ddns.py --status Show timer/service status\n" + ) + ) + parser.add_argument("--start", action="store_true", help="Run update and install systemd timer") + parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer") + parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)") + parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP") + parser.add_argument("--status", action="store_true", help="Show timer/service status") + + args = parser.parse_args() + + if not any([args.start, args.disable, args.apply, args.force, args.status]): + parser.print_help() + return + + if args.status: + show_status() + return + + cfg = load_config() + general = cfg["general"] + setup_logging(general["log_max_kb"], general["log_errors_only"]) + + if args.disable: + remove_timer() + return + + if args.start: + run_update(cfg) + install_timer(cfg) + return + + if args.apply or args.force: + run_update(cfg, force=args.force) + +if __name__ == "__main__": + main() diff --git a/vpn.py b/vpn.py new file mode 100644 index 0000000..9b9f595 --- /dev/null +++ b/vpn.py @@ -0,0 +1,1001 @@ +#!/usr/bin/env python3 +""" +vpn.py -- Manage WireGuard VPN server and peers. + +Reads core.json for VPN interface configuration. Any VLAN whose interface +name starts with "wg" is treated as a WireGuard interface. Peer data is +stored in per-interface dotfiles (.vpn-wg0, .vpn-wg1, etc.) in the same +directory as this script. + +Server private keys: + Stored at /etc/wireguard/.key (root read-only, 600). + Generated once on --apply if not already present. + +Peer key model (WireGuard is symmetric): + Each peer generates a keypair. The peer's PRIVATE key is embedded in + their client config file and must be transferred to them securely. + The peer's PUBLIC key is stored in the dotfile and written into the + WireGuard conf. The server's public key is embedded in each client config. + +Client config files: + Generated as vpn-client-.conf in the same directory as vpn.py. + Permissions: 600 (root read-only). + Contains the peer's private key -- transfer to the client by secure means + (e.g. encrypted email, USB), then delete from this server. + +Usage: + sudo python3 vpn.py --add-peer Add a new peer interactively + sudo python3 vpn.py --manage-peers List and manage existing peers + sudo python3 vpn.py --apply Write WireGuard config and sync peers + sudo python3 vpn.py --disable Stop WireGuard on all interfaces + sudo python3 vpn.py --status Show WireGuard service and interface status + sudo python3 vpn.py --view-peers Show per-peer handshake and traffic stats +""" + +import ipaddress +import json +import os +import re +import subprocess +import sys +import argparse +from pathlib import Path +from datetime import datetime, timezone + +SCRIPT_DIR = Path(__file__).parent +DHCP_CONFIG_FILE = SCRIPT_DIR / "core.json" +DDNS_CONFIG_FILE = SCRIPT_DIR / "ddns.json" +WG_DIR = Path("/etc/wireguard") +KEEPALIVE = 25 + +# ------------------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------------------ + +def die(msg): + print(f"ERROR: {msg}") + sys.exit(1) + +def check_root(): + if os.geteuid() != 0: + die("This script must be run as root (sudo).") + +def chown_to_script_dir_owner(path): + """Chown a file to the owner of the script directory. + Keeps SCRIPT_DIR files user-owned even when running as root. + /etc/wireguard files are intentionally excluded — they stay root-owned. + """ + try: + stat = SCRIPT_DIR.stat() + os.chown(path, stat.st_uid, stat.st_gid) + except OSError: + pass # non-fatal + +def run(cmd, check=True, capture=True): + return subprocess.run(cmd, capture_output=capture, text=True, check=check) + +def wg_available(): + return subprocess.run(["which", "wg"], capture_output=True).returncode == 0 + +def sense_mtu(dhcp_data): + """ + Derive the recommended WireGuard tunnel MTU from the WAN interface MTU. + WireGuard adds 60 bytes of overhead for IPv4, so tunnel MTU = link MTU - 60. + Falls back to 1420 (correct for standard 1500 MTU links) if sensing fails. + Reads wan_interface from core.json general block. + """ + wan = dhcp_data.get("general", {}).get("wan_interface", "") + if wan: + try: + result = subprocess.run( + ["ip", "link", "show", wan], + capture_output=True, text=True + ) + m = re.search(r"\bmtu\s+(\d+)", result.stdout) + if m: + return int(m.group(1)) - 60 + except Exception: + pass + return 1420 + +def check_wireguard_tools(): + """Ensure wireguard-tools is installed; prompt to install via apt if not.""" + if wg_available(): + return + print("wireguard-tools is not installed (provides wg and wg-quick).") + print() + while True: + choice = input("Install wireguard-tools now via apt? [y/N]: ").strip().lower() + if choice in ("y", "yes"): + break + if choice in ("n", "no", ""): + die("Cannot continue without wireguard-tools. Install it and retry.") + result = subprocess.run(["apt-get", "install", "-y", "wireguard-tools"]) + if result.returncode != 0: + die("Package installation failed. Install wireguard-tools manually and retry.") + +def _fmt_bytes(n): + if n < 1024: + return f"{n} B" + elif n < 1024 ** 2: + return f"{n / 1024:.1f} KB" + elif n < 1024 ** 3: + return f"{n / 1024**2:.1f} MB" + else: + return f"{n / 1024**3:.2f} GB" + +# ------------------------------------------------------------------------------ +# Load core.json / dotfiles +# ------------------------------------------------------------------------------ + +def load_dhcp(): + if not DHCP_CONFIG_FILE.exists(): + die(f"Config file not found: {DHCP_CONFIG_FILE}") + with open(DHCP_CONFIG_FILE) as f: + return json.load(f) + +def wg_interfaces(dhcp_data): + """Return list of VLAN dicts whose interface name starts with 'wg'.""" + return [v for v in dhcp_data.get("vlans", []) + if v.get("interface", "").startswith("wg")] + +def dotfile_path(iface): + return SCRIPT_DIR / f".vpn-{iface}" + +def vpi(vlan): + """Return the vpn_information dict for a WG VLAN (plain object, not list).""" + return vlan["vpn_information"] + +def server_key_path(iface): + return WG_DIR / f"{iface}.key" + +def wg_conf_path(iface): + return WG_DIR / f"{iface}.conf" + +def load_peers(iface): + """Load peers list from the dotfile. Returns [] if file does not exist.""" + path = dotfile_path(iface) + if not path.exists(): + return [] + with open(path) as f: + return json.load(f).get("peers", []) + +def save_peers(iface, peers): + """Write peers list to the dotfile with 600 permissions.""" + path = dotfile_path(iface) + with open(path, "w") as f: + json.dump({"peers": peers}, f, indent=2) + f.write("\n") + path.chmod(0o600) + chown_to_script_dir_owner(path) + +# ------------------------------------------------------------------------------ +# IP allocation +# ------------------------------------------------------------------------------ + +def next_available_ip(vlan, peers): + """ + Find the first available peer IP in the wg VLAN subnet, starting from .2. + Skips the gateway IP. Scans .2-.254 for the first gap. + """ + gateway = vpi(vlan)["gateway"] + gw_net = ipaddress.IPv4Interface(f"{gateway}/24") + network = gw_net.network + base = int(network.network_address) + + used = {int(ipaddress.IPv4Address(gateway))} + for peer in peers: + try: + used.add(int(ipaddress.IPv4Interface(peer["ip"]).ip)) + except (KeyError, ValueError): + pass + + for offset in range(2, 255): + candidate = ipaddress.IPv4Address(base + offset) + if int(candidate) not in used: + return f"{candidate}/32" + + die(f"No available IPs in VPN subnet {network} (all .2-.254 allocated).") + +# ------------------------------------------------------------------------------ +# Key management +# ------------------------------------------------------------------------------ + +def generate_server_key(iface): + """Generate server private key and store at WG_DIR/.key (600).""" + WG_DIR.mkdir(exist_ok=True) + private = run(["wg", "genkey"]).stdout.strip() + kf = server_key_path(iface) + kf.write_text(private + "\n") + kf.chmod(0o600) + print(f"Server private key generated: {kf}") + +def get_server_public_key(iface): + """Derive and return the server's public key from the stored private key.""" + kf = server_key_path(iface) + if not kf.exists(): + die(f"Server private key not found at {kf}. Run --apply first.") + private = kf.read_text().strip() + return subprocess.run( + ["wg", "pubkey"], input=private, capture_output=True, text=True, check=True + ).stdout.strip() + +def generate_peer_keypair(): + """Generate and return (private_key, public_key) for a peer.""" + private = run(["wg", "genkey"]).stdout.strip() + public = subprocess.run( + ["wg", "pubkey"], input=private, capture_output=True, text=True, check=True + ).stdout.strip() + return private, public + +# ------------------------------------------------------------------------------ +# Endpoint resolution +# ------------------------------------------------------------------------------ + +def resolve_endpoint(listen_port): + """ + Resolve the public endpoint for client configs. + + Resolution order: + 1. First enabled provider hostname from ddns.json + 2. Manual entry prompt if ddns.json is missing or has no enabled provider + + The user is always shown the resolved value and given the opportunity to + edit it before it is used. + """ + import readline + + candidate = None + source = None + + if DDNS_CONFIG_FILE.exists(): + with open(DDNS_CONFIG_FILE) as f: + ddns = json.load(f) + for provider in ddns.get("providers", []): + if provider.get("enabled") is not True: + continue + ptype = provider.get("provider", "").lower() + if ptype == "noip": + hostnames = provider.get("hostnames", []) + if hostnames: + candidate = f"{hostnames[0]}:{listen_port}" + source = "ddns.json" + break + elif ptype == "duckdns": + subdomains = provider.get("subdomains", []) + if subdomains: + candidate = f"{subdomains[0]}.duckdns.org:{listen_port}" + source = "ddns.json" + break + if not candidate: + print("No enabled DDNS provider found in ddns.json.") + else: + print(f"ddns.json not found at {DDNS_CONFIG_FILE}.") + + if candidate: + prompt = f"Public endpoint (from {source}): " + else: + print("Please enter the public endpoint manually.") + prompt = "Public endpoint (hostname:port): " + + while True: + try: + readline.set_startup_hook( + lambda: readline.insert_text(candidate) if candidate else None + ) + entry = input(prompt).strip() + finally: + readline.set_startup_hook(None) + + if not entry: + print(" Endpoint cannot be empty.") + continue + if ":" not in entry: + entry = f"{entry}:{listen_port}" + return entry + +# ------------------------------------------------------------------------------ +# Split-tunnel route computation +# ------------------------------------------------------------------------------ + +def split_tunnel_routes(dhcp_data): + """ + Return a list of CIDR strings for all VLANs defined in core.json. + WG VLANs use vpn_information.gateway to derive their subnet. + Used as AllowedIPs in client configs when split_tunnel is true. + """ + routes = [] + for v in dhcp_data.get("vlans", []): + if v.get("interface", "").startswith("wg"): + gw = vpi(v)["gateway"] + net = ipaddress.IPv4Network(f"{gw}/24", strict=False) + routes.append(str(net)) + else: + d = v["dhcp"] + net = ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False) + routes.append(str(net)) + return routes + +# ------------------------------------------------------------------------------ +# Client config +# ------------------------------------------------------------------------------ + +def build_client_conf(peer, private_key, server_public_key, endpoint, + allowed_ips, dns, domain, mtu): + dns_line = f"DNS = {dns}, {domain}" if domain else f"DNS = {dns}" + return "\n".join([ + "[Interface]", + f"PrivateKey = {private_key}", + f"Address = {peer['ip']}", + dns_line, + f"MTU = {mtu}", + "", + "[Peer]", + f"PublicKey = {server_public_key}", + f"Endpoint = {endpoint}", + f"AllowedIPs = {allowed_ips}", + f"PersistentKeepalive = {KEEPALIVE}", + "", + ]) + +def write_client_conf(peer, private_key, server_public_key, endpoint, + allowed_ips, dns, domain, mtu): + conf_path = SCRIPT_DIR / f"vpn-client-{peer['name']}.conf" + content = build_client_conf(peer, private_key, server_public_key, + endpoint, allowed_ips, dns, domain, mtu) + conf_path.write_text(content) + conf_path.chmod(0o600) + chown_to_script_dir_owner(conf_path) + return conf_path + +# ------------------------------------------------------------------------------ +# WireGuard server conf +# ------------------------------------------------------------------------------ + +def build_wg_conf(vlan, peers, server_private_key): + iface = vlan["interface"] + info = vpi(vlan) + gateway = info["gateway"] + gw_net = ipaddress.IPv4Interface(f"{gateway}/24") + server_ip = f"{gateway}/{gw_net.network.prefixlen}" + listen_port = info["listen_port"] + + lines = [ + "# Generated by vpn.py -- do not edit manually.", + "# Run: sudo python3 vpn.py --apply", + "", + "[Interface]", + f"PrivateKey = {server_private_key}", + f"Address = {server_ip}", + f"ListenPort = {listen_port}", + "", + ] + for peer in peers: + if peer.get("enabled") is True: + lines += [ + f"# {peer['name']}", + "[Peer]", + f"PublicKey = {peer['public_key']}", + f"AllowedIPs = {peer['ip']}", + "", + ] + return "\n".join(lines) + +# ------------------------------------------------------------------------------ +# Live peer sync +# ------------------------------------------------------------------------------ + +def sync_peers_live(iface, peers): + """ + Sync live WireGuard peers to match the dotfile state without a service + restart. Adds peers present in the dotfile (enabled) but not live; + removes peers live but not in the enabled dotfile set. + """ + result = run(["wg", "show", iface, "dump"], check=False) + if result.returncode != 0: + return # interface not up yet + + lines = result.stdout.strip().splitlines() + live_keys = set() + for line in lines[1:]: # first line is the server interface itself + parts = line.split("\t") + if parts: + live_keys.add(parts[0]) + + enabled_peers = { + p["public_key"]: p + for p in peers + if p.get("enabled") is True + } + dotfile_keys = set(enabled_peers.keys()) + + for key in dotfile_keys - live_keys: + peer = enabled_peers[key] + run(["wg", "set", iface, "peer", key, "allowed-ips", peer["ip"]]) + print(f" Added peer: {peer['name']} ({peer['ip']})") + + for key in live_keys - dotfile_keys: + run(["wg", "set", iface, "peer", key, "remove"]) + print(f" Removed peer: {key[:16]}...") + +# ------------------------------------------------------------------------------ +# Interface selection +# ------------------------------------------------------------------------------ + +def validate_wg_vlans(wg_vlans): + """Die with a clear message if any wg VLAN is missing a valid vpn_information block.""" + for vlan in wg_vlans: + iface = vlan.get("interface", "?") + info = vlan.get("vpn_information") + if not isinstance(info, dict): + die(f"Interface '{iface}' is missing a vpn_information block in core.json. " + f"Add: \"vpn_information\": {{\"listen_port\": 51820, \"gateway\": \"...\"}}") + if not isinstance(info.get("listen_port"), int): + die(f"Interface '{iface}' vpn_information is missing a valid listen_port in core.json.") + if not info.get("gateway"): + die(f"Interface '{iface}' vpn_information is missing gateway in core.json.") + +def pick_wg_interface(wg_vlans): + """ + If only one wg interface exists, return it immediately. + Otherwise, print a numbered list and prompt the user to pick. + """ + if len(wg_vlans) == 1: + return wg_vlans[0] + + print("Available WireGuard interfaces:") + for i, vlan in enumerate(wg_vlans, 1): + lp = vpi(vlan)["listen_port"] + print(f" {i}. {vlan['interface']} ({vlan['name']}, port {lp})") + print() + + while True: + choice = input("Select interface number: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(wg_vlans): + return wg_vlans[idx] + except ValueError: + pass + print(" Invalid selection.") + +# ------------------------------------------------------------------------------ +# --add-peer +# ------------------------------------------------------------------------------ + +def cmd_add_peer(dhcp_data): + check_root() + check_wireguard_tools() + + wg_vlans = wg_interfaces(dhcp_data) + + vlan = pick_wg_interface(wg_vlans) + iface = vlan["interface"] + peers = load_peers(iface) + + # -- Resolve endpoint ------------------------------------------------------- + endpoint = resolve_endpoint(vpi(vlan)["listen_port"]) + + # -- Peer name ------------------------------------------------------------- + print() + while True: + name = input("Peer name (e.g. norman-laptop): ").strip() + if not name: + print(" Name cannot be empty.") + continue + if any(p["name"] == name for p in peers): + print(f" A peer named '{name}' already exists for {iface}.") + continue + break + + # -- Peer IP --------------------------------------------------------------- + prefill = next_available_ip(vlan, peers) + print(f"Peer IP [{prefill}]: ", end="", flush=True) + ip_input = input().strip() + peer_ip = ip_input if ip_input else prefill + + d = vpi(vlan) + gateway = d["gateway"] + network = ipaddress.IPv4Network(f"{gateway}/24", strict=False) + try: + iface_ip = ipaddress.IPv4Interface(peer_ip) + if iface_ip.ip not in network: + die(f"IP '{peer_ip}' is not within VPN subnet {network}.") + if iface_ip.ip == ipaddress.IPv4Address(gateway): + die(f"IP '{peer_ip}' is the server gateway. Choose a different IP.") + for p in peers: + if ipaddress.IPv4Interface(p["ip"]).ip == iface_ip.ip: + die(f"IP '{peer_ip}' is already assigned to peer '{p['name']}'.") + except ValueError as e: + die(f"Invalid IP '{peer_ip}': {e}") + + # -- Generate keypair ------------------------------------------------------- + print(f"\nGenerating keypair for '{name}'...") + private_key, public_key = generate_peer_keypair() + + # -- Ensure server key exists ----------------------------------------------- + kf = server_key_path(iface) + if not kf.exists(): + print(f"Server private key not found for {iface} -- generating now...") + generate_server_key(iface) + server_public_key = get_server_public_key(iface) + + # -- Split tunnel prompt --------------------------------------------------- + print() + st_input = input("Split tunnel? Route only VPN subnets (not all traffic) through WireGuard. [Y/n]: ").strip().lower() + split_tunnel = st_input != "n" + + if split_tunnel: + allowed_ips = ", ".join(split_tunnel_routes(dhcp_data)) + else: + allowed_ips = "0.0.0.0/0" + + info = vpi(vlan) + dns = info.get("explicit_overrides", {}).get("dns_server", "") or gateway + domain = info.get("domain", "") + mtu_override = info.get("explicit_overrides", {}).get("mtu", "") + mtu = int(mtu_override) if mtu_override else sense_mtu(dhcp_data) + + # -- Add peer to dotfile ---------------------------------------------------- + new_peer = {"name": name, "ip": peer_ip, "public_key": public_key, + "split_tunnel": split_tunnel, "enabled": True} + peers.append(new_peer) + save_peers(iface, peers) + print(f"Peer '{name}' added to {dotfile_path(iface).name} (ip: {peer_ip}).") + + # -- Write client config ---------------------------------------------------- + conf_path = write_client_conf(new_peer, private_key, server_public_key, + endpoint, allowed_ips, dns, domain, mtu) + + private_key = "0" * len(private_key) + del private_key + + # -- Instructions ----------------------------------------------------------- + print() + print("=" * 68) + print(f" Client config written: {conf_path}") + print() + print(" NEXT STEPS:") + print(f" 1. Transfer {conf_path.name} to '{name}' by secure means") + print(" (encrypted email, USB drive, etc.).") + print(" 2. The recipient imports it into their WireGuard app.") + print(f" 3. Delete {conf_path} from this server once transferred.") + print() + print(" WARNING: This file contains the peer's private key.") + print(" Do not leave it on this server longer than necessary.") + print("=" * 68) + print() + print(" To apply changes to WireGuard, run:") + print(" sudo python3 vpn.py --apply") + print() + +# ------------------------------------------------------------------------------ +# --list-peers +# ------------------------------------------------------------------------------ + +def cmd_list_peers(dhcp_data): + check_root() + check_wireguard_tools() + + wg_vlans = wg_interfaces(dhcp_data) + + # -- Collect all peers across all interfaces -------------------------------- + # Each entry: (iface, peer_dict, vlan_dict, peers_list) + all_entries = [] + for vlan in wg_vlans: + iface = vlan["interface"] + peers = load_peers(iface) + for peer in peers: + all_entries.append((iface, peer, vlan, peers)) + + if not all_entries: + print("No peers found across any WireGuard interface.") + return + + # -- List peers ------------------------------------------------------------- + print("Peers:") + for i, (iface, peer, _, _) in enumerate(all_entries, 1): + status = "enabled" if peer.get("enabled") else "disabled" + print(f" {i}. [{iface}] {peer['name']} {peer['ip']} [{status}]") + print() + + while True: + choice = input("Select peer number (or Enter to cancel): ").strip() + if not choice: + return + try: + idx = int(choice) - 1 + if 0 <= idx < len(all_entries): + break + except ValueError: + pass + print(" Invalid selection.") + + iface, peer, vlan, peers = all_entries[idx] + print(f"\nSelected: {peer['name']} on {iface}") + print(" 1. Rename") + print(" 2. Regenerate keys") + print(" 3. Delete") + print(" 4. Cancel") + print() + + action = input("Select action: ").strip() + modified = False + + if action == "1": + # -- Rename ---------------------------------------------------------------- + while True: + new_name = input(f"New name [{peer['name']}]: ").strip() + if not new_name: + print(" Name cannot be empty.") + continue + if any(p["name"] == new_name for p in peers if p is not peer): + print(f" A peer named '{new_name}' already exists for {iface}.") + continue + break + + old_conf = SCRIPT_DIR / f"vpn-client-{peer['name']}.conf" + new_conf = SCRIPT_DIR / f"vpn-client-{new_name}.conf" + if old_conf.exists(): + old_conf.rename(new_conf) + print(f" Renamed client config: {old_conf.name} -> {new_conf.name}") + + peer["name"] = new_name + save_peers(iface, peers) + print(f"Peer renamed to '{new_name}'.") + modified = True + + elif action == "2": + # -- Regenerate keys ------------------------------------------------------- + kf = server_key_path(iface) + if not kf.exists(): + die(f"Server private key not found at {kf}. Run --apply first.") + + endpoint = resolve_endpoint(vpi(vlan)["listen_port"]) + print(f"\nRegenerating keypair for '{peer['name']}'...") + private_key, public_key = generate_peer_keypair() + server_public_key = get_server_public_key(iface) + + if peer.get("split_tunnel", True): + allowed_ips = ", ".join(split_tunnel_routes(dhcp_data)) + else: + allowed_ips = "0.0.0.0/0" + + info = vpi(vlan) + gateway = info["gateway"] + dns = info.get("explicit_overrides", {}).get("dns_server", "") or gateway + domain = info.get("domain", "") + mtu_override = info.get("explicit_overrides", {}).get("mtu", "") + mtu = int(mtu_override) if mtu_override else sense_mtu(dhcp_data) + + peer["public_key"] = public_key + save_peers(iface, peers) + + conf_path = write_client_conf(peer, private_key, server_public_key, + endpoint, allowed_ips, dns, domain, mtu) + + private_key = "0" * len(private_key) + del private_key + + print() + print("=" * 68) + print(f" New client config written: {conf_path}") + print() + print(" WARNING: This file contains the peer's private key.") + print(" The previous client config is now invalid.") + print(" Transfer the new config and delete it from this server.") + print("=" * 68) + modified = True + + elif action == "3": + # -- Delete ---------------------------------------------------------------- + confirm = input(f"\nDelete peer '{peer['name']}' from {iface}? [y/N]: ").strip().lower() + if confirm != "y": + print("Cancelled.") + return + + peers[:] = [p for p in peers if p["name"] != peer["name"]] + save_peers(iface, peers) + + conf_path = SCRIPT_DIR / f"vpn-client-{peer['name']}.conf" + if conf_path.exists(): + print(f" NOTE: Client config {conf_path.name} still exists on this server.") + print(f" It is now invalid and should be deleted.") + + print(f"Peer '{peer['name']}' deleted from {iface}.") + modified = True + + elif action == "4": + print("Cancelled.") + return + + else: + die("Invalid action.") + + if modified: + print() + print(" To apply changes to WireGuard, run:") + print(" sudo python3 vpn.py --apply") + print() + +# ------------------------------------------------------------------------------ +# --apply +# ------------------------------------------------------------------------------ + +def cmd_apply(dhcp_data): + check_root() + check_wireguard_tools() + + wg_vlans = wg_interfaces(dhcp_data) + + for vlan in wg_vlans: + iface = vlan["interface"] + print(f"-- {iface} " + "-" * 58) + + peers = load_peers(iface) + + # -- Ensure server key ----------------------------------------------- + kf = server_key_path(iface) + if not kf.exists(): + print(f" Generating server private key for {iface}...") + generate_server_key(iface) + else: + print(f" Using existing server key: {kf}") + + server_private_key = kf.read_text().strip() + server_public_key = get_server_public_key(iface) + + # -- Write wg conf --------------------------------------------------- + WG_DIR.mkdir(exist_ok=True) + conf_file = wg_conf_path(iface) + new_conf = build_wg_conf(vlan, peers, server_private_key) + listen_port = vpi(vlan)["listen_port"] + + # Detect whether the listen port has changed in the existing conf + port_changed = False + if conf_file.exists(): + for line in conf_file.read_text().splitlines(): + if line.startswith("ListenPort"): + old_port = line.split("=")[1].strip() + port_changed = old_port != str(listen_port) + break + + conf_file.write_text(new_conf) + conf_file.chmod(0o600) + print(f" Written: {conf_file}") + + # -- Start or sync service ------------------------------------------- + svc = f"wg-quick@{iface}" + result = run(["systemctl", "is-active", svc], check=False) + + if result.stdout.strip() == "active": + if port_changed: + print(f" Listen port changed -- restarting {svc}...") + result2 = run(["systemctl", "restart", svc], check=False) + if result2.returncode != 0: + die(f"Failed to restart {svc}:\n{result2.stderr.strip()}") + print(f" Service {svc} restarted.") + else: + print(f" Service {svc} is active -- syncing peers live...") + sync_peers_live(iface, peers) + else: + result2 = run(["systemctl", "enable", "--now", svc], check=False) + if result2.returncode != 0: + result3 = run(["systemctl", "reload-or-restart", svc], check=False) + if result3.returncode != 0: + die(f"Failed to start {svc}:\n{result3.stderr.strip()}") + print(f" Service {svc} enabled and started.") + + # -- Summary --------------------------------------------------------- + enabled_peers = [p for p in peers if p.get("enabled") is True] + print(f" Server public key: {server_public_key}") + print(f" Listen port: UDP {vpi(vlan)['listen_port']}") + print(f" Enabled peers: {len(enabled_peers)}") + for p in enabled_peers: + print(f" {p['ip']:<22} {p['name']}") + print() + + # -- Apply core config to pick up VPN firewall rules --------------------- + core_py = SCRIPT_DIR / "core.py" + if core_py.exists(): + print("-- Applying core config (core.py --apply) ----------------------------") + result = subprocess.run( + [sys.executable, str(core_py), "--apply"], + capture_output=False + ) + if result.returncode != 0: + print("WARNING: core.py --apply returned non-zero. Check output above.") + else: + print(f"WARNING: {core_py} not found -- run core.py --apply manually to load VPN firewall rules.") + +# ------------------------------------------------------------------------------ +# --disable +# ------------------------------------------------------------------------------ + +def cmd_disable(dhcp_data): + check_root() + wg_vlans = wg_interfaces(dhcp_data) + + for vlan in wg_vlans: + iface = vlan["interface"] + svc = f"wg-quick@{iface}" + result = run(["systemctl", "disable", "--now", svc], check=False) + if result.returncode != 0: + print(f"WARNING: {svc} may not have been running:\n{result.stderr.strip()}") + else: + print(f"WireGuard service {svc} stopped and disabled.") + +# ------------------------------------------------------------------------------ +# --status +# ------------------------------------------------------------------------------ + +def cmd_status(dhcp_data): + check_root() + wg_vlans = wg_interfaces(dhcp_data) + + print(f" {'UNIT':<45} {'ACTIVE':<12} {'ENABLED'}") + print(f" {'-'*45} {'-'*12} {'-'*10}") + + for vlan in wg_vlans: + iface = vlan["interface"] + svc = f"wg-quick@{iface}" + + r_active = run(["systemctl", "is-active", svc], check=False) + r_enabled = run(["systemctl", "is-enabled", svc], check=False) + active = r_active.stdout.strip() + enabled = r_enabled.stdout.strip() + active_sym = "✓" if active == "active" else "✗" + enabled_sym = "✓" if enabled == "enabled" else "✗" + print(f" {svc:<45} {active_sym} {active:<10} {enabled_sym} {enabled}") + + if active == "active": + result = run(["wg", "show", iface], check=False) + if result.returncode == 0: + info = {} + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith("public key:"): + info["pubkey"] = line.split(":", 1)[1].strip() + elif line.startswith("listening port:"): + info["port"] = line.split(":", 1)[1].strip() + elif line.startswith("peer:"): + info.setdefault("peers", 0) + info["peers"] += 1 + if "pubkey" in info: + print(f" public key: {info['pubkey']}") + if "port" in info: + print(f" listening port: {info['port']}") + peers = load_peers(iface) + enabled_peers = [p for p in peers if p.get("enabled") is True] + print(f" peers: {len(enabled_peers)} configured, {info.get('peers', 0)} connected") + +# ------------------------------------------------------------------------------ +# --logs +# ------------------------------------------------------------------------------ + +def cmd_logs(dhcp_data): + check_root() + wg_vlans = wg_interfaces(dhcp_data) + + now = datetime.now(timezone.utc) + + for vlan in wg_vlans: + iface = vlan["interface"] + peers = load_peers(iface) + peer_by_key = {p["public_key"]: p["name"] for p in peers} + + print(f"-- {iface} " + "-" * 58) + + result = run(["wg", "show", iface, "dump"], check=False) + if result.returncode != 0: + print(f" WireGuard interface '{iface}' is not up.") + print() + continue + + lines = result.stdout.strip().splitlines() + peer_lines = lines[1:] # first line is the server interface itself + + if not peer_lines: + print(" No peers currently configured.") + print() + continue + + print(f" {'PEER':<22} {'IP':<20} {'ENDPOINT':<26} {'LAST HANDSHAKE':<22} {'RX':<12} {'TX'}") + print(" " + "-" * 106) + + for ln in peer_lines: + parts = ln.split("\t") + if len(parts) < 6: + continue + pub_key = parts[0] + endpoint = parts[2] if parts[2] != "(none)" else "not connected" + allowed_ips = parts[3] + last_hs_ts = parts[4] + rx_bytes = int(parts[5]) + tx_bytes = int(parts[6]) if len(parts) > 6 else 0 + + name = peer_by_key.get(pub_key, pub_key[:12] + "...") + + if last_hs_ts == "0": + last_hs = "never" + else: + ts = int(last_hs_ts) + hs_dt = datetime.fromtimestamp(ts, tz=timezone.utc) + delta = now - hs_dt + seconds = int(delta.total_seconds()) + if seconds < 60: + last_hs = f"{seconds}s ago" + elif seconds < 3600: + last_hs = f"{seconds // 60}m ago" + elif seconds < 86400: + last_hs = f"{seconds // 3600}h ago" + else: + last_hs = f"{seconds // 86400}d ago" + + rx_str = _fmt_bytes(rx_bytes) + tx_str = _fmt_bytes(tx_bytes) + display_ip = allowed_ips.split(",")[0].strip() + + print(f" {name:<22} {display_ip:<20} {endpoint:<26} {last_hs:<22} {rx_str:<12} {tx_str}") + + print() + +# ------------------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------------------ + +def main(): + parser = argparse.ArgumentParser( + description="Manage WireGuard VPN server and peers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "examples:\n" + " sudo python3 vpn.py --add-peer Add a new peer interactively\n" + " sudo python3 vpn.py --manage-peers List and manage existing peers\n" + " sudo python3 vpn.py --apply Write WireGuard config and sync peers\n" + " sudo python3 vpn.py --disable Stop WireGuard on all interfaces\n" + " sudo python3 vpn.py --status Show WireGuard service and interface status\n" + " sudo python3 vpn.py --view-peers Show per-peer handshake and traffic stats\n" + ) + ) + parser.add_argument("--add-peer", action="store_true", + help="Add a new peer interactively") + parser.add_argument("--manage-peers", action="store_true", + help="List and manage existing peers") + parser.add_argument("--apply", action="store_true", + help="Write WireGuard config and sync peers") + parser.add_argument("--disable", action="store_true", + help="Stop WireGuard on all interfaces") + parser.add_argument("--status", action="store_true", + help="Show WireGuard service and interface status") + parser.add_argument("--view-peers", action="store_true", + help="Show per-peer handshake and traffic stats") + + args = parser.parse_args() + + if not any([args.add_peer, args.manage_peers, args.apply, + args.disable, args.status, args.view_peers]): + parser.print_help() + sys.exit(0) + + dhcp_data = load_dhcp() + wg_vlans = wg_interfaces(dhcp_data) + if not wg_vlans: + die("No WireGuard interfaces (wg*) found in core.json.") + validate_wg_vlans(wg_vlans) + + if args.add_peer: + cmd_add_peer(dhcp_data) + elif args.manage_peers: + cmd_list_peers(dhcp_data) + elif args.apply: + cmd_apply(dhcp_data) + elif args.disable: + cmd_disable(dhcp_data) + elif args.status: + cmd_status(dhcp_data) + elif args.view_peers: + cmd_logs(dhcp_data) + +if __name__ == "__main__": + main()