// 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> `