// 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"
	"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 = `<?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:2</deviceType><friendlyName>OnHub</friendlyName><manufacturer>Google</manufacturer><manufacturerURL>http://google.com/</manufacturerURL><modelDescription>Wireless Router</modelDescription><modelName>OnHub</modelName><modelNumber>1</modelNumber><modelURL>https://on.google.com/hub/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece</UDN><serviceList><service><serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType><serviceId>urn:upnp-org:serviceId:Layer3Forwarding1</serviceId><controlURL>/ctl/L3F</controlURL><eventSubURL>/evt/L3F</eventSubURL><SCPDURL>/L3F.xml</SCPDURL></service><service><serviceType>urn:schemas-upnp-org:service:DeviceProtection:1</serviceType><serviceId>urn:upnp-org:serviceId:DeviceProtection1</serviceId><controlURL>/ctl/DP</controlURL><eventSubURL>/evt/DP</eventSubURL><SCPDURL>/DP.xml</SCPDURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANDevice:2</deviceType><friendlyName>WANDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>WAN Device</modelDescription><modelName>WAN Device</modelName><modelNumber>20210414</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ecf</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType><serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId><controlURL>/ctl/CmnIfCfg</controlURL><eventSubURL>/evt/CmnIfCfg</eventSubURL><SCPDURL>/WANCfg.xml</SCPDURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:2</deviceType><friendlyName>WANConnectionDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>MiniUPnP daemon</modelDescription><modelName>MiniUPnPd</modelName><modelNumber>20210414</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ec0</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:2</serviceType><serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId><controlURL>/ctl/IPConn</controlURL><eventSubURL>/evt/IPConn</eventSubURL><SCPDURL>/WANIPCn.xml</SCPDURL></service></serviceList></device></deviceList></device></deviceList><presentationURL>http://testwifi.here/</presentationURL></device></root>`

	// 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 = `<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337"><specVersion><major>1</major><minor>1</minor></specVersion><device><deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType><friendlyName>FreeBSD router</friendlyName><manufacturer>FreeBSD</manufacturer><manufacturerURL>http://www.freebsd.org/</manufacturerURL><modelDescription>FreeBSD router</modelDescription><modelName>FreeBSD router</modelName><modelNumber>2.5.0-RELEASE</modelNumber><modelURL>http://www.freebsd.org/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac11</UDN><serviceList><service><serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType><serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId><SCPDURL>/L3F.xml</SCPDURL><controlURL>/ctl/L3F</controlURL><eventSubURL>/evt/L3F</eventSubURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType><friendlyName>WANDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>WAN Device</modelDescription><modelName>WAN Device</modelName><modelNumber>20210205</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac12</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType><serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId><SCPDURL>/WANCfg.xml</SCPDURL><controlURL>/ctl/CmnIfCfg</controlURL><eventSubURL>/evt/CmnIfCfg</eventSubURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType><friendlyName>WANConnectionDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>MiniUPnP daemon</modelDescription><modelName>MiniUPnPd</modelName><modelNumber>20210205</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac13</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType><serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId><SCPDURL>/WANIPCn.xml</SCPDURL><controlURL>/ctl/IPConn</controlURL><eventSubURL>/evt/IPConn</eventSubURL></service></serviceList></device></deviceList></device></deviceList><presentationURL>https://192.168.1.1/</presentationURL></device></root>`

	// 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"
)

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)\n",
		},
		{
			"pfsense",
			pfSenseRootDescXML,
			"*internetgateway2.WANIPConnection1",
			"saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; FreeBSD router (FreeBSD)\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()
			var logBuf tstest.MemLogger
			c, err := getUPnPClient(context.Background(), logBuf.Logf, DebugKnobs{}, gw, uPnPDiscoResponse{
				Location: ts.URL + "/rootDesc.xml",
			})
			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()

	c := newTestClient(t, igd)
	t.Logf("Listening on upnp=%v", c.testUPnPPort)
	defer c.Close()

	c.debug.VerboseLogs = true

	// This is a very basic fake UPnP server handler.
	var sawRequestWithLease atomic.Bool
	igd.SetUPnPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Logf("got UPnP request %s %s", r.Method, r.URL.Path)
		switch r.URL.Path {
		case "/rootDesc.xml":
			io.WriteString(w, testRootDesc)
		case "/ctl/IPConn":
			body, err := io.ReadAll(r.Body)
			if err != nil {
				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 {
				t.Errorf("bad request: %v", err)
				http.Error(w, "bad request", http.StatusBadRequest)
				return
			}

			requestType := outerRequest.Body.Request.XMLName.Local
			upnpRequest := outerRequest.Body.Inner
			t.Logf("UPnP request: %s", requestType)

			switch requestType {
			case "AddPortMapping":
				// 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([]byte(upnpRequest), &req); err != nil {
					t.Errorf("bad request: %v", err)
					http.Error(w, "bad request", http.StatusBadRequest)
					return
				}

				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.
					io.WriteString(w, testAddPortMappingPermanentLease)
					sawRequestWithLease.Store(true)
				} else {
					// Success!
					io.WriteString(w, testAddPortMappingResponse)
				}
			case "GetExternalIPAddress":
				io.WriteString(w, testGetExternalIPAddressResponse)

			case "DeletePortMapping":
				// Do nothing for test

			default:
				t.Errorf("unhandled UPnP request type %q", requestType)
				http.Error(w, "bad request", http.StatusBadRequest)
			}
		default:
			t.Logf("ignoring request")
			http.NotFound(w, r)
		}
	}))

	ctx := context.Background()
	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), 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)
}

const testRootDesc = `<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337">
  <specVersion>
    <major>1</major>
    <minor>1</minor>
  </specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
    <friendlyName>Tailscale Test Router</friendlyName>
    <manufacturer>Tailscale</manufacturer>
    <manufacturerURL>https://tailscale.com</manufacturerURL>
    <modelDescription>Tailscale Test Router</modelDescription>
    <modelName>Tailscale Test Router</modelName>
    <modelNumber>2.5.0-RELEASE</modelNumber>
    <modelURL>https://tailscale.com</modelURL>
    <serialNumber>1234</serialNumber>
    <UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
    <deviceList>
      <device>
	<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
	<friendlyName>WANDevice</friendlyName>
	<manufacturer>MiniUPnP</manufacturer>
	<manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>
	<modelDescription>WAN Device</modelDescription>
	<modelName>WAN Device</modelName>
	<modelNumber>20990102</modelNumber>
	<modelURL>http://miniupnp.free.fr/</modelURL>
	<serialNumber>1234</serialNumber>
	<UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
	<UPC>000000000000</UPC>
	<deviceList>
	  <device>
	    <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
	    <friendlyName>WANConnectionDevice</friendlyName>
	    <manufacturer>MiniUPnP</manufacturer>
	    <manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>
	    <modelDescription>MiniUPnP daemon</modelDescription>
	    <modelName>MiniUPnPd</modelName>
	    <modelNumber>20210205</modelNumber>
	    <modelURL>http://miniupnp.free.fr/</modelURL>
	    <serialNumber>1234</serialNumber>
	    <UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
	    <UPC>000000000000</UPC>
	    <serviceList>
	      <service>
		<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
		<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
		<SCPDURL>/WANIPCn.xml</SCPDURL>
		<controlURL>/ctl/IPConn</controlURL>
		<eventSubURL>/evt/IPConn</eventSubURL>
	      </service>
	    </serviceList>
	  </device>
	</deviceList>
      </device>
    </deviceList>
    <presentationURL>https://127.0.0.1/</presentationURL>
  </device>
</root>
`

const testAddPortMappingPermanentLease = `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <s:Fault>
      <faultCode>s:Client</faultCode>
      <faultString>UPnPError</faultString>
      <detail>
        <UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
          <errorCode>725</errorCode>
          <errorDescription>OnlyPermanentLeasesSupported</errorDescription>
        </UPnPError>
      </detail>
    </s:Fault>
  </s:Body>
</s:Envelope>
`

const testAddPortMappingResponse = `<?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:AddPortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1"/>
  </s:Body>
</s:Envelope>
`

const testGetExternalIPAddressResponse = `<?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>123.123.123.123</NewExternalIPAddress>
    </u:GetExternalIPAddressResponse>
  </s:Body>
</s:Envelope>
`