Files
tailscale/feature/conn25/datapath_test.go
Michael Ben-Ami ce7789071f feature/conn25: add NATing support with flow caching
Introduce a datapathHandler that implements hooks that will
receive packets from the tstun.Wrapper. This commit does not wire
those up just yet.

Perform DNAT from Magic IP to Transit IP on outbound flows on clients,
and reverse SNAT in the reverse direction.

Perform DNAT from Transit IP to final destination IP on outbound flows
on connectors, and reverse SNAT in the reverse direction.

Introduce FlowTable to cache validated flows by 5-tuple for fast lookups
after the first packet.

Flow expiration is not covered, and is intended as future work before
the feature is officially released.

Fixes tailscale/corp#34249
Fixes tailscale/corp#35995

Co-authored-by: Fran Bull <fran@tailscale.com>
Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-03-18 11:49:47 -04:00

362 lines
12 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package conn25
import (
"errors"
"net/netip"
"testing"
"tailscale.com/net/packet"
"tailscale.com/types/ipproto"
"tailscale.com/wgengine/filter"
)
type testConn25 struct {
clientTransitIPForMagicIPFn func(netip.Addr) (netip.Addr, error)
connectorRealIPForTransitIPConnectionFn func(netip.Addr, netip.Addr) (netip.Addr, error)
}
func (tc *testConn25) ClientTransitIPForMagicIP(magicIP netip.Addr) (netip.Addr, error) {
return tc.clientTransitIPForMagicIPFn(magicIP)
}
func (tc *testConn25) ConnectorRealIPForTransitIPConnection(srcIP netip.Addr, transitIP netip.Addr) (netip.Addr, error) {
return tc.connectorRealIPForTransitIPConnectionFn(srcIP, transitIP)
}
func TestHandlePacketFromTunDevice(t *testing.T) {
clientSrcIP := netip.MustParseAddr("100.70.0.1")
magicIP := netip.MustParseAddr("10.64.0.1")
unusedMagicIP := netip.MustParseAddr("10.64.0.2")
transitIP := netip.MustParseAddr("169.254.0.1")
realIP := netip.MustParseAddr("240.64.0.1")
clientPort := uint16(1234)
serverPort := uint16(80)
tests := []struct {
description string
p *packet.Parsed
throwMappingErr bool
expectedSrc netip.AddrPort
expectedDst netip.AddrPort
expectedFilterResponse filter.Response
}{
{
description: "accept-and-nat-new-client-flow-mapped-magic-ip",
p: &packet.Parsed{
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(magicIP, serverPort),
},
expectedSrc: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(transitIP, serverPort),
expectedFilterResponse: filter.Accept,
},
{
description: "drop-unmapped-magic-ip",
p: &packet.Parsed{
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(unusedMagicIP, serverPort),
},
expectedSrc: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(unusedMagicIP, serverPort),
expectedFilterResponse: filter.Drop,
},
{
description: "accept-dont-nat-other-mapping-error",
p: &packet.Parsed{
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(magicIP, serverPort),
},
throwMappingErr: true,
expectedSrc: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(magicIP, serverPort),
expectedFilterResponse: filter.Accept,
},
{
description: "accept-dont-nat-uninteresting-client-side",
p: &packet.Parsed{
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(realIP, serverPort),
},
expectedSrc: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(realIP, serverPort),
expectedFilterResponse: filter.Accept,
},
{
description: "accept-dont-nat-uninteresting-connector-side",
p: &packet.Parsed{
Src: netip.AddrPortFrom(realIP, serverPort),
Dst: netip.AddrPortFrom(clientSrcIP, clientPort),
},
expectedSrc: netip.AddrPortFrom(realIP, serverPort),
expectedDst: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedFilterResponse: filter.Accept,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
mock := &testConn25{}
mock.clientTransitIPForMagicIPFn = func(mip netip.Addr) (netip.Addr, error) {
if tt.throwMappingErr {
return netip.Addr{}, errors.New("synthetic mapping error")
}
if mip == magicIP {
return transitIP, nil
}
if mip == unusedMagicIP {
return netip.Addr{}, ErrUnmappedMagicIP
}
return netip.Addr{}, nil
}
dph := newDatapathHandler(mock, nil)
tt.p.IPProto = ipproto.UDP
tt.p.IPVersion = 4
tt.p.StuffForTesting(40)
if want, got := tt.expectedFilterResponse, dph.HandlePacketFromTunDevice(tt.p); want != got {
t.Errorf("unexpected filter response: want %v, got %v", want, got)
}
if want, got := tt.expectedSrc, tt.p.Src; want != got {
t.Errorf("unexpected packet src: want %v, got %v", want, got)
}
if want, got := tt.expectedDst, tt.p.Dst; want != got {
t.Errorf("unexpected packet dst: want %v, got %v", want, got)
}
})
}
}
func TestHandlePacketFromWireGuard(t *testing.T) {
clientSrcIP := netip.MustParseAddr("100.70.0.1")
unknownSrcIP := netip.MustParseAddr("100.99.99.99")
transitIP := netip.MustParseAddr("169.254.0.1")
realIP := netip.MustParseAddr("240.64.0.1")
clientPort := uint16(1234)
serverPort := uint16(80)
tests := []struct {
description string
p *packet.Parsed
throwMappingErr bool
expectedSrc netip.AddrPort
expectedDst netip.AddrPort
expectedFilterResponse filter.Response
}{
{
description: "accept-and-nat-new-connector-flow-mapped-src-and-transit-ip",
p: &packet.Parsed{
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(transitIP, serverPort),
},
expectedSrc: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(realIP, serverPort),
expectedFilterResponse: filter.Accept,
},
{
description: "drop-unmapped-src-and-transit-ip",
p: &packet.Parsed{
Src: netip.AddrPortFrom(unknownSrcIP, clientPort),
Dst: netip.AddrPortFrom(transitIP, serverPort),
},
expectedSrc: netip.AddrPortFrom(unknownSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(transitIP, serverPort),
expectedFilterResponse: filter.Drop,
},
{
description: "accept-dont-nat-other-mapping-error",
p: &packet.Parsed{
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(transitIP, serverPort),
},
throwMappingErr: true,
expectedSrc: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(transitIP, serverPort),
expectedFilterResponse: filter.Accept,
},
{
description: "accept-dont-nat-uninteresting-connector-side",
p: &packet.Parsed{
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(realIP, serverPort),
},
expectedSrc: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedDst: netip.AddrPortFrom(realIP, serverPort),
expectedFilterResponse: filter.Accept,
},
{
description: "accept-dont-nat-uninteresting-client-side",
p: &packet.Parsed{
Src: netip.AddrPortFrom(realIP, serverPort),
Dst: netip.AddrPortFrom(clientSrcIP, clientPort),
},
expectedSrc: netip.AddrPortFrom(realIP, serverPort),
expectedDst: netip.AddrPortFrom(clientSrcIP, clientPort),
expectedFilterResponse: filter.Accept,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
mock := &testConn25{}
mock.connectorRealIPForTransitIPConnectionFn = func(src, tip netip.Addr) (netip.Addr, error) {
if tt.throwMappingErr {
return netip.Addr{}, errors.New("synthetic mapping error")
}
if tip == transitIP {
if src == clientSrcIP {
return realIP, nil
} else {
return netip.Addr{}, ErrUnmappedSrcAndTransitIP
}
}
return netip.Addr{}, nil
}
dph := newDatapathHandler(mock, nil)
tt.p.IPProto = ipproto.UDP
tt.p.IPVersion = 4
tt.p.StuffForTesting(40)
if want, got := tt.expectedFilterResponse, dph.HandlePacketFromWireGuard(tt.p); want != got {
t.Errorf("unexpected filter response: want %v, got %v", want, got)
}
if want, got := tt.expectedSrc, tt.p.Src; want != got {
t.Errorf("unexpected packet src: want %v, got %v", want, got)
}
if want, got := tt.expectedDst, tt.p.Dst; want != got {
t.Errorf("unexpected packet dst: want %v, got %v", want, got)
}
})
}
}
func TestClientFlowCache(t *testing.T) {
getTransitIPCalled := false
clientSrcIP := netip.MustParseAddr("100.70.0.1")
magicIP := netip.MustParseAddr("10.64.0.1")
transitIP := netip.MustParseAddr("169.254.0.1")
clientPort := uint16(1234)
serverPort := uint16(80)
mock := &testConn25{}
mock.clientTransitIPForMagicIPFn = func(mip netip.Addr) (netip.Addr, error) {
if getTransitIPCalled {
t.Errorf("ClientGetTransitIPForMagicIP unexpectedly called more than once")
}
getTransitIPCalled = true
return transitIP, nil
}
dph := newDatapathHandler(mock, nil)
outgoing := packet.Parsed{
IPProto: ipproto.UDP,
IPVersion: 4,
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(magicIP, serverPort),
}
outgoing.StuffForTesting(40)
o1 := outgoing
if dph.HandlePacketFromTunDevice(&o1) != filter.Accept {
t.Errorf("first call to HandlePacketFromTunDevice was not accepted")
}
if want, got := netip.AddrPortFrom(transitIP, serverPort), o1.Dst; want != got {
t.Errorf("unexpected packet dst after first call: want %v, got %v", want, got)
}
// The second call should use the cache.
o2 := outgoing
if dph.HandlePacketFromTunDevice(&o2) != filter.Accept {
t.Errorf("second call to HandlePacketFromTunDevice was not accepted")
}
if want, got := netip.AddrPortFrom(transitIP, serverPort), o2.Dst; want != got {
t.Errorf("unexpected packet dst after second call: want %v, got %v", want, got)
}
// Return traffic should have the Transit IP as the source,
// and be SNATed to the Magic IP.
incoming := &packet.Parsed{
IPProto: ipproto.UDP,
IPVersion: 4,
Src: netip.AddrPortFrom(transitIP, serverPort),
Dst: netip.AddrPortFrom(clientSrcIP, clientPort),
}
incoming.StuffForTesting(40)
if dph.HandlePacketFromWireGuard(incoming) != filter.Accept {
t.Errorf("call to HandlePacketFromWireGuard was not accepted")
}
if want, got := netip.AddrPortFrom(magicIP, serverPort), incoming.Src; want != got {
t.Errorf("unexpected packet src after second call: want %v, got %v", want, got)
}
}
func TestConnectorFlowCache(t *testing.T) {
getRealIPCalled := false
clientSrcIP := netip.MustParseAddr("100.70.0.1")
transitIP := netip.MustParseAddr("169.254.0.1")
realIP := netip.MustParseAddr("240.64.0.1")
clientPort := uint16(1234)
serverPort := uint16(80)
mock := &testConn25{}
mock.connectorRealIPForTransitIPConnectionFn = func(src, tip netip.Addr) (netip.Addr, error) {
if getRealIPCalled {
t.Errorf("ConnectorRealIPForTransitIPConnection unexpectedly called more than once")
}
getRealIPCalled = true
return realIP, nil
}
dph := newDatapathHandler(mock, nil)
outgoing := packet.Parsed{
IPProto: ipproto.UDP,
IPVersion: 4,
Src: netip.AddrPortFrom(clientSrcIP, clientPort),
Dst: netip.AddrPortFrom(transitIP, serverPort),
}
outgoing.StuffForTesting(40)
o1 := outgoing
if dph.HandlePacketFromWireGuard(&o1) != filter.Accept {
t.Errorf("first call to HandlePacketFromWireGuard was not accepted")
}
if want, got := netip.AddrPortFrom(realIP, serverPort), o1.Dst; want != got {
t.Errorf("unexpected packet dst after first call: want %v, got %v", want, got)
}
// The second call should use the cache.
o2 := outgoing
if dph.HandlePacketFromWireGuard(&o2) != filter.Accept {
t.Errorf("second call to HandlePacketFromWireGuard was not accepted")
}
if want, got := netip.AddrPortFrom(realIP, serverPort), o2.Dst; want != got {
t.Errorf("unexpected packet dst after second call: want %v, got %v", want, got)
}
// Return traffic should have the Real IP as the source,
// and be SNATed to the Transit IP.
incoming := &packet.Parsed{
IPProto: ipproto.UDP,
IPVersion: 4,
Src: netip.AddrPortFrom(realIP, serverPort),
Dst: netip.AddrPortFrom(clientSrcIP, clientPort),
}
incoming.StuffForTesting(40)
if dph.HandlePacketFromTunDevice(incoming) != filter.Accept {
t.Errorf("call to HandlePacketFromTunDevice was not accepted")
}
if want, got := netip.AddrPortFrom(transitIP, serverPort), incoming.Src; want != got {
t.Errorf("unexpected packet src after second call: want %v, got %v", want, got)
}
}