diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix index 209b43d6be8a..7602dd8ed69e 100644 --- a/maintainers/maintainer-list.nix +++ b/maintainers/maintainer-list.nix @@ -22366,6 +22366,12 @@ githubId = 6118602; name = "Viktor"; }; + tne = { + email = "tne@garudalinux.org"; + github = "JustTNE"; + githubId = 38938720; + name = "TNE"; + }; tnias = { email = "phil@grmr.de"; matrix = "@tnias:stratum0.org"; diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md index 83ec8b2de680..c7ad29646cf6 100644 --- a/nixos/doc/manual/release-notes/rl-2505.section.md +++ b/nixos/doc/manual/release-notes/rl-2505.section.md @@ -39,6 +39,8 @@ - `zammad` has had its support for MySQL removed, since it was never working correctly and is now deprecated upstream. Check the [migration guide](https://docs.zammad.org/en/latest/appendix/migrate-to-postgresql.html) for how to convert your database to PostgreSQL. +- The behavior of the `networking.nat.externalIP` and `networking.nat.externalIPv6` options has been changed. `networking.nat.forwardPorts` now only forwards packets destined for the specified IP addresses. + - `kanata` was updated to v1.7.0, which introduces several breaking changes. See the release notes of [v1.7.0](https://github.com/jtroo/kanata/releases/tag/v1.7.0) diff --git a/nixos/modules/services/networking/nat-iptables.nix b/nixos/modules/services/networking/nat-iptables.nix index 351ba9ec3554..4a4d1f2a258d 100644 --- a/nixos/modules/services/networking/nat-iptables.nix +++ b/nixos/modules/services/networking/nat-iptables.nix @@ -32,16 +32,21 @@ let ip46tables -w -t nat -D OUTPUT -j nixos-nat-out 2>/dev/null || true ip46tables -w -t nat -F nixos-nat-out 2>/dev/null || true ip46tables -w -t nat -X nixos-nat-out 2>/dev/null || true + ip46tables -w -t filter -D FORWARD -j nixos-filter-forward 2>/dev/null || true + ip46tables -w -t filter -F nixos-filter-forward 2>/dev/null || true + ip46tables -w -t filter -X nixos-filter-forward 2>/dev/null || true ${cfg.extraStopCommands} ''; - mkSetupNat = { iptables, dest, internalIPs, forwardPorts }: '' + mkSetupNat = { iptables, dest, internalIPs, forwardPorts, externalIp }: '' # We can't match on incoming interface in POSTROUTING, so # mark packets coming from the internal interfaces. ${concatMapStrings (iface: '' ${iptables} -w -t nat -A nixos-nat-pre \ -i '${iface}' -j MARK --set-mark 1 + ${iptables} -w -t filter -A nixos-filter-forward \ + -i '${iface}' ${optionalString (cfg.externalInterface != null) "-o ${cfg.externalInterface}"} -j ACCEPT '') cfg.internalInterfaces} # NAT the marked packets. @@ -54,14 +59,23 @@ let ${concatMapStrings (range: '' ${iptables} -w -t nat -A nixos-nat-post \ -s '${range}' ${optionalString (cfg.externalInterface != null) "-o ${cfg.externalInterface}"} ${dest} + ${iptables} -w -t filter -A nixos-filter-forward \ + -s '${range}' ${optionalString (cfg.externalInterface != null) "-o ${cfg.externalInterface}"} -j ACCEPT '') internalIPs} + # Related connections are allowed + ${iptables} -w -t filter -A nixos-filter-forward \ + -m state --state ESTABLISHED,RELATED -j ACCEPT + # NAT from external ports to internal ports. ${concatMapStrings (fwd: '' ${iptables} -w -t nat -A nixos-nat-pre \ -i ${toString cfg.externalInterface} -p ${fwd.proto} \ - --dport ${builtins.toString fwd.sourcePort} \ + ${optionalString (externalIp != null) "-d ${externalIp}"} --dport ${builtins.toString fwd.sourcePort} \ -j DNAT --to-destination ${fwd.destination} + ${iptables} -w -t filter -A nixos-filter-forward \ + -i ${toString cfg.externalInterface} -p ${fwd.proto} \ + --dport ${builtins.toString fwd.sourcePort} -j ACCEPT ${concatMapStrings (loopbackip: let @@ -77,15 +91,32 @@ let -j DNAT --to-destination ${fwd.destination} # Allow connections to ${loopbackip}:${toString fwd.sourcePort} from other hosts behind NAT - ${iptables} -w -t nat -A nixos-nat-pre \ - -d ${loopbackip} -p ${fwd.proto} \ - --dport ${builtins.toString fwd.sourcePort} \ - -j DNAT --to-destination ${fwd.destination} - - ${iptables} -w -t nat -A nixos-nat-post \ - -d ${destinationIP} -p ${fwd.proto} \ - --dport ${destinationPorts} \ - -j SNAT --to-source ${loopbackip} + ${concatMapStrings (range: '' + ${iptables} -w -t nat -A nixos-nat-pre \ + -d ${loopbackip} -p ${fwd.proto} -s '${range}' \ + --dport ${builtins.toString fwd.sourcePort} \ + -j DNAT --to-destination ${fwd.destination} + ${iptables} -w -t nat -A nixos-nat-post \ + -d ${destinationIP} -p ${fwd.proto} \ + -s '${range}' --dport ${destinationPorts} \ + -j SNAT --to-source ${loopbackip} + ${iptables} -w -t filter -A nixos-filter-forward \ + -d ${destinationIP} -p ${fwd.proto} \ + -s '${range}' --dport ${destinationPorts} -j ACCEPT + '') internalIPs} + ${concatMapStrings (iface: '' + ${iptables} -w -t nat -A nixos-nat-pre \ + -d ${loopbackip} -p ${fwd.proto} -i '${iface}' \ + --dport ${builtins.toString fwd.sourcePort} \ + -j DNAT --to-destination ${fwd.destination} + ${iptables} -w -t nat -A nixos-nat-post \ + -d ${destinationIP} -p ${fwd.proto} \ + -i '${iface}' --dport ${destinationPorts} \ + -j SNAT --to-source ${loopbackip} + ${iptables} -w -t filter -A nixos-filter-forward \ + -d ${destinationIP} -p ${fwd.proto} \ + -i '${iface}' --dport ${destinationPorts} -j ACCEPT + '') cfg.internalInterfaces} '') fwd.loopbackIPs} '') forwardPorts} ''; @@ -96,12 +127,14 @@ let ip46tables -w -t nat -N nixos-nat-pre ip46tables -w -t nat -N nixos-nat-post ip46tables -w -t nat -N nixos-nat-out + ip46tables -w -t filter -N nixos-filter-forward ${mkSetupNat { iptables = "iptables"; inherit dest; inherit (cfg) internalIPs; forwardPorts = filter (x: !(isIPv6 x.destination)) cfg.forwardPorts; + externalIp = cfg.externalIP; }} ${optionalString cfg.enableIPv6 (mkSetupNat { @@ -109,6 +142,7 @@ let dest = destIPv6; internalIPs = cfg.internalIPv6s; forwardPorts = filter (x: isIPv6 x.destination) cfg.forwardPorts; + externalIp = cfg.externalIPv6; })} ${optionalString (cfg.dmzHost != null) '' @@ -123,6 +157,7 @@ let ip46tables -w -t nat -A PREROUTING -j nixos-nat-pre ip46tables -w -t nat -A POSTROUTING -j nixos-nat-post ip46tables -w -t nat -A OUTPUT -j nixos-nat-out + ip46tables -w -t filter -A FORWARD -j nixos-filter-forward ''; in diff --git a/nixos/modules/services/networking/nat-nftables.nix b/nixos/modules/services/networking/nat-nftables.nix index 7aa93d8a64b1..951b14cec791 100644 --- a/nixos/modules/services/networking/nat-nftables.nix +++ b/nixos/modules/services/networking/nat-nftables.nix @@ -33,14 +33,14 @@ let ports = if m == null then throw "bad ip:ports `${IPPorts}'" else elemAt m 1; }; - mkTable = { ipVer, dest, ipSet, forwardPorts, dmzHost }: + mkTable = { ipVer, dest, ipSet, forwardPorts, dmzHost, externalIP }: let # nftables maps for port forward - # l4proto . dport : addr . port + # [daddr .] l4proto . dport : addr . port fwdMap = toNftSet (map (fwd: with (splitIPPorts fwd.destination); - "${fwd.proto} . ${toNftRange fwd.sourcePort} : ${IP} . ${ports}" + "${optionalString (externalIP != null) "${externalIP} . "}${fwd.proto} . ${toNftRange fwd.sourcePort} : ${IP} . ${ports}" ) forwardPorts); @@ -69,7 +69,7 @@ let type nat hook prerouting priority dstnat; ${optionalString (fwdMap != "") '' - iifname "${cfg.externalInterface}" meta l4proto { tcp, udp } dnat meta l4proto . th dport map { ${fwdMap} } comment "port forward" + iifname "${cfg.externalInterface}" meta l4proto { tcp, udp } dnat ${optionalString (externalIP != null) "${ipVer} daddr . "}meta l4proto . th dport map { ${fwdMap} } comment "port forward" ''} ${optionalString (fwdLoopDnatMap != "") '' @@ -133,7 +133,7 @@ in ipVer = "ip"; inherit dest ipSet; forwardPorts = filter (x: !(isIPv6 x.destination)) cfg.forwardPorts; - inherit (cfg) dmzHost; + inherit (cfg) dmzHost externalIP; }; }; "nixos-nat6" = mkIf cfg.enableIPv6 { @@ -145,6 +145,7 @@ in ipSet = ipv6Set; forwardPorts = filter (x: isIPv6 x.destination) cfg.forwardPorts; dmzHost = null; + externalIP = cfg.externalIPv6; }; }; }; diff --git a/nixos/modules/services/networking/nat.nix b/nixos/modules/services/networking/nat.nix index c2e0cfee9b85..8cd2464d3358 100644 --- a/nixos/modules/services/networking/nat.nix +++ b/nixos/modules/services/networking/nat.nix @@ -20,7 +20,10 @@ in type = types.bool; default = false; description = '' - Whether to enable Network Address Translation (NAT). + Whether to enable Network Address Translation (NAT). A + properly configured firewall or a trusted L2 on all network + interfaces is required to prevent unauthorized access to + the internal network. ''; }; @@ -82,7 +85,8 @@ in The public IP address to which packets from the local network are to be rewritten. If this is left empty, the IP address associated with the external interface will be - used. + used. Only connections made to this IP address will be + forwarded to the internal network when using forwardPorts. ''; }; @@ -94,7 +98,8 @@ in The public IPv6 address to which packets from the local network are to be rewritten. If this is left empty, the IP address associated with the external interface will be - used. + used. Only connections made to this IP address will be + forwarded to the internal network when using forwardPorts. ''; }; diff --git a/nixos/tests/nat.nix b/nixos/tests/nat.nix index 507e0fd72e2a..a00febf766ae 100644 --- a/nixos/tests/nat.nix +++ b/nixos/tests/nat.nix @@ -1,100 +1,278 @@ -# This is a simple distributed test involving a topology with two -# separate virtual networks - the "inside" and the "outside" - with a -# client on the inside network, a server on the outside network, and a -# router connected to both that performs Network Address Translation -# for the client. -import ./make-test-python.nix ({ pkgs, lib, withFirewall, nftables ? false, ... }: +# This is a distributed test of the Network Address Translation involving a topology +# with a router inbetween three separate virtual networks: +# - "external" -- i.e. the internet, +# - "internal" -- i.e. an office LAN, +# +# This test puts one server on each of those networks and its primary goal is to ensure that: +# - server (named client in the code) in internal network can reach server (named server in the code) on the external network, +# - server in external network can not reach server in internal network (skipped in some cases), +# - when using externalIP, only the specified IP is used for NAT, +# - port forwarding functionality behaves correctly +# +# The client is behind the nat (read: protected by the nat) and the server is on the external network, attempting to access services behind the NAT. + +import ./make-test-python.nix ({ pkgs, lib, withFirewall ? false, nftables ? false, ... }: let unit = if nftables then "nftables" else (if withFirewall then "firewall" else "nat"); + + routerAlternativeExternalIp = "192.168.2.234"; + + makeNginxConfig = hostname: { + enable = true; + virtualHosts."${hostname}" = { + root = "/etc"; + locations."/".index = "hostname"; + listen = [ + { + addr = "0.0.0.0"; + port = 80; + } + { + addr = "0.0.0.0"; + port = 8080; + } + ]; + }; + }; + + makeCommonConfig = hostname: { + services.nginx = makeNginxConfig hostname; + services.vsftpd = { + enable = true; + anonymousUser = true; + localRoot = "/etc/"; + extraConfig = '' + pasv_min_port=51000 + pasv_max_port=51999 + ''; + }; + + # Disable eth0 autoconfiguration + networking.useDHCP = false; + + environment.systemPackages = [ + (pkgs.writeScriptBin "check-connection" + '' + #!/usr/bin/env bash + + set -e + + if [[ "$2" == "" || "$3" == "" || "$1" == "--help" || "$1" == "-h" ]]; + then + echo "check-connection <[expect-success|expect-failure]>" + exit 1 + fi + + ADDRESS="$1" + HOSTNAME="$2" + + function test_icmp() { timeout 3 ping -c 1 $ADDRESS; } + function test_http() { [[ `timeout 3 curl $ADDRESS` == "$HOSTNAME" ]]; } + function test_ftp() { timeout 3 curl ftp://$ADDRESS; } + + if [[ "$3" == "expect-success" ]]; + then + test_icmp; test_http; test_ftp + else + ! test_icmp; ! test_http; ! test_ftp + fi + '' + ) + (pkgs.writeScriptBin "check-last-clients-ip" + '' + #!/usr/bin/env bash + set -e + + [[ `cat /var/log/nginx/access.log | tail -n1 | awk '{print $1}'` == "$1" ]] + '' + ) + ]; + }; + + # VLANS: + # 1 -- simulates the internal network + # 2 -- simulates the external network in { name = "nat" + (lib.optionalString nftables "Nftables") + (if withFirewall then "WithFirewall" else "Standalone"); meta = with pkgs.lib.maintainers; { - maintainers = [ rob ]; + maintainers = [ tne rob ]; }; nodes = - { - client = { lib, nodes, ... }: { - virtualisation.vlans = [ 1 ]; - networking.defaultGateway = - (lib.head nodes.router.networking.interfaces.eth2.ipv4.addresses).address; - networking.nftables.enable = nftables; - }; + { client = + { pkgs, nodes, ... }: + lib.mkMerge [ + ( makeCommonConfig "client" ) + { virtualisation.vlans = [ 1 ]; + networking.defaultGateway = + (pkgs.lib.head nodes.router.networking.interfaces.eth1.ipv4.addresses).address; + networking.nftables.enable = nftables; + networking.firewall.enable = false; + } + ]; - router = { lib, ... }: { - virtualisation.vlans = [ 2 1 ]; - networking.firewall.enable = withFirewall; - networking.firewall.filterForward = nftables; - networking.nftables.enable = nftables; - networking.nat.enable = true; - networking.nat.internalIPs = [ "192.168.1.0/24" ]; - networking.nat.externalInterface = "eth1"; + router = + { nodes, ... }: lib.mkMerge [ + ( makeCommonConfig "router" ) + { virtualisation.vlans = [ 1 2 ]; + networking.firewall = { + enable = withFirewall; + filterForward = nftables; + allowedTCPPorts = [ 21 80 8080 ]; + # For FTP passive mode + allowedTCPPortRanges = [ { from = 51000; to = 51999; } ]; + }; + networking.nftables.enable = nftables; + networking.nat = + let + clientIp = (pkgs.lib.head nodes.client.networking.interfaces.eth1.ipv4.addresses).address; + serverIp = (pkgs.lib.head nodes.router.networking.interfaces.eth2.ipv4.addresses).address; + in + { + enable = true; + internalIPs = [ "${clientIp}/24" ]; + # internalInterfaces = [ "eth1" ]; + externalInterface = "eth2"; + externalIP = serverIp; - specialisation.no-nat.configuration = { - networking.nat.enable = lib.mkForce false; - }; - }; + forwardPorts = [ + { + destination = "${clientIp}:8080"; + proto = "tcp"; + sourcePort = 8080; + + loopbackIPs = [ serverIp ]; + } + ]; + }; + + networking.interfaces.eth2.ipv4.addresses = + lib.mkOrder 10000 [ { address = routerAlternativeExternalIp; prefixLength = 24; } ]; + + services.nginx.virtualHosts.router.listen = lib.mkOrder (-1) [ { + addr = routerAlternativeExternalIp; + port = 8080; + } ]; + + specialisation.no-nat.configuration = { + networking.nat.enable = lib.mkForce false; + }; + } + ]; server = - { ... }: - { virtualisation.vlans = [ 2 ]; - networking.firewall.enable = false; - services.httpd.enable = true; - services.httpd.adminAddr = "foo@example.org"; - services.vsftpd.enable = true; - services.vsftpd.anonymousUser = true; - }; + { nodes, ... }: lib.mkMerge [ + ( makeCommonConfig "server" ) + { virtualisation.vlans = [ 2 ]; + networking.firewall.enable = false; + + networking.defaultGateway = + (pkgs.lib.head nodes.router.networking.interfaces.eth2.ipv4.addresses).address; + } + ]; }; - testScript = '' + testScript = + { nodes, ... }: let + clientIp = (pkgs.lib.head nodes.client.networking.interfaces.eth1.ipv4.addresses).address; + serverIp = (pkgs.lib.head nodes.server.networking.interfaces.eth1.ipv4.addresses).address; + routerIp = (pkgs.lib.head nodes.router.networking.interfaces.eth2.ipv4.addresses).address; + in '' + def wait_for_machine(m): + m.wait_for_unit("network.target") + m.wait_for_unit("nginx.service") + client.start() router.start() server.start() - # The router should have access to the server. - server.wait_for_unit("network.target") - server.wait_for_unit("httpd") - router.wait_for_unit("network.target") - router.succeed("curl -4 --fail http://server/ >&2") + wait_for_machine(router) + wait_for_machine(client) + wait_for_machine(server) - # The client should be also able to connect via the NAT router. - router.wait_for_unit("${unit}") - client.wait_for_unit("network.target") - client.succeed("curl --fail http://server/ >&2") - client.succeed("ping -4 -c 1 server >&2") + # We assume we are isolated from layer 2 attacks or are securely configured (like disabling forwarding by default) + # Relevant moby issue describing the problem allowing bypassing of NAT: https://github.com/moby/moby/issues/14041 + ${lib.optionalString (!nftables) '' + router.succeed("iptables -P FORWARD DROP") + ''} - # Test whether passive FTP works. - server.wait_for_unit("vsftpd") - server.succeed("echo Hello World > /home/ftp/foo.txt") - client.succeed("curl -v ftp://server/foo.txt >&2") + # Sanity checks. + ## The router should have direct access to the server + router.succeed("check-connection ${serverIp} server expect-success") + ## The server should have direct access to the router + server.succeed("check-connection ${routerIp} router expect-success") - # Test whether active FTP works. - client.fail("curl -v -P - ftp://server/foo.txt >&2") + # The client should be also able to connect via the NAT router... + client.succeed("check-connection ${serverIp} server expect-success") + # ... but its IP should be rewritten to be that of the router. + server.succeed("check-last-clients-ip ${routerIp}") - # Test ICMP. - client.succeed("ping -4 -c 1 router >&2") - router.succeed("ping -4 -c 1 client >&2") + # Active FTP (where the FTP server connects back to us via a random port) should work directly... + router.succeed("timeout 3 curl -P eth2:51000-51999 ftp://${serverIp}") + # ... but not from behind NAT. + client.fail("timeout 3 curl -P eth1:51000-51999 ftp://${serverIp};") - # If we turn off NAT, the client shouldn't be able to reach the server. + # If using nftables without firewall, filterForward can't be used and L2 security can't easily be simulated like with iptables, skipping. + # See moby github issue mentioned above. + ${lib.optionalString (nftables && withFirewall) '' + # The server should not be able to reach the client directly... + server.succeed("check-connection ${clientIp} client expect-failure") + ''} + # ... but the server should be able to reach a port forwarded address of the client + server.succeed('[[ `timeout 3 curl http://${routerIp}:8080` == "client" ]]') + # The IP address the client sees should not be rewritten to be that of the router (#277016) + client.succeed("check-last-clients-ip ${serverIp}") + + # But this forwarded port shouldn't intercept communication with + # other IPs than externalIp. + server.succeed('[[ `timeout 3 curl http://${routerAlternativeExternalIp}:8080` == "router" ]]') + + # The loopback should allow the router itself to access the forwarded port + # Note: The reason we use routerIp here is because only routerIp is listed for reflection in networking.nat.forwardPorts.loopbackIPs + # The purpose of loopbackIPs is to allow things inside of the NAT to for example access their own public domain when a service has to make a request + # to itself/another service on the same NAT through a public address + router.succeed('[[ `timeout 3 curl http://${routerIp}:8080` == "client" ]]') + # The loopback should also allow the client to access its own forwarded port + client.succeed('[[ `timeout 3 curl http://${routerIp}:8080` == "client" ]]') + + # If we turn off NAT, nothing should work + router.succeed( + "systemctl stop ${unit}.service" + ) + + # If using nftables and firewall, this makes no sense. We deactivated the firewall after all, + # so we are once again affected by the same issue as the moby github issue mentioned above. + # If using nftables without firewall, filterForward can't be used and L2 security can't easily be simulated like with iptables, skipping. + # See moby github issue mentioned above. + ${lib.optionalString (!nftables) '' + client.succeed("check-connection ${serverIp} server expect-failure") + server.succeed("check-connection ${clientIp} client expect-failure") + ''} + # These should revert to their pre-NATed versions + server.succeed('[[ `timeout 3 curl http://${routerIp}:8080` == "router" ]]') + router.succeed('[[ `timeout 3 curl http://${routerIp}:8080` == "router" ]]') + + # Reverse the effect of nat stop + router.succeed( + "systemctl start ${unit}.service" + ) + + # Switch to a config without NAT at all, again nothing should work router.succeed( "/run/booted-system/specialisation/no-nat/bin/switch-to-configuration test 2>&1" ) - client.fail("curl -4 --fail --connect-timeout 5 http://server/ >&2") - client.fail("ping -4 -c 1 server >&2") - # And make sure that reloading the NAT job works. - router.succeed( - "/run/booted-system/bin/switch-to-configuration test 2>&1" - ) - # FIXME: this should not be necessary, but nat.service is not started because - # network.target is not triggered - # (https://github.com/NixOS/nixpkgs/issues/16230#issuecomment-226408359) - ${lib.optionalString (!withFirewall && !nftables) '' - router.succeed("systemctl start nat.service") + # If using nftables without firewall, filterForward can't be used and L2 security can't easily be simulated like with iptables, skipping. + # See moby github issue mentioned above. + ${lib.optionalString (nftables && withFirewall) '' + client.succeed("check-connection ${serverIp} server expect-failure") + server.succeed("check-connection ${clientIp} client expect-failure") ''} - client.succeed("curl -4 --fail http://server/ >&2") - client.succeed("ping -4 -c 1 server >&2") + + # These should revert to their pre-NATed versions + server.succeed('[[ `timeout 3 curl http://${routerIp}:8080` == "router" ]]') + router.succeed('[[ `timeout 3 curl http://${routerIp}:8080` == "router" ]]') ''; })