// 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 = `
1
0
urn:schemas-upnp-org:device:InternetGatewayDevice:1
MikroTik Router
MikroTik
https://www.mikrotik.com/
Router OS
uuid:UUID-MIKROTIK-INTERNET-GATEWAY-DEVICE-
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
@SERVERURL@
@SERVERURL@
`
const testGetStatusInfoResponseDisconnected = `
Disconnected
ERROR_NONE
0
`
const testGetExternalIPAddressResponsePrivate = `
10.9.8.7
`
const testGetExternalIPAddressResponseInvalid = `
not-an-ip-addr
`