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:
committed by
Brad Fitzpatrick
parent
568c0bda24
commit
e0677ccc76
+28
-2
@@ -1101,7 +1101,10 @@ func invertGSOChecksum(pkt []byte, gso netstack_GSO) {
|
||||
pkt[at+1] = ^pkt[at+1]
|
||||
}
|
||||
|
||||
// injectedRead handles injected reads, which bypass filters.
|
||||
// injectedRead handles injected reads. Injected packets bypass the outbound
|
||||
// filter rules, but UDP/SCTP flow state is still recorded via
|
||||
// [filter.Filter.UpdateOutboundFlowState] so inbound replies are admitted by
|
||||
// [filter.Filter.RunIn].
|
||||
func (t *Wrapper) injectedRead(res tunInjectedRead, outBuffs [][]byte, sizes []int, offset int) (n int, err error) {
|
||||
var gso netstack_GSO
|
||||
|
||||
@@ -1128,6 +1131,28 @@ func (t *Wrapper) injectedRead(res tunInjectedRead, outBuffs [][]byte, sizes []i
|
||||
defer parsedPacketPool.Put(p)
|
||||
p.Decode(pkt)
|
||||
|
||||
// Record reverse-flow connection-tracking state for this outbound packet so
|
||||
// that inbound replies are admitted by the filter. Injected packets bypass
|
||||
// the regular RunOut path that records this state for UDP/SCTP flows; doing
|
||||
// it here keeps userspace-networking and tsnet UDP replies from being
|
||||
// dropped as "no matching rule". This must run before SNAT so the tracked
|
||||
// tuple matches what RunIn sees after DNAT on the inbound side. Select
|
||||
// between the normal and jailed filters the same way
|
||||
// filterPacketOutboundToWireGuard does, so jailed peers (e.g. Mullvad exit
|
||||
// nodes) record state on the filter that will run on the reply. See #14229
|
||||
// and #20064.
|
||||
if !t.disableFilter {
|
||||
var filt *filter.Filter
|
||||
if pc.outboundPacketIsJailed(p) {
|
||||
filt = t.jailedFilter.Load()
|
||||
} else {
|
||||
filt = t.filter.Load()
|
||||
}
|
||||
if filt != nil {
|
||||
filt.UpdateOutboundFlowState(p)
|
||||
}
|
||||
}
|
||||
|
||||
invertGSOChecksum(pkt, gso)
|
||||
pc.snat(p)
|
||||
invertGSOChecksum(pkt, gso)
|
||||
@@ -1500,7 +1525,8 @@ func (t *Wrapper) injectOutboundPong(pp *packet.Parsed, req packet.TSMPPingReque
|
||||
// InjectOutbound makes the Wrapper device behave as if a packet
|
||||
// with the given contents was sent to the network.
|
||||
// It does not block, but takes ownership of the packet.
|
||||
// The injected packet will not pass through outbound filters.
|
||||
// The injected packet will not pass through outbound filter rules,
|
||||
// but UDP/SCTP flow state is recorded so inbound replies are admitted.
|
||||
// Injecting an empty packet is a no-op.
|
||||
func (t *Wrapper) InjectOutbound(pkt []byte) error {
|
||||
if len(pkt) > MaxPacketSize {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user