From d8b9698eaaaf40ea857ed888ffe5b7ac99575c8f Mon Sep 17 00:00:00 2001 From: Andrew Dunham Date: Tue, 18 Oct 2022 17:29:54 -0400 Subject: [PATCH] WIP Signed-off-by: Andrew Dunham Change-Id: Id806c5c62b5097d9a5a7600324349ce7692d4d55 --- client/tailscale/apitype/apitype.go | 37 +++++- client/tailscale/localclient.go | 16 +++ cmd/tailscale/cli/debug.go | 19 +++ ipn/ipnlocal/local.go | 188 ++++++++++++++++++++++++++++ ipn/localapi/localapi.go | 21 ++++ wgengine/userspace.go | 8 +- wgengine/watchdog.go | 3 + wgengine/wgengine.go | 2 + 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 @@ -108,6 +108,11 @@ var debugCmd = &ffcli.Command{ Exec: localAPIAction("rebind"), ShortHelp: "force a magicsock rebind", }, + { + Name: "subnet-router", + Exec: runDebugSubnetRouter, + ShortHelp: "debug connectivity to a host through a subnet router", + }, { Name: "prefs", Exec: runPrefs, @@ -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 ") + } + + 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