From 04bfb2ccf5f6054a40ed3293b2285178690f99c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20He=C3=9F?= Date: Mon, 24 Nov 2025 16:48:58 +0100 Subject: [PATCH 1/3] nixos/nix-daemon-firewall: Add a module to firewall FODs --- nixos/modules/module-list.nix | 1 + .../services/system/nix-daemon-firewall.nix | 141 +++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/nix-daemon-firewall.nix | 238 ++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 nixos/modules/services/system/nix-daemon-firewall.nix create mode 100644 nixos/tests/nix-daemon-firewall.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 0e27de7945bf..136da502782b 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1517,6 +1517,7 @@ ./services/system/earlyoom.nix ./services/system/kerberos/default.nix ./services/system/localtimed.nix + ./services/system/nix-daemon-firewall.nix ./services/system/nix-daemon.nix ./services/system/nscd.nix ./services/system/nvme-rs.nix diff --git a/nixos/modules/services/system/nix-daemon-firewall.nix b/nixos/modules/services/system/nix-daemon-firewall.nix new file mode 100644 index 000000000000..1d738ae27f9c --- /dev/null +++ b/nixos/modules/services/system/nix-daemon-firewall.nix @@ -0,0 +1,141 @@ +{ config, lib, ... }: +let + + cfg = config.nix.firewall; + +in +{ + options.nix.firewall = { + enable = lib.mkEnableOption "firewalling for outgoing traffic of the nix daemon"; + + allowLoopback = lib.mkOption { + description = "Whether not not to allow traffic on the loopback interface. Traffic is still subject to protocol/port rules"; + default = false; + example = true; + }; + + allowPrivateNetworks = lib.mkOption { + description = "Whether not not to allow traffic to local networks. Traffic is still subject to protocol/port rules. Note that this option may break DNS resolution when the DNS resolver is in a local network"; + default = true; + example = false; + }; + + allowNonTCPUDP = lib.mkOption { + description = "Whether to allow traffic that is neither TCP nor UDP"; + type = lib.types.bool; + default = false; + example = true; + }; + + allowedTCPPorts = lib.mkOption { + description = "TCP ports to which traffic is allowed. Specifying no ports will allow all TCP traffic"; + type = lib.types.listOf ( + lib.types.oneOf [ + lib.types.singleLineStr + lib.types.port + ] + ); + default = [ ]; + example = [ + "http" + 443 + "30000-31000" + ]; + }; + + allowedUDPPorts = lib.mkOption { + description = "UDP ports to which traffic is allowed. Specifying no ports will allow all UDP traffic"; + type = lib.types.listOf ( + lib.types.oneOf [ + lib.types.singleLineStr + lib.types.port + ] + ); + default = [ ]; + example = [ 53 ]; + }; + + extraNftablesRules = lib.mkOption { + description = "Extra nftables rules to prepend to the generated ones"; + type = lib.types.listOf lib.types.singleLineStr; + default = [ ]; + example = [ "ip daddr 1.1.1.1 udp dport accept" ]; + }; + }; + + config = lib.mkIf cfg.enable { + # Ensure we can properly use nftables + assertions = [ + { + assertion = config.networking.nftables.enable; + message = '' + The nix-daemon firewall requires an nftables-based firewall. + networking.nftables.enable must be set to true. + ''; + } + { + assertion = !config.networking.nftables.flushRuleset; + message = '' + The nix-daemon firewall writes extra tables to nftables. + networking.nftables.flushRuleset must be set to false. + ''; + } + ]; + + systemd.services.nix-daemon = { + after = [ "nftables.service" ]; + # Add the cgroup ID to a nft set on daemon start + serviceConfig.NFTSet = "cgroup:inet:nix_daemon_firewall:nix_daemon"; + }; + + # Generate nftables rules + networking.nftables.ruleset = '' + table inet nix_daemon_firewall { + set nix_daemon { + type cgroupsv2 + } + chain output { + type filter hook output priority 0; + socket cgroupv2 level 2 @nix_daemon goto nix_daemon_traffic + accept + } + chain nix_daemon_traffic { + # Extra rules + ${lib.concatStringsSep "\n" cfg.extraNftablesRules} + + # Loopback + ${lib.optionalString (!cfg.allowLoopback) "ip daddr 127.0.0.0/8 counter drop"} + ${lib.optionalString (!cfg.allowLoopback) "ip6 daddr ::1 counter drop"} + + # Local networks + ${lib.optionalString ( + !cfg.allowPrivateNetworks + ) "ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 } counter drop"} + ${lib.optionalString ( + !cfg.allowPrivateNetworks + ) "ip6 daddr { fd00::/8, fe80::/10 } counter drop"} + + # TCP + ${lib.optionalString (cfg.allowedTCPPorts != [ ]) '' + tcp dport { ${lib.concatStringsSep ", " (map builtins.toString cfg.allowedTCPPorts)} } accept + ip protocol tcp counter drop + ip6 nexthdr tcp counter drop + ''} + + # UDP + ${lib.optionalString (cfg.allowedUDPPorts != [ ]) '' + udp dport { ${lib.concatStringsSep ", " (map builtins.toString cfg.allowedUDPPorts)} } accept + ip protocol udp counter drop + ip6 nexthdr udp counter drop + ''} + + # Non-TCP and non-UDP + ${lib.optionalString (!cfg.allowNonTCPUDP) "ip protocol != { tcp, udp } counter drop"} + ${lib.optionalString (!cfg.allowNonTCPUDP) "ip6 nexthdr != { tcp, udp } counter drop"} + + accept + } + } + ''; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index c26c0d48644c..23a46d241f27 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1067,6 +1067,7 @@ in nitter = runTest ./nitter.nix; nix-channel = pkgs.callPackage ../modules/config/nix-channel/test.nix { }; nix-config = runTest ./nix-config.nix; + nix-daemon-firewall = runTest ./nix-daemon-firewall.nix; nix-ld = runTest ./nix-ld.nix; nix-misc = handleTest ./nix/misc.nix { }; nix-required-mounts = runTest ./nix-required-mounts; diff --git a/nixos/tests/nix-daemon-firewall.nix b/nixos/tests/nix-daemon-firewall.nix new file mode 100644 index 000000000000..787ef54a344c --- /dev/null +++ b/nixos/tests/nix-daemon-firewall.nix @@ -0,0 +1,238 @@ +{ lib, pkgs, ... }: +let + # Hashes for the test files we serve. We have multiple of them to force nix + # to download the files instead of using the already-downloaded variant + hashes = { + a = "sha256-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs="; + b = "sha256-PiPoFgA5WUoziU9lZOGxNIu9egCI1CxKy3PurtWcAJ0="; + c = "sha256-Ln0sA6lQeuJl7PW1NWiFpTOTogKdJBOUmXJloaJa78Y="; + }; + + # Builds a .nix file that can download a file from the test server + mkFetcher = + { + name, + url, + hash, + }: + pkgs.writeText "${name}.nix" '' + derivation { + name = "${name}-fetch"; + builder = "builtin:fetchurl"; + system = "builtin"; + preferLocalBuild = true; + + url = "${url}"; + outputHash = "${hash}"; + outputHashMode = "flat"; + } + ''; + + # Fetches a file from the public IP, port 80 + publicFetcher = mkFetcher { + name = "public"; + url = "http://1.0.0.1/a"; + hash = hashes.a; + }; + + # Fetches a file from the private IP, port 80 + privateFetcher = mkFetcher { + name = "private"; + url = "http://192.168.0.1/b"; + hash = hashes.b; + }; + + # Fetches a file from the public IP, port 81 + publicFetcherOtherPort = mkFetcher { + name = "public"; + url = "http://192.168.0.1:81/c"; + hash = hashes.c; + }; + + # Fetches a file from localhost + localhostFetcher = mkFetcher { + name = "localhost"; + url = "http://127.0.0.1/a"; + hash = hashes.a; + }; + + # Generates a file but tires to resolve via DNS first + resolver = pkgs.writeText "pinger.nix" '' + derivation { + name = "resolver"; + + system = builtins.currentSystem; + preferLocalBuild = true; + + builder = "/bin/sh"; + args = [ "-c" "${pkgs.pkgsStatic.busybox}/bin/nslookup . && echo hello > $out" ]; + + outputHash = "sha256-WJG1tSLV3whtD/CxEPvZ0hu0/HFjrzTQgoai6Eb2vgM="; + outputHashMode = "flat"; + } + ''; + + # Base system for all fetchers + baseNixSystem = ipSuffix: { + networking = { + useDHCP = false; + interfaces.eth1 = { + ipv4.addresses = [ + { + address = "192.168.0.${toString ipSuffix}"; + prefixLength = 24; + } + { + address = "1.0.0.${toString ipSuffix}"; + prefixLength = 24; + } + ]; + }; + nftables = { + enable = true; + flushRuleset = false; + }; + }; + + nix.settings = { + # Gets rid of substitution warnings + substituters = lib.mkForce [ ]; + # Gives us access inside the nix sandbox + extra-sandbox-paths = [ "${pkgs.pkgsStatic.busybox}" ]; + }; + + # Easy way to get files to the system + environment.etc = { + "fetch-public.nix".source = publicFetcher; + "fetch-private.nix".source = privateFetcher; + "fetch-public-other-port.nix".source = publicFetcherOtherPort; + "fetch-localhost.nix".source = localhostFetcher; + "resolver.nix".source = resolver; + }; + + # For localhost tests + services.nginx = { + enable = true; + virtualHosts.default.locations."/a".return = "200 \"a\""; + }; + }; +in +{ + name = "nix-daemon-firewall"; + meta.maintainers = with lib.maintainers; [ das_j ]; + + nodes = { + httpServer = { + services.nginx = { + enable = true; + virtualHosts.default = { + listen = [ + { + addr = "0.0.0.0"; + port = 80; + } + { + addr = "0.0.0.0"; + port = 81; + } + ]; + locations = { + "/a".return = "200 \"a\""; + "/b".return = "200 \"b\""; + "/c".return = "200 \"c\""; + }; + }; + }; + networking = { + useDHCP = false; + interfaces.eth1 = { + ipv4.addresses = [ + { + address = "192.168.0.1"; + prefixLength = 24; + } + { + address = "1.0.0.1"; + prefixLength = 24; + } + ]; + }; + + firewall.allowedTCPPorts = [ + 80 + 81 + ]; + }; + }; + + unfirewalled = { + imports = [ (baseNixSystem 2) ]; + }; + + everythingAllowed = { + imports = [ (baseNixSystem 3) ]; + nix.firewall = { + enable = true; + allowLoopback = true; + allowNonTCPUDP = true; + }; + }; + + noLocalNets = { + imports = [ (baseNixSystem 4) ]; + nix.firewall = { + enable = true; + allowPrivateNetworks = false; + }; + }; + + onlyPort80 = { + imports = [ (baseNixSystem 5) ]; + nix.firewall = { + enable = true; + allowedTCPPorts = [ 80 ]; + }; + }; + + resolved = { + imports = [ (baseNixSystem 6) ]; + nix.firewall = { + enable = true; + }; + services.resolved.enable = true; + }; + }; + + testScript = '' + # Startup + start_all() + httpServer.wait_for_open_port(80) + + # No firewall + unfirewalled.wait_for_unit("multi-user.target") + unfirewalled.fail("nft list ruleset | grep nix_daemon_traffic") + + # Everything allowed + everythingAllowed.wait_for_unit("multi-user.target") + everythingAllowed.succeed("nft list ruleset | grep nix_daemon_traffic") + everythingAllowed.succeed("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-public.nix") + everythingAllowed.succeed("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-private.nix") + everythingAllowed.succeed("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-public-other-port.nix") + everythingAllowed.succeed("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-localhost.nix") + + # No local networks and no localhost + noLocalNets.wait_for_unit("multi-user.target") + noLocalNets.succeed("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-public.nix") + noLocalNets.fail("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-private.nix") + noLocalNets.fail("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-localhost.nix") + + # Ports + onlyPort80.wait_for_unit("multi-user.target") + onlyPort80.succeed("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-public.nix") + onlyPort80.fail("NIX_REMOTE=daemon nix-build --timeout 5 /etc/fetch-public-other-port.nix") + + # systemd-resolved + resolved.wait_for_unit("multi-user.target") + resolved.succeed("NIX_REMOTE=daemon nix-build --timeout 5 /etc/resolver.nix") + ''; +} From dd9cc36cd012f499ba350a79ff8947b43c1e1008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20He=C3=9F?= Date: Mon, 24 Nov 2025 16:49:15 +0100 Subject: [PATCH 2/3] nixos/resolved: Fix nix-daemon-firewall with resolved --- nixos/modules/system/boot/resolved.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nixos/modules/system/boot/resolved.nix b/nixos/modules/system/boot/resolved.nix index bf72d2f2747f..f11f76ef8f64 100644 --- a/nixos/modules/system/boot/resolved.nix +++ b/nixos/modules/system/boot/resolved.nix @@ -213,6 +213,10 @@ in networking.resolvconf.package = pkgs.systemd; + nix.firewall.extraNftablesRules = [ + "ip daddr { 127.0.0.53, 127.0.0.54 } udp dport 53 accept comment \"systemd-resolved listening IPs\"" + ]; + }) (mkIf config.boot.initrd.services.resolved.enable { From 7ea17890e59684c4be74041e7166687e5ee0d04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20He=C3=9F?= Date: Sat, 29 Nov 2025 21:32:11 +0100 Subject: [PATCH 3/3] nixos/nix-daemon-firewall: Fix firewall checking --- nixos/modules/services/system/nix-daemon-firewall.nix | 8 ++++++-- nixos/tests/nix-daemon-firewall.nix | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/nixos/modules/services/system/nix-daemon-firewall.nix b/nixos/modules/services/system/nix-daemon-firewall.nix index 1d738ae27f9c..9c3945d3c9ef 100644 --- a/nixos/modules/services/system/nix-daemon-firewall.nix +++ b/nixos/modules/services/system/nix-daemon-firewall.nix @@ -9,13 +9,13 @@ in enable = lib.mkEnableOption "firewalling for outgoing traffic of the nix daemon"; allowLoopback = lib.mkOption { - description = "Whether not not to allow traffic on the loopback interface. Traffic is still subject to protocol/port rules"; + description = "Whether to allow traffic on the loopback interface. Traffic is still subject to protocol/port rules"; default = false; example = true; }; allowPrivateNetworks = lib.mkOption { - description = "Whether not not to allow traffic to local networks. Traffic is still subject to protocol/port rules. Note that this option may break DNS resolution when the DNS resolver is in a local network"; + description = "Whether to allow traffic to local networks. Traffic is still subject to protocol/port rules. Note that this option may break DNS resolution when the DNS resolver is in a local network"; default = true; example = false; }; @@ -137,5 +137,9 @@ in } } ''; + # Not supported by LKL yet so the ruleset check would fail + networking.nftables.preCheckRuleset = '' + sed -i 's/socket cgroupv2 level 2 @nix_daemon//g' ruleset.conf + ''; }; } diff --git a/nixos/tests/nix-daemon-firewall.nix b/nixos/tests/nix-daemon-firewall.nix index 787ef54a344c..0b75b9659589 100644 --- a/nixos/tests/nix-daemon-firewall.nix +++ b/nixos/tests/nix-daemon-firewall.nix @@ -56,7 +56,7 @@ let hash = hashes.a; }; - # Generates a file but tires to resolve via DNS first + # Generates a file but tries to resolve via DNS first resolver = pkgs.writeText "pinger.nix" '' derivation { name = "resolver";