nixos/services/router.nix
ediblerope 5426e3847b router: expose forwarded ports on eno1; AdGuard rewrite for LAN hostname
- Input chain now accepts WAN traffic for every port in ports.toml so
  external access (SSH, HTTP, HTTPS, game ports) works through the eero's
  upstream port forwards during phase 1, and via our own DNAT in phase 2.
- Add AdGuard DNS rewrite nordhammer.it → 192.168.4.25 so LAN clients
  hit the mediaserver directly instead of relying on eero hairpin NAT.
  Target changes to 10.0.0.1 at phase 2 cutover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 10:22:56 +01:00

177 lines
6.1 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 so they're easy to edit.
{ config, lib, pkgs, ... }:
let
portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
destDefault = portsData.dest_default;
# Phase 1 transition: the mediaserver is still a DHCP client on the eero's
# network (192.168.4.0/22), and existing clients reach it via eno1. Trust
# those subnets as input sources so SSH + AdGuard DNS keep working.
# After cutover to eero bridge mode (phase 2), set this to [] — eno1
# becomes strictly WAN-only.
trustedLegacyCidrs = [ "192.168.4.0/22" ];
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
};
# --- 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;
ct state established,related accept
ct state invalid drop
iifname "lo" accept
# LAN is trusted
iifname "eth0" 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;
ct state established,related accept
ct state invalid drop
# LAN anywhere
iifname "eth0" accept
# WAN LAN only if it was DNAT'd by a port-forward rule
iifname "eno1" oifname "eth0" ct status dnat accept
}
chain output {
type filter hook output priority 0; policy accept;
}
'';
};
tables.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"
];
# Helpful: log leases to the journal
log-dhcp = true;
};
};
};
}