diff options
Diffstat (limited to 'net/portmapper/upnp.go')
| -rw-r--r-- | net/portmapper/upnp.go | 135 |
1 files changed, 100 insertions, 35 deletions
diff --git a/net/portmapper/upnp.go b/net/portmapper/upnp.go index 5ec91353e..9d3deb414 100644 --- a/net/portmapper/upnp.go +++ b/net/portmapper/upnp.go @@ -8,17 +8,27 @@ package portmapper import ( + "bufio" + "bytes" "context" "fmt" + "log" "math/rand" + "net/http" "net/url" "time" + "github.com/tailscale/goupnp" "github.com/tailscale/goupnp/dcps/internetgateway2" "inet.af/netaddr" "tailscale.com/control/controlknobs" + "tailscale.com/net/netns" ) +// VerboseLogs controls verbose debug logging. +// It exists for use by "tailscaled debug --portmap". +var VerboseLogs bool + // References: // // WANIP Connection v2: http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v2-Service.pdf @@ -44,7 +54,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 +88,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 +103,8 @@ const tsPortMappingDesc = "tailscale-portmap" // 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 +143,76 @@ 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, gw netaddr.IP, meta uPnPDiscoResponse) (upnpClient, error) { if controlknobs.DisableUPnP() { return nil, nil } - ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond) + + if meta.Location == "" { + return nil, nil + } + + if VerboseLogs { + log.Printf("fetching %v", meta.Location) + } + u, err := url.Parse(meta.Location) + if err != nil { + return nil, err + } + + 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) + } + + ctx, cancel := context.WithTimeout(ctx, 500*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)) + // This part does a network fetch. + root, err := goupnp.DeviceByURL(ctx, u) 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] - } - }() + // 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 +} - select { - case client := <-clients: - return client, nil - case <-ctx.Done(): - return nil, ctx.Err() +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 +237,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, gw, meta) + if VerboseLogs { + log.Printf("getUPnPClient: %T, %v", client, err) + } if err != nil { return netaddr.IPPort{}, false } @@ -221,11 +265,17 @@ func (c *Client) getUPnPPortMapping( internal.IP().String(), time.Second*pmpMapLifetimeSec, ) + if VerboseLogs { + log.Printf("addAnyPortMapping: %v, %v", newPort, err) + } if err != nil { return netaddr.IPPort{}, false } // TODO cache this ip somewhere? extIP, err := client.GetExternalIPAddress(ctx) + if VerboseLogs { + log.Printf("client.GetExternalIPAddress: %v, %v", extIP, err) + } if err != nil { // TODO this doesn't seem right return netaddr.IPPort{}, false @@ -246,3 +296,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 +} |
