From 1b2062f3c1bdb12bbadbd2b4bdbb851929c4c12e Mon Sep 17 00:00:00 2001 From: Michael Ben-Ami Date: Tue, 23 Jun 2026 16:51:51 -0400 Subject: [PATCH] net/tstun: invoke conn25 app connector hook on injected reads The primary purpose is that return packets from the target app get properly SNATed on connectors with --tun=userspace-networking, matching the NAT behavior in the kernel tun path. This is also necessary but not sufficient for clients of connectors in userspace networking mode. The hook will DNAT MagicIPs, but won't actually be sent MagicIPs until conn25 app connector DNS works with userspace networking. Fixes tailscale/corp#43201 Signed-off-by: Michael Ben-Ami --- net/tstun/wrap.go | 20 ++++++++++++++++++++ net/tstun/wrap_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/net/tstun/wrap.go b/net/tstun/wrap.go index ec7bc94ea..dbaabdc0b 100644 --- a/net/tstun/wrap.go +++ b/net/tstun/wrap.go @@ -1154,6 +1154,26 @@ func (t *Wrapper) injectedRead(res tunInjectedRead, outBuffs [][]byte, sizes []i } invertGSOChecksum(pkt, gso) + // Check if this is a packet for conn25-style app connectors, + // and perform the necessary NAT. The main case that requires + // NAT from netstack toward WireGuard is an SNAT on return traffic + // from the target application on the internet, translating + // the original server's source IP to the TransitIP. + // The hook can also perform DNAT for client-originated traffic, + // translating the destination MagicIP to a TransitIP, and rejects + // MagicIPs that have not been approved for the client. + // + // Normal non-connector traffic is forwarded unmodified. + // + // Cross-tailnet conn25 app connector connections are not supported, + // so at most one of this hook and the following pc.snat should modify the packet. + if t.PreFilterPacketOutboundToWireGuardAppConnectorIntercept != nil { + if r := t.PreFilterPacketOutboundToWireGuardAppConnectorIntercept(p, t); r.IsDrop() { + metricPacketOut.Add(1) + metricPacketOutDrop.Add(1) + return 0, nil + } + } pc.snat(p) invertGSOChecksum(pkt, gso) diff --git a/net/tstun/wrap_test.go b/net/tstun/wrap_test.go index 644bb9f53..631dc083e 100644 --- a/net/tstun/wrap_test.go +++ b/net/tstun/wrap_test.go @@ -28,6 +28,7 @@ "tailscale.com/disco" "tailscale.com/net/netaddr" "tailscale.com/net/packet" + "tailscale.com/net/packet/checksum" "tailscale.com/tstest" "tailscale.com/tstime/mono" "tailscale.com/types/ipproto" @@ -1120,3 +1121,36 @@ func TestInterceptOrdering(t *testing.T) { t.Errorf("got number of intercepts run in Read(): %d; want: %d", seq, numOutboundIntercepts) } } + +func TestInjectedReadCallsAppConnectorHook(t *testing.T) { + var called bool + hook := func(p *packet.Parsed, _ *Wrapper) filter.Response { + called = true + checksum.UpdateSrcAddr(p, netip.MustParseAddr("169.254.0.1")) + return filter.Accept + } + + bus := eventbustest.NewBus(t) + _, tun := newFakeTUN(t.Logf, bus, false) + tun.PreFilterPacketOutboundToWireGuardAppConnectorIntercept = hook + tun.Start() + defer tun.Close() + + if err := tun.InjectOutbound(udp4("145.53.32.10", "100.25.63.57", 80, 12345)); err != nil { + t.Fatalf("InjectOutbound error: %v", err) + } + + var buf [MaxPacketSize]byte + sizes := make([]int, 1) + tun.Read([][]byte{buf[:]}, sizes, 0) + + if !called { + t.Error("app connector hook was not called in InjectOutbound") + } + + wantPkt := udp4("169.254.0.1", "100.25.63.57", 80, 12345) + gotPkt := buf[:sizes[0]] + if !bytes.Equal(wantPkt, gotPkt) { + t.Errorf("packet mismatch\nwant:\t% x\ngot:\t% x", wantPkt, gotPkt) + } +}