Initial commit

This commit is contained in:
Matthew Grotke 2026-04-09 23:50:42 -04:00
commit 9c043ae30c
6 changed files with 5655 additions and 0 deletions

345
README.md Normal file
View file

@ -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 `<gateway IP>: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-<n>.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
```

250
core.json Normal file
View file

@ -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"]
}
}

3477
core.py Normal file

File diff suppressed because it is too large Load diff

52
ddns.json Normal file
View file

@ -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/"
]
}
]
}

530
ddns.py Normal file
View file

@ -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-<description>.
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:
<html>...<body>Current IP Address: 1.2.3.4</body></html>
"""
# 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 <ip> -- update successful
nochg <ip> -- 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()

1001
vpn.py Normal file

File diff suppressed because it is too large Load diff