Forgejo's runner spins up a per-workflow Docker network for every job, which lives on a br-XXXXXX bridge — not docker0. Without this rule, the in-container git clone (and anything else outbound) hits the forward chain's default-deny and times out. Match docker0 plus the br-* glob in both input and forward so any Docker network model works. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
194 lines
7.1 KiB
Nix
194 lines
7.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 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
|
|
};
|
|
|
|
# --- 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
|
|
# Docker containers reaching host services (e.g. Profilarr → Radarr
|
|
# 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
|
|
# 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
|
|
# 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
|
|
# 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;
|
|
}
|
|
'';
|
|
};
|
|
# 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"
|
|
dhcp-host = [
|
|
"f0:a7:31:6c:50:4b,camera-bedroom,10.0.0.39"
|
|
];
|
|
# Helpful: log leases to the journal
|
|
log-dhcp = true;
|
|
};
|
|
};
|
|
|
|
};
|
|
}
|