Development
This commit is contained in:
parent
d0cfffac52
commit
adcfe55c7c
24 changed files with 405 additions and 359 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue