From 23147546552eea151ea97b92a2a5dba3890b05b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Sat, 26 Sep 2026 12:48:51 +0800 Subject: [PATCH] fix(youko): run transmission in its own network namespace (#324) --- .typos.toml | 3 + .../homelab-services/README.md | 23 ++-- .../homelab-services/caddy.nix | 4 +- .../homelab-services/transmission.nix | 103 +++++++++++++++++- 4 files changed, 123 insertions(+), 10 deletions(-) diff --git a/.typos.toml b/.typos.toml index 57582c3c..ec378d48 100644 --- a/.typos.toml +++ b/.typos.toml @@ -42,4 +42,7 @@ extend-ignore-re = [ "f5e0dc", "f9e2af", "fab387", + + # The upstream dictionary maps UPnP to "UOnP", which is wrong; keep UPnP. + "UPnP", ] diff --git a/hosts/12kingdoms-youko/homelab-services/README.md b/hosts/12kingdoms-youko/homelab-services/README.md index 2b6f7fc1..2916b72a 100644 --- a/hosts/12kingdoms-youko/homelab-services/README.md +++ b/hosts/12kingdoms-youko/homelab-services/README.md @@ -21,13 +21,22 @@ Services: 1. victoriametrics + vmalert + alertmanager + grafana: Monitor the metrics of my homelab. 1. homepage + uptime-kuma: Service dashboard and uptime checks. -All the services assumes a reverse proxy to be setup in the front, they are all listening on -localhost, and a caddy service is listening on the local network interface and proxy the requests to -the services. +All the services assume a reverse proxy in front: they listen on localhost, and a caddy service +listens on the local network interface and proxies requests to them. The exception is transmission, +which runs in its own network namespace and binds its RPC to `192.168.5.118`. + +## transmission networking + +transmission deliberately does not use the host's default gateway `suzi` (192.168.5.178): it is a +mihomo transparent proxy that hijacks DNS to fake IPs (only routable through the proxy) and has no +UPnP/NAT-PMP, so a proxied transmission can never accept inbound peers. + +Instead the service runs in its own network namespace, with a veth on `br0`, the address +`192.168.5.118`, a default route at the main router (`192.168.5.1`), a real resolver, and a fixed +MAC for a stable IPv6 IID. It listens on `192.168.5.118:51413` (TCP/UDP and IPv6) and caddy reaches +its RPC at `192.168.5.118:9091`. See `transmission.nix` for the implementation. ## TODO -- transmission has no reachable inbound port: youko's default gateway is `suzi` (192.168.5.178, - mihomo transparent proxy, no UPnP/NAT-PMP) and its traffic is proxied, so - `port-forwarding-enabled` never maps 51413. Fix by routing transmission **direct** and using its - public IPv6 (or a static v4 port-forward on the main router, or a port-forwarding VPN). +- Forward `51413/tcp+udp` to `192.168.5.118` on the main router, or enable UPnP there. NAT-PMP and + UPnP now target the main router, which is reachable from the namespace. diff --git a/hosts/12kingdoms-youko/homelab-services/caddy.nix b/hosts/12kingdoms-youko/homelab-services/caddy.nix index aaee1534..13f31049 100644 --- a/hosts/12kingdoms-youko/homelab-services/caddy.nix +++ b/hosts/12kingdoms-youko/homelab-services/caddy.nix @@ -160,7 +160,9 @@ in virtualHosts."transmission.writefor.fun".extraConfig = '' ${privateTlsConfig} encode zstd gzip - reverse_proxy http://localhost:9091 + # transmission runs in its own network namespace (see transmission.nix), + # so its RPC is reached over that namespace address, not localhost. + reverse_proxy http://192.168.5.118:9091 ''; # Monitoring diff --git a/hosts/12kingdoms-youko/homelab-services/transmission.nix b/hosts/12kingdoms-youko/homelab-services/transmission.nix index 144c74a8..cb73569b 100644 --- a/hosts/12kingdoms-youko/homelab-services/transmission.nix +++ b/hosts/12kingdoms-youko/homelab-services/transmission.nix @@ -1,5 +1,6 @@ { config, + lib, myvars, pkgs, ... @@ -7,6 +8,32 @@ let dataDir = "/data/fileshare/public/transmission"; name = "transmission"; + + # Transmission runs in its own network namespace with a direct path to the + # main router: the default host gateway is suzi, a transparent proxy that + # hijacks DNS to fake IPs (which only resolve on the proxied path) and has no + # UPnP/NAT-PMP. In the namespace it gets its own address, a real resolver, and + # a default route at the main router, so peers can reach it and it can resolve + # real addresses without going through the proxy. + netns = "transmission"; + nsIp = "192.168.5.118"; + hostVeth = "tr-hveth"; + nsVeth = "tr-nveth"; + + # Real resolvers for the namespace (v4 first; the host's resolver is suzi). + resolvConf = pkgs.writeText "transmission-resolv.conf" ( + lib.concatMapStrings (ns: "nameserver ${ns}\n") myvars.networking.nameservers + ); + + # Drop nss-resolve from the host's nsswitch for this service only: it talks to + # the host's resolved over a unix socket, which would resolve through suzi and + # return fake IPs again, bypassing the resolvers above. + nsswitchConf = pkgs.runCommand "transmission-nsswitch.conf" { } '' + sed -E 's/ resolve \[!UNAVAIL=return\]//' ${config.environment.etc."nsswitch.conf".source} > $out + ''; + + ip = "${pkgs.iproute2}/bin/ip"; + sysctl = "${pkgs.procps}/bin/sysctl"; in { # Join the shared fileshare group so transmission can read/write files @@ -66,7 +93,10 @@ in # rpc = Web Interface rpc-port = 9091; - rpc-bind-address = "127.0.0.1"; + # The netns has its own loopback, so caddy (on the host) reaches the RPC + # over the namespace address instead of 127.0.0.1. Plain HTTP on the LAN; + # the RPC auth and whitelists below still apply. + rpc-bind-address = nsIp; anti-brute-force-enabled = true; # After this amount of failed authentication attempts is surpassed, # the RPC server will deny any further authentication attempts until it is restarted. @@ -103,7 +133,11 @@ in lpd-enabled = true; # The peer port to listen for incoming connections. peer-port = 51413; - # Enable UOnP or NAT-PMP to forward a port through your firewall(NAT). + # Bind peer sockets to the namespace address, so the listener, outgoing + # source, and UPnP/NAT-PMP advertisement all use the address the main + # router forwards 51413 to. + bind-address-ipv4 = nsIp; + # Enable UPnP or NAT-PMP to forward a port through your firewall(NAT). # https://github.com/transmission/transmission/blob/main/docs/Port-Forwarding-Guide.md port-forwarding-enabled = true; @@ -137,6 +171,71 @@ in }; }; + # Create the namespace and its veth (the host end becomes another port on + # br0), so the service below can join it and appear as its own LAN host. + systemd.services.transmission-netns = { + description = "Network namespace for transmission"; + wantedBy = [ "multi-user.target" ]; + after = [ "systemd-networkd.service" ]; + preStart = '' + for _ in $(seq 1 30); do + ${ip} link show br0 >/dev/null 2>&1 && break + sleep 1 + done + ${ip} link show br0 >/dev/null 2>&1 || { echo "br0 is not ready" >&2; exit 1; } + ''; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStop = "${ip} netns del ${netns}"; + }; + script = '' + set -euo pipefail + # idempotent: clean up leftovers from an unclean stop + ${ip} netns del ${netns} 2>/dev/null || true + ${ip} link del ${hostVeth} 2>/dev/null || true + + ${ip} netns add ${netns} + ${ip} link add ${hostVeth} type veth peer name ${nsVeth} + ${ip} link set ${hostVeth} master br0 + ${ip} link set ${hostVeth} up + ${ip} link set ${nsVeth} netns ${netns} + + ${ip} -n ${netns} link set lo up + # Pin the MAC so the SLAAC-based IPv6 address is stable across reboots. + ${ip} -n ${netns} link set ${nsVeth} address 02:00:00:00:76:01 + # Keep a stable address: temporary addresses rotate and would not match + # what transmission listens on and advertises. + ${ip} netns exec ${netns} ${sysctl} -q -w net.ipv6.conf.${nsVeth}.use_tempaddr=0 || true + ${ip} -n ${netns} link set ${nsVeth} up + ${ip} -n ${netns} addr add ${nsIp}/24 dev ${nsVeth} + ${ip} -n ${netns} route add default via ${myvars.networking.mainGateway} + ''; + }; + + # Join the namespace and resolve through real resolvers instead of suzi. + systemd.services.transmission = { + after = [ "transmission-netns.service" ]; + requires = [ "transmission-netns.service" ]; + # restart together with the namespace; otherwise the service would stay in + # the deleted namespace after the netns unit is recreated + partOf = [ "transmission-netns.service" ]; + serviceConfig = { + NetworkNamespacePath = "/run/netns/${netns}"; + # Joining a namespace owned by the host is incompatible with the module's + # default PrivateUsers=; the rest of the sandbox stays in place. + PrivateUsers = false; + # Resolve directly instead of through the host's systemd-resolved (suzi). + BindReadOnlyPaths = [ + "${resolvConf}:/etc/resolv.conf" + "${nsswitchConf}:/etc/nsswitch.conf" + ]; + # glibc's resolver uses netlink to pick source addresses; the module's + # default set does not include it. + RestrictAddressFamilies = [ "AF_NETLINK" ]; + }; + }; + # The /data mounts are `nofail`, so nothing orders transmission after them; # without this the unit can start before /data/fileshare/public is mounted and # fail its mount-namespace setup (seen on the host, where the tasks race).