// 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/net/portmapper/portmappertype"
	"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
`
	// Huawei, https://github.com/tailscale/tailscale/issues/10911
	huaweiRootDescXML = `
  
    1
    0
  
  
    urn:dslforum-org:device:InternetGatewayDevice:1
    HG531 V1
    Huawei Technologies Co., Ltd.
    http://www.huawei.com
    Huawei Home Gateway
    HG531 V1
    Huawei Model
    http://www.huawei.com
    G6J8W15326003974
    uuid:00e0fc37-2626-2828-2600-587f668bdd9a
    000000000001
    
      
        urn:www-huawei-com:service:DeviceConfig:1
        urn:www-huawei-com:serviceId:DeviceConfig1
        /desc/DevCfg.xml
        /ctrlt/DeviceConfig_1
        /evt/DeviceConfig_1
      
      
        urn:dslforum-org:service:LANConfigSecurity:1
        urn:dslforum-org:serviceId:LANConfigSecurity1
        /desc/LANSec.xml
        /ctrlt/LANConfigSecurity_1
        /evt/LANConfigSecurity_1
      
      
        urn:dslforum-org:service:Layer3Forwarding:1
        urn:dslforum-org:serviceId:Layer3Forwarding1
        /desc/L3Fwd.xml
        /ctrlt/Layer3Forwarding_1
        /evt/Layer3Forwarding_1
      
    
    
      
        urn:dslforum-org:device:WANDevice:1
        WANDevice
        Huawei Technologies Co., Ltd.
        http://www.huawei.com
        Huawei Home Gateway
        HG531 V1
        Huawei Model
        http://www.huawei.com
        G6J8W15326003974
        uuid:00e0fc37-2626-2828-2601-587f668bdd9a
        000000000001
        
          
            urn:dslforum-org:service:WANDSLInterfaceConfig:1
            urn:dslforum-org:serviceId:WANDSLInterfaceConfig1
            /desc/WanDslIfCfg.xml
            /ctrlt/WANDSLInterfaceConfig_1
            /evt/WANDSLInterfaceConfig_1
          
          
            urn:dslforum-org:service:WANCommonInterfaceConfig:1
            urn:dslforum-org:serviceId:WANCommonInterfaceConfig1
            /desc/WanCommonIfc1.xml
            /ctrlt/WANCommonInterfaceConfig_1
            /evt/WANCommonInterfaceConfig_1
          
        
        
          
            urn:dslforum-org:device:WANConnectionDevice:1
            WANConnectionDevice
            Huawei Technologies Co., Ltd.
            http://www.huawei.com
            Huawei Home Gateway
            HG531 V1
            Huawei Model
            http://www.huawei.com
            G6J8W15326003974
            uuid:00e0fc37-2626-2828-2603-587f668bdd9a
            000000000001
            
              
                urn:dslforum-org:service:WANPPPConnection:1
                urn:dslforum-org:serviceId:WANPPPConnection1
                /desc/WanPppConn.xml
                /ctrlt/WANPPPConnection_1
                /evt/WANPPPConnection_1
              
              
                urn:dslforum-org:service:WANEthernetConnectionManagement:1
                urn:dslforum-org:serviceId:WANEthernetConnectionManagement1
                /desc/WanEthConnMgt.xml
                /ctrlt/WANEthernetConnectionManagement_1
                /evt/WANEthernetConnectionManagement_1
              
              
                urn:dslforum-org:service:WANDSLLinkConfig:1
                urn:dslforum-org:serviceId:WANDSLLinkConfig1
                /desc/WanDslLink.xml
                /ctrlt/WANDSLLinkConfig_1
                /evt/WANDSLLinkConfig_1
              
            
          
        
      
      
        urn:dslforum-org:device:LANDevice:1
        LANDevice
        Huawei Technologies Co., Ltd.
        http://www.huawei.com
        Huawei Home Gateway
        HG531 V1
        Huawei Model
        http://www.huawei.com
        G6J8W15326003974
        uuid:00e0fc37-2626-2828-2602-587f668bdd9a
        000000000001
        
          
            urn:dslforum-org:service:WLANConfiguration:1
            urn:dslforum-org:serviceId:WLANConfiguration4
            /desc/WLANCfg.xml
            /ctrlt/WLANConfiguration_4
            /evt/WLANConfiguration_4
          
          
            urn:dslforum-org:service:WLANConfiguration:1
            urn:dslforum-org:serviceId:WLANConfiguration3
            /desc/WLANCfg.xml
            /ctrlt/WLANConfiguration_3
            /evt/WLANConfiguration_3
          
          
            urn:dslforum-org:service:WLANConfiguration:1
            urn:dslforum-org:serviceId:WLANConfiguration2
            /desc/WLANCfg.xml
            /ctrlt/WLANConfiguration_2
            /evt/WLANConfiguration_2
          
          
            urn:dslforum-org:service:WLANConfiguration:1
            urn:dslforum-org:serviceId:WLANConfiguration1
            /desc/WLANCfg.xml
            /ctrlt/WLANConfiguration_1
            /evt/WLANConfiguration_1
          
          
            urn:dslforum-org:service:LANHostConfigManagement:1
            urn:dslforum-org:serviceId:LANHostConfigManagement1
            /desc/LanHostCfgMgmt.xml
            /ctrlt/LANHostConfigManagement_1
            /evt/LANHostConfigManagement_1
          
        
      
    
    http://127.0.0.1
  
