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 {