| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359 |
- // 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, 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, nil)
- t.Logf("Listening on upnp=%v", c.testUPnPPort)
- // 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>
- `
|