diff --git a/cmd/tailscale/netcheck.go b/cmd/tailscale/netcheck.go index c94ca1015..6179eff68 100644 --- a/cmd/tailscale/netcheck.go +++ b/cmd/tailscale/netcheck.go @@ -18,7 +18,6 @@ import ( "github.com/peterbourgon/ff/v2/ffcli" "tailscale.com/derp/derpmap" "tailscale.com/net/dnscache" - "tailscale.com/net/interfaces" "tailscale.com/net/netcheck" "tailscale.com/tailcfg" "tailscale.com/types/logger" @@ -51,11 +50,6 @@ func runNetcheck(ctx context.Context, args []string) error { if netcheckArgs.verbose { c.Logf = logger.WithPrefix(log.Printf, "netcheck: ") c.Verbose = true - if gw, ok := interfaces.LikelyHomeRouterIP(); ok { - c.Logf("likely home router: %v", gw) - } else { - c.Logf("no likely home router IP found") - } } else { c.Logf = logger.Discard } @@ -123,6 +117,7 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error { } fmt.Printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP) fmt.Printf("\t* HairPinning: %v\n", report.HairPinning) + fmt.Printf("\t* PortMapping: %v\n", portMapping(report)) // When DERP latency checking failed, // magicsock will try to pick the DERP server that @@ -148,3 +143,20 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error { } return nil } + +func portMapping(r *netcheck.Report) string { + if !r.AnyPortMappingChecked() { + return "not checked" + } + var got []string + if r.UPnP.EqualBool(true) { + got = append(got, "UPnP") + } + if r.PMP.EqualBool(true) { + got = append(got, "NAT-PMP") + } + if r.PCP.EqualBool(true) { + got = append(got, "PCP") + } + return strings.Join(got, ", ") +} diff --git a/net/interfaces/interfaces.go b/net/interfaces/interfaces.go index 4626f5429..3fcecfe40 100644 --- a/net/interfaces/interfaces.go +++ b/net/interfaces/interfaces.go @@ -234,12 +234,32 @@ var likelyHomeRouterIP func() (netaddr.IP, bool) // LikelyHomeRouterIP returns the likely IP of the residential router, // which will always be an IPv4 private address, if found. +// In addition, it returns the IP address of the current machine on +// the LAN using that gateway. // This is used as the destination for UPnP, NAT-PMP, PCP, etc queries. -func LikelyHomeRouterIP() (ip netaddr.IP, ok bool) { +func LikelyHomeRouterIP() (gateway, myIP netaddr.IP, ok bool) { if likelyHomeRouterIP != nil { - return likelyHomeRouterIP() + gateway, ok = likelyHomeRouterIP() + if !ok { + return + } + } + if !ok { + return } - return ip, false + ForeachInterfaceAddress(func(i Interface, ip netaddr.IP) { + if !i.IsUp() || ip.IsZero() || !myIP.IsZero() { + return + } + for _, prefix := range privatev4s { + if prefix.Contains(gateway) && prefix.Contains(ip) { + myIP = ip + ok = true + return + } + } + }) + return gateway, myIP, !myIP.IsZero() } func isPrivateIP(ip netaddr.IP) bool { @@ -262,6 +282,7 @@ var ( private1 = mustCIDR("10.0.0.0/8") private2 = mustCIDR("172.16.0.0/12") private3 = mustCIDR("192.168.0.0/16") + privatev4s = []netaddr.IPPrefix{private1, private2, private3} cgNAT = mustCIDR("100.64.0.0/10") linkLocalIPv4 = mustCIDR("169.254.0.0/16") v6Global1 = mustCIDR("2000::/3") diff --git a/net/interfaces/interfaces_test.go b/net/interfaces/interfaces_test.go index 0cd2bbb98..11238eed5 100644 --- a/net/interfaces/interfaces_test.go +++ b/net/interfaces/interfaces_test.go @@ -49,6 +49,10 @@ func TestGetState(t *testing.T) { } func TestLikelyHomeRouterIP(t *testing.T) { - ip, ok := LikelyHomeRouterIP() - t.Logf("got %v, %v", ip, ok) + gw, my, ok := LikelyHomeRouterIP() + if !ok { + t.Logf("no result") + return + } + t.Logf("myIP = %v; gw = %v", my, gw) } diff --git a/net/netcheck/netcheck.go b/net/netcheck/netcheck.go index 71fd39ee8..a5f8a8f18 100644 --- a/net/netcheck/netcheck.go +++ b/net/netcheck/netcheck.go @@ -8,7 +8,9 @@ package netcheck import ( "bufio" "context" + "crypto/rand" "crypto/tls" + "encoding/binary" "errors" "fmt" "io" @@ -21,6 +23,7 @@ import ( "time" "github.com/tcnksm/go-httpstat" + "go4.org/mem" "inet.af/netaddr" "tailscale.com/derp/derphttp" "tailscale.com/net/dnscache" @@ -34,15 +37,26 @@ import ( ) type Report struct { - UDP bool // UDP works - IPv6 bool // IPv6 works - IPv4 bool // IPv4 works - MappingVariesByDestIP opt.Bool // for IPv4 - HairPinning opt.Bool // for IPv4 - PreferredDERP int // or 0 for unknown - RegionLatency map[int]time.Duration // keyed by DERP Region ID - RegionV4Latency map[int]time.Duration // keyed by DERP Region ID - RegionV6Latency map[int]time.Duration // keyed by DERP Region ID + UDP bool // UDP works + IPv6 bool // IPv6 works + IPv4 bool // IPv4 works + MappingVariesByDestIP opt.Bool // for IPv4 + HairPinning opt.Bool // for IPv4 + + // UPnP is whether UPnP appears present on the LAN. + // Empty means not checked. + UPnP opt.Bool + // PMP is whether NAT-PMP appears present on the LAN. + // Empty means not checked. + PMP opt.Bool + // PCP is whether PCP appears present on the LAN. + // Empty means not checked. + PCP opt.Bool + + PreferredDERP int // or 0 for unknown + RegionLatency map[int]time.Duration // keyed by DERP Region ID + RegionV4Latency map[int]time.Duration // keyed by DERP Region ID + RegionV6Latency map[int]time.Duration // keyed by DERP Region ID GlobalV4 string // ip:port of global IPv4 GlobalV6 string // [ip]:port of global IPv6 @@ -50,6 +64,11 @@ type Report struct { // TODO: update Clone when adding new fields } +// AnyPortMappingChecked reports whether any of UPnP, PMP, or PCP are non-empty. +func (r *Report) AnyPortMappingChecked() bool { + return r.UPnP != "" || r.PMP != "" || r.PCP != "" +} + func (r *Report) Clone() *Report { if r == nil { return nil @@ -434,6 +453,7 @@ type reportState struct { pc4Hair net.PacketConn incremental bool // doing a lite, follow-up netcheck stopProbeCh chan struct{} + waitPortMap sync.WaitGroup mu sync.Mutex sentHairCheck bool @@ -599,6 +619,98 @@ func (rs *reportState) stopProbes() { } } +func (rs *reportState) setOptBool(b *opt.Bool, v bool) { + rs.mu.Lock() + defer rs.mu.Unlock() + b.Set(v) +} + +func (rs *reportState) probePortMapServices() { + defer rs.waitPortMap.Done() + gw, myIP, ok := interfaces.LikelyHomeRouterIP() + if !ok { + return + } + + rs.setOptBool(&rs.report.UPnP, false) + rs.setOptBool(&rs.report.PMP, false) + rs.setOptBool(&rs.report.PCP, false) + + port1900 := netaddr.IPPort{IP: gw, Port: 1900}.UDPAddr() + port5351 := netaddr.IPPort{IP: gw, Port: 5351}.UDPAddr() + + rs.c.logf("probePortMapServices: me %v -> gw %v", myIP, gw) + + // Create a UDP4 socket used just for querying for UPnP, NAT-PMP, and PCP. + uc, err := netns.Listener().ListenPacket(context.Background(), "udp4", ":0") + if err != nil { + rs.c.logf("probePortMapServices: %v", err) + return + } + defer uc.Close() + tempPort := uc.LocalAddr().(*net.UDPAddr).Port + uc.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + + // Send request packets for all three protocols. + uc.WriteTo(uPnPPacket, port1900) + uc.WriteTo(pmpPacket, port5351) + uc.WriteTo(pcpPacket(myIP, tempPort, false), port5351) + + res := make([]byte, 1500) + for { + n, addr, err := uc.ReadFrom(res) + if err != nil { + return + } + switch addr.(*net.UDPAddr).Port { + case 1900: + if mem.Contains(mem.B(res[:n]), mem.S(":InternetGatewayDevice:")) { + rs.setOptBool(&rs.report.UPnP, true) + } + case 5351: + if n == 12 && res[0] == 0x00 { // right length and version 0 + rs.setOptBool(&rs.report.PMP, true) + } + if n == 60 && res[0] == 0x02 { // right length and version 2 + rs.setOptBool(&rs.report.PCP, true) + // Delete the mapping. + uc.WriteTo(pcpPacket(myIP, tempPort, true), port5351) + } + } + } +} + +var pmpPacket = []byte{0, 0} // version 0, opcode 0 = "Public address request" + +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 v4unspec, _ = netaddr.ParseIP("0.0.0.0") + +func pcpPacket(myIP netaddr.IP, mapToLocalPort int, delete bool) []byte { + const udpProtoNumber = 17 + lifetimeSeconds := uint32(1) + if delete { + lifetimeSeconds = 0 + } + const opMap = 1 + pkt := make([]byte, (32+32+128)/8+(96+8+24+16+16+128)/8) + pkt[0] = 2 // version + pkt[1] = opMap + binary.BigEndian.PutUint32(pkt[4:8], lifetimeSeconds) + myIP16 := myIP.As16() + copy(pkt[8:], myIP16[:]) + rand.Read(pkt[24 : 24+12]) + pkt[36] = udpProtoNumber + binary.BigEndian.PutUint16(pkt[40:], uint16(mapToLocalPort)) + v4unspec16 := v4unspec.As16() + copy(pkt[40:], v4unspec16[:]) + return pkt +} + func newReport() *Report { return &Report{ RegionLatency: make(map[int]time.Duration), @@ -671,6 +783,9 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap) (*Report, e } defer rs.pc4Hair.Close() + rs.waitPortMap.Add(1) + go rs.probePortMapServices() + // At least the Apple Airport Extreme doesn't allow hairpin // sends from a private socket until it's seen traffic from // that src IP:port to something else out on the internet. @@ -738,6 +853,7 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap) (*Report, e } rs.waitHairCheck(ctx) + rs.waitPortMap.Wait() rs.stopTimers() // Try HTTPS latency check if all STUN probes failed due to UDP presumably being blocked. @@ -861,6 +977,11 @@ func (c *Client) logConciseReport(r *Report, dm *tailcfg.DERPMap) { fmt.Fprintf(w, " v6=%v", r.IPv6) fmt.Fprintf(w, " mapvarydest=%v", r.MappingVariesByDestIP) fmt.Fprintf(w, " hair=%v", r.HairPinning) + if r.AnyPortMappingChecked() { + fmt.Fprintf(w, " portmap=%v%v%v", conciseOptBool(r.UPnP, "U"), conciseOptBool(r.PMP, "M"), conciseOptBool(r.PCP, "C")) + } else { + fmt.Fprintf(w, " portmap=?") + } if r.GlobalV4 != "" { fmt.Fprintf(w, " v4a=%v", r.GlobalV4) } @@ -1069,3 +1190,17 @@ func maxDurationValue(m map[int]time.Duration) (max time.Duration) { } return max } + +func conciseOptBool(b opt.Bool, trueVal string) string { + if b == "" { + return "_" + } + v, ok := b.Get() + if !ok { + return "x" + } + if v { + return trueVal + } + return "" +} diff --git a/net/netcheck/netcheck_test.go b/net/netcheck/netcheck_test.go index 34a12c19a..b8f4bfbf5 100644 --- a/net/netcheck/netcheck_test.go +++ b/net/netcheck/netcheck_test.go @@ -100,6 +100,9 @@ func TestWorksWhenUDPBlocked(t *testing.T) { t.Fatal(err) } want := newReport() + r.UPnP = "" + r.PMP = "" + r.PCP = "" if !reflect.DeepEqual(r, want) { t.Errorf("mismatch\n got: %+v\nwant: %+v\n", r, want) @@ -463,7 +466,7 @@ func TestLogConciseReport(t *testing.T) { { name: "no_udp", r: &Report{}, - want: "udp=false v4=false v6=false mapvarydest= hair= derp=0", + want: "udp=false v4=false v6=false mapvarydest= hair= portmap=? derp=0", }, { name: "ipv4_one_region", @@ -478,7 +481,7 @@ func TestLogConciseReport(t *testing.T) { 1: 10 * ms, }, }, - want: "udp=true v6=false mapvarydest= hair= derp=1 derpdist=1v4:10ms", + want: "udp=true v6=false mapvarydest= hair= portmap=? derp=1 derpdist=1v4:10ms", }, { name: "ipv4_all_region", @@ -497,7 +500,7 @@ func TestLogConciseReport(t *testing.T) { 3: 30 * ms, }, }, - want: "udp=true v6=false mapvarydest= hair= derp=1 derpdist=1v4:10ms,2v4:20ms,3v4:30ms", + want: "udp=true v6=false mapvarydest= hair= portmap=? derp=1 derpdist=1v4:10ms,2v4:20ms,3v4:30ms", }, { name: "ipboth_all_region", @@ -522,7 +525,27 @@ func TestLogConciseReport(t *testing.T) { 3: 30 * ms, }, }, - want: "udp=true v6=true mapvarydest= hair= derp=1 derpdist=1v4:10ms,1v6:10ms,2v4:20ms,2v6:20ms,3v4:30ms,3v6:30ms", + want: "udp=true v6=true mapvarydest= hair= portmap=? derp=1 derpdist=1v4:10ms,1v6:10ms,2v4:20ms,2v6:20ms,3v4:30ms,3v6:30ms", + }, + { + name: "portmap_all", + r: &Report{ + UDP: true, + UPnP: "true", + PMP: "true", + PCP: "true", + }, + want: "udp=true v4=false v6=false mapvarydest= hair= portmap=UMC derp=0", + }, + { + name: "portmap_some", + r: &Report{ + UDP: true, + UPnP: "true", + PMP: "false", + PCP: "true", + }, + want: "udp=true v4=false v6=false mapvarydest= hair= portmap=UC derp=0", }, } for _, tt := range tests { diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 79a123826..626cd4347 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -310,6 +310,18 @@ type NetInfo struct { // WorkingUDP is whether UDP works. WorkingUDP opt.Bool + // UPnP is whether UPnP appears present on the LAN. + // Empty means not checked. + UPnP opt.Bool + + // PMP is whether NAT-PMP appears present on the LAN. + // Empty means not checked. + PMP opt.Bool + + // PCP is whether PCP appears present on the LAN. + // Empty means not checked. + PCP opt.Bool + // PreferredDERP is this node's preferred DERP server // for incoming traffic. The node might be be temporarily // connected to multiple DERP servers (to send to other nodes) @@ -338,9 +350,32 @@ func (ni *NetInfo) String() string { if ni == nil { return "NetInfo(nil)" } - return fmt.Sprintf("NetInfo{varies=%v hairpin=%v ipv6=%v udp=%v derp=#%v link=%q}", + return fmt.Sprintf("NetInfo{varies=%v hairpin=%v ipv6=%v udp=%v derp=#%v portmap=%v link=%q}", ni.MappingVariesByDestIP, ni.HairPinning, ni.WorkingIPv6, - ni.WorkingUDP, ni.PreferredDERP, ni.LinkType) + ni.WorkingUDP, ni.PreferredDERP, + ni.portMapSummary(), + ni.LinkType) +} + +func (ni *NetInfo) portMapSummary() string { + if ni.UPnP == "" && ni.PMP == "" && ni.PCP == "" { + return "na" + } + return conciseOptBool(ni.UPnP, "U") + conciseOptBool(ni.PMP, "M") + conciseOptBool(ni.PCP, "C") +} + +func conciseOptBool(b opt.Bool, trueVal string) string { + if b == "" { + return "_" + } + v, ok := b.Get() + if !ok { + return "x" + } + if v { + return trueVal + } + return "" } // BasicallyEqual reports whether ni and ni2 are basically equal, ignoring @@ -356,6 +391,9 @@ func (ni *NetInfo) BasicallyEqual(ni2 *NetInfo) bool { ni.HairPinning == ni2.HairPinning && ni.WorkingIPv6 == ni2.WorkingIPv6 && ni.WorkingUDP == ni2.WorkingUDP && + ni.UPnP == ni2.UPnP && + ni.PMP == ni2.PMP && + ni.PCP == ni2.PCP && ni.PreferredDERP == ni2.PreferredDERP && ni.LinkType == ni2.LinkType } diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index 63af39505..f33cad4e5 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -329,6 +329,9 @@ func TestNetInfoFields(t *testing.T) { "HairPinning", "WorkingIPv6", "WorkingUDP", + "UPnP", + "PMP", + "PCP", "PreferredDERP", "LinkType", "DERPLatency", diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index deef60953..0adeaaf55 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -389,6 +389,9 @@ func (c *Conn) updateNetInfo(ctx context.Context) (*netcheck.Report, error) { DERPLatency: map[string]float64{}, MappingVariesByDestIP: report.MappingVariesByDestIP, HairPinning: report.HairPinning, + UPnP: report.UPnP, + PMP: report.PMP, + PCP: report.PCP, } for rid, d := range report.RegionV4Latency { ni.DERPLatency[fmt.Sprintf("%d-v4", rid)] = d.Seconds()