From 0a766d40a47bd3afe76289b96671e32c98ad7886 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 3 Nov 2026 14:36:40 +0000 Subject: [PATCH 1/2] test(mesh): drop STUN in the host_network VMs so nodes advertise only their own addresses Each node's Tailscale client advertised, beside its interface addresses, the runner's public address a STUN server on a public DERP region saw it come from, which hairpins back through the runner's NAT. magicsock picks between the two by measured latency with a bonus for a private address, and sub-millisecond jitter can outweigh it, so the path a node used varied between runs. Each VM now drops UDP to port 3478, as on a LAN whose NAT does not hairpin; DERP over TCP still carries the disco messages. --- tests/mesh/run.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/mesh/run.sh b/tests/mesh/run.sh index d34abec..88412a6 100755 --- a/tests/mesh/run.sh +++ b/tests/mesh/run.sh @@ -503,6 +503,10 @@ start_vms() { sleep 1 done done + # Each node's Tailscale client advertises the endpoints it finds for itself: its interface addresses, and the address a STUN server on a public DERP region sees it come from, which on a hosted runner is the runner's public address. That address hairpins back through the runner's NAT, and magicsock picks between it and the test network address by measured latency, with a bonus for a private address that sub-millisecond jitter can outweigh (betterAddr in wgengine/magicsock/endpoint.go of the pinned Tailscale), so which one a node uses varied between runs. STUN is UDP to port 3478, so it is dropped in every VM, as on a LAN whose NAT does not hairpin: a node then advertises only addresses of its own VM's interfaces (see assert_direct_paths), and DERP, over TCP, still carries the disco messages that set a direct path up. + for index in $(seq 1 "$node_count"); do + docker exec "$(vm "$index")" iptables -I OUTPUT 1 -p udp --dport 3478 -j DROP + done } # A Headscale server on this machine's Docker, at a fixed address on the test network, with a policy that lets the nodes register, reach each other and route pods; prints the API key and a join key on two lines. From 31218db6d1c5c07968fb3c5b052d675e174cc7cb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 3 Nov 2026 15:14:28 +0000 Subject: [PATCH 2/2] test(mesh): assert host_network nodes advertise only addresses of their own VM assert_direct_paths checked the path tailscale ping settled on, which depends on latency measured during the run. It now also checks that every endpoint each node advertises for itself is an address of its own VM's interfaces, so an endpoint learnt over STUN, which can carry a path through the runner's NAT, fails the scenario on every run that has one rather than on the runs where it happens to win. The bridge addresses its Docker and k3s create are interface addresses of the VM too, so they are allowed. --- tests/mesh/run.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/mesh/run.sh b/tests/mesh/run.sh index 88412a6..ca4bb25 100755 --- a/tests/mesh/run.sh +++ b/tests/mesh/run.sh @@ -566,7 +566,7 @@ write_host_network_inventory() { # With host networking, every node's Tailscale client must reach every other node directly at that node's daemon's own address (its stand-in LAN address), not through a relay, and the node container must publish no port. assert_direct_paths() { - local index other status deadline + local index other status deadline own outside for index in $(seq 1 "$node_count"); do [ "$(docker_on "$index" inspect --format '{{.HostConfig.NetworkMode}}' "$(container "$index")")" = host ] || fail "$(container "$index") is not on the host network" [ -z "$(docker_on "$index" port "$(container "$index")")" ] || fail "$(container "$index") publishes ports: $(docker_on "$index" port "$(container "$index")")" @@ -581,6 +581,15 @@ assert_direct_paths() { echo " $(node_name "$index") -> $(node_name "$other"): $(echo "$status" | tail -n 1)" done done + # Every endpoint each node advertises for itself is an address of its own VM's interfaces (the test network address, and the bridges its Docker and k3s create): none is an address learnt over STUN, which belongs to the runner's NAT and would let its peers pick a path through it instead (see start_vms). tailscale status lists a node's own endpoints under Self.Addrs, and none for its peers (UpdateStatus in wgengine/magicsock/magicsock.go of the pinned Tailscale fills Self only). The node container is on its VM's host network, so the VM's interfaces are the node's. + for index in $(seq 1 "$node_count"); do + own=$(docker exec "$(vm "$index")" ip -o addr show | awk '{ split($4, parts, "/"); print parts[1] }') + status=$(docker_on "$index" exec "$(container "$index")" tailscale status --json