`
	noSupportedServicesRootDesc = `
  
    1
    0
  
  
    urn:dslforum-org:device:InternetGatewayDevice:1
    Fake Router
    Tailscale, Inc
    http://www.tailscale.com
    Fake Router
    Test Model
    v1
    http://www.tailscale.com
    123456789
    uuid:11111111-2222-3333-4444-555555555555
    000000000001
    
      
        urn:schemas-microsoft-com:service:OSInfo:1
        urn:microsoft-com:serviceId:OSInfo1
        /osinfo.xml
        /upnp/control/aaaaaaaaaa/osinfo
        /upnp/event/aaaaaaaaaa/osinfo
      
    
    
      
	urn:schemas-upnp-org:device:WANDevice:1
        WANDevice
        Tailscale, Inc
	http://www.tailscale.com
	Tailscale Test Router
	Test Model
	v1
	http://www.tailscale.com
	123456789
	uuid:11111111-2222-3333-4444-555555555555
        000000000001
        
          
            urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1
            urn:upnp-org:serviceId:WANCommonIFC1
            /ctl/bbbbbbbb
            /evt/bbbbbbbb
            /WANCfg.xml
          
        
        
          
	    urn:schemas-upnp-org:device:WANConnectionDevice:1
            WANConnectionDevice
	    Tailscale, Inc
	    http://www.tailscale.com
	    Tailscale Test Router
	    Test Model
	    v1
	    http://www.tailscale.com
	    123456789
	    uuid:11111111-2222-3333-4444-555555555555
            000000000001
            
              
		urn:tailscale:service:SomethingElse:1
		urn:upnp-org:serviceId:TailscaleSomethingElse
                /desc/SomethingElse.xml
                /ctrlt/SomethingElse_1
                /evt/SomethingElse_1
              
            
          
        
      
    
    http://127.0.0.1
  
