From fdc081c291fbb1c885784f4930741f12ed932230 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Mon, 2 Aug 2021 22:09:50 -0700 Subject: [PATCH] net/portmapper: fix UPnP probing, work against all ports Prior to Tailscale 1.12 it detected UPnP on any port. Starting with Tailscale 1.11.x, it stopped detecting UPnP on all ports. Then start plumbing its discovered Location header port number to the code that was assuming port 5000. Fixes #2109 Signed-off-by: Brad Fitzpatrick --- cmd/tailscale/depaware.txt | 2 +- cmd/tailscaled/debug.go | 30 +++++-- cmd/tailscaled/depaware.txt | 2 +- go.mod | 2 +- go.sum | 4 +- net/portmapper/disabled_stubs.go | 6 +- net/portmapper/portmapper.go | 76 ++++++++++------ net/portmapper/upnp.go | 143 +++++++++++++++++++++++-------- net/portmapper/upnp_test.go | 117 +++++++++++++++++++++++++ 9 files changed, 310 insertions(+), 72 deletions(-) create mode 100644 net/portmapper/upnp_test.go 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) + } + }) + } +}