// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package portmapper import ( "context" "encoding/xml" "fmt" "io" "net" "net/http" "net/http/httptest" "net/netip" "reflect" "regexp" "slices" "sync/atomic" "testing" "tailscale.com/tstest" ) // Google Wifi const ( googleWifiUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nUSN: uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece::urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nEXT:\r\nSERVER: Linux/5.4.0-1034-gcp UPnP/1.1 MiniUPnPd/1.9\r\nLOCATION: http://192.168.86.1:5000/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1\r\nBOOTID.UPNP.ORG: 1\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n" googleWifiRootDescXML = ` 10urn:schemas-upnp-org:device:InternetGatewayDevice:2OnHubGooglehttp://google.com/Wireless RouterOnHub1https://on.google.com/hub/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30eceurn:schemas-upnp-org:service:Layer3Forwarding:1urn:upnp-org:serviceId:Layer3Forwarding1/ctl/L3F/evt/L3F/L3F.xmlurn:schemas-upnp-org:service:DeviceProtection:1urn:upnp-org:serviceId:DeviceProtection1/ctl/DP/evt/DP/DP.xmlurn:schemas-upnp-org:device:WANDevice:2WANDeviceMiniUPnPhttp://miniupnp.free.fr/WAN DeviceWAN Device20210414http://miniupnp.free.fr/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30ecf000000000000urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1urn:upnp-org:serviceId:WANCommonIFC1/ctl/CmnIfCfg/evt/CmnIfCfg/WANCfg.xmlurn:schemas-upnp-org:device:WANConnectionDevice:2WANConnectionDeviceMiniUPnPhttp://miniupnp.free.fr/MiniUPnP daemonMiniUPnPd20210414http://miniupnp.free.fr/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30ec0000000000000urn:schemas-upnp-org:service:WANIPConnection:2urn:upnp-org:serviceId:WANIPConn1/ctl/IPConn/evt/IPConn/WANIPCn.xmlhttp://testwifi.here/` // pfSense 2.5.0-RELEASE / FreeBSD 12.2-STABLE pfSenseUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nEXT:\r\nSERVER: FreeBSD/12.2-STABLE UPnP/1.1 MiniUPnPd/2.2.1\r\nLOCATION: http://192.168.1.1:2189/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1627958564\r\nBOOTID.UPNP.ORG: 1627958564\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n" pfSenseRootDescXML = ` 11urn:schemas-upnp-org:device:InternetGatewayDevice:1FreeBSD routerFreeBSDhttp://www.freebsd.org/FreeBSD routerFreeBSD router2.5.0-RELEASEhttp://www.freebsd.org/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac11urn:schemas-upnp-org:service:Layer3Forwarding:1urn:upnp-org:serviceId:L3Forwarding1/L3F.xml/ctl/L3F/evt/L3Furn:schemas-upnp-org:device:WANDevice:1WANDeviceMiniUPnPhttp://miniupnp.free.fr/WAN DeviceWAN Device20210205http://miniupnp.free.fr/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac12000000000000urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1urn:upnp-org:serviceId:WANCommonIFC1/WANCfg.xml/ctl/CmnIfCfg/evt/CmnIfCfgurn:schemas-upnp-org:device:WANConnectionDevice:1WANConnectionDeviceMiniUPnPhttp://miniupnp.free.fr/MiniUPnP daemonMiniUPnPd20210205http://miniupnp.free.fr/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac13000000000000urn:schemas-upnp-org:service:WANIPConnection:1urn:upnp-org:serviceId:WANIPConn1/WANIPCn.xml/ctl/IPConn/evt/IPConnhttps://192.168.1.1/` // Sagemcom FAST3890V3, https://github.com/tailscale/tailscale/issues/3557 sagemcomUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Tue, 14 Dec 2021 07:51:29 GMT\r\nEXT:\r\nLOCATION: http://192.168.0.1:49153/69692b70/gatedesc0b.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: cabd6488-1dd1-11b2-9e52-a7461e1f098e\r\nSERVER: \r\nUser-Agent: redsonic\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:75802409-bccb-40e7-8e6c-fa095ecce13e::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n" // Huawei, https://github.com/tailscale/tailscale/issues/6320 huaweiUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Fri, 25 Nov 2022 07:04:37 GMT\r\nEXT:\r\nLOCATION: http://192.168.1.1:49652/49652gatedesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: ce8dd8b0-732d-11be-a4a1-a2b26c8915fb\r\nSERVER: Linux/4.4.240, UPnP/1.0, Portable SDK for UPnP devices/1.12.1\r\nX-User-Agent: UPnP/1.0 DLNADOC/1.50\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:00e0fc37-2525-2828-2500-0C31DCD93368::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n" // Mikrotik CHR v7.10, https://github.com/tailscale/tailscale/issues/8364 mikrotikRootDescXML = ` 1 0 urn:schemas-upnp-org:device:InternetGatewayDevice:1 MikroTik Router MikroTik https://www.mikrotik.com/ Router OS uuid:UUID-MIKROTIK-INTERNET-GATEWAY-DEVICE- image/gif 16 16 8 /logo16.gif image/gif 32 32 8 /logo32.gif image/gif 48 48 8 /logo48.gif 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 http://10.0.0.1/ http://127.0.0.1/ http://10.0.0.1:2828 ` ) func TestParseUPnPDiscoResponse(t *testing.T) { tests := []struct { name string headers string want uPnPDiscoResponse }{ {"google", googleWifiUPnPDisco, uPnPDiscoResponse{ Location: "http://192.168.86.1:5000/rootDesc.xml", Server: "Linux/5.4.0-1034-gcp UPnP/1.1 MiniUPnPd/1.9", USN: "uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece::urn:schemas-upnp-org:device:InternetGatewayDevice:2", }}, {"pfsense", pfSenseUPnPDisco, uPnPDiscoResponse{ Location: "http://192.168.1.1:2189/rootDesc.xml", Server: "FreeBSD/12.2-STABLE UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }}, {"sagemcom", sagemcomUPnPDisco, uPnPDiscoResponse{ Location: "http://192.168.0.1:49153/69692b70/gatedesc0b.xml", Server: "", USN: "uuid:75802409-bccb-40e7-8e6c-fa095ecce13e::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }}, {"huawei", huaweiUPnPDisco, uPnPDiscoResponse{ Location: "http://192.168.1.1:49652/49652gatedesc.xml", Server: "Linux/4.4.240, UPnP/1.0, Portable SDK for UPnP devices/1.12.1", USN: "uuid:00e0fc37-2525-2828-2500-0C31DCD93368::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseUPnPDiscoResponse([]byte(tt.headers)) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, tt.want) { t.Errorf("unexpected result:\n got: %+v\nwant: %+v\n", got, tt.want) } }) } } func TestGetUPnPClient(t *testing.T) { tests := []struct { name string xmlBody string want string wantLog string }{ { "google", googleWifiRootDescXML, "*internetgateway2.WANIPConnection2", "saw UPnP type WANIPConnection2 at http://127.0.0.1:NNN/rootDesc.xml; OnHub (Google), method=single\n", }, { "pfsense", pfSenseRootDescXML, "*internetgateway2.WANIPConnection1", "saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; FreeBSD router (FreeBSD), method=single\n", }, { "mikrotik", mikrotikRootDescXML, "*internetgateway2.WANIPConnection1", "saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; MikroTik Router (MikroTik), method=none\n", }, // TODO(bradfitz): find a PPP one in the wild } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.RequestURI == "/rootDesc.xml" { io.WriteString(w, tt.xmlBody) return } http.NotFound(w, r) })) defer ts.Close() gw, _ := netip.AddrFromSlice(ts.Listener.Addr().(*net.TCPAddr).IP) gw = gw.Unmap() ctx := context.Background() var logBuf tstest.MemLogger dev, loc, err := getUPnPRootDevice(ctx, logBuf.Logf, DebugKnobs{}, gw, uPnPDiscoResponse{ Location: ts.URL + "/rootDesc.xml", }) if err != nil { t.Fatal(err) } c, err := selectBestService(ctx, logBuf.Logf, dev, loc) if err != nil { t.Fatal(err) } got := fmt.Sprintf("%T", c) if got != tt.want { t.Errorf("got %v; want %v", got, tt.want) } gotLog := regexp.MustCompile(`127\.0\.0\.1:\d+`).ReplaceAllString(logBuf.String(), "127.0.0.1:NNN") if gotLog != tt.wantLog { t.Errorf("logged %q; want %q", gotLog, tt.wantLog) } }) } } func TestGetUPnPPortMapping(t *testing.T) { igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) if err != nil { t.Fatal(err) } defer igd.Close() // This is a very basic fake UPnP server handler. var sawRequestWithLease atomic.Bool handlers := map[string]any{ "AddPortMapping": func(body []byte) (int, string) { // Decode a minimal body to determine whether we skip the request or not. var req struct { Protocol string `xml:"NewProtocol"` InternalPort string `xml:"NewInternalPort"` ExternalPort string `xml:"NewExternalPort"` InternalClient string `xml:"NewInternalClient"` LeaseDuration string `xml:"NewLeaseDuration"` } if err := xml.Unmarshal(body, &req); err != nil { t.Errorf("bad request: %v", err) return http.StatusBadRequest, "bad request" } if req.Protocol != "UDP" { t.Errorf(`got Protocol=%q, want "UDP"`, req.Protocol) } if req.LeaseDuration != "0" { // Return a fake error to ensure that we fall back to a permanent lease. sawRequestWithLease.Store(true) return http.StatusOK, testAddPortMappingPermanentLease } // Success! return http.StatusOK, testAddPortMappingResponse }, "GetExternalIPAddress": testGetExternalIPAddressResponse, "GetStatusInfo": testGetStatusInfoResponse, "DeletePortMapping": "", // Do nothing for test } ctx := context.Background() rootDescsToTest := []string{testRootDesc, mikrotikRootDescXML} for _, rootDesc := range rootDescsToTest { igd.SetUPnPHandler(&upnpServer{ t: t, Desc: rootDesc, Control: map[string]map[string]any{ "/ctl/IPConn": handlers, "/upnp/control/yomkmsnooi/wanipconn-1": handlers, }, }) c := newTestClient(t, igd) t.Logf("Listening on upnp=%v", c.testUPnPPort) defer c.Close() c.debug.VerboseLogs = true // Try twice to test the "cache previous mapping" logic. var ( firstResponse netip.AddrPort prevPort uint16 ) for i := 0; i < 2; i++ { sawRequestWithLease.Store(false) res, err := c.Probe(ctx) if err != nil { t.Fatalf("Probe: %v", err) } if !res.UPnP { t.Errorf("didn't detect UPnP") } gw, myIP, ok := c.gatewayAndSelfIP() if !ok { t.Fatalf("could not get gateway and self IP") } t.Logf("gw=%v myIP=%v", gw, myIP) ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), prevPort) if !ok { t.Fatal("could not get UPnP port mapping") } if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want { t.Errorf("bad external address; got %v want %v", got, want) } if !sawRequestWithLease.Load() { t.Errorf("wanted request with lease, but didn't see one") } if i == 0 { firstResponse = ext prevPort = ext.Port() } else if firstResponse != ext { t.Errorf("got different response on second attempt: (got) %v != %v (want)", ext, firstResponse) } t.Logf("external IP: %v", ext) } } } func TestGetUPnPPortMappingNoResponses(t *testing.T) { igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) if err != nil { t.Fatal(err) } defer igd.Close() c := newTestClient(t, igd) t.Logf("Listening on upnp=%v", c.testUPnPPort) defer c.Close() c.debug.VerboseLogs = true // Do this before setting uPnPMetas since it invalidates those mappings // if gw/myIP change. gw, myIP, _ := c.gatewayAndSelfIP() t.Run("ErrorContactingUPnP", func(t *testing.T) { c.mu.Lock() c.uPnPMetas = []uPnPDiscoResponse{{ Location: "http://127.0.0.1:1/does-not-exist.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", }} c.mu.Unlock() _, ok := c.getUPnPPortMapping(context.Background(), gw, netip.AddrPortFrom(myIP, 12345), 0) if ok { t.Errorf("expected no mapping when there are no responses") } }) } func TestProcessUPnPResponses(t *testing.T) { testCases := []struct { name string responses []uPnPDiscoResponse want []uPnPDiscoResponse }{ { name: "single", responses: []uPnPDiscoResponse{{ Location: "http://192.168.1.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }}, want: []uPnPDiscoResponse{{ Location: "http://192.168.1.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }}, }, { name: "multiple_with_same_location", responses: []uPnPDiscoResponse{ { Location: "http://192.168.1.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }, { Location: "http://192.168.1.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", }, }, want: []uPnPDiscoResponse{{ Location: "http://192.168.1.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", }}, }, { name: "multiple_with_different_location", responses: []uPnPDiscoResponse{ { Location: "http://192.168.1.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }, { Location: "http://192.168.100.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", }, }, want: []uPnPDiscoResponse{ // note: this sorts first because we prefer "InternetGatewayDevice:2" { Location: "http://192.168.100.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", }, { Location: "http://192.168.1.1:2828/control.xml", Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", }, }, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { got := processUPnPResponses(slices.Clone(tt.responses)) if !reflect.DeepEqual(got, tt.want) { t.Errorf("unexpected result:\n got: %+v\nwant: %+v\n", got, tt.want) } }) } } type upnpServer struct { t *testing.T Desc string // root device XML Control map[string]map[string]any // map["/url"]map["UPnPService"]response } func (u *upnpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { u.t.Logf("got UPnP request %s %s", r.Method, r.URL.Path) if r.URL.Path == "/rootDesc.xml" { io.WriteString(w, u.Desc) return } if control, ok := u.Control[r.URL.Path]; ok { u.handleControl(w, r, control) return } u.t.Logf("ignoring request") http.NotFound(w, r) } func (u *upnpServer) handleControl(w http.ResponseWriter, r *http.Request, handlers map[string]any) { body, err := io.ReadAll(r.Body) if err != nil { u.t.Errorf("error reading request body: %v", err) http.Error(w, "bad request", http.StatusBadRequest) return } // Decode the request type. var outerRequest struct { Body struct { Request struct { XMLName xml.Name } `xml:",any"` Inner string `xml:",innerxml"` } `xml:"Body"` } if err := xml.Unmarshal(body, &outerRequest); err != nil { u.t.Errorf("bad request: %v", err) http.Error(w, "bad request", http.StatusBadRequest) return } requestType := outerRequest.Body.Request.XMLName.Local upnpRequest := outerRequest.Body.Inner u.t.Logf("UPnP request: %s", requestType) handler, ok := handlers[requestType] if !ok { u.t.Errorf("unhandled UPnP request type %q", requestType) http.Error(w, "bad request", http.StatusBadRequest) return } switch v := handler.(type) { case string: io.WriteString(w, v) case []byte: w.Write(v) // Function handlers case func(string) string: io.WriteString(w, v(upnpRequest)) case func([]byte) string: io.WriteString(w, v([]byte(upnpRequest))) case func(string) (int, string): code, body := v(upnpRequest) w.WriteHeader(code) io.WriteString(w, body) case func([]byte) (int, string): code, body := v([]byte(upnpRequest)) w.WriteHeader(code) io.WriteString(w, body) default: u.t.Fatalf("invalid handler type: %T", v) http.Error(w, "invalid handler type", http.StatusInternalServerError) return } } const testRootDesc = ` 1 1 urn:schemas-upnp-org:device:InternetGatewayDevice:1 Tailscale Test Router Tailscale https://tailscale.com Tailscale Test Router Tailscale Test Router 2.5.0-RELEASE https://tailscale.com 1234 uuid:1974e83b-6dc7-4635-92b3-6a85a4037294 urn:schemas-upnp-org:device:WANDevice:1 WANDevice MiniUPnP http://miniupnp.free.fr/ WAN Device WAN Device 20990102 http://miniupnp.free.fr/ 1234 uuid:1974e83b-6dc7-4635-92b3-6a85a4037294 000000000000 urn:schemas-upnp-org:device:WANConnectionDevice:1 WANConnectionDevice MiniUPnP http://miniupnp.free.fr/ MiniUPnP daemon MiniUPnPd 20210205 http://miniupnp.free.fr/ 1234 uuid:1974e83b-6dc7-4635-92b3-6a85a4037294 000000000000 urn:schemas-upnp-org:service:WANIPConnection:1 urn:upnp-org:serviceId:WANIPConn1 /WANIPCn.xml /ctl/IPConn /evt/IPConn https://127.0.0.1/ ` const testAddPortMappingPermanentLease = ` s:Client UPnPError 725 OnlyPermanentLeasesSupported ` const testAddPortMappingResponse = ` ` const testGetExternalIPAddressResponse = ` 123.123.123.123 ` const testGetStatusInfoResponse = ` Connected ERROR_NONE 9999 `