# 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 ` 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; }; }; }; }