wgengine/netstack: absorb all quad-100 traffic locally, never leak to peers

Previously, handleLocalPackets intercepted traffic to the Tailscale
service IP (100.100.100.100 / fd7a:115c:a1e0::53) only for an allow-list
of ports: TCP 53/80/8080 and UDP 53. Any other port returned
filter.Accept, letting the packet fall through to the ACL filter and
wireguard-go, which would attempt a peer lookup. No peer owns the
quad-100 AllowedIP, so after ~5s pendopen.go would log:

    open-conn-track: timeout opening ...; no associated peer node

This is the common "conntrack error no peer found for 100.100.100.100:853"
log spam seen in the wild (e.g. from systemd-resolved or another
resolver speculatively trying DoT on quad-100). It also leaks quad-100
packets onto the tailnet.

Remove the port allow-list so handleLocalPackets absorbs every quad-100
packet into netstack regardless of IP protocol or port. Traffic never
reaches the conntrack / peer-routing layers.

With the allow-list gone, acceptTCP needs a corresponding guard: on a
quad-100 TCP port we don't serve, execution used to fall through to the
isTailscaleIP case (quad-100 is in the tailscale IP range), which
rewrote the dial target to 127.0.0.1:<port> and forwardTCP'd the
connection to whatever happened to be listening on the host's loopback
at that port. Add a hittingServiceIP case that RSTs cleanly instead,
placed before the isTailscaleIP fallthrough.

TestQuad100UnservedTCPPortDoesNotForward is a new integration test that
injects a TCP SYN to 100.100.100.100:853 via handleLocalPackets, stubs
forwardDialFunc, and asserts the dialer is not invoked; it catches
regressions of the acceptTCP recursion/loopback-redirection case.

Fixes #15796
Fixes #19421
Updates #3261
Updates #11305

Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
James Tucker
2026-04-24 02:18:44 +00:00
committed by James Tucker
parent 006d7e180e
commit 1b40911611
2 changed files with 223 additions and 14 deletions
+39 -14
View File
@@ -845,20 +845,27 @@ func (ns *Impl) handleLocalPackets(p *packet.Parsed, t *tstun.Wrapper, gro *gro.
serviceName, isVIPServiceIP := ns.atomicIPVIPServiceMap.Load()[dst]
switch {
case dst == serviceIP || dst == serviceIPv6:
// We want to intercept some traffic to the "service IP" (e.g.
// 100.100.100.100 for IPv4). However, of traffic to the
// service IP, we only care about UDP 53, and TCP on port 53,
// 80, and 8080.
switch p.IPProto {
case ipproto.TCP:
if port := p.Dst.Port(); port != 53 && port != 80 && port != 8080 && !ns.isLoopbackPort(port) {
return filter.Accept, gro
}
case ipproto.UDP:
if port := p.Dst.Port(); port != 53 && !ns.isLoopbackPort(port) {
return filter.Accept, gro
}
}
// Traffic to the Tailscale service IP (100.100.100.100 /
// fd7a:115c:a1e0::53) is always terminated locally on this
// node; it must never be forwarded out over WireGuard to a
// peer. Netstack's TCP/UDP acceptors handle the ports we
// actually serve (UDP 53 MagicDNS, TCP 53/80/8080 for DNS,
// the web client, and Taildrive, plus any debug loopback
// port). Other ports are rejected cleanly by netstack: UDP
// closes the endpoint in acceptUDP, and TCP is RST'd by
// acceptTCP's hittingServiceIP guard.
//
// Previously we returned filter.Accept for TCP/UDP on any
// other port, which let the packet fall through to the ACL
// filter and ultimately wireguard-go, where no peer owns the
// quad-100 AllowedIP. That produced noisy "open-conn-track:
// timeout opening ...; no associated peer node" log lines
// (e.g. for stray traffic to 100.100.100.100:853 / DoT) and
// leaked quad-100 packets onto the tailnet.
//
// We now unconditionally absorb quad-100 into netstack here,
// regardless of IP protocol or port, so such traffic never
// reaches the conntrack / peer-routing layers.
case isVIPServiceIP:
// returns all active VIP services in a set, since the IPVIPServiceMap
// contains inactive service IPs when node hosts the service, we need to
@@ -1654,6 +1661,24 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
} else {
dialIP = ipv4Loopback
}
case hittingServiceIP:
// TCP to the Tailscale service IP on a port we don't serve
// (anything other than DNS/53, web client/80, Taildrive/8080,
// or the debug loopback port handled above). handleLocalPackets
// absorbs all quad-100 traffic into netstack to prevent it
// from leaking to WireGuard peers as noisy "open-conn-track:
// timeout opening ...; no associated peer node" log lines
// (see the comment there).
//
// Without this explicit guard, execution would fall through
// to the isTailscaleIP case below (quad-100 is in the
// tailscale IP range), rewriting the dial target to
// 127.0.0.1:<port> and forwardTCP'ing the connection onto
// whatever random service happens to be listening on the
// host's loopback at that port. Reject cleanly with a RST
// here instead.
r.Complete(true) // sends a RST
return
case isTailscaleIP:
dialIP = ipv4Loopback
}