mirror of
https://github.com/tailscale/tailscale.git
synced 2025-03-24 10:10:59 +00:00

Previously, we would select the first WANIPConnection2 (and related) client from the root device, without any additional checks. However, some routers expose multiple UPnP devices in various states, and simply picking the first available one can result in attempting to perform a portmap with a device that isn't functional. Instead, mimic what the miniupnpc code does, and prefer devices that are (a) reporting as Connected, and (b) have a valid external IP address. For our use-case, we additionally prefer devices that have an external IP address that's a public address, to increase the likelihood that we can obtain a direct connection from peers. Finally, we split out fetching the root device (getUPnPRootDevice) from selecting the best service within that root device (selectBestService), and add some extensive tests for various UPnP server behaviours. RELNOTE=Improve UPnP portmapping when multiple UPnP services exist Updates #8364 Signed-off-by: Andrew Dunham <andrew@du.nham.ca> Change-Id: I71795cd80be6214dfcef0fe83115a5e3fe4b8753
554 lines
25 KiB
Go
554 lines
25 KiB
Go
// 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"
|
|
"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 = `<?xml version="1.0"?>
|
|
<root xmlns="urn:schemas-upnp-org:device-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><device><deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:2</deviceType><friendlyName>OnHub</friendlyName><manufacturer>Google</manufacturer><manufacturerURL>http://google.com/</manufacturerURL><modelDescription>Wireless Router</modelDescription><modelName>OnHub</modelName><modelNumber>1</modelNumber><modelURL>https://on.google.com/hub/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece</UDN><serviceList><service><serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType><serviceId>urn:upnp-org:serviceId:Layer3Forwarding1</serviceId><controlURL>/ctl/L3F</controlURL><eventSubURL>/evt/L3F</eventSubURL><SCPDURL>/L3F.xml</SCPDURL></service><service><serviceType>urn:schemas-upnp-org:service:DeviceProtection:1</serviceType><serviceId>urn:upnp-org:serviceId:DeviceProtection1</serviceId><controlURL>/ctl/DP</controlURL><eventSubURL>/evt/DP</eventSubURL><SCPDURL>/DP.xml</SCPDURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANDevice:2</deviceType><friendlyName>WANDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>WAN Device</modelDescription><modelName>WAN Device</modelName><modelNumber>20210414</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ecf</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType><serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId><controlURL>/ctl/CmnIfCfg</controlURL><eventSubURL>/evt/CmnIfCfg</eventSubURL><SCPDURL>/WANCfg.xml</SCPDURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:2</deviceType><friendlyName>WANConnectionDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>MiniUPnP daemon</modelDescription><modelName>MiniUPnPd</modelName><modelNumber>20210414</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ec0</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:2</serviceType><serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId><controlURL>/ctl/IPConn</controlURL><eventSubURL>/evt/IPConn</eventSubURL><SCPDURL>/WANIPCn.xml</SCPDURL></service></serviceList></device></deviceList></device></deviceList><presentationURL>http://testwifi.here/</presentationURL></device></root>`
|
|
|
|
// 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 = `<?xml version="1.0"?>
|
|
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337"><specVersion><major>1</major><minor>1</minor></specVersion><device><deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType><friendlyName>FreeBSD router</friendlyName><manufacturer>FreeBSD</manufacturer><manufacturerURL>http://www.freebsd.org/</manufacturerURL><modelDescription>FreeBSD router</modelDescription><modelName>FreeBSD router</modelName><modelNumber>2.5.0-RELEASE</modelNumber><modelURL>http://www.freebsd.org/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac11</UDN><serviceList><service><serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType><serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId><SCPDURL>/L3F.xml</SCPDURL><controlURL>/ctl/L3F</controlURL><eventSubURL>/evt/L3F</eventSubURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType><friendlyName>WANDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>WAN Device</modelDescription><modelName>WAN Device</modelName><modelNumber>20210205</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac12</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType><serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId><SCPDURL>/WANCfg.xml</SCPDURL><controlURL>/ctl/CmnIfCfg</controlURL><eventSubURL>/evt/CmnIfCfg</eventSubURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType><friendlyName>WANConnectionDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>MiniUPnP daemon</modelDescription><modelName>MiniUPnPd</modelName><modelNumber>20210205</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac13</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType><serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId><SCPDURL>/WANIPCn.xml</SCPDURL><controlURL>/ctl/IPConn</controlURL><eventSubURL>/evt/IPConn</eventSubURL></service></serviceList></device></deviceList></device></deviceList><presentationURL>https://192.168.1.1/</presentationURL></device></root>`
|
|
|
|
// 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 = `<?xml version="1.0"?>
|
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
|
<specVersion>
|
|
<major>1</major>
|
|
<minor>0</minor>
|
|
</specVersion>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
|
|
<friendlyName>MikroTik Router</friendlyName>
|
|
<manufacturer>MikroTik</manufacturer>
|
|
<manufacturerURL>https://www.mikrotik.com/</manufacturerURL>
|
|
<modelName>Router OS</modelName>
|
|
<UDN>uuid:UUID-MIKROTIK-INTERNET-GATEWAY-DEVICE-</UDN>
|
|
<iconList>
|
|
<icon>
|
|
<mimetype>image/gif</mimetype>
|
|
<width>16</width>
|
|
<height>16</height>
|
|
<depth>8</depth>
|
|
<url>/logo16.gif</url>
|
|
</icon>
|
|
<icon>
|
|
<mimetype>image/gif</mimetype>
|
|
<width>32</width>
|
|
<height>32</height>
|
|
<depth>8</depth>
|
|
<url>/logo32.gif</url>
|
|
</icon>
|
|
<icon>
|
|
<mimetype>image/gif</mimetype>
|
|
<width>48</width>
|
|
<height>48</height>
|
|
<depth>8</depth>
|
|
<url>/logo48.gif</url>
|
|
</icon>
|
|
</iconList>
|
|
<serviceList>
|
|
<service>
|
|
<serviceType>urn:schemas-microsoft-com:service:OSInfo:1</serviceType>
|
|
<serviceId>urn:microsoft-com:serviceId:OSInfo1</serviceId>
|
|
<SCPDURL>/osinfo.xml</SCPDURL>
|
|
<controlURL>/upnp/control/oqjsxqshhz/osinfo</controlURL>
|
|
<eventSubURL>/upnp/event/cwzcyndrjf/osinfo</eventSubURL>
|
|
</service>
|
|
</serviceList>
|
|
<deviceList>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
|
|
<friendlyName>WAN Device</friendlyName>
|
|
<manufacturer>MikroTik</manufacturer>
|
|
<manufacturerURL>https://www.mikrotik.com/</manufacturerURL>
|
|
<modelName>Router OS</modelName>
|
|
<UDN>uuid:UUID-MIKROTIK-WAN-DEVICE--1</UDN>
|
|
<serviceList>
|
|
<service>
|
|
<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>
|
|
<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>
|
|
<SCPDURL>/wancommonifc-1.xml</SCPDURL>
|
|
<controlURL>/upnp/control/ivvmxhunyq/wancommonifc-1</controlURL>
|
|
<eventSubURL>/upnp/event/mkjzdqvryf/wancommonifc-1</eventSubURL>
|
|
</service>
|
|
</serviceList>
|
|
<deviceList>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
|
|
<friendlyName>WAN Connection Device</friendlyName>
|
|
<manufacturer>MikroTik</manufacturer>
|
|
<manufacturerURL>https://www.mikrotik.com/</manufacturerURL>
|
|
<modelName>Router OS</modelName>
|
|
<UDN>uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--1</UDN>
|
|
<serviceList>
|
|
<service>
|
|
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
|
|
<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
|
|
<SCPDURL>/wanipconn-1.xml</SCPDURL>
|
|
<controlURL>/upnp/control/yomkmsnooi/wanipconn-1</controlURL>
|
|
<eventSubURL>/upnp/event/veeabhzzva/wanipconn-1</eventSubURL>
|
|
</service>
|
|
</serviceList>
|
|
</device>
|
|
</deviceList>
|
|
</device>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
|
|
<friendlyName>WAN Device</friendlyName>
|
|
<manufacturer>MikroTik</manufacturer>
|
|
<manufacturerURL>https://www.mikrotik.com/</manufacturerURL>
|
|
<modelName>Router OS</modelName>
|
|
<UDN>uuid:UUID-MIKROTIK-WAN-DEVICE--7</UDN>
|
|
<serviceList>
|
|
<service>
|
|
<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>
|
|
<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>
|
|
<SCPDURL>/wancommonifc-7.xml</SCPDURL>
|
|
<controlURL>/upnp/control/vzcyyzzttz/wancommonifc-7</controlURL>
|
|
<eventSubURL>/upnp/event/womwbqtbkq/wancommonifc-7</eventSubURL>
|
|
</service>
|
|
</serviceList>
|
|
<deviceList>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
|
|
<friendlyName>WAN Connection Device</friendlyName>
|
|
<manufacturer>MikroTik</manufacturer>
|
|
<manufacturerURL>https://www.mikrotik.com/</manufacturerURL>
|
|
<modelName>Router OS</modelName>
|
|
<UDN>uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--7</UDN>
|
|
<serviceList>
|
|
<service>
|
|
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
|
|
<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
|
|
<SCPDURL>/wanipconn-7.xml</SCPDURL>
|
|
<controlURL>/upnp/control/xstnsgeuyh/wanipconn-7</controlURL>
|
|
<eventSubURL>/upnp/event/rscixkusbs/wanipconn-7</eventSubURL>
|
|
</service>
|
|
</serviceList>
|
|
</device>
|
|
</deviceList>
|
|
</device>
|
|
</deviceList>
|
|
<disabledForTestPresentationURL>http://10.0.0.1/</disabledForTestPresentationURL>
|
|
<presentationURL>http://127.0.0.1/</presentationURL>
|
|
</device>
|
|
<disabledForTestURLBase>http://10.0.0.1:2828</disabledForTestURLBase>
|
|
</root>
|
|
`
|
|
)
|
|
|
|
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",
|
|
},
|
|
// 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
|
|
|
|
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), 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)
|
|
}
|
|
if !sawRequestWithLease.Load() {
|
|
t.Errorf("wanted request with lease, but didn't see one")
|
|
}
|
|
t.Logf("external IP: %v", ext)
|
|
}
|
|
}
|
|
|
|
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 = `<?xml version="1.0"?>
|
|
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337">
|
|
<specVersion>
|
|
<major>1</major>
|
|
<minor>1</minor>
|
|
</specVersion>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
|
|
<friendlyName>Tailscale Test Router</friendlyName>
|
|
<manufacturer>Tailscale</manufacturer>
|
|
<manufacturerURL>https://tailscale.com</manufacturerURL>
|
|
<modelDescription>Tailscale Test Router</modelDescription>
|
|
<modelName>Tailscale Test Router</modelName>
|
|
<modelNumber>2.5.0-RELEASE</modelNumber>
|
|
<modelURL>https://tailscale.com</modelURL>
|
|
<serialNumber>1234</serialNumber>
|
|
<UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
|
|
<deviceList>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
|
|
<friendlyName>WANDevice</friendlyName>
|
|
<manufacturer>MiniUPnP</manufacturer>
|
|
<manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>
|
|
<modelDescription>WAN Device</modelDescription>
|
|
<modelName>WAN Device</modelName>
|
|
<modelNumber>20990102</modelNumber>
|
|
<modelURL>http://miniupnp.free.fr/</modelURL>
|
|
<serialNumber>1234</serialNumber>
|
|
<UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
|
|
<UPC>000000000000</UPC>
|
|
<deviceList>
|
|
<device>
|
|
<deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
|
|
<friendlyName>WANConnectionDevice</friendlyName>
|
|
<manufacturer>MiniUPnP</manufacturer>
|
|
<manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>
|
|
<modelDescription>MiniUPnP daemon</modelDescription>
|
|
<modelName>MiniUPnPd</modelName>
|
|
<modelNumber>20210205</modelNumber>
|
|
<modelURL>http://miniupnp.free.fr/</modelURL>
|
|
<serialNumber>1234</serialNumber>
|
|
<UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
|
|
<UPC>000000000000</UPC>
|
|
<serviceList>
|
|
<service>
|
|
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
|
|
<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
|
|
<SCPDURL>/WANIPCn.xml</SCPDURL>
|
|
<controlURL>/ctl/IPConn</controlURL>
|
|
<eventSubURL>/evt/IPConn</eventSubURL>
|
|
</service>
|
|
</serviceList>
|
|
</device>
|
|
</deviceList>
|
|
</device>
|
|
</deviceList>
|
|
<presentationURL>https://127.0.0.1/</presentationURL>
|
|
</device>
|
|
</root>
|
|
`
|
|
|
|
const testAddPortMappingPermanentLease = `<?xml version="1.0"?>
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
<s:Body>
|
|
<s:Fault>
|
|
<faultCode>s:Client</faultCode>
|
|
<faultString>UPnPError</faultString>
|
|
<detail>
|
|
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
|
|
<errorCode>725</errorCode>
|
|
<errorDescription>OnlyPermanentLeasesSupported</errorDescription>
|
|
</UPnPError>
|
|
</detail>
|
|
</s:Fault>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`
|
|
|
|
const testAddPortMappingResponse = `<?xml version="1.0"?>
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
<s:Body>
|
|
<u:AddPortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1"/>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`
|
|
|
|
const testGetExternalIPAddressResponse = `<?xml version="1.0"?>
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
<s:Body>
|
|
<u:GetExternalIPAddressResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
|
|
<NewExternalIPAddress>123.123.123.123</NewExternalIPAddress>
|
|
</u:GetExternalIPAddressResponse>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`
|
|
|
|
const testGetStatusInfoResponse = `<?xml version="1.0"?>
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
<s:Body>
|
|
<u:GetStatusInfoResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
|
|
<NewConnectionStatus>Connected</NewConnectionStatus>
|
|
<NewLastConnectionError>ERROR_NONE</NewLastConnectionError>
|
|
<NewUptime>9999</NewUptime>
|
|
</u:GetStatusInfoResponse>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`
|