tailscale/net/portmapper/select_test.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>
`