diff --git a/tstest/natlab/vnet/portmapper.go b/tstest/natlab/vnet/portmapper.go
index 5acdc7050..2dbc2351f 100644
--- a/tstest/natlab/vnet/portmapper.go
+++ b/tstest/natlab/vnet/portmapper.go
@@ -5,11 +5,33 @@ package vnet
import (
"bytes"
+ "encoding/binary"
"encoding/xml"
"fmt"
"io"
+ "maps"
+ "math/rand/v2"
"net/http"
"net/netip"
+ "time"
+
+ "tailscale.com/util/mak"
+)
+
+// PxP packets
+const (
+ opcodeANNOUNCE = 0 // external address lookup in NAT-PMP
+ opcodeMAP = 1 // MAP UDP in NAT-PMP
+
+ protocolUDP = 17
+
+ versionNATPMP = 0
+ versionPCP = 2
+
+ resultSuccess = 0
+ resultUnsuppVersion = 1
+
+ opcodeResponseBit = 128
)
// router's UPnP HTTP server, as advertised in its SSDP responses
@@ -134,3 +156,324 @@ func (n *network) handleUPnPControl(w http.ResponseWriter, r *http.Request) {
`%s`,
name, out, name)
}
+
+func (n *network) handlePxPRequest(req UDPPacket) {
+ if req.Payload[1]&opcodeResponseBit != 0 {
+ n.logf("vnet: ignoring NAT-PMP/PCP packet with response bit set % 02x", req.Payload)
+ return
+ }
+ switch req.Payload[0] {
+ case versionNATPMP:
+ switch {
+ case n.svcs.Contains(NATPMP):
+ n.handleNATPMPRequest(req)
+ case n.svcs.Contains(PCP):
+ n.writePCPUnsuppVersion(req)
+ }
+ case versionPCP:
+ switch {
+ case n.svcs.Contains(PCP):
+ n.handlePCPRequest(req)
+ case n.svcs.Contains(NATPMP):
+ n.writeNATPMPUnsuppVersion(req)
+ }
+ default:
+ n.logf("vnet: ignoring NAT-PMP/PCP packet with unknown version % 02x", req.Payload)
+ }
+}
+
+func (n *network) writeNATPMPUnsuppVersion(req UDPPacket) {
+ res := make([]byte, 0, 8)
+ res = append(res,
+ versionNATPMP,
+ 0, // opcode is 0 as per https://www.rfc-editor.org/rfc/rfc6886#section-3.5
+ 0, resultUnsuppVersion,
+ )
+ epoch := uint32(time.Now().Unix())
+ res = binary.BigEndian.AppendUint32(res, epoch)
+ n.WriteUDPPacketNoNAT(UDPPacket{
+ Src: req.Dst,
+ Dst: req.Src,
+ Payload: res,
+ })
+}
+
+func (n *network) writePCPUnsuppVersion(req UDPPacket) {
+ opcode := req.Payload[1]
+ res := make([]byte, 0, 24)
+ res = append(res,
+ versionPCP,
+ opcodeResponseBit|opcode,
+ 0, // reserved
+ resultUnsuppVersion,
+ )
+ lifetimeSec := uint32(1800) // 30 min
+ res = binary.BigEndian.AppendUint32(res, lifetimeSec)
+ epoch := uint32(time.Now().Unix())
+ res = binary.BigEndian.AppendUint32(res, epoch)
+ res = append(res, make([]byte, 12)...) // reserved (96 bits)
+ n.WriteUDPPacketNoNAT(UDPPacket{
+ Src: req.Dst,
+ Dst: req.Src,
+ Payload: res,
+ })
+}
+
+func (n *network) handleNATPMPRequest(req UDPPacket) {
+ if !n.svcs.Contains(NATPMP) {
+ return
+ }
+ if len(req.Payload) < 2 || req.Payload[0] != versionNATPMP {
+ n.logf("vnet: ignoring invalid NAT-PMP packet % 02x", req.Payload)
+ return
+ }
+
+ if len(req.Payload) == 2 && req.Payload[1] == opcodeANNOUNCE {
+ // https://www.rfc-editor.org/rfc/rfc6886#section-3.2
+
+ res := make([]byte, 0, 12)
+ res = append(res,
+ versionNATPMP,
+ opcodeResponseBit|opcodeANNOUNCE,
+ 0, resultSuccess,
+ )
+ epoch := uint32(time.Now().Unix())
+ res = binary.BigEndian.AppendUint32(res, epoch)
+ wan4 := n.wanIP4.As4()
+ res = append(res, wan4[:]...)
+ n.WriteUDPPacketNoNAT(UDPPacket{
+ Src: req.Dst,
+ Dst: req.Src,
+ Payload: res,
+ })
+ return
+ }
+
+ if len(req.Payload) == 12 && req.Payload[1] == opcodeMAP {
+ // https://www.rfc-editor.org/rfc/rfc6886#section-3.3
+ // "00 01 00 00 ed 40 00 00 00 00 1c 20" =>
+ // 00 ver
+ // 01 op=map UDP
+ // 00 00 reserved (0 in request; in response, this is the result code)
+ // ed 40 internal port 60736
+ // 00 00 suggested external port
+ // 00 00 1c 20 suggested lifetime in seconds (7200 sec = 2 hours)
+ internalPort := binary.BigEndian.Uint16(req.Payload[4:6])
+ wantExtPort := binary.BigEndian.Uint16(req.Payload[6:8])
+ lifetimeSec := binary.BigEndian.Uint32(req.Payload[8:12])
+ gotPort, ok := n.doPortMap(req.Src.Addr(), internalPort, wantExtPort, int(lifetimeSec), NATPMP)
+ if !ok {
+ // TODO(fmarier): handle portmap releases properly
+ n.logf("NAT-PMP map request for %v:%d failed", req.Src.Addr(), internalPort)
+ return
+ }
+ res := make([]byte, 0, 16)
+ res = append(res,
+ versionNATPMP,
+ opcodeResponseBit|opcodeMAP,
+ 0, resultSuccess,
+ )
+ epoch := uint32(time.Now().Unix())
+ res = binary.BigEndian.AppendUint32(res, epoch)
+ res = binary.BigEndian.AppendUint16(res, internalPort)
+ res = binary.BigEndian.AppendUint16(res, gotPort)
+ res = binary.BigEndian.AppendUint32(res, lifetimeSec)
+ n.WriteUDPPacketNoNAT(UDPPacket{
+ Src: req.Dst,
+ Dst: req.Src,
+ Payload: res,
+ })
+ return
+ }
+
+ n.logf("TODO: handle NAT-PMP packet % 02x", req.Payload)
+}
+
+func (n *network) handlePCPRequest(req UDPPacket) {
+ if !n.svcs.Contains(PCP) {
+ return
+ }
+
+ if len(req.Payload) < 24 || req.Payload[0] != versionPCP {
+ n.logf("vnet: ignoring invalid PCP packet % 02x", req.Payload)
+ return
+ }
+ // TODO(fmarier): check that client address in the packet matches req.Src
+
+ if len(req.Payload) == 24 && req.Payload[1] == opcodeANNOUNCE {
+ // https://www.rfc-editor.org/rfc/rfc6887#section-14
+ // "02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66" =>
+ // 02 ver
+ // 00 op=announce
+ // 00 00 reserved
+ // 00 00 00 00 requested lifetime in seconds
+ // 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66 client IP (10.7.1.102)
+ res := make([]byte, 0, 24)
+ res = append(res,
+ versionPCP,
+ opcodeResponseBit|opcodeANNOUNCE,
+ 0, // reserved
+ resultSuccess,
+ 0, 0, 0, 0, // lifetime (0 for ANNOUNCE)
+ )
+ epoch := uint32(time.Now().Unix())
+ res = binary.BigEndian.AppendUint32(res, epoch)
+ res = append(res, make([]byte, 12)...) // reserved (96 bits)
+
+ // "02 80 00 00 00 00 00 00 6a c9 62 fb 00 00 00 00 00 00 00 00 00 00 00 00" =>
+ // 02 ver
+ // 80 op=announce+128
+ // 00 reserved
+ // 00 result=success
+ // 00 00 00 00 lifetime in seconds
+ // 6a c9 62 fb epoch (2026-10-09T21:56:11 UTC)
+ // 00 00 00 00 00 00 00 00 00 00 00 00 reserved
+ n.WriteUDPPacketNoNAT(UDPPacket{
+ Src: req.Dst,
+ Dst: req.Src,
+ Payload: res,
+ })
+ return
+ }
+
+ if len(req.Payload) == 60 && req.Payload[1] == opcodeMAP && req.Payload[36] == protocolUDP {
+ // https://www.rfc-editor.org/rfc/rfc6887#section-11
+ // "02 01 00 00 00 00 1c 20 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f 11 00 00 00 9f 40 00 00 00 00 00 00 00 00 00 00 00 00 ff ff 00 00 00 00" =>
+ // 02 ver
+ // 01 op=map
+ // 00 00 reserved
+ // 00 00 1c 20 requested lifetime in seconds (7200 sec = 2 hours)
+ // 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66 client IP (10.7.1.102)
+ // 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f nonce
+ // 11 protocol (17 = UDP)
+ // 00 00 00 reserved
+ // 9f 40 internal port 40768
+ // 00 00 suggested external port
+ // 00 00 00 00 00 00 00 00 00 00 ff ff 00 00 00 00 suggested external IP
+ lifetimeSec := binary.BigEndian.Uint32(req.Payload[4:8])
+ internalPort := binary.BigEndian.Uint16(req.Payload[40:42])
+ // TODO(fmarier): reject wildcard mappings (internalPort = 0)
+ wantExtPort := binary.BigEndian.Uint16(req.Payload[42:44])
+ // TODO(fmarier): reject wantedExtAddr that doesn't match IP version
+ nonce := req.Payload[24:36]
+ // TODO(fmarier): pass the nonce into doPortmap so that it can be checked on renewals/deletions
+ gotPort, ok := n.doPortMap(req.Src.Addr(), internalPort, wantExtPort, int(lifetimeSec), PCP)
+ if !ok {
+ // TODO(fmarier): handle portmap releases
+ n.logf("PCP map request for %v:%d failed", req.Src.Addr(), internalPort)
+ return
+ }
+ res := make([]byte, 0, 60)
+ res = append(res,
+ versionPCP,
+ opcodeResponseBit|opcodeMAP,
+ 0, // reserved
+ resultSuccess,
+ )
+ res = binary.BigEndian.AppendUint32(res, lifetimeSec)
+ epoch := uint32(time.Now().Unix())
+ res = binary.BigEndian.AppendUint32(res, epoch)
+ res = append(res, make([]byte, 12)...) // reserved (96 bits)
+ res = append(res, nonce...)
+ res = append(res,
+ protocolUDP,
+ 0, 0, 0, // reserved
+ )
+ res = binary.BigEndian.AppendUint16(res, internalPort)
+ res = binary.BigEndian.AppendUint16(res, gotPort)
+ wan4 := n.wanIP4.As16()
+ res = append(res, wan4[:]...) // TODO(fmarier): handle non-zero requested external addresses
+
+ // "02 81 00 00 00 00 1c 20 6a c9 62 fb 00 00 00 00 00 00 00 00 00 00 00 00 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f 11 00 00 00 9f 40 e6 59 00 00 00 00 00 00 00 00 00 00 ff ff 02 01 01 01" =>
+ // 02 vers
+ // 81 op=map+128
+ // 00 reserved
+ // 00 result=success
+ // 00 00 1c 20 lifetime in seconds (7200 sec = 2 hours)
+ // 6a c9 62 fb epoch (2026-10-09T21:56:11 UTC)
+ // 00 00 00 00 00 00 00 00 00 00 00 00 reserved
+ // 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f nonce
+ // 11 protocol (17 = UDP)
+ // 00 00 00 reserved
+ // 9f 40 internal port 40768
+ // e6 59 external port 58969
+ // 00 00 00 00 00 00 00 00 00 00 ff ff 02 01 01 01 external address (2.1.1.1)
+ n.WriteUDPPacketNoNAT(UDPPacket{
+ Src: req.Dst,
+ Dst: req.Src,
+ Payload: res,
+ })
+ return
+ }
+
+ n.logf("TODO: handle PCP packet % 02x", req.Payload)
+}
+
+type PortMapping struct {
+ dst netip.AddrPort // LAN IP:port
+ expiry time.Time
+ Service NetworkService
+}
+
+func (n *network) portMappings() map[netip.AddrPort]PortMapping {
+ n.natMu.Lock()
+ defer n.natMu.Unlock()
+ now := time.Now()
+ ret := maps.Clone(n.portMap)
+ maps.DeleteFunc(ret, func(_ netip.AddrPort, pm PortMapping) bool {
+ return now.After(pm.expiry)
+ })
+ return ret
+}
+
+func (n *network) doPortMap(src netip.Addr, dstLANPort, wantExtPort uint16, sec int, service NetworkService) (gotPort uint16, ok bool) {
+ n.natMu.Lock()
+ defer n.natMu.Unlock()
+
+ if !n.svcs.Contains(service) {
+ return 0, false
+ }
+
+ wanAP := netip.AddrPortFrom(n.wanIP4, wantExtPort)
+ dst := netip.AddrPortFrom(src, dstLANPort)
+
+ if sec == 0 {
+ lanAP, ok := n.portMap[wanAP]
+ if ok && lanAP.dst.Addr() == src {
+ n.logf("vnet: released %v mapping from %v to %v", service, wanAP, dst)
+ delete(n.portMap, wanAP)
+ }
+ return 0, false
+ }
+
+ // See if they already have a mapping and extend expiry if so.
+ // TODO(fmarier): renewing with a different protocol overwrites the
+ // service that created the mapping, so PortMappings only reports the
+ // most recent one.
+ for k, v := range n.portMap {
+ if v.dst == dst {
+ n.portMap[k] = PortMapping{
+ dst: dst,
+ expiry: time.Now().Add(time.Duration(sec) * time.Second),
+ Service: service,
+ }
+ n.logf("vnet: renewed %v mapping from %v to %v", service, wanAP, dst)
+ return k.Port(), true
+ }
+ }
+
+ for range 20_000 {
+ if wanAP.Port() > 0 && !n.natTable.IsPublicPortUsed(wanAP) {
+ mak.Set(&n.portMap, wanAP, PortMapping{
+ dst: dst,
+ expiry: time.Now().Add(time.Duration(sec) * time.Second),
+ Service: service,
+ })
+ n.logf("vnet: allocated %v mapping from %v to %v", service, wanAP, dst)
+ return wanAP.Port(), true
+ }
+ wantExtPort = rand.N(uint16(32<<10)) + 32<<10
+ wanAP = netip.AddrPortFrom(n.wanIP4, wantExtPort)
+ }
+ return 0, false
+}
diff --git a/tstest/natlab/vnet/portmapper_test.go b/tstest/natlab/vnet/portmapper_test.go
new file mode 100644
index 000000000..38d03ae50
--- /dev/null
+++ b/tstest/natlab/vnet/portmapper_test.go
@@ -0,0 +1,200 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+// These unit tests exist to catch regressions in the natlab portmapping server
+// implementations. See tstest/natlab/vmtest/portmapper_test.go for the VM
+// tests which verify that the tailscale client works.
+
+package vnet
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/xml"
+ "fmt"
+ "mime"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "strings"
+ "testing"
+
+ "tailscale.com/util/must"
+)
+
+func newPortmapNetwork(t *testing.T, svc NetworkService) (*Server, *network) {
+ t.Helper()
+ var c Config
+ nw := c.AddNetwork("2.1.1.1", "192.168.0.1/24", svc)
+ c.AddNode(nw)
+ s, err := New(&c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.StopUnsolicitedRAsForTest()
+ t.Cleanup(s.Close)
+ return s, nw.network
+}
+
+func mustHex(s string) []byte {
+ return must.Get(hex.DecodeString(strings.ReplaceAll(s, " ", "")))
+}
+
+// checkReply checks that got matches want, which is in "% x" format.
+// Bytes written as "??" in want vary between runs (e.g. epoch, allocated
+// port) and aren't compared.
+func checkReply(t *testing.T, got []byte, want string) {
+ t.Helper()
+ gotHex, wantHex := strings.Fields(fmt.Sprintf("% x", got)), strings.Fields(want)
+ if len(gotHex) != len(wantHex) {
+ t.Fatalf("got % x\nwant %s", got, want)
+ }
+ for i, w := range wantHex {
+ if w != "??" && w != gotHex[i] {
+ t.Errorf("got % x\nwant %s", got, want)
+ return
+ }
+ }
+}
+
+func checkMapping(t *testing.T, n *network, extPort uint16, want netip.AddrPort) {
+ t.Helper()
+ n.natMu.Lock()
+ defer n.natMu.Unlock()
+ if m := n.portMap[netip.AddrPortFrom(n.wanIP4, extPort)]; m.dst != want {
+ t.Errorf("mapping for external port %d is to %v, want %v", extPort, m.dst, want)
+ }
+}
+
+type soapResponse struct {
+ ExternalIP netip.Addr `xml:"NewExternalIPAddress"`
+ ReservedPort uint16 `xml:"NewReservedPort"`
+}
+
+func soapRequest(t *testing.T, n *network, action, args string) soapResponse {
+ t.Helper()
+ body := fmt.Sprintf(`
+
+
+ %[2]s
+
+`, action, args)
+ rec := httptest.NewRecorder()
+ n.upnpHandler().ServeHTTP(rec, httptest.NewRequest("POST", "/ctl/IPConn", strings.NewReader(body)))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: status %d; body:\n%s", action, rec.Code, rec.Body)
+ }
+
+ var env struct {
+ XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Envelope"`
+ Body struct {
+ Response soapResponse `xml:",any"`
+ } `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"`
+ }
+ if err := xml.Unmarshal(rec.Body.Bytes(), &env); err != nil {
+ t.Fatalf("%s: parsing response: %v\n%s", action, err, rec.Body)
+ }
+ return env.Body.Response
+}
+
+var (
+ pcpAnnounceReq = mustHex("02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff ff c0 a8 00 65") // PCP ANNOUNCE from 192.168.0.101
+ natpmpAddrReq = mustHex("00 00") // NAT-PMP external address request
+)
+
+func TestNATPMP(t *testing.T) {
+ s, n := newPortmapNetwork(t, NATPMP)
+ t.Run("external-address", func(t *testing.T) {
+ got := sendUDPToRouter(t, s, pxpPort, natpmpAddrReq)
+ // success, external address 2.1.1.1
+ checkReply(t, got, "00 80 00 00 ?? ?? ?? ?? 02 01 01 01")
+ })
+ t.Run("map-udp", func(t *testing.T) {
+ // MAP UDP of int port 60736 to any ext port for 7200s
+ got := sendUDPToRouter(t, s, pxpPort, mustHex("00 01 00 00 ed 40 00 00 00 00 1c 20"))
+ // success, MAP UDP of int port 60736 for 7200s
+ checkReply(t, got, "00 81 00 00 ?? ?? ?? ?? ed 40 ?? ?? 00 00 1c 20")
+ checkMapping(t, n, binary.BigEndian.Uint16(got[10:12]), netip.AddrPortFrom(clientIPv4(1), 60736))
+ })
+ t.Run("request-unsupported", func(t *testing.T) {
+ got := sendUDPToRouter(t, s, pxpPort, pcpAnnounceReq)
+ // NAT-PMP unsupported version
+ checkReply(t, got, "00 00 00 01 ?? ?? ?? ??")
+ })
+}
+
+func TestPCP(t *testing.T) {
+ s, n := newPortmapNetwork(t, PCP)
+ t.Run("announce", func(t *testing.T) {
+ got := sendUDPToRouter(t, s, pxpPort, pcpAnnounceReq)
+ // ANNOUNCE success
+ checkReply(t, got, "02 80 00 00 00 00 00 00 ?? ?? ?? ?? 00 00 00 00 00 00 00 00 00 00 00 00")
+ })
+ t.Run("map-udp", func(t *testing.T) {
+ // MAP UDP of int 192.168.0.101:40768 to any ext IP and port for 7200s, with nonce 95bcb5d93d07ecbbc8b3bd4f
+ got := sendUDPToRouter(t, s, pxpPort, mustHex("02 01 00 00 00 00 1c 20 00 00 00 00 00 00 00 00 00 00 ff ff c0 a8 00 65 "+
+ "95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f 11 00 00 00 9f 40 00 00 00 00 00 00 00 00 00 00 00 00 ff ff 00 00 00 00"))
+ // success, MAP UDP of int port 40768 to ext IP 2.1.1.1 for 7200s, same nonce
+ checkReply(t, got, "02 81 00 00 00 00 1c 20 ?? ?? ?? ?? 00 00 00 00 00 00 00 00 00 00 00 00 "+
+ "95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f 11 00 00 00 9f 40 ?? ?? 00 00 00 00 00 00 00 00 00 00 ff ff 02 01 01 01")
+ checkMapping(t, n, binary.BigEndian.Uint16(got[42:44]), netip.AddrPortFrom(clientIPv4(1), 40768))
+ })
+ t.Run("request-unsupported", func(t *testing.T) {
+ got := sendUDPToRouter(t, s, pxpPort, natpmpAddrReq)
+ // PCP unsupported version, error lifetime 1800s
+ checkReply(t, got, "02 80 00 01 00 00 07 08 ?? ?? ?? ?? 00 00 00 00 00 00 00 00 00 00 00 00")
+ })
+}
+
+func TestUPnP(t *testing.T) {
+ s, n := newPortmapNetwork(t, UPnP)
+ t.Run("ssdp-msearch", func(t *testing.T) {
+ // Same as uPnPPacket in net/portmapper.
+ ssdpSearchReq := []byte("M-SEARCH * HTTP/1.1\r\n" +
+ "HOST: 239.255.255.250:1900\r\n" +
+ "ST: ssdp:all\r\n" +
+ "MAN: \"ssdp:discover\"\r\n" +
+ "MX: 2\r\n\r\n")
+ got := sendUDPToRouter(t, s, ssdpPort, ssdpSearchReq)
+ res, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(got)), nil)
+ if err != nil {
+ t.Fatalf("parsing SSDP reply: %v\n%s", err, got)
+ }
+ if loc, want := res.Header.Get("Location"), "http://192.168.0.1:5000/rootDesc.xml"; loc != want {
+ t.Errorf("Location = %q, want %q", loc, want)
+ }
+ if st := res.Header.Get("St"); !strings.Contains(st, ":InternetGatewayDevice:") {
+ t.Errorf("ST = %q, want an InternetGatewayDevice", st)
+ }
+ })
+ t.Run("root-desc", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ n.upnpHandler().ServeHTTP(rec, httptest.NewRequest("GET", "/rootDesc.xml", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d; body:\n%s", rec.Code, rec.Body)
+ }
+ if mt, _, err := mime.ParseMediaType(rec.Header().Get("Content-Type")); err != nil || mt != "text/xml" {
+ t.Errorf("Content-Type = %q, want text/xml", rec.Header().Get("Content-Type"))
+ }
+ if rec.Body.Len() == 0 {
+ t.Error("empty body")
+ }
+ })
+ t.Run("get-external-ip", func(t *testing.T) {
+ res := soapRequest(t, n, "GetExternalIPAddress", "")
+ want := netip.MustParseAddr("2.1.1.1")
+ if res.ExternalIP != want {
+ t.Errorf("NewExternalIPAddress = %v, want %v", res.ExternalIP, want)
+ }
+ })
+ t.Run("add-any-port-mapping", func(t *testing.T) {
+ res := soapRequest(t, n, "AddAnyPortMapping", `
+ 40768
+ 40768
+ 192.168.0.101
+ 7200`)
+ checkMapping(t, n, res.ReservedPort, netip.AddrPortFrom(clientIPv4(1), 40768))
+ })
+}
diff --git a/tstest/natlab/vnet/vnet.go b/tstest/natlab/vnet/vnet.go
index 005e26bb5..209145b3e 100644
--- a/tstest/natlab/vnet/vnet.go
+++ b/tstest/natlab/vnet/vnet.go
@@ -83,22 +83,6 @@ const (
ssdpPort = 1900
)
-// PxP packets
-const (
- opcodeANNOUNCE = 0 // external address lookup in NAT-PMP
- opcodeMAP = 1 // MAP UDP in NAT-PMP
-
- protocolUDP = 17
-
- versionNATPMP = 0
- versionPCP = 2
-
- resultSuccess = 0
- resultUnsuppVersion = 1
-
- opcodeResponseBit = 128
-)
-
func (s *Server) PopulateDERPMapIPs() error {
out, err := exec.Command("tailscale", "debug", "derp-map").Output()
if err != nil {
@@ -674,23 +658,6 @@ func (m MAC) String() string {
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", m[0], m[1], m[2], m[3], m[4], m[5])
}
-type PortMapping struct {
- dst netip.AddrPort // LAN IP:port
- expiry time.Time
- Service NetworkService
-}
-
-func (n *network) portMappings() map[netip.AddrPort]PortMapping {
- n.natMu.Lock()
- defer n.natMu.Unlock()
- now := time.Now()
- ret := maps.Clone(n.portMap)
- maps.DeleteFunc(ret, func(_ netip.AddrPort, pm PortMapping) bool {
- return now.After(pm.expiry)
- })
- return ret
-}
-
// writerFunc is a function that writes an Ethernet frame to a connected client.
//
// ethFrame is the Ethernet frame to write.
@@ -2766,58 +2733,6 @@ func (n *network) IsPublicPortUsed(ap netip.AddrPort) bool {
return ok
}
-func (n *network) doPortMap(src netip.Addr, dstLANPort, wantExtPort uint16, sec int, service NetworkService) (gotPort uint16, ok bool) {
- n.natMu.Lock()
- defer n.natMu.Unlock()
-
- if !n.svcs.Contains(service) {
- return 0, false
- }
-
- wanAP := netip.AddrPortFrom(n.wanIP4, wantExtPort)
- dst := netip.AddrPortFrom(src, dstLANPort)
-
- if sec == 0 {
- lanAP, ok := n.portMap[wanAP]
- if ok && lanAP.dst.Addr() == src {
- n.logf("vnet: released %v mapping from %v to %v", service, wanAP, dst)
- delete(n.portMap, wanAP)
- }
- return 0, false
- }
-
- // See if they already have a mapping and extend expiry if so.
- // TODO(fmarier): renewing with a different protocol overwrites the
- // service that created the mapping, so PortMappings only reports the
- // most recent one.
- for k, v := range n.portMap {
- if v.dst == dst {
- n.portMap[k] = PortMapping{
- dst: dst,
- expiry: time.Now().Add(time.Duration(sec) * time.Second),
- Service: service,
- }
- n.logf("vnet: renewed %v mapping from %v to %v", service, wanAP, dst)
- return k.Port(), true
- }
- }
-
- for range 20_000 {
- if wanAP.Port() > 0 && !n.natTable.IsPublicPortUsed(wanAP) {
- mak.Set(&n.portMap, wanAP, PortMapping{
- dst: dst,
- expiry: time.Now().Add(time.Duration(sec) * time.Second),
- Service: service,
- })
- n.logf("vnet: allocated %v mapping from %v to %v", service, wanAP, dst)
- return wanAP.Port(), true
- }
- wantExtPort = rand.N(uint16(32<<10)) + 32<<10
- wanAP = netip.AddrPortFrom(n.wanIP4, wantExtPort)
- }
- return 0, false
-}
-
func (n *network) createARPResponse(pkt gopacket.Packet) ([]byte, error) {
ethLayer, ok := pkt.Layer(layers.LayerTypeEthernet).(*layers.Ethernet)
if !ok {
@@ -2867,258 +2782,6 @@ func (n *network) createARPResponse(pkt gopacket.Packet) ([]byte, error) {
return buffer.Bytes(), nil
}
-func (n *network) handlePxPRequest(req UDPPacket) {
- if req.Payload[1]&opcodeResponseBit != 0 {
- n.logf("vnet: ignoring NAT-PMP/PCP packet with response bit set % 02x", req.Payload)
- return
- }
- switch req.Payload[0] {
- case versionNATPMP:
- switch {
- case n.svcs.Contains(NATPMP):
- n.handleNATPMPRequest(req)
- case n.svcs.Contains(PCP):
- n.writePCPUnsuppVersion(req)
- }
- case versionPCP:
- switch {
- case n.svcs.Contains(PCP):
- n.handlePCPRequest(req)
- case n.svcs.Contains(NATPMP):
- n.writeNATPMPUnsuppVersion(req)
- }
- default:
- n.logf("vnet: ignoring NAT-PMP/PCP packet with unknown version % 02x", req.Payload)
- }
-}
-
-func (n *network) writeNATPMPUnsuppVersion(req UDPPacket) {
- res := make([]byte, 0, 8)
- res = append(res,
- versionNATPMP,
- 0, // opcode is 0 as per https://www.rfc-editor.org/rfc/rfc6886#section-3.5
- 0, resultUnsuppVersion,
- )
- epoch := uint32(time.Now().Unix())
- res = binary.BigEndian.AppendUint32(res, epoch)
- n.WriteUDPPacketNoNAT(UDPPacket{
- Src: req.Dst,
- Dst: req.Src,
- Payload: res,
- })
-}
-
-func (n *network) writePCPUnsuppVersion(req UDPPacket) {
- opcode := req.Payload[1]
- res := make([]byte, 0, 24)
- res = append(res,
- versionPCP,
- opcodeResponseBit|opcode,
- 0, // reserved
- resultUnsuppVersion,
- )
- lifetimeSec := uint32(1800) // 30 min
- res = binary.BigEndian.AppendUint32(res, lifetimeSec)
- epoch := uint32(time.Now().Unix())
- res = binary.BigEndian.AppendUint32(res, epoch)
- res = append(res, make([]byte, 12)...) // reserved (96 bits)
- n.WriteUDPPacketNoNAT(UDPPacket{
- Src: req.Dst,
- Dst: req.Src,
- Payload: res,
- })
-}
-
-func (n *network) handleNATPMPRequest(req UDPPacket) {
- if !n.svcs.Contains(NATPMP) {
- return
- }
- if len(req.Payload) < 2 || req.Payload[0] != versionNATPMP {
- n.logf("vnet: ignoring invalid NAT-PMP packet % 02x", req.Payload)
- return
- }
-
- if len(req.Payload) == 2 && req.Payload[1] == opcodeANNOUNCE {
- // https://www.rfc-editor.org/rfc/rfc6886#section-3.2
-
- res := make([]byte, 0, 12)
- res = append(res,
- versionNATPMP,
- opcodeResponseBit|opcodeANNOUNCE,
- 0, resultSuccess,
- )
- epoch := uint32(time.Now().Unix())
- res = binary.BigEndian.AppendUint32(res, epoch)
- wan4 := n.wanIP4.As4()
- res = append(res, wan4[:]...)
- n.WriteUDPPacketNoNAT(UDPPacket{
- Src: req.Dst,
- Dst: req.Src,
- Payload: res,
- })
- return
- }
-
- if len(req.Payload) == 12 && req.Payload[1] == opcodeMAP {
- // https://www.rfc-editor.org/rfc/rfc6886#section-3.3
- // "00 01 00 00 ed 40 00 00 00 00 1c 20" =>
- // 00 ver
- // 01 op=map UDP
- // 00 00 reserved (0 in request; in response, this is the result code)
- // ed 40 internal port 60736
- // 00 00 suggested external port
- // 00 00 1c 20 suggested lifetime in seconds (7200 sec = 2 hours)
- internalPort := binary.BigEndian.Uint16(req.Payload[4:6])
- wantExtPort := binary.BigEndian.Uint16(req.Payload[6:8])
- lifetimeSec := binary.BigEndian.Uint32(req.Payload[8:12])
- gotPort, ok := n.doPortMap(req.Src.Addr(), internalPort, wantExtPort, int(lifetimeSec), NATPMP)
- if !ok {
- // TODO(fmarier): handle portmap releases properly
- n.logf("NAT-PMP map request for %v:%d failed", req.Src.Addr(), internalPort)
- return
- }
- res := make([]byte, 0, 16)
- res = append(res,
- versionNATPMP,
- opcodeResponseBit|opcodeMAP,
- 0, resultSuccess,
- )
- epoch := uint32(time.Now().Unix())
- res = binary.BigEndian.AppendUint32(res, epoch)
- res = binary.BigEndian.AppendUint16(res, internalPort)
- res = binary.BigEndian.AppendUint16(res, gotPort)
- res = binary.BigEndian.AppendUint32(res, lifetimeSec)
- n.WriteUDPPacketNoNAT(UDPPacket{
- Src: req.Dst,
- Dst: req.Src,
- Payload: res,
- })
- return
- }
-
- n.logf("TODO: handle NAT-PMP packet % 02x", req.Payload)
-}
-
-func (n *network) handlePCPRequest(req UDPPacket) {
- if !n.svcs.Contains(PCP) {
- return
- }
-
- if len(req.Payload) < 24 || req.Payload[0] != versionPCP {
- n.logf("vnet: ignoring invalid PCP packet % 02x", req.Payload)
- return
- }
- // TODO(fmarier): check that client address in the packet matches req.Src
-
- if len(req.Payload) == 24 && req.Payload[1] == opcodeANNOUNCE {
- // https://www.rfc-editor.org/rfc/rfc6887#section-14
- // "02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66" =>
- // 02 ver
- // 00 op=announce
- // 00 00 reserved
- // 00 00 00 00 requested lifetime in seconds
- // 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66 client IP (10.7.1.102)
- res := make([]byte, 0, 24)
- res = append(res,
- versionPCP,
- opcodeResponseBit|opcodeANNOUNCE,
- 0, // reserved
- resultSuccess,
- 0, 0, 0, 0, // lifetime (0 for ANNOUNCE)
- )
- epoch := uint32(time.Now().Unix())
- res = binary.BigEndian.AppendUint32(res, epoch)
- res = append(res, make([]byte, 12)...) // reserved (96 bits)
-
- // "02 80 00 00 00 00 00 00 6a c9 62 fb 00 00 00 00 00 00 00 00 00 00 00 00" =>
- // 02 ver
- // 80 op=announce+128
- // 00 reserved
- // 00 result=success
- // 00 00 00 00 lifetime in seconds
- // 6a c9 62 fb epoch (2026-10-09T21:56:11 UTC)
- // 00 00 00 00 00 00 00 00 00 00 00 00 reserved
- n.WriteUDPPacketNoNAT(UDPPacket{
- Src: req.Dst,
- Dst: req.Src,
- Payload: res,
- })
- return
- }
-
- if len(req.Payload) == 60 && req.Payload[1] == opcodeMAP && req.Payload[36] == protocolUDP {
- // https://www.rfc-editor.org/rfc/rfc6887#section-11
- // "02 01 00 00 00 00 1c 20 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f 11 00 00 00 9f 40 00 00 00 00 00 00 00 00 00 00 00 00 ff ff 00 00 00 00" =>
- // 02 ver
- // 01 op=map
- // 00 00 reserved
- // 00 00 1c 20 requested lifetime in seconds (7200 sec = 2 hours)
- // 00 00 00 00 00 00 00 00 00 00 ff ff 0a 07 01 66 client IP (10.7.1.102)
- // 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f nonce
- // 11 protocol (17 = UDP)
- // 00 00 00 reserved
- // 9f 40 internal port 40768
- // 00 00 suggested external port
- // 00 00 00 00 00 00 00 00 00 00 ff ff 00 00 00 00 suggested external IP
- lifetimeSec := binary.BigEndian.Uint32(req.Payload[4:8])
- internalPort := binary.BigEndian.Uint16(req.Payload[40:42])
- // TODO(fmarier): reject wildcard mappings (internalPort = 0)
- wantExtPort := binary.BigEndian.Uint16(req.Payload[42:44])
- // TODO(fmarier): reject wantedExtAddr that doesn't match IP version
- nonce := req.Payload[24:36]
- // TODO(fmarier): pass the nonce into doPortmap so that it can be checked on renewals/deletions
- gotPort, ok := n.doPortMap(req.Src.Addr(), internalPort, wantExtPort, int(lifetimeSec), PCP)
- if !ok {
- // TODO(fmarier): handle portmap releases
- n.logf("PCP map request for %v:%d failed", req.Src.Addr(), internalPort)
- return
- }
- res := make([]byte, 0, 60)
- res = append(res,
- versionPCP,
- opcodeResponseBit|opcodeMAP,
- 0, // reserved
- resultSuccess,
- )
- res = binary.BigEndian.AppendUint32(res, lifetimeSec)
- epoch := uint32(time.Now().Unix())
- res = binary.BigEndian.AppendUint32(res, epoch)
- res = append(res, make([]byte, 12)...) // reserved (96 bits)
- res = append(res, nonce...)
- res = append(res,
- protocolUDP,
- 0, 0, 0, // reserved
- )
- res = binary.BigEndian.AppendUint16(res, internalPort)
- res = binary.BigEndian.AppendUint16(res, gotPort)
- wan4 := n.wanIP4.As16()
- res = append(res, wan4[:]...) // TODO(fmarier): handle non-zero requested external addresses
-
- // "02 81 00 00 00 00 1c 20 6a c9 62 fb 00 00 00 00 00 00 00 00 00 00 00 00 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f 11 00 00 00 9f 40 e6 59 00 00 00 00 00 00 00 00 00 00 ff ff 02 01 01 01" =>
- // 02 vers
- // 81 op=map+128
- // 00 reserved
- // 00 result=success
- // 00 00 1c 20 lifetime in seconds (7200 sec = 2 hours)
- // 6a c9 62 fb epoch (2026-10-09T21:56:11 UTC)
- // 00 00 00 00 00 00 00 00 00 00 00 00 reserved
- // 95 bc b5 d9 3d 07 ec bb c8 b3 bd 4f nonce
- // 11 protocol (17 = UDP)
- // 00 00 00 reserved
- // 9f 40 internal port 40768
- // e6 59 external port 58969
- // 00 00 00 00 00 00 00 00 00 00 ff ff 02 01 01 01 external address (2.1.1.1)
- n.WriteUDPPacketNoNAT(UDPPacket{
- Src: req.Dst,
- Dst: req.Src,
- Payload: res,
- })
- return
- }
-
- n.logf("TODO: handle PCP packet % 02x", req.Payload)
-}
-
// UDPPacket is a UDP packet.
//
// For the purposes of this project, a UDP packet
diff --git a/tstest/natlab/vnet/vnet_test.go b/tstest/natlab/vnet/vnet_test.go
index 5087dce94..b25c724ca 100644
--- a/tstest/natlab/vnet/vnet_test.go
+++ b/tstest/natlab/vnet/vnet_test.go
@@ -507,6 +507,37 @@ func (se *sideEffects) logf(format string, args ...any) {
se.logs = append(se.logs, fmt.Sprintf(format, args...))
}
+// sendUDPToRouter sends payload from node 1 to the router's LAN IP on dstPort
+// and returns the payload of the router's reply.
+func sendUDPToRouter(t *testing.T, s *Server, dstPort uint16, payload []byte) []byte {
+ t.Helper()
+ se := newSideEffects(s)
+ routerIP := s.nodes[0].net.lanIP4.Addr()
+ eth := &layers.Ethernet{SrcMAC: nodeMac(1).HWAddr(), DstMAC: routerMac(1).HWAddr()}
+ ip := mkIPLayer(layers.IPProtocolUDP, clientIPv4(1), routerIP)
+ udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
+ if err := s.handleEthernetFrameFromVM(mustPacket(eth, ip, udp, gopacket.Payload(payload))); err != nil {
+ t.Fatal(err)
+ }
+ if len(se.got) != 1 {
+ t.Fatalf("got %d reply packets, want 1; logs:\n%s", len(se.got), strings.Join(se.logs, "\n"))
+ }
+ pkt := gopacket.NewPacket(se.got[0].eth, layers.LayerTypeEthernet, gopacket.Default)
+ ip4, _ := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4)
+ res, _ := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP)
+ if ip4 == nil || res == nil {
+ t.Fatalf("reply is not IPv4/UDP:\n%v", pkt)
+ }
+ if src, _ := netip.AddrFromSlice(ip4.SrcIP); src != routerIP || uint16(res.SrcPort) != dstPort {
+ // The client drops replies that don't come from the gateway's port.
+ t.Errorf("reply from %v:%d, want %v:%d", src, res.SrcPort, routerIP, dstPort)
+ }
+ if dst, _ := netip.AddrFromSlice(ip4.DstIP); dst != clientIPv4(1) || res.DstPort != udp.SrcPort {
+ t.Errorf("reply to %v:%d, want %v:%d", dst, res.DstPort, clientIPv4(1), udp.SrcPort)
+ }
+ return res.Payload
+}
+
// all aggregates several side effects checkers into one.
func all(checks ...func(*sideEffects) error) func(*sideEffects) error {
return func(se *sideEffects) error {