summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/tailscale/apitype/apitype.go37
-rw-r--r--client/tailscale/localclient.go16
-rw-r--r--cmd/tailscale/cli/debug.go19
-rw-r--r--ipn/ipnlocal/local.go188
-rw-r--r--ipn/localapi/localapi.go21
-rw-r--r--wgengine/userspace.go8
-rw-r--r--wgengine/watchdog.go3
-rw-r--r--wgengine/wgengine.go2
8 files changed, 291 insertions, 3 deletions
diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go
index d10e20533..325d5b845 100644
--- a/client/tailscale/apitype/apitype.go
+++ b/client/tailscale/apitype/apitype.go
@@ -5,7 +5,11 @@
// Package apitype contains types for the Tailscale local API and control plane API.
package apitype
-import "tailscale.com/tailcfg"
+import (
+ "net/netip"
+
+ "tailscale.com/tailcfg"
+)
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
type WhoIsResponse struct {
@@ -30,3 +34,34 @@ type WaitingFile struct {
Name string
Size int64
}
+
+// TODO: docs
+type SubnetRouteDebugResponse struct {
+ InputAddr string
+ Addresses []SubnetRouteDebugAddress
+ Nodes []SubnetRouteDebugNode
+ Errors []string `json:",omitempty"`
+}
+
+type SubnetRouteDebugAddress struct {
+ Addr netip.Addr
+ Source string
+}
+
+type SubnetRouteDebugPingResponse struct {
+ IP netip.Addr
+ Err string `json:",omitempty"`
+ LatencySeconds float64 `json:",omitempty"`
+}
+
+// TODO: docs
+type SubnetRouteDebugNode struct {
+ StableID tailcfg.StableNodeID
+ Name string
+ AllowedIPs []netip.Prefix
+ Primary []netip.Prefix `json:",omitempty"`
+ Online string
+ IsExitNode bool
+ DiscoPing *SubnetRouteDebugPingResponse `json:",omitempty"`
+ ICMPPing *SubnetRouteDebugPingResponse `json:",omitempty"`
+}
diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go
index 07319e277..1f8e76390 100644
--- a/client/tailscale/localclient.go
+++ b/client/tailscale/localclient.go
@@ -348,6 +348,22 @@ func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
return nil
}
+// TODO: docs
+func (lc *LocalClient) DebugSubnetRoute(ctx context.Context, addr string) (*apitype.SubnetRouteDebugResponse, error) {
+ urlvals := make(url.Values)
+ urlvals.Set("addr", addr)
+
+ body, err := lc.send(ctx, "POST", "/localapi/v0/debug-subnet-route?"+urlvals.Encode(), 200, nil)
+ if err != nil {
+ return nil, fmt.Errorf("error %w: %s", err, body)
+ }
+ var res apitype.SubnetRouteDebugResponse
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, err
+ }
+ return &res, nil
+}
+
// SetComponentDebugLogging sets component's debug logging enabled for
// the provided duration. If the duration is in the past, the debug logging
// is disabled.
diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go
index 1293d10e9..5fc5e41ac 100644
--- a/cmd/tailscale/cli/debug.go
+++ b/cmd/tailscale/cli/debug.go
@@ -109,6 +109,11 @@ var debugCmd = &ffcli.Command{
ShortHelp: "force a magicsock rebind",
},
{
+ Name: "subnet-router",
+ Exec: runDebugSubnetRouter,
+ ShortHelp: "debug connectivity to a host through a subnet router",
+ },
+ {
Name: "prefs",
Exec: runPrefs,
ShortHelp: "print prefs",
@@ -546,3 +551,17 @@ func runDebugComponentLogs(ctx context.Context, args []string) error {
}
return nil
}
+
+func runDebugSubnetRouter(ctx context.Context, args []string) error {
+ if len(args) != 1 {
+ return errors.New("usage: debug subnet-router <hostname-or-ipv6>")
+ }
+
+ s, err := localClient.DebugSubnetRoute(ctx, args[0])
+ if err != nil {
+ return err
+ }
+ j, _ := json.MarshalIndent(s, "", "\t")
+ outln(string(j))
+ return nil
+}
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index e6ac4701f..ea55bb31e 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -25,6 +25,7 @@ import (
"go4.org/netipx"
"golang.org/x/exp/slices"
+ "golang.org/x/sync/errgroup"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/control/controlclient"
"tailscale.com/doctor"
@@ -3724,6 +3725,193 @@ func (b *LocalBackend) DebugReSTUN() error {
return nil
}
+func (b *LocalBackend) DebugSubnetRoute(ctx context.Context, addr string) (*apitype.SubnetRouteDebugResponse, error) {
+ b.mu.Lock()
+ nm := b.netMap
+ b.mu.Unlock()
+
+ if nm == nil {
+ return nil, errors.New("no netmap")
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ out := &apitype.SubnetRouteDebugResponse{
+ InputAddr: addr,
+ }
+
+ returnWithError := func(format string, args ...any) (*apitype.SubnetRouteDebugResponse, error) {
+ if len(format) > 0 && format[len(format)-1] != '\n' {
+ format += "\n"
+ }
+ out.Errors = append(out.Errors, fmt.Sprintf(format, args...))
+ return out, nil
+ }
+
+ ip, err := netip.ParseAddr(addr)
+ if err != nil {
+ // Try resolving the address using both the Go and platform-specific resolver
+ var addrs []apitype.SubnetRouteDebugAddress
+ for _, preferGo := range []bool{true, false} {
+ resolver := net.Resolver{PreferGo: preferGo}
+ ips, err := resolver.LookupNetIP(ctx, "ip", addr)
+ if err != nil {
+ return returnWithError("error resolving address %q: %v", addr, err)
+ }
+ for _, ip := range ips {
+ addrs = append(addrs, apitype.SubnetRouteDebugAddress{
+ Addr: ip.Unmap(),
+ Source: fmt.Sprintf("net.Resolver{PreferGo: %v}", preferGo),
+ })
+ }
+ }
+
+ // Pick the first IP, since we always expect it.
+ // TODO: try all IPs?
+ out.Addresses = addrs
+ ip = addrs[0].Addr
+ } else {
+ out.Addresses = []apitype.SubnetRouteDebugAddress{{
+ Addr: ip,
+ Source: "user",
+ }}
+ }
+ ip = ip.Unmap()
+
+ // Try to determine which subnet router is routing this address.
+ type nodeWithMatching struct {
+ *tailcfg.Node
+ MatchingAllowedIPs []netip.Prefix
+ }
+ var ns []nodeWithMatching
+ for _, peer := range nm.Peers {
+ curr := nodeWithMatching{Node: peer}
+ for _, allowedip := range peer.AllowedIPs {
+ if !allowedip.Contains(ip) {
+ continue
+ }
+ curr.MatchingAllowedIPs = append(curr.MatchingAllowedIPs, allowedip)
+ }
+
+ if len(curr.MatchingAllowedIPs) > 0 {
+ ns = append(ns, curr)
+ }
+ }
+ if len(ns) == 0 {
+ return returnWithError("this node has no peers advertising a route for %s", ip)
+ }
+
+ // For each possible subnet router, check the status.
+ type nodeRes struct {
+ dn apitype.SubnetRouteDebugNode
+ err error
+ }
+ nodeResults := make(chan nodeRes, len(ns))
+ grp, grpCtx := errgroup.WithContext(ctx)
+ grp.SetLimit(5)
+ for _, node := range ns {
+ node := node // capture loop variable
+ grp.Go(func() error {
+ var retErr error
+ dn := apitype.SubnetRouteDebugNode{
+ StableID: node.StableID,
+ Name: node.Name,
+ AllowedIPs: node.MatchingAllowedIPs,
+ }
+ defer func() {
+ nodeResults <- nodeRes{dn, retErr}
+ }()
+
+ // Check PrimaryRoutes
+ for _, pref := range node.PrimaryRoutes {
+ if pref.Contains(ip) {
+ dn.Primary = append(dn.Primary, pref)
+ }
+ }
+
+ // Check for exit node
+ if tsaddr.ContainsExitRoutes(node.AllowedIPs) {
+ dn.IsExitNode = true
+ }
+
+ // Do online checks after gathering all data that doesn't
+ // require an interaction, so we can 'continue' if the node
+ // isn't online.
+ if node.Online == nil {
+ dn.Online = "unknown"
+ return nil
+ } else if !*node.Online {
+ dn.Online = "false"
+ return nil
+ } else {
+ dn.Online = "true"
+ }
+
+ // Try pinging the node itself
+ // TODO: try all IPs?
+ // TODO: check if we have the right v4/v6 address support
+ candidates := []struct {
+ ty tailcfg.PingType
+ res **apitype.SubnetRouteDebugPingResponse
+ }{
+ {tailcfg.PingDisco, &dn.DiscoPing},
+ {tailcfg.PingICMP, &dn.ICMPPing},
+ }
+ for _, cand := range candidates {
+ ip := node.Addresses[0].Addr()
+
+ res := &apitype.SubnetRouteDebugPingResponse{
+ IP: ip,
+ }
+ *cand.res = res
+
+ pingRes := make(chan *ipnstate.PingResult, 1)
+ b.e.Ping(ip, cand.ty, func(pr *ipnstate.PingResult) {
+ select {
+ case pingRes <- pr:
+ default:
+ }
+ })
+ select {
+ case pr := <-pingRes:
+ if pr.Err != "" {
+ res.Err = pr.Err
+ } else {
+ res.LatencySeconds = pr.LatencySeconds
+ }
+
+ case <-grpCtx.Done():
+ res.Err = grpCtx.Err().Error()
+ retErr = fmt.Errorf("context canceled while waiting for %s response from %v: %v", cand.ty, ip, grpCtx.Err())
+ return nil
+ }
+ }
+ return nil
+ })
+ }
+ grp.Wait()
+
+resultsLoop:
+ for i := 0; i < len(ns); i++ {
+ select {
+ case res := <-nodeResults:
+ if res.err != nil {
+ out.Errors = append(out.Errors, res.err.Error())
+ }
+ out.Nodes = append(out.Nodes, res.dn)
+
+ // We could have finished before starting all goroutines, so we
+ // need to handle the case where our channel doesn't have all
+ // the responses.
+ default:
+ break resultsLoop
+ }
+ }
+
+ return out, nil
+}
+
func (b *LocalBackend) magicConn() (*magicsock.Conn, error) {
ig, ok := b.e.(wgengine.InternalsGetter)
if !ok {
diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go
index bfdd6a15a..fe34fdc44 100644
--- a/ipn/localapi/localapi.go
+++ b/ipn/localapi/localapi.go
@@ -59,6 +59,7 @@ var handler = map[string]localAPIHandler{
"check-prefs": (*Handler).serveCheckPrefs,
"component-debug-logging": (*Handler).serveComponentDebugLogging,
"debug": (*Handler).serveDebug,
+ "debug-subnet-route": (*Handler).serveDebugSubnetRoute,
"derpmap": (*Handler).serveDERPMap,
"dial": (*Handler).serveDial,
"file-targets": (*Handler).serveFileTargets,
@@ -417,6 +418,26 @@ func (h *Handler) serveComponentDebugLogging(w http.ResponseWriter, r *http.Requ
json.NewEncoder(w).Encode(res)
}
+func (h *Handler) serveDebugSubnetRoute(w http.ResponseWriter, r *http.Request) {
+ if !h.PermitWrite {
+ http.Error(w, "debug access denied", http.StatusForbidden)
+ return
+ }
+ addr := r.FormValue("addr")
+ res, err := h.b.DebugSubnetRoute(r.Context(), addr)
+ w.Header().Set("Content-Type", "application/json")
+ if err != nil {
+ json.NewEncoder(w).Encode(struct {
+ Errors []string
+ }{
+ Errors: []string{err.Error()},
+ })
+ return
+ }
+
+ json.NewEncoder(w).Encode(res)
+}
+
// serveProfileFunc is the implementation of Handler.serveProfile, after auth,
// for platforms where we want to link it in.
var serveProfileFunc func(http.ResponseWriter, *http.Request)
diff --git a/wgengine/userspace.go b/wgengine/userspace.go
index 34f328513..21da7a303 100644
--- a/wgengine/userspace.go
+++ b/wgengine/userspace.go
@@ -1289,7 +1289,11 @@ func (e *userspaceEngine) Ping(ip netip.Addr, pingType tailcfg.PingType, cb func
return
}
peer := pip.Node
+ e.PingPeer(ip, peer, pingType, cb)
+}
+func (e *userspaceEngine) PingPeer(ip netip.Addr, peer *tailcfg.Node, pingType tailcfg.PingType, cb func(*ipnstate.PingResult)) {
+ res := &ipnstate.PingResult{IP: ip.String()}
e.logf("ping(%v): sending %v ping to %v %v ...", ip, pingType, peer.Key.ShortString(), peer.ComputedName)
switch pingType {
case "disco":
@@ -1297,7 +1301,7 @@ func (e *userspaceEngine) Ping(ip netip.Addr, pingType tailcfg.PingType, cb func
case "TSMP":
e.sendTSMPPing(ip, peer, res, cb)
case "ICMP":
- e.sendICMPEchoRequest(ip, peer, res, cb)
+ e.SendICMPEchoRequest(ip, peer, res, cb)
}
}
@@ -1318,7 +1322,7 @@ func (e *userspaceEngine) mySelfIPMatchingFamily(dst netip.Addr) (src netip.Addr
return netip.Addr{}, errors.New("no self address in netmap matching address family")
}
-func (e *userspaceEngine) sendICMPEchoRequest(destIP netip.Addr, peer *tailcfg.Node, res *ipnstate.PingResult, cb func(*ipnstate.PingResult)) {
+func (e *userspaceEngine) SendICMPEchoRequest(destIP netip.Addr, peer *tailcfg.Node, res *ipnstate.PingResult, cb func(*ipnstate.PingResult)) {
srcIP, err := e.mySelfIPMatchingFamily(destIP)
if err != nil {
res.Err = err.Error()
diff --git a/wgengine/watchdog.go b/wgengine/watchdog.go
index 46260221f..b1c9a1ddd 100644
--- a/wgengine/watchdog.go
+++ b/wgengine/watchdog.go
@@ -169,6 +169,9 @@ func (e *watchdogEngine) DiscoPublicKey() (k key.DiscoPublic) {
func (e *watchdogEngine) Ping(ip netip.Addr, pingType tailcfg.PingType, cb func(*ipnstate.PingResult)) {
e.watchdog("Ping", func() { e.wrap.Ping(ip, pingType, cb) })
}
+func (e *watchdogEngine) PingPeer(ip netip.Addr, peer *tailcfg.Node, pingType tailcfg.PingType, cb func(*ipnstate.PingResult)) {
+ e.watchdog("PingPeer", func() { e.wrap.PingPeer(ip, peer, pingType, cb) })
+}
func (e *watchdogEngine) RegisterIPPortIdentity(ipp netip.AddrPort, tsIP netip.Addr) {
e.watchdog("RegisterIPPortIdentity", func() { e.wrap.RegisterIPPortIdentity(ipp, tsIP) })
}
diff --git a/wgengine/wgengine.go b/wgengine/wgengine.go
index 1bee017d2..831cec495 100644
--- a/wgengine/wgengine.go
+++ b/wgengine/wgengine.go
@@ -158,6 +158,8 @@ type Engine interface {
// then call cb with its ping latency & method.
Ping(ip netip.Addr, pingType tailcfg.PingType, cb func(*ipnstate.PingResult))
+ PingPeer(ip netip.Addr, peer *tailcfg.Node, pingType tailcfg.PingType, cb func(*ipnstate.PingResult))
+
// RegisterIPPortIdentity registers a given node (identified by its
// Tailscale IP) as temporarily having the given IP:port for whois lookups.
// The IP:port is generally a localhost IP and an ephemeral port, used