Get rid of your router and use your existing Linux server as a router instead (you need 2 Ethernet ports)
Find a file
2026-04-09 23:50:42 -04:00
core.json Initial commit 2026-04-09 23:50:42 -04:00
core.py Initial commit 2026-04-09 23:50:42 -04:00
ddns.json Initial commit 2026-04-09 23:50:42 -04:00
ddns.py Initial commit 2026-04-09 23:50:42 -04:00
README.md Initial commit 2026-04-09 23:50:42 -04:00
vpn.py Initial commit 2026-04-09 23:50:42 -04:00

Linux Home Router Suite

A collection of Python scripts that transform an existing Linux server (with at least two Ethernet NICs) into a fully featured home router - eliminating the need for a separate router appliance.

Why Replace Your Router?

Consumer and prosumer router appliances are constrained by OEM firmware. Security patches depend entirely on the vendor's release schedule, features and functionality are often limited, and devices that reach end of life receive no vendor support at all, leaving gaping security vulnerabilities exposed on your network indefinitely. Running your router on a general-purpose Linux machine gives you:

  • Faster speeds - Utilize full fledged computer hardware (typically exceeds that of consumer appliances)
  • Full flexibility - Any configuration that Linux and its tooling support
  • Better security - Patch your own kernel and packages on your own schedule, with no dependency on a vendor who may have abandoned your hardware

Summary

These scripts do not run continuously in the background. They simply install and facilitate the configuration of battle-hardened software (dnsmasq, for DHCP and DNS, nftables for firewall and NAT, chrony for NTP, freeradius for RADIUS, avahi for mDNS discovery, and wireguard for VPN) by using intuitive JSON files that you can edit. The scripts also install systemd timers to run periodic activities: updating the DNS blocklist(s) of your choice (default once per day), optionally checking if your external IP address changed (default every 5 mins) and if so, updating a DDNS provider.


Capabilities

The suite is organized into three independent but complementary scripts, each managing one layer of the stack:

Core: DHCP, DNS, Blocklists, Firewall, RADIUS, and mDNS (core.py)

  • Configures VLAN sub-interfaces via systemd-networkd
  • Assigns static or dynamic DHCP reservations by MAC address and hostname
  • Defines dynamic IP pools per VLAN
  • Manages per-VLAN gateway, DNS, and NTP settings derived from server_identities
  • Runs one dnsmasq instance per VLAN, each bound exclusively to its gateway IP, giving true per-VLAN DNS filtering
  • Downloads and merges blocklists from upstream providers you choose (e.g. OISD, Hagezi)
  • Applies per-VLAN content filtering - VLANs with different blocklist sets each get their own merged blocklist
  • Supports local hostname overrides (split DNS for DDNS hostnames)
  • Installs a daily systemd timer to refresh blocklists
  • Tracks lifetime DNS metrics (queries forwarded, cache hits, authoritative, TCP peaks, pool usage)
  • Builds nftables tables atomically - safe to re-apply without service disruption
  • Handles port forwarding (DNAT/SNAT) for externally accessible services
  • Handles port wrangling - redirects DNS and NTP requests to the local resolver regardless of what the client device may have hardcoded
  • Blocks traffic from specific IPs or subnets via banned_ips - supports single IPs, CIDR notation, wildcards, and ranges for both IPv4 and IPv6
  • Enforces inter-VLAN isolation by default (forward chain policy drop); specific cross-VLAN traffic is permitted via inter_vlan_exceptions
  • Masquerades outbound traffic for all non-WireGuard VLANs automatically
  • Auto-detects active container bridge interfaces (Docker, Podman, libvirt, etc.) and adds forward rules so VLAN clients can reach containerized services
  • Installs a systemd boot service (core-nat.service) to re-apply firewall rules on every boot
  • Co-exists with Docker (does not touch Docker-managed nat/filter tables)
  • Generates FreeRADIUS clients.conf and users files from core.json reservations, enabling dynamic VLAN assignment via MAC Authentication Bypass (MAB) for both wired (802.1X) and wireless clients
  • Manages a .radius-secret shared secret file (generated automatically on first --apply if RADIUS is enabled)
  • Configures avahi-daemon as an mDNS reflector to forward service discovery announcements (AirPrint, AirPlay, Chromecast, etc.) across VLANs

Optional: VPN (vpn.py)

  • 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

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
  • Caches the last known IP per provider to avoid unnecessary API calls
  • Installs a systemd timer that runs every 5 minutes by default
  • Logs all updates and errors to ddns.log

Software Dependencies

These packages are required. core.py --install checks that they are installed and will prompt you to install any that are missing.

Dependency Purpose Required By
python3 Runs all scripts All
systemd Service, timer, networkd, and timesyncd management All
dnsmasq DHCP server and DNS resolver/forwarder core.py
nftables Firewall, NAT, port forwarding, and port wrangling core.py
chrony NTP server - synchronizes system clock and serves time to VLAN clients core.py
freeradius RADIUS server for dynamic VLAN assignment via MAC auth core.py
avahi-daemon mDNS reflector for cross-VLAN service discovery core.py
wireguard-tools WireGuard VPN (wg, wg-quick) vpn.py

