From c5cd3c2bc3991310b281da7ca17c7991af996fae Mon Sep 17 00:00:00 2001 From: Matt Votava Date: Tue, 6 Aug 2024 10:26:08 -0700 Subject: [PATCH] nixos/systemd-networkd: add NFTSet related options Includes NixOS test and validation on required fields. --- nixos/lib/systemd-lib.nix | 39 +++++++ nixos/modules/system/boot/networkd.nix | 10 ++ nixos/tests/systemd-networkd-nftset.nix | 132 ++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 nixos/tests/systemd-networkd-nftset.nix diff --git a/nixos/lib/systemd-lib.nix b/nixos/lib/systemd-lib.nix index fedd85f09b805..b7c3f7e913ebf 100644 --- a/nixos/lib/systemd-lib.nix +++ b/nixos/lib/systemd-lib.nix @@ -33,6 +33,7 @@ let mkAfter mkIf optional + optionals optionalAttrs optionalString pipe @@ -196,6 +197,44 @@ in rec { optional (attr ? ${name}) "Systemd ${group} field `${name}' has been removed. See ${see}"; + # https://github.com/systemd/systemd/blob/af1a6db58fde8f64edcf7d27e1f3b636c999934c/src/basic/parse-util.c#L794 + assertNftSet = name: group: attr: + let + splitSet = (splitString ":"); + allSets = if isString attr.${name} then [(splitSet attr.${name})] else map splitSet attr.${name}; + getField = xs: i: if length xs >= i + 1 then elemAt xs i else null; + assertNotNull = field: val: optional (val == null || val == "") + "\t`${field}' must be specified."; + assertOneOf = field: values: val: + (assertNotNull field val) ++ + (optional (val != null && !elem val values) + "\t`${field}' cannot have value `${val}'."); + assertStrLen = field: min: max: val: + (assertNotNull field val) ++ + (optional (val != null && val != "" && (stringLength val < min || stringLength val > max)) + "\t`${field}' length must be between ${toString min} and ${toString max}."); + assertNameValid = field: val: + optional (val != null && match "[a-zA-Z][a-zA-Z0-9/\\_.]*" val == null) + "\t`${field}' must begin with an alphabetic character (a-z,A-Z), followed by zero or more alphanumeric characters (a-z,A-Z,0-9) and the characters slash (/), backslash (\\), underscore (_) and dot (.)."; + check = def: [ + (assertOneOf "source" [ "address" "prefix" "ifindex" ] (getField def 0)) + (assertOneOf "family" [ "arp" "bridge" "inet" "ip" "ip6" "netdev" ] (getField def 1)) + (assertStrLen "table" 1 31 (getField def 2)) + (assertNameValid "table" (getField def 2)) + (assertStrLen "set" 1 31 (getField def 3)) + (assertNameValid "set" (getField def 3)) + ]; + errors = flatten (map check allSets); + assertions = + if ! (isString attr.${name} || isList attr.${name}) then + [ "Systemd ${group} field `${name}' is not a string or list of strings." ] + else + optional (length errors > 0) + "`${name}' must be in the form source:family:table:set." + ++ errors; + in + optionals (attr ? ${name}) assertions; + checkUnitConfig = group: checks: attrs: let # We're applied at the top-level type (attrsOf unitOption), so the actual # unit options might contain attributes from mkOverride and mkIf that we need to diff --git a/nixos/modules/system/boot/networkd.nix b/nixos/modules/system/boot/networkd.nix index 44210c8a82c64..2fa228e35f30b 100644 --- a/nixos/modules/system/boot/networkd.nix +++ b/nixos/modules/system/boot/networkd.nix @@ -745,6 +745,7 @@ let "ManageTemporaryAddress" "AddPrefixRoute" "AutoJoin" + "NFTSet" ]) (assertHasField "Address") (assertValueOneOf "PreferredLifetime" ["forever" "infinity" "0" 0]) @@ -754,6 +755,7 @@ let (assertValueOneOf "ManageTemporaryAddress" boolValues) (assertValueOneOf "AddPrefixRoute" boolValues) (assertValueOneOf "AutoJoin" boolValues) + (assertNftSet "NFTSet") ]; sectionRoutingPolicyRule = checkUnitConfigWithLegacyKey "routingPolicyRuleConfig" "RoutingPolicyRule" [ @@ -871,6 +873,7 @@ let "FallbackLeaseLifetimeSec" "Label" "Use6RD" + "NFTSet" ]) (assertValueOneOf "UseDNS" boolValues) (assertValueOneOf "RoutesToDNS" boolValues) @@ -896,6 +899,7 @@ let (assertValueOneOf "SendDecline" boolValues) (assertValueOneOf "FallbackLeaseLifetimeSec" ["forever" "infinity"]) (assertValueOneOf "Use6RD" boolValues) + (assertNftSet "NFTSet") ]; sectionDHCPv6 = checkUnitConfig "DHCPv6" [ @@ -920,6 +924,7 @@ let "IAID" "UseDelegatedPrefix" "SendRelease" + "NFTSet" ]) (assertValueOneOf "UseAddress" boolValues) (assertValueOneOf "UseDNS" boolValues) @@ -933,6 +938,7 @@ let (assertInt "IAID") (assertValueOneOf "UseDelegatedPrefix" boolValues) (assertValueOneOf "SendRelease" boolValues) + (assertNftSet "NFTSet") ]; sectionDHCPPrefixDelegation = checkUnitConfig "DHCPPrefixDelegation" [ @@ -944,11 +950,13 @@ let "Token" "ManageTemporaryAddress" "RouteMetric" + "NFTSet" ]) (assertValueOneOf "Announce" boolValues) (assertValueOneOf "Assign" boolValues) (assertValueOneOf "ManageTemporaryAddress" boolValues) (assertRange "RouteMetric" 0 4294967295) + (assertNftSet "NFTSet") ]; sectionIPv6AcceptRA = checkUnitConfig "IPv6AcceptRA" [ @@ -971,6 +979,7 @@ let "UseRoutePrefix" "Token" "UsePREF64" + "NFTSet" ]) (assertValueOneOf "UseDNS" boolValues) (assertValueOneOf "UseDomains" (boolValues ++ ["route"])) @@ -982,6 +991,7 @@ let (assertValueOneOf "UseGateway" boolValues) (assertValueOneOf "UseRoutePrefix" boolValues) (assertValueOneOf "UsePREF64" boolValues) + (assertNftSet "NFTSet") ]; sectionDHCPServer = checkUnitConfig "DHCPServer" [ diff --git a/nixos/tests/systemd-networkd-nftset.nix b/nixos/tests/systemd-networkd-nftset.nix new file mode 100644 index 0000000000000..1ff624e256a55 --- /dev/null +++ b/nixos/tests/systemd-networkd-nftset.nix @@ -0,0 +1,132 @@ +# This tests systemd-networkd NFTSet option. The interface's statically +# configured address is added to an nft set, and the DHCP configured address is +# added to another. The sets are used by one rule that blocks connections to +# the static address, and one rule that blocks connections to the DHCP address. +# It is tested that the expected connections succeed or fail from another host. +import ./make-test-python.nix ( + { pkgs, ... }: + { + name = "systemd-networkd-nftset"; + meta = with pkgs.lib.maintainers; { + maintainers = [ mvnetbiz ]; + }; + nodes = { + router = + { ... }: + { + virtualisation.vlans = [ 1 ]; + systemd.services.systemd-networkd.environment.SYSTEMD_LOG_LEVEL = "debug"; + networking = { + useNetworkd = true; + useDHCP = false; + firewall.enable = false; + }; + systemd.network = { + networks = { + # systemd-networkd will load the first network unit file + # that matches, ordered lexiographically by filename. + # /etc/systemd/network/{40-eth1,99-main}.network already + # exists. This network unit must be loaded for the test, + # however, hence why this network is named such. + "01-eth1" = { + name = "eth1"; + networkConfig = { + DHCPServer = true; + IPv6AcceptRA = "no"; + Address = "10.0.0.1/24"; + }; + dhcpServerConfig = { + PoolOffset = 100; + PoolSize = 1; + }; + }; + }; + }; + }; + + client = + { ... }: + { + virtualisation.vlans = [ 1 ]; + systemd.services.systemd-networkd.environment.SYSTEMD_LOG_LEVEL = "debug"; + networking = { + useNetworkd = true; + useDHCP = false; + firewall.enable = false; + nftables = { + enable = true; + flushRuleset = true; + ruleset = '' + table inet mytable { + set dhcp_set { + type ipv4_addr + } + set static_set { + type ipv4_addr + } + chain input { + type filter hook input priority filter; policy accept; + ip daddr @dhcp_set tcp dport 80 reject with tcp reset + ip daddr @static_set tcp dport 8080 reject with tcp reset + } + } + ''; + }; + }; + systemd.network.networks."01-eth" = { + name = "eth1"; + networkConfig = { + DHCP = "ipv4"; + IPv6AcceptRA = "no"; + }; + addresses = [ + { + Address = "10.0.0.2/24"; + NFTSet = "address:inet:mytable:static_set"; + } + ]; + dhcpV4Config = { + NFTSet = "address:inet:mytable:dhcp_set"; + }; + }; + services.nginx = { + enable = true; + virtualHosts.localhost.listen = [ + { + addr = "0.0.0.0"; + port = 80; + } + { + addr = "0.0.0.0"; + port = 8080; + } + ]; + }; + }; + }; + testScript = + { ... }: + '' + start_all() + + router.systemctl("start network-online.target") + client.systemctl("start network-online.target") + router.wait_for_unit("systemd-networkd-wait-online.service") + client.wait_for_unit("systemd-networkd-wait-online.service") + + # should be able to ping both IPs + router.wait_until_succeeds("ping -c 5 10.0.0.2") + router.wait_until_succeeds("ping -c 5 10.0.0.100") + + client.wait_for_unit("nginx.service") + client.wait_for_unit("nftables.service") + # should be able to get static IP, but not the DHCP IP on port 80 + router.wait_until_succeeds("curl 10.0.0.2") + router.wait_until_fails("curl 10.0.0.100"); + + # vice versa on port 8080 + router.wait_until_succeeds("curl 10.0.0.100:8080") + router.wait_until_fails("curl 10.0.0.2:8080"); + ''; + } +)