Development

This commit is contained in:
Matthew Grotke 2026-05-25 19:59:42 -04:00
parent d0cfffac52
commit adcfe55c7c
24 changed files with 405 additions and 359 deletions

View file

@ -10,7 +10,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 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 |
| `config.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)
@ -33,7 +33,7 @@ All configuration lives in two JSON files. Edit these to match your network befo
## Initial Configuration
### 1. Edit Core Configuration (`core.json`)
### 1. Edit Core Configuration (`config.json`)
Edit the top-level `network_interfaces` block:
@ -149,7 +149,7 @@ mDNS (Multicast DNS) is the protocol devices use to advertise and discover servi
**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`:
Configure mDNS reflection with the top-level `mdns_reflection` block in `config.json`:
```json
"mdns_reflection": {
@ -190,7 +190,7 @@ sudo python3 ddns.py --start # Run an immediate IP update and install t
Optional (if WireGuard VPN is desired):
1. Add a WireGuard VLAN to `core.json` with `is_vpn: true` (see configuration example above)
1. Add a WireGuard VLAN to `config.json` with `is_vpn: true` (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 `create_vpn_peer.py` (see below), then run `sudo python3 core.py --apply` again to sync them to the live interface
@ -201,7 +201,7 @@ 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` is optional. Transfer the `.conf` to the peer device by secure means, then delete it from the server.
The script reads the specified WireGuard VLAN from `config.json`, validates the IP against the VLAN subnet, generates a keypair, appends the peer to `config.json`, and writes the client `.conf` file. If the config has exactly one WireGuard VLAN, `--iface` is optional. Transfer the `.conf` to the peer device by secure means, then delete it from the server.
---
@ -266,7 +266,7 @@ Only `--start` and `--disable` require `sudo` as they install/remove systemd tim
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 --update # 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

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
core.py -- Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables.
core.py -- Apply config.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables.
Each VLAN defined in core.json gets its own dnsmasq instance that handles
Each VLAN defined in config.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 peers have statically assigned IPs).
@ -105,7 +105,7 @@ from validation import (
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
METRICS_FILE = SCRIPT_DIR / ".dns-metrics"
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
@ -260,7 +260,7 @@ def load_config():
with open(CONFIG_FILE) as f:
data = json.load(f)
if not data.get("vlans"):
die("No vlans defined in core.json.")
die("No vlans defined in config.json.")
return data
# ===================================================================
@ -270,7 +270,7 @@ def load_config():
def build_netdev(vlan, vid, iface):
return "\n".join([
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
"[NetDev]",
f"Name={iface}",
@ -286,7 +286,7 @@ def build_network(vlan, vid, iface, all_vlan_ids):
prefix = network.prefixlen
lines = [
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
"[Match]",
f"Name={iface}",
@ -452,7 +452,7 @@ def build_vlan_dnsmasq_conf(vlan, data, iface):
L.append(s)
line("# Generated by core.py -- do not edit manually.")
line("# Edit core.json and re-run: sudo python3 core.py --apply")
line("# Edit config.json and re-run: sudo python3 core.py --apply")
line(f"# VLAN: {name} (vlan_id={derive_vlan_id(vlan.get('subnet', ''), vlan.get('subnet_mask', 24))})")
line()
line(f"pid-file={vlan_pid_file(vlan)}")
@ -772,7 +772,7 @@ def generate_wg_server_key(iface):
return private
def build_wg_server_conf(vlan, server_private_key, iface):
"""Build the /etc/wireguard/<iface>.conf content from core.json peers."""
"""Build the /etc/wireguard/<iface>.conf content from config.json peers."""
info = vlan["vpn_information"]
gateway = resolve_vlan_options(vlan)["gateway"]
network = network_for(vlan)
@ -1158,7 +1158,7 @@ def install_ddns_timer(data):
"",
"[Service]",
"Type=oneshot",
f"ExecStart=/usr/bin/python3 {script_path} --apply",
f"ExecStart=/usr/bin/python3 {script_path} --update",
"",
])
timer_content = "\n".join([
@ -1387,7 +1387,7 @@ def build_nft_config(data, dry_run=False):
L.append(s)
line("# Generated by core.py -- do not edit manually.")
line("# Edit core.json and re-run: sudo python3 core.py --apply")
line("# Edit config.json and re-run: sudo python3 core.py --apply")
line()
# ==========================================================================
@ -1829,7 +1829,7 @@ def build_radius_clients_conf(data, secret):
"""Generate freeradius clients.conf from reservations with radius_client: true."""
lines = [
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
"# localhost (required)",
"client localhost {",
@ -1867,7 +1867,7 @@ def build_radius_users(data):
lines = [
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
]
@ -3028,7 +3028,7 @@ def cmd_apply(data, dry_run=False):
def main():
parser = argparse.ArgumentParser(
description="Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables",
description="Apply config.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
create_vpn_peer.py -- Add a WireGuard peer to core.json and write the client .conf file.
create_vpn_peer.py -- Add a WireGuard peer to config.json and write the client .conf file.
Generates a fresh keypair, appends the peer to the specified WireGuard VLAN in core.json,
Generates a fresh keypair, appends the peer to the specified WireGuard VLAN in config.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
@ -26,7 +26,7 @@ import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
def die(msg):
@ -61,7 +61,7 @@ def find_wg_vlan(data, iface=None, vlan_id=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. "
die(f"No WireGuard VLAN with interface '{iface}' found in config.json. "
f"Known WireGuard interfaces: {known}.")
return vlan
@ -71,12 +71,12 @@ def find_wg_vlan(data, iface=None, vlan_id=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. "
die(f"No WireGuard VLAN with vlan_id {vlan_id} found in config.json. "
f"Known WireGuard VLANs: {known}.")
return vlan
if not wg_vlans:
die("No WireGuard VLANs found in core.json. "
die("No WireGuard VLANs found in config.json. "
"Add a VLAN with is_vpn set to true.")
if len(wg_vlans) > 1:
options = " " + "\n ".join(
@ -149,7 +149,7 @@ def build_client_conf(vlan, peer_ip, private_key, server_pub, split_tunnel):
def main():
parser = argparse.ArgumentParser(
description="Add a WireGuard peer to core.json and write the client .conf file."
description="Add a WireGuard peer to config.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)")
@ -198,7 +198,7 @@ def main():
private_key, public_key = generate_keypair()
srv_pub = server_pubkey(iface)
# -- Update core.json ------------------------------------------------------
# -- Update config.json ------------------------------------------------------
peers.append({
"name": args.name,
"ip": peer_ip,
@ -207,7 +207,7 @@ def main():
"enabled": True,
})
save_config(data)
print(f"Added peer '{args.name}' to core.json.")
print(f"Added peer '{args.name}' to config.json.")
# -- Write client conf -----------------------------------------------------
conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel)

View file

@ -2,7 +2,7 @@
"""
ddns.py -- Update DDNS provider(s) with current public IP.
Reads the ddns block from core.json, fetches the current public IP,
Reads the ddns block from config.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 managed by core.py --apply.
@ -16,7 +16,7 @@ Logs to ddns.log in the same directory as this script.
Log is cleared when it exceeds general.log_max_kb from config.
Usage:
python3 ddns.py --apply Run update once (used by timer)
python3 ddns.py --update Run update once (used by timer)
python3 ddns.py --force Force update regardless of cached IP
python3 ddns.py --getip Print current public IP and exit
"""
@ -32,7 +32,7 @@ import logging
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service"
LOG_FILE = SCRIPT_DIR / "ddns.log"
@ -512,18 +512,18 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" python3 ddns.py --apply Run update once (used by timer)\n"
" python3 ddns.py --update Run update once (used by timer)\n"
" python3 ddns.py --force Force update regardless of cached IP\n"
" python3 ddns.py --getip Print current public IP and exit\n"
)
)
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
parser.add_argument("--update", 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("--getip", action="store_true", help="Print current public IP and exit")
args = parser.parse_args()
if not any([args.apply, args.force, args.getip]):
if not any([args.update, args.force, args.getip]):
parser.print_help()
return
@ -540,7 +540,7 @@ def main():
general = cfg["general"]
setup_logging(general["log_max_kb"], general["log_errors_only"])
if args.apply or args.force:
if args.update or args.force:
run_update(cfg, force=args.force)
if __name__ == "__main__":

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
dns-blocklists.py -- Download and merge DNS blocklists defined in core.json.
dns-blocklists.py -- Download and merge DNS blocklists defined in config.json.
Reads the blocklists library from core.json, downloads every blocklist referenced
Reads the blocklists library from config.json, downloads every blocklist referenced
by at least one VLAN, merges them into per-combo conf files (one per unique
combination of blocklist names), then sends SIGHUP to each running dnsmasq
instance so it reloads its config without restarting.
@ -23,7 +23,7 @@ from pathlib import Path
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
LOG_FILE = SCRIPT_DIR / "dns-blocklists.log"
@ -80,7 +80,7 @@ def load_config():
with open(CONFIG_FILE) as f:
data = json.load(f)
if not data.get("vlans"):
die("No vlans defined in core.json.")
die("No vlans defined in config.json.")
return data

View file

@ -1,7 +1,7 @@
"""
health.py -- System health checks for Routlin.
Reads core.json, checks services, configuration files, and logs, then writes
Reads config.json, checks services, configuration files, and logs, then writes
.health JSON. Imported by core.py; also runnable standalone.
Public API:
@ -29,7 +29,7 @@ from validation import derive_interface, derive_vlan_id, is_wg
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
HEALTH_FILE = SCRIPT_DIR / ".health"
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
LEASES_DIR = Path("/var/lib/misc")
@ -532,7 +532,7 @@ def check_configurations(data):
f"DHCP pool ({vlan['name']})", "warning",
f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full "
f"({len(leases)}/{pool_size} leases).",
"Expand the pool range in core.json or clean up stale leases "
"Expand the pool range in config.json or clean up stale leases "
f"with: `sudo python3 core.py --reset-leases {vlan['name']}`"))
else:
results.append(_ok(f"dhcp_pool_{vlan['name']}",
@ -596,7 +596,7 @@ def check_configurations(data):
results.append(_problem(
"upstream_dns", "Upstream DNS reachability", "warning",
f"Upstream DNS server(s) unreachable on port 53: {', '.join(unreachable)}.",
"Check WAN connectivity and upstream DNS server addresses in core.json."))
"Check WAN connectivity and upstream DNS server addresses in config.json."))
elif servers:
results.append(_ok("upstream_dns", "Upstream DNS reachability"))

View file

@ -564,13 +564,13 @@ def main():
# -- Dashboard -------------------------------------------------
header("Dashboard (optional)")
print(" The Routlin Dashboard is a web UI for managing the router.")
print(" It runs as a Docker container. Without it, core.json must")
print(" It runs as a Docker container. Without it, config.json must")
print(" be edited manually.")
print()
next_step = (
f"\n Next step: use the web dashboard to configure your network, or\n"
f" configure {SCRIPT_DIR}/core.json manually and then run:\n"
f" configure {SCRIPT_DIR}/config.json manually and then run:\n"
f" sudo python3 {SCRIPT_DIR}/core.py --apply"
)

View file

@ -1,5 +1,5 @@
"""
validation.py -- Shared structural validators for core.json fields.
validation.py -- Shared structural validators for config.json fields.
Lives alongside core.py in ~/routlin/ and is volume-mounted into the
routlin-dash container at /app/validation.py. Importable by both
@ -304,7 +304,7 @@ def derive_interface(vlan, data):
# ===================================================================
def validate_config(data):
"""Validate core.json structure and content. Returns list of error strings."""
"""Validate config.json structure and content. Returns list of error strings."""
errors = []
seen_vlan_ids = {}
seen_interfaces = {}