diff --git a/common.nix b/common.nix index 34a4894..32fc978 100644 --- a/common.nix +++ b/common.nix @@ -33,6 +33,7 @@ ./services/homepage.nix ./services/arr-interconnect.nix ./services/adguard.nix + ./services/router.nix ]; ### Make build time quicker diff --git a/ports.toml b/ports.toml new file mode 100644 index 0000000..95da6d4 --- /dev/null +++ b/ports.toml @@ -0,0 +1,36 @@ +# ports.toml — WAN → LAN port forwards for the router (services/router.nix) +# +# Each [[forward]] block adds a DNAT rule from WAN to the LAN IP below. +# Fields: +# name — human label, appears in journal logs +# port — single port (number), e.g. 443 +# ports — port range as a string, e.g. "26901-26902" +# protocol — "tcp", "udp", or "both" +# dest — LAN IP to forward to (optional; defaults to 10.0.0.1) + +dest_default = "10.0.0.1" + +[[forward]] +name = "HTTP" +port = 80 +protocol = "tcp" + +[[forward]] +name = "HTTPS" +port = 443 +protocol = "tcp" + +[[forward]] +name = "SSH" +port = 22 +protocol = "tcp" + +[[forward]] +name = "7DTD game" +port = 26900 +protocol = "both" + +[[forward]] +name = "7DTD voice/dynamic" +ports = "26901-26902" +protocol = "udp" diff --git a/services/router.nix b/services/router.nix new file mode 100644 index 0000000..bbf88e8 --- /dev/null +++ b/services/router.nix @@ -0,0 +1,153 @@ +# 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; + + # 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; + +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 + # 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; + }; + }; + + }; +}