diff --git a/devices.toml b/devices.toml index af52d3a..30ec0fc 100644 --- a/devices.toml +++ b/devices.toml @@ -8,10 +8,17 @@ # Must be OUTSIDE the DHCP pool 10.0.0.100-250. # blocked — true drops all traffic from this MAC on the LAN interface. # Omitted rather than written as false, which is what the UI does. +# isolated — true refuses to route anything for this MAC: no internet, no +# other LAN host. It can still reach services on the router +# itself (DHCP, DNS, and go2rtc pulling its feed all terminate +# here, so they never hit the forward chain). Stand-in for the +# camera VLAN this network can't build. Unlike `blocked`, the +# device keeps working for its one intended job. # note — free text, shown in the UI only [[device]] mac = "f0:a7:31:6c:50:4b" name = "camera-bedroom" ip = "10.0.0.39" -note = "go2rtc / RTSP source" +isolated = true +note = "go2rtc / RTSP source; no route off-LAN — see isolated flag above" diff --git a/services/go2rtc.nix b/services/go2rtc.nix index 2db8d52..d3de231 100644 --- a/services/go2rtc.nix +++ b/services/go2rtc.nix @@ -1,13 +1,30 @@ # services/go2rtc.nix — Native go2rtc camera streaming # RTSP credentials kept out of the nix store via runtime config templating +# +# Every listener binds to loopback. go2rtc's API has no authentication of its +# own and `GET /api/streams` returns each producer URL verbatim — for this +# camera that string contains its RTSP username and password. A bare ":1984" +# binds every interface, and the router's input chain accepts the whole LAN +# ("iifname eth0 accept" in services/router.nix), so any device on the wifi +# could read those credentials and then talk to the camera directly, out of +# band of this server entirely. Port 8554 leaked the video itself the same way. +# +# Nothing off-box needs these: nginx reverse-proxies 1984 from localhost behind +# Authelia (camera.nordhammer.it) and frigate.nix, when it is enabled, pulls +# rtsp://127.0.0.1:8554. Browsers lose direct WebRTC and fall back to MSE over +# the proxied websocket — a little more latency, no loss of function. { config, lib, pkgs, ... }: let + listeners = { + api.listen = "127.0.0.1:1984"; + rtsp.listen = "127.0.0.1:8554"; + webrtc.listen = "127.0.0.1:8555"; + }; + # Template config with placeholder — real URL injected at runtime - configTemplate = pkgs.writeText "go2rtc-template.yaml" (builtins.toJSON { - streams.kids_bedroom = "@RTSP_URL@"; - api.listen = ":1984"; - webrtc.listen = ":8555"; - }); + configTemplate = pkgs.writeText "go2rtc-template.yaml" (builtins.toJSON ( + listeners // { streams.kids_bedroom = "@RTSP_URL@"; } + )); injectSecrets = pkgs.writeShellScript "go2rtc-inject-secrets" '' set -euo pipefail @@ -24,12 +41,11 @@ in { config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + # settings is not what the daemon reads (ExecStart below points at the + # templated config), but keep it identical so the two can't drift. services.go2rtc = { enable = true; - settings = { - api.listen = ":1984"; - webrtc.listen = ":8555"; - }; + settings = listeners; }; # Override to use runtime-templated config with secrets diff --git a/services/router.nix b/services/router.nix index 20aa64d..1c4e9c5 100644 --- a/services/router.nix +++ b/services/router.nix @@ -37,6 +37,25 @@ let (d: ''iifname "eth0" ether saddr ${d.mac} drop comment "${d.name} blocked"'') blockedDevices; + isolatedDevices = builtins.filter (d: d.isolated or false) devices; + + # Forward-chain-only drop: the device may still talk to the router itself, + # but the router will not carry its traffic anywhere. For the IP camera that + # means go2rtc keeps pulling RTSP (router is the endpoint, so that path never + # reaches the forward hook) while the camera loses the internet and every + # other LAN host — the isolation a separate VLAN would have given us. + # + # Emitted at the TOP of the forward chain for the same reason blockRules are: + # placed after `ct state established,related accept`, an already-open session + # to the vendor cloud would survive indefinitely. + # + # ponytail: MAC-based like blockRules, so it inherits the same weakness — a + # device that randomises its MAC walks around it. Fine for an IP camera, + # which won't; match on the DHCP-reserved IP too if that ever changes. + isolateRules = lib.concatMapStringsSep "\n " + (d: ''iifname "eth0" ether saddr ${d.mac} drop comment "${d.name} isolated (no routing)"'') + isolatedDevices; + # Phase-1 transition list; empty now that eero is in bridge mode and # eno1 is strictly the ISP-facing WAN. trustedLegacyCidrs = [ ]; @@ -187,8 +206,9 @@ in } chain forward { type filter hook forward priority 0; policy drop; - # Blocked devices first — before the conntrack accept. + # Blocked and isolated devices first — before the conntrack accept. ${blockRules} + ${isolateRules} ct state established,related accept ct state invalid drop # LAN → anywhere