UI improvement
This commit is contained in:
parent
575edc836d
commit
9a272ee959
16 changed files with 2477 additions and 1604 deletions
|
|
@ -48,19 +48,19 @@ The suite is organized into three independent but complementary scripts, each ma
|
|||
- Manages a `.radius-secret` shared secret file (generated automatically on first `--apply` if RADIUS is enabled)
|
||||
- Configures `avahi-daemon` as an mDNS reflector to forward service discovery announcements (AirPrint, AirPlay, Chromecast, etc.) across VLANs
|
||||
|
||||
### Optional: VPN (`vpn.py`)
|
||||
### 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`)
|
||||
- Allocates IP addresses to remote peers automatically from the VPN VLAN subnet
|
||||
- Generates per-peer client config files ready for import into any WireGuard client, with per-peer choice of split tunnel or full tunnel routing
|
||||
- Resolves the server's public endpoint from the DDNS config or manual entry
|
||||
- Stores peer data in per-interface dotfiles (`.vpn-wg0`, etc.) alongside the scripts
|
||||
- Reports per-peer handshake times and RX/TX byte counts
|
||||
- `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 No-IP and DuckDNS), supporting multiple hostnames and subdomains per provider
|
||||
- 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`
|
||||
|
|
@ -80,7 +80,7 @@ These packages are required. `core.py --install` checks that they are installed
|
|||
| `chrony` | NTP server - synchronizes system clock and serves time to VLAN clients | `core.py` |
|
||||
| `freeradius` | RADIUS server for dynamic VLAN assignment via MAC auth | `core.py` |
|
||||
| `avahi-daemon` | mDNS reflector for cross-VLAN service discovery | `core.py` |
|
||||
| `wireguard-tools` | WireGuard VPN (`wg`, `wg-quick`) | `vpn.py` |
|
||||
| `wireguard-tools` | WireGuard VPN (`wg`, `wg-quick`) | `core.py` (when WireGuard VLANs are configured) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ All configuration lives in two JSON files. Edit these to match your network befo
|
|||
|
||||
| File | Controls |
|
||||
|---|---|
|
||||
| `core.json` | VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface and listen port, upstream DNS servers, blocklist sources, per-VLAN blocklist assignments, host overrides, banned IPs, WAN interface, port forwarding rules, port wrangling, inter-VLAN exceptions |
|
||||
| `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)
|
||||
|
|
@ -116,7 +116,7 @@ All configuration lives in two JSON files. Edit these to match your network befo
|
|||
| File | Purpose |
|
||||
|---|---|
|
||||
| `.radius-secret` | Shared secret between FreeRADIUS and RADIUS clients (APs, switches). Generated automatically on first `--apply` when RADIUS is enabled. Root-owned intentionally. |
|
||||
| `.vpn-wg0` (etc.) | WireGuard peer data per interface. Managed by `vpn.py`. |
|
||||
| `.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`. |
|
||||
|
||||
|
|
@ -144,33 +144,41 @@ Edit the `vlans` array to match your network topology. For each VLAN:
|
|||
- Set `interface` to the NIC name for VLAN 1 (e.g. `enp6s0`); sub-interfaces are named automatically (e.g. `enp6s0.10`). For WireGuard VLANs, use `wg0`, `wg1`, etc.
|
||||
- Set `radius_default` to `true` on exactly one VLAN - unknown MACs will be placed here (typically guest). All other VLANs set this to `false`.
|
||||
- Set `use_blocklists` to a list of blocklist names for this VLAN - leave empty for unfiltered DNS
|
||||
- Set `server_identities` to the IPs the router itself will hold on this VLAN. The lowest last-octet IP is auto-used as gateway, DNS, and NTP server unless overridden in `dhcp.explicit_overrides`.
|
||||
- Set `dhcp` fields: `subnet`, `subnet_mask`, pool start/end, `lease_time`, and optionally `explicit_overrides` for gateway, dns_server, or ntp_server
|
||||
- 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` and `server_identities`:
|
||||
- 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,
|
||||
"gateway": "192.168.40.1",
|
||||
"server_endpoint": "vpn.example.com",
|
||||
"domain": "local",
|
||||
"explicit_overrides": { "dns_server": "", "mtu": "" }
|
||||
"explicit_overrides": { "gateway": "", "dns_server": "", "mtu": "" }
|
||||
},
|
||||
"reservations": [],
|
||||
"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:
|
||||
|
|
@ -272,15 +280,26 @@ Optional (if DDNS is desired):
|
|||
sudo python3 ddns.py --start # Run an immediate IP update and install the update timer
|
||||
```
|
||||
|
||||
Optional (if VPN is desired):
|
||||
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
|
||||
sudo python3 vpn.py --add-peer # Add a VPN peer interactively
|
||||
sudo python3 vpn.py --apply # Write WireGuard config and start the interface
|
||||
sudo python3 core.py --apply # Run again after VPN to start dnsmasq for the VPN VLAN(s)
|
||||
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
|
||||
```
|
||||
|
||||
After adding VPN peers, transfer `vpn-client-<n>.conf` to the peer device by secure means, then delete it from this server.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -309,19 +328,23 @@ python3 core.py --view-rules # Active nftables ruleset
|
|||
python3 core.py --view-metrics # Lifetime DNS metrics across all VLAN instances
|
||||
```
|
||||
|
||||
### vpn.py
|
||||
### create_vpn_peer.py
|
||||
|
||||
All `vpn.py` commands require `sudo`.
|
||||
Does not require `sudo`. Requires `wireguard-tools` (`wg` must be on PATH) and a prior `core.py --apply` to generate the server keypair.
|
||||
|
||||
```
|
||||
sudo python3 vpn.py --add-peer # Add a VPN peer interactively
|
||||
sudo python3 vpn.py --manage-peers # Rename, regenerate keys, or delete a peer
|
||||
sudo python3 vpn.py --apply # Write WireGuard config and start/restart the interface
|
||||
sudo python3 vpn.py --disable # Stop WireGuard on all interfaces
|
||||
sudo python3 vpn.py --status # WireGuard service and interface status
|
||||
sudo python3 vpn.py --view-peers # Per-peer handshake times and traffic stats
|
||||
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.
|
||||
|
|
@ -342,6 +365,7 @@ python3 ddns.py --getip # Print current public IP and exit
|
|||
|
||||
```bash
|
||||
sudo python3 core.py --disable # Revert to network client (interactive wizard)
|
||||
sudo python3 vpn.py --disable # Stop WireGuard on all interfaces
|
||||
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`.
|
||||
|
|
|
|||
684
router/core.json
684
router/core.json
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"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,
|
||||
|
|
@ -17,19 +17,53 @@
|
|||
"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" }
|
||||
{
|
||||
"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",
|
||||
|
|
@ -38,213 +72,615 @@
|
|||
"ip": "192.168.1.20"
|
||||
}
|
||||
],
|
||||
|
||||
"blocklists": [
|
||||
{
|
||||
"name": "oisd-big",
|
||||
"description": "OISD Big - ads, phishing, malware, telemetry",
|
||||
"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",
|
||||
"description": "Hagezi Light (ads, tracking, metrics, badware)",
|
||||
"save_as": "hagezi-light.conf",
|
||||
"url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/light.txt",
|
||||
"format": "dnsmasq"
|
||||
},
|
||||
{
|
||||
"name": "hagezi-pro-plus",
|
||||
"description": "Hagezi Pro Plus - ads, tracking, porn, gambling combined",
|
||||
"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" }
|
||||
{
|
||||
"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 }
|
||||
{
|
||||
"description": "WireGuard VPN",
|
||||
"enabled": true,
|
||||
"protocol": "udp",
|
||||
"dest_port": 51820,
|
||||
"nat_ip": "192.168.1.20",
|
||||
"nat_port": 51820
|
||||
},
|
||||
{
|
||||
"description": "Plex Server",
|
||||
"enabled": true,
|
||||
"protocol": "both",
|
||||
"dest_port": 32400,
|
||||
"nat_ip": "192.168.1.20",
|
||||
"nat_port": 32400
|
||||
},
|
||||
{
|
||||
"description": "Web Server HTTP",
|
||||
"enabled": true,
|
||||
"protocol": "tcp",
|
||||
"dest_port": 80,
|
||||
"nat_ip": "192.168.1.20",
|
||||
"nat_port": 80
|
||||
},
|
||||
{
|
||||
"description": "Web Server HTTPS",
|
||||
"enabled": true,
|
||||
"protocol": "tcp",
|
||||
"dest_port": 443,
|
||||
"nat_ip": "192.168.1.20",
|
||||
"nat_port": 443
|
||||
},
|
||||
{
|
||||
"description": "Game Server",
|
||||
"enabled": true,
|
||||
"protocol": "tcp",
|
||||
"dest_port": 25565,
|
||||
"nat_ip": "192.168.1.20",
|
||||
"nat_port": 25565
|
||||
},
|
||||
{
|
||||
"description": "SSH",
|
||||
"enabled": false,
|
||||
"protocol": "tcp",
|
||||
"dest_port": 22,
|
||||
"nat_ip": "192.168.1.20",
|
||||
"nat_port": 22
|
||||
}
|
||||
],
|
||||
|
||||
"vlans": [
|
||||
|
||||
{
|
||||
"vlan_id": 1,
|
||||
"name": "trusted",
|
||||
"interface": "enp6s0",
|
||||
"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" }
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light"
|
||||
],
|
||||
"dhcp": {
|
||||
"subnet": "192.168.1.0",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"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": "" }
|
||||
"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" }
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
{
|
||||
"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",
|
||||
"interface": "enp6s0.10",
|
||||
"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" }
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light"
|
||||
],
|
||||
"dhcp": {
|
||||
"subnet": "192.168.10.0",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"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": "" }
|
||||
"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" }
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
{
|
||||
"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",
|
||||
"interface": "enp6s0.20",
|
||||
"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" }
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light"
|
||||
],
|
||||
"dhcp": {
|
||||
"subnet": "192.168.20.0",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"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": "" }
|
||||
"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" }
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
{
|
||||
"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",
|
||||
"interface": "enp6s0.30",
|
||||
"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" }
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light",
|
||||
"hagezi-pro-plus"
|
||||
],
|
||||
"dhcp": {
|
||||
"subnet": "192.168.30.0",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"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": "" }
|
||||
"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" }
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
{
|
||||
"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",
|
||||
"interface": "wg0",
|
||||
"subnet": "192.168.40.0",
|
||||
"subnet_mask": 24,
|
||||
"radius_default": false,
|
||||
"mdns_reflection": false,
|
||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||
"use_blocklists": [
|
||||
"oisd-big",
|
||||
"hagezi-light"
|
||||
],
|
||||
"server_identities": [
|
||||
{
|
||||
"description": "Router/Gateway",
|
||||
"ip": "192.168.40.1"
|
||||
}
|
||||
],
|
||||
"vpn_information": {
|
||||
"listen_port": 51820,
|
||||
"gateway": "192.168.40.1",
|
||||
"server_endpoint": "",
|
||||
"domain": "local",
|
||||
"explicit_overrides": { "dns_server": "", "mtu": "" }
|
||||
"explicit_overrides": {
|
||||
"gateway": "",
|
||||
"dns_server": "",
|
||||
"mtu": ""
|
||||
}
|
||||
},
|
||||
"reservations": [],
|
||||
"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" }
|
||||
]
|
||||
{
|
||||
"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
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
}
|
||||
|
|
|
|||
620
router/core.py
620
router/core.py
|
|
@ -4,7 +4,7 @@ core.py -- Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and
|
|||
|
||||
Each VLAN defined in core.json gets its own dnsmasq instance that handles
|
||||
both DHCP and DNS for that VLAN. WireGuard VLANs get a DNS-only instance
|
||||
(no DHCP, since WireGuard peers get IPs from vpn.py).
|
||||
(no DHCP, since peers have statically assigned IPs).
|
||||
|
||||
Each instance binds exclusively to its VLAN gateway IP on port 53, so
|
||||
instances do not conflict with each other or with the system dnsmasq.service,
|
||||
|
|
@ -117,6 +117,8 @@ TIMER_SVC_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.service"
|
|||
RESOLV_CONF = Path("/etc/resolv.conf")
|
||||
NAT_SERVICE_NAME = "core-nat"
|
||||
NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
|
||||
WG_DIR = Path("/etc/wireguard")
|
||||
WG_KEEPALIVE = 25
|
||||
|
||||
log = None
|
||||
|
||||
|
|
@ -180,13 +182,15 @@ def check_root():
|
|||
if os.geteuid() != 0:
|
||||
die("This script must be run as root (sudo).")
|
||||
|
||||
def prefix_to_dotted(n):
|
||||
mask = (0xFFFFFFFF << (32 - int(n))) & 0xFFFFFFFF
|
||||
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
|
||||
|
||||
def network_for(vlan):
|
||||
d = vlan["dhcp"]
|
||||
return ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False)
|
||||
return ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
|
||||
|
||||
def lowest_quartet_ip(vlan):
|
||||
"""Return the server_identity IP with the lowest value in the last octet.
|
||||
Only called for non-WG VLANs which have a server_identities list."""
|
||||
"""Return the server_identity IP with the lowest value in the last octet."""
|
||||
identities = vlan.get("server_identities", [])
|
||||
ips = []
|
||||
for s in identities:
|
||||
|
|
@ -202,26 +206,28 @@ def resolve_vlan_options(vlan):
|
|||
"""
|
||||
Resolve gateway, dns_server, and ntp_server for a VLAN.
|
||||
|
||||
For WG VLANs: gateway comes directly from vpn_information.gateway.
|
||||
dns_server defaults to gateway unless explicit_overrides.dns_server
|
||||
is set. ntp_server is None -- WireGuard has no DHCP so NTP cannot
|
||||
be advertised to peers.
|
||||
For both WG and non-WG VLANs: gateway defaults to the lowest-last-octet
|
||||
server_identity IP unless overridden in explicit_overrides. The gateway
|
||||
override must be one of the server_identity IPs.
|
||||
|
||||
For non-WG VLANs: all three default to the lowest-last-octet
|
||||
server_identity IP unless overridden in dhcp.explicit_overrides.
|
||||
WG VLANs: ntp_server is None (WireGuard has no DHCP so NTP cannot be
|
||||
advertised to peers). Overrides live in vpn_information.explicit_overrides.
|
||||
|
||||
Non-WG VLANs: overrides live in dhcp_information.explicit_overrides.
|
||||
Returns a dict with keys: gateway, dns_server, ntp_server.
|
||||
"""
|
||||
if is_wg(vlan):
|
||||
vpi = vlan["vpn_information"]
|
||||
gateway = vpi["gateway"]
|
||||
overrides = vpi.get("explicit_overrides", {})
|
||||
default = lowest_quartet_ip(vlan) or str(next(network_for(vlan).hosts()))
|
||||
gateway = overrides.get("gateway", "") or default
|
||||
dns = overrides.get("dns_server", "") or gateway
|
||||
return {
|
||||
"gateway": gateway,
|
||||
"dns_server": dns,
|
||||
"ntp_server": None,
|
||||
}
|
||||
overrides = vlan.get("dhcp", {}).get("explicit_overrides", {})
|
||||
overrides = vlan.get("dhcp_information", {}).get("explicit_overrides", {})
|
||||
default = lowest_quartet_ip(vlan)
|
||||
return {
|
||||
"gateway": overrides.get("gateway", "") or default,
|
||||
|
|
@ -233,7 +239,27 @@ def is_physical(vlan):
|
|||
return vlan["vlan_id"] == 1
|
||||
|
||||
def is_wg(vlan):
|
||||
return vlan.get("interface", "").startswith("wg")
|
||||
return vlan.get("is_vpn", False)
|
||||
|
||||
|
||||
def inject_interfaces(data):
|
||||
"""Compute and inject the 'interface' field for every VLAN from is_vpn + vlan_id.
|
||||
|
||||
is_vpn=False (regular VLAN):
|
||||
vlan_id 1 → general.lan_interface (e.g. enp6s0)
|
||||
vlan_id N → lan_interface.N (e.g. enp6s0.10)
|
||||
is_vpn=True (WireGuard VLAN):
|
||||
1st WG VLAN → wg0, 2nd → wg1, etc. (order in vlans array)
|
||||
"""
|
||||
lan = data.get("general", {}).get("lan_interface", "eth0")
|
||||
wg_idx = 0
|
||||
for vlan in data.get("vlans", []):
|
||||
if vlan.get("is_vpn"):
|
||||
vlan["interface"] = f"wg{wg_idx}"
|
||||
wg_idx += 1
|
||||
else:
|
||||
vid = vlan.get("vlan_id", 1)
|
||||
vlan["interface"] = lan if vid == 1 else f"{lan}.{vid}"
|
||||
|
||||
def networkd_stem(vlan):
|
||||
return f"10-router-{vlan['name']}"
|
||||
|
|
@ -298,6 +324,7 @@ def load_config():
|
|||
# ===================================================================
|
||||
|
||||
def validate_config(data):
|
||||
inject_interfaces(data)
|
||||
errors = []
|
||||
seen_vlan_ids = {}
|
||||
seen_interfaces = {}
|
||||
|
|
@ -308,11 +335,15 @@ def validate_config(data):
|
|||
if not data.get("upstream_dns", {}).get("upstream_servers"):
|
||||
errors.append("upstream_dns.upstream_servers is missing or empty.")
|
||||
|
||||
# -- WAN interface ---------------------------------------------------------
|
||||
wan = data.get("general", {}).get("wan_interface", "")
|
||||
# -- 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.")
|
||||
else:
|
||||
if not lan:
|
||||
errors.append("general.lan_interface is missing or empty.")
|
||||
if wan and lan:
|
||||
available_interfaces = set()
|
||||
try:
|
||||
result = subprocess.run(["ip", "link", "show"], capture_output=True, text=True)
|
||||
|
|
@ -320,8 +351,13 @@ def validate_config(data):
|
|||
available_interfaces = {i.split("@")[0] for i in available_interfaces}
|
||||
except Exception:
|
||||
pass
|
||||
if available_interfaces and wan not in available_interfaces:
|
||||
errors.append(f"general.wan_interface: '{wan}' does not exist on this system.")
|
||||
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 = {}
|
||||
|
|
@ -370,9 +406,11 @@ def validate_config(data):
|
|||
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 not isinstance(lp, int) or not (1 <= lp <= 65535):
|
||||
|
|
@ -382,160 +420,234 @@ def validate_config(data):
|
|||
f"'{seen_listen_ports[lp]}'.")
|
||||
else:
|
||||
seen_listen_ports[lp] = name
|
||||
gw = vpi.get("gateway", "")
|
||||
if not gw:
|
||||
errors.append(f"{label}: vpn_information.gateway is required.")
|
||||
|
||||
# -- 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
|
||||
try:
|
||||
ip = ipaddress.IPv4Address(ip_str)
|
||||
if wg_net and ip not in wg_net:
|
||||
errors.append(f"{ilabel}: ip '{ip_str}' is not within subnet {wg_net}.")
|
||||
else:
|
||||
identity_ips.append(ip)
|
||||
except ValueError:
|
||||
errors.append(f"{ilabel}: ip '{ip_str}' is not a valid IPv4 address.")
|
||||
|
||||
# -- 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:
|
||||
try:
|
||||
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]}."
|
||||
)
|
||||
except ValueError:
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.gateway '{gw}' is not a valid IPv4 address.")
|
||||
dns = eo.get("dns_server", "")
|
||||
if dns:
|
||||
try:
|
||||
ipaddress.IPv4Address(dns)
|
||||
except ValueError:
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.dns_server '{dns}' is not a valid IPv4 address.")
|
||||
mtu = eo.get("mtu", "")
|
||||
if mtu:
|
||||
try:
|
||||
m = int(mtu)
|
||||
if not (576 <= m <= 9000):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.mtu {mtu} is out of valid range (576-9000).")
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.mtu '{mtu}' is not a valid integer.")
|
||||
|
||||
# -- 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.")
|
||||
else:
|
||||
try:
|
||||
ipaddress.IPv4Address(gw)
|
||||
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
|
||||
except ValueError:
|
||||
errors.append(f"{label}: vpn_information.gateway '{gw}' is not a valid IPv4 address.")
|
||||
eo = vpi.get("explicit_overrides", {})
|
||||
if not isinstance(eo, dict):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides must be a plain object.")
|
||||
else:
|
||||
dns = eo.get("dns_server", "")
|
||||
if dns:
|
||||
try:
|
||||
ipaddress.IPv4Address(dns)
|
||||
except ValueError:
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.dns_server '{dns}' is not a valid IPv4 address.")
|
||||
mtu = eo.get("mtu", "")
|
||||
if mtu:
|
||||
try:
|
||||
m = int(mtu)
|
||||
if not (576 <= m <= 9000):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.mtu {mtu} is out of valid range (576-9000).")
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"{label}: vpn_information.explicit_overrides.mtu '{mtu}' is not a valid integer.")
|
||||
# WG VLANs have no server_identities or dhcp block -- skip remaining validation
|
||||
errors.append(f"{plabel}: ip '{pip_str}' is not a valid IPv4 address.")
|
||||
continue
|
||||
|
||||
if not vlan.get("server_identities"):
|
||||
errors.append(f"{label}: server_identities is empty or missing.")
|
||||
continue
|
||||
|
||||
d = vlan.get("dhcp", {})
|
||||
required_dhcp = {"subnet", "subnet_mask", "dynamic_pool_start",
|
||||
"dynamic_pool_end", "lease_time"}
|
||||
missing = required_dhcp - set(d.keys())
|
||||
if missing:
|
||||
errors.append(f"{label}: missing dhcp fields: {missing}")
|
||||
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
|
||||
|
||||
if not is_wg(vlan):
|
||||
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
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False)
|
||||
vlan_networks[iface] = network
|
||||
except ValueError as e:
|
||||
errors.append(f"{label}: invalid subnet/subnet_mask: {e}")
|
||||
continue
|
||||
ip = ipaddress.IPv4Address(ip_str)
|
||||
except ValueError:
|
||||
errors.append(f"{label}: {field_label} '{ip_str}' is not a valid IPv4 address.")
|
||||
return None
|
||||
if ip not in network:
|
||||
errors.append(f"{label}: {field_label} '{ip_str}' is not within subnet {network}.")
|
||||
return ip
|
||||
|
||||
def check_ip(field_label, ip_str, allow_none=False):
|
||||
if ip_str is None:
|
||||
if not allow_none:
|
||||
errors.append(f"{label}: {field_label} is null/missing.")
|
||||
return None
|
||||
try:
|
||||
ip = ipaddress.IPv4Address(ip_str)
|
||||
except ValueError:
|
||||
errors.append(f"{label}: {field_label} '{ip_str}' is not a valid IPv4 address.")
|
||||
return None
|
||||
if ip not in network:
|
||||
errors.append(f"{label}: {field_label} '{ip_str}' is not within subnet {network}.")
|
||||
return ip
|
||||
identity_ips = []
|
||||
for idx, ident in enumerate(vlan["server_identities"]):
|
||||
ip = check_ip(
|
||||
f"server_identities[{idx}] '{ident.get('description', '?')}'",
|
||||
ident.get("ip")
|
||||
)
|
||||
if ip:
|
||||
identity_ips.append(ip)
|
||||
|
||||
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)
|
||||
|
||||
# -- Validate explicit_overrides -----------------------------------
|
||||
eo = d.get("explicit_overrides", {})
|
||||
if not isinstance(eo, dict):
|
||||
errors.append(f"{label}: explicit_overrides must be a plain object.")
|
||||
pool_start = check_ip("dynamic_pool_start", d["dynamic_pool_start"])
|
||||
pool_end = check_ip("dynamic_pool_end", d["dynamic_pool_end"])
|
||||
|
||||
if pool_start and pool_end and pool_start > pool_end:
|
||||
errors.append(
|
||||
f"{label}: dynamic_pool_start '{pool_start}' is greater than "
|
||||
f"dynamic_pool_end '{pool_end}'."
|
||||
)
|
||||
|
||||
if pool_start and pool_end:
|
||||
for ip in identity_ips:
|
||||
if pool_start <= ip <= pool_end:
|
||||
errors.append(
|
||||
f"{label}: server_identity '{ip}' falls inside the dynamic "
|
||||
f"pool ({pool_start} - {pool_end})."
|
||||
)
|
||||
|
||||
seen_res_ips = {}
|
||||
seen_res_macs = {}
|
||||
for r in vlan.get("reservations", []):
|
||||
rdesc = r.get("description", "?")
|
||||
rmac = r.get("mac", "").lower().strip()
|
||||
|
||||
if is_dynamic_ip(r):
|
||||
rip = None # no pinned IP -- skip all IP validation
|
||||
else:
|
||||
gw = eo.get("gateway", "")
|
||||
if gw:
|
||||
gw_ip = check_ip("explicit_overrides.gateway", gw)
|
||||
if gw_ip and gw_ip not in identity_ips:
|
||||
rip = check_ip(f"reservation '{rdesc}' ip", r.get("ip"))
|
||||
|
||||
if rip:
|
||||
if pool_start and pool_end and pool_start <= rip <= pool_end:
|
||||
errors.append(
|
||||
f"{label}: reservation '{rdesc}' ip '{rip}' falls inside "
|
||||
f"the dynamic pool ({pool_start} - {pool_end})."
|
||||
)
|
||||
rip_str = str(rip)
|
||||
if rip_str in seen_res_ips:
|
||||
# Allow same IP for different MACs (multi-interface device)
|
||||
# Only flag if same MAC is also duplicated (caught below)
|
||||
if rmac and rmac in seen_res_ips[rip_str]:
|
||||
errors.append(
|
||||
f"{label}: explicit_overrides.gateway '{gw}' does not match "
|
||||
f"any server_identity IP. Must be one of: "
|
||||
f"{[str(ip) for ip in identity_ips]}."
|
||||
f"{label}: reservation '{rdesc}' ip '{rip}' and MAC '{rmac}' "
|
||||
f"duplicates '{seen_res_ips[rip_str][rmac]}'."
|
||||
)
|
||||
dns = eo.get("dns_server", "")
|
||||
if dns:
|
||||
check_ip("explicit_overrides.dns_server", dns)
|
||||
ntp = eo.get("ntp_server", "")
|
||||
if ntp:
|
||||
check_ip("explicit_overrides.ntp_server", ntp)
|
||||
|
||||
pool_start = check_ip("dynamic_pool_start", d["dynamic_pool_start"])
|
||||
pool_end = check_ip("dynamic_pool_end", d["dynamic_pool_end"])
|
||||
|
||||
if pool_start and pool_end and pool_start > pool_end:
|
||||
errors.append(
|
||||
f"{label}: dynamic_pool_start '{pool_start}' is greater than "
|
||||
f"dynamic_pool_end '{pool_end}'."
|
||||
)
|
||||
|
||||
if pool_start and pool_end:
|
||||
for ip in identity_ips:
|
||||
if pool_start <= ip <= pool_end:
|
||||
errors.append(
|
||||
f"{label}: server_identity '{ip}' falls inside the dynamic "
|
||||
f"pool ({pool_start} - {pool_end})."
|
||||
)
|
||||
|
||||
seen_res_ips = {}
|
||||
seen_res_macs = {}
|
||||
for r in vlan.get("reservations", []):
|
||||
rdesc = r.get("description", "?")
|
||||
rmac = r.get("mac", "").lower().strip()
|
||||
|
||||
if is_dynamic_ip(r):
|
||||
rip = None # no pinned IP -- skip all IP validation
|
||||
else:
|
||||
seen_res_ips[rip_str][rmac] = rdesc
|
||||
else:
|
||||
rip = check_ip(f"reservation '{rdesc}' ip", r.get("ip"))
|
||||
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 rip:
|
||||
if pool_start and pool_end and pool_start <= rip <= pool_end:
|
||||
errors.append(
|
||||
f"{label}: reservation '{rdesc}' ip '{rip}' falls inside "
|
||||
f"the dynamic pool ({pool_start} - {pool_end})."
|
||||
)
|
||||
rip_str = str(rip)
|
||||
if rip_str in seen_res_ips:
|
||||
# Allow same IP for different MACs (multi-interface device)
|
||||
# Only flag if same MAC is also duplicated (caught below)
|
||||
if rmac and rmac in seen_res_ips[rip_str]:
|
||||
errors.append(
|
||||
f"{label}: reservation '{rdesc}' ip '{rip}' and MAC '{rmac}' "
|
||||
f"duplicates '{seen_res_ips[rip_str][rmac]}'."
|
||||
)
|
||||
else:
|
||||
seen_res_ips[rip_str][rmac] = rdesc
|
||||
else:
|
||||
seen_res_ips[rip_str] = {rmac: rdesc}
|
||||
if rip in identity_ips:
|
||||
errors.append(
|
||||
f"{label}: reservation '{rdesc}' ip '{rip}' conflicts "
|
||||
f"with a server_identity."
|
||||
)
|
||||
|
||||
if rmac:
|
||||
if rmac in seen_res_macs:
|
||||
errors.append(
|
||||
f"{label}: reservation '{rdesc}' MAC '{rmac}' duplicates "
|
||||
f"'{seen_res_macs[rmac]}'."
|
||||
)
|
||||
else:
|
||||
seen_res_macs[rmac] = rdesc
|
||||
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:
|
||||
|
|
@ -631,6 +743,25 @@ def validate_config(data):
|
|||
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.")
|
||||
else:
|
||||
try:
|
||||
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."
|
||||
)
|
||||
except ValueError:
|
||||
errors.append(f"{lbl}: '{ip_str}' is not a valid IPv4 address.")
|
||||
|
||||
# -- banned_ips validation -------------------------------------------------
|
||||
for idx, entry in enumerate(data.get("banned_ips", [])):
|
||||
ip = entry.get("ip", "")
|
||||
|
|
@ -947,7 +1078,7 @@ def build_vlan_dnsmasq_conf(vlan, data):
|
|||
overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True]
|
||||
name = vlan["name"]
|
||||
iface = vlan["interface"]
|
||||
d = vlan.get("dhcp", {})
|
||||
d = vlan.get("dhcp_information", {})
|
||||
opts = resolve_vlan_options(vlan)
|
||||
gateway = opts["gateway"]
|
||||
|
||||
|
|
@ -982,7 +1113,8 @@ def build_vlan_dnsmasq_conf(vlan, data):
|
|||
|
||||
if not is_wg(vlan):
|
||||
line("# -- DHCP -----------------------------------------------------------")
|
||||
line(f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{d['subnet_mask']},{d['lease_time']}")
|
||||
dotted_mask = prefix_to_dotted(vlan['subnet_mask'])
|
||||
line(f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{dotted_mask},{d['lease_time']}")
|
||||
line(f"domain={d.get('domain', 'local')}")
|
||||
line()
|
||||
line(f"dhcp-option=tag:{name},option:router,{gateway}")
|
||||
|
|
@ -1207,13 +1339,7 @@ def ensure_chrony(data):
|
|||
content = chrony_conf.read_text()
|
||||
subnets = []
|
||||
for v in data["vlans"]:
|
||||
if is_wg(v):
|
||||
# Derive subnet from gateway IP -- always a /24
|
||||
gw = v["vpn_information"]["gateway"]
|
||||
net = ipaddress.IPv4Network(f"{gw}/24", strict=False)
|
||||
subnets.append(str(net))
|
||||
else:
|
||||
subnets.append(str(network_for(v)))
|
||||
subnets.append(str(network_for(v)))
|
||||
added = []
|
||||
for subnet in subnets:
|
||||
line = f"allow {subnet}"
|
||||
|
|
@ -1297,6 +1423,123 @@ def wg_interface_up(iface):
|
|||
capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
def wg_server_key_path(iface):
|
||||
return WG_DIR / f"{iface}.key"
|
||||
|
||||
def wg_server_pubkey_path(iface):
|
||||
"""Public key written to the configs dir so the Flask app can read it."""
|
||||
return SCRIPT_DIR / f".wg-{iface}.pub"
|
||||
|
||||
def wg_conf_path_for(iface):
|
||||
return WG_DIR / f"{iface}.conf"
|
||||
|
||||
def generate_wg_server_key(iface):
|
||||
WG_DIR.mkdir(exist_ok=True)
|
||||
result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True)
|
||||
private = result.stdout.strip()
|
||||
kf = wg_server_key_path(iface)
|
||||
kf.write_text(private + "\n")
|
||||
kf.chmod(0o600)
|
||||
return private
|
||||
|
||||
def build_wg_server_conf(vlan, server_private_key):
|
||||
"""Build the /etc/wireguard/<iface>.conf content from core.json peers."""
|
||||
iface = vlan["interface"]
|
||||
info = vlan["vpn_information"]
|
||||
gateway = resolve_vlan_options(vlan)["gateway"]
|
||||
network = network_for(vlan)
|
||||
server_ip = f"{gateway}/{network.prefixlen}"
|
||||
listen_port = info["listen_port"]
|
||||
domain = info.get("domain", "local")
|
||||
|
||||
L = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Run: sudo python3 core.py --apply",
|
||||
"",
|
||||
"[Interface]",
|
||||
f"PrivateKey = {server_private_key}",
|
||||
f"Address = {server_ip}",
|
||||
f"ListenPort = {listen_port}",
|
||||
"",
|
||||
]
|
||||
|
||||
for peer in vlan.get("peers", []):
|
||||
if not peer.get("enabled", True):
|
||||
L += [f"# DISABLED: {peer['name']}", ""]
|
||||
continue
|
||||
L += [
|
||||
f"# {peer['name']}",
|
||||
"[Peer]",
|
||||
f"PublicKey = {peer['public_key']}",
|
||||
f"AllowedIPs = {peer['ip']}/32",
|
||||
f"PersistentKeepalive = {WG_KEEPALIVE}",
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(L)
|
||||
|
||||
def ensure_wg_interfaces(data):
|
||||
"""Generate WireGuard server confs and bring up / sync all WG interfaces."""
|
||||
wg_vlans = [v for v in data.get("vlans", []) if is_wg(v)]
|
||||
if not wg_vlans:
|
||||
return
|
||||
|
||||
for vlan in wg_vlans:
|
||||
iface = vlan["interface"]
|
||||
print(f" [{iface}]")
|
||||
|
||||
kf = wg_server_key_path(iface)
|
||||
if not kf.exists():
|
||||
print(f" Generating server private key...")
|
||||
private = generate_wg_server_key(iface)
|
||||
else:
|
||||
private = kf.read_text().strip()
|
||||
|
||||
pub_result = subprocess.run(
|
||||
["wg", "pubkey"], input=private, capture_output=True, text=True, check=True
|
||||
)
|
||||
public = pub_result.stdout.strip()
|
||||
pubkey_file = wg_server_pubkey_path(iface)
|
||||
pubkey_file.write_text(public + "\n")
|
||||
chown_to_script_dir_owner(pubkey_file)
|
||||
print(f" Server public key: {public[:20]}...")
|
||||
|
||||
WG_DIR.mkdir(exist_ok=True)
|
||||
conf_file = wg_conf_path_for(iface)
|
||||
new_conf = build_wg_server_conf(vlan, private)
|
||||
listen_port = vlan["vpn_information"]["listen_port"]
|
||||
|
||||
port_changed = False
|
||||
if conf_file.exists():
|
||||
m = re.search(r'ListenPort\s*=\s*(\d+)', conf_file.read_text())
|
||||
if m and int(m.group(1)) != listen_port:
|
||||
port_changed = True
|
||||
|
||||
conf_file.write_text(new_conf)
|
||||
conf_file.chmod(0o600)
|
||||
|
||||
peer_count = len([p for p in vlan.get("peers", []) if p.get("enabled", True)])
|
||||
print(f" Wrote {conf_file} ({peer_count} enabled peer(s))")
|
||||
|
||||
if not wg_interface_up(iface):
|
||||
print(f" Bringing up {iface}...")
|
||||
r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}")
|
||||
else:
|
||||
print(f" {iface} is up.")
|
||||
elif port_changed:
|
||||
print(f" Listen port changed -- restarting {iface}...")
|
||||
subprocess.run(["wg-quick", "down", iface], capture_output=True, text=True)
|
||||
r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}")
|
||||
else:
|
||||
print(f" {iface} restarted.")
|
||||
else:
|
||||
print(f" Syncing peers to live {iface}...")
|
||||
subprocess.run(["wg", "syncconf", iface, str(conf_file)], capture_output=True, text=True)
|
||||
|
||||
def get_container_bridges():
|
||||
"""Return all active bridge interfaces not managed by our VLAN config.
|
||||
Works universally for Docker, Podman, LXC, libvirt, etc. -- anything
|
||||
|
|
@ -1358,9 +1601,7 @@ def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
|
|||
|
||||
for vlan in data["vlans"]:
|
||||
if is_wg(vlan) and not dry_run and not wg_interface_up(vlan["interface"]):
|
||||
print(f"Skipped VLAN '{vlan['name']}': {vlan['interface']} is not up (WireGuard not running).")
|
||||
print(" To enable the VPN VLAN, start WireGuard with vpn.py --apply")
|
||||
print(" (core.py --apply will be called again automatically).")
|
||||
print(f"Skipped VLAN '{vlan['name']}': {vlan['interface']} is not up. Run --apply again after WireGuard is up.")
|
||||
continue
|
||||
|
||||
conf_content = build_vlan_dnsmasq_conf(vlan, data)
|
||||
|
|
@ -1690,13 +1931,11 @@ def build_nft_config(data, dry_run=False):
|
|||
# Build interface -> network map for nat_ip -> iface lookup in forward chain
|
||||
vlan_networks = {}
|
||||
for v in vlans:
|
||||
if not is_wg(v):
|
||||
d = v.get("dhcp", {})
|
||||
try:
|
||||
net = ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False)
|
||||
vlan_networks[v["interface"]] = net
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
try:
|
||||
net = network_for(v)
|
||||
vlan_networks[v["interface"]] = net
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
all_except = rule_enabled(data.get("inter_vlan_exceptions", []))
|
||||
banned_v4, banned_v6 = banned_ip_sets(data)
|
||||
|
|
@ -2000,16 +2239,14 @@ def apply_nftables(data, dry_run=False):
|
|||
print("nftables rules applied successfully.")
|
||||
|
||||
# Build set of active subnets for filtering exception display
|
||||
import ipaddress as _ipaddress
|
||||
active_subnets = []
|
||||
for v in data["vlans"]:
|
||||
if is_wg(v):
|
||||
if wg_interface_up(v["interface"]):
|
||||
gw = v["vpn_information"]["gateway"]
|
||||
active_subnets.append(_ipaddress.IPv4Network(f"{gw}/24", strict=False))
|
||||
else:
|
||||
d = v["dhcp"]
|
||||
active_subnets.append(_ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False))
|
||||
if is_wg(v) and not wg_interface_up(v["interface"]):
|
||||
continue
|
||||
try:
|
||||
active_subnets.append(network_for(v))
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
def dst_is_active(r):
|
||||
dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "")
|
||||
|
|
@ -2894,14 +3131,7 @@ def _dry_run_conflicting_services(data):
|
|||
chrony_conf = Path("/etc/chrony/chrony.conf")
|
||||
if chrony_conf.exists():
|
||||
content = chrony_conf.read_text()
|
||||
subnets = []
|
||||
for v in data["vlans"]:
|
||||
if is_wg(v):
|
||||
gw = v["vpn_information"]["gateway"]
|
||||
net = ipaddress.IPv4Network(f"{gw}/24", strict=False)
|
||||
subnets.append(str(net))
|
||||
else:
|
||||
subnets.append(str(network_for(v)))
|
||||
subnets = [str(network_for(v)) for v in data["vlans"]]
|
||||
missing = [s for s in subnets if f"allow {s}" not in content]
|
||||
if missing:
|
||||
print(f" Would add chrony allow directives for: {', '.join(missing)}")
|
||||
|
|
@ -3261,6 +3491,7 @@ def cmd_apply(data, dry_run=False):
|
|||
dnsmasq confs, start/restart all services whose interface is up, nftables,
|
||||
timer, and boot service. Safe to run repeatedly.
|
||||
"""
|
||||
inject_interfaces(data)
|
||||
if dry_run:
|
||||
print("[DRY RUN] --apply would perform the following actions:")
|
||||
print()
|
||||
|
|
@ -3307,14 +3538,16 @@ def cmd_apply(data, dry_run=False):
|
|||
|
||||
total_enabled = sum(
|
||||
len([r for r in v.get("reservations", []) if r.get("enabled") is True])
|
||||
for v in data["vlans"]
|
||||
for v in data["vlans"] if not is_wg(v)
|
||||
)
|
||||
total_disabled = sum(
|
||||
len([r for r in v.get("reservations", []) if r.get("enabled") is not True])
|
||||
for v in data["vlans"]
|
||||
for v in data["vlans"] if not is_wg(v)
|
||||
)
|
||||
total_wg_peers = sum(len(v.get("peers", [])) for v in data["vlans"] if is_wg(v))
|
||||
wg_part = f", {total_wg_peers} WG peer(s)" if total_wg_peers else ""
|
||||
print(f"Applying config: {len(data['vlans'])} VLAN(s), "
|
||||
f"{total_enabled} reservation(s), {total_disabled} skipped.")
|
||||
f"{total_enabled} reservation(s), {total_disabled} skipped{wg_part}.")
|
||||
print()
|
||||
|
||||
print("-- Conflicting services ----------------------------------------------")
|
||||
|
|
@ -3327,6 +3560,11 @@ def cmd_apply(data, dry_run=False):
|
|||
apply_networkd(data, only_if_changed=True)
|
||||
print()
|
||||
|
||||
if any(is_wg(v) for v in data["vlans"]):
|
||||
print("-- WireGuard interfaces ----------------------------------------------")
|
||||
ensure_wg_interfaces(data)
|
||||
print()
|
||||
|
||||
print("-- dnsmasq instances -------------------------------------------------")
|
||||
if not blocklists_available(data):
|
||||
print(" NOTE: No merged blocklist files found -- blocklist rules will be absent.")
|
||||
|
|
|
|||
229
router/create_vpn_peer.py
Normal file
229
router/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()
|
||||
1005
router/vpn.py
1005
router/vpn.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue