tstest/natlab/vnet: add start of virtual network-based NAT Lab

Updates #13038

Change-Id: I3c74120d73149c1329288621f6474bbbcaa7e1a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2024-08-05 12:06:48 -07:00 committed by Brad Fitzpatrick
parent 6ca078c46e
commit 1ed958fe23
11 changed files with 2062 additions and 4 deletions

159
cmd/tta/tta.go Normal file
View File

@ -0,0 +1,159 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The tta server is the Tailscale Test Agent.
//
// It runs on each Tailscale node being integration tested and permits the test
// harness to control the node. It connects out to the test drver (rather than
// accepting any TCP connections inbound, which might be blocked depending on
// the scenario being tested) and then the test driver turns the TCP connection
// around and sends request back.
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
"tailscale.com/util/set"
"tailscale.com/version/distro"
)
var (
driverAddr = flag.String("driver", "test-driver.tailscale:8008", "address of the test driver; by default we use the DNS name test-driver.tailscale which is special cased in the emulated network's DNS server")
)
type chanListener <-chan net.Conn
func serveCmd(w http.ResponseWriter, cmd string, args ...string) {
if distro.Get() == distro.Gokrazy && !strings.Contains(cmd, "/") {
cmd = "/user/" + cmd
}
out, err := exec.Command(cmd, args...).CombinedOutput()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if err != nil {
w.Header().Set("Exec-Err", err.Error())
w.WriteHeader(500)
}
w.Write(out)
}
func main() {
if distro.Get() == distro.Gokrazy {
cmdLine, _ := os.ReadFile("/proc/cmdline")
if !bytes.Contains(cmdLine, []byte("tailscale-tta=1")) {
// "Exiting immediately with status code 0 when the
// GOKRAZY_FIRST_START=1 environment variable is set means “dont
// start the program on boot”"
return
}
}
flag.Parse()
log.Printf("Tailscale Test Agent running.")
var mux http.ServeMux
var hs http.Server
hs.Handler = &mux
var (
stMu sync.Mutex
newSet = set.Set[net.Conn]{} // conns in StateNew
)
needConnCh := make(chan bool, 1)
hs.ConnState = func(c net.Conn, s http.ConnState) {
stMu.Lock()
defer stMu.Unlock()
switch s {
case http.StateNew:
newSet.Add(c)
case http.StateClosed:
newSet.Delete(c)
}
if len(newSet) == 0 {
select {
case needConnCh <- true:
default:
}
}
}
conns := make(chan net.Conn, 1)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "TTA\n")
return
})
mux.HandleFunc("/up", func(w http.ResponseWriter, r *http.Request) {
serveCmd(w, "tailscale", "up", "--auth-key=test")
})
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
serveCmd(w, "tailscale", "status", "--json")
})
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
target := r.FormValue("target")
cmd := exec.Command("tailscale", "ping", target)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.(http.Flusher).Flush()
cmd.Stdout = w
cmd.Stderr = w
if err := cmd.Run(); err != nil {
fmt.Fprintf(w, "error: %v\n", err)
}
})
go hs.Serve(chanListener(conns))
var lastErr string
needConnCh <- true
for {
<-needConnCh
c, err := connect()
log.Printf("Connect: %v", err)
if err != nil {
s := err.Error()
if s != lastErr {
log.Printf("Connect failure: %v", s)
}
lastErr = s
time.Sleep(time.Second)
continue
}
conns <- c
time.Sleep(time.Second)
}
}
func connect() (net.Conn, error) {
c, err := net.Dial("tcp", *driverAddr)
if err != nil {
return nil, err
}
return c, nil
}
func (cl chanListener) Accept() (net.Conn, error) {
c, ok := <-cl
if !ok {
return nil, errors.New("closed")
}
return c, nil
}
func (cl chanListener) Close() error {
return nil
}
func (cl chanListener) Addr() net.Addr {
return &net.TCPAddr{
IP: net.ParseIP("52.0.0.34"), // TS..DR(iver)
Port: 123,
}
}

20
cmd/vnet/run-krazy.sh Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
echo "Type 'C-a c' to enter monitor; q to quit."
set -eux
qemu-system-x86_64 -M microvm,isa-serial=off \
-m 1G \
-nodefaults -no-user-config -nographic \
-kernel $HOME/src/github.com/tailscale/gokrazy-kernel/vmlinuz \
-append "console=hvc0 root=PARTUUID=60c24cc1-f3f9-427a-8199-dd02023b0001/PARTNROFF=1 ro init=/gokrazy/init panic=10 oops=panic pci=off nousb tsc=unstable clocksource=hpet tailscale-tta=1" \
-drive id=blk0,file=$HOME/src/tailscale.com/gokrazy/tsapp.img,format=raw \
-device virtio-blk-device,drive=blk0 \
-netdev stream,id=net0,addr.type=unix,addr.path=/tmp/qemu.sock \
-device virtio-serial-device \
-device virtio-net-device,netdev=net0,mac=52:cc:cc:cc:cc:00 \
-chardev stdio,id=virtiocon0,mux=on \
-device virtconsole,chardev=virtiocon0 \
-mon chardev=virtiocon0,mode=readline \
-audio none

101
cmd/vnet/vnet-main.go Normal file
View File

@ -0,0 +1,101 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The vnet binary runs a virtual network stack in userspace for qemu instances
// to connect to and simulate various network conditions.
package main
import (
"context"
"flag"
"log"
"net"
"os"
"time"
"tailscale.com/tstest/natlab/vnet"
)
var (
listen = flag.String("listen", "/tmp/qemu.sock", "path to listen on")
nat = flag.String("nat", "easy", "type of NAT to use")
portmap = flag.Bool("portmap", false, "enable portmapping")
dgram = flag.Bool("dgram", false, "enable datagram mode; for use with macOS Hypervisor.Framework and VZFileHandleNetworkDeviceAttachment")
)
func main() {
flag.Parse()
if _, err := os.Stat(*listen); err == nil {
os.Remove(*listen)
}
var srv net.Listener
var err error
var conn *net.UnixConn
if *dgram {
addr, err := net.ResolveUnixAddr("unixgram", *listen)
if err != nil {
log.Fatalf("ResolveUnixAddr: %v", err)
}
conn, err = net.ListenUnixgram("unixgram", addr)
if err != nil {
log.Fatalf("ListenUnixgram: %v", err)
}
defer conn.Close()
} else {
srv, err = net.Listen("unix", *listen)
}
if err != nil {
log.Fatal(err)
}
var c vnet.Config
node1 := c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.NAT(*nat)))
c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", vnet.NAT(*nat)))
if *portmap {
node1.Network().AddService(vnet.NATPMP)
}
s, err := vnet.New(&c)
if err != nil {
log.Fatalf("newServer: %v", err)
}
if err := s.PopulateDERPMapIPs(); err != nil {
log.Printf("warning: ignoring failure to populate DERP map: %v", err)
}
s.WriteStartingBanner(os.Stdout)
go func() {
getStatus := func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
st, err := s.NodeStatus(ctx, node1)
if err != nil {
log.Printf("NodeStatus: %v", err)
return
}
log.Printf("NodeStatus: %q", st)
}
for {
time.Sleep(5 * time.Second)
getStatus()
}
}()
if conn != nil {
s.ServeUnixConn(conn, vnet.ProtocolUnixDGRAM)
return
}
for {
c, err := srv.Accept()
if err != nil {
log.Printf("Accept: %v", err)
continue
}
go s.ServeUnixConn(c.(*net.UnixConn), vnet.ProtocolQEMU)
}
}

1
go.mod
View File

@ -39,6 +39,7 @@ require (
github.com/golangci/golangci-lint v1.52.2
github.com/google/go-cmp v0.6.0
github.com/google/go-containerregistry v0.18.0
github.com/google/gopacket v1.1.19
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806
github.com/google/uuid v1.6.0
github.com/goreleaser/nfpm/v2 v2.33.1

2
go.sum
View File

@ -477,6 +477,8 @@ github.com/google/go-containerregistry v0.18.0/go.mod h1:u0qB2l7mvtWVR5kNcbFIhFY
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 h1:CVuJwN34x4xM2aT4sIKhmeib40NeBPhRihNjQmpJsA4=
github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=

View File

@ -122,8 +122,12 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:t
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1 h1:ycpNCSYwzZ7x4G4ioPNtKQmIY0G/3o4pVf8wCZq6blY=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9 h1:81P7rjnikHKTJ75EkjppvbwUfKHDHYk6LJpO5PZy8pA=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8=
github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
@ -170,6 +174,8 @@ golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 h1:/8/t5pz/mgdRXhYOIeqqYhFAQLE4DDGegc0Y4ZjyFJM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU=
k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q=
k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc=
k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k=

View File

@ -1,16 +1,21 @@
{
"Hostname": "tsapp",
"Update": { "NoPassword": true },
"Update": {
"NoPassword": true
},
"SerialConsole": "ttyS0,115200",
"Packages": [
"github.com/gokrazy/serial-busybox",
"github.com/gokrazy/breakglass",
"tailscale.com/cmd/tailscale",
"tailscale.com/cmd/tailscaled"
"tailscale.com/cmd/tailscaled",
"tailscale.com/cmd/tta"
],
"PackageConfig": {
"github.com/gokrazy/breakglass": {
"CommandLineFlags": [ "-authorized_keys=ec2" ]
"CommandLineFlags": [
"-authorized_keys=ec2"
]
},
"tailscale.com/cmd/tailscale": {
"ExtraFilePaths": {
@ -21,4 +26,4 @@
"KernelPackage": "github.com/tailscale/gokrazy-kernel",
"FirmwarePackage": "github.com/tailscale/gokrazy-kernel",
"InternalCompatibilityFlags": {}
}
}

217
tstest/natlab/vnet/conf.go Normal file
View File

@ -0,0 +1,217 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package vnet
import (
"cmp"
"fmt"
"net/netip"
"slices"
"tailscale.com/util/set"
)
// Note: the exported Node and Network are the configuration types;
// the unexported node and network are the runtime types that are actually
// used once the server is created.
// Config is the requested state of the natlab virtual network.
//
// The zero value is a valid empty configuration. Call AddNode
// and AddNetwork to methods on the returned Node and Network
// values to modify the config before calling NewServer.
// Once the NewServer is called, Config is no longer used.
type Config struct {
nodes []*Node
networks []*Network
}
// AddNode creates a new node in the world.
//
// The opts may be of the following types:
// - *Network: zero, one, or more networks to add this node to
// - TODO: more
//
// On an error or unknown opt type, AddNode returns a
// node with a carried error that gets returned later.
func (c *Config) AddNode(opts ...any) *Node {
num := len(c.nodes)
n := &Node{
mac: MAC{0x52, 0xcc, 0xcc, 0xcc, 0xcc, byte(num)}, // 52=TS then 0xcc for ccclient
}
c.nodes = append(c.nodes, n)
for _, o := range opts {
switch o := o.(type) {
case *Network:
if !slices.Contains(o.nodes, n) {
o.nodes = append(o.nodes, n)
}
n.nets = append(n.nets, o)
default:
if n.err == nil {
n.err = fmt.Errorf("unknown AddNode option type %T", o)
}
}
}
return n
}
// AddNetwork add a new network.
//
// The opts may be of the following types:
// - string IP address, for the network's WAN IP (if any)
// - string netip.Prefix, for the network's LAN IP (defaults to 192.168.0.0/24)
// - NAT, the type of NAT to use
// - NetworkService, a service to add to the network
//
// On an error or unknown opt type, AddNetwork returns a
// network with a carried error that gets returned later.
func (c *Config) AddNetwork(opts ...any) *Network {
num := len(c.networks)
n := &Network{
mac: MAC{0x52, 0xee, 0xee, 0xee, 0xee, byte(num)}, // 52=TS then 0xee for 'etwork
}
c.networks = append(c.networks, n)
for _, o := range opts {
switch o := o.(type) {
case string:
if ip, err := netip.ParseAddr(o); err == nil {
n.wanIP = ip
} else if ip, err := netip.ParsePrefix(o); err == nil {
n.lanIP = ip
} else {
if n.err == nil {
n.err = fmt.Errorf("unknown string option %q", o)
}
}
case NAT:
n.natType = o
case NetworkService:
n.AddService(o)
default:
if n.err == nil {
n.err = fmt.Errorf("unknown AddNetwork option type %T", o)
}
}
}
return n
}
// Node is the configuration of a node in the virtual network.
type Node struct {
err error
n *node // nil until NewServer called
// TODO(bradfitz): this is halfway converted to supporting multiple NICs
// but not done. We need a MAC-per-Network.
mac MAC
nets []*Network
}
// Network returns the first network this node is connected to,
// or nil if none.
func (n *Node) Network() *Network {
if len(n.nets) == 0 {
return nil
}
return n.nets[0]
}
// Network is the configuration of a network in the virtual network.
type Network struct {
mac MAC // MAC address of the router/gateway
natType NAT
wanIP netip.Addr
lanIP netip.Prefix
nodes []*Node
svcs set.Set[NetworkService]
// ...
err error // carried error
}
// NetworkService is a service that can be added to a network.
type NetworkService string
const (
NATPMP NetworkService = "NAT-PMP"
PCP NetworkService = "PCP"
UPnP NetworkService = "UPnP"
)
// AddService adds a network service (such as port mapping protocols) to a
// network.
func (n *Network) AddService(s NetworkService) {
if n.svcs == nil {
n.svcs = set.Of(s)
} else {
n.svcs.Add(s)
}
}
// initFromConfig initializes the server from the previous calls
// to NewNode and NewNetwork and returns an error if
// there were any configuration issues.
func (s *Server) initFromConfig(c *Config) error {
netOfConf := map[*Network]*network{}
for _, conf := range c.networks {
if conf.err != nil {
return conf.err
}
if !conf.lanIP.IsValid() {
conf.lanIP = netip.MustParsePrefix("192.168.0.0/24")
}
n := &network{
s: s,
mac: conf.mac,
portmap: conf.svcs.Contains(NATPMP), // TODO: expand network.portmap
wanIP: conf.wanIP,
lanIP: conf.lanIP,
nodesByIP: map[netip.Addr]*node{},
}
netOfConf[conf] = n
s.networks.Add(n)
if _, ok := s.networkByWAN[conf.wanIP]; ok {
return fmt.Errorf("two networks have the same WAN IP %v; Anycast not (yet?) supported", conf.wanIP)
}
s.networkByWAN[conf.wanIP] = n
}
for _, conf := range c.nodes {
if conf.err != nil {
return conf.err
}
n := &node{
mac: conf.mac,
net: netOfConf[conf.Network()],
}
conf.n = n
if _, ok := s.nodeByMAC[n.mac]; ok {
return fmt.Errorf("two nodes have the same MAC %v", n.mac)
}
s.nodes = append(s.nodes, n)
s.nodeByMAC[n.mac] = n
// Allocate a lanIP for the node. Use the network's CIDR and use final
// octet 101 (for first node), 102, etc. The node number comes from the
// last octent of the MAC address (0-based)
ip4 := n.net.lanIP.Addr().As4()
ip4[3] = 101 + n.mac[5]
n.lanIP = netip.AddrFrom4(ip4)
n.net.nodesByIP[n.lanIP] = n
}
// Now that nodes are populated, set up NAT:
for _, conf := range c.networks {
n := netOfConf[conf]
natType := cmp.Or(conf.natType, EasyNAT)
if err := n.InitNAT(natType); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,71 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package vnet
import "testing"
func TestConfig(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
wantErr string
}{
{
name: "simple",
setup: func(c *Config) {
c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", EasyNAT, NATPMP))
c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", HardNAT))
},
},
{
name: "indirect",
setup: func(c *Config) {
n1 := c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", HardNAT))
n1.Network().AddService(NATPMP)
c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", NAT("hard")))
},
},
{
name: "multi-node-in-net",
setup: func(c *Config) {
net1 := c.AddNetwork("2.1.1.1", "192.168.1.1/24")
c.AddNode(net1)
c.AddNode(net1)
},
},
{
name: "dup-wan-ip",
setup: func(c *Config) {
c.AddNetwork("2.1.1.1", "192.168.1.1/24")
c.AddNetwork("2.1.1.1", "10.2.0.1/16")
},
wantErr: "two networks have the same WAN IP 2.1.1.1; Anycast not (yet?) supported",
},
{
name: "one-to-one-nat-with-multiple-nodes",
setup: func(c *Config) {
net1 := c.AddNetwork("2.1.1.1", "192.168.1.1/24", One2OneNAT)
c.AddNode(net1)
c.AddNode(net1)
},
wantErr: "error creating NAT type \"one2one\" for network 2.1.1.1: can't use one2one NAT type on networks other than single-node networks",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var c Config
tt.setup(&c)
_, err := New(&c)
if err == nil {
if tt.wantErr == "" {
return
}
t.Fatalf("got success; wanted error %q", tt.wantErr)
}
if err.Error() != tt.wantErr {
t.Fatalf("got error %q; want %q", err, tt.wantErr)
}
})
}
}

239
tstest/natlab/vnet/nat.go Normal file
View File

@ -0,0 +1,239 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package vnet
import (
"errors"
"math/rand/v2"
"net/netip"
"time"
"tailscale.com/util/mak"
)
const (
One2OneNAT NAT = "one2one"
EasyNAT NAT = "easy"
HardNAT NAT = "hard"
)
// IPPool is the interface that a NAT implementation uses to get information
// about a network.
//
// Outside of tests, this is typically a *network.
type IPPool interface {
// WANIP returns the primary WAN IP address.
//
// TODO: add another method for networks with multiple WAN IP addresses.
WANIP() netip.Addr
// SoleLanIP reports whether this network has a sole LAN client
// and if so, its IP address.
SoleLANIP() (_ netip.Addr, ok bool)
// TODO: port availability stuff for interacting with portmapping
}
// newTableFunc is a constructor for a NAT table.
// The provided IPPool is typically (outside of tests) a *network.
type newTableFunc func(IPPool) (NATTable, error)
// NAT is a type of NAT that's known to natlab.
//
// For example, "easy" for Linux-style NAT, "hard" for FreeBSD-style NAT, etc.
type NAT string
// natTypes are the known NAT types.
var natTypes = map[NAT]newTableFunc{}
// registerNATType registers a NAT type.
func registerNATType(name NAT, f newTableFunc) {
if _, ok := natTypes[name]; ok {
panic("duplicate NAT type: " + name)
}
natTypes[name] = f
}
// NATTable is what a NAT implementation is expected to do.
//
// This project tests Tailscale as it faces various combinations various NAT
// implementations (e.g. Linux easy style NAT vs FreeBSD hard/endpoint dependent
// NAT vs Cloud 1:1 NAT, etc)
//
// Implementations of NATTable need not handle concurrency; the natlab serializes
// all calls into a NATTable.
//
// The provided `at` value will typically be time.Now, except for tests.
// Implementations should not use real time and should only compare
// previously provided time values.
type NATTable interface {
// PickOutgoingSrc returns the source address to use for an outgoing packet.
//
// The result should either be invalid (to drop the packet) or a WAN (not
// private) IP address.
//
// Typically, the src is a LAN source IP address, but it might also be a WAN
// IP address if the packet is being forwarded for a source machine that has
// a public IP address.
PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort)
// PickIncomingDst returns the destination address to use for an incoming
// packet. The incoming src address is always a public WAN IP.
//
// The result should either be invalid (to drop the packet) or the IP
// address of a machine on the local network address, usually a private
// LAN IP.
PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort)
}
// oneToOneNAT is a 1:1 NAT, like a typical EC2 VM.
type oneToOneNAT struct {
lanIP netip.Addr
wanIP netip.Addr
}
func init() {
registerNATType(One2OneNAT, func(p IPPool) (NATTable, error) {
lanIP, ok := p.SoleLANIP()
if !ok {
return nil, errors.New("can't use one2one NAT type on networks other than single-node networks")
}
return &oneToOneNAT{lanIP: lanIP, wanIP: p.WANIP()}, nil
})
}
func (n *oneToOneNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) {
return netip.AddrPortFrom(n.wanIP, src.Port())
}
func (n *oneToOneNAT) PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort) {
return netip.AddrPortFrom(n.lanIP, dst.Port())
}
type hardKeyOut struct {
lanIP netip.Addr
dst netip.AddrPort
}
type hardKeyIn struct {
wanPort uint16
src netip.AddrPort
}
type portMappingAndTime struct {
port uint16
at time.Time
}
type lanAddrAndTime struct {
lanAddr netip.AddrPort
at time.Time
}
// hardNAT is an "Endpoint Dependent" NAT, like FreeBSD/pfSense/OPNsense.
// This is shown as "MappingVariesByDestIP: true" by netcheck, and what
// Tailscale calls "Hard NAT".
type hardNAT struct {
wanIP netip.Addr
out map[hardKeyOut]portMappingAndTime
in map[hardKeyIn]lanAddrAndTime
}
func init() {
registerNATType(HardNAT, func(p IPPool) (NATTable, error) {
return &hardNAT{wanIP: p.WANIP()}, nil
})
}
func (n *hardNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) {
ko := hardKeyOut{src.Addr(), dst}
if pm, ok := n.out[ko]; ok {
// Existing flow.
// TODO: bump timestamp
return netip.AddrPortFrom(n.wanIP, pm.port)
}
// No existing mapping exists. Create one.
// TODO: clean up old expired mappings
// Instead of proper data structures that would be efficient, we instead
// just loop a bunch and look for a free port. This project is only used
// by tests and doesn't care about performance, this is good enough.
for {
port := rand.N(uint16(32<<10)) + 32<<10 // pick some "ephemeral" port
ki := hardKeyIn{wanPort: port, src: dst}
if _, ok := n.in[ki]; ok {
// Port already in use.
continue
}
mak.Set(&n.in, ki, lanAddrAndTime{lanAddr: src, at: at})
mak.Set(&n.out, ko, portMappingAndTime{port: port, at: at})
return netip.AddrPortFrom(n.wanIP, port)
}
}
func (n *hardNAT) 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.
}
ki := hardKeyIn{wanPort: dst.Port(), src: src}
if pm, ok := n.in[ki]; ok {
// Existing flow.
return pm.lanAddr
}
return netip.AddrPort{} // drop; no mapping
}
// easyNAT is an "Endpoint Independent" NAT, like Linux and most home routers
// (many of which are Linux).
//
// This is shown as "MappingVariesByDestIP: false" by netcheck, and what
// Tailscale calls "Easy NAT".
//
// Unlike Linux, this implementation is capped at 32k entries and doesn't resort
// to other allocation strategies when all 32k WAN ports are taken.
type easyNAT struct {
wanIP netip.Addr
out map[netip.AddrPort]portMappingAndTime
in map[uint16]lanAddrAndTime
}
func init() {
registerNATType(EasyNAT, func(p IPPool) (NATTable, error) {
return &easyNAT{wanIP: p.WANIP()}, nil
})
}
func (n *easyNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) {
if pm, ok := n.out[src]; 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)
// Found a free port.
mak.Set(&n.out, src, 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 *easyNAT) 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.
}
return n.in[dst.Port()].lanAddr
}

1237
tstest/natlab/vnet/vnet.go Normal file

File diff suppressed because it is too large Load Diff