// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package portmapper
import (
"context"
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"regexp"
"slices"
"sync/atomic"
"testing"
"tailscale.com/tstest"
)
// Google Wifi
const (
googleWifiUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nUSN: uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece::urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nEXT:\r\nSERVER: Linux/5.4.0-1034-gcp UPnP/1.1 MiniUPnPd/1.9\r\nLOCATION: http://192.168.86.1:5000/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1\r\nBOOTID.UPNP.ORG: 1\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n"
googleWifiRootDescXML = `
10urn:schemas-upnp-org:device:InternetGatewayDevice:2OnHubGooglehttp://google.com/Wireless RouterOnHub1https://on.google.com/hub/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30eceurn:schemas-upnp-org:service:Layer3Forwarding:1urn:upnp-org:serviceId:Layer3Forwarding1/ctl/L3F/evt/L3F/L3F.xmlurn:schemas-upnp-org:service:DeviceProtection:1urn:upnp-org:serviceId:DeviceProtection1/ctl/DP/evt/DP/DP.xmlurn:schemas-upnp-org:device:WANDevice:2WANDeviceMiniUPnPhttp://miniupnp.free.fr/WAN DeviceWAN Device20210414http://miniupnp.free.fr/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30ecf000000000000urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1urn:upnp-org:serviceId:WANCommonIFC1/ctl/CmnIfCfg/evt/CmnIfCfg/WANCfg.xmlurn:schemas-upnp-org:device:WANConnectionDevice:2WANConnectionDeviceMiniUPnPhttp://miniupnp.free.fr/MiniUPnP daemonMiniUPnPd20210414http://miniupnp.free.fr/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30ec0000000000000urn:schemas-upnp-org:service:WANIPConnection:2urn:upnp-org:serviceId:WANIPConn1/ctl/IPConn/evt/IPConn/WANIPCn.xmlhttp://testwifi.here/`
// pfSense 2.5.0-RELEASE / FreeBSD 12.2-STABLE
pfSenseUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nEXT:\r\nSERVER: FreeBSD/12.2-STABLE UPnP/1.1 MiniUPnPd/2.2.1\r\nLOCATION: http://192.168.1.1:2189/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1627958564\r\nBOOTID.UPNP.ORG: 1627958564\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n"
pfSenseRootDescXML = `
11urn:schemas-upnp-org:device:InternetGatewayDevice:1FreeBSD routerFreeBSDhttp://www.freebsd.org/FreeBSD routerFreeBSD router2.5.0-RELEASEhttp://www.freebsd.org/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac11urn:schemas-upnp-org:service:Layer3Forwarding:1urn:upnp-org:serviceId:L3Forwarding1/L3F.xml/ctl/L3F/evt/L3Furn:schemas-upnp-org:device:WANDevice:1WANDeviceMiniUPnPhttp://miniupnp.free.fr/WAN DeviceWAN Device20210205http://miniupnp.free.fr/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac12000000000000urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1urn:upnp-org:serviceId:WANCommonIFC1/WANCfg.xml/ctl/CmnIfCfg/evt/CmnIfCfgurn:schemas-upnp-org:device:WANConnectionDevice:1WANConnectionDeviceMiniUPnPhttp://miniupnp.free.fr/MiniUPnP daemonMiniUPnPd20210205http://miniupnp.free.fr/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac13000000000000urn:schemas-upnp-org:service:WANIPConnection:1urn:upnp-org:serviceId:WANIPConn1/WANIPCn.xml/ctl/IPConn/evt/IPConnhttps://192.168.1.1/`
// Sagemcom FAST3890V3, https://github.com/tailscale/tailscale/issues/3557
sagemcomUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Tue, 14 Dec 2021 07:51:29 GMT\r\nEXT:\r\nLOCATION: http://192.168.0.1:49153/69692b70/gatedesc0b.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: cabd6488-1dd1-11b2-9e52-a7461e1f098e\r\nSERVER: \r\nUser-Agent: redsonic\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:75802409-bccb-40e7-8e6c-fa095ecce13e::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n"
// Huawei, https://github.com/tailscale/tailscale/issues/6320
huaweiUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Fri, 25 Nov 2022 07:04:37 GMT\r\nEXT:\r\nLOCATION: http://192.168.1.1:49652/49652gatedesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: ce8dd8b0-732d-11be-a4a1-a2b26c8915fb\r\nSERVER: Linux/4.4.240, UPnP/1.0, Portable SDK for UPnP devices/1.12.1\r\nX-User-Agent: UPnP/1.0 DLNADOC/1.50\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:00e0fc37-2525-2828-2500-0C31DCD93368::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n"
// Mikrotik CHR v7.10, https://github.com/tailscale/tailscale/issues/8364
mikrotikRootDescXML = `
1
0
urn:schemas-upnp-org:device:InternetGatewayDevice:1
MikroTik Router
MikroTik
https://www.mikrotik.com/
Router OS
uuid:UUID-MIKROTIK-INTERNET-GATEWAY-DEVICE-
image/gif
16
16
8
/logo16.gif
image/gif
32
32
8
/logo32.gif
image/gif
48
48
8
/logo48.gif
urn:schemas-microsoft-com:service:OSInfo:1
urn:microsoft-com:serviceId:OSInfo1
/osinfo.xml
/upnp/control/oqjsxqshhz/osinfo
/upnp/event/cwzcyndrjf/osinfo
urn:schemas-upnp-org:device:WANDevice:1
WAN Device
MikroTik
https://www.mikrotik.com/
Router OS
uuid:UUID-MIKROTIK-WAN-DEVICE--1
urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1
urn:upnp-org:serviceId:WANCommonIFC1
/wancommonifc-1.xml
/upnp/control/ivvmxhunyq/wancommonifc-1
/upnp/event/mkjzdqvryf/wancommonifc-1
urn:schemas-upnp-org:device:WANConnectionDevice:1
WAN Connection Device
MikroTik
https://www.mikrotik.com/
Router OS
uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--1
urn:schemas-upnp-org:service:WANIPConnection:1
urn:upnp-org:serviceId:WANIPConn1
/wanipconn-1.xml
/upnp/control/yomkmsnooi/wanipconn-1
/upnp/event/veeabhzzva/wanipconn-1
urn:schemas-upnp-org:device:WANDevice:1
WAN Device
MikroTik
https://www.mikrotik.com/
Router OS
uuid:UUID-MIKROTIK-WAN-DEVICE--7
urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1
urn:upnp-org:serviceId:WANCommonIFC1
/wancommonifc-7.xml
/upnp/control/vzcyyzzttz/wancommonifc-7
/upnp/event/womwbqtbkq/wancommonifc-7
urn:schemas-upnp-org:device:WANConnectionDevice:1
WAN Connection Device
MikroTik
https://www.mikrotik.com/
Router OS
uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--7
urn:schemas-upnp-org:service:WANIPConnection:1
urn:upnp-org:serviceId:WANIPConn1
/wanipconn-7.xml
/upnp/control/xstnsgeuyh/wanipconn-7
/upnp/event/rscixkusbs/wanipconn-7
http://10.0.0.1/
http://127.0.0.1/
http://10.0.0.1:2828
`
// Huawei, https://github.com/tailscale/tailscale/issues/10911
huaweiRootDescXML = `
1
0
urn:dslforum-org:device:InternetGatewayDevice:1
HG531 V1
Huawei Technologies Co., Ltd.
http://www.huawei.com
Huawei Home Gateway
HG531 V1
Huawei Model
http://www.huawei.com
G6J8W15326003974
uuid:00e0fc37-2626-2828-2600-587f668bdd9a
000000000001
urn:www-huawei-com:service:DeviceConfig:1
urn:www-huawei-com:serviceId:DeviceConfig1
/desc/DevCfg.xml
/ctrlt/DeviceConfig_1
/evt/DeviceConfig_1
urn:dslforum-org:service:LANConfigSecurity:1
urn:dslforum-org:serviceId:LANConfigSecurity1
/desc/LANSec.xml
/ctrlt/LANConfigSecurity_1
/evt/LANConfigSecurity_1
urn:dslforum-org:service:Layer3Forwarding:1
urn:dslforum-org:serviceId:Layer3Forwarding1
/desc/L3Fwd.xml
/ctrlt/Layer3Forwarding_1
/evt/Layer3Forwarding_1
urn:dslforum-org:device:WANDevice:1
WANDevice
Huawei Technologies Co., Ltd.
http://www.huawei.com
Huawei Home Gateway
HG531 V1
Huawei Model
http://www.huawei.com
G6J8W15326003974
uuid:00e0fc37-2626-2828-2601-587f668bdd9a
000000000001
urn:dslforum-org:service:WANDSLInterfaceConfig:1
urn:dslforum-org:serviceId:WANDSLInterfaceConfig1
/desc/WanDslIfCfg.xml
/ctrlt/WANDSLInterfaceConfig_1
/evt/WANDSLInterfaceConfig_1
urn:dslforum-org:service:WANCommonInterfaceConfig:1
urn:dslforum-org:serviceId:WANCommonInterfaceConfig1
/desc/WanCommonIfc1.xml
/ctrlt/WANCommonInterfaceConfig_1
/evt/WANCommonInterfaceConfig_1
urn:dslforum-org:device:WANConnectionDevice:1
WANConnectionDevice
Huawei Technologies Co., Ltd.
http://www.huawei.com
Huawei Home Gateway
HG531 V1
Huawei Model
http://www.huawei.com
G6J8W15326003974
uuid:00e0fc37-2626-2828-2603-587f668bdd9a
000000000001
urn:dslforum-org:service:WANPPPConnection:1
urn:dslforum-org:serviceId:WANPPPConnection1
/desc/WanPppConn.xml
/ctrlt/WANPPPConnection_1
/evt/WANPPPConnection_1
urn:dslforum-org:service:WANEthernetConnectionManagement:1
urn:dslforum-org:serviceId:WANEthernetConnectionManagement1
/desc/WanEthConnMgt.xml
/ctrlt/WANEthernetConnectionManagement_1
/evt/WANEthernetConnectionManagement_1
urn:dslforum-org:service:WANDSLLinkConfig:1
urn:dslforum-org:serviceId:WANDSLLinkConfig1
/desc/WanDslLink.xml
/ctrlt/WANDSLLinkConfig_1
/evt/WANDSLLinkConfig_1
urn:dslforum-org:device:LANDevice:1
LANDevice
Huawei Technologies Co., Ltd.
http://www.huawei.com
Huawei Home Gateway
HG531 V1
Huawei Model
http://www.huawei.com
G6J8W15326003974
uuid:00e0fc37-2626-2828-2602-587f668bdd9a
000000000001
urn:dslforum-org:service:WLANConfiguration:1
urn:dslforum-org:serviceId:WLANConfiguration4
/desc/WLANCfg.xml
/ctrlt/WLANConfiguration_4
/evt/WLANConfiguration_4
urn:dslforum-org:service:WLANConfiguration:1
urn:dslforum-org:serviceId:WLANConfiguration3
/desc/WLANCfg.xml
/ctrlt/WLANConfiguration_3
/evt/WLANConfiguration_3
urn:dslforum-org:service:WLANConfiguration:1
urn:dslforum-org:serviceId:WLANConfiguration2
/desc/WLANCfg.xml
/ctrlt/WLANConfiguration_2
/evt/WLANConfiguration_2
urn:dslforum-org:service:WLANConfiguration:1
urn:dslforum-org:serviceId:WLANConfiguration1
/desc/WLANCfg.xml
/ctrlt/WLANConfiguration_1
/evt/WLANConfiguration_1
urn:dslforum-org:service:LANHostConfigManagement:1
urn:dslforum-org:serviceId:LANHostConfigManagement1
/desc/LanHostCfgMgmt.xml
/ctrlt/LANHostConfigManagement_1
/evt/LANHostConfigManagement_1
http://127.0.0.1
`
noSupportedServicesRootDesc = `
1
0
urn:dslforum-org:device:InternetGatewayDevice:1
Fake Router
Tailscale, Inc
http://www.tailscale.com
Fake Router
Test Model
v1
http://www.tailscale.com
123456789
uuid:11111111-2222-3333-4444-555555555555
000000000001
urn:schemas-microsoft-com:service:OSInfo:1
urn:microsoft-com:serviceId:OSInfo1
/osinfo.xml
/upnp/control/aaaaaaaaaa/osinfo
/upnp/event/aaaaaaaaaa/osinfo
urn:schemas-upnp-org:device:WANDevice:1
WANDevice
Tailscale, Inc
http://www.tailscale.com
Tailscale Test Router
Test Model
v1
http://www.tailscale.com
123456789
uuid:11111111-2222-3333-4444-555555555555
000000000001
urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1
urn:upnp-org:serviceId:WANCommonIFC1
/ctl/bbbbbbbb
/evt/bbbbbbbb
/WANCfg.xml
urn:schemas-upnp-org:device:WANConnectionDevice:1
WANConnectionDevice
Tailscale, Inc
http://www.tailscale.com
Tailscale Test Router
Test Model
v1
http://www.tailscale.com
123456789
uuid:11111111-2222-3333-4444-555555555555
000000000001
urn:tailscale:service:SomethingElse:1
urn:upnp-org:serviceId:TailscaleSomethingElse
/desc/SomethingElse.xml
/ctrlt/SomethingElse_1
/evt/SomethingElse_1
http://127.0.0.1
`
)
func TestParseUPnPDiscoResponse(t *testing.T) {
tests := []struct {
name string
headers string
want uPnPDiscoResponse
}{
{"google", googleWifiUPnPDisco, uPnPDiscoResponse{
Location: "http://192.168.86.1:5000/rootDesc.xml",
Server: "Linux/5.4.0-1034-gcp UPnP/1.1 MiniUPnPd/1.9",
USN: "uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece::urn:schemas-upnp-org:device:InternetGatewayDevice:2",
}},
{"pfsense", pfSenseUPnPDisco, uPnPDiscoResponse{
Location: "http://192.168.1.1:2189/rootDesc.xml",
Server: "FreeBSD/12.2-STABLE UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
}},
{"sagemcom", sagemcomUPnPDisco, uPnPDiscoResponse{
Location: "http://192.168.0.1:49153/69692b70/gatedesc0b.xml",
Server: "",
USN: "uuid:75802409-bccb-40e7-8e6c-fa095ecce13e::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
}},
{"huawei", huaweiUPnPDisco, uPnPDiscoResponse{
Location: "http://192.168.1.1:49652/49652gatedesc.xml",
Server: "Linux/4.4.240, UPnP/1.0, Portable SDK for UPnP devices/1.12.1",
USN: "uuid:00e0fc37-2525-2828-2500-0C31DCD93368::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseUPnPDiscoResponse([]byte(tt.headers))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("unexpected result:\n got: %+v\nwant: %+v\n", got, tt.want)
}
})
}
}
func TestGetUPnPClient(t *testing.T) {
tests := []struct {
name string
xmlBody string
want string
wantLog string
}{
{
"google",
googleWifiRootDescXML,
"*internetgateway2.WANIPConnection2",
"saw UPnP type WANIPConnection2 at http://127.0.0.1:NNN/rootDesc.xml; OnHub (Google), method=single\n",
},
{
"pfsense",
pfSenseRootDescXML,
"*internetgateway2.WANIPConnection1",
"saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; FreeBSD router (FreeBSD), method=single\n",
},
{
"mikrotik",
mikrotikRootDescXML,
"*internetgateway2.WANIPConnection1",
"saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; MikroTik Router (MikroTik), method=none\n",
},
{
"huawei",
huaweiRootDescXML,
"*portmapper.legacyWANPPPConnection1",
"saw UPnP type *portmapper.legacyWANPPPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; HG531 V1 (Huawei Technologies Co., Ltd.), method=single\n",
},
{
"not_supported",
noSupportedServicesRootDesc,
"",
"",
},
// TODO(bradfitz): find a PPP one in the wild
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.RequestURI == "/rootDesc.xml" {
io.WriteString(w, tt.xmlBody)
return
}
http.NotFound(w, r)
}))
defer ts.Close()
gw, _ := netip.AddrFromSlice(ts.Listener.Addr().(*net.TCPAddr).IP)
gw = gw.Unmap()
ctx := context.Background()
var logBuf tstest.MemLogger
dev, loc, err := getUPnPRootDevice(ctx, logBuf.Logf, DebugKnobs{}, gw, uPnPDiscoResponse{
Location: ts.URL + "/rootDesc.xml",
})
if err != nil {
t.Fatal(err)
}
c, err := selectBestService(ctx, logBuf.Logf, dev, loc)
if err != nil {
t.Fatal(err)
}
got := fmt.Sprintf("%T", c)
if got != tt.want {
t.Errorf("got %v; want %v", got, tt.want)
}
gotLog := regexp.MustCompile(`127\.0\.0\.1:\d+`).ReplaceAllString(logBuf.String(), "127.0.0.1:NNN")
if gotLog != tt.wantLog {
t.Errorf("logged %q; want %q", gotLog, tt.wantLog)
}
})
}
}
func TestGetUPnPPortMapping(t *testing.T) {
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
if err != nil {
t.Fatal(err)
}
defer igd.Close()
// This is a very basic fake UPnP server handler.
var sawRequestWithLease atomic.Bool
handlers := map[string]any{
"AddPortMapping": func(body []byte) (int, string) {
// Decode a minimal body to determine whether we skip the request or not.
var req struct {
Protocol string `xml:"NewProtocol"`
InternalPort string `xml:"NewInternalPort"`
ExternalPort string `xml:"NewExternalPort"`
InternalClient string `xml:"NewInternalClient"`
LeaseDuration string `xml:"NewLeaseDuration"`
}
if err := xml.Unmarshal(body, &req); err != nil {
t.Errorf("bad request: %v", err)
return http.StatusBadRequest, "bad request"
}
if req.Protocol != "UDP" {
t.Errorf(`got Protocol=%q, want "UDP"`, req.Protocol)
}
if req.LeaseDuration != "0" {
// Return a fake error to ensure that we fall back to a permanent lease.
sawRequestWithLease.Store(true)
return http.StatusOK, testAddPortMappingPermanentLease
}
// Success!
return http.StatusOK, testAddPortMappingResponse
},
"GetExternalIPAddress": testGetExternalIPAddressResponse,
"GetStatusInfo": testGetStatusInfoResponse,
"DeletePortMapping": "", // Do nothing for test
}
ctx := context.Background()
rootDescsToTest := []string{testRootDesc, mikrotikRootDescXML}
for _, rootDesc := range rootDescsToTest {
igd.SetUPnPHandler(&upnpServer{
t: t,
Desc: rootDesc,
Control: map[string]map[string]any{
"/ctl/IPConn": handlers,
"/upnp/control/yomkmsnooi/wanipconn-1": handlers,
},
})
c := newTestClient(t, igd)
t.Logf("Listening on upnp=%v", c.testUPnPPort)
defer c.Close()
c.debug.VerboseLogs = true
// Try twice to test the "cache previous mapping" logic.
var (
firstResponse netip.AddrPort
prevPort uint16
)
for i := 0; i < 2; i++ {
sawRequestWithLease.Store(false)
res, err := c.Probe(ctx)
if err != nil {
t.Fatalf("Probe: %v", err)
}
if !res.UPnP {
t.Errorf("didn't detect UPnP")
}
gw, myIP, ok := c.gatewayAndSelfIP()
if !ok {
t.Fatalf("could not get gateway and self IP")
}
t.Logf("gw=%v myIP=%v", gw, myIP)
ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), prevPort)
if !ok {
t.Fatal("could not get UPnP port mapping")
}
if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want {
t.Errorf("bad external address; got %v want %v", got, want)
}
if !sawRequestWithLease.Load() {
t.Errorf("wanted request with lease, but didn't see one")
}
if i == 0 {
firstResponse = ext
prevPort = ext.Port()
} else if firstResponse != ext {
t.Errorf("got different response on second attempt: (got) %v != %v (want)", ext, firstResponse)
}
t.Logf("external IP: %v", ext)
}
}
}
// TestGetUPnPPortMapping_NoValidServices tests that getUPnPPortMapping doesn't
// crash when a valid UPnP response with no supported services is discovered
// and parsed.
//
// See https://github.com/tailscale/tailscale/issues/10911
func TestGetUPnPPortMapping_NoValidServices(t *testing.T) {
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
if err != nil {
t.Fatal(err)
}
defer igd.Close()
igd.SetUPnPHandler(&upnpServer{
t: t,
Desc: noSupportedServicesRootDesc,
})
c := newTestClient(t, igd)
defer c.Close()
c.debug.VerboseLogs = true
ctx := context.Background()
res, err := c.Probe(ctx)
if err != nil {
t.Fatalf("Probe: %v", err)
}
if !res.UPnP {
t.Errorf("didn't detect UPnP")
}
gw, myIP, ok := c.gatewayAndSelfIP()
if !ok {
t.Fatalf("could not get gateway and self IP")
}
// This shouldn't panic
_, ok = c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
if ok {
t.Fatal("did not expect to get UPnP port mapping")
}
}
// Tests the legacy behaviour with the pre-UPnP standard portmapping service.
func TestGetUPnPPortMapping_Legacy(t *testing.T) {
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
if err != nil {
t.Fatal(err)
}
defer igd.Close()
// This is a very basic fake UPnP server handler.
handlers := map[string]any{
"AddPortMapping": testLegacyAddPortMappingResponse,
"GetExternalIPAddress": testLegacyGetExternalIPAddressResponse,
"GetStatusInfo": testLegacyGetStatusInfoResponse,
"DeletePortMapping": "", // Do nothing for test
}
igd.SetUPnPHandler(&upnpServer{
t: t,
Desc: huaweiRootDescXML,
Control: map[string]map[string]any{
"/ctrlt/WANPPPConnection_1": handlers,
},
})
c := newTestClient(t, igd)
defer c.Close()
c.debug.VerboseLogs = true
ctx := context.Background()
res, err := c.Probe(ctx)
if err != nil {
t.Fatalf("Probe: %v", err)
}
if !res.UPnP {
t.Errorf("didn't detect UPnP")
}
gw, myIP, ok := c.gatewayAndSelfIP()
if !ok {
t.Fatalf("could not get gateway and self IP")
}
ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
if !ok {
t.Fatal("could not get UPnP port mapping")
}
if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want {
t.Errorf("bad external address; got %v want %v", got, want)
}
}
func TestGetUPnPPortMappingNoResponses(t *testing.T) {
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
if err != nil {
t.Fatal(err)
}
defer igd.Close()
c := newTestClient(t, igd)
t.Logf("Listening on upnp=%v", c.testUPnPPort)
defer c.Close()
c.debug.VerboseLogs = true
// Do this before setting uPnPMetas since it invalidates those mappings
// if gw/myIP change.
gw, myIP, _ := c.gatewayAndSelfIP()
t.Run("ErrorContactingUPnP", func(t *testing.T) {
c.mu.Lock()
c.uPnPMetas = []uPnPDiscoResponse{{
Location: "http://127.0.0.1:1/does-not-exist.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2",
}}
c.mu.Unlock()
_, ok := c.getUPnPPortMapping(context.Background(), gw, netip.AddrPortFrom(myIP, 12345), 0)
if ok {
t.Errorf("expected no mapping when there are no responses")
}
})
}
func TestProcessUPnPResponses(t *testing.T) {
testCases := []struct {
name string
responses []uPnPDiscoResponse
want []uPnPDiscoResponse
}{
{
name: "single",
responses: []uPnPDiscoResponse{{
Location: "http://192.168.1.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
}},
want: []uPnPDiscoResponse{{
Location: "http://192.168.1.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
}},
},
{
name: "multiple_with_same_location",
responses: []uPnPDiscoResponse{
{
Location: "http://192.168.1.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
},
{
Location: "http://192.168.1.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2",
},
},
want: []uPnPDiscoResponse{{
Location: "http://192.168.1.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2",
}},
},
{
name: "multiple_with_different_location",
responses: []uPnPDiscoResponse{
{
Location: "http://192.168.1.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
},
{
Location: "http://192.168.100.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2",
},
},
want: []uPnPDiscoResponse{
// note: this sorts first because we prefer "InternetGatewayDevice:2"
{
Location: "http://192.168.100.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2",
},
{
Location: "http://192.168.1.1:2828/control.xml",
Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1",
USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1",
},
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := processUPnPResponses(slices.Clone(tt.responses))
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("unexpected result:\n got: %+v\nwant: %+v\n", got, tt.want)
}
})
}
}
type upnpServer struct {
t *testing.T
Desc string // root device XML
Control map[string]map[string]any // map["/url"]map["UPnPService"]response
}
func (u *upnpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
u.t.Logf("got UPnP request %s %s", r.Method, r.URL.Path)
if r.URL.Path == "/rootDesc.xml" {
io.WriteString(w, u.Desc)
return
}
if control, ok := u.Control[r.URL.Path]; ok {
u.handleControl(w, r, control)
return
}
u.t.Logf("ignoring request")
http.NotFound(w, r)
}
func (u *upnpServer) handleControl(w http.ResponseWriter, r *http.Request, handlers map[string]any) {
body, err := io.ReadAll(r.Body)
if err != nil {
u.t.Errorf("error reading request body: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Decode the request type.
var outerRequest struct {
Body struct {
Request struct {
XMLName xml.Name
} `xml:",any"`
Inner string `xml:",innerxml"`
} `xml:"Body"`
}
if err := xml.Unmarshal(body, &outerRequest); err != nil {
u.t.Errorf("bad request: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
requestType := outerRequest.Body.Request.XMLName.Local
upnpRequest := outerRequest.Body.Inner
u.t.Logf("UPnP request: %s", requestType)
handler, ok := handlers[requestType]
if !ok {
u.t.Errorf("unhandled UPnP request type %q", requestType)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
switch v := handler.(type) {
case string:
io.WriteString(w, v)
case []byte:
w.Write(v)
// Function handlers
case func(string) string:
io.WriteString(w, v(upnpRequest))
case func([]byte) string:
io.WriteString(w, v([]byte(upnpRequest)))
case func(string) (int, string):
code, body := v(upnpRequest)
w.WriteHeader(code)
io.WriteString(w, body)
case func([]byte) (int, string):
code, body := v([]byte(upnpRequest))
w.WriteHeader(code)
io.WriteString(w, body)
default:
u.t.Fatalf("invalid handler type: %T", v)
http.Error(w, "invalid handler type", http.StatusInternalServerError)
return
}
}
const testRootDesc = `
1
1
urn:schemas-upnp-org:device:InternetGatewayDevice:1
Tailscale Test Router
Tailscale
https://tailscale.com
Tailscale Test Router
Tailscale Test Router
2.5.0-RELEASE
https://tailscale.com
1234
uuid:1974e83b-6dc7-4635-92b3-6a85a4037294
urn:schemas-upnp-org:device:WANDevice:1
WANDevice
MiniUPnP
http://miniupnp.free.fr/
WAN Device
WAN Device
20990102
http://miniupnp.free.fr/
1234
uuid:1974e83b-6dc7-4635-92b3-6a85a4037294
000000000000
urn:schemas-upnp-org:device:WANConnectionDevice:1
WANConnectionDevice
MiniUPnP
http://miniupnp.free.fr/
MiniUPnP daemon
MiniUPnPd
20210205
http://miniupnp.free.fr/
1234
uuid:1974e83b-6dc7-4635-92b3-6a85a4037294
000000000000
urn:schemas-upnp-org:service:WANIPConnection:1
urn:upnp-org:serviceId:WANIPConn1
/WANIPCn.xml
/ctl/IPConn
/evt/IPConn
https://127.0.0.1/
`
const testAddPortMappingPermanentLease = `
s:Client
UPnPError
725
OnlyPermanentLeasesSupported
`
const testAddPortMappingResponse = `
`
const testGetExternalIPAddressResponse = `
123.123.123.123
`
const testGetStatusInfoResponse = `
Connected
ERROR_NONE
9999
`
const testLegacyAddPortMappingResponse = `
`
const testLegacyGetExternalIPAddressResponse = `
123.123.123.123
`
const testLegacyGetStatusInfoResponse = `
Connected
ERROR_NONE
9999
`