linuxrouter/routlin/USAGE.md
2026-05-31 02:17:25 -04:00

16 KiB

Routlin - Manual Usage

This document covers manual configuration and operation via the command line and JSON files directly. If you are using the Routlin Dashboard web UI, most of this is handled for you and you do not need to follow these steps.


Configuration Files

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

File Controls
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)

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.
.<iface>.pub WireGuard server public key per interface (e.g. .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.
.dashboard-pending Changes held back when Apply on Save is disabled; flushed to .dashboard-queue when Apply Now is clicked.
.health JSON health check results written by core.py --apply, core.py --status, and the routlin-health-check timer (every 5 minutes). Read by the dashboard to display problem alerts.
.dns-metrics Cumulative lifetime DNS metrics across all VLAN instances. Created and updated each time --view-metrics is run.
.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 (config.json)

Edit the top-level network_interfaces 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 dns_blocking.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 subnet and subnet_mask. The VLAN ID is derived automatically from the subnet: for a /24 it is the third octet (e.g. 192.168.10.0/24 -> VLAN ID 10); for a /16 it is the second octet. Ensure this matches the 802.1Q tag configured on your switch. VLAN ID 1 (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_interface sibling - set interface in general to the LAN-facing NIC (e.g. enp6s0). Sub-interfaces for all other VLANs are named automatically (e.g. enp6s0.10).
  • 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_information.explicit_overrides.
  • 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, set is_vpn: true and include a vpn_information block instead of dhcp_information and server_identities, and a peers array instead of reservations. WireGuard interface names (wg0, wg1, ...) are assigned automatically in ascending order of VLAN ID.
{
  "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_servers": "", "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 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 config.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, duckdns, or cloudflare
  • For No-IP: set username, password, and the hostnames array
  • For DuckDNS: set token and the subdomains array
  • For Cloudflare: set api_token and the relevant zone/record details
  • 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 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 dns-blocklists.py          # 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):

  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
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 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.


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, timers, boot service; runs health checks at end
sudo python3 core.py --apply --dry-run      # Preview --apply actions without making changes
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                    # Service status, config checks, and log alerts for all managed components; writes .health
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

dns-blocklists.py

sudo python3 dns-blocklists.py

Downloads every blocklist referenced by at least one VLAN, merges them into per-combination conf files, then calls core.py --apply to reload dnsmasq instances. Run this after initial deployment and any time you add or change blocklist sources. The daily systemd timer installed by core.py --apply runs this automatically.

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 --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

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.