WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
2 changed files with 211 additions and 85 deletions
Showing only changes of commit a68be19739 - Show all commits
+11
View File
@@ -1683,6 +1683,17 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
// here instead.
r.Complete(true) // sends a RST
return
case ns.isVIPServiceIP(dialIP):
// TCP to a VIP service IP on a port the service does not serve. A served
// port returns early above (TCPHandlerForDst is non-nil), so reaching here
// means this node has no serve handler for this port. Don't fall through
// to the isTailscaleIP case below (a VIP is in the Tailscale IP range),
// which would rewrite the dial target to 127.0.0.1:<port> and forwardTCP
// the connection onto whatever unrelated service happens to be listening
// on the host's loopback at that port — reachable via the service IP by
// any peer, even one granted access only to the service. Reject with a RST.
r.Complete(true) // sends a RST
return
case isTailscaleIP:
dialIP = ipv4Loopback
}
+200 -85
View File
@@ -1095,6 +1095,30 @@ func TestHandleLocalPackets(t *testing.T) {
t.Errorf("got filter outcome %v, want filter.DropSilently", resp)
}
})
t.Run("ShouldNotHandleInactiveVIPService", func(t *testing.T) {
// Tests that packets to Tailscale Services we don't host are accepted.
inactiveVIP := netip.MustParseAddr("100.99.55.222")
impl.lb.ForTest().SetIPServiceMappings(netmap.IPServiceMappings{
netip.MustParseAddr("100.99.55.111"): "svc:test-service", // active (shared fixture)
netip.MustParseAddr("fd7a:115c:a1e0::abcd"): "svc:test-service",
inactiveVIP: "svc:inactive-elsewhere",
})
t.Cleanup(func() {
// Restore the shared fixture for any later/parallel subtests.
impl.lb.ForTest().SetIPServiceMappings(IPServiceMap)
})
pkt := &packet.Parsed{
IPVersion: 4,
IPProto: ipproto.TCP,
Src: netip.MustParseAddrPort("127.0.0.1:9999"),
Dst: netip.AddrPortFrom(inactiveVIP, 80),
TCPFlags: packet.TCPSyn,
}
resp, _ := impl.handleLocalPackets(pkt, impl.tundev, nil)
if resp != filter.Accept {
t.Errorf("inactive VIP service: got filter outcome %v, want filter.Accept (pass through to host)", resp)
}
})
t.Run("OtherNonHandled", func(t *testing.T) {
t.Parallel()
pkt := &packet.Parsed{
@@ -1119,97 +1143,188 @@ func TestHandleLocalPackets(t *testing.T) {
})
}
// TestQuad100UnservedTCPPortDoesNotForward verifies that a TCP SYN to the
// Tailscale service IP (100.100.100.100) on a port we don't serve is
// absorbed by netstack and rejected cleanly, without triggering the
// outbound forwardTCP dialer.
// TestAcceptTCPRoutingTailscaleIPRange tests how acceptTCP behaves for TCP SYN
// packets destined to IPs in the Tailscale range (100.64.0.0/10).
//
// handleLocalPackets now absorbs all quad-100 traffic regardless of
// port to prevent it leaking to WireGuard peers (which produced noisy
// "open-conn-track: timeout opening ...; no associated peer node" log
// lines). That leaves acceptTCP responsible for rejecting connections
// to ports we don't handle; without an explicit guard, execution would
// fall through to the isTailscaleIP case (quad-100 is in the tailscale
// range), rewriting the dial target to 127.0.0.1:<port> and forwarding
// the connection to whatever random service happened to be listening
// on the host's loopback at that port.
//
// This test asserts that the forward dialer is NOT invoked for quad-100
// SYNs on unserved ports; the guard in acceptTCP must RST instead.
func TestQuad100UnservedTCPPortDoesNotForward(t *testing.T) {
impl := makeNetstack(t, func(impl *Impl) {
impl.ProcessSubnets = false
impl.ProcessLocalIPs = false
impl.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress)
})
// - Packets to the Tailscale IP should be forwarded to loopback if there is
// no configured handler for the port.
// - Packets to the service IP (100.100.100.100) on non-served ports should
// never make it to the local host.
// - Packets to a Tailscale Service VIP on non-served ports should never make
// it to the local host.
func TestAcceptTCPLoopbackForwardVsRST(t *testing.T) {
serviceVIP := netip.MustParseAddr("100.90.1.2")
selfIP := netip.MustParseAddr("100.64.1.2")
const serviceName = "svc:test"
dialFn, gotConn := makeHangDialer(t)
impl.forwardDialFunc = dialFn
// Use a client IP in the CGNAT range so shouldProcessInbound-adjacent
// code paths treat this as plausibly-peer-sourced traffic, matching
// what a real stray quad-100 probe from the host OS would look like.
client := netip.MustParseAddr("100.101.102.103")
quad100 := tsaddr.TailscaleServiceIP()
// 853 is DoT, the specific case called out in the original bug
// report ("conntrack error no peer found for 100.100.100.100:853").
// Before the fix, port 853 (and any non-{53,80,8080} port) leaked
// out to WireGuard; after the fix it is absorbed here and must NOT
// trigger forwardTCP.
pkt := tcp4syn(t, client, quad100, 1234, 853)
var parsed packet.Parsed
parsed.Decode(pkt)
resp, _ := impl.handleLocalPackets(&parsed, impl.tundev, nil)
if resp != filter.DropSilently {
t.Fatalf("handleLocalPackets for quad-100:853: got %v, want filter.DropSilently", resp)
cases := []struct {
name string
// configure runs inside makeNetstack, before Start.
configure func(*Impl)
// afterStart runs after Start, for state that requires the backend to be
// running (prefs, service IP maps, NIC address registration). Optional.
afterStart func(t *testing.T, impl *Impl)
// dst is the SYN's destination. Its port has no registered handler.
dst netip.AddrPort
// inbound selects the injection path: true => injectInbound (a packet
// from a peer), false => handleLocalPackets (host-originated, or the
// kernel-hairpin re-entry).
inbound bool
// wantForward is whether acceptTCP should forward the connection to the
// host's loopback (true) or reject it with a RST (false).
wantForward bool
}{
{
name: "Quad-100UnservedPortIsRST",
configure: func(impl *Impl) {
impl.ProcessSubnets = false
impl.ProcessLocalIPs = false
impl.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress)
},
// 853 is DoT, the specific case called out in the original bug
// report ("conntrack error no peer found for 100.100.100.100:853").
// Before the fix, port 853 (and any non-{53,80,8080} port) leaked
// out to WireGuard; after the fix it is absorbed and must NOT
// trigger forwardTCP. handleLocalPackets absorbs all quad-100
// traffic regardless of port to prevent it leaking to WireGuard
// peers (which produced noisy "open-conn-track: timeout opening ...;
// no associated peer node" log lines), leaving acceptTCP to reject
// the unserved port with a RST rather than falling through to the
// isTailscaleIP loopback rewrite.
dst: netip.AddrPortFrom(tsaddr.TailscaleServiceIP(), 853),
wantForward: false,
},
{
name: "VIPServiceUnservedPortIsRST",
configure: func(impl *Impl) {
impl.ProcessSubnets = false
impl.ProcessLocalIPs = false
impl.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress)
impl.atomicIsVIPServiceIPFunc.Store(func(addr netip.Addr) bool {
return addr == serviceVIP
})
},
afterStart: func(t *testing.T, impl *Impl) {
// Mark the service as one this node hosts and is actively
// serving, so handleLocalPackets absorbs its traffic into
// netstack (rather than letting a non-hosted VIP route through).
// The service serves no TCP ports here, so every port is
// "non-served". AdvertiseServices flows through to
// UpdateActiveVIPServices, marking the service active.
prefs := ipn.NewPrefs()
prefs.AdvertiseServices = []string{serviceName}
if _, err := impl.lb.EditPrefs(&ipn.MaskedPrefs{
Prefs: *prefs,
AdvertiseServicesSet: true,
}); err != nil {
t.Fatalf("EditPrefs: %v", err)
}
impl.lb.ForTest().SetIPServiceMappings(netmap.IPServiceMappings{serviceVIP: serviceName})
},
dst: netip.AddrPortFrom(serviceVIP, 8001), // 8001: a port the service doesn't serve
wantForward: false,
},
{
name: "LocalTailscaleIPUnhandledPortForwardsToLoopback",
configure: func(impl *Impl) {
impl.ProcessSubnets = false
// ProcessLocalIPs=true so an inbound packet to a local Tailscale
// IP is absorbed into netstack and dispatched to acceptTCP.
impl.ProcessLocalIPs = true
impl.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool {
return addr == selfIP
})
},
// 9999 has no SSH/webclient/peerapi/serve handler, so
// TCPHandlerForDst returns nil and acceptTCP falls to the
// isTailscaleIP case, which rewrites the dial to 127.0.0.1:9999 and
// calls forwardTCP. This is how local handlers reach the host, and
// the RST guards above must not swallow it.
dst: netip.AddrPortFrom(selfIP, 9999),
inbound: true,
wantForward: true,
},
}
// acceptTCP runs asynchronously in the gVisor TCP dispatcher after
// handleLocalPackets injects the packet into netstack. Use the
// in-flight connection counter as a deterministic synchronization
// point: wrapTCPProtocolHandler increments connsInFlightByClient
// when the dispatcher hands the connection off to acceptTCP, and
// acceptTCP's deferred decrementInFlightTCPForward decrements it
// on return.
//
// On the green path (RST guard fires), acceptTCP returns promptly
// and the counter reaches 0. On the red path (fall-through to
// forwardTCP), acceptTCP blocks inside the forwardDialFunc call —
// makeHangDialer signals gotConn on entry (buffered, non-blocking)
// and then blocks forever — so the counter never reaches 0 but
// gotConn fires synchronously from the dispatcher goroutine. A
// select on both races those outcomes without real-time padding.
//
// testing/synctest is not usable here: gVisor's sleep package calls
// the runtime's gopark directly rather than via the standard
// library, so synctest.Wait() cannot observe those goroutines
// becoming durably blocked and hangs indefinitely.
inFlightZero := make(chan struct{})
go func() {
for {
impl.mu.Lock()
n := impl.connsInFlightByClient[client]
impl.mu.Unlock()
if n == 0 {
close(inFlightZero)
return
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
impl := makeNetstack(t, tc.configure)
if tc.afterStart != nil {
tc.afterStart(t, impl)
}
time.Sleep(time.Millisecond)
}
}()
select {
case <-gotConn:
t.Fatalf("forwardDialFunc was called for quad-100:853; acceptTCP fell through to forwardTCP instead of sending RST. This means stray traffic to quad-100 on unserved ports is being redirected to the host's loopback at the same port.")
case <-inFlightZero:
// acceptTCP returned cleanly; the RST guard fired.
case <-time.After(5 * time.Second):
// Safety net so a regression in the in-flight counter plumbing
// doesn't hang the whole test run; both outcomes above should
// fire within milliseconds in practice.
t.Fatal("timed out waiting for acceptTCP to dispatch quad-100:853 SYN")
dialFn, gotConn := makeHangDialer(t)
impl.forwardDialFunc = dialFn
// Use a client IP in the CGNAT range so shouldProcessInbound-adjacent
// code paths treat this as plausibly-peer-sourced traffic, matching
// what a real stray probe from a peer or the host OS would look like.
client := netip.MustParseAddr("100.101.102.103")
pkt := tcp4syn(t, client, tc.dst.Addr(), 1234, tc.dst.Port())
var parsed packet.Parsed
parsed.Decode(pkt)
// Both injection paths absorb the packet into netstack, returning
// filter.DropSilently (i.e. not handing it to the host); acceptTCP
// is then dispatched with the packet.
inject := impl.handleLocalPackets
if tc.inbound {
inject = impl.injectInbound
}
if resp, _ := inject(&parsed, impl.tundev, nil); resp != filter.DropSilently {
t.Fatalf("inject for %v: got filter outcome %v, want filter.DropSilently", tc.dst, resp)
}
// acceptTCP runs asynchronously in the gVisor TCP dispatcher after
// handleLocalPackets injects the packet into netstack. Use the
// in-flight connection counter as a deterministic synchronization
// point: wrapTCPProtocolHandler increments connsInFlightByClient
// when the dispatcher hands the connection off to acceptTCP, and
// acceptTCP's deferred decrementInFlightTCPForward decrements it
// on return.
//
// On the green path (RST guard fires), acceptTCP returns promptly
// and the counter reaches 0. On the red path (fall-through to
// forwardTCP), acceptTCP blocks inside the forwardDialFunc call —
// makeHangDialer signals gotConn on entry (buffered, non-blocking)
// and then blocks forever — so the counter never reaches 0 but
// gotConn fires synchronously from the dispatcher goroutine. A
// select on both races those outcomes without real-time padding.
//
// testing/synctest is not usable here: gVisor's sleep package calls
// the runtime's gopark directly rather than via the standard
// library, so synctest.Wait() cannot observe those goroutines
// becoming durably blocked and hangs indefinitely.
inFlightZero := make(chan struct{})
go func() {
for {
impl.mu.Lock()
n := impl.connsInFlightByClient[client]
impl.mu.Unlock()
if n == 0 {
close(inFlightZero)
return
}
time.Sleep(time.Millisecond)
}
}()
select {
case <-gotConn:
if !tc.wantForward {
t.Fatalf("forwardDialFunc was called for %v; acceptTCP forwarded to the host's loopback instead of sending a RST", tc.dst)
}
case <-inFlightZero:
if tc.wantForward {
t.Fatalf("forwardDialFunc was NOT called for %v; acceptTCP rejected the connection instead of forwarding it to loopback", tc.dst)
}
case <-time.After(5 * time.Second):
// Safety net so a regression in the in-flight counter plumbing
// doesn't hang the whole test run; both outcomes above should
// fire within milliseconds in practice.
t.Fatalf("timed out waiting for acceptTCP to dispatch %v SYN", tc.dst)
}
})
}
}