diff --git a/tstest/integration/nat/nat_test.go b/tstest/integration/nat/nat_test.go index b50c0da8a..453a74cf1 100644 --- a/tstest/integration/nat/nat_test.go +++ b/tstest/integration/nat/nat_test.go @@ -102,6 +102,13 @@ func easy(c *vnet.Config) *vnet.Node { fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT)) } +func easyAF(c *vnet.Config) *vnet.Node { + n := c.NumNodes() + 1 + return c.AddNode(c.AddNetwork( + fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP + fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyAFNAT)) +} + func sameLAN(c *vnet.Config) *vnet.Node { nw := c.FirstNetwork() if nw == nil { @@ -391,6 +398,11 @@ func TestEasyHard(t *testing.T) { nt.runTest(easy, hard) } +func TestEasyAFHard(t *testing.T) { + nt := newNatTest(t) + nt.runTest(easyAF, hard) +} + func TestEasyHardPMP(t *testing.T) { nt := newNatTest(t) nt.runTest(easy, hardPMP) @@ -430,6 +442,7 @@ func TestGrid(t *testing.T) { } types := []nodeType{ {"easy", easy}, + {"easyAF", easyAF}, {"hard", hard}, {"easyPMP", easyPMP}, {"hardPMP", hardPMP}, @@ -483,10 +496,13 @@ func TestGrid(t *testing.T) { pf := func(format string, args ...any) { fmt.Fprintf(&hb, format, args...) } + rewrite := func(s string) string { + return strings.ReplaceAll(s, "PMP", "+pm") + } pf("") pf("") for _, a := range types { - pf("", a.name) + pf("", rewrite(a.name)) } pf("\n") @@ -494,7 +510,7 @@ func TestGrid(t *testing.T) { if a.name == "sameLAN" { continue } - pf("", a.name) + pf("", rewrite(a.name)) for _, b := range types { key := a.name + "-" + b.name key2 := b.name + "-" + a.name @@ -509,7 +525,14 @@ func TestGrid(t *testing.T) { } pf("\n") } - pf("
%s%s
%s
%s
") + pf("") + pf("easy: Endpoint-Independent Mapping, Address and Port-Dependent Filtering (e.g. Linux, Google Wifi, Unifi, eero)
") + pf("easyAF: Endpoint-Independent Mapping, Address-Dependent Filtering (James says telephony things or Zyxel type things)
") + pf("hard: Address and Port-Dependent Mapping, Address and Port-Dependent Filtering (FreeBSD, OPNSense, pfSense)
") + pf("one2one: One-to-One NAT (e.g. an EC2 instance with a public IPv4)
") + pf("x+pm: x, with port mapping (NAT-PMP, PCP, UPnP, etc)
") + pf("sameLAN: a second node in the same LAN as the first
") + pf("") if err := os.WriteFile("grid.html", hb.Bytes(), 0666); err != nil { t.Fatalf("writeFile: %v", err) diff --git a/tstest/natlab/vnet/easyaf.go b/tstest/natlab/vnet/easyaf.go new file mode 100644 index 000000000..0901bbdff --- /dev/null +++ b/tstest/natlab/vnet/easyaf.go @@ -0,0 +1,91 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package vnet + +import ( + "log" + "math/rand/v2" + "net/netip" + "time" + + "tailscale.com/util/mak" +) + +// easyAFNAT is an "Endpoint Independent" NAT, like Linux and most home routers +// (many of which are Linux), but with only address filtering, not address+port +// filtering. +// +// James says these are used by "anyone with “voip helpers” turned on" +// "which is a lot of home modem routers" ... "probably like most of the zyxel +// type things". +type easyAFNAT struct { + pool IPPool + wanIP netip.Addr + out map[netip.Addr]portMappingAndTime + in map[uint16]lanAddrAndTime + lastOut map[srcAPDstAddrTuple]time.Time // (lan:port, wan:port) => last packet out time +} + +type srcAPDstAddrTuple struct { + src netip.AddrPort + dst netip.Addr +} + +func init() { + registerNATType(EasyAFNAT, func(p IPPool) (NATTable, error) { + return &easyAFNAT{pool: p, wanIP: p.WANIP()}, nil + }) +} + +func (n *easyAFNAT) IsPublicPortUsed(ap netip.AddrPort) bool { + if ap.Addr() != n.wanIP { + return false + } + _, ok := n.in[ap.Port()] + return ok +} + +func (n *easyAFNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) { + mak.Set(&n.lastOut, srcAPDstAddrTuple{src, dst.Addr()}, at) + if pm, ok := n.out[src.Addr()]; ok { + // Existing flow. + // TODO: bump timestamp + return netip.AddrPortFrom(n.wanIP, pm.port) + } + + // Loop through all 32k high (ephemeral) ports, starting at a random + // position and looping back around to the start. + start := rand.N(uint16(32 << 10)) + for off := range uint16(32 << 10) { + port := 32<<10 + (start+off)%(32<<10) + if _, ok := n.in[port]; !ok { + wanAddr := netip.AddrPortFrom(n.wanIP, port) + if n.pool.IsPublicPortUsed(wanAddr) { + continue + } + + // Found a free port. + mak.Set(&n.out, src.Addr(), portMappingAndTime{port: port, at: at}) + mak.Set(&n.in, port, lanAddrAndTime{lanAddr: src, at: at}) + return wanAddr + } + } + return netip.AddrPort{} // failed to allocate a mapping; TODO: fire an alert? +} + +func (n *easyAFNAT) PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort) { + if dst.Addr() != n.wanIP { + return netip.AddrPort{} // drop; not for us. shouldn't happen if natlabd routing isn't broken. + } + lanDst = n.in[dst.Port()].lanAddr + + // Stateful firewall: drop incoming packets that don't have traffic out. + // TODO(bradfitz): verify Linux does this in the router code, not in the NAT code. + if t, ok := n.lastOut[srcAPDstAddrTuple{lanDst, src.Addr()}]; !ok || at.Sub(t) > 300*time.Second { + log.Printf("Drop incoming packet from %v to %v; no recent outgoing packet", src, dst) + return netip.AddrPort{} + } + + return lanDst +} diff --git a/tstest/natlab/vnet/nat.go b/tstest/natlab/vnet/nat.go index 1922c745c..ad6f29b3a 100644 --- a/tstest/natlab/vnet/nat.go +++ b/tstest/natlab/vnet/nat.go @@ -15,7 +15,8 @@ import ( const ( One2OneNAT NAT = "one2one" - EasyNAT NAT = "easy" + EasyNAT NAT = "easy" // address+port filtering + EasyAFNAT NAT = "easyaf" // address filtering (not port) HardNAT NAT = "hard" )