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 |
|---|---|
| `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 |
| `.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. |
| `.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 (`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 `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.
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:
-`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:
```json
{
"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`:
```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
```bash
sudo python3 install.py # Install required packages; optionally set up dashboard and HTTPS
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