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 <julianknodt@gmail.com>
This commit is contained in:
julianknodt 2021-06-11 14:14:48 -07:00
parent 525eb5ce41
commit caceeff374
9 changed files with 657 additions and 140 deletions

View File

@ -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+

View File

@ -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+

3
go.mod
View File

@ -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

6
go.sum
View File

@ -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=

View File

@ -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"

View File

@ -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)

447
net/portmapper/probe.go Normal file
View File

@ -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)
}
}
}

View File

@ -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)
}

120
net/portmapper/upnp.go Normal file
View File

@ -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
}
}