New cnx.router.accessPorts option pins a port untagged to one VLAN via bridge PVID/EgressUntagged; convention is the last copper port as an always-available mgmt recovery port. gw-cnx-1 port roles: enp1s0 WAN, enp2s0 trunk, enp3s0 temporary DHCP uplink into the old OPNsense LAN (back to trunk at cutover), enp4s0 untagged mgmt.
9.6 KiB
Site gateways
NixOS routers (OPNsense replacements) on Topton 1U fanless boxes — Intel N300,
4x i226-V 2.5G ports (some units add 2x 10G SFP+), 16GB DDR5, 256GB mSATA.
Four sites are planned; the first is gw-cnx-1 (Chiang Mai). Everything lives
in the reusable modules/router/ module (cnx.router.* options); a site's
machine config only sets port names, VLANs, and feature flags.
Naming: gw-<city>-<n>, e.g. gw-cnx-1.
What each gateway runs
| Function | Implementation |
|---|---|
| WAN | PPPoE (pppd), per-site ISP credentials via clan vars prompts; wan.vlanId when the ISP tags the session (AIS: 10); wan.macAddress to clone the old router's MAC if the ISP has it pinned |
| LAN | VLAN-filtering bridge br0 over the trunk ports (networkd); accessPorts pin a port untagged to one VLAN — convention: the last copper port is an untagged mgmt recovery port |
| Firewall/NAT | nftables: default-deny WAN, no inter-VLAN, MSS clamp, v4 NAT |
| DHCP | Kea, one subnet per VLAN |
| DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control |
| IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC |
| Bans | CrowdSec + nftables bouncer (sshd log parsing) |
| Omada | Optional per site: TP-Link Omada controller as a podman container |
| Proxy | Optional: Caddy reverse proxy for internal services under *.<site><n>.cnx.network with a real Let's Encrypt wildcard (DNS-01 against ns1) |
| Diagnostics | iperf3 server on 5201, reachable from every VLAN and the mesh (iperf3 -c <router-vlan-addr>); CLI toolkit: tcpdump, mtr, ethtool, conntrack, kdig, iftop, librespeed-cli |
| Speed test | Hourly librespeed run (speedtest.timer) → speedtest_* metrics via node_exporter; vmalert flags download < 50% of the link's own 7-day median (WANSpeedDegraded) |
| Management | ZeroTier mesh: SSH, node_exporter, journald upload — like the fleet |
| Storage | Single-disk ZFS (zstd, /var its own dataset for snapshots) |
Addressing plan
Each site owns 10.<siteId>.0.0/16. Defaults per VLAN: subnet
10.<siteId>.<vlanId>.0/24, router at .1, DHCP pool .100–.199
(.2–.99 static/infra, .200–.254 reserved).
VLAN id convention (fleet-wide): 10 = mgmt, 20 = lan (both mandatory at every site), 30 = guest, 40 = iot (reserved). Sites add their own beyond that.
Larger subnets (e.g. public-WiFi guest networks that outgrow a /24) are carved
from the upper half 10.<siteId>.128.0/17 and set explicitly on the VLAN.
The lower half stays reserved for /24s indexed by VLAN id. High-churn VLANs
should also shorten dhcp.leaseTime (default 86400 s) so the pool recycles.
First user: gw-cnx-2 (site 2) runs the public WiFi — guest VLAN 30 at
10.2.128.0/22, pool 10.2.128.100 – 10.2.131.250, dhcp.leaseTime = 3600.
| Site | siteId | mgmt | lan | site-specific VLANs |
|---|---|---|---|---|
| cnx | 1 | 10.1.10.0/24 |
10.1.20.0/24 |
iot 40, voip 50, dmz 60, unit1–5 110/120/130/140/150 (all /24s) |
Static DHCP leases are declared per VLAN via dhcp.reservations (attribute
name = hostname, plus hwAddress/ipAddress); park them outside the pool,
in the .2–.99 infra range or .200–.254.
Trust model: mgmt → everything; other VLANs → router DNS/DHCP + internet only (no inter-VLAN); WAN → nothing inbound; mesh → SSH, metrics, Omada UI.
Adding a site
- Copy
machines/gw-cnx-1/tomachines/gw-<city>-<n>/; setsite,siteId(next free number), port names, VLANs, andomada.enable. Fill the real disk id indisko.nix(from the installer:ls -l /dev/disk/by-id/). Keep themgmt/lanVLANs. - Add the machine to
inventory.machinesinclan.nixand to the machine list inmodules/mesh-hosts.nix. Do not add it tomodules/hosts.nix(dynamic PPPoE IP; clan connects over the mesh). - Add its node_exporter and Blocky scrape targets in
modules/monitoring/server.nix. - If the site uses the internal proxy (
cnx.router.proxy.enable), wire ns1: import(import ../../modules/dns/acme-gw-secret.nix "gw-<city>-<n>")in both the gateway's and ns1's configuration, and on ns1 add thedns-acme-gw-<city>-<n>-knotgenerator, itskeyFilesentry, and anacl_acme_gw_<city>_<n>scoped to_acme-challenge.<city><n>on thecnx.networkzone (copy thegw-cnx-1blocks). Thenclan vars generate ns1and redeploy ns1. git addthe new machine directory — flake evaluation only sees git-tracked files, so an untrackedmachines/gw-…/is silently ignored.clan vars generate gw-<city>-<n>— prompts for the site's PPPoE credentials, mints the ZeroTier identity etc. (nix flake checkfails until this has run, because mesh-hosts reads the ZeroTier IP var.)- Boot the box from a NixOS installer USB on the local network, then:
clan machines install gw-<city>-<n> --target-host root@<lan-ip> - Check
facter.jsonfor the real NIC names, fixwan.interface/trunkPortsif the enumeration differs, andclan machines update gw-<city>-<n>(rides the mesh from then on). - Add a row to the site table above and to the machines table in Overview.
Omada controller
Sites with TP-Link Omada APs/switches set cnx.router.omada.enable = true.
The controller runs as a podman container (mbentley/omada-controller — there
is no nixpkgs package) with host networking on the mgmt VLAN, where adoption
broadcasts (UDP 29810) live. UI: https://<mgmt-addr>:8043 from mgmt, or the
gateway's mesh address from an admin machine. Controller state is under
/var/lib/omada, declared as clan state (clan.core.state.omada); wiring it
into the borgbackup instance is still a follow-up.
Internal reverse proxy
cnx.router.proxy.enable puts Caddy on the gateway, terminating TLS for
<service>.<site><n>.cnx.network (e.g. https://omada.cnx1.cnx.network) and
forwarding to internal backends:
cnx.router.proxy = {
enable = true;
services.omada = {
backend = "https://127.0.0.1:8043";
insecureSkipVerify = true; # Omada's cert is self-signed
};
};
- Cert: one real Let's Encrypt wildcard
*.<site><n>.cnx.networkper gateway, issued via ACME DNS-01 (lego/RFC2136) against ns1 — works behind PPPoE with zero inbound reachability, and browsers trust it without a CA install. Each gateway has its own TSIG key (acme_gw_<city>_<n>) that ns1 scopes to_acme-challenge.<site><n>TXT records only. - Resolution: the names exist only internally — Blocky answers
*.<site><n>.cnx.networkwith the router'slanaddress; the publiccnx.networkzone never carries them. - Access:
proxy.allowVlans(defaultmgmt+lan) get 443 (and 80 for the HTTP→HTTPS redirect). Not exposed to WAN, guest VLANs, or the mesh.
Runbook
- PPPoE down:
systemctl status pppd-wan,journalctl -u pppd-wanon the gateway (over the mesh). ISP credentials live in thepppoe-credentialsvars generator; re-enter withclan vars generate gw-<city>-<n> --regenerate. - A VLAN gets no leases:
systemctl status kea-dhcp4-server; check the port'sbridgeVLANstagging and that the switch trunk carries the VLAN. - DNS/blocklist issues: Blocky metrics are in VictoriaMetrics (job
blocky);journalctl -u blockyon the gateway. - Slow internet reported /
WANSpeedDegradedfiring: check thespeedtest_download_mbps/speedtest_ping_mstrend in VictoriaMetrics, thenlibrespeed-cliandmtr <isp-gw>on the gateway to separate ISP degradation from local saturation (iftopon ppp0 shows who's eating it). - Banned yourself:
cscli decisions list/cscli decisions delete --ip <ip>on the gateway. The ZeroTier mesh/88is whitelisted at the parser stage (cnx/mesh-whitelist), so admin access over the mesh can never be banned.
Follow-ups
- Borgbackup client for Omada + Kea lease state.
- Suricata (IDS-only) if CPU headroom allows — deliberately skipped for now.
- Remaining three sites.