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 <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2021-08-02 22:09:50 -07:00 committed by Brad Fitzpatrick
parent f013960d87
commit fdc081c291
9 changed files with 310 additions and 72 deletions

View File

@ -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/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 from github.com/peterbourgon/ff/v2/ffcli
github.com/peterbourgon/ff/v2/ffcli from tailscale.com/cmd/tailscale/cli 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/dcps/internetgateway2 from tailscale.com/net/portmapper
github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+ github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp

View File

@ -206,6 +206,22 @@ func debugPortmap(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second) ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() 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) done := make(chan bool, 1)
var c *portmapper.Client var c *portmapper.Client
@ -248,6 +264,13 @@ func debugPortmap(ctx context.Context) error {
} }
logf("gw=%v; self=%v", gw, selfIP) 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) res, err := c.Probe(ctx)
if err != nil { if err != nil {
return fmt.Errorf("Probe: %v", err) return fmt.Errorf("Probe: %v", err)
@ -259,13 +282,6 @@ func debugPortmap(ctx context.Context) error {
return nil 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 { if ext, ok := c.GetCachedMappingOrStartCreatingOne(); ok {
logf("mapping: %v", ext) logf("mapping: %v", ext)
} else { } else {

View File

@ -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 L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
W github.com/pkg/errors from github.com/tailscale/certstore W github.com/pkg/errors from github.com/tailscale/certstore
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient 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/dcps/internetgateway2 from tailscale.com/net/portmapper
github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+ github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp

2
go.mod
View File

@ -31,7 +31,7 @@ require (
github.com/pkg/sftp v1.13.0 github.com/pkg/sftp v1.13.0
github.com/tailscale/certstore v0.0.0-20210528134328-066c94b793d3 github.com/tailscale/certstore v0.0.0-20210528134328-066c94b793d3
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027 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/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2
github.com/tcnksm/go-httpstat v0.2.0 github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0 github.com/toqueteos/webbrowser v1.2.0

4
go.sum
View File

@ -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/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 h1:lK99QQdH3yBWY6aGilF+IRlQIdmhzLrsEmF6JgN+Ryw=
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= 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.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
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/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 h1:reREUgl2FG+o7YCsrZB8XLjnuKv5hEIWtnOdAbRAXZI=
github.com/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2/go.mod h1:STqf+YV0ADdzk4ejtXFsGqDpATP9JoL0OB+hiFQbkdE= 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= github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=

View File

@ -15,8 +15,10 @@
type upnpClient interface{} type upnpClient interface{}
func getUPnPClient(ctx context.Context, gw netaddr.IP) (upnpClient, error) { type uPnPDiscoResponse struct{}
return nil, nil
func parseUPnPDiscoResponse([]byte) (uPnPDiscoResponse, error) {
return uPnPDiscoResponse{}, nil
} }
func (c *Client) getUPnPPortMapping( func (c *Client) getUPnPPortMapping(

View File

@ -14,15 +14,25 @@
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http"
"sync" "sync"
"time" "time"
"go4.org/mem"
"inet.af/netaddr" "inet.af/netaddr"
"tailscale.com/net/interfaces" "tailscale.com/net/interfaces"
"tailscale.com/net/netns" "tailscale.com/net/netns"
"tailscale.com/types/logger" "tailscale.com/types/logger"
) )
// Debub knobs for "tailscaled debug --portmap".
var (
VerboseLogs bool
DisableUPnP bool
DisablePMP bool
DisablePCP bool
)
// References: // References:
// //
// NAT-PMP: https://tools.ietf.org/html/rfc6886 // NAT-PMP: https://tools.ietf.org/html/rfc6886
@ -62,8 +72,11 @@ type Client struct {
pmpPubIPTime time.Time // time pmpPubIP last verified pmpPubIPTime time.Time // time pmpPubIP last verified
pmpLastEpoch uint32 pmpLastEpoch uint32
pcpSawTime time.Time // time we last saw PCP was available pcpSawTime time.Time // time we last saw PCP was available
uPnPSawTime time.Time // time we last saw UPnP 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 localPort uint16
@ -210,6 +223,7 @@ func (c *Client) invalidateMappingsLocked(releaseOld bool) {
c.pmpPubIPTime = time.Time{} c.pmpPubIPTime = time.Time{}
c.pcpSawTime = time.Time{} c.pcpSawTime = time.Time{}
c.uPnPSawTime = time.Time{} c.uPnPSawTime = time.Time{}
c.uPnPMeta = uPnPDiscoResponse{}
} }
func (c *Client) sawPMPRecently() bool { 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 // find a PMP service, bail out early rather than probing
// again. Cuts down latency for most clients. // again. Cuts down latency for most clients.
haveRecentPMP := c.sawPMPRecentlyLocked() haveRecentPMP := c.sawPMPRecentlyLocked()
if haveRecentPMP { if haveRecentPMP {
m.external = m.external.WithIP(c.pmpPubIP) 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 cancel()
defer closeCloserOnContextDone(ctx, uc)() 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() pcpAddr := netaddr.IPPortFrom(gw, pcpPort).UDPAddr()
pmpAddr := netaddr.IPPortFrom(gw, pmpPort).UDPAddr() pmpAddr := netaddr.IPPortFrom(gw, pmpPort).UDPAddr()
upnpAddr := netaddr.IPPortFrom(gw, upnpPort).UDPAddr()
// Don't send probes to services that we recently learned (for // Don't send probes to services that we recently learned (for
// the same gw/myIP) are available. See // the same gw/myIP) are available. See
// https://github.com/tailscale/tailscale/issues/1001 // https://github.com/tailscale/tailscale/issues/1001
if c.sawPMPRecently() { if c.sawPMPRecently() {
res.PMP = true res.PMP = true
} else { } else if !DisablePMP {
uc.WriteTo(pmpReqExternalAddrPacket, pmpAddr) uc.WriteTo(pmpReqExternalAddrPacket, pmpAddr)
} }
if c.sawPCPRecently() { if c.sawPCPRecently() {
res.PCP = true res.PCP = true
} else { } else if !DisablePCP {
uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr) uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr)
} }
if c.sawUPnPRecently() {
res.UPnP = true
} else if !DisableUPnP {
uc.WriteTo(uPnPPacket, upnpAddr)
}
buf := make([]byte, 1500) buf := make([]byte, 1500)
pcpHeard := false // true when we get any PCP response pcpHeard := false // true when we get any PCP response
for { for {
if pcpHeard && res.PMP { if pcpHeard && res.PMP && res.UPnP {
// Nothing more to discover. // Nothing more to discover.
return res, nil return res, nil
} }
@ -612,6 +614,21 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
} }
port := addr.(*net.UDPAddr).Port port := addr.(*net.UDPAddr).Port
switch 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 case pcpPort: // same as pmpPort
if pres, ok := parsePCPResponse(buf[:n]); ok { if pres, ok := parsePCPResponse(buf[:n]); ok {
if pres.OpCode == pcpOpReply|pcpOpAnnounce { 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" 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")

View File

@ -8,15 +8,22 @@
package portmapper package portmapper
import ( import (
"bufio"
"bytes"
"context" "context"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http"
"net/url" "net/url"
"strings"
"time" "time"
"github.com/tailscale/goupnp"
"github.com/tailscale/goupnp/dcps/internetgateway2" "github.com/tailscale/goupnp/dcps/internetgateway2"
"inet.af/netaddr" "inet.af/netaddr"
"tailscale.com/control/controlknobs" "tailscale.com/control/controlknobs"
"tailscale.com/net/netns"
"tailscale.com/types/logger"
) )
// References: // References:
@ -44,7 +51,8 @@ func (u *upnpMapping) Release(ctx context.Context) {
} }
// upnpClient is an interface over the multiple different clients exported by goupnp, // 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 { type upnpClient interface {
AddPortMapping( AddPortMapping(
ctx context.Context, 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 // 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. // 604800 seconds, but not sure why this is desired. The recommended time is 3600 seconds.
leaseDurationSec uint32, leaseDurationSec uint32,
) (err error) ) error
DeletePortMapping(ctx context.Context, remoteHost string, externalPort uint16, protocol string) error DeletePortMapping(ctx context.Context, remoteHost string, externalPort uint16, protocol string) error
GetExternalIPAddress(ctx context.Context) (externalIPAddress string, err 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. // 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), // It returns the new external port (which may not be identical to the external port specified),
// or an error. // or an error.
//
// TODO(bradfitz): also returned the actual lease duration obtained. and check it regularly.
func addAnyPortMapping( func addAnyPortMapping(
ctx context.Context, ctx context.Context,
upnp upnpClient, upnp upnpClient,
@ -130,51 +140,89 @@ func addAnyPortMapping(
return externalPort, err 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. // now.
// Adapted from https://github.com/huin/goupnp/blob/master/GUIDE.md. // 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() { if controlknobs.DisableUPnP() {
return nil, nil 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 if meta.Location == "" {
u, err := url.Parse(fmt.Sprintf("http://%s:5000/rootDesc.xml", gw)) return nil, nil
}
if VerboseLogs {
logf("fetching %v", meta.Location)
}
u, err := url.Parse(meta.Location)
if err != nil { if err != nil {
return nil, err return nil, err
} }
clients := make(chan upnpClient, 3) ipp, err := netaddr.ParseIPPort(u.Host)
go func() { if err != nil {
var err error return nil, fmt.Errorf("unexpected host %q in %q", u.Host, meta.Location)
ip1Clients, err := internetgateway2.NewWANIPConnection1ClientsByURL(ctx, u) }
if err == nil && len(ip1Clients) > 0 { if ipp.IP() != gw {
clients <- ip1Clients[0] return nil, fmt.Errorf("UPnP discovered root %q does not match gateway IP %v; ignoring UPnP",
} meta.Location, gw)
}() }
go func() {
ip2Clients, err := internetgateway2.NewWANIPConnection2ClientsByURL(ctx, u) // We're fetching a smallish XML document over plain HTTP
if err == nil && len(ip2Clients) > 0 { // across the local LAN, without using DNS. There should be
clients <- ip2Clients[0] // very few round trips and low latency, so one second is a
} // long time.
}() ctx, cancel := context.WithTimeout(ctx, time.Second)
go func() { defer cancel()
ppp1Clients, err := internetgateway2.NewWANPPPConnection1ClientsByURL(ctx, u)
if err == nil && len(ppp1Clients) > 0 { // This part does a network fetch.
clients <- ppp1Clients[0] 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 { // These parts don't do a network fetch.
case client := <-clients: // Pick the best service type available.
return client, nil if cc, _ := internetgateway2.NewWANIPConnection2ClientsFromRootDevice(ctx, root, u); len(cc) > 0 {
case <-ctx.Done(): return cc[0], nil
return nil, ctx.Err()
} }
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, // getUPnPPortMapping attempts to create a port-mapping over the UPnP protocol. On success,
@ -199,11 +247,17 @@ func (c *Client) getUPnPPortMapping(
var err error var err error
c.mu.Lock() c.mu.Lock()
oldMapping, ok := c.mapping.(*upnpMapping) oldMapping, ok := c.mapping.(*upnpMapping)
meta := c.uPnPMeta
httpClient := c.upnpHTTPClientLocked()
c.mu.Unlock() c.mu.Unlock()
if ok && oldMapping != nil { if ok && oldMapping != nil {
client = oldMapping.client client = oldMapping.client
} else { } 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 { if err != nil {
return netaddr.IPPort{}, false return netaddr.IPPort{}, false
} }
@ -221,11 +275,17 @@ func (c *Client) getUPnPPortMapping(
internal.IP().String(), internal.IP().String(),
time.Second*pmpMapLifetimeSec, time.Second*pmpMapLifetimeSec,
) )
if VerboseLogs {
c.logf("addAnyPortMapping: %v, %v", newPort, err)
}
if err != nil { if err != nil {
return netaddr.IPPort{}, false return netaddr.IPPort{}, false
} }
// TODO cache this ip somewhere? // TODO cache this ip somewhere?
extIP, err := client.GetExternalIPAddress(ctx) extIP, err := client.GetExternalIPAddress(ctx)
if VerboseLogs {
c.logf("client.GetExternalIPAddress: %v, %v", extIP, err)
}
if err != nil { if err != nil {
// TODO this doesn't seem right // TODO this doesn't seem right
return netaddr.IPPort{}, false return netaddr.IPPort{}, false
@ -246,3 +306,18 @@ func (c *Client) getUPnPPortMapping(
c.localPort = newPort c.localPort = newPort
return upnp.external, true 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
}

117
net/portmapper/upnp_test.go Normal file
View File

@ -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 = `<?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
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 = `<?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>`
)
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)
}
})
}
}