From 1b27dadd6090e92ca4572614112e10dc0615c0b8 Mon Sep 17 00:00:00 2001 From: Alex Jackson Date: Sun, 6 Sep 2026 17:06:35 -0500 Subject: [PATCH] fix: preserve Tailscale policy rules across networkd restarts --- hosts/patroclus/hypervisor.nix | 10 ++ hosts/patroclus/tests/README.md | 29 ++++++ .../tests/networkd-routing-policy.nix | 97 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 hosts/patroclus/tests/README.md create mode 100644 hosts/patroclus/tests/networkd-routing-policy.nix diff --git a/hosts/patroclus/hypervisor.nix b/hosts/patroclus/hypervisor.nix index 643e410..210e677 100644 --- a/hosts/patroclus/hypervisor.nix +++ b/hosts/patroclus/hypervisor.nix @@ -1,3 +1,4 @@ +{ config, pkgs, ... }: let ethernetIface = "eno2"; # this is what the iface is named in the system bridgeIface = "br0"; # created by this file @@ -19,6 +20,10 @@ in networking.useNetworkd = true; systemd.network = { enable = true; + # Tailscale owns policy rules 5210-5270. networkd otherwise removes them + # when it starts/reconfigures links, which can interrupt tailnet routing + # and the host's MagicDNS path. Leave rules owned by other services alone. + config.networkConfig.ManageForeignRoutingPolicyRules = false; netdevs.${bridgeIface}.netdevConfig = { Name = bridgeIface; Kind = "bridge"; @@ -46,4 +51,9 @@ in }; }; }; + + system.build.networkdRoutingPolicyTest = import ./tests/networkd-routing-policy.nix { + inherit pkgs; + networkConfig = config.systemd.network.config.networkConfig; + }; } diff --git a/hosts/patroclus/tests/README.md b/hosts/patroclus/tests/README.md new file mode 100644 index 0000000..74f5330 --- /dev/null +++ b/hosts/patroclus/tests/README.md @@ -0,0 +1,29 @@ +# networkd / Tailscale routing regression + +Run from the repository root: + +```sh +nix build --no-link --print-build-logs \ + .#nixosConfigurations.patroclusStripped.config.system.build.networkdRoutingPolicyTest +``` + +This is a NixOS VM test, separate from the editor firewall's namespace test. +It requires a Linux builder with KVM/NixOS-test support. It boots an isolated +guest, runs the pinned systemd-networkd with the host's actual global settings, +and installs Tailscale-style IPv4 and IPv6 policy rules. + +It verifies that those rules survive: + +- a networkd restart; +- a managed-link reconfiguration; +- adding an editor bridge and restarting networkd. + +The negative control switches the guest to the previous `yes` default and +verifies that networkd really deletes the rules. Restoring the production +configuration must preserve them again. All restarts and configuration changes +occur in the disposable test VM, not on the machine running the test. + +This covers the routing-rule deletion observed during the September 6 deployment. +It does not emulate Tailscale's NextDNS-over-HTTPS connections or prove the entire +DNS interruption was caused solely by rule deletion. In particular, temporary +IPv6 address changes during activation remain a separate possible contributor. diff --git a/hosts/patroclus/tests/networkd-routing-policy.nix b/hosts/patroclus/tests/networkd-routing-policy.nix new file mode 100644 index 0000000..1af5f79 --- /dev/null +++ b/hosts/patroclus/tests/networkd-routing-policy.nix @@ -0,0 +1,97 @@ +# A real networkd restart test in a disposable NixOS VM. The production setting +# is passed in by hypervisor.nix; no network service on the test host is touched. +{ pkgs, networkConfig }: +pkgs.testers.runNixOSTest { + name = "patroclus-networkd-routing-policy"; + + nodes.machine = { + virtualisation.memorySize = 512; + networking.useNetworkd = true; + systemd.network = { + config.networkConfig = networkConfig; + netdevs."10-dummy0".netdevConfig = { + Name = "dummy0"; + Kind = "dummy"; + }; + networks."10-dummy0" = { + matchConfig.Name = "dummy0"; + address = [ "192.0.2.1/24" ]; + networkConfig.ConfigureWithoutCarrier = true; + linkConfig.RequiredForOnline = "no"; + }; + }; + }; + + testScript = '' + import json + + start_all() + machine.wait_for_unit("systemd-networkd.service") + + def wait_configured(interface): + machine.wait_until_succeeds( + f"networkctl status {interface} --no-pager | grep -F '(configured)'" + ) + + def add_rules(): + for family in ("-4", "-6"): + for rule in ( + "pref 5210 fwmark 0x80000/0xff0000 lookup main", + "pref 5230 fwmark 0x80000/0xff0000 lookup default", + "pref 5250 fwmark 0x80000/0xff0000 unreachable", + "pref 5270 lookup 52", + ): + machine.succeed(f"ip {family} rule add {rule}") + + def rules(): + return { + family: json.loads(machine.succeed(f"ip {family} -j rule show")) + for family in ("-4", "-6") + } + + wait_configured("dummy0") + add_rules() + expected = rules() + + with subtest("foreign IPv4 and IPv6 rules survive networkd restart"): + machine.succeed("systemctl restart systemd-networkd") + machine.wait_for_unit("systemd-networkd.service") + wait_configured("dummy0") + assert rules() == expected + + with subtest("foreign rules survive reconfiguration"): + machine.succeed("networkctl reconfigure dummy0") + wait_configured("dummy0") + assert rules() == expected + + with subtest("adding the editor bridge preserves foreign rules"): + machine.succeed("mkdir -p /run/systemd/network") + machine.succeed("printf '[NetDev]\\nName=agentbr0\\nKind=bridge\\n' > /run/systemd/network/30-agentbr0.netdev") + machine.succeed("printf '[Match]\\nName=agentbr0\\n[Network]\\nAddress=192.168.83.1/24\\nConfigureWithoutCarrier=yes\\nDHCP=no\\nIPv6AcceptRA=no\\nLinkLocalAddressing=no\\n' > /run/systemd/network/30-agentbr0.network") + machine.succeed("systemctl restart systemd-networkd") + machine.wait_for_unit("systemd-networkd.service") + wait_configured("agentbr0") + machine.succeed("ip -4 address show agentbr0 | grep -F 192.168.83.1/24") + assert rules() == expected + + with subtest("negative control reproduces deletion with the old default"): + # This makes the test sensitive to the original bug, rather than just + # checking that an option evaluates. It runs only inside this test VM. + machine.succeed("mkdir -p /run/systemd/networkd.conf.d") + machine.succeed("printf '[Network]\\nManageForeignRoutingPolicyRules=yes\\n' > /run/systemd/networkd.conf.d/99-control.conf") + machine.succeed("systemctl restart systemd-networkd") + machine.wait_for_unit("systemd-networkd.service") + wait_configured("dummy0") + for family in ("-4", "-6"): + machine.wait_until_succeeds(f"! ip {family} rule show | grep -E '^52(10|30|50|70):'") + assert rules() != expected + + with subtest("restoring production configuration preserves rules again"): + machine.succeed("rm /run/systemd/networkd.conf.d/99-control.conf") + add_rules() + machine.succeed("systemctl restart systemd-networkd") + machine.wait_for_unit("systemd-networkd.service") + wait_configured("dummy0") + assert rules() == expected + ''; +}