From caceeff374454d349a49a38265643c03db24b177 Mon Sep 17 00:00:00 2001 From: julianknodt Date: Fri, 11 Jun 2021 14:14:48 -0700 Subject: [PATCH] net/portmapper: add stateful prober Previously, the prober was stateless, and probe needed to be called manually whenever additional probes were required. This adds a stateful prober, which can theoretically reuse clients between runs and have smarter strategies for delays between retries, which may be crucial depending on how slow UPnP is. Signed-off-by: julianknodt --- cmd/tailscale/depaware.txt | 24 +- cmd/tailscaled/depaware.txt | 23 +- go.mod | 3 +- go.sum | 6 + net/portmapper/portmapper.go | 138 +-------- net/portmapper/portmapper_test.go | 10 +- net/portmapper/probe.go | 447 ++++++++++++++++++++++++++++++ net/portmapper/probe_test.go | 26 ++ net/portmapper/upnp.go | 120 ++++++++ 9 files changed, 657 insertions(+), 140 deletions(-) create mode 100644 net/portmapper/probe.go create mode 100644 net/portmapper/probe_test.go create mode 100644 net/portmapper/upnp.go diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index d74464198..8c88db568 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -3,6 +3,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate+ W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy + github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2 + github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper + github.com/huin/goupnp/httpu from github.com/huin/goupnp+ + github.com/huin/goupnp/scpd from github.com/huin/goupnp + github.com/huin/goupnp/soap from github.com/huin/goupnp+ + github.com/huin/goupnp/ssdp from github.com/huin/goupnp github.com/kballard/go-shellquote 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 @@ -70,18 +76,32 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep golang.org/x/crypto/poly1305 from golang.org/x/crypto/chacha20poly1305+ golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ golang.org/x/net/dns/dnsmessage from net + golang.org/x/net/html from golang.org/x/net/html/charset + golang.org/x/net/html/atom from golang.org/x/net/html + golang.org/x/net/html/charset from github.com/huin/goupnp golang.org/x/net/http/httpguts from net/http+ golang.org/x/net/http/httpproxy from net/http golang.org/x/net/http2/hpack from net/http golang.org/x/net/idna from golang.org/x/net/http/httpguts+ golang.org/x/net/proxy from tailscale.com/net/netns D golang.org/x/net/route from net+ - golang.org/x/sync/errgroup from tailscale.com/derp + golang.org/x/sync/errgroup from tailscale.com/derp+ golang.org/x/sync/singleflight from tailscale.com/net/dnscache golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+ LD golang.org/x/sys/unix from tailscale.com/net/netns+ W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+ W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg + golang.org/x/text/encoding from golang.org/x/net/html/charset+ + golang.org/x/text/encoding/charmap from golang.org/x/net/html/charset+ + golang.org/x/text/encoding/htmlindex from golang.org/x/net/html/charset + golang.org/x/text/encoding/internal from golang.org/x/text/encoding/charmap+ + golang.org/x/text/encoding/japanese from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/korean from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/simplifiedchinese from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/traditionalchinese from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/unicode from golang.org/x/text/encoding/htmlindex + golang.org/x/text/language from golang.org/x/text/encoding/htmlindex + golang.org/x/text/runes from golang.org/x/text/encoding/unicode golang.org/x/text/secure/bidirule from golang.org/x/net/idna golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ @@ -126,7 +146,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep encoding/hex from crypto/x509+ encoding/json from expvar+ encoding/pem from crypto/tls+ - encoding/xml from tailscale.com/cmd/tailscale/cli + encoding/xml from tailscale.com/cmd/tailscale/cli+ errors from bufio+ expvar from tailscale.com/derp+ flag from github.com/peterbourgon/ff/v2+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 6f6e3f1e4..56846e41f 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -10,6 +10,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns github.com/golang/snappy from github.com/klauspost/compress/zstd github.com/google/btree from inet.af/netstack/tcpip/header+ + github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2 + github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper + github.com/huin/goupnp/httpu from github.com/huin/goupnp+ + github.com/huin/goupnp/scpd from github.com/huin/goupnp + github.com/huin/goupnp/soap from github.com/huin/goupnp+ + github.com/huin/goupnp/ssdp from github.com/huin/goupnp L github.com/josharian/native from github.com/mdlayher/netlink+ L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/wgengine/monitor L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink @@ -170,6 +176,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ golang.org/x/net/bpf from github.com/mdlayher/netlink+ golang.org/x/net/dns/dnsmessage from net+ + golang.org/x/net/html from golang.org/x/net/html/charset + golang.org/x/net/html/atom from golang.org/x/net/html + golang.org/x/net/html/charset from github.com/huin/goupnp golang.org/x/net/http/httpguts from net/http+ golang.org/x/net/http/httpproxy from net/http golang.org/x/net/http2/hpack from net/http @@ -178,7 +187,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de golang.org/x/net/ipv6 from golang.zx2c4.com/wireguard/device+ golang.org/x/net/proxy from tailscale.com/net/netns D golang.org/x/net/route from net+ - golang.org/x/sync/errgroup from tailscale.com/derp + golang.org/x/sync/errgroup from tailscale.com/derp+ golang.org/x/sync/singleflight from tailscale.com/net/dnscache golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+ LD golang.org/x/sys/unix from github.com/mdlayher/netlink+ @@ -187,6 +196,17 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de W golang.org/x/sys/windows/svc from tailscale.com/cmd/tailscaled+ W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled golang.org/x/term from tailscale.com/logpolicy + golang.org/x/text/encoding from golang.org/x/net/html/charset+ + golang.org/x/text/encoding/charmap from golang.org/x/net/html/charset+ + golang.org/x/text/encoding/htmlindex from golang.org/x/net/html/charset + golang.org/x/text/encoding/internal from golang.org/x/text/encoding/charmap+ + golang.org/x/text/encoding/japanese from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/korean from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/simplifiedchinese from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/traditionalchinese from golang.org/x/text/encoding/htmlindex + golang.org/x/text/encoding/unicode from golang.org/x/text/encoding/htmlindex + golang.org/x/text/language from golang.org/x/text/encoding/htmlindex + golang.org/x/text/runes from golang.org/x/text/encoding/unicode golang.org/x/text/secure/bidirule from golang.org/x/net/idna golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ @@ -232,6 +252,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de encoding/hex from crypto/x509+ encoding/json from expvar+ encoding/pem from crypto/tls+ + encoding/xml from github.com/huin/goupnp+ errors from bufio+ expvar from tailscale.com/derp+ flag from tailscale.com/cmd/tailscaled+ diff --git a/go.mod b/go.mod index cec93c137..114bd7fc6 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/google/goexpect v0.0.0-20210430020637-ab937bf7fd6f github.com/google/uuid v1.1.2 github.com/goreleaser/nfpm v1.10.3 + github.com/huin/goupnp v1.0.0 // indirect github.com/jsimonetti/rtnetlink v0.0.0-20210525051524-4cc836578190 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/klauspost/compress v1.12.2 @@ -32,7 +33,7 @@ require ( github.com/toqueteos/webbrowser v1.2.0 go4.org/mem v0.0.0-20201119185036-c04c5a6ff174 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a - golang.org/x/net v0.0.0-20210525063256-abc453219eb5 + golang.org/x/net v0.0.0-20210610132358-84b48f89b13b golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sys v0.0.0-20210608053332-aa57babbf139 golang.org/x/term v0.0.0-20210503060354-a79de5458b56 diff --git a/go.sum b/go.sum index d8401ac69..8c970d2a8 100644 --- a/go.sum +++ b/go.sum @@ -296,6 +296,9 @@ github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/J github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huin/goupnp v1.0.0 h1:wg75sLpL6DZqwHQN6E1Cfk6mtfzS45z8OV+ic+DtHRo= +github.com/huin/goupnp v1.0.0/go.mod h1:n9v9KO1tAxYH82qOn+UTIFQDmx5n1Zxd/ClZDMX7Bnc= +github.com/huin/goutil v0.0.0-20170803182201-1ca381bf3150/go.mod h1:PpLOETDnJ0o3iZrZfqZzyLl6l7F3c6L1oWn7OICBi6o= github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -685,6 +688,7 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -718,6 +722,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5 h1:wjuX4b5yYQnEQHzd+CBcrcC6OVR2J1CN6mUy0oSxIPo= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b h1:k+E048sYJHyVnsr1GDrRZWQ32D2C7lWs9JRc0bel53A= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/net/portmapper/portmapper.go b/net/portmapper/portmapper.go index 19a2931d6..1680053e3 100644 --- a/net/portmapper/portmapper.go +++ b/net/portmapper/portmapper.go @@ -17,7 +17,6 @@ import ( "sync" "time" - "go4.org/mem" "inet.af/netaddr" "tailscale.com/net/interfaces" "tailscale.com/net/netns" @@ -377,6 +376,12 @@ const ( pmpCodeUnsupportedOpcode pmpResultCode = 5 ) +type ProbeResult struct { + PCP bool + PMP bool + UPnP bool +} + func buildPMPRequestMappingPacket(localPort, prevPort uint16, lifetimeSec uint32) (pkt []byte) { pkt = make([]byte, 12) @@ -433,127 +438,6 @@ func parsePMPResponse(pkt []byte) (res pmpResponse, ok bool) { return res, true } -type ProbeResult struct { - PCP bool - PMP bool - UPnP bool -} - -// Probe returns a summary of which port mapping services are -// available on the network. -// -// If a probe has run recently and there haven't been any network changes since, -// the returned result might be server from the Client's cache, without -// sending any network traffic. -func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) { - gw, myIP, ok := c.gatewayAndSelfIP() - if !ok { - return res, ErrGatewayNotFound - } - defer func() { - if err == nil { - c.mu.Lock() - defer c.mu.Unlock() - c.lastProbe = time.Now() - } - }() - - uc, err := netns.Listener().ListenPacket(context.Background(), "udp4", ":0") - if err != nil { - c.logf("ProbePCP: %v", err) - return res, err - } - defer uc.Close() - ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond) - defer cancel() - defer closeCloserOnContextDone(ctx, uc)() - - 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 { - uc.WriteTo(pmpReqExternalAddrPacket, pmpAddr) - } - if c.sawPCPRecently() { - res.PCP = true - } 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 && res.UPnP { - // Nothing more to discover. - return res, nil - } - n, addr, err := uc.ReadFrom(buf) - if err != nil { - if ctx.Err() == context.DeadlineExceeded { - err = nil - } - return res, err - } - port := addr.(*net.UDPAddr).Port - switch port { - case upnpPort: - if mem.Contains(mem.B(buf[:n]), mem.S(":InternetGatewayDevice:")) { - res.UPnP = true - c.mu.Lock() - c.uPnPSawTime = time.Now() - c.mu.Unlock() - } - case pcpPort: // same as pmpPort - if pres, ok := parsePCPResponse(buf[:n]); ok { - if pres.OpCode == pcpOpReply|pcpOpAnnounce { - pcpHeard = true - c.mu.Lock() - c.pcpSawTime = time.Now() - c.mu.Unlock() - switch pres.ResultCode { - case pcpCodeOK: - c.logf("Got PCP response: epoch: %v", pres.Epoch) - res.PCP = true - continue - case pcpCodeNotAuthorized: - // A PCP service is running, but refuses to - // provide port mapping services. - res.PCP = false - continue - default: - // Fall through to unexpected log line. - } - } - c.logf("unexpected PCP probe response: %+v", pres) - } - if pres, ok := parsePMPResponse(buf[:n]); ok { - if pres.OpCode == pmpOpReply|pmpOpMapPublicAddr && pres.ResultCode == pmpCodeOK { - c.logf("Got PMP response; IP: %v, epoch: %v", pres.PublicAddr, pres.SecondsSinceEpoch) - res.PMP = true - c.mu.Lock() - c.pmpPubIP = pres.PublicAddr - c.pmpPubIPTime = time.Now() - c.pmpLastEpoch = pres.SecondsSinceEpoch - c.mu.Unlock() - continue - } - c.logf("unexpected PMP probe response: %+v", pres) - } - } - } -} - const ( pcpVersion = 2 pcpPort = 5351 @@ -627,14 +511,4 @@ func parsePCPResponse(b []byte) (res pcpResponse, ok bool) { return res, true } -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") - var pmpReqExternalAddrPacket = []byte{0, 0} // version 0, opcode 0 = "Public address request" diff --git a/net/portmapper/portmapper_test.go b/net/portmapper/portmapper_test.go index 13673dec4..b9c2a5935 100644 --- a/net/portmapper/portmapper_test.go +++ b/net/portmapper/portmapper_test.go @@ -32,12 +32,13 @@ func TestClientProbe(t *testing.T) { t.Skip("skipping test without HIT_NETWORK=1") } c := NewClient(t.Logf) - for i := 0; i < 2; i++ { + c.NewProber(context.Background()) + for i := 0; i < 30; i++ { if i > 0 { time.Sleep(100 * time.Millisecond) } - res, err := c.Probe(context.Background()) - t.Logf("Got: %+v, %v", res, err) + res, err := c.Prober.CurrentStatus() + t.Logf("Got(t=%dms): %+v, %v", i*100, res, err) } } @@ -47,7 +48,8 @@ func TestClientProbeThenMap(t *testing.T) { } c := NewClient(t.Logf) c.SetLocalPort(1234) - res, err := c.Probe(context.Background()) + c.NewProber(context.Background()) + res, err := c.Prober.StatusBlock() t.Logf("Probe: %+v, %v", res, err) ext, err := c.CreateOrGetMapping(context.Background()) t.Logf("CreateOrGetMapping: %v, %v", ext, err) diff --git a/net/portmapper/probe.go b/net/portmapper/probe.go new file mode 100644 index 000000000..757580363 --- /dev/null +++ b/net/portmapper/probe.go @@ -0,0 +1,447 @@ +// 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" + "sync" + "time" + + "inet.af/netaddr" + "tailscale.com/net/netns" +) + +type ProbeResult struct { + PCP bool + PMP bool + UPnP bool +} + +// Probe returns a summary of which port mapping services are +// available on the network. +// +// If a probe has run recently and there haven't been any network changes since, +// the returned result might be served from the Client's cache, without +// sending any network traffic. +func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) { + gw, myIP, ok := c.gatewayAndSelfIP() + if !ok { + return res, ErrGatewayNotFound + } + defer func() { + if err == nil { + c.mu.Lock() + c.lastProbe = time.Now() + c.mu.Unlock() + } + }() + + uc, err := netns.Listener().ListenPacket(ctx, "udp4", ":0") + if err != nil { + c.logf("ProbePCP: %v", err) + return res, err + } + defer uc.Close() + ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond) + defer cancel() + defer closeCloserOnContextDone(ctx, uc)() + + pcpAddr := netaddr.IPPortFrom(gw, pcpPort).UDPAddr() + pmpAddr := netaddr.IPPortFrom(gw, pmpPort).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 + wg := sync.WaitGroup{} + defer wg.Wait() + if c.sawUPnPRecently() { + res.UPnP = true + } else { + wg.Add(1) + go func() { + // TODO(jknodt) this is expensive, maybe it's worth caching it and just reusing it + // more aggressively + hasUPnP, _ := probeUPnP(ctx) + if hasUPnP { + res.UPnP = true + c.mu.Lock() + c.uPnPSawTime = time.Now() + c.mu.Unlock() + } + wg.Done() + }() + } + if c.sawPMPRecently() { + res.PMP = true + } else { + uc.WriteTo(pmpReqExternalAddrPacket, pmpAddr) + } + if c.sawPCPRecently() { + res.PCP = true + } else { + uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr) + } + + buf := make([]byte, 1500) + pcpHeard := false // true when we get any PCP response + for { + if pcpHeard && res.PMP { + // Nothing more to discover. + return res, nil + } + n, _, err := uc.ReadFrom(buf) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + err = nil + } + return res, err + } + if pres, ok := parsePCPResponse(buf[:n]); ok { + if pres.OpCode == pcpOpReply|pcpOpAnnounce { + pcpHeard = true + c.mu.Lock() + c.pcpSawTime = time.Now() + c.mu.Unlock() + switch pres.ResultCode { + case pcpCodeOK: + c.logf("Got PCP response: epoch: %v", pres.Epoch) + res.PCP = true + continue + case pcpCodeNotAuthorized: + // A PCP service is running, but refuses to + // provide port mapping services. + res.PCP = false + continue + default: + // Fall through to unexpected log line. + } + } + c.logf("unexpected PCP probe response: %+v", pres) + } + if pres, ok := parsePMPResponse(buf[:n]); ok { + if pres.OpCode == pmpOpReply|pmpOpMapPublicAddr && pres.ResultCode == pmpCodeOK { + c.logf("Got PMP response; IP: %v, epoch: %v", pres.PublicAddr, pres.SecondsSinceEpoch) + res.PMP = true + c.mu.Lock() + c.pmpPubIP = pres.PublicAddr + c.pmpPubIPTime = time.Now() + c.pmpLastEpoch = pres.SecondsSinceEpoch + c.mu.Unlock() + continue + } + c.logf("unexpected PMP probe response: %+v", pres) + } + } +} + +type Prober struct { + // pause signals the probe to either pause temporarily (true), or stop entirely (false) + // to restart the probe, send another pause to it. + pause chan<- bool + + PMP *ProbeSubResult + PCP *ProbeSubResult + + upnpClient upnpClient + UPnP *ProbeSubResult +} + +// NewProber creates a new prober for a given client. +func (c *Client) NewProber(ctx context.Context) (p *Prober) { + stop := make(chan bool) + p = &Prober{ + stop: stop, + + PMP: NewProbeSubResult(), + PCP: NewProbeSubResult(), + UPnP: NewProbeSubResult(), + } + + go func() { + for { + pmp_ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond) + hasPCP, hasPMP, err := c.probePMPAndPCP(pmp_ctx) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + err = nil + // the global context has passed, exit cleanly + cancel() + return + } + if pmp_ctx.Err() == context.DeadlineExceeded { + err = nil + } + } + cancel() + p.PMP.Set(hasPMP, err) + p.PCP.Set(hasPCP, err) + + t := time.NewTimer(trustServiceStillAvailableDuration * 3 / 4) + + select { + case should_pause := <-pause: + if !should_pause { + t.Stop() + return + } + restart := <-pause + if !restart { + t.Stop() + return + } + case <-t.C: // break through and retry the connection + } + } + }() + + go func() { + // Do not timeout on getting an initial client, as we can reuse it so paying an initial cost + // is fine. + upnpClient, err := getUPnPClient(ctx) + if upnpClient == nil || err != nil { + p.UPnP.Set(false, err) + return + } + p.upnpClient = upnpClient + defer func() { + // unset client when no longer using it. + p.upnpClient = nil + upnpClient.RequestTermination() + }() + // TODO maybe do something fancy/dynamic with more delay (exponential back-off) + for { + upnp_ctx, cancel := context.WithTimeout(ctx, 6*time.Second) + retries := 0 + hasUPnP := false + const num_connect_retries = 5 + for retries < num_connect_retries { + status, _, _, statusErr := p.upnpClient.GetStatusInfo() + if statusErr != nil { + err = statusErr + break + } + hasUPnP = hasUPnP || status == "Connected" + if status == "Disconnected" { + upnpClient.RequestConnection() + } + retries += 1 + } + // need to manually check these since GetStatusInfo doesn't take a context + if ctx.Err() == context.DeadlineExceeded { + err = nil + // the global context has passed, exit cleanly + cancel() + return + } + if upnp_ctx.Err() == context.DeadlineExceeded { + err = nil + } + cancel() + p.UPnP.Set(hasUPnP, err) + + t := time.NewTimer(trustServiceStillAvailableDuration * 3 / 4) + + select { + case should_pause := <-pause: + if !should_pause { + t.Stop() + return + } + restart := <-pause + if !restart { + t.Stop() + return + } + case <-t.C: // break through and retry the connection + } + } + }() + + return +} + +// Stop gracefully turns the Prober off. +func (p *Prober) Stop() { + close(p.pause) +} + +// Pauses the prober if currently running, or starts if it was previously paused +func (p *Prober) Toggle() { + p.pause <- true +} + +// CurrentStatus returns the current results of the prober, regardless of whether they have +// completed or not. +func (p *Prober) CurrentStatus() (res ProbeResult, err error) { + hasPMP, errPMP := p.PMP.PresentCurrent() + res.PMP = hasPMP + err = errPMP + + hasUPnP, errUPnP := p.UPnP.PresentCurrent() + res.UPnP = hasUPnP + if err == nil { + err = errUPnP + } + + hasPCP, errPCP := p.PCP.PresentCurrent() + res.PCP = hasPCP + if err == nil { + err = errPCP + } + return +} + +func (p *Prober) StatusBlock() (res ProbeResult, err error) { + hasPMP, errPMP := p.PMP.PresentBlock() + res.PMP = hasPMP + err = errPMP + + hasUPnP, errUPnP := p.UPnP.PresentBlock() + res.UPnP = hasUPnP + if err == nil { + err = errUPnP + } + + hasPCP, errPCP := p.PCP.PresentBlock() + res.PCP = hasPCP + if err == nil { + err = errPCP + } + return +} + +type ProbeSubResult struct { + cond *sync.Cond + // If this probe has finished, regardless of success or failure + completed bool + + // whether or not this feature is present + present bool + // most recent error + err error + + // time we last saw it to be available. + sawTime time.Time +} + +func NewProbeSubResult() *ProbeSubResult { + return &ProbeSubResult{ + cond: &sync.Cond{ + L: &sync.Mutex{}, + }, + } +} + +// PresentBlock blocks until the probe completes, then returns the result. +func (psr *ProbeSubResult) PresentBlock() (bool, error) { + psr.cond.L.Lock() + defer psr.cond.L.Unlock() + for !psr.completed { + psr.cond.Wait() + } + return psr.present, psr.err +} + +// PresentCurrent returns the current state, regardless whether or not the probe has completed. +func (psr *ProbeSubResult) PresentCurrent() (bool, error) { + psr.cond.L.Lock() + defer psr.cond.L.Unlock() + present := psr.present && psr.sawTime.After(time.Now().Add(-trustServiceStillAvailableDuration)) + return present, psr.err +} + +func (psr *ProbeSubResult) Set(present bool, err error) { + saw := time.Now() + psr.cond.L.Lock() + psr.sawTime = saw + psr.completed = true + psr.err = err + psr.present = present + psr.cond.L.Unlock() + + psr.cond.Broadcast() +} + +func (c *Client) probePMPAndPCP(ctx context.Context) (pcp bool, pmp bool, err error) { + gw, myIP, ok := c.gatewayAndSelfIP() + if !ok { + return false, false, ErrGatewayNotFound + } + + uc, err := netns.Listener().ListenPacket(ctx, "udp4", ":0") + if err != nil { + c.logf("ProbePCP/PMP: %v", err) + return false, false, err + } + defer uc.Close() + defer closeCloserOnContextDone(ctx, uc)() + + pcpAddr := netaddr.IPPortFrom(gw, pcpPort).UDPAddr() + pmpAddr := netaddr.IPPortFrom(gw, pmpPort).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() { + pmp = true + } else { + uc.WriteTo(pmpReqExternalAddrPacket, pmpAddr) + } + if c.sawPCPRecently() { + pcp = true + } else { + uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr) + } + + buf := make([]byte, 1500) + pcpHeard := false // true when we get any PCP response + for { + if pcpHeard && pmp { + // Nothing more to discover. + return + } + n, _, err := uc.ReadFrom(buf) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + err = nil + } + return pcp, pmp, err + } + if pres, ok := parsePCPResponse(buf[:n]); ok { + if pres.OpCode == pcpOpReply|pcpOpAnnounce { + pcpHeard = true + c.mu.Lock() + c.pcpSawTime = time.Now() + c.mu.Unlock() + switch pres.ResultCode { + case pcpCodeOK: + c.logf("Got PCP response: epoch: %v", pres.Epoch) + pcp = true + continue + case pcpCodeNotAuthorized: + // A PCP service is running, but refuses to + // provide port mapping services. + pcp = false + continue + default: + // Fall through to unexpected log line. + } + } + c.logf("unexpected PCP probe response: %+v", pres) + } + if pres, ok := parsePMPResponse(buf[:n]); ok { + if pres.OpCode == pmpOpReply|pmpOpMapPublicAddr && pres.ResultCode == pmpCodeOK { + c.logf("Got PMP response; IP: %v, epoch: %v", pres.PublicAddr, pres.SecondsSinceEpoch) + pmp = true + c.mu.Lock() + c.pmpPubIP = pres.PublicAddr + c.pmpPubIPTime = time.Now() + c.pmpLastEpoch = pres.SecondsSinceEpoch + c.mu.Unlock() + continue + } + c.logf("unexpected PMP probe response: %+v", pres) + } + } +} diff --git a/net/portmapper/probe_test.go b/net/portmapper/probe_test.go new file mode 100644 index 000000000..48c926530 --- /dev/null +++ b/net/portmapper/probe_test.go @@ -0,0 +1,26 @@ +// 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" + "os" + "strconv" + "testing" + "time" +) + +func TestClientProber(t *testing.T) { + if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v { + t.Skip("skipping test without HIT_NETWORK=1") + } + c := NewClient(t.Logf) + ctx := context.Background() + prober := c.NewProber(ctx) + time.Sleep(3 * time.Second) + prober.Stop() + res, err := prober.CurrentStatus() + t.Logf("Got: %+v, %v", res, err) +} diff --git a/net/portmapper/upnp.go b/net/portmapper/upnp.go new file mode 100644 index 000000000..98ab9f430 --- /dev/null +++ b/net/portmapper/upnp.go @@ -0,0 +1,120 @@ +// 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" + "sync" + + "github.com/huin/goupnp/dcps/internetgateway2" + "golang.org/x/sync/errgroup" +) + +// probeUPnP returns true if there are any upnp clients, or false with an error if none can be +// found. +func probeUPnP(ctx context.Context) (bool, error) { + wg := sync.WaitGroup{} + any := make(chan bool) + errChan := make(chan error) + wg.Add(3) + go func() { + ip1Clients, _, err := internetgateway2.NewWANIPConnection1Clients() + if len(ip1Clients) > 0 { + any <- true + } + wg.Done() + wg.Wait() + errChan <- err + }() + go func() { + ip2Clients, _, err := internetgateway2.NewWANIPConnection2Clients() + if len(ip2Clients) > 0 { + any <- true + } + wg.Done() + wg.Wait() + errChan <- err + }() + go func() { + ppp1Clients, _, err := internetgateway2.NewWANPPPConnection1Clients() + if len(ppp1Clients) > 0 { + any <- true + } + wg.Done() + wg.Wait() + errChan <- err + }() + + select { + case <-any: + return true, nil + case err := <-errChan: + // TODO probably want to take the non-nil of all the errors? Or something. + return false, err + case <-ctx.Done(): + return false, nil + } +} + +type upnpClient interface { + // http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v2-Service.pdf + // Implicitly assume that the calls for all these are uniform, which might be a dangerous + // assumption. + AddPortMapping( + NewRemoteHost string, + NewExternalPort uint16, + NewProtocol string, + NewInternalPort uint16, + NewInternalClient string, + NewEnabled bool, + NewPortMappingDescription string, + NewLeaseDuration uint32, + ) (err error) + + DeletePortMapping(NewRemoteHost string, NewExternalPort uint16, NewProtocol string) error + GetStatusInfo() (status string, lastErr string, uptime uint32, err error) + + RequestTermination() error + RequestConnection() error +} + +// getUPnPClients 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) (upnpClient, error) { + tasks, _ := errgroup.WithContext(ctx) + var ip1Clients []*internetgateway2.WANIPConnection1 + tasks.Go(func() error { + var err error + ip1Clients, _, err = internetgateway2.NewWANIPConnection1Clients() + return err + }) + var ip2Clients []*internetgateway2.WANIPConnection2 + tasks.Go(func() error { + var err error + ip2Clients, _, err = internetgateway2.NewWANIPConnection2Clients() + return err + }) + var ppp1Clients []*internetgateway2.WANPPPConnection1 + tasks.Go(func() error { + var err error + ppp1Clients, _, err = internetgateway2.NewWANPPPConnection1Clients() + return err + }) + + err := tasks.Wait() + + switch { + case len(ip2Clients) > 0: + return ip2Clients[0], nil + case len(ip1Clients) > 0: + return ip1Clients[0], nil + case len(ppp1Clients) > 0: + return ppp1Clients[0], nil + default: + // Didn't get any outputs, report if there was an error or nil if + // just no clients. + return nil, err + } +}