tstest/natlab/vnet: move PCP/NAT-PMP code to portmapper.go (#22145)

portmapper.go was introduced for UPnP in commit
8ed43f1f2a.

This also adds a unit test which exercises the incomplete
natlab server implementation of these portmapping protocols.

Updates #21713

Change-Id: I33b61ccbe8c1d8d99d56aa357ae24777be3f5739
Signed-off-by: Francois Marier <francois@tailscale.com>
This commit is contained in:
François Marier authored and GitHub committed 2026-11-03 14:51:11 -07:00
1 parent 6fa10dae73
commit ca4e1972fa
4 files changed
+574 -337

No files matched your search

+343
View File
@@ -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:Body><u:%sResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:2">%s</u:%sResponse></s:Body></s:Envelope>`,
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
}
+200
View File
@@ -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(`<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:%[1]s xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:2">%[2]s</u:%[1]s>
</s:Body>
</s:Envelope>`, 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", `
<NewExternalPort>40768</NewExternalPort>
<NewInternalPort>40768</NewInternalPort>
<NewInternalClient>192.168.0.101</NewInternalClient>
<NewLeaseDuration>7200</NewLeaseDuration>`)
checkMapping(t, n, res.ReservedPort, netip.AddrPortFrom(clientIPv4(1), 40768))
})
}
-337
View File
@@ -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
+31
View File
@@ -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 {