diff --git a/nixos/modules/system/boot/networkd.nix b/nixos/modules/system/boot/networkd.nix
index 56a9d6b1138..a684238a2ad 100644
--- a/nixos/modules/system/boot/networkd.nix
+++ b/nixos/modules/system/boot/networkd.nix
@@ -55,6 +55,11 @@ let
(assertMacAddress "MACAddress")
];
+ checkVRF = checkUnitConfig "VRF" [
+ (assertOnlyFields [ "Table" ])
+ (assertMinimum "Table" 0)
+ ];
+
# NOTE The PrivateKey directive is missing on purpose here, please
# do not add it to this list. The nix store is world-readable let's
# refrain ourselves from providing a footgun.
@@ -349,6 +354,21 @@ let
'';
};
+ vrfConfig = mkOption {
+ default = {};
+ example = { Table = 2342; };
+ type = types.addCheck (types.attrsOf unitOption) checkVRF;
+ description = ''
+ Each attribute in this set specifies an option in the
+ [VRF] section of the unit. See
+ systemd.netdev
+ 5 for details.
+ A detailed explanation about how VRFs work can be found in the
+ kernel
+ docs.
+ '';
+ };
+
wireguardConfig = mkOption {
default = {};
example = {
@@ -844,6 +864,11 @@ let
[Xfrm]
${attrsToSection def.xfrmConfig}
+ ''}
+ ${optionalString (def.vrfConfig != { }) ''
+ [VRF]
+ ${attrsToSection def.vrfConfig}
+
''}
${optionalString (def.wireguardConfig != { }) ''
[WireGuard]
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 6bace8ffe6d..282d31005f0 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -275,6 +275,7 @@ in
systemd-analyze = handleTest ./systemd-analyze.nix {};
systemd-confinement = handleTest ./systemd-confinement.nix {};
systemd-timesyncd = handleTest ./systemd-timesyncd.nix {};
+ systemd-networkd-vrf = handleTest ./systemd-networkd-vrf.nix {};
systemd-networkd-wireguard = handleTest ./systemd-networkd-wireguard.nix {};
systemd-nspawn = handleTest ./systemd-nspawn.nix {};
pdns-recursor = handleTest ./pdns-recursor.nix {};
diff --git a/nixos/tests/systemd-networkd-vrf.nix b/nixos/tests/systemd-networkd-vrf.nix
new file mode 100644
index 00000000000..5bc824531e8
--- /dev/null
+++ b/nixos/tests/systemd-networkd-vrf.nix
@@ -0,0 +1,221 @@
+import ./make-test-python.nix ({ pkgs, lib, ... }: let
+ inherit (import ./ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;
+in {
+ name = "systemd-networkd-vrf";
+ meta.maintainers = with lib.maintainers; [ ma27 ];
+
+ nodes = {
+ client = { pkgs, ... }: {
+ virtualisation.vlans = [ 1 2 ];
+
+ networking = {
+ useDHCP = false;
+ useNetworkd = true;
+ firewall.checkReversePath = "loose";
+ };
+
+ systemd.network = {
+ enable = true;
+
+ netdevs."10-vrf1" = {
+ netdevConfig = {
+ Kind = "vrf";
+ Name = "vrf1";
+ MTUBytes = "1300";
+ };
+ vrfConfig.Table = 23;
+ };
+ netdevs."10-vrf2" = {
+ netdevConfig = {
+ Kind = "vrf";
+ Name = "vrf2";
+ MTUBytes = "1300";
+ };
+ vrfConfig.Table = 42;
+ };
+
+ networks."10-vrf1" = {
+ matchConfig.Name = "vrf1";
+ networkConfig.IPForward = "yes";
+ routes = [
+ { routeConfig = { Destination = "192.168.1.2"; Metric = "100"; }; }
+ ];
+ };
+ networks."10-vrf2" = {
+ matchConfig.Name = "vrf2";
+ networkConfig.IPForward = "yes";
+ routes = [
+ { routeConfig = { Destination = "192.168.2.3"; Metric = "100"; }; }
+ ];
+ };
+
+ networks."10-eth1" = {
+ matchConfig.Name = "eth1";
+ linkConfig.RequiredForOnline = "no";
+ networkConfig = {
+ VRF = "vrf1";
+ Address = "192.168.1.1";
+ IPForward = "yes";
+ };
+ };
+ networks."10-eth2" = {
+ matchConfig.Name = "eth2";
+ linkConfig.RequiredForOnline = "no";
+ networkConfig = {
+ VRF = "vrf2";
+ Address = "192.168.2.1";
+ IPForward = "yes";
+ };
+ };
+ };
+ };
+
+ node1 = { pkgs, ... }: {
+ virtualisation.vlans = [ 1 ];
+ networking = {
+ useDHCP = false;
+ useNetworkd = true;
+ };
+
+ services.openssh.enable = true;
+ users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
+
+ systemd.network = {
+ enable = true;
+
+ networks."10-eth1" = {
+ matchConfig.Name = "eth1";
+ linkConfig.RequiredForOnline = "no";
+ networkConfig = {
+ Address = "192.168.1.2";
+ IPForward = "yes";
+ };
+ };
+ };
+ };
+
+ node2 = { pkgs, ... }: {
+ virtualisation.vlans = [ 2 ];
+ networking = {
+ useDHCP = false;
+ useNetworkd = true;
+ };
+
+ systemd.network = {
+ enable = true;
+
+ networks."10-eth2" = {
+ matchConfig.Name = "eth2";
+ linkConfig.RequiredForOnline = "no";
+ networkConfig = {
+ Address = "192.168.2.3";
+ IPForward = "yes";
+ };
+ };
+ };
+ };
+
+ node3 = { pkgs, ... }: {
+ virtualisation.vlans = [ 2 ];
+ networking = {
+ useDHCP = false;
+ useNetworkd = true;
+ };
+
+ systemd.network = {
+ enable = true;
+
+ networks."10-eth2" = {
+ matchConfig.Name = "eth2";
+ linkConfig.RequiredForOnline = "no";
+ networkConfig = {
+ Address = "192.168.2.4";
+ IPForward = "yes";
+ };
+ };
+ };
+ };
+ };
+
+ testScript = ''
+ def compare_tables(expected, actual):
+ assert (
+ expected == actual
+ ), """
+ Routing tables don't match!
+ Expected:
+ {}
+ Actual:
+ {}
+ """.format(
+ expected, actual
+ )
+
+
+ start_all()
+
+ client.wait_for_unit("network.target")
+ node1.wait_for_unit("network.target")
+ node2.wait_for_unit("network.target")
+ node3.wait_for_unit("network.target")
+
+ client_ipv4_table = """
+ 192.168.1.2 dev vrf1 proto static metric 100
+ 192.168.2.3 dev vrf2 proto static metric 100
+ """.strip()
+ vrf1_table = """
+ broadcast 192.168.1.0 dev eth1 proto kernel scope link src 192.168.1.1
+ 192.168.1.0/24 dev eth1 proto kernel scope link src 192.168.1.1
+ local 192.168.1.1 dev eth1 proto kernel scope host src 192.168.1.1
+ broadcast 192.168.1.255 dev eth1 proto kernel scope link src 192.168.1.1
+ """.strip()
+ vrf2_table = """
+ broadcast 192.168.2.0 dev eth2 proto kernel scope link src 192.168.2.1
+ 192.168.2.0/24 dev eth2 proto kernel scope link src 192.168.2.1
+ local 192.168.2.1 dev eth2 proto kernel scope host src 192.168.2.1
+ broadcast 192.168.2.255 dev eth2 proto kernel scope link src 192.168.2.1
+ """.strip()
+
+ # Check that networkd properly configures the main routing table
+ # and the routing tables for the VRF.
+ with subtest("check vrf routing tables"):
+ compare_tables(
+ client_ipv4_table, client.succeed("ip -4 route list | head -n2").strip()
+ )
+ compare_tables(
+ vrf1_table, client.succeed("ip -4 route list table 23 | head -n4").strip()
+ )
+ compare_tables(
+ vrf2_table, client.succeed("ip -4 route list table 42 | head -n4").strip()
+ )
+
+ # Ensure that other nodes are reachable via ICMP through the VRF.
+ with subtest("icmp through vrf works"):
+ client.succeed("ping -c5 192.168.1.2")
+ client.succeed("ping -c5 192.168.2.3")
+
+ # Test whether SSH through a VRF IP is possible.
+ # (Note: this seems to be an issue on Linux 5.x, so I decided to add this to
+ # ensure that we catch this when updating the default kernel).
+ with subtest("tcp traffic through vrf works"):
+ node1.wait_for_open_port(22)
+ client.succeed(
+ "cat ${snakeOilPrivateKey} > privkey.snakeoil"
+ )
+ client.succeed("chmod 600 privkey.snakeoil")
+ client.succeed(
+ "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i privkey.snakeoil root@192.168.1.2 true"
+ )
+
+ # Only configured routes through the VRF from the main routing table should
+ # work. Additional IPs are only reachable when binding to the vrf interface.
+ with subtest("only routes from main routing table work by default"):
+ client.fail("ping -c5 192.168.2.4")
+ client.succeed("ping -I vrf2 -c5 192.168.2.4")
+
+ client.shutdown()
+ node1.shutdown()
+ node2.shutdown()
+ node3.shutdown()
+ '';
+})