Conflicting Software

The following services conflict with this suite. No manual action is required: core.py disables them automatically on --apply. core.py re-enables them on --disable.

  • systemd-resolved - DNS stub resolver that conflicts with dnsmasq on port 53. Disabled on --apply; re-enabled on --disable.
  • systemd-timesyncd - Basic SNTP client that cannot serve time to LAN clients. Disabled on --apply and replaced by chrony; re-enabled on --disable.
  • ufw - Firewall manager that conflicts with the nftables ruleset. Disabled on --apply without removal.

Hardware Requirements

  • A Linux server with at least two Ethernet NICs:
    • One NIC facing your ISP modem/ONT (WAN)
    • One NIC facing your internal switch (LAN)

Configuration Files

All configuration lives in two JSON files. Edit these to match your network before running any scripts.

File Controls
core.json VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface 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
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.
.vpn-wg0 (etc.) WireGuard peer data per interface. Managed by vpn.py.
.ddns-last-ip-* Cached public IP per DDNS provider. Managed by ddns.py.
.ddns-last-service Tracks IP-check service rotation. Managed by ddns.py.

Initial Configuration

1. Edit Core Configuration (core.json)

Edit the top-level general block:

  • Set wan_interface to the name of your WAN-facing NIC (e.g. eno2). Run ip link to find it.

Edit the top-level blocks:

  • Set upstream_dns.upstream_servers to your preferred DNS resolvers (e.g. 1.1.1.1, 8.8.8.8)
  • Add blocklist sources under blocklists with a name, URL, and format for each (e.g. OISD, Hagezi)
  • Add entries to host_overrides for any local hostnames that should resolve to a specific IP (e.g. a DDNS hostname pointing to an internal server)
  • Add entries to port_forwarding for any services that should be reachable from the internet (specify protocol, external port, destination IP, and destination port)
  • Add entries to banned_ips to block traffic from specific IPs or networks (see below)

Edit the vlans array to match your network topology. For each VLAN:

  • Set vlan_id to a unique integer (1 = untagged physical interface, all others are 802.1Q tagged)
  • Set interface to the NIC name for VLAN 1 (e.g. enp6s0); sub-interfaces are named automatically (e.g. enp6s0.10). For WireGuard VLANs, use wg0, wg1, etc.
  • Set radius_default to true on exactly one VLAN - unknown MACs will be placed here (typically guest). All other VLANs set this to false.
  • Set use_blocklists to a list of blocklist names for this VLAN - leave empty for unfiltered DNS
  • Set server_identities to the IPs the router itself will hold on this VLAN. The lowest last-octet IP is auto-used as gateway, DNS, and NTP server unless overridden in dhcp.explicit_overrides.
  • Set dhcp fields: subnet, subnet_mask, 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:
{
  "vlan_id": 40,
  "name": "vpn",
  "interface": "wg0",
  "radius_default": false,
  "use_blocklists": ["oisd-big"],
  "vpn_information": {
    "listen_port": 51820,
    "gateway": "192.168.40.1",
    "domain": "local",
    "explicit_overrides": { "dns_server": "", "mtu": "" }
  },
  "reservations": [],
  "port_wrangling": [...]
}

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 to false to disable without removing the entry
  • Bans apply to both IPv4 and IPv6 traffic

Inter-VLAN Firewall

All cross-VLAN traffic is blocked by default (nftables forward chain policy drop). To permit specific traffic between VLANs, add entries to the top-level inter_vlan_exceptions array:

{
  "description": "Kids -> Plex",
  "enabled": true,
  "protocol": "both",
  "src_ip_or_subnet": "192.168.30.0/24",
  "dst_ip_or_subnet": "192.168.1.20",
  "dst_port": 32400
}
  • src_ip_or_subnet - single IP or CIDR subnet
  • dst_ip_or_subnet - single IP or CIDR subnet
  • dst_port - optional; omit to allow all ports to the destination
  • protocol - tcp, udp, or both
  • enabled - set to false to disable without removing

RADIUS / Dynamic VLAN Assignment

When at least one reservation has radius_client: true, RADIUS is automatically enabled:

  • FreeRADIUS is configured to accept authentication requests from those devices (APs, switches)
  • Every MAC reservation across all VLANs is mapped to its VLAN ID in the FreeRADIUS users file
  • Unknown MACs are assigned to the radius_default VLAN
  • The shared secret is stored in .radius-secret and generated on first --apply
  • Port 1812 is restricted in nftables to accept connections only from radius_client IPs

Point your AP/switch RADIUS configuration at <gateway IP>:1812 using the secret from .radius-secret.

mDNS Reflection

mDNS (Multicast DNS) is the protocol devices use to advertise and discover services on a local network - it powers AirPrint (printer discovery), AirPlay, Chromecast, and similar zero-configuration protocols. mDNS uses the multicast address 224.0.0.251:5353, which is intentionally scoped to a single subnet and does not cross VLAN boundaries on its own.

