mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 21:15:39 +00:00
d05a572db4
Instead of taking the first UPnP response we receive and using that to create port mappings, store all received UPnP responses, sort and deduplicate them, and then try all of them to obtain an external address. Updates #10602 Signed-off-by: Andrew Dunham <andrew@du.nham.ca> Change-Id: I783ccb1834834ee2a9ecbae2b16d801f2354302f
679 lines
29 KiB
Go
679 lines
29 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"
|
|
"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 = `<?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
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = `<?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>
|
|
`
|