`
)
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",
		},
		{
			"huawei",
			huaweiRootDescXML,
			"*portmapper.legacyWANPPPConnection1",
			"saw UPnP type *portmapper.legacyWANPPPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; HG531 V1 (Huawei Technologies Co., Ltd.), method=single\n",
		},
		{
			"not_supported",
			noSupportedServicesRootDesc,
			"",
			"",
		},
		// 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, 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, nil)
		t.Logf("Listening on upnp=%v", c.testUPnPPort)
		c.debug.VerboseLogs = true
		// Try twice to test the "cache previous mapping" logic.
		var (
			firstResponse netip.AddrPort
			prevPort      uint16
		)
		for i := range 2 {
			sawRequestWithLease.Store(false)
			mustProbeUPnP(t, ctx, c)
			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 TestGetUPnPPortMapping_LeaseDuration(t *testing.T) {
	testCases := []struct {
		name string
		resp string
	}{
		{"only_permanent_leases", testAddPortMappingPermanentLease},
		{"invalid_args", testAddPortMappingPermanentLease_InvalidArgs},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// 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, tc.resp
					}
					return http.StatusOK, testAddPortMappingResponse
				},
				"GetExternalIPAddress": testGetExternalIPAddressResponse,
				"GetStatusInfo":        testGetStatusInfoResponse,
				"DeletePortMapping":    "", // Do nothing for test
			}
			igd, err := NewTestIGD(t, TestIGDOptions{UPnP: true})
			if err != nil {
				t.Fatal(err)
			}
			defer igd.Close()
			igd.SetUPnPHandler(&upnpServer{
				t:    t,
				Desc: testRootDesc,
				Control: map[string]map[string]any{
					"/ctl/IPConn":                          handlers,
					"/upnp/control/yomkmsnooi/wanipconn-1": handlers,
				},
			})
			ctx := context.Background()
			c := newTestClient(t, igd, nil)
			c.debug.VerboseLogs = true
			t.Logf("Listening on upnp=%v", c.testUPnPPort)
			// Actually test the UPnP port mapping.
			mustProbeUPnP(t, ctx, c)
			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), 0)
			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")
			}
			t.Logf("external IP: %v", ext)
		})
	}
}
// TestGetUPnPPortMapping_NoValidServices tests that getUPnPPortMapping doesn't
// crash when a valid UPnP response with no supported services is discovered
// and parsed.
//
// See https://github.com/tailscale/tailscale/issues/10911
func TestGetUPnPPortMapping_NoValidServices(t *testing.T) {
	igd, err := NewTestIGD(t, TestIGDOptions{UPnP: true})
	if err != nil {
		t.Fatal(err)
	}
	defer igd.Close()
	igd.SetUPnPHandler(&upnpServer{
		t:    t,
		Desc: noSupportedServicesRootDesc,
	})
	c := newTestClient(t, igd, nil)
	c.debug.VerboseLogs = true
	ctx := context.Background()
	mustProbeUPnP(t, ctx, c)
	gw, myIP, ok := c.gatewayAndSelfIP()
	if !ok {
		t.Fatalf("could not get gateway and self IP")
	}
	// This shouldn't panic
	_, ok = c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
	if ok {
		t.Fatal("did not expect to get UPnP port mapping")
	}
}
// Tests the legacy behaviour with the pre-UPnP standard portmapping service.
func TestGetUPnPPortMapping_Legacy(t *testing.T) {
	igd, err := NewTestIGD(t, TestIGDOptions{UPnP: true})
	if err != nil {
		t.Fatal(err)
	}
	defer igd.Close()
	// This is a very basic fake UPnP server handler.
	handlers := map[string]any{
		"AddPortMapping":       testLegacyAddPortMappingResponse,
		"GetExternalIPAddress": testLegacyGetExternalIPAddressResponse,
		"GetStatusInfo":        testLegacyGetStatusInfoResponse,
		"DeletePortMapping":    "", // Do nothing for test
	}
	igd.SetUPnPHandler(&upnpServer{
		t:    t,
		Desc: huaweiRootDescXML,
		Control: map[string]map[string]any{
			"/ctrlt/WANPPPConnection_1": handlers,
		},
	})
	c := newTestClient(t, igd, nil)
	c.debug.VerboseLogs = true
	ctx := context.Background()
	mustProbeUPnP(t, ctx, c)
	gw, myIP, ok := c.gatewayAndSelfIP()
	if !ok {
		t.Fatalf("could not get gateway and self IP")
	}
	ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
	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)
	}
}
func TestGetUPnPPortMappingNoResponses(t *testing.T) {
	igd, err := NewTestIGD(t, TestIGDOptions{UPnP: true})
	if err != nil {
		t.Fatal(err)
	}
	defer igd.Close()
	c := newTestClient(t, igd, nil)
	t.Logf("Listening on upnp=%v", c.testUPnPPort)
	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)
			}
		})
	}
}
// See: https://github.com/tailscale/corp/issues/23538
func TestGetUPnPPortMapping_Invalid(t *testing.T) {
	for _, responseAddr := range []string{
		"0.0.0.0",
		"127.0.0.1",
	} {
		t.Run(responseAddr, func(t *testing.T) {
			igd, err := NewTestIGD(t, TestIGDOptions{UPnP: true})
			if err != nil {
				t.Fatal(err)
			}
			defer igd.Close()
			// This is a very basic fake UPnP server handler.
			handlers := map[string]any{
				"AddPortMapping":       testAddPortMappingResponse,
				"GetExternalIPAddress": makeGetExternalIPAddressResponse(responseAddr),
				"GetStatusInfo":        testGetStatusInfoResponse,
				"DeletePortMapping":    "", // Do nothing for test
			}
			igd.SetUPnPHandler(&upnpServer{
				t:    t,
				Desc: huaweiRootDescXML,
				Control: map[string]map[string]any{
					"/ctrlt/WANPPPConnection_1": handlers,
				},
			})
			c := newTestClient(t, igd, nil)
			c.debug.VerboseLogs = true
			ctx := context.Background()
			mustProbeUPnP(t, ctx, c)
			gw, myIP, ok := c.gatewayAndSelfIP()
			if !ok {
				t.Fatalf("could not get gateway and self IP")
			}
			ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
			if ok {
				t.Fatal("did not expect to get UPnP port mapping")
			}
			if ext.IsValid() {
				t.Fatalf("expected no external address; got %v", ext)
			}
		})
	}
}
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
	}
}
func mustProbeUPnP(tb testing.TB, ctx context.Context, c *Client) portmappertype.ProbeResult {
	tb.Helper()
	res, err := c.Probe(ctx)
	if err != nil {
		tb.Fatalf("Probe: %v", err)
	}
	if !res.UPnP {
		tb.Fatalf("didn't detect UPnP")
	}
	return res
}
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 testAddPortMappingPermanentLease_InvalidArgs = `
 
   
    SOAP:Client
    UPnPError
    
     
      402
      Invalid Args
     
    
   
 
`
const testAddPortMappingResponse = `
  
    
  
`
const testGetExternalIPAddressResponse = `
  
    
      123.123.123.123
    
  
`
const testGetStatusInfoResponse = `
  
    
      Connected
      ERROR_NONE
      9999
    
  
`
const testLegacyAddPortMappingResponse = `
  
    
  
`
const testLegacyGetExternalIPAddressResponse = `
  
    
      123.123.123.123
    
  
`
const testLegacyGetStatusInfoResponse = `
  
    
      Connected
      ERROR_NONE
      9999
    
  
`
func makeGetExternalIPAddressResponse(ip string) string {
	return fmt.Sprintf(`
  
    
      %s
    
  
`, ip)
}