Re-resolve WireGuard peers' endpoint names

systemd-networkd resolves an endpoint name once, when it creates the
device. A peer behind a dynamic DNS name that changes its address was
never found again until the next restart.

A timer (router.wireguardEndpointRefresh, default 1min, null for off)
runs wg set for the peers with a name whose last handshake is older than
135 seconds, as wireguard-tools' reresolve-dns does. Working tunnels are
left alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-08 18:17:01 +01:00
co-authored by Claude Sonnet 5.5
parent 879b618981
commit 5572172904
4 changed files with 148 additions and 3 deletions
+41 -2
View File
@@ -168,6 +168,12 @@ pkgs.testers.runNixOSTest {
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
# Names for peer endpoints, so the test can move them.
networking.hosts = {
"203.0.113.60" = [ "siteb.test" ];
"203.0.113.70" = [ "ghost.test" ];
};
environment.etc = lib.listToAttrs (
map
(n: {
@@ -182,7 +188,14 @@ pkgs.testers.runNixOSTest {
"s2s"
"ff"
]
);
)
// {
# A tunnel to a peer that does not exist: it never has a handshake.
"wg/ghost.key" = {
text = key.router-rw.priv;
mode = "0600";
};
};
router = {
enable = true;
@@ -230,11 +243,20 @@ pkgs.testers.runNixOSTest {
sourceAddresses = [ "192.168.10.1" ];
peers.siteb = {
publicKey = key.siteb.pub;
endpoint = "203.0.113.60:51821";
endpoint = "siteb.test:51821";
allowedIPs = [ "192.168.50.0/24" ];
persistentKeepalive = 5;
};
};
ghost = {
privateKeyFile = "/etc/wg/ghost.key";
zone = "ghost";
peers.nobody = {
publicKey = key.laptop.pub;
endpoint = "ghost.test:51830";
allowedIPs = [ "192.168.77.0/24" ];
};
};
# Provider tunnel: only the listed destinations go through it.
ff = {
privateKeyFile = "/etc/wg/ff.key";
@@ -362,6 +384,23 @@ pkgs.testers.runNixOSTest {
for dev in ("wg-rw", "wg-s2s", "wg-ff"):
router.wait_until_succeeds(f"wg show {dev} latest-handshakes | awk '$2 > 0' | grep -q .")
with subtest("peer endpoint names are looked up again, but only for peers without a handshake"):
router.succeed("systemctl is-enabled router-wireguard-reresolve.timer")
router.succeed("wg show wg-ghost endpoints | grep -q 203.0.113.70:51830")
# The names now have other addresses.
router.succeed("rm /etc/hosts; printf '127.0.0.1 localhost\\n203.0.113.71 ghost.test\\n203.0.113.99 siteb.test\\n' > /etc/hosts")
router.succeed("systemctl start router-wireguard-reresolve.service")
router.succeed("wg show wg-ghost endpoints | grep -q 203.0.113.71:51830")
router.succeed("journalctl -u router-wireguard-reresolve --no-pager | grep -q 'endpoint 203.0.113.70:51830 -> 203.0.113.71:51830'")
# The working tunnel keeps its endpoint (it has a fresh handshake).
router.succeed("wg show wg-s2s endpoints | grep -q 203.0.113.60:51821")
# A name that does not resolve leaves the endpoint as it was.
router.succeed("printf '127.0.0.1 localhost\\n' > /etc/hosts")
router.succeed("systemctl start router-wireguard-reresolve.service")
router.succeed("wg show wg-ghost endpoints | grep -q 203.0.113.71:51830")
router.succeed("journalctl -u router-wireguard-reresolve --no-pager | grep -q 'ghost.test:51830'")
router.succeed("rm /etc/hosts; ln -s /etc/static/hosts /etc/hosts")
with subtest("road warrior: dual-stack tunnel to the router, DNS and SSH over it"):
laptop.succeed("ping -c 1 -W 2 10.100.0.1")
laptop.succeed("ping -6 -c 1 -W 2 fd42:100::1")