mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-04 23:45:34 +00:00
bac4890467
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
361 lines
12 KiB
Go
361 lines
12 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package portmapper
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/tailscale/goupnp"
|
|
"github.com/tailscale/goupnp/dcps/internetgateway2"
|
|
)
|
|
|
|
// NOTE: this is in a distinct file because the various string constants are
|
|
// pretty verbose.
|
|
|
|
func TestSelectBestService(t *testing.T) {
|
|
mustParseURL := func(ss string) *url.URL {
|
|
u, err := url.Parse(ss)
|
|
if err != nil {
|
|
t.Fatalf("error parsing URL %q: %v", ss, err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// Run a fake IGD server to respond to UPnP requests.
|
|
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer igd.Close()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
rootDesc string
|
|
control map[string]map[string]any
|
|
want string // controlURL field
|
|
}{
|
|
{
|
|
name: "single_device",
|
|
rootDesc: testRootDesc,
|
|
control: map[string]map[string]any{
|
|
// Service that's up and should be selected.
|
|
"/ctl/IPConn": {
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponse,
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
},
|
|
},
|
|
want: "/ctl/IPConn",
|
|
},
|
|
{
|
|
name: "first_device_disconnected",
|
|
rootDesc: testSelectRootDesc,
|
|
control: map[string]map[string]any{
|
|
// Service that's down; it's important that this is the
|
|
// one that's down since it's ordered first in the XML
|
|
// and we want to verify that our code properly queries
|
|
// and then skips it.
|
|
"/upnp/control/yomkmsnooi/wanipconn-1": {
|
|
"GetStatusInfo": testGetStatusInfoResponseDisconnected,
|
|
// NOTE: nothing else should be called
|
|
// if GetStatusInfo returns a
|
|
// disconnected result
|
|
},
|
|
// Service that's up and should be selected.
|
|
"/upnp/control/xstnsgeuyh/wanipconn-7": {
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponse,
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
},
|
|
},
|
|
want: "/upnp/control/xstnsgeuyh/wanipconn-7",
|
|
},
|
|
{
|
|
name: "prefer_public_external_IP",
|
|
rootDesc: testSelectRootDesc,
|
|
control: map[string]map[string]any{
|
|
// Service with a private external IP; order matters as above.
|
|
"/upnp/control/yomkmsnooi/wanipconn-1": {
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponsePrivate,
|
|
},
|
|
// Service that's up and should be selected.
|
|
"/upnp/control/xstnsgeuyh/wanipconn-7": {
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponse,
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
},
|
|
},
|
|
want: "/upnp/control/xstnsgeuyh/wanipconn-7",
|
|
},
|
|
{
|
|
name: "all_private_external_IPs",
|
|
rootDesc: testSelectRootDesc,
|
|
control: map[string]map[string]any{
|
|
"/upnp/control/yomkmsnooi/wanipconn-1": {
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponsePrivate,
|
|
},
|
|
"/upnp/control/xstnsgeuyh/wanipconn-7": {
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponsePrivate,
|
|
},
|
|
},
|
|
want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML
|
|
},
|
|
{
|
|
name: "nothing_connected",
|
|
rootDesc: testSelectRootDesc,
|
|
control: map[string]map[string]any{
|
|
"/upnp/control/yomkmsnooi/wanipconn-1": {
|
|
"GetStatusInfo": testGetStatusInfoResponseDisconnected,
|
|
},
|
|
"/upnp/control/xstnsgeuyh/wanipconn-7": {
|
|
"GetStatusInfo": testGetStatusInfoResponseDisconnected,
|
|
},
|
|
},
|
|
want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML
|
|
},
|
|
{
|
|
name: "GetStatusInfo_errors",
|
|
rootDesc: testSelectRootDesc,
|
|
control: map[string]map[string]any{
|
|
"/upnp/control/yomkmsnooi/wanipconn-1": {
|
|
"GetStatusInfo": func(_ string) (int, string) {
|
|
return http.StatusInternalServerError, "internal error"
|
|
},
|
|
},
|
|
"/upnp/control/xstnsgeuyh/wanipconn-7": {
|
|
"GetStatusInfo": func(_ string) (int, string) {
|
|
return http.StatusNotFound, "not found"
|
|
},
|
|
},
|
|
},
|
|
want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML
|
|
},
|
|
{
|
|
name: "GetExternalIPAddress_bad_ip",
|
|
rootDesc: testSelectRootDesc,
|
|
control: map[string]map[string]any{
|
|
"/upnp/control/yomkmsnooi/wanipconn-1": {
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponseInvalid,
|
|
},
|
|
"/upnp/control/xstnsgeuyh/wanipconn-7": {
|
|
"GetStatusInfo": testGetStatusInfoResponse,
|
|
"GetExternalIPAddress": testGetExternalIPAddressResponse,
|
|
},
|
|
},
|
|
want: "/upnp/control/xstnsgeuyh/wanipconn-7",
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Ensure that we're using our test IGD server for all requests.
|
|
rootDesc := strings.ReplaceAll(tt.rootDesc, "@SERVERURL@", igd.ts.URL)
|
|
|
|
igd.SetUPnPHandler(&upnpServer{
|
|
t: t,
|
|
Desc: rootDesc,
|
|
Control: tt.control,
|
|
})
|
|
c := newTestClient(t, igd)
|
|
t.Logf("Listening on upnp=%v", c.testUPnPPort)
|
|
defer c.Close()
|
|
|
|
// Ensure that we're using the HTTP client that talks to our test IGD server
|
|
ctx := context.Background()
|
|
ctx = goupnp.WithHTTPClient(ctx, c.upnpHTTPClientLocked())
|
|
|
|
loc := mustParseURL(igd.ts.URL)
|
|
rootDev := mustParseRootDev(t, rootDesc, loc)
|
|
|
|
svc, err := selectBestService(ctx, t.Logf, rootDev, loc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var controlURL string
|
|
switch v := svc.(type) {
|
|
case *internetgateway2.WANIPConnection2:
|
|
controlURL = v.ServiceClient.Service.ControlURL.Str
|
|
case *internetgateway2.WANIPConnection1:
|
|
controlURL = v.ServiceClient.Service.ControlURL.Str
|
|
case *internetgateway2.WANPPPConnection1:
|
|
controlURL = v.ServiceClient.Service.ControlURL.Str
|
|
default:
|
|
t.Fatalf("unknown client type: %T", v)
|
|
}
|
|
|
|
if controlURL != tt.want {
|
|
t.Errorf("mismatched controlURL: got=%q want=%q", controlURL, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustParseRootDev(t *testing.T, devXML string, loc *url.URL) *goupnp.RootDevice {
|
|
decoder := xml.NewDecoder(strings.NewReader(devXML))
|
|
decoder.DefaultSpace = goupnp.DeviceXMLNamespace
|
|
decoder.CharsetReader = goupnp.CharsetReaderDefault
|
|
|
|
root := new(goupnp.RootDevice)
|
|
if err := decoder.Decode(root); err != nil {
|
|
t.Fatalf("error decoding device XML: %v", err)
|
|
}
|
|
|
|
// Ensure the URLBase is set properly; this is how DeviceByURL does it.
|
|
var urlBaseStr string
|
|
if root.URLBaseStr != "" {
|
|
urlBaseStr = root.URLBaseStr
|
|
} else {
|
|
urlBaseStr = loc.String()
|
|
}
|
|
urlBase, err := url.Parse(urlBaseStr)
|
|
if err != nil {
|
|
t.Fatalf("error parsing URL %q: %v", urlBaseStr, err)
|
|
}
|
|
root.SetURLBase(urlBase)
|
|
|
|
return root
|
|
}
|
|
|
|
// Note: adapted from mikrotikRootDescXML with addresses replaced with
|
|
// localhost, and unnecessary fields removed.
|
|
const testSelectRootDesc = `<?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>
|
|
<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>
|
|
<presentationURL>@SERVERURL@</presentationURL>
|
|
</device>
|
|
<URLBase>@SERVERURL@</URLBase>
|
|
</root>`
|
|
|
|
const testGetStatusInfoResponseDisconnected = `<?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>Disconnected</NewConnectionStatus>
|
|
<NewLastConnectionError>ERROR_NONE</NewLastConnectionError>
|
|
<NewUptime>0</NewUptime>
|
|
</u:GetStatusInfoResponse>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`
|
|
|
|
const testGetExternalIPAddressResponsePrivate = `<?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>10.9.8.7</NewExternalIPAddress>
|
|
</u:GetExternalIPAddressResponse>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`
|
|
|
|
const testGetExternalIPAddressResponseInvalid = `<?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>not-an-ip-addr</NewExternalIPAddress>
|
|
</u:GetExternalIPAddressResponse>
|
|
</s:Body>
|
|
</s:Envelope>
|
|
`
|