Turn mediaserver into a home router
Adds services/router.nix with systemd-networkd (eno1=WAN via DHCP,
eth0=LAN 10.0.0.1/24), nftables (NAT + firewall, default drop on WAN
in), dnsmasq (DHCP only — AdGuard Home keeps :53 for DNS), and sysctl
IP forwarding. NetworkManager is forced off on this host.
Port forwards live in ports.toml at the repo root and are imported via
builtins.fromTOML. Supports single ports, ranges ("26901-26902"), and
"both" protocol. Initial forwards: 22, 80, 443, 26900, 26901-26902.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:48:38 +01:00
|
|
|
# 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;
|
|
|
|
|
|
2026-04-24 10:52:11 +01:00
|
|
|
# Phase-1 transition list; empty now that eero is in bridge mode and
|
|
|
|
|
# eno1 is strictly the ISP-facing WAN.
|
|
|
|
|
trustedLegacyCidrs = [ ];
|
2026-04-24 10:17:06 +01:00
|
|
|
|
|
|
|
|
legacyTrustRules = lib.concatMapStringsSep "\n "
|
|
|
|
|
(cidr: ''iifname "eno1" ip saddr ${cidr} accept'')
|
|
|
|
|
trustedLegacyCidrs;
|
|
|
|
|
|
Turn mediaserver into a home router
Adds services/router.nix with systemd-networkd (eno1=WAN via DHCP,
eth0=LAN 10.0.0.1/24), nftables (NAT + firewall, default drop on WAN
in), dnsmasq (DHCP only — AdGuard Home keeps :53 for DNS), and sysctl
IP forwarding. NetworkManager is forced off on this host.
Port forwards live in ports.toml at the repo root and are imported via
builtins.fromTOML. Supports single ports, ranges ("26901-26902"), and
"both" protocol. Initial forwards: 22, 80, 443, 26900, 26901-26902.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:48:38 +01:00
|
|
|
# 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;
|
|
|
|
|
|
2026-04-24 10:22:56 +01:00
|
|
|
# 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;
|
|
|
|
|
|
Turn mediaserver into a home router
Adds services/router.nix with systemd-networkd (eno1=WAN via DHCP,
eth0=LAN 10.0.0.1/24), nftables (NAT + firewall, default drop on WAN
in), dnsmasq (DHCP only — AdGuard Home keeps :53 for DNS), and sysctl
IP forwarding. NetworkManager is forced off on this host.
Port forwards live in ports.toml at the repo root and are imported via
builtins.fromTOML. Supports single ports, ranges ("26901-26902"), and
"both" protocol. Initial forwards: 22, 80, 443, 26900, 26901-26902.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:48:38 +01:00
|
|
|
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
|
2026-04-30 20:47:46 +01:00
|
|
|
# Docker containers reaching host services (e.g. Profilarr → Radarr
|
2026-05-01 19:10:29 +01:00
|
|
|
# on 10.0.0.1:7878). The forward chain already trusts docker bridges
|
|
|
|
|
# for outbound; this is the matching INPUT rule for traffic landing
|
|
|
|
|
# on the host's own LAN/bridge IPs from a container. The br-* glob
|
|
|
|
|
# covers user-defined networks (the Forgejo runner creates one per
|
|
|
|
|
# workflow), so it doesn't fall through to the default-deny policy.
|
|
|
|
|
iifname { "docker0", "br-*" } accept
|
2026-04-24 10:17:06 +01:00
|
|
|
# Phase 1: also trust the existing eero subnet on eno1 so SSH
|
|
|
|
|
# and AdGuard DNS keep working during the transition.
|
|
|
|
|
${legacyTrustRules}
|
2026-04-24 10:22:56 +01:00
|
|
|
# Accept WAN traffic for ports we publicly expose (ports.toml).
|
|
|
|
|
${wanPortInputRules}
|
Turn mediaserver into a home router
Adds services/router.nix with systemd-networkd (eno1=WAN via DHCP,
eth0=LAN 10.0.0.1/24), nftables (NAT + firewall, default drop on WAN
in), dnsmasq (DHCP only — AdGuard Home keeps :53 for DNS), and sysctl
IP forwarding. NetworkManager is forced off on this host.
Port forwards live in ports.toml at the repo root and are imported via
builtins.fromTOML. Supports single ports, ranges ("26901-26902"), and
"both" protocol. Initial forwards: 22, 80, 443, 26900, 26901-26902.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:48:38 +01:00
|
|
|
# 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
|
2026-05-01 19:10:29 +01:00
|
|
|
# Docker containers → anywhere (needed for image pulls, LinuxGSM
|
|
|
|
|
# bootstrap, Forgejo runner workflows, etc.). br-* matches the
|
|
|
|
|
# user-defined bridges Docker creates for custom networks.
|
|
|
|
|
iifname { "docker0", "br-*" } accept
|
2026-04-26 19:42:15 +01:00
|
|
|
# WAN → any port-forward target (LAN host or docker container)
|
|
|
|
|
iifname "eno1" ct status dnat accept
|
Turn mediaserver into a home router
Adds services/router.nix with systemd-networkd (eno1=WAN via DHCP,
eth0=LAN 10.0.0.1/24), nftables (NAT + firewall, default drop on WAN
in), dnsmasq (DHCP only — AdGuard Home keeps :53 for DNS), and sysctl
IP forwarding. NetworkManager is forced off on this host.
Port forwards live in ports.toml at the repo root and are imported via
builtins.fromTOML. Supports single ports, ranges ("26901-26902"), and
"both" protocol. Initial forwards: 22, 80, 443, 26900, 26901-26902.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:48:38 +01:00
|
|
|
}
|
|
|
|
|
chain output {
|
|
|
|
|
type filter hook output priority 0; policy accept;
|
|
|
|
|
}
|
|
|
|
|
'';
|
|
|
|
|
};
|
2026-04-26 19:43:33 +01:00
|
|
|
# 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 = {
|
Turn mediaserver into a home router
Adds services/router.nix with systemd-networkd (eno1=WAN via DHCP,
eth0=LAN 10.0.0.1/24), nftables (NAT + firewall, default drop on WAN
in), dnsmasq (DHCP only — AdGuard Home keeps :53 for DNS), and sysctl
IP forwarding. NetworkManager is forced off on this host.
Port forwards live in ports.toml at the repo root and are imported via
builtins.fromTOML. Supports single ports, ranges ("26901-26902"), and
"both" protocol. Initial forwards: 22, 80, 443, 26900, 26901-26902.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:48:38 +01:00
|
|
|
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"
|
|
|
|
|
];
|
2026-04-24 10:52:11 +01:00
|
|
|
# Static reservations — format: "MAC,label,IP"
|
|
|
|
|
dhcp-host = [
|
|
|
|
|
"f0:a7:31:6c:50:4b,camera-bedroom,10.0.0.39"
|
|
|
|
|
];
|
Turn mediaserver into a home router
Adds services/router.nix with systemd-networkd (eno1=WAN via DHCP,
eth0=LAN 10.0.0.1/24), nftables (NAT + firewall, default drop on WAN
in), dnsmasq (DHCP only — AdGuard Home keeps :53 for DNS), and sysctl
IP forwarding. NetworkManager is forced off on this host.
Port forwards live in ports.toml at the repo root and are imported via
builtins.fromTOML. Supports single ports, ranges ("26901-26902"), and
"both" protocol. Initial forwards: 22, 80, 443, 26900, 26901-26902.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:48:38 +01:00
|
|
|
# Helpful: log leases to the journal
|
|
|
|
|
log-dhcp = true;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
};
|
|
|
|
|
}
|