nixos/services/router.nix
rope c00a1f85bc router-ui: per-device traffic, last seen, and saner device saves
- nftables dynamic sets count bytes per LAN address; a 1-min tick folds
  them into totals that survive a ruleset reload
- devices page shows last seen + per-device down/up
- saving no longer pushes every DHCP lease into devices.toml, only rows
  with a reservation, note or block
- empty-state text on the traffic graphs instead of blank space

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:42:50 +01:00

260 lines
10 KiB
Nix

# services/router.nix — mediaserver acts as the home router
#
# Layout:
# eno1 = WAN (DHCP from ISP; in phase 1, from the eero still in router mode)
# eth0 = LAN (static 10.0.0.1/24, serves DHCP to downstream clients)
#
# Services on this box:
# - systemd-networkd: interface management (replaces NetworkManager here)
# - nftables: NAT (masquerade out WAN) + firewall (drop WAN inbound except ports.toml)
# - dnsmasq: DHCP only (port 0 for DNS — AdGuard Home owns :53)
# - AdGuard Home (already running): DNS for LAN clients
#
# Port forwards live in ../ports.toml and LAN devices (static reservations +
# block list) in ../devices.toml, so both are easy to edit — by hand, or via
# the router UI (services/router-ui.nix), which only ever writes those two
# TOML files and never generates Nix.
{ config, lib, pkgs, ... }:
let
portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
destDefault = portsData.dest_default;
devices = (builtins.fromTOML (builtins.readFile ../devices.toml)).device or [ ];
reservedDevices = builtins.filter (d: d ? ip) devices;
blockedDevices = builtins.filter (d: d.blocked or false) devices;
# Drop everything from a blocked MAC arriving on the LAN. These are emitted
# at the TOP of the input and forward chains, ahead of the `ct state
# established,related accept` line — otherwise a device that was already
# talking keeps its existing flows alive indefinitely.
#
# ponytail: conntrack entries created before the block still linger until
# they time out (a few minutes). Add `conntrack -D -s <ip>` to the apply
# path if that wait ever matters.
# ponytail: MAC-based, so a device that randomises its MAC walks around it.
blockRules = lib.concatMapStringsSep "\n "
(d: ''iifname "eth0" ether saddr ${d.mac} drop comment "${d.name} blocked"'')
blockedDevices;
# Phase-1 transition list; empty now that eero is in bridge mode and
# eno1 is strictly the ISP-facing WAN.
trustedLegacyCidrs = [ ];
legacyTrustRules = lib.concatMapStringsSep "\n "
(cidr: ''iifname "eno1" ip saddr ${cidr} accept'')
trustedLegacyCidrs;
# Expand "both" into [tcp, udp]; normalise port vs ports; default dest.
expandForward = entry:
let
protos = if entry.protocol == "both" then [ "tcp" "udp" ] else [ entry.protocol ];
portExpr =
if entry ? port then toString entry.port
else if entry ? ports then builtins.replaceStrings [ "-" ] [ "-" ] entry.ports
else throw "ports.toml entry '${entry.name}' has neither 'port' nor 'ports'";
dest = entry.dest or destDefault;
in
map (p: { inherit (entry) name; proto = p; port = portExpr; dest = dest; }) protos;
forwards = lib.concatMap expandForward portsData.forward;
# nftables accepts port-range literals like "26901-26902" as-is.
dnatRules = lib.concatMapStringsSep "\n "
(f: ''${f.proto} dport ${f.port} dnat to ${f.dest} comment "${f.name}"'')
forwards;
# Input-chain accept rules so WAN traffic to forwarded ports reaches the
# mediaserver. Works in both phases:
# phase 1: eero DNATs to 192.168.4.25, arrives on eno1 — matched here.
# phase 2: our DNAT rewrites dst to 10.0.0.1 (local), arrives on eno1 — matched here.
wanPortInputRules = lib.concatMapStringsSep "\n "
(f: ''iifname "eno1" ${f.proto} dport ${f.port} accept comment "${f.name}"'')
forwards;
in
{
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
# --- Networking stack: systemd-networkd owns the router NICs ---
networking.networkmanager.enable = lib.mkForce false;
networking.useNetworkd = true;
services.resolved.enable = false; # AdGuard Home binds :53
# Disable the scripted firewall — nftables takes over below.
networking.firewall.enable = false;
# IP forwarding is required for routing.
boot.kernel.sysctl = {
"net.ipv4.ip_forward" = 1;
"net.ipv4.conf.all.rp_filter" = 1;
"net.ipv6.conf.all.forwarding" = 0; # no IPv6 upstream yet
"net.ipv6.conf.all.disable_ipv6" = 1;
};
# Pin interface names to MAC addresses so they never swap across boots.
# Without this, "eth0" is an unpredictable kernel name that depends on
# device probe order — if the NICs swap, the entire LAN/WAN config breaks.
services.udev.extraRules = ''
SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="6c:0b:84:0c:4c:58", NAME="eth0"
SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="6c:0b:84:0c:4c:59", NAME="eno1"
'';
# --- Interface configuration ---
systemd.network = {
enable = true;
networks = {
"10-wan" = {
matchConfig.Name = "eno1";
networkConfig = {
DHCP = "ipv4";
IPv6AcceptRA = false;
};
dhcpV4Config = {
UseDNS = false; # don't overwrite resolv.conf from ISP DNS
UseHostname = false;
};
};
"20-lan" = {
matchConfig.Name = "eth0";
networkConfig = {
Address = "10.0.0.1/24";
ConfigureWithoutCarrier = true;
IPv6AcceptRA = false;
};
linkConfig.RequiredForOnline = "no";
};
};
};
# --- nftables: NAT + firewall ---
networking.nftables = {
enable = true;
tables.filter = {
family = "inet";
content = ''
chain input {
type filter hook input priority 0; policy drop;
# Blocked devices first before the conntrack accept.
${blockRules}
ct state established,related accept
ct state invalid drop
iifname "lo" accept
# LAN is trusted
iifname "eth0" accept
# Container bridges reaching host services (e.g. Profilarr Radarr
# on 10.0.0.1:7878, Forgejo runner AdGuard DNS). br-* covers
# Docker user-defined networks, podman* covers Podman (rootful).
iifname { "docker0", "br-*", "podman*" } accept
# Phase 1: also trust the existing eero subnet on eno1 so SSH
# and AdGuard DNS keep working during the transition.
${legacyTrustRules}
# Accept WAN traffic for ports we publicly expose (ports.toml).
${wanPortInputRules}
# ICMP from anywhere (ping, path-MTU)
icmp type echo-request accept
icmpv6 type echo-request accept
}
chain forward {
type filter hook forward priority 0; policy drop;
# Blocked devices first before the conntrack accept.
${blockRules}
ct state established,related accept
ct state invalid drop
# LAN anywhere
iifname "eth0" accept
# Containers anywhere (image pulls, Forgejo runner workflows,
# etc.). br-* = Docker custom networks, podman* = Podman rootful.
iifname { "docker0", "br-*", "podman*" } accept
# WAN any port-forward target (LAN host or docker container)
iifname "eno1" ct status dnat accept
}
chain output {
type filter hook output priority 0; policy accept;
}
'';
};
# Per-device byte counters for the router UI's traffic page.
#
# Dynamic sets keyed on the LAN address, so hosts appear on their own —
# nothing here needs to know which devices exist. prerouting/postrouting
# rather than forward, because forward misses LAN↔router traffic, and on
# this box that includes every Jellyfin stream. Priority -300 puts both
# chains ahead of NAT, so addresses are still the device's own.
#
# Counters reset whenever this table reloads (i.e. every switch that
# touches nftables). router-accounting.service snapshots them each
# minute and accumulates deltas into a file that survives, so the UI's
# totals are continuous even though these counters aren't.
tables.accounting = {
family = "ip";
content = ''
set up {
type ipv4_addr
flags dynamic
counter
timeout 7d
}
set down {
type ipv4_addr
flags dynamic
counter
timeout 7d
}
chain pre {
type filter hook prerouting priority -300; policy accept;
iifname "eth0" update @up { ip saddr }
}
chain post {
type filter hook postrouting priority -300; policy accept;
oifname "eth0" update @down { ip daddr }
}
'';
};
# Use a distinct table name so we don't share `ip nat` with Docker —
# Docker manages its own DOCKER/PREROUTING chains in `ip nat`, and
# NixOS's nftables module rebuilds whichever tables it owns on every
# activation, which would wipe Docker's rules. Hooks at the same
# priority across separate tables coexist fine.
tables.router-nat = {
family = "ip";
content = ''
chain prerouting {
type nat hook prerouting priority -100; policy accept;
iifname "eno1" jump port_forwards
}
chain port_forwards {
${dnatRules}
}
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname "eno1" masquerade
}
'';
};
};
# --- DHCP server on the LAN ---
services.dnsmasq = {
enable = true;
settings = {
interface = "eth0";
bind-interfaces = true;
# AdGuard Home owns DNS; dnsmasq only does DHCP.
port = 0;
dhcp-range = [ "10.0.0.100,10.0.0.250,12h" ];
dhcp-option = [
"option:router,10.0.0.1"
"option:dns-server,10.0.0.1"
];
# Static reservations — format: "MAC,label,IP". From ../devices.toml.
dhcp-host = map (d: "${d.mac},${d.name},${d.ip}") reservedDevices;
# Helpful: log leases to the journal
log-dhcp = true;
};
};
};
}