├── README.md ├── default.nix ├── nftables-firewall.nix ├── nftables-json-canonicaliser.jq ├── nftables-nat.nix ├── options.nix └── test-vm.nix /README.md: -------------------------------------------------------------------------------- 1 | # nftables for NixOS 2 | 3 | An implementation of the NixOS firewall (`networking.firewall` and 4 | `networking.nat`) on top of nftables instead of iptables. 5 | 6 | Apparently iptables is "legacy", but the most immediate gain is atomic 7 | applications of firewall rules. 8 | 9 | This is only the generation step. It does not integrate with a NixOS 10 | system. 11 | 12 | ## Notes and caveats 13 | 14 | This implementation is a precise port of the firewall from NixOS. It 15 | was ported by hand, but is verified against the output of 16 | `iptables-restore-translate`, which converts `iptables-save` output 17 | into an nft script. For debugging and verifying, see the test cases in 18 | `default.nix` and build the diffs via `nix-build -A diff`. 19 | 20 | nftables offers matching on `ip`, `ip6`, and `inet`, which supports 21 | both. Users probably want to use the `inet` family, since the 22 | distinction between ipv4 and ipv6 isn't normally important, and the 23 | `ip46helper` is gone. However, this port only translates to `ip` and 24 | `ip6` to mimic `iptables` and `ip6tables`. 25 | 26 | In `iptables` every rule implicitly has a counter, while in `nftables` 27 | counters are explicit. In order to match the old behavior (and 28 | `iptables-restore-translate`), all rules have a counter added. 29 | 30 | ## TODO 31 | 32 | - [ ] Test more 33 | - [ ] Upstream 34 | 35 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { nixpkgs ? import {} }: 2 | 3 | 4 | with nixpkgs; 5 | with lib; 6 | 7 | let 8 | canonicalise = name: rules: vmTools.runInLinuxVM ( 9 | runCommand "canonicalise-${name}" { 10 | nativeBuildInputs = [ nftables jq ]; 11 | inherit rules; 12 | } '' 13 | for r in $rules; do 14 | nft -f $r 15 | done 16 | 17 | nft -s list ruleset > $out/rules.nft 18 | 19 | nft -s --json list ruleset | jq -f ${./nftables-json-canonicaliser.jq} > $out/rules.json 20 | '' 21 | ); 22 | 23 | translate = name: cfg: 24 | let 25 | migrated = vmTools.runInLinuxVM ( 26 | runCommand "automatically-migrate-${name}" { 27 | nativeBuildInputs = [ iptables-legacy ]; 28 | } '' 29 | script="${(nixos cfg).config.systemd.services.firewall.serviceConfig.ExecStart}" 30 | script=''${script#@} 31 | eval "$script" 32 | 33 | mkdir -p $out/{iptables,nftables} 34 | 35 | iptables-save > $out/iptables/ip4 36 | ip6tables-save > $out/iptables/ip6 37 | 38 | ${iptables-nftables-compat}/bin/iptables-restore-translate \ 39 | -f $out/iptables/ip4 | tee $out/nftables/ip4 40 | 41 | ${iptables-nftables-compat}/bin/ip6tables-restore-translate \ 42 | -f $out/iptables/ip6 | tee $out/nftables/ip6 43 | '' 44 | ); 45 | in canonicalise name [ "${migrated}/nftables/ip4" "${migrated}/nftables/ip6" ]; 46 | 47 | generate = name: cfg: 48 | let nixosConfig = { imports = [ cfg ./nftables-firewall.nix ./nftables-nat.nix ]; }; 49 | in canonicalise name ((nixos nixosConfig).config.build.debug.nftables.rulesetFile); 50 | 51 | diffConfigs = cfgs: 52 | runCommand "diff" { 53 | nativeBuildInputs = [ diffoscope ]; 54 | } '' 55 | 56 | mkdir -p $out/{translated,generated} 57 | ${concatStrings (flip mapAttrsToList cfgs (name: cfg: '' 58 | cp ${translate name cfg}/rules.json $out/translated/${name}.json 59 | cp ${generate name cfg}/rules.json $out/generated/${name}.json 60 | ''))} 61 | 62 | rc=0 63 | diffoscope --html $out/diff.html \ 64 | --no-default-limits --output-empty \ 65 | $out/translated $out/generated || rc=$? 66 | if [ $rc -ne 1 ] && [ $rc -ne 0 ]; then 67 | exit $rc 68 | fi 69 | ''; 70 | 71 | testCases = { 72 | empty = {}; 73 | 74 | nov6 = { 75 | networking.enableIPv6 = false; 76 | }; 77 | 78 | rejectAndLog = { 79 | networking.firewall = { 80 | rejectPackets = true; 81 | logRefusedPackets = true; 82 | logRefusedUnicastsOnly = false; 83 | logReversePathDrops = true; 84 | }; 85 | }; 86 | 87 | acceptingPorts = { 88 | networking.firewall = { 89 | trustedInterfaces = [ "dummy-trusted" ]; 90 | allowPing = true; 91 | # pingLimit = "???"; 92 | allowedUDPPorts = [ 1001 ]; 93 | allowedTCPPorts = [ 1002 ]; 94 | allowedTCPPortRanges = [ { from = 1100; to = 1105; } ]; 95 | allowedUDPPortRanges = [ { from = 1200; to = 1205; } ]; 96 | interfaces.dummy0 = { 97 | allowedUDPPorts = [ 2001 ]; 98 | allowedTCPPorts = [ 2002 ]; 99 | allowedTCPPortRanges = [ { from = 2100; to = 2105; } ]; 100 | allowedUDPPortRanges = [ { from = 2200; to = 2205; } ]; 101 | }; 102 | }; 103 | }; 104 | 105 | natMasquerade = { 106 | networking.nat = { 107 | internalInterfaces = [ "eth0" ]; 108 | externalInterface = "eth1"; 109 | }; 110 | }; 111 | 112 | natFull = { 113 | networking.nat = { 114 | enable = true; 115 | internalInterfaces = [ "eth0" ]; 116 | internalIPs = [ "192.168.1.0/24" ]; 117 | externalInterface = "eth1"; 118 | externalIP = "203.0.113.123"; 119 | forwardPorts = let 120 | fwds = [ 121 | { sourcePort = 1000; destination = "10.0.0.1"; proto = "tcp"; } 122 | { sourcePort = 1001; destination = "10.0.0.1"; proto = "udp"; } 123 | { sourcePort = "2000:2999"; destination = "10.0.0.1"; proto = "tcp"; } 124 | { sourcePort = "3000:3999"; destination = "10.0.0.1"; proto = "udp"; } 125 | { sourcePort = "4000:4999"; destination = "10.0.0.1:14000-14999"; proto = "tcp"; } 126 | { sourcePort = "5000:5999"; destination = "10.0.0.1:15000-15999"; proto = "udp"; } 127 | ]; 128 | loopbackableFwds = [ 129 | { sourcePort = 1002; destination = "10.0.0.1:80"; proto = "tcp"; } 130 | { sourcePort = 1003; destination = "10.0.0.1:80"; proto = "udp"; } 131 | ]; in ( 132 | fwds 133 | ++ loopbackableFwds 134 | ++ map (x: x // { loopbackIPs = [ "55.1.2.3" ]; }) loopbackableFwds 135 | ); 136 | 137 | dmzHost = "10.0.0.2"; 138 | }; 139 | }; 140 | 141 | # TODO: nat without firewall 142 | }; 143 | 144 | in 145 | { 146 | diff = diffConfigs testCases; 147 | 148 | translated = mapAttrs translate testCases; 149 | 150 | generated = mapAttrs generate testCases; 151 | } 152 | -------------------------------------------------------------------------------- /nftables-firewall.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | 3 | let 4 | inherit (lib) mkOption types flip concatMapStrings optionalString 5 | concatStrings mapAttrsToList mapAttrs optionals; 6 | 7 | inherit (import ./options.nix { inherit lib; }) commonOptions; 8 | 9 | cfg = config.networking.firewall; 10 | 11 | # This is ordered to match iptables-save, so the diffs are smaller. 12 | # Order is not important once this module is no longer an active 13 | # porting job. 14 | defaultConfigs = [ 15 | "ipv4-nat.nft" 16 | "ipv4-raw.nft" 17 | "ipv4-filter.nft" 18 | # "ipv4-mangle.nft" 19 | ] ++ optionals config.networking.enableIPv6 [ 20 | "ipv6-filter.nft" 21 | # "ipv6-mangle.nft" 22 | # "ipv6-nat.nft" 23 | "ipv6-raw.nft" 24 | ]; 25 | 26 | inherit (config.boot.kernelPackages) kernel; 27 | 28 | kernelHasRPFilter = ((kernel.config.isEnabled or (x: false)) "IP_NF_MATCH_RPFILTER") || (kernel.features.netfilterRPFilter or false); 29 | 30 | defaultInterface = { default = mapAttrs (name: value: cfg.${name}) commonOptions; }; 31 | allInterfaces = defaultInterface // cfg.interfaces; 32 | 33 | add46Entity = table: ent: '' 34 | table ip ${table} { 35 | 36 | ${ent "v4"} 37 | 38 | } 39 | 40 | ${optionalString config.networking.enableIPv6 '' 41 | table ip6 ${table} { 42 | 43 | ${ent "v6"} 44 | 45 | } 46 | ''} 47 | ''; 48 | 49 | nixos-fw-accept = family: '' 50 | # The "nixos-fw-accept" chain just accepts packets. 51 | 52 | chain nixos-fw-accept { 53 | counter accept 54 | } 55 | ''; 56 | 57 | nixos-fw-refuse = family: '' 58 | # The "nixos-fw-refuse" chain rejects or drops packets. 59 | 60 | chain nixos-fw-refuse { 61 | 62 | ${if cfg.rejectPackets then '' 63 | # Send a reset for existing TCP connections that we've 64 | # somehow forgotten about. Send ICMP "port unreachable" 65 | # for everything else. 66 | tcp flags & (fin | syn | rst | ack) != syn counter reject with tcp reset 67 | counter reject 68 | '' else '' 69 | counter drop 70 | ''} 71 | 72 | } 73 | ''; 74 | 75 | nixos-fw-log-refuse = family: '' 76 | # The "nixos-fw-log-refuse" chain performs logging, then 77 | # jumps to the "nixos-fw-refuse" chain. 78 | 79 | chain nixos-fw-log-refuse { 80 | 81 | ${optionalString cfg.logRefusedConnections '' 82 | tcp flags & (fin | syn | rst | ack) == syn \ 83 | counter log prefix "refused connection: " level info 84 | ''} 85 | 86 | ${optionalString (cfg.logRefusedPackets && !cfg.logRefusedUnicastsOnly) '' 87 | meta pkttype broadcast counter log prefix "refused broadcast: " level info 88 | meta pkttype multicast counter log prefix "refused multicast: " level info 89 | ''} 90 | 91 | meta pkttype != host counter jump nixos-fw-refuse 92 | 93 | ${optionalString cfg.logRefusedPackets '' 94 | counter log prefix "refused packet: " level info 95 | ''} 96 | 97 | counter jump nixos-fw-refuse 98 | 99 | } 100 | ''; 101 | 102 | nixos-fw-rpfilter = family: '' 103 | # Perform a reverse-path test to refuse spoofers 104 | # For now, we just drop, as the raw table doesn't have a log-refuse yet 105 | chain nixos-fw-rpfilter { 106 | 107 | fib saddr . mark . iif oif != 0 counter return 108 | 109 | ${optionalString (family == "v4") '' 110 | # Allows this host to act as a DHCP4 client without first having to use APIPA 111 | udp sport 67 udp dport 68 counter return 112 | 113 | # Allows this host to act as a DHCPv4 server 114 | ip daddr 255.255.255.255 udp sport 68 udp dport 67 counter return 115 | ''} 116 | 117 | ${optionalString cfg.logReversePathDrops '' 118 | counter log prefix "rpfilter drop: " level info 119 | ''} 120 | 121 | counter drop 122 | } 123 | ''; 124 | 125 | nixos-fw = family: '' 126 | # The "nixos-fw" chain does the actual work. 127 | chain nixos-fw { 128 | 129 | # Accept all traffic on the trusted interfaces. 130 | ${flip concatMapStrings cfg.trustedInterfaces (iface: '' 131 | iifname "${iface}" counter jump nixos-fw-accept 132 | '')} 133 | 134 | # Accept packets from established or related connections. 135 | ct state established,related counter jump nixos-fw-accept 136 | 137 | # Accept connections to the allowed TCP ports. 138 | ${concatStrings (mapAttrsToList (iface: cfg: 139 | concatMapStrings (port: 140 | '' 141 | ${optionalString (iface != "default") ''iifname "${iface}" '' 142 | }tcp dport ${toString port} counter jump nixos-fw-accept 143 | '' 144 | ) cfg.allowedTCPPorts 145 | ) allInterfaces)} 146 | 147 | # Accept connections to the allowed TCP port ranges. 148 | ${concatStrings (mapAttrsToList (iface: cfg: 149 | concatMapStrings (rangeAttr: 150 | let range = toString rangeAttr.from + "-" + toString rangeAttr.to; in 151 | '' 152 | ${optionalString (iface != "default") ''iifname "${iface}" '' 153 | }tcp dport ${range} counter jump nixos-fw-accept 154 | '' 155 | ) cfg.allowedTCPPortRanges 156 | ) allInterfaces)} 157 | 158 | # Accept connections to the allowed UDP ports. 159 | ${concatStrings (mapAttrsToList (iface: cfg: 160 | concatMapStrings (port: 161 | '' 162 | ${optionalString (iface != "default") ''iifname "${iface}" '' 163 | }udp dport ${toString port} counter jump nixos-fw-accept 164 | '' 165 | ) cfg.allowedUDPPorts 166 | ) allInterfaces)} 167 | 168 | # Accept connections to the allowed UDP port ranges. 169 | ${concatStrings (mapAttrsToList (iface: cfg: 170 | concatMapStrings (rangeAttr: 171 | let range = toString rangeAttr.from + "-" + toString rangeAttr.to; in 172 | '' 173 | ${optionalString (iface != "default") ''iifname "${iface}" '' 174 | }udp dport ${range} counter jump nixos-fw-accept 175 | '' 176 | ) cfg.allowedUDPPortRanges 177 | ) allInterfaces)} 178 | 179 | ${optionalString (family == "v4") '' 180 | # Optionally respond to ICMPv4 pings. 181 | ${optionalString cfg.allowPing '' 182 | icmp type echo-request counter jump nixos-fw-accept 183 | ''} 184 | ''} 185 | 186 | ${optionalString (family == "v6") '' 187 | # Accept all ICMPv6 messages except redirects and node 188 | # information queries (type 139). See RFC 4890, section 189 | # 4.4. 190 | icmpv6 type nd-redirect counter drop 191 | meta l4proto 58 counter jump nixos-fw-accept 192 | 193 | # Allow this host to act as a DHCPv6 client 194 | ip6 daddr fe80::/64 udp dport 546 counter jump nixos-fw-accept 195 | ''} 196 | } 197 | ''; 198 | 199 | firewallCfg = pkgs.writeText "rules.nft" '' 200 | flush ruleset 201 | 202 | ${flip concatMapStrings defaultConfigs (configFile: '' 203 | include "${pkgs.nftables}/etc/nftables/${configFile}" 204 | '')} 205 | 206 | ${add46Entity "filter" nixos-fw-accept} 207 | ${add46Entity "filter" nixos-fw-refuse} 208 | ${add46Entity "filter" nixos-fw-log-refuse} 209 | ${optionalString (kernelHasRPFilter && (cfg.checkReversePath != false)) '' 210 | ${add46Entity "raw" nixos-fw-rpfilter} 211 | add rule ip raw prerouting counter jump nixos-fw-rpfilter 212 | ${optionalString config.networking.enableIPv6 '' 213 | add rule ip6 raw prerouting counter jump nixos-fw-rpfilter 214 | ''} 215 | ''} 216 | ${add46Entity "filter" nixos-fw} 217 | 218 | ${config.build.debug.nftables.extraCommands} 219 | 220 | add rule ip filter nixos-fw counter jump nixos-fw-log-refuse 221 | ${optionalString config.networking.enableIPv6 '' 222 | add rule ip6 filter nixos-fw counter jump nixos-fw-log-refuse 223 | ''} 224 | 225 | add rule ip filter input counter jump nixos-fw 226 | ${optionalString config.networking.enableIPv6 '' 227 | add rule ip6 filter input counter jump nixos-fw 228 | ''} 229 | ''; 230 | in 231 | { 232 | options = { 233 | build.debug.nftables = { 234 | rulesetFile = mkOption {type = types.path; }; 235 | extraCommands = mkOption { type = types.lines; default = ""; }; 236 | }; 237 | }; 238 | 239 | config = { 240 | build.debug.nftables.rulesetFile = firewallCfg; 241 | }; 242 | } 243 | -------------------------------------------------------------------------------- /nftables-json-canonicaliser.jq: -------------------------------------------------------------------------------- 1 | def remove_handles: 2 | walk( 3 | if (type == "object") then 4 | del(.handle) 5 | else 6 | . 7 | end 8 | ); 9 | 10 | def downcase_chains: 11 | if(.chain) then 12 | .chain.name |= ascii_downcase 13 | elif(.rule) then 14 | .rule.chain |= ascii_downcase 15 | else 16 | . 17 | end; 18 | 19 | 20 | def element_type: 21 | if .table then "table" 22 | elif .chain then "chain" 23 | elif .rule then "rule" 24 | else "other" 25 | end; 26 | 27 | def group_by_map(f): 28 | group_by(f) 29 | | map({ (.[0] | f): .}) 30 | | add; 31 | 32 | def seperate_elements: 33 | group_by_map(element_type); 34 | 35 | def rule_owner: 36 | "family:\(.family)-table:\(.table)-chain:\(.chain)"; 37 | 38 | .nftables 39 | | remove_handles 40 | | map(downcase_chains) 41 | | seperate_elements 42 | | .table |= sort 43 | | .chain |= sort 44 | | .rule |= group_by_map(.rule | rule_owner) 45 | -------------------------------------------------------------------------------- /nftables-nat.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | 3 | let 4 | inherit (lib) mkIf concatMapStrings optionalString elemAt isInt; 5 | 6 | cfg = config.networking.nat; 7 | 8 | dest = if cfg.externalIP == null then "masquerade" else "snat to ${cfg.externalIP}"; 9 | 10 | oifExternal = optionalString (cfg.externalInterface != null) 11 | ''oifname "${cfg.externalInterface}"''; 12 | 13 | iptablesPortsToNftables = range: 14 | if isInt range then toString range 15 | else let m = builtins.match "([0-9]+):([0-9]+)" range; 16 | in if m == null then range # assume a single port, rely in input validation. 17 | else "${elemAt m 0}-${elemAt m 1}"; 18 | 19 | in 20 | { 21 | 22 | config = mkIf config.networking.nat.enable { 23 | build.debug.nftables.extraCommands = '' 24 | table ip nat { 25 | chain nixos-nat-pre { 26 | # We can't match on incoming interface in POSTROUTING, so 27 | # mark packets coming from the internal interfaces. 28 | ${concatMapStrings (iface: '' 29 | iifname "${iface}" counter meta mark set 1 30 | '') cfg.internalInterfaces} 31 | } 32 | 33 | chain nixos-nat-post { 34 | # NAT the marked packets. 35 | ${optionalString (cfg.internalInterfaces != []) '' 36 | ${oifExternal} meta mark 1 counter ${dest} 37 | ''} 38 | 39 | # NAT packets coming from the internal IPs. 40 | ${concatMapStrings (range: '' 41 | ${oifExternal} ip saddr ${range} counter ${dest} 42 | '') cfg.internalIPs} 43 | } 44 | } 45 | 46 | # NAT from external ports to internal ports. 47 | ${concatMapStrings (fwd: 48 | let nftSourcePort = iptablesPortsToNftables fwd.sourcePort; in 49 | '' 50 | add rule ip nat nixos-nat-pre \ 51 | iifname "${cfg.externalInterface}" ${fwd.proto} dport ${nftSourcePort} \ 52 | counter dnat to ${fwd.destination} 53 | 54 | ${concatMapStrings (loopbackip: 55 | let 56 | m = builtins.match "([0-9.]+):([0-9-]+)" fwd.destination; 57 | destinationIP = if (m == null) then throw "bad ip:ports `${fwd.destination}'" else elemAt m 0; 58 | destinationPorts = if (m == null) then throw "bad ip:ports `${fwd.destination}'" else elemAt m 1; 59 | in '' 60 | # Allow connections to ${loopbackip}:${nftSourcePort} from the host itself 61 | add rule ip nat output \ 62 | ip daddr ${loopbackip} ${fwd.proto} dport ${nftSourcePort} \ 63 | counter dnat to ${fwd.destination} 64 | 65 | # Allow connections to ${loopbackip}:${nftSourcePort} from other hosts behind NAT 66 | add rule ip nat nixos-nat-pre \ 67 | ip daddr ${loopbackip} ${fwd.proto} dport ${nftSourcePort} \ 68 | counter dnat to ${fwd.destination} 69 | 70 | add rule ip nat nixos-nat-post \ 71 | ip daddr ${destinationIP} ${fwd.proto} dport ${iptablesPortsToNftables destinationPorts} \ 72 | counter snat to ${loopbackip} 73 | '') fwd.loopbackIPs} 74 | '') cfg.forwardPorts} 75 | 76 | ${optionalString (cfg.dmzHost != null) '' 77 | add rule ip nat nixos-nat-pre \ 78 | iifname "${cfg.externalInterface}" \ 79 | counter dnat to ${cfg.dmzHost} 80 | ''} 81 | 82 | # Append our chains to the nat tables 83 | add rule ip nat prerouting counter jump nixos-nat-pre 84 | add rule ip nat postrouting counter jump nixos-nat-post 85 | ''; 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /options.nix: -------------------------------------------------------------------------------- 1 | # Extracted from upstream, since I couldn't find a way to export it. 2 | { lib }: 3 | 4 | with lib; 5 | 6 | { 7 | commonOptions = { 8 | allowedTCPPorts = mkOption { 9 | type = types.listOf types.port; 10 | default = [ ]; 11 | apply = canonicalizePortList; 12 | example = [ 22 80 ]; 13 | description = 14 | '' 15 | List of TCP ports on which incoming connections are 16 | accepted. 17 | ''; 18 | }; 19 | 20 | allowedTCPPortRanges = mkOption { 21 | type = types.listOf (types.attrsOf types.port); 22 | default = [ ]; 23 | example = [ { from = 8999; to = 9003; } ]; 24 | description = 25 | '' 26 | A range of TCP ports on which incoming connections are 27 | accepted. 28 | ''; 29 | }; 30 | 31 | allowedUDPPorts = mkOption { 32 | type = types.listOf types.port; 33 | default = [ ]; 34 | apply = canonicalizePortList; 35 | example = [ 53 ]; 36 | description = 37 | '' 38 | List of open UDP ports. 39 | ''; 40 | }; 41 | 42 | allowedUDPPortRanges = mkOption { 43 | type = types.listOf (types.attrsOf types.port); 44 | default = [ ]; 45 | example = [ { from = 60000; to = 61000; } ]; 46 | description = 47 | '' 48 | Range of open UDP ports. 49 | ''; 50 | }; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /test-vm.nix: -------------------------------------------------------------------------------- 1 | # build me with: 2 | # 3 | # nixos-rebuild -I nixos-config=test-vm.nix build-vm 4 | 5 | { config, ... }: 6 | { 7 | imports = [ 8 | ./nftables-firewall.nix 9 | # ./nftables-nat.nix -- cannot be used, as the in-tree nat module will conflict. 10 | ]; 11 | 12 | 13 | config = { 14 | services.mingetty.autologinUser = "root"; 15 | 16 | networking.firewall.enable = false; 17 | networking.nftables.enable = true; 18 | networking.nftables.rulesetFile = config.build.debug.nftables.rulesetFile; 19 | }; 20 | } 21 | --------------------------------------------------------------------------------