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