net/tstun, wgengine/filter: track UDP flow state for injected packets

Outbound packets produced by netstack (used by tailscaled with
--tun userspace-networking, by tsnet, and by the SOCKS5/HTTP proxies)
enter the wrapper via InjectOutbound{,PacketBuffer} and take the
injectedRead path, which bypasses Filter.RunOut.

RunOut's side effect for UDP/SCTP is to insert the reverse-flow tuple
into the connection-tracking LRU so that Filter.RunIn admits inbound
replies that no explicit ACL rule covers. Skipping it on the injected
path meant a netstack-side dial of UDP would send fine but the reply
would be dropped as "no matching rule". The kernel-TUN path was
already fine because it goes through RunOut.

Fixes #14229
Fixes #20064

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I816ef55c493a12ff4f561cd89c095559b5c2743b
This commit is contained in:
Brad Fitzpatrick
2026-06-22 15:57:37 -07:00
committed by Brad Fitzpatrick
parent 568c0bda24
commit e0677ccc76
4 changed files with 252 additions and 4 deletions
+65
View File
@@ -458,6 +458,71 @@ func TestFilter(t *testing.T) {
assertMetricPackets(t, "outACL", 0, metricOutboundDroppedPacketsACL)
}
// TestInjectOutboundRecordsUDPFlowState verifies that an injected outbound UDP
// packet (as produced by netstack on userspace-networking / tsnet / SOCKS5
// callers) records reverse-flow state so that the matching inbound reply is
// admitted by the inbound filter, even when no explicit ACL rule covers the
// reply. See tailscale/tailscale#14229 and tailscale/tailscale#20064.
func TestInjectOutboundRecordsUDPFlowState(t *testing.T) {
bus := eventbustest.NewBus(t)
chtun, tun := newChannelTUN(t.Logf, bus, true) // secure: install filter
defer tun.Close()
// 53 isn't in setfilter's allowed inbound port range (89-90), so a reply
// from 5.6.7.8:53 → 1.2.3.4:<port> is only admissible via reverse-flow
// state recorded by the prior outbound packet.
const localPort, peerPort = 33333, 53
const localIP, peerIP = "1.2.3.4", "5.6.7.8"
// Inject a UDP packet outbound. Run in a goroutine since
// InjectOutbound blocks on the unbuffered vectorOutbound channel
// until Read drains it.
go func() {
if err := tun.InjectOutbound(udp4(localIP, peerIP, localPort, peerPort)); err != nil {
t.Errorf("InjectOutbound: %v", err)
}
}()
// Drain the injected packet via Read. This drives injectedRead, which
// is what records the reverse-flow tuple in filter state.
var buf [MaxPacketSize]byte
sizes := make([]int, 1)
if n, err := tun.Read([][]byte{buf[:]}, sizes, 0); err != nil {
t.Fatalf("Read: %v", err)
} else if n != 1 {
t.Fatalf("Read returned %d packets, want 1", n)
}
// Now simulate the inbound UDP reply. Without flow-state tracking on the
// injected outbound path, the inbound filter has no matching rule and
// drops the reply silently. With tracking, it should be delivered.
replyPkt := udp4(peerIP, localIP, peerPort, localPort)
// tun.Write blocks writing to chtun.Inbound when the filter accepts the
// packet, so drain Inbound concurrently and confirm delivery there.
delivered := make(chan []byte, 1)
go func() {
select {
case got := <-chtun.Inbound:
delivered <- got
case <-tun.closed:
}
}()
if _, err := tun.Write([][]byte{replyPkt}, 0); err != nil {
t.Fatalf("Write: %v", err)
}
select {
case got := <-delivered:
if !bytes.Equal(got, replyPkt) {
t.Errorf("delivered packet mismatch")
}
case <-time.After(5 * time.Second):
t.Fatal("inbound UDP reply was dropped by filter; injected outbound did not record flow state")
}
}
func assertMetricPackets(t *testing.T, metricName string, want, got int64) {
t.Helper()
if want != got {