diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt
index 1a5f70f61..77f5e16b0 100644
--- a/cmd/tailscale/depaware.txt
+++ b/cmd/tailscale/depaware.txt
@@ -7,7 +7,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
💣 github.com/mitchellh/go-ps from tailscale.com/cmd/tailscale/cli
github.com/peterbourgon/ff/v2 from github.com/peterbourgon/ff/v2/ffcli
github.com/peterbourgon/ff/v2/ffcli from tailscale.com/cmd/tailscale/cli
- github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2
+ github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+
github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
diff --git a/cmd/tailscaled/debug.go b/cmd/tailscaled/debug.go
index bf9842eed..d614ef315 100644
--- a/cmd/tailscaled/debug.go
+++ b/cmd/tailscaled/debug.go
@@ -206,6 +206,22 @@ func debugPortmap(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
+ portmapper.VerboseLogs = true
+ switch os.Getenv("TS_DEBUG_PORTMAP_TYPE") {
+ case "":
+ case "pmp":
+ portmapper.DisablePCP = true
+ portmapper.DisableUPnP = true
+ case "pcp":
+ portmapper.DisablePMP = true
+ portmapper.DisableUPnP = true
+ case "upnp":
+ portmapper.DisablePCP = true
+ portmapper.DisablePMP = true
+ default:
+ log.Fatalf("TS_DEBUG_PORTMAP_TYPE must be one of pmp,pcp,upnp")
+ }
+
done := make(chan bool, 1)
var c *portmapper.Client
@@ -248,6 +264,13 @@ func debugPortmap(ctx context.Context) error {
}
logf("gw=%v; self=%v", gw, selfIP)
+ uc, err := net.ListenPacket("udp", "0.0.0.0:0")
+ if err != nil {
+ return err
+ }
+ defer uc.Close()
+ c.SetLocalPort(uint16(uc.LocalAddr().(*net.UDPAddr).Port))
+
res, err := c.Probe(ctx)
if err != nil {
return fmt.Errorf("Probe: %v", err)
@@ -259,13 +282,6 @@ func debugPortmap(ctx context.Context) error {
return nil
}
- uc, err := net.ListenPacket("udp", "0.0.0.0:0")
- if err != nil {
- return err
- }
- defer uc.Close()
- c.SetLocalPort(uint16(uc.LocalAddr().(*net.UDPAddr).Port))
-
if ext, ok := c.GetCachedMappingOrStartCreatingOne(); ok {
logf("mapping: %v", ext)
} else {
diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt
index 627eaadb4..5c0e19ed6 100644
--- a/cmd/tailscaled/depaware.txt
+++ b/cmd/tailscaled/depaware.txt
@@ -23,7 +23,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
W github.com/pkg/errors from github.com/tailscale/certstore
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
- github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2
+ github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+
github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
diff --git a/go.mod b/go.mod
index e3f7c91f6..ab84f5245 100644
--- a/go.mod
+++ b/go.mod
@@ -31,7 +31,7 @@ require (
github.com/pkg/sftp v1.13.0
github.com/tailscale/certstore v0.0.0-20210528134328-066c94b793d3
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027
- github.com/tailscale/goupnp v1.0.1-0.20210710010003-1cf2d718bbb2
+ github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05
github.com/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2
github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0
diff --git a/go.sum b/go.sum
index edfa05cc6..3a8f27dc8 100644
--- a/go.sum
+++ b/go.sum
@@ -581,8 +581,8 @@ github.com/tailscale/certstore v0.0.0-20210528134328-066c94b793d3 h1:fEubocuQkrl
github.com/tailscale/certstore v0.0.0-20210528134328-066c94b793d3/go.mod h1:2P+hpOwd53e7JMX/L4f3VXkv1G+33ES6IWZSrkIeWNs=
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027 h1:lK99QQdH3yBWY6aGilF+IRlQIdmhzLrsEmF6JgN+Ryw=
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
-github.com/tailscale/goupnp v1.0.1-0.20210710010003-1cf2d718bbb2 h1:AIJ8AF9O7jBmCwilP0ydwJMIzW5dw48Us8f3hLJhYBY=
-github.com/tailscale/goupnp v1.0.1-0.20210710010003-1cf2d718bbb2/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
+github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
+github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
github.com/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2 h1:reREUgl2FG+o7YCsrZB8XLjnuKv5hEIWtnOdAbRAXZI=
github.com/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2/go.mod h1:STqf+YV0ADdzk4ejtXFsGqDpATP9JoL0OB+hiFQbkdE=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
diff --git a/net/portmapper/disabled_stubs.go b/net/portmapper/disabled_stubs.go
index fb1572e78..438be0111 100644
--- a/net/portmapper/disabled_stubs.go
+++ b/net/portmapper/disabled_stubs.go
@@ -15,8 +15,10 @@
type upnpClient interface{}
-func getUPnPClient(ctx context.Context, gw netaddr.IP) (upnpClient, error) {
- return nil, nil
+type uPnPDiscoResponse struct{}
+
+func parseUPnPDiscoResponse([]byte) (uPnPDiscoResponse, error) {
+ return uPnPDiscoResponse{}, nil
}
func (c *Client) getUPnPPortMapping(
diff --git a/net/portmapper/portmapper.go b/net/portmapper/portmapper.go
index e9ddf27c9..d08ddbd77 100644
--- a/net/portmapper/portmapper.go
+++ b/net/portmapper/portmapper.go
@@ -14,15 +14,25 @@
"fmt"
"io"
"net"
+ "net/http"
"sync"
"time"
+ "go4.org/mem"
"inet.af/netaddr"
"tailscale.com/net/interfaces"
"tailscale.com/net/netns"
"tailscale.com/types/logger"
)
+// Debub knobs for "tailscaled debug --portmap".
+var (
+ VerboseLogs bool
+ DisableUPnP bool
+ DisablePMP bool
+ DisablePCP bool
+)
+
// References:
//
// NAT-PMP: https://tools.ietf.org/html/rfc6886
@@ -62,8 +72,11 @@ type Client struct {
pmpPubIPTime time.Time // time pmpPubIP last verified
pmpLastEpoch uint32
- pcpSawTime time.Time // time we last saw PCP was available
- uPnPSawTime time.Time // time we last saw UPnP was available
+ pcpSawTime time.Time // time we last saw PCP was available
+
+ uPnPSawTime time.Time // time we last saw UPnP was available
+ uPnPMeta uPnPDiscoResponse // Location header from UPnP UDP discovery response
+ uPnPHTTPClient *http.Client // netns-configured HTTP client for UPnP; nil until needed
localPort uint16
@@ -210,6 +223,7 @@ func (c *Client) invalidateMappingsLocked(releaseOld bool) {
c.pmpPubIPTime = time.Time{}
c.pcpSawTime = time.Time{}
c.uPnPSawTime = time.Time{}
+ c.uPnPMeta = uPnPDiscoResponse{}
}
func (c *Client) sawPMPRecently() bool {
@@ -361,6 +375,7 @@ func (c *Client) createOrGetMapping(ctx context.Context) (external netaddr.IPPor
// find a PMP service, bail out early rather than probing
// again. Cuts down latency for most clients.
haveRecentPMP := c.sawPMPRecentlyLocked()
+
if haveRecentPMP {
m.external = m.external.WithIP(c.pmpPubIP)
}
@@ -560,46 +575,33 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
defer cancel()
defer closeCloserOnContextDone(ctx, uc)()
- if c.sawUPnPRecently() {
- res.UPnP = true
- } else {
- hasUPnP := make(chan bool, 1)
- defer func() {
- res.UPnP = <-hasUPnP
- }()
- go func() {
- client, err := getUPnPClient(ctx, gw)
- if err == nil && client != nil {
- hasUPnP <- true
- c.mu.Lock()
- c.uPnPSawTime = time.Now()
- c.mu.Unlock()
- }
- close(hasUPnP)
- }()
- }
-
pcpAddr := netaddr.IPPortFrom(gw, pcpPort).UDPAddr()
pmpAddr := netaddr.IPPortFrom(gw, pmpPort).UDPAddr()
+ upnpAddr := netaddr.IPPortFrom(gw, upnpPort).UDPAddr()
// Don't send probes to services that we recently learned (for
// the same gw/myIP) are available. See
// https://github.com/tailscale/tailscale/issues/1001
if c.sawPMPRecently() {
res.PMP = true
- } else {
+ } else if !DisablePMP {
uc.WriteTo(pmpReqExternalAddrPacket, pmpAddr)
}
if c.sawPCPRecently() {
res.PCP = true
- } else {
+ } else if !DisablePCP {
uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr)
}
+ if c.sawUPnPRecently() {
+ res.UPnP = true
+ } else if !DisableUPnP {
+ uc.WriteTo(uPnPPacket, upnpAddr)
+ }
buf := make([]byte, 1500)
pcpHeard := false // true when we get any PCP response
for {
- if pcpHeard && res.PMP {
+ if pcpHeard && res.PMP && res.UPnP {
// Nothing more to discover.
return res, nil
}
@@ -612,6 +614,21 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
}
port := addr.(*net.UDPAddr).Port
switch port {
+ case upnpPort:
+ if mem.Contains(mem.B(buf[:n]), mem.S(":InternetGatewayDevice:")) {
+ meta, err := parseUPnPDiscoResponse(buf[:n])
+ if err != nil {
+ c.logf("unrecognized UPnP discovery response; ignoring")
+ }
+ if VerboseLogs {
+ c.logf("UPnP reply %+v, %q", meta, buf[:n])
+ }
+ res.UPnP = true
+ c.mu.Lock()
+ c.uPnPSawTime = time.Now()
+ c.uPnPMeta = meta
+ c.mu.Unlock()
+ }
case pcpPort: // same as pmpPort
if pres, ok := parsePCPResponse(buf[:n]); ok {
if pres.OpCode == pcpOpReply|pcpOpAnnounce {
@@ -724,3 +741,14 @@ func parsePCPResponse(b []byte) (res pcpResponse, ok bool) {
}
var pmpReqExternalAddrPacket = []byte{0, 0} // version 0, opcode 0 = "Public address request"
+
+const (
+ upnpPort = 1900 // for UDP discovery only; TCP port discovered later
+)
+
+// uPnPPacket is the UPnP UDP discovery packet's request body.
+var uPnPPacket = []byte("M-SEARCH * HTTP/1.1\r\n" +
+ "HOST: 239.255.255.250:1900\r\n" +
+ "ST: ssdp:all\r\n" +
+ "MAN: \"ssdp:discover\"\r\n" +
+ "MX: 2\r\n\r\n")
diff --git a/net/portmapper/upnp.go b/net/portmapper/upnp.go
index 5ec91353e..3e7aba4e1 100644
--- a/net/portmapper/upnp.go
+++ b/net/portmapper/upnp.go
@@ -8,15 +8,22 @@
package portmapper
import (
+ "bufio"
+ "bytes"
"context"
"fmt"
"math/rand"
+ "net/http"
"net/url"
+ "strings"
"time"
+ "github.com/tailscale/goupnp"
"github.com/tailscale/goupnp/dcps/internetgateway2"
"inet.af/netaddr"
"tailscale.com/control/controlknobs"
+ "tailscale.com/net/netns"
+ "tailscale.com/types/logger"
)
// References:
@@ -44,7 +51,8 @@ func (u *upnpMapping) Release(ctx context.Context) {
}
// upnpClient is an interface over the multiple different clients exported by goupnp,
-// exposing the functions we need for portmapping. They are auto-generated from XML-specs.
+// exposing the functions we need for portmapping. Those clients are auto-generated from XML-specs,
+// which is why they're not very idiomatic.
type upnpClient interface {
AddPortMapping(
ctx context.Context,
@@ -77,7 +85,7 @@ type upnpClient interface {
// greater than 0. From the spec, it appears if it is set to 0, it will switch to using
// 604800 seconds, but not sure why this is desired. The recommended time is 3600 seconds.
leaseDurationSec uint32,
- ) (err error)
+ ) error
DeletePortMapping(ctx context.Context, remoteHost string, externalPort uint16, protocol string) error
GetExternalIPAddress(ctx context.Context) (externalIPAddress string, err error)
@@ -92,6 +100,8 @@ type upnpClient interface {
// behavior of calling AddPortMapping with port = 0 to specify a wildcard port.
// It returns the new external port (which may not be identical to the external port specified),
// or an error.
+//
+// TODO(bradfitz): also returned the actual lease duration obtained. and check it regularly.
func addAnyPortMapping(
ctx context.Context,
upnp upnpClient,
@@ -130,51 +140,89 @@ func addAnyPortMapping(
return externalPort, err
}
-// getUPnPClients gets a client for interfacing with UPnP, ignoring the underlying protocol for
+// getUPnPClient gets a client for interfacing with UPnP, ignoring the underlying protocol for
// now.
// Adapted from https://github.com/huin/goupnp/blob/master/GUIDE.md.
-func getUPnPClient(ctx context.Context, gw netaddr.IP) (upnpClient, error) {
+//
+// The gw is the detected gateway.
+//
+// The meta is the most recently parsed UDP discovery packet response
+// from the Internet Gateway Device.
+//
+// The provided ctx is not retained in the returned upnpClient, but
+// its associated HTTP client is (if set via goupnp.WithHTTPClient).
+func getUPnPClient(ctx context.Context, logf logger.Logf, gw netaddr.IP, meta uPnPDiscoResponse) (client upnpClient, err error) {
if controlknobs.DisableUPnP() {
return nil, nil
}
- ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
- defer cancel()
- // Attempt to connect over the multiple available connection types concurrently,
- // returning the fastest.
- // TODO(jknodt): this url seems super brittle? maybe discovery is better but this is faster
- u, err := url.Parse(fmt.Sprintf("http://%s:5000/rootDesc.xml", gw))
+ if meta.Location == "" {
+ return nil, nil
+ }
+
+ if VerboseLogs {
+ logf("fetching %v", meta.Location)
+ }
+ u, err := url.Parse(meta.Location)
if err != nil {
return nil, err
}
- clients := make(chan upnpClient, 3)
- go func() {
- var err error
- ip1Clients, err := internetgateway2.NewWANIPConnection1ClientsByURL(ctx, u)
- if err == nil && len(ip1Clients) > 0 {
- clients <- ip1Clients[0]
- }
- }()
- go func() {
- ip2Clients, err := internetgateway2.NewWANIPConnection2ClientsByURL(ctx, u)
- if err == nil && len(ip2Clients) > 0 {
- clients <- ip2Clients[0]
- }
- }()
- go func() {
- ppp1Clients, err := internetgateway2.NewWANPPPConnection1ClientsByURL(ctx, u)
- if err == nil && len(ppp1Clients) > 0 {
- clients <- ppp1Clients[0]
+ ipp, err := netaddr.ParseIPPort(u.Host)
+ if err != nil {
+ return nil, fmt.Errorf("unexpected host %q in %q", u.Host, meta.Location)
+ }
+ if ipp.IP() != gw {
+ return nil, fmt.Errorf("UPnP discovered root %q does not match gateway IP %v; ignoring UPnP",
+ meta.Location, gw)
+ }
+
+ // We're fetching a smallish XML document over plain HTTP
+ // across the local LAN, without using DNS. There should be
+ // very few round trips and low latency, so one second is a
+ // long time.
+ ctx, cancel := context.WithTimeout(ctx, time.Second)
+ defer cancel()
+
+ // This part does a network fetch.
+ root, err := goupnp.DeviceByURL(ctx, u)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() {
+ if client == nil {
+ return
}
+ logf("saw UPnP type %v at %v; %v (%v)",
+ strings.TrimPrefix(fmt.Sprintf("%T", client), "*internetgateway2."),
+ meta.Location, root.Device.FriendlyName, root.Device.Manufacturer)
}()
- select {
- case client := <-clients:
- return client, nil
- case <-ctx.Done():
- return nil, ctx.Err()
+ // These parts don't do a network fetch.
+ // Pick the best service type available.
+ if cc, _ := internetgateway2.NewWANIPConnection2ClientsFromRootDevice(ctx, root, u); len(cc) > 0 {
+ return cc[0], nil
}
+ if cc, _ := internetgateway2.NewWANIPConnection1ClientsFromRootDevice(ctx, root, u); len(cc) > 0 {
+ return cc[0], nil
+ }
+ if cc, _ := internetgateway2.NewWANPPPConnection1ClientsFromRootDevice(ctx, root, u); len(cc) > 0 {
+ return cc[0], nil
+ }
+ return nil, nil
+}
+
+func (c *Client) upnpHTTPClientLocked() *http.Client {
+ if c.uPnPHTTPClient == nil {
+ c.uPnPHTTPClient = &http.Client{
+ Transport: &http.Transport{
+ DialContext: netns.NewDialer().DialContext,
+ IdleConnTimeout: 2 * time.Second, // LAN is cheap
+ },
+ }
+ }
+ return c.uPnPHTTPClient
}
// getUPnPPortMapping attempts to create a port-mapping over the UPnP protocol. On success,
@@ -199,11 +247,17 @@ func (c *Client) getUPnPPortMapping(
var err error
c.mu.Lock()
oldMapping, ok := c.mapping.(*upnpMapping)
+ meta := c.uPnPMeta
+ httpClient := c.upnpHTTPClientLocked()
c.mu.Unlock()
if ok && oldMapping != nil {
client = oldMapping.client
} else {
- client, err = getUPnPClient(ctx, gw)
+ ctx := goupnp.WithHTTPClient(ctx, httpClient)
+ client, err = getUPnPClient(ctx, c.logf, gw, meta)
+ if VerboseLogs {
+ c.logf("getUPnPClient: %T, %v", client, err)
+ }
if err != nil {
return netaddr.IPPort{}, false
}
@@ -221,11 +275,17 @@ func (c *Client) getUPnPPortMapping(
internal.IP().String(),
time.Second*pmpMapLifetimeSec,
)
+ if VerboseLogs {
+ c.logf("addAnyPortMapping: %v, %v", newPort, err)
+ }
if err != nil {
return netaddr.IPPort{}, false
}
// TODO cache this ip somewhere?
extIP, err := client.GetExternalIPAddress(ctx)
+ if VerboseLogs {
+ c.logf("client.GetExternalIPAddress: %v, %v", extIP, err)
+ }
if err != nil {
// TODO this doesn't seem right
return netaddr.IPPort{}, false
@@ -246,3 +306,18 @@ func (c *Client) getUPnPPortMapping(
c.localPort = newPort
return upnp.external, true
}
+
+type uPnPDiscoResponse struct {
+ Location string
+}
+
+// parseUPnPDiscoResponse parses a UPnP HTTP-over-UDP discovery response.
+func parseUPnPDiscoResponse(body []byte) (uPnPDiscoResponse, error) {
+ var r uPnPDiscoResponse
+ res, err := http.ReadResponse(bufio.NewReaderSize(bytes.NewReader(body), 128), nil)
+ if err != nil {
+ return r, err
+ }
+ r.Location = res.Header.Get("Location")
+ return r, nil
+}
diff --git a/net/portmapper/upnp_test.go b/net/portmapper/upnp_test.go
new file mode 100644
index 000000000..9bd25b192
--- /dev/null
+++ b/net/portmapper/upnp_test.go
@@ -0,0 +1,117 @@
+// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package portmapper
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "regexp"
+ "testing"
+
+ "inet.af/netaddr"
+)
+
+// 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
+const (
+ 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/`
+)
+
+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",
+ }},
+ {"pfsense", pfSenseUPnPDisco, uPnPDiscoResponse{
+ Location: "http://192.168.1.1:2189/rootDesc.xml",
+ }},
+ }
+ 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, _ := netaddr.FromStdIP(ts.Listener.Addr().(*net.TCPAddr).IP)
+ var logBuf bytes.Buffer
+ logf := func(format string, a ...interface{}) {
+ fmt.Fprintf(&logBuf, format, a...)
+ logBuf.WriteByte('\n')
+ }
+ c, err := getUPnPClient(context.Background(), logf, 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)
+ }
+ })
+ }
+}