diff options
Diffstat (limited to 'net/portmapper')
| -rw-r--r-- | net/portmapper/disabled_stubs.go | 4 | ||||
| -rw-r--r-- | net/portmapper/portmapper.go | 59 | ||||
| -rw-r--r-- | net/portmapper/upnp.go | 135 | ||||
| -rw-r--r-- | net/portmapper/upnp_test.go | 95 |
4 files changed, 232 insertions, 61 deletions
diff --git a/net/portmapper/disabled_stubs.go b/net/portmapper/disabled_stubs.go index fb1572e78..f7003b0ed 100644 --- a/net/portmapper/disabled_stubs.go +++ b/net/portmapper/disabled_stubs.go @@ -15,10 +15,6 @@ import ( type upnpClient interface{} -func getUPnPClient(ctx context.Context, gw netaddr.IP) (upnpClient, error) { - return nil, nil -} - func (c *Client) getUPnPPortMapping( ctx context.Context, gw netaddr.IP, diff --git a/net/portmapper/portmapper.go b/net/portmapper/portmapper.go index e9ddf27c9..595ad2e08 100644 --- a/net/portmapper/portmapper.go +++ b/net/portmapper/portmapper.go @@ -14,9 +14,11 @@ import ( "fmt" "io" "net" + "net/http" "sync" "time" + "go4.org/mem" "inet.af/netaddr" "tailscale.com/net/interfaces" "tailscale.com/net/netns" @@ -62,8 +64,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 // nil until needed localPort uint16 @@ -560,27 +565,9 @@ 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 @@ -595,11 +582,16 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) { } else { uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr) } + if c.sawUPnPRecently() { + res.UPnP = true + } else { + 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 +604,19 @@ 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") + } + // log.Printf("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 +729,13 @@ func parsePCPResponse(b []byte) (res pcpResponse, ok bool) { } var pmpReqExternalAddrPacket = []byte{0, 0} // version 0, opcode 0 = "Public address request" + +const ( + upnpPort = 1900 +) + +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..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 +} diff --git a/net/portmapper/upnp_test.go b/net/portmapper/upnp_test.go new file mode 100644 index 000000000..972ec50d9 --- /dev/null +++ b/net/portmapper/upnp_test.go @@ -0,0 +1,95 @@ +// 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 ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "reflect" + "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 + }{ + {"google", googleWifiRootDescXML, "*internetgateway2.WANIPConnection2"}, + {"pfsense", pfSenseRootDescXML, "*internetgateway2.WANIPConnection1"}, + // 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) + c, err := getUPnPClient(context.Background(), 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) + } + }) + } +} |