Single-VLAN networks: mDNS works without any configuration - all devices share the same subnet and can hear each other's announcements directly. The mdns_reflection feature is unnecessary and should be left disabled or omitted entirely.

Multi-VLAN networks: A device on the IoT VLAN (e.g. a network printer) advertising via mDNS is invisible to devices on the Kids or Trusted VLANs, because the multicast packets never leave the IoT subnet. The mdns_reflection feature solves this by running avahi-daemon as an mDNS proxy on the router, which has an interface on every VLAN. Avahi listens for mDNS announcements arriving on any of the designated reflection interfaces and re-broadcasts them on all the others, making services discoverable across VLANs without requiring any changes on the devices themselves.

Configure mDNS reflection with the top-level mdns_reflection block in core.json:

"mdns_reflection": {
  "enabled": true,
  "reflect_vlans": ["iot", "guest", "kids"]
}
  • enabled - set to false to disable entirely; avahi-daemon will be stopped and disabled on the next --apply
  • reflect_vlans - list of VLAN names to participate in reflection; must contain at least two names; WireGuard VLANs are not supported

Important: mDNS reflection makes services discoverable across VLANs, but the actual service traffic still requires appropriate inter_vlan_exceptions rules to pass through the firewall. For example, to print from the Kids VLAN to a printer on the IoT VLAN, you need both mDNS reflection (so the printer is discovered) and firewall exceptions for ports 9100/TCP and 631/TCP (so the print job can actually reach it).

2. Edit DDNS Configuration (ddns.json)

  • Set provider to noip or duckdns
  • For No-IP: set username, password, and the hostnames array
  • For DuckDNS: set token and the subdomains array
  • Set timer_interval to how often the IP should be checked (default: 5m)
  • The ip_check_services list is used in rotation to detect your current public IP - the defaults can be left as-is

Initial Deployment

sudo ./core.py --install            # Check and install required packages
sudo ./core.py --apply              # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers
sudo ./core.py --update-blocklists  # Download and apply blocklists

Optional (if DDNS is desired):

sudo ./ddns.py --start              # Run an immediate IP update and install the update timer

Optional (if VPN is desired):

sudo ./vpn.py --add-peer            # Add a VPN peer interactively
sudo ./vpn.py --apply               # Write WireGuard config and start the interface
sudo ./core.py --apply              # Run again after VPN to start dnsmasq for the VPN VLAN(s)

After adding VPN peers, transfer vpn-client-<n>.conf to the peer device by secure means, then delete it from this server.


Usage Reference

All scripts are designed to be run multiple times - re-running --apply replaces the previous configuration safely.

core.py

Commands that modify system state require sudo. Read-only commands do not.

sudo ./core.py --install              # Check and interactively install required packages
sudo ./core.py --apply                # Apply full config: networkd, dnsmasq, nftables, RADIUS, mDNS, timer, boot service
sudo ./core.py --apply --dry-run      # Preview --apply actions without making changes
sudo ./core.py --update-blocklists    # Download and merge blocklists, then --apply
sudo ./core.py --disable              # Revert to network client (interactive wizard)
sudo ./core.py --disable --dry-run    # Preview --disable wizard without making changes
sudo ./core.py --reset-leases         # Stop dnsmasq, delete all lease files, restart (forces devices to re-acquire)
sudo ./core.py --reset-leases VLAN    # Reset leases for a specific VLAN only (e.g. trusted, iot, guest)

./core.py --status                    # Per-VLAN dnsmasq, freeradius, avahi-daemon, timer, and boot service status
./core.py --view-configs              # Active per-VLAN dnsmasq config files
./core.py --view-leases               # Active DHCP leases across all VLANs with VLAN, type, and description
./core.py --view-rules                # Active nftables ruleset
./core.py --view-metrics              # Lifetime DNS metrics across all VLAN instances

vpn.py

All vpn.py commands require sudo.

sudo ./vpn.py --add-peer              # Add a VPN peer interactively
sudo ./vpn.py --manage-peers          # Rename, regenerate keys, or delete a peer
sudo ./vpn.py --apply                 # Write WireGuard config and start/restart the interface
sudo ./vpn.py --disable               # Stop WireGuard on all interfaces
sudo ./vpn.py --status                # WireGuard service and interface status
sudo ./vpn.py --view-peers            # Per-peer handshake times and traffic stats

ddns.py

Only --start and --disable require sudo as they install/remove systemd timer files. All other commands run as a normal user.

sudo ./ddns.py --start                # Run update and install systemd timer
sudo ./ddns.py --disable              # Stop updates and remove systemd timer

./ddns.py --apply                     # Run one immediate DDNS update (used by timer)
./ddns.py --force                     # Force update regardless of cached IP
./ddns.py --status                    # Timer/service status

Disabling / Uninstalling Components

sudo ./core.py --disable              # Revert to network client (interactive wizard)
sudo ./vpn.py --disable               # Stop WireGuard on all interfaces
sudo ./ddns.py --disable              # Stop and remove DDNS timer