Development
This commit is contained in:
parent
8766c6c9a2
commit
ee31a18ac6
43 changed files with 54 additions and 48 deletions
1
routlin/DESCRIPTION.txt
Normal file
1
routlin/DESCRIPTION.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Turn any Linux machine with 2 NICs into an enterprise-grade router and firewall. Ditch vendor gated appliances and opaque firmware while keeping your machine fully multipurpose and under your control. Easily manage VLANs, NAT, DNS, DHCP, VPNs, RADIUS, mDNS, and content filtering through a modern interface built on battle-tested Linux tools like dnsmasq, nftables, systemd-networkd, FreeRADIUS, and WireGuard. Designed to integrate seamlessly with existing enterprise and prosumer networking hardware.
|
||||
371
routlin/README.md
Normal file
371
routlin/README.md
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
# 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
|
||||
- Auto-detects active container bridge interfaces and adds DNS listening on each bridge IP, so containers can reach the local DNS resolver during builds and at runtime (container services, e.g. Docker, Podman, must be running at the time of `--apply`)
|
||||
- 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: WireGuard VPN (managed by `core.py` and the dashboard)
|
||||
|
||||
- Supports any number of WireGuard interfaces defined in `core.json` (any VLAN with an interface name starting with `wg`)
|
||||
- `core.py --apply` generates the server keypair on first run, writes the server conf to `/etc/wireguard/`, and brings the interface up with `wg-quick`. Subsequent applies sync peer changes live without restarting the interface
|
||||
- Peer management is done through the router dashboard: add a peer, set its IP and tunnel mode, and the dashboard generates and downloads the ready-to-import client `.conf` file immediately — the private key is never stored
|
||||
- Peer data (name, IP, public key, enabled state) is stored directly in `core.json` alongside the rest of the network config
|
||||
- Supports per-peer choice of split-tunnel (VPN subnet only) or full-tunnel (all traffic) routing
|
||||
- Reports active peer connections, handshake times, and RX/TX byte counts on the dashboard VPN view
|
||||
|
||||
### Optional: DDNS (`ddns.py`)
|
||||
|
||||
- Detects the current public IP by rotating through multiple IP-check services
|
||||
- Updates the specified DNS providers (currently supporting Cloudflare, 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`) | `core.py` (when WireGuard VLANs are configured) |
|
||||
|
||||
---
|
||||
|
||||
## 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; replaced by `chrony`. Disabled on `--apply`; 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 settings and peers, 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. |
|
||||
| `.wg-<iface>.pub` | WireGuard server public key per interface (e.g. `.wg-wg0.pub`). Written by `core.py --apply`; read by the dashboard to embed in client config downloads. |
|
||||
| `.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_information.explicit_overrides`.
|
||||
- Set `subnet` and `subnet_mask` at the top level of the VLAN object
|
||||
- Set `dhcp_information` fields: 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_information` and `server_identities`, and a `peers` array instead of `reservations`. Peer management (add, edit, regenerate conf, delete) is done through the dashboard:
|
||||
|
||||
```json
|
||||
{
|
||||
"vlan_id": 40,
|
||||
"name": "vpn",
|
||||
"interface": "wg0",
|
||||
"subnet": "192.168.40.0",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"radius_default": false,
|
||||
"use_blocklists": ["oisd-big"],
|
||||
"server_identities": [
|
||||
{ "description": "Router/Gateway", "ip": "192.168.40.1" }
|
||||
],
|
||||
"vpn_information": {
|
||||
"listen_port": 51820,
|
||||
"server_endpoint": "vpn.example.com",
|
||||
"domain": "local",
|
||||
"explicit_overrides": { "gateway": "", "dns_server": "", "mtu": "" }
|
||||
},
|
||||
"peers": [],
|
||||
"port_wrangling": [...]
|
||||
}
|
||||
```
|
||||
|
||||
The gateway IP is derived from the `server_identities` entry with the lowest value in the last octet (same rule as non-WG VLANs). If `explicit_overrides.gateway` is set, it must match one of the `server_identities` IPs.
|
||||
|
||||
### 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 python3 core.py --install # Check and install required packages
|
||||
sudo python3 core.py --apply # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers
|
||||
sudo python3 core.py --update-blocklists # Download and apply blocklists
|
||||
```
|
||||
|
||||
Optional (if DDNS is desired):
|
||||
|
||||
```bash
|
||||
sudo python3 ddns.py --start # Run an immediate IP update and install the update timer
|
||||
```
|
||||
|
||||
Optional (if WireGuard VPN is desired):
|
||||
|
||||
1. Add a WireGuard VLAN to `core.json` with `interface: "wg0"` (see configuration example above)
|
||||
2. Run `sudo python3 core.py --apply` — this generates the server keypair, writes `/etc/wireguard/wg0.conf`, and brings the interface up
|
||||
3. Add peers using one of the two methods below, then run `sudo python3 core.py --apply` again to sync them to the live interface
|
||||
|
||||
**With the router dashboard:**
|
||||
Open the VPN view, fill in the Server Endpoint (your public hostname or IP), and add peers — each peer triggers an immediate `.conf` file download ready to import into any WireGuard client.
|
||||
|
||||
**Without the dashboard (`create_vpn_peer.py`):**
|
||||
|
||||
```bash
|
||||
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2
|
||||
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --iface wg0
|
||||
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --vlan-id 40
|
||||
python3 create_vpn_peer.py --name phone --ip 192.168.40.3 --split-tunnel
|
||||
python3 create_vpn_peer.py --name tablet --ip 192.168.40.4 --output ~/tablet.conf
|
||||
```
|
||||
|
||||
The script reads the specified WireGuard VLAN from `core.json`, validates the IP against the VLAN subnet, generates a keypair, appends the peer to `core.json`, and writes the client `.conf` file. If the config has exactly one WireGuard VLAN, `--iface` and `--vlan-id` are optional. Transfer the `.conf` to the peer device by secure means, then delete it from the 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 python3 core.py --install # Check and interactively install required packages
|
||||
sudo python3 core.py --apply # Apply full config: networkd, dnsmasq, nftables, RADIUS, mDNS, timer, boot service
|
||||
sudo python3 core.py --apply --dry-run # Preview --apply actions without making changes
|
||||
sudo python3 core.py --update-blocklists # Download and merge blocklists, then --apply
|
||||
sudo python3 core.py --disable # Revert to network client (interactive wizard)
|
||||
sudo python3 core.py --disable --dry-run # Preview --disable wizard without making changes
|
||||
sudo python3 core.py --reset-leases # Stop dnsmasq, delete all lease files, restart (forces devices to re-acquire)
|
||||
sudo python3 core.py --reset-leases VLAN # Reset leases for a specific VLAN only (e.g. trusted, iot, guest)
|
||||
|
||||
python3 core.py --status # Per-VLAN dnsmasq, freeradius, avahi-daemon, timer, and boot service status
|
||||
python3 core.py --view-configs # Active per-VLAN dnsmasq config files
|
||||
python3 core.py --view-leases # Active DHCP leases across all VLANs with VLAN, type, and description
|
||||
python3 core.py --view-rules # Active nftables ruleset
|
||||
python3 core.py --view-metrics # Lifetime DNS metrics across all VLAN instances
|
||||
```
|
||||
|
||||
### create_vpn_peer.py
|
||||
|
||||
Does not require `sudo`. Requires `wireguard-tools` (`wg` must be on PATH) and a prior `core.py --apply` to generate the server keypair.
|
||||
|
||||
```
|
||||
python3 create_vpn_peer.py --name NAME --ip IP [--iface IFACE | --vlan-id ID] [--split-tunnel] [--output FILE]
|
||||
|
||||
--name NAME Peer name (e.g. laptop)
|
||||
--ip IP Peer IP within the VPN subnet (e.g. 192.168.40.2)
|
||||
--iface IFACE WireGuard interface to add the peer to (e.g. wg0)
|
||||
--vlan-id ID VLAN ID of the WireGuard VLAN (e.g. 40); alternative to --iface
|
||||
--split-tunnel Route only VPN subnet traffic through the tunnel (default: full tunnel)
|
||||
--output FILE Output path for the client .conf file (default: vpn-client-<name>.conf)
|
||||
```
|
||||
|
||||
`--iface` and `--vlan-id` are mutually exclusive. Both are optional when the config contains exactly one WireGuard VLAN.
|
||||
|
||||
### ddns.py
|
||||
|
||||
Only `--start` and `--disable` require `sudo` as they install/remove systemd timer files. All other commands run as a normal user.
|
||||
|
||||
```
|
||||
sudo python3 ddns.py --start # Run update and install systemd timer
|
||||
sudo python3 ddns.py --disable # Stop updates and remove systemd timer
|
||||
|
||||
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
|
||||
python3 ddns.py --force # Force update regardless of cached IP
|
||||
python3 ddns.py --status # Timer/service status
|
||||
python3 ddns.py --getip # Print current public IP and exit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Disabling / Uninstalling Components
|
||||
|
||||
```bash
|
||||
sudo python3 core.py --disable # Revert to network client (interactive wizard)
|
||||
sudo python3 ddns.py --disable # Stop and remove DDNS timer
|
||||
```
|
||||
|
||||
WireGuard interfaces are brought down automatically by `core.py --disable`. To stop a WireGuard interface independently: `sudo wg-quick down wg0`.
|
||||
686
routlin/core.json
Normal file
686
routlin/core.json
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
{
|
||||
"general": {
|
||||
"wan_interface": "eno2",
|
||||
"lan_interface": "enp6s0",
|
||||
"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)",
|
||||
"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",
|
||||
"subnet": "192.168.1.0",
|
||||
"subnet_mask": 24,
|
||||
"radius_default": false,
|
||||
"mdns_reflection": 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_information": {
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"is_vpn": false
|
||||
},
|
||||
{
|
||||
"vlan_id": 10,
|
||||
"name": "iot",
|
||||
"subnet": "192.168.10.0",
|
||||
"subnet_mask": 24,
|
||||
"radius_default": false,
|
||||
"mdns_reflection": true,
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light"
|
||||
],
|
||||
"server_identities": [
|
||||
{
|
||||
"description": "Router/Gateway",
|
||||
"ip": "192.168.10.1"
|
||||
}
|
||||
],
|
||||
"dhcp_information": {
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"is_vpn": false
|
||||
},
|
||||
{
|
||||
"vlan_id": 20,
|
||||
"name": "guest",
|
||||
"subnet": "192.168.20.0",
|
||||
"subnet_mask": 24,
|
||||
"radius_default": true,
|
||||
"mdns_reflection": true,
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light"
|
||||
],
|
||||
"server_identities": [
|
||||
{
|
||||
"description": "Router/Gateway",
|
||||
"ip": "192.168.20.1"
|
||||
}
|
||||
],
|
||||
"dhcp_information": {
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"is_vpn": false
|
||||
},
|
||||
{
|
||||
"vlan_id": 30,
|
||||
"name": "kids",
|
||||
"subnet": "192.168.30.0",
|
||||
"subnet_mask": 24,
|
||||
"radius_default": false,
|
||||
"mdns_reflection": true,
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light",
|
||||
"hagezi-pro-plus"
|
||||
],
|
||||
"server_identities": [
|
||||
{
|
||||
"description": "Router/Gateway",
|
||||
"ip": "192.168.30.1"
|
||||
}
|
||||
],
|
||||
"dhcp_information": {
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"is_vpn": false
|
||||
},
|
||||
{
|
||||
"vlan_id": 40,
|
||||
"name": "vpn",
|
||||
"subnet": "192.168.40.0",
|
||||
"subnet_mask": 24,
|
||||
"radius_default": false,
|
||||
"mdns_reflection": false,
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light"
|
||||
],
|
||||
"server_identities": [
|
||||
{
|
||||
"description": "Router/Gateway",
|
||||
"ip": "192.168.40.1"
|
||||
}
|
||||
],
|
||||
"vpn_information": {
|
||||
"listen_port": 51820,
|
||||
"server_endpoint": "",
|
||||
"domain": "local",
|
||||
"explicit_overrides": {
|
||||
"gateway": "",
|
||||
"dns_server": "",
|
||||
"mtu": ""
|
||||
}
|
||||
},
|
||||
"peers": [],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"is_vpn": true
|
||||
}
|
||||
]
|
||||
}
|
||||
3336
routlin/core.py
Normal file
3336
routlin/core.py
Normal file
File diff suppressed because it is too large
Load diff
229
routlin/create_vpn_peer.py
Normal file
229
routlin/create_vpn_peer.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
create_vpn_peer.py -- Add a WireGuard peer to core.json and write the client .conf file.
|
||||
|
||||
Generates a fresh keypair, appends the peer to the specified WireGuard VLAN in core.json,
|
||||
and saves a ready-to-import client config file.
|
||||
|
||||
Use --iface or --vlan-id to select the target VLAN. If the config contains exactly one
|
||||
WireGuard VLAN, both flags are optional and it is selected automatically.
|
||||
|
||||
Run core.py --apply after adding peers to sync the changes to the live interface.
|
||||
|
||||
Usage:
|
||||
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2
|
||||
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --iface wg0
|
||||
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --vlan-id 40
|
||||
python3 create_vpn_peer.py --name phone --ip 192.168.40.3 --split-tunnel
|
||||
python3 create_vpn_peer.py --name tablet --ip 192.168.40.4 --output ~/tablet.conf
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
CONFIG_FILE = SCRIPT_DIR / "core.json"
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_config():
|
||||
if not CONFIG_FILE.exists():
|
||||
die(f"Config file not found: {CONFIG_FILE}")
|
||||
with open(CONFIG_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_config(data):
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def resolve_wg_iface(vlan, data):
|
||||
"""Return wg0, wg1, ... based on position among is_vpn VLANs."""
|
||||
wg_vlans = [v for v in data.get("vlans", []) if v.get("is_vpn")]
|
||||
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
|
||||
return f"wg{idx}"
|
||||
|
||||
|
||||
def find_wg_vlan(data, iface=None, vlan_id=None):
|
||||
"""Return the target WireGuard VLAN, or die with a helpful message."""
|
||||
wg_vlans = [v for v in data.get("vlans", []) if v.get("is_vpn")]
|
||||
|
||||
if iface is not None:
|
||||
vlan = next((v for v in wg_vlans if resolve_wg_iface(v, data) == iface), None)
|
||||
if vlan is None:
|
||||
known = ", ".join(resolve_wg_iface(v, data) for v in wg_vlans) or "none"
|
||||
die(f"No WireGuard VLAN with interface '{iface}' found in core.json. "
|
||||
f"Known WireGuard interfaces: {known}.")
|
||||
return vlan
|
||||
|
||||
if vlan_id is not None:
|
||||
vlan = next((v for v in wg_vlans if v.get("vlan_id") == vlan_id), None)
|
||||
if vlan is None:
|
||||
known = ", ".join(
|
||||
f"{v['vlan_id']} ({resolve_wg_iface(v, data)})" for v in wg_vlans
|
||||
) or "none"
|
||||
die(f"No WireGuard VLAN with vlan_id {vlan_id} found in core.json. "
|
||||
f"Known WireGuard VLANs: {known}.")
|
||||
return vlan
|
||||
|
||||
if not wg_vlans:
|
||||
die("No WireGuard VLANs found in core.json. "
|
||||
"Add a VLAN with is_vpn set to true.")
|
||||
if len(wg_vlans) > 1:
|
||||
options = " " + "\n ".join(
|
||||
f"--iface {resolve_wg_iface(v, data)} or --vlan-id {v['vlan_id']} ({v.get('name', '?')})"
|
||||
for v in wg_vlans
|
||||
)
|
||||
die(f"Multiple WireGuard VLANs found. Specify one:\n{options}")
|
||||
return wg_vlans[0]
|
||||
|
||||
|
||||
def server_pubkey(iface):
|
||||
path = SCRIPT_DIR / f".wg-{iface}.pub"
|
||||
if not path.exists():
|
||||
die(
|
||||
f"Server public key not found: {path}\n"
|
||||
f"Run 'sudo python3 core.py --apply' first to generate the server keypair."
|
||||
)
|
||||
return path.read_text().strip()
|
||||
|
||||
|
||||
def generate_keypair():
|
||||
try:
|
||||
private = subprocess.run(
|
||||
["wg", "genkey"], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
public = subprocess.run(
|
||||
["wg", "pubkey"], input=private, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
return private, public
|
||||
except FileNotFoundError:
|
||||
die("'wg' not found. Install wireguard-tools: sudo apt install wireguard-tools")
|
||||
except subprocess.CalledProcessError as e:
|
||||
die(f"Key generation failed: {e.stderr.strip()}")
|
||||
|
||||
|
||||
def build_client_conf(vlan, peer_ip, private_key, server_pub, split_tunnel):
|
||||
info = vlan.get("vpn_information", {})
|
||||
overrides = info.get("explicit_overrides", {})
|
||||
subnet = vlan["subnet"]
|
||||
mask = vlan["subnet_mask"]
|
||||
network = ipaddress.IPv4Network(f"{subnet}/{mask}", strict=False)
|
||||
ident_ips = [s["ip"] for s in vlan.get("server_identities", []) if s.get("ip")]
|
||||
default = str(min((ipaddress.IPv4Address(ip) for ip in ident_ips),
|
||||
key=lambda x: x.packed[-1])) if ident_ips else str(next(network.hosts()))
|
||||
gateway = overrides.get("gateway") or default
|
||||
dns = overrides.get("dns_server") or gateway
|
||||
prefix = network.prefixlen
|
||||
mtu = overrides.get("mtu", "")
|
||||
endpoint = info.get("server_endpoint", "")
|
||||
listen_port = info.get("listen_port", 51820)
|
||||
|
||||
allowed_ips = f"{subnet}/{prefix}" if split_tunnel else "0.0.0.0/0"
|
||||
|
||||
lines = [
|
||||
"# Generated by create_vpn_peer.py",
|
||||
"",
|
||||
"[Interface]",
|
||||
f"PrivateKey = {private_key}",
|
||||
f"Address = {peer_ip}/{prefix}",
|
||||
f"DNS = {dns}",
|
||||
]
|
||||
if mtu:
|
||||
lines.append(f"MTU = {mtu}")
|
||||
lines += ["", "[Peer]", f"PublicKey = {server_pub}"]
|
||||
if endpoint:
|
||||
lines.append(f"Endpoint = {endpoint}:{listen_port}")
|
||||
lines += [f"AllowedIPs = {allowed_ips}", "PersistentKeepalive = 25", ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add a WireGuard peer to core.json and write the client .conf file."
|
||||
)
|
||||
parser.add_argument("--name", required=True, help="Peer name (e.g. laptop)")
|
||||
parser.add_argument("--ip", required=True, help="Peer IP within the VPN subnet (e.g. 192.168.40.2)")
|
||||
parser.add_argument("--split-tunnel", action="store_true",
|
||||
help="Route only VPN subnet traffic through the tunnel (default: full tunnel)")
|
||||
parser.add_argument("--output", default=None,
|
||||
help="Output path for the client .conf file (default: vpn-client-<name>.conf)")
|
||||
|
||||
sel = parser.add_mutually_exclusive_group()
|
||||
sel.add_argument("--iface", default=None, metavar="IFACE",
|
||||
help="WireGuard interface to add the peer to (e.g. wg0)")
|
||||
sel.add_argument("--vlan-id", default=None, type=int, metavar="ID",
|
||||
help="VLAN ID of the WireGuard VLAN to add the peer to (e.g. 40)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# -- Validate IP -----------------------------------------------------------
|
||||
try:
|
||||
peer_ip = str(ipaddress.IPv4Address(args.ip))
|
||||
except ValueError:
|
||||
die(f"'{args.ip}' is not a valid IPv4 address.")
|
||||
|
||||
# -- Load config and find WG VLAN ------------------------------------------
|
||||
data = load_config()
|
||||
vlan = find_wg_vlan(data, iface=args.iface, vlan_id=args.vlan_id)
|
||||
|
||||
iface = resolve_wg_iface(vlan, data)
|
||||
|
||||
# -- Validate peer IP is within subnet -------------------------------------
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
|
||||
except (KeyError, ValueError) as e:
|
||||
die(f"Invalid subnet in WireGuard VLAN: {e}")
|
||||
|
||||
if ipaddress.IPv4Address(peer_ip) not in network:
|
||||
die(f"IP {peer_ip} is not within the VPN subnet {network}.")
|
||||
|
||||
# -- Check for duplicates --------------------------------------------------
|
||||
peers = vlan.setdefault("peers", [])
|
||||
if any(p.get("name") == args.name for p in peers):
|
||||
die(f"A peer named '{args.name}' already exists.")
|
||||
if any(p.get("ip") == peer_ip for p in peers):
|
||||
die(f"IP {peer_ip} is already assigned to another peer.")
|
||||
|
||||
# -- Generate keypair and read server public key ---------------------------
|
||||
print(f"Generating keypair for '{args.name}'...")
|
||||
private_key, public_key = generate_keypair()
|
||||
srv_pub = server_pubkey(iface)
|
||||
|
||||
# -- Update core.json ------------------------------------------------------
|
||||
peers.append({
|
||||
"name": args.name,
|
||||
"ip": peer_ip,
|
||||
"public_key": public_key,
|
||||
"split_tunnel": args.split_tunnel,
|
||||
"enabled": True,
|
||||
})
|
||||
save_config(data)
|
||||
print(f"Added peer '{args.name}' to core.json.")
|
||||
|
||||
# -- Write client conf -----------------------------------------------------
|
||||
conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel)
|
||||
if args.output:
|
||||
out_path = Path(args.output)
|
||||
else:
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in args.name)
|
||||
out_path = SCRIPT_DIR / f"vpn-client-{safe}.conf"
|
||||
|
||||
out_path.write_text(conf_content)
|
||||
print(f"Client config saved: {out_path}")
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(f" 1. Transfer {out_path.name} to the peer device by secure means, then delete it.")
|
||||
print(f" 2. Run 'sudo python3 core.py --apply' to sync the new peer to the live interface.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
56
routlin/ddns.json
Normal file
56
routlin/ddns.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"general": {
|
||||
"log_max_kb": 1024,
|
||||
"log_errors_only": false,
|
||||
"ip_check_services": [
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
"https://api4.my-ip.io/ip",
|
||||
"https://ipv4.icanhazip.com",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://1.1.1.1/cdn-cgi/trace",
|
||||
"cf-dns:myip.cloudflare",
|
||||
"https://ipinfo.io/ip",
|
||||
"https://ipecho.net/plain",
|
||||
"https://ident.me",
|
||||
"https://myip.dnsomatic.com",
|
||||
"https://wtfismyip.com/text"
|
||||
],
|
||||
"timer_interval": "10m"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"description": "No-IP Account",
|
||||
"provider": "noip",
|
||||
"enabled": true,
|
||||
"username": "your-username",
|
||||
"password": "your-password",
|
||||
"hostnames": [
|
||||
"yoursubdomain.ddns.net",
|
||||
"yourothersubdomain.ddns.net"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Cloudflare Account",
|
||||
"provider": "cloudflare",
|
||||
"enabled": true,
|
||||
"api_token": "your-cloudflare-api-token",
|
||||
"hostnames": [
|
||||
"yourdomain.com",
|
||||
"yoursubdomain.yourdomain.com",
|
||||
"yourothersubdomain.yourdomain.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "DuckDNS Account",
|
||||
"provider": "duckdns",
|
||||
"enabled": false,
|
||||
"api_token": "your-duckdns-api-token",
|
||||
"hostnames": [
|
||||
"yoursubdomain.duckdns.org",
|
||||
"yourothersubdomain.duckdns.org"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
654
routlin/ddns.py
Normal file
654
routlin/ddns.py
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
#!/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
|
||||
python3 ddns.py --apply Run update once (used by timer)
|
||||
python3 ddns.py --force Force update regardless of cached IP
|
||||
python3 ddns.py --status Show timer/service status
|
||||
python3 ddns.py --getip Print current public IP and exit
|
||||
"""
|
||||
|
||||
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}", file=sys.stderr)
|
||||
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}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not data["general"]["ip_check_services"]:
|
||||
print("ERROR: ip_check_services list is empty.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate providers block
|
||||
if not data.get("providers"):
|
||||
print("ERROR: No providers defined in config.", file=sys.stderr)
|
||||
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}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
ptype = p.get("provider", "").lower()
|
||||
if ptype == "noip":
|
||||
extra = {"username", "password", "hostnames"}
|
||||
elif ptype == "duckdns":
|
||||
extra = {"api_token", "hostnames"}
|
||||
elif ptype == "cloudflare":
|
||||
extra = {"api_token", "hostnames"}
|
||||
else:
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
missing = extra - set(p.keys())
|
||||
if missing:
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr)
|
||||
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 plain text, key=value format (e.g. Cloudflare /cdn-cgi/trace where
|
||||
the ip= line is the caller's IP while h= is the server's IP), and HTML.
|
||||
"""
|
||||
# Check for key=value format first (e.g. /cdn-cgi/trace)
|
||||
for line in body.splitlines():
|
||||
if line.startswith("ip="):
|
||||
candidate = line[3:].strip()
|
||||
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', candidate):
|
||||
return candidate
|
||||
# Try plain text (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_ip_via_cf_dns(spec):
|
||||
"""Query Cloudflare's myip.cloudflare via DNS TXT (chaos class) for the caller's IP.
|
||||
spec format: 'cf-dns:<hostname>' e.g. 'cf-dns:myip.cloudflare'
|
||||
Requires the 'dig' utility to be installed.
|
||||
"""
|
||||
hostname = spec[len("cf-dns:"):]
|
||||
cmd = ["dig", "+short", "@1.1.1.1", "chaos", "txt", hostname]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
# TXT records come back quoted: "203.0.113.50"
|
||||
ip = result.stdout.strip().strip('"').split()[0]
|
||||
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', ip):
|
||||
return ip
|
||||
except FileNotFoundError:
|
||||
log.warning("'dig' command not found; cannot use cf-dns IP check service.")
|
||||
except Exception:
|
||||
pass
|
||||
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, service in enumerate(ordered):
|
||||
try:
|
||||
if service.startswith("cf-dns:"):
|
||||
ip = _get_ip_via_cf_dns(service)
|
||||
else:
|
||||
req = urllib.request.Request(service, 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 {service}: {ip}")
|
||||
return ip
|
||||
except Exception as e:
|
||||
log.warning(f"IP check failed for {service}: {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["api_token"]
|
||||
subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"])
|
||||
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
|
||||
|
||||
# ===================================================================
|
||||
# Cloudflare DNS update
|
||||
# ===================================================================
|
||||
|
||||
def _cf_api_get(url, headers):
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
log.error(f"Cloudflare API GET error ({url}): {e}")
|
||||
return None
|
||||
|
||||
def _cf_get_zone_id(zone_name, headers):
|
||||
data = _cf_api_get(
|
||||
f"https://api.cloudflare.com/client/v4/zones?name={zone_name}", headers
|
||||
)
|
||||
if data and data.get("success") and data["result"]:
|
||||
return data["result"][0]["id"]
|
||||
return None
|
||||
|
||||
def _cf_get_record_id(zone_id, hostname, headers):
|
||||
data = _cf_api_get(
|
||||
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?name={hostname}&type=A",
|
||||
headers,
|
||||
)
|
||||
if data and data.get("success") and data["result"]:
|
||||
return data["result"][0]["id"]
|
||||
return None
|
||||
|
||||
def update_cloudflare(provider, ip):
|
||||
"""
|
||||
Cloudflare DNS update API.
|
||||
Docs: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
|
||||
Bearer-token auth. Looks up zone and record IDs dynamically, then PATCHes each A record.
|
||||
"""
|
||||
token = provider["api_token"]
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "ddns-update/1.0",
|
||||
}
|
||||
success = True
|
||||
for hostname in provider["hostnames"]:
|
||||
zone_name = ".".join(hostname.split(".")[-2:])
|
||||
zone_id = _cf_get_zone_id(zone_name, headers)
|
||||
if not zone_id:
|
||||
log.error(f"Cloudflare: zone '{zone_name}' not found in account.")
|
||||
success = False
|
||||
continue
|
||||
record_id = _cf_get_record_id(zone_id, hostname, headers)
|
||||
if not record_id:
|
||||
log.error(f"Cloudflare: A record for '{hostname}' not found in zone '{zone_name}'.")
|
||||
success = False
|
||||
continue
|
||||
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}"
|
||||
payload = json.dumps({"content": ip}).encode()
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="PATCH")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
if data.get("success"):
|
||||
log.info(f"Cloudflare updated successfully: {hostname} -> {ip}")
|
||||
else:
|
||||
log.error(f"Cloudflare update failed for '{hostname}': {data.get('errors')}")
|
||||
success = False
|
||||
except Exception as e:
|
||||
log.error(f"Cloudflare API PATCH error for '{hostname}': {e}")
|
||||
success = False
|
||||
return success
|
||||
|
||||
# ===================================================================
|
||||
# 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)
|
||||
elif ptype == "cloudflare":
|
||||
success = update_cloudflare(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, getip_only=False):
|
||||
"""Perform a single DDNS update pass. Called by both timer and --start.
|
||||
If force=True, bypasses the cached IP check and always updates.
|
||||
If getip_only=True, prints the detected public IP and returns without updating providers."""
|
||||
general = cfg["general"]
|
||||
current_ip = get_public_ip(general["ip_check_services"])
|
||||
|
||||
if getip_only:
|
||||
print(current_ip)
|
||||
return
|
||||
|
||||
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"
|
||||
" python3 ddns.py --apply Run update once (used by timer)\n"
|
||||
" python3 ddns.py --force Force update regardless of cached IP\n"
|
||||
" python3 ddns.py --status Show timer/service status\n"
|
||||
" python3 ddns.py --getip Print current public IP and exit\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")
|
||||
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]):
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
if args.status:
|
||||
show_status()
|
||||
return
|
||||
|
||||
if args.getip:
|
||||
global log
|
||||
log = logging.getLogger("ddns_quiet")
|
||||
log.addHandler(logging.NullHandler())
|
||||
log.propagate = False
|
||||
cfg = load_config()
|
||||
run_update(cfg, getip_only=True)
|
||||
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()
|
||||
740
routlin/validation.py
Normal file
740
routlin/validation.py
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
"""
|
||||
validation.py -- Shared structural validators for core.json fields.
|
||||
|
||||
Lives alongside core.py in ~/routlin/ and is volume-mounted into the
|
||||
routlin-dash container at /app/validation.py. Importable by both
|
||||
core.py (router host) and the Flask app directly.
|
||||
|
||||
Convention: primitive validators accept a raw string and return the
|
||||
normalised valid value, or '' / None if the input is invalid.
|
||||
"""
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
|
||||
VALID_PROTOCOLS = {'tcp', 'udp', 'both'}
|
||||
VALID_BLOCKLIST_FORMATS = {'dnsmasq', 'hosts'}
|
||||
VALID_DDNS_PROVIDERS = ('noip', 'cloudflare', 'duckdns')
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# IP / CIDR
|
||||
# ===================================================================
|
||||
|
||||
def ip(value):
|
||||
"""Return value if it is a valid IPv4 or IPv6 address, else ''."""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
return v
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
|
||||
def ip_or_cidr(value):
|
||||
"""Return value if it is a valid IPv4/IPv6 address or CIDR network, else ''."""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
return v
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
ipaddress.ip_network(v, strict=False)
|
||||
return v
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
|
||||
def ipv4(value):
|
||||
"""Return value if it is a valid IPv4 address, else ''."""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
ipaddress.IPv4Address(v)
|
||||
return v
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
|
||||
def ipv4_or_cidr(value):
|
||||
"""Return value if it is a valid IPv4 address or IPv4 CIDR network, else ''."""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
ipaddress.IPv4Address(v)
|
||||
return v
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
ipaddress.IPv4Network(v, strict=False)
|
||||
return v
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Port
|
||||
# ===================================================================
|
||||
|
||||
def port(value):
|
||||
"""Return port as string if valid 1-65535, else ''."""
|
||||
try:
|
||||
p = int(re.sub(r'[^0-9]', '', str(value)))
|
||||
if 1 <= p <= 65535:
|
||||
return str(p)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Integer range
|
||||
# ===================================================================
|
||||
|
||||
def int_range(value, lo, hi):
|
||||
"""Return value as int if it is an integer within [lo, hi], else None.
|
||||
|
||||
lo and hi may be None to indicate no lower or upper bound respectively.
|
||||
"""
|
||||
try:
|
||||
v = int(str(value).strip())
|
||||
if (lo is None or v >= lo) and (hi is None or v <= hi):
|
||||
return v
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Domain name
|
||||
# ===================================================================
|
||||
|
||||
def domainname(value):
|
||||
"""Return value if it is a valid domain name, else ''.
|
||||
|
||||
Rules: labels separated by dots; each label contains only
|
||||
letters, digits, and hyphens; no label may start or end with a
|
||||
hyphen; no consecutive dots; total length <= 253.
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip().lower()
|
||||
if len(v) > 253:
|
||||
return ''
|
||||
if '..' in v or v.startswith('.') or v.endswith('.'):
|
||||
return ''
|
||||
labels = v.split('.')
|
||||
for label in labels:
|
||||
if not label:
|
||||
return ''
|
||||
if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$', label):
|
||||
return ''
|
||||
return v
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Banned-IP pattern
|
||||
# ===================================================================
|
||||
|
||||
def banned_ip(value):
|
||||
"""
|
||||
Return value if it is a valid banned_ip pattern, else ''.
|
||||
|
||||
Accepted formats (mirrors core.py expand_banned_ip):
|
||||
IPv4:
|
||||
Single address 192.0.2.1
|
||||
CIDR 192.0.2.0/24
|
||||
Wildcard octet 192.0.2.*
|
||||
Octet range 192.0.2.10-20
|
||||
(combinations that expand to <=1024 entries are accepted)
|
||||
IPv6:
|
||||
Single address 2001:db8::1
|
||||
CIDR 2001:db8::/32
|
||||
Trailing wildcard 2001:db8:c17:*
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
_check_banned_ip(v)
|
||||
return v
|
||||
except (ValueError, TypeError):
|
||||
return ''
|
||||
|
||||
|
||||
def _check_banned_ip(ip_str):
|
||||
if ':' in ip_str:
|
||||
_check_banned_ipv6(ip_str)
|
||||
else:
|
||||
_check_banned_ipv4(ip_str)
|
||||
|
||||
|
||||
def _check_banned_ipv4(ip_str):
|
||||
if '/' in ip_str:
|
||||
ipaddress.IPv4Network(ip_str, strict=False)
|
||||
return
|
||||
|
||||
parts = ip_str.split('.')
|
||||
if len(parts) != 4:
|
||||
raise ValueError(f"Expected 4 octets: {ip_str!r}")
|
||||
|
||||
def parse_octet(s):
|
||||
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}")
|
||||
return (lo, hi)
|
||||
v = int(s)
|
||||
if not 0 <= v <= 255:
|
||||
raise ValueError(f"Octet {v} out of 0-255")
|
||||
return (v, v)
|
||||
|
||||
ranges = [parse_octet(p) for p in parts]
|
||||
|
||||
trailing = 0
|
||||
for lo, hi in reversed(ranges):
|
||||
if lo == 0 and hi == 255:
|
||||
trailing += 1
|
||||
else:
|
||||
break
|
||||
|
||||
total = 1
|
||||
for lo, hi in ranges[:4 - trailing]:
|
||||
total *= (hi - lo + 1)
|
||||
if total > 1024:
|
||||
raise ValueError(f"Pattern expands to {total} entries (limit 1024); use CIDR")
|
||||
|
||||
|
||||
def _check_banned_ipv6(ip_str):
|
||||
if '/' in ip_str:
|
||||
ipaddress.IPv6Network(ip_str, strict=False)
|
||||
return
|
||||
if '*' not in ip_str:
|
||||
ipaddress.IPv6Address(ip_str)
|
||||
return
|
||||
if not ip_str.endswith(':*'):
|
||||
raise ValueError(f"Unsupported IPv6 wildcard: {ip_str!r}; use 'prefix:*' or CIDR")
|
||||
prefix_part = ip_str[:-2]
|
||||
if '::' in prefix_part:
|
||||
left, right = prefix_part.split('::', 1)
|
||||
lg = [g for g in left.split(':') if g] if left else []
|
||||
rg = [g for g in right.split(':') if g] if right else []
|
||||
zeros = 8 - len(lg) - len(rg) - 1
|
||||
if zeros < 0:
|
||||
raise ValueError(f"Too many groups in {ip_str!r}")
|
||||
groups = lg + ['0000'] * zeros + rg
|
||||
else:
|
||||
groups = [g for g in prefix_part.split(':') if g]
|
||||
if not (1 <= len(groups) <= 7):
|
||||
raise ValueError(f"IPv6 wildcard must have 1-7 prefix groups: {ip_str!r}")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# VLAN / interface helpers (shared with core.py apply logic)
|
||||
# ===================================================================
|
||||
|
||||
def is_wg(vlan):
|
||||
return vlan.get("is_vpn", False)
|
||||
|
||||
|
||||
def is_dynamic_ip(r):
|
||||
"""Return True if a reservation has no pinned IP (DHCP assigns from pool)."""
|
||||
ip = r.get("ip", "dynamic")
|
||||
return ip in ("", "dynamic") or ip is None
|
||||
|
||||
|
||||
def derive_vlan_id(subnet, prefix):
|
||||
"""Return VLAN ID (1-4094) derived from the active octet of the network address, or None."""
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f'{subnet}/{prefix}', strict=False)
|
||||
octets = list(network.network_address.packed)
|
||||
byte_idx = (int(prefix) - 1) // 8
|
||||
vlan_id = octets[byte_idx]
|
||||
if 1 <= vlan_id <= 4094:
|
||||
return vlan_id
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_vlan_derived_fields(data):
|
||||
"""Return a deep copy of data with vlan_id and interface computed for every VLAN.
|
||||
|
||||
WireGuard VLANs are assigned wg0/wg1/... in ascending vlan_id order for
|
||||
deterministic interface naming regardless of JSON list order.
|
||||
Does not mutate the input dict.
|
||||
"""
|
||||
import copy
|
||||
result = copy.deepcopy(data)
|
||||
lan = result.get("general", {}).get("lan_interface", "eth0")
|
||||
vlans = result.get("vlans", [])
|
||||
|
||||
for vlan in vlans:
|
||||
vlan["vlan_id"] = derive_vlan_id(vlan.get("subnet", ""), vlan.get("subnet_mask", 24))
|
||||
|
||||
wg_entries = [(i, v) for i, v in enumerate(vlans) if is_wg(v)]
|
||||
wg_sorted = sorted(wg_entries, key=lambda x: (x[1].get("vlan_id") is None, x[1].get("vlan_id") or 0))
|
||||
for wg_idx, (_, vlan) in enumerate(wg_sorted):
|
||||
vlan["interface"] = f"wg{wg_idx}"
|
||||
|
||||
for vlan in vlans:
|
||||
if not is_wg(vlan):
|
||||
vid = vlan.get("vlan_id", 1)
|
||||
vlan["interface"] = lan if vid == 1 else f"{lan}.{vid}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Full config validation (shared with core.py --apply)
|
||||
# ===================================================================
|
||||
|
||||
def validate_config(data):
|
||||
"""Validate core.json structure and content. Returns list of error strings."""
|
||||
errors = []
|
||||
seen_vlan_ids = {}
|
||||
seen_interfaces = {}
|
||||
seen_names = {}
|
||||
seen_listen_ports = {}
|
||||
|
||||
# Pre-compute per-VLAN vlan_ids and interface names without mutating data
|
||||
_lan = data.get("general", {}).get("lan_interface", "eth0")
|
||||
_all_vlans = data.get("vlans", [])
|
||||
_derived_ids = [
|
||||
derive_vlan_id(_v.get("subnet", ""), _v.get("subnet_mask", 24))
|
||||
for _v in _all_vlans
|
||||
]
|
||||
_wg_sorted = sorted(
|
||||
[(i, _derived_ids[i]) for i, _v in enumerate(_all_vlans) if is_wg(_v)],
|
||||
key=lambda x: (x[1] is None, x[1] or 0)
|
||||
)
|
||||
_wg_order = {orig_i: wg_idx for wg_idx, (orig_i, _) in enumerate(_wg_sorted)}
|
||||
vlan_ifaces = []
|
||||
for i, _vlan in enumerate(_all_vlans):
|
||||
if is_wg(_vlan):
|
||||
vlan_ifaces.append(f"wg{_wg_order[i]}")
|
||||
else:
|
||||
_vid = _derived_ids[i]
|
||||
vlan_ifaces.append(_lan if _vid == 1 else f"{_lan}.{_vid}")
|
||||
|
||||
# -- upstream_dns block ----------------------------------------------------
|
||||
if not data.get("upstream_dns", {}).get("upstream_servers"):
|
||||
errors.append("upstream_dns.upstream_servers is missing or empty.")
|
||||
|
||||
# -- WAN / LAN interfaces --------------------------------------------------
|
||||
gen = data.get("general", {})
|
||||
wan = gen.get("wan_interface", "")
|
||||
lan = gen.get("lan_interface", "")
|
||||
if not wan:
|
||||
errors.append("general.wan_interface is missing or empty.")
|
||||
if not lan:
|
||||
errors.append("general.lan_interface is missing or empty.")
|
||||
if wan and lan:
|
||||
available_interfaces = set()
|
||||
try:
|
||||
available_interfaces = set(os.listdir('/sys/class/net'))
|
||||
except Exception:
|
||||
pass
|
||||
if available_interfaces:
|
||||
if wan not in available_interfaces:
|
||||
errors.append(f"general.wan_interface: '{wan}' does not exist on this system.")
|
||||
if lan not in available_interfaces:
|
||||
errors.append(f"general.lan_interface: '{lan}' does not exist on this system.")
|
||||
if wan == lan:
|
||||
errors.append(f"general.wan_interface and general.lan_interface must be different (both set to '{wan}').")
|
||||
|
||||
# -- 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 VALID_BLOCKLIST_FORMATS:
|
||||
errors.append(f"{label}: format must be one of: {', '.join(sorted(VALID_BLOCKLIST_FORMATS))}.")
|
||||
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 = {} # iface -> IPv4Network (used for NAT section)
|
||||
|
||||
for i, (vlan, iface) in enumerate(zip(_all_vlans, vlan_ifaces)):
|
||||
vlan_id = _derived_ids[i]
|
||||
name = vlan.get("name", "?")
|
||||
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 vlan.get("mdns_reflection") is True and is_wg(vlan):
|
||||
errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.")
|
||||
|
||||
if is_wg(vlan):
|
||||
# -- vpn_information -----------------------------------------------
|
||||
vpi = vlan.get("vpn_information")
|
||||
if not isinstance(vpi, dict):
|
||||
errors.append(f"{label}: vpn_information must be a plain object.")
|
||||
vpi = {}
|
||||
else:
|
||||
lp = vpi.get("listen_port")
|
||||
if int_range(lp, 1, 65535) is None:
|
||||
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
|
||||
|
||||
# -- subnet/subnet_mask --------------------------------------------
|
||||
for field in ("subnet", "subnet_mask"):
|
||||
if not vlan.get(field):
|
||||
errors.append(f"{label}: missing required field '{field}'.")
|
||||
wg_net = None
|
||||
if vlan.get("subnet") and vlan.get("subnet_mask"):
|
||||
try:
|
||||
wg_net = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
|
||||
vlan_networks[iface] = wg_net
|
||||
except ValueError as e:
|
||||
errors.append(f"{label}: invalid subnet/subnet_mask: {e}")
|
||||
|
||||
# -- server_identities ---------------------------------------------
|
||||
if not vlan.get("server_identities"):
|
||||
errors.append(f"{label}: server_identities is empty or missing.")
|
||||
identity_ips = []
|
||||
for idx, ident in enumerate(vlan.get("server_identities", [])):
|
||||
ip_str = ident.get("ip", "")
|
||||
ilabel = f"{label} server_identities[{idx}] '{ident.get('description', '?')}'"
|
||||
if not ip_str:
|
||||
errors.append(f"{ilabel}: missing 'ip' field.")
|
||||
continue
|
||||
if not ipv4(ip_str):
|
||||
errors.append(f"{ilabel}: ip '{ip_str}' is not a valid IPv4 address.")
|
||||
continue
|
||||
ip_addr = ipaddress.IPv4Address(ip_str)
|
||||
if wg_net and ip_addr not in wg_net:
|
||||
errors.append(f"{ilabel}: ip '{ip_str}' is not within subnet {wg_net}.")
|
||||
else:
|
||||
identity_ips.append(ip_addr)
|
||||
|
||||
# -- vpn_information.explicit_overrides ----------------------------
|
||||
eo = vpi.get("explicit_overrides", {}) if isinstance(vpi, dict) else {}
|
||||
if not isinstance(eo, dict):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides must be a plain object.")
|
||||
else:
|
||||
gw = eo.get("gateway", "")
|
||||
if gw:
|
||||
if not ipv4(gw):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.gateway '{gw}' is not a valid IPv4 address.")
|
||||
else:
|
||||
gw_ip = ipaddress.IPv4Address(gw)
|
||||
if identity_ips and gw_ip not in identity_ips:
|
||||
errors.append(
|
||||
f"{label}: vpn_information.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 and not ipv4(dns):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.dns_server '{dns}' is not a valid IPv4 address.")
|
||||
mtu = eo.get("mtu", "")
|
||||
if mtu and int_range(mtu, 576, 9000) is None:
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.mtu '{mtu}' must be an integer in range 576-9000.")
|
||||
domain_val = vpi.get("domain", "") if isinstance(vpi, dict) else ""
|
||||
if domain_val and not domainname(domain_val):
|
||||
errors.append(f"{label}: vpn_information.domain '{domain_val}' is not a valid domain name.")
|
||||
|
||||
# -- peers ---------------------------------------------------------
|
||||
seen_peer_names = {}
|
||||
seen_peer_ips = {}
|
||||
for pidx, peer in enumerate(vlan.get("peers", [])):
|
||||
pname = peer.get("name", "")
|
||||
plabel = f"{label} peer[{pidx}] '{pname}'"
|
||||
if not pname:
|
||||
errors.append(f"{plabel}: missing 'name' field.")
|
||||
elif pname in seen_peer_names:
|
||||
errors.append(f"{plabel}: duplicate peer name '{pname}'.")
|
||||
else:
|
||||
seen_peer_names[pname] = pidx
|
||||
if not peer.get("public_key"):
|
||||
errors.append(f"{plabel}: missing 'public_key' field.")
|
||||
pip_str = peer.get("ip", "")
|
||||
if not pip_str:
|
||||
errors.append(f"{plabel}: missing 'ip' field.")
|
||||
elif not ipv4(pip_str):
|
||||
errors.append(f"{plabel}: ip '{pip_str}' is not a valid IPv4 address.")
|
||||
else:
|
||||
pip = ipaddress.IPv4Address(pip_str)
|
||||
if wg_net and pip not in wg_net:
|
||||
errors.append(f"{plabel}: ip '{pip_str}' is not within subnet {wg_net}.")
|
||||
if pip in identity_ips:
|
||||
errors.append(f"{plabel}: ip '{pip_str}' conflicts with a server_identity.")
|
||||
if pip_str in seen_peer_ips:
|
||||
errors.append(
|
||||
f"{plabel}: duplicate peer ip '{pip_str}' "
|
||||
f"(also used by peer '{seen_peer_ips[pip_str]}')."
|
||||
)
|
||||
else:
|
||||
seen_peer_ips[pip_str] = pname
|
||||
continue
|
||||
|
||||
if not vlan.get("server_identities"):
|
||||
errors.append(f"{label}: server_identities is empty or missing.")
|
||||
continue
|
||||
|
||||
for field in ("subnet", "subnet_mask"):
|
||||
if not vlan.get(field):
|
||||
errors.append(f"{label}: missing required top-level field '{field}'.")
|
||||
if not vlan.get("subnet") or not vlan.get("subnet_mask"):
|
||||
continue
|
||||
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
|
||||
vlan_networks[iface] = network
|
||||
except ValueError as e:
|
||||
errors.append(f"{label}: invalid subnet/subnet_mask: {e}")
|
||||
continue
|
||||
|
||||
d = vlan.get("dhcp_information", {})
|
||||
required_dhcp = {"dynamic_pool_start", "dynamic_pool_end", "lease_time"}
|
||||
missing = required_dhcp - set(d.keys())
|
||||
if missing:
|
||||
errors.append(f"{label}: missing dhcp_information fields: {missing}")
|
||||
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
|
||||
if not ipv4(ip_str):
|
||||
errors.append(f"{label}: {field_label} '{ip_str}' is not a valid IPv4 address.")
|
||||
return None
|
||||
addr = ipaddress.IPv4Address(ip_str)
|
||||
if addr not in network:
|
||||
errors.append(f"{label}: {field_label} '{ip_str}' is not within subnet {network}.")
|
||||
return addr
|
||||
|
||||
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
|
||||
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)
|
||||
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 = VALID_PROTOCOLS
|
||||
known_interfaces = set(seen_interfaces.keys())
|
||||
|
||||
def nat_check_port(label, port):
|
||||
if int_range(port, 1, 65535) is None:
|
||||
errors.append(f"{label}: '{port}' is not a valid port number (1-65535).")
|
||||
|
||||
def nat_check_ip(label, ip_str):
|
||||
if not ipv4(ip_str):
|
||||
errors.append(f"{label}: '{ip_str}' is not a valid IPv4 address.")
|
||||
return None
|
||||
return ipaddress.IPv4Address(ip_str)
|
||||
|
||||
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, iface in zip(data.get("vlans", []), vlan_ifaces):
|
||||
name = vlan.get("name", "?")
|
||||
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"]
|
||||
if not ipv4_or_cidr(val):
|
||||
errors.append(f"{label}: src_ip_or_subnet '{val}' is not a valid "
|
||||
f"IPv4 address or network.")
|
||||
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:
|
||||
if not ipv4_or_cidr(dst):
|
||||
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"))
|
||||
|
||||
# -- radius_default uniqueness check ---------------------------------------
|
||||
defaults = [v["name"] for v in data.get("vlans", []) if v.get("radius_default") is True]
|
||||
if len(defaults) > 1:
|
||||
errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). "
|
||||
f"Only one VLAN may be the RADIUS default.")
|
||||
|
||||
# -- host_overrides validation ---------------------------------------------
|
||||
all_vlan_nets = list(vlan_networks.values())
|
||||
for idx, entry in enumerate(data.get("host_overrides", [])):
|
||||
lbl = f"host_overrides[{idx}] '{entry.get('host', '?')}'"
|
||||
if not entry.get("host"):
|
||||
errors.append(f"{lbl}: missing 'host' field.")
|
||||
ip_str = entry.get("ip", "")
|
||||
if not ip_str:
|
||||
errors.append(f"{lbl}: missing 'ip' field.")
|
||||
elif not ipv4(ip_str):
|
||||
errors.append(f"{lbl}: '{ip_str}' is not a valid IPv4 address.")
|
||||
else:
|
||||
ip_addr = ipaddress.IPv4Address(ip_str)
|
||||
if all_vlan_nets and not any(ip_addr in net for net in all_vlan_nets):
|
||||
errors.append(f"{lbl}: '{ip_str}' does not fall within any configured VLAN subnet.")
|
||||
|
||||
# -- banned_ips validation -------------------------------------------------
|
||||
for idx, entry in enumerate(data.get("banned_ips", [])):
|
||||
ip_val = entry.get("ip", "")
|
||||
lbl = f"banned_ips[{idx}] '{entry.get('description', '')}'"
|
||||
if not ip_val:
|
||||
errors.append(f"{lbl}: missing 'ip' field.")
|
||||
continue
|
||||
if not banned_ip(ip_val):
|
||||
errors.append(f"{lbl}: '{ip_val}' is not a valid IP, CIDR, or wildcard pattern.")
|
||||
|
||||
return errors
|
||||
Loading…
Add table
Add a link
Reference in a new issue