23 KiB
Routlin
A collection of Python scripts that transform an existing Linux server (with at least two Ethernet NICs) into a fully featured home router, eliminating the need for a separate router appliance.
Why Replace Your Router?
Consumer and prosumer router appliances are constrained by OEM firmware. Security patches depend entirely on the vendor's release schedule, features and functionality are often limited, and devices that reach end of life receive no vendor support at all, leaving gaping security vulnerabilities exposed on your network indefinitely. Running your router on a general-purpose Linux machine gives you:
- Faster speeds - Utilize full fledged computer hardware (typically exceeds that of consumer appliances)
- Full flexibility - Any configuration that Linux and its tooling support
- Better security - Patch your own kernel and packages on your own schedule, with no dependency on a vendor who may have abandoned your hardware
Summary
These scripts do not run continuously in the background. They install and facilitate the configuration of battle-hardened software (dnsmasq for DHCP and DNS, nftables for firewall and NAT, chrony for NTP, freeradius for RADIUS, avahi for mDNS discovery, and wireguard for VPN) using JSON files that you edit. The scripts also install systemd timers to run periodic activities: updating the DNS blocklist(s) of your choice (default once per day), and optionally checking if your external IP address changed (default every 5 mins) and if so, updating a DDNS provider.
Capabilities
The suite is organized into three independent but complementary scripts, each managing one layer of the stack:
Core: DHCP, DNS, Blocklists, Firewall, RADIUS, and mDNS (core.py)
- Configures VLAN sub-interfaces via
systemd-networkd - Assigns static or dynamic DHCP reservations by MAC address and hostname
- Defines dynamic IP pools per VLAN
- Manages per-VLAN gateway, DNS, and NTP settings derived from
server_identities - Runs one
dnsmasqinstance per VLAN, each bound exclusively to its gateway IP, giving true per-VLAN DNS filtering - Downloads and merges blocklists from upstream providers you choose (e.g. OISD, Hagezi)
- Applies per-VLAN content filtering - VLANs with different blocklist sets each get their own merged blocklist
- Supports local hostname overrides (split DNS for DDNS hostnames)
- Installs a daily
systemdtimer to refresh blocklists - Tracks lifetime DNS metrics (queries forwarded, cache hits, authoritative, TCP peaks, pool usage)
- Builds
nftablestables atomically - safe to re-apply without service disruption - Handles port forwarding (DNAT/SNAT) for externally accessible services
- Handles port wrangling - redirects DNS and NTP requests to the local resolver regardless of what the client device may have hardcoded
- Blocks traffic from specific IPs or subnets via
banned_ips- supports single IPs, CIDR notation, wildcards, and ranges for both IPv4 and IPv6 - Enforces inter-VLAN isolation by default (forward chain policy drop); specific cross-VLAN traffic is permitted via
inter_vlan_exceptions - Masquerades outbound traffic for all non-WireGuard VLANs automatically
- Auto-detects active container bridge interfaces (Docker, Podman, libvirt, etc.) and adds forward rules so VLAN clients can reach containerized services
- Auto-detects active container bridge interfaces and adds DNS listening on each bridge IP, so containers can reach the local DNS resolver during builds and at runtime
- Installs a
systemdboot service (routlin-nat.service) to re-apply firewall rules on every boot - Co-exists with Docker (does not touch Docker-managed
nat/filtertables) - Generates FreeRADIUS
clients.confandusersfiles fromcore.jsonreservations, enabling dynamic VLAN assignment via MAC Authentication Bypass (MAB) for both wired (802.1X) and wireless clients - Manages a
.radius-secretshared secret file (generated automatically on first--applyif RADIUS is enabled) - Configures
avahi-daemonas an mDNS reflector to forward service discovery announcements (AirPrint, AirPlay, Chromecast, etc.) across VLANs
Optional: WireGuard VPN (managed by core.py and the dashboard)
- Supports any number of WireGuard interfaces defined in
core.json(any VLAN withis_vpn: true) core.py --applygenerates the server keypair on first run, writes the server conf to/etc/wireguard/, and brings the interface up withwg-quick. Subsequent applies sync peer changes live without restarting the interface- Peer management is done through the Routlin Dashboard: add a peer, set its IP and tunnel mode, and the dashboard generates and downloads the ready-to-import client
.conffile immediately - the private key is never stored - Peer data (name, IP, public key, enabled state) is stored directly in
core.jsonalongside the rest of the network config - Supports per-peer choice of split-tunnel (VPN subnet only) or full-tunnel (all traffic) routing
- Reports active peer connections, handshake times, and RX/TX byte counts on the dashboard VPN view
Optional: DDNS (ddns.py)
- Detects the current public IP by rotating through multiple IP-check services
- Updates the specified DNS providers (currently supporting Cloudflare, No-IP and DuckDNS), supporting multiple hostnames and subdomains per provider
- Caches the last known IP per provider to avoid unnecessary API calls
- Installs a
systemdtimer that runs every 5 minutes by default - Logs all updates and errors to
ddns.log
Optional: Routlin Dashboard
- Web UI for managing all aspects of the router (VLANs, reservations, blocklists, VPN, DDNS, firewall, and more) without editing JSON by hand
- Runs as a Docker container alongside the existing scripts
- Changes made in the dashboard are queued and applied to the live system automatically via a 1-minute systemd timer
- Set up via
install.py, which configures SMTP, generates a secret key, and starts the container
Software Dependencies
These packages are required. install.py checks that they are installed and will prompt to install any that are missing.
| Dependency | Purpose | Required By |
|---|---|---|
python3 |
Runs all scripts | All |
systemd |
Service, timer, networkd, and timesyncd management | All |
dnsmasq |
DHCP server and DNS resolver/forwarder | core.py |
nftables |
Firewall, NAT, port forwarding, and port wrangling | core.py |
chrony |
NTP server - synchronizes system clock and serves time to VLAN clients | core.py |
freeradius |
RADIUS server for dynamic VLAN assignment via MAC auth | core.py |
avahi-daemon |
mDNS reflector for cross-VLAN service discovery | core.py |
wireguard-tools |
WireGuard VPN (wg, wg-quick) |
core.py (when WireGuard VLANs are configured) |
docker |
Runs the Routlin Dashboard container | install.py (dashboard only) |
Conflicting Software
The following services conflict with this suite. No manual action is required: core.py disables them automatically on --apply. core.py re-enables them on --disable.
- systemd-resolved - DNS stub resolver that conflicts with
dnsmasqon port 53. Disabled on--apply; re-enabled on--disable. - systemd-timesyncd - Basic SNTP client that cannot serve time to LAN clients; replaced by
chrony. Disabled on--apply; re-enabled on--disable. - ufw - Firewall manager that conflicts with the
nftablesruleset. Disabled on--applywithout removal.
Hardware Requirements
- A Linux server with at least two Ethernet NICs:
- One NIC facing your ISP modem/ONT (WAN)
- One NIC facing your internal switch (LAN)
Configuration Files
All configuration lives in two JSON files. Edit these to match your network before running any scripts.
| File | Controls |
|---|---|
core.json |
VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface settings and peers, upstream DNS servers, blocklist sources, per-VLAN blocklist assignments, host overrides, banned IPs, WAN interface, port forwarding rules, port wrangling, inter-VLAN exceptions |
ddns.json |
DDNS provider credentials, hostnames/subdomains, update interval, IP-check services |
Dotfiles (auto-generated, do not edit)
| File | Purpose |
|---|---|
.radius-secret |
Shared secret between FreeRADIUS and RADIUS clients (APs, switches). Generated automatically on first --apply when RADIUS is enabled. Root-owned intentionally. |
.wg-<iface>.pub |
WireGuard server public key per interface (e.g. .wg-wg0.pub). Written by core.py --apply; read by the dashboard to embed in client config downloads. |
.dashboard-queue |
Pending apply commands written by the dashboard; consumed by the 1-minute timer. |
.dashboard-done |
UUIDs of already-processed queue entries; prevents duplicate execution. |
.dashboard-last-run |
Epoch timestamp of the last timer execution. |
.dashboard-lock |
PID lock file preventing concurrent timer runs. |
.ddns-last-ip-* |
Cached public IP per DDNS provider. Managed by ddns.py. |
.ddns-last-service |
Tracks IP-check service rotation. Managed by ddns.py. |
Initial Configuration
1. Edit Core Configuration (core.json)
Edit the top-level general block:
- Set
wan_interfaceto the name of your WAN-facing NIC (e.g.eno2). Runip linkto find it.
Edit the top-level blocks:
- Set
upstream_dns.upstream_serversto your preferred DNS resolvers (e.g.1.1.1.1,8.8.8.8) - Add blocklist sources under
blocklistswith a name, URL, and format for each (e.g. OISD, Hagezi) - Add entries to
host_overridesfor any local hostnames that should resolve to a specific IP (e.g. a DDNS hostname pointing to an internal server) - Add entries to
port_forwardingfor any services that should be reachable from the internet (specify protocol, external port, destination IP, and destination port) - Add entries to
banned_ipsto block traffic from specific IPs or networks (see below)
Edit the vlans array to match your network topology. For each VLAN:
- Set
subnetandsubnet_mask. The VLAN ID is derived automatically from the subnet: for a/24it is the third octet (e.g.192.168.10.0/24-> VLAN ID10); for a/16it is the second octet. Ensure this matches the 802.1Q tag configured on your switch. VLAN ID1(e.g.192.168.1.0/24) is treated as the untagged physical interface. - For VLAN 1 (the untagged interface), the physical NIC name is taken from your
general.wan_interfacesibling - setinterfaceingeneralto the LAN-facing NIC (e.g.enp6s0). Sub-interfaces for all other VLANs are named automatically (e.g.enp6s0.10). - Set
radius_defaulttotrueon exactly one VLAN - unknown MACs will be placed here (typically guest). All other VLANs set this tofalse. - Set
use_blockliststo a list of blocklist names for this VLAN - leave empty for unfiltered DNS - Set
server_identitiesto 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 indhcp_information.explicit_overrides. - Set
dhcp_informationfields: pool start/end,lease_time, and optionallyexplicit_overridesfor gateway, dns_server, or ntp_server - Add
reservationsfor devices that need a known VLAN assignment by MAC address. Theipfield is optional:- Omit
ip, set it to"", or set it to"dynamic"to let DHCP assign from the pool (hostname is still set) - Set
ipto a specific address outside the dynamic pool to pin the device to that IP - Set
radius_client: trueon any device (AP, switch) that will authenticate other devices via RADIUS
- Omit
- Add per-VLAN
port_wranglingentries to redirect DNS or NTP requests to the local resolver - For WireGuard VLANs, set
is_vpn: trueand include avpn_informationblock instead ofdhcp_informationandserver_identities, and apeersarray instead ofreservations. WireGuard interface names (wg0,wg1, ...) are assigned automatically in ascending order of VLAN ID. Peer management (add, edit, regenerate conf, delete) is done through the dashboard:
{
"is_vpn": true,
"name": "vpn",
"subnet": "192.168.40.0",
"subnet_mask": "255.255.255.0",
"radius_default": false,
"use_blocklists": ["oisd-big"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.40.1" }
],
"vpn_information": {
"listen_port": 51820,
"server_endpoint": "vpn.example.com",
"domain": "local",
"explicit_overrides": { "gateway": "", "dns_server": "", "mtu": "" }
},
"peers": [],
"port_wrangling": []
}
The gateway IP is derived from the server_identities entry with the lowest value in the last octet (same rule as non-WG VLANs). If explicit_overrides.gateway is set, it must match one of the server_identities IPs.
Banned IPs
The top-level banned_ips array blocks inbound and outbound traffic to/from specific IPs or networks at the firewall level. This is useful for blocking known malicious hosts, entire ASNs, or geographic ranges. Entries support a flexible address syntax:
"banned_ips": [
{ "description": "Single IP", "enabled": true, "ip": "94.130.52.18" },
{ "description": "IPv4 /24 wildcard", "enabled": true, "ip": "94.130.52.*" },
{ "description": "IPv4 /16 wildcard", "enabled": true, "ip": "94.130.*.*" },
{ "description": "IPv4 CIDR", "enabled": true, "ip": "94.130.0.0/16" },
{ "description": "IPv4 range", "enabled": true, "ip": "94.130.52.1-20" },
{ "description": "IPv4 range+wildcard", "enabled": true, "ip": "94.130-133.52.*" },
{ "description": "Single IPv6", "enabled": true, "ip": "2a01:4f8:c17:b0f::2" },
{ "description": "IPv6 /48 wildcard", "enabled": true, "ip": "2a01:4f8:c17:*" },
{ "description": "IPv6 CIDR", "enabled": true, "ip": "2a01:4f8::/32" }
]
ip- the address or range to block; supports single IPs, CIDR notation, wildcard octets (*), and numeric ranges within a quartet (e.g.1-20)enabled- set tofalseto disable without removing the entry- Bans apply to both IPv4 and IPv6 traffic
Inter-VLAN Firewall
All cross-VLAN traffic is blocked by default (nftables forward chain policy drop). To permit specific traffic between VLANs, add entries to the top-level inter_vlan_exceptions array:
{
"description": "Kids -> Plex",
"enabled": true,
"protocol": "both",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 32400
}
src_ip_or_subnet- single IP or CIDR subnetdst_ip_or_subnet- single IP or CIDR subnetdst_port- optional; omit to allow all ports to the destinationprotocol-tcp,udp, orbothenabled- set tofalseto disable without removing
RADIUS / Dynamic VLAN Assignment
When at least one reservation has radius_client: true, RADIUS is automatically enabled:
- FreeRADIUS is configured to accept authentication requests from those devices (APs, switches)
- Every MAC reservation across all VLANs is mapped to its VLAN ID in the FreeRADIUS
usersfile - Unknown MACs are assigned to the
radius_defaultVLAN - The shared secret is stored in
.radius-secretand generated on first--apply - Port 1812 is restricted in nftables to accept connections only from
radius_clientIPs
Point your AP/switch RADIUS configuration at <gateway IP>:1812 using the secret from .radius-secret.
mDNS Reflection
mDNS (Multicast DNS) is the protocol devices use to advertise and discover services on a local network - it powers AirPrint (printer discovery), AirPlay, Chromecast, and similar zero-configuration protocols. mDNS uses the multicast address 224.0.0.251:5353, which is intentionally scoped to a single subnet and does not cross VLAN boundaries on its own.
Single-VLAN networks: mDNS works without any configuration - all devices share the same subnet and can hear each other's announcements directly. The mdns_reflection feature is unnecessary and should be left disabled or omitted entirely.
Multi-VLAN networks: A device on the IoT VLAN (e.g. a network printer) advertising via mDNS is invisible to devices on the Kids or Trusted VLANs, because the multicast packets never leave the IoT subnet. The mdns_reflection feature solves this by running avahi-daemon as an mDNS proxy on the router, which has an interface on every VLAN. Avahi listens for mDNS announcements arriving on any of the designated reflection interfaces and re-broadcasts them on all the others, making services discoverable across VLANs without requiring any changes on the devices themselves.
Configure mDNS reflection with the top-level mdns_reflection block in core.json:
"mdns_reflection": {
"enabled": true,
"reflect_vlans": ["iot", "guest", "kids"]
}
enabled- set tofalseto disable entirely; avahi-daemon will be stopped and disabled on the next--applyreflect_vlans- list of VLAN names to participate in reflection; must contain at least two names; WireGuard VLANs are not supported
Important: mDNS reflection makes services discoverable across VLANs, but the actual service traffic still requires appropriate inter_vlan_exceptions rules to pass through the firewall. For example, to print from the Kids VLAN to a printer on the IoT VLAN, you need both mDNS reflection (so the printer is discovered) and firewall exceptions for ports 9100/TCP and 631/TCP (so the print job can actually reach it).
2. Edit DDNS Configuration (ddns.json)
- Set
providertonoip,duckdns, orcloudflare - For No-IP: set
username,password, and thehostnamesarray - For DuckDNS: set
tokenand thesubdomainsarray - For Cloudflare: set
api_tokenand the relevant zone/record details - Set
timer_intervalto how often the IP should be checked (default:5m) - The
ip_check_serviceslist is used in rotation to detect your current public IP - the defaults can be left as-is
Initial Deployment
sudo python3 install.py # Install required packages; optionally set up dashboard and HTTPS
sudo python3 core.py --apply # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers
sudo python3 core.py --update-blocklists # Download and apply blocklists
Optional (if DDNS is desired):
sudo python3 ddns.py --start # Run an immediate IP update and install the update timer
Optional (if WireGuard VPN is desired):
- Add a WireGuard VLAN to
core.jsonwithis_vpn: true(see configuration example above) - Run
sudo python3 core.py --apply- this generates the server keypair, writes/etc/wireguard/wg0.conf, and brings the interface up - Add peers through the dashboard (each peer triggers an immediate
.conffile download), then--applyagain to sync them to the live interface
Without the dashboard (create_vpn_peer.py):
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 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.
Usage Reference
All scripts are designed to be run multiple times - re-running --apply replaces the previous configuration safely.
install.py
sudo python3 install.py
Interactive setup wizard. Detects the Linux package manager, installs required system packages, and optionally sets up the Routlin Dashboard (Docker container with SMTP configuration) and external HTTPS access via Caddy. Safe to re-run: skips already-installed packages and prompts before reconfiguring an existing dashboard.
core.py
Commands that modify system state require sudo. Read-only commands do not.
sudo python3 core.py --apply # Apply full config: networkd, dnsmasq, nftables, RADIUS, mDNS, timer, boot service
sudo python3 core.py --apply --dry-run # Preview --apply actions without making changes
sudo python3 core.py --update-blocklists # Download and merge blocklists, then --apply
sudo python3 core.py --disable # Revert to network client (interactive wizard)
sudo python3 core.py --disable --dry-run # Preview --disable wizard without making changes
sudo python3 core.py --reset-leases # Stop dnsmasq, delete all lease files, restart (forces devices to re-acquire)
sudo python3 core.py --reset-leases VLAN # Reset leases for a specific VLAN only (e.g. trusted, iot, guest)
python3 core.py --status # Per-VLAN dnsmasq, freeradius, avahi-daemon, timer, and boot service status
python3 core.py --view-configs # Active per-VLAN dnsmasq config files
python3 core.py --view-leases # Active DHCP leases across all VLANs with VLAN, type, and description
python3 core.py --view-rules # Active nftables ruleset
python3 core.py --view-metrics # Lifetime DNS metrics across all VLAN instances
create_vpn_peer.py
Does not require sudo. Requires wireguard-tools (wg must be on PATH) and a prior core.py --apply to generate the server keypair.
python3 create_vpn_peer.py --name NAME --ip IP [--iface IFACE] [--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); optional if only one WireGuard VLAN exists
--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)
ddns.py
Only --start and --disable require sudo as they install/remove systemd timer files. All other commands run as a normal user.
sudo python3 ddns.py --start # Run update and install systemd timer
sudo python3 ddns.py --disable # Stop updates and remove systemd timer
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
python3 ddns.py --force # Force update regardless of cached IP
python3 ddns.py --status # Timer/service status
python3 ddns.py --getip # Print current public IP and exit
Disabling / Uninstalling Components
sudo python3 core.py --disable # Revert to network client (interactive wizard)
sudo python3 ddns.py --disable # Stop and remove DDNS timer
WireGuard interfaces are brought down automatically by core.py --disable. To stop a WireGuard interface independently: sudo wg-quick down wg0.