client/local,ipn/localapi: add /localapi/v0/routecheck endpoint (#19640)

In order to support a `tailscale routecheck` command, we introduce the
`/localapi/v0/routecheck` endpoint to the local API. This endpoint
returns the most recent report collected by the routecheck client.
If `force=true` is an argument in the query string, then this endpoint
will actively probe before returning the report.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
Simon Law
2026-06-01 11:06:14 -07:00
committed by GitHub
parent 28801674a6
commit 2ee9eacb94
14 changed files with 335 additions and 35 deletions
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_routecheck
package local
import (
"context"
"errors"
"fmt"
"net/http"
"tailscale.com/net/routecheck"
)
// ErrReportPending is returned by [Client.RouteCheck] and [Client.RouteCheckProbe]
// when the report is pending.
var ErrRouteCheckReportUnavailable = errors.New("report pending")
// RouteCheckProbe performs a routecheck probe and waits for its report.
func (lc *Client) RouteCheckProbe(ctx context.Context) (*routecheck.Report, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck?probe=true", http.StatusOK, nil)
if err != nil {
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
return nil, ErrRouteCheckReportUnavailable
}
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*routecheck.Report](body)
}
// RouteCheck requests the report compiled by the latest routecheck probe.
func (lc *Client) RouteCheck(ctx context.Context) (*routecheck.Report, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck", http.StatusOK, nil)
if err != nil {
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
return nil, ErrRouteCheckReportUnavailable
}
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*routecheck.Report](body)
}
+4 -2
View File
@@ -107,12 +107,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/local
tailscale.com/net/netx from tailscale.com/net/dnscache+
tailscale.com/net/routecheck from tailscale.com/client/local
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun from tailscale.com/net/stunserver
tailscale.com/net/stunserver from tailscale.com/cmd/derper
L tailscale.com/net/tcpinfo from tailscale.com/derp/derpserver
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/net/routecheck
tailscale.com/net/tsaddr from tailscale.com/ipn+
tailscale.com/net/udprelay/status from tailscale.com/client/local
tailscale.com/net/wsconn from tailscale.com/derp/derpserver
@@ -135,7 +137,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/netmap from tailscale.com/ipn+
tailscale.com/types/opt from tailscale.com/envknob+
tailscale.com/types/persist from tailscale.com/ipn+
tailscale.com/types/preftype from tailscale.com/ipn
@@ -310,7 +312,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
go/token from google.golang.org/protobuf/internal/strs
hash from crypto+
hash/crc32 from compress/gzip+
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/fnv from google.golang.org/protobuf/internal/detrand+
hash/maphash from go4.org/mem+
html from net/http/pprof+
html/template from tailscale.com/cmd/derper+
+2 -1
View File
@@ -807,13 +807,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/ipn/localapi+
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal+
tailscale.com/net/tsaddr from tailscale.com/client/web+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
+3
View File
@@ -220,10 +220,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/net/ping from tailscale.com/net/netcheck
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/routecheck from tailscale.com/client/local
tailscale.com/net/sockstats from tailscale.com/control/controlhttp+
tailscale.com/net/stun from tailscale.com/net/netcheck
tailscale.com/net/tlsdial from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/net/routecheck
tailscale.com/net/tsaddr from tailscale.com/client/web+
tailscale.com/net/tsdial from tailscale.com/cmd/tailscale/cli+
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
@@ -466,6 +468,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
hash from compress/zlib+
hash/adler32 from compress/zlib
hash/crc32 from compress/gzip+
hash/fnv from tailscale.com/net/traffic
hash/maphash from go4.org/mem
html from html/template+
html/template from tailscale.com/util/eventbus
+2 -1
View File
@@ -112,6 +112,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
github.com/go-json-experiment/json/v1 from tailscale.com/feature/routecheck
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+
@@ -372,7 +373,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/portmapper from tailscale.com/feature/portmapper+
tailscale.com/net/portmapper/portmappertype from tailscale.com/feature/portmapper+
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
tailscale.com/net/routecheck from tailscale.com/feature/routecheck
tailscale.com/net/routecheck from tailscale.com/feature/routecheck+
tailscale.com/net/routetable from tailscale.com/doctor/routetable
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
+2 -1
View File
@@ -206,13 +206,14 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/ipn/localapi+
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal+
tailscale.com/net/tsaddr from tailscale.com/client/web+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
+14
View File
@@ -5,9 +5,23 @@ package routecheck
import (
"tailscale.com/ipn/ipnext"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/net/routecheck"
)
// ClientFor returns the [routecheck.Client] for a given backend,
// or nil if route checking is not available for that backend.
func ClientFor(b *ipnlocal.LocalBackend) *routecheck.Client {
e, ok := ipnlocal.GetExt[*Extension](b)
if e == nil || !ok {
return nil
}
return e.Client
}
// Report contains the result of a single routecheck.
type Report = routecheck.Report
// NodeBackender is a shim between [ipnext.Host] and [routecheck.NodeBackender].
type nodeBackender struct{ ipnext.Host }
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routecheck
import (
"net/http"
"strconv"
"time"
jsonv2 "github.com/go-json-experiment/json"
jsonv1 "github.com/go-json-experiment/json/v1"
"tailscale.com/ipn/localapi"
"tailscale.com/net/routecheck"
"tailscale.com/util/httpm"
)
func init() {
localapi.Register("routecheck", serveRouteCheck)
}
// ServeRouteCheck handles the API endpoint that serves the routecheck Report.
// If the probe form field is true, then this handler will refresh the Report
// before serving it.
// If the timeout form field is a valid duration, the probe will consider a node
// to be unreachable if it doesnt respond before the timeout expires.
func serveRouteCheck(h *localapi.Handler, w http.ResponseWriter, r *http.Request) {
rc := ClientFor(h.LocalBackend())
if rc == nil {
http.Error(w, "routecheck is not enabled", http.StatusServiceUnavailable)
return
}
if r.Method != httpm.POST {
http.Error(w, "want POST", http.StatusMethodNotAllowed)
return
}
var err error
var report *routecheck.Report
if defBool(r.FormValue("probe"), false) {
timeout := defDuration(r.FormValue("timeout"), routecheck.DefaultTimeout)
timeout = min(max(0, timeout), 60*time.Second) // clamp to [0s, 60s]
report, err = rc.Refresh(r.Context(), timeout)
} else {
report = rc.Report()
}
if err != nil {
localapi.WriteErrorJSON(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
if report == nil {
w.WriteHeader(http.StatusNoContent)
return
}
// TODO(sfllaw): Since ipn/localapi is still using encoding/json
// with its default options, marshal with DefaultOptionsV1.
jsonv2.MarshalWrite(w, report, jsonv1.DefaultOptionsV1())
}
func defBool(a string, def bool) bool {
if a == "" {
return def
}
v, err := strconv.ParseBool(a)
if err != nil {
return def
}
return v
}
func defDuration(a string, def time.Duration) time.Duration {
if a == "" {
return def
}
v, err := time.ParseDuration(a)
if err != nil {
return def
}
return v
}
+16
View File
@@ -50,6 +50,12 @@ func (c *Client) probe(ctx context.Context, nodes iter.Seq[probed], limit int, t
var mu syncs.Mutex
r := &Report{}
timestampProbe := func(n probed) {
mu.Lock()
defer mu.Unlock()
mak.Set(&r.LastProbed, n.ID(), time.Now())
}
markReachable := func(n probed) {
mu.Lock()
defer mu.Unlock()
@@ -81,12 +87,22 @@ func (c *Client) probe(ctx context.Context, nodes iter.Seq[probed], limit int, t
// TODO(sfllaw): Add a mechanism to mark a node as unreachable
// because it fails of establish a new WireGuard connection.
if n.IsWireGuardOnly() {
timestampProbe(n)
markReachable(n)
continue
}
g.Go(func() error {
metricPing.Add(1)
// We record the timestamp of each nodes latest probe
// so we can probe in incremental batches
// and to limit the rate that any given node is pinged.
//
// TODO(sfllaw): We currently record the timestamp
// but havent implemented batching or rate-limiting yet.
defer timestampProbe(n)
// TODO(sfllaw): Why did we choose Disco ping instead of TSMP ping?
// After all, a TSMP ping proves that the peer Tailscale node is there
// and that both nodes know each others WireGuard keys,
+49 -8
View File
@@ -4,10 +4,16 @@
package routecheck
import (
"cmp"
"context"
"maps"
"net/netip"
"slices"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
)
@@ -26,9 +32,9 @@ func (c *Client) Report() *Report {
}
// TODO(sfllaw): Return the latest snapshot produced by background probing.
r, err := c.ProbeAllHARouters(context.TODO(), 5, DefaultTimeout)
r, err := c.Refresh(context.TODO(), DefaultTimeout)
if err != nil {
c.logf("reachability report error: %v", err)
c.logf("%v", err)
}
return r
}
@@ -36,26 +42,61 @@ func (c *Client) Report() *Report {
// Report contains the result of a single routecheck.
type Report struct {
// Done is the time when the report was finished.
Done time.Time
Done time.Time `json:"done"`
// Reachable is the set of nodes that were reachable from the current host
// when this report was compiled. Missing nodes may or may not be reachable.
Reachable map[tailcfg.NodeID]Node
Reachable NodeSet `json:"reachable"`
// LastProbed tracks the last time a given node was probed.
// This is used to rate-limit reachability probing, so an entrys
// presence doesnt imply that it is reachable.
LastProbed map[tailcfg.NodeID]time.Time `json:"-"` // not marshaled
}
// Node represents a node in the reachability report.
type Node struct {
ID tailcfg.NodeID
ID tailcfg.NodeID `json:"id"`
// Name is the FQDN of the node.
// It is also the MagicDNS name for the node.
// It has a trailing dot.
// e.g. "host.tail-scale.ts.net."
Name string
Name string `json:"name"`
// Addr is the IP address that was probed.
Addr netip.Addr
Addr netip.Addr `json:"addr"`
// Routes are the subnets that the node will route.
Routes []netip.Prefix
Routes []netip.Prefix `json:"routes"`
}
// NodeSet is a set of nodes keyed by node ID, so duplicates are easily detected.
// To prevent stuttering, it marshals itself as a JSON array, sorted by node ID.
type NodeSet map[tailcfg.NodeID]Node
var _ jsonv2.MarshalerTo = &NodeSet{}
var _ jsonv2.UnmarshalerFrom = &NodeSet{}
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (ns NodeSet) MarshalJSONTo(enc *jsontext.Encoder) error {
nodes := slices.SortedFunc(maps.Values(ns), func(a, b Node) int {
return cmp.Compare(a.ID, b.ID)
})
return jsonv2.MarshalEncode(enc, nodes)
}
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (ns *NodeSet) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var nodes []Node
if err := jsonv2.UnmarshalDecode(dec, &nodes); err != nil {
return err
}
if *ns == nil {
*ns = make(NodeSet, len(nodes))
}
for _, n := range nodes {
(*ns)[n.ID] = n
}
return nil
}
+18
View File
@@ -7,13 +7,20 @@ package routecheck
import (
"context"
"errors"
"fmt"
"net/netip"
"sync/atomic"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/util/clientmetric"
)
var (
metricRefresh = clientmetric.NewCounter("routecheck_refresh")
)
// Client generates Reports describing the result of both passive and active
@@ -150,6 +157,17 @@ func (c *Client) waitForNetMap(ctx context.Context) (*netmap.NetworkMap, error)
}
}
// Refresh generates a new reachability report and returns it.
// A peer is considered unreachable if it doesnt respond within the timeout.
func (c *Client) Refresh(ctx context.Context, timeout time.Duration) (*Report, error) {
metricRefresh.Add(1)
r, err := c.ProbeAllHARouters(ctx, 5, timeout)
if err != nil {
return nil, fmt.Errorf("error probing routers: %w", err)
}
return r, nil
}
// Close immediately stops all active probes.
func (c *Client) Close() error {
if c == nil {
+94 -19
View File
@@ -4,10 +4,12 @@
package routecheck_test
import (
"context"
"fmt"
"maps"
"net/netip"
"slices"
"sync/atomic"
"testing"
"testing/synctest"
"time"
@@ -24,7 +26,7 @@ import (
"tailscale.com/util/set"
)
func TestReport(t *testing.T) {
func TestRefresh(t *testing.T) {
for _, tt := range []struct {
name string
init bool // true before the netmap has been loaded
@@ -33,9 +35,13 @@ func TestReport(t *testing.T) {
want []tailcfg.NodeID // Report.Reachable nodes
}{
{
name: "before-netmap",
name: "wait-for-netmap",
init: true,
want: nil,
peers: []tailcfg.NodeView{
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
},
want: []tailcfg.NodeID{11, 12},
},
{
name: "no-peers",
@@ -126,28 +132,59 @@ func TestReport(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// The backend is initialized without a NetMap.
b := newStubBackend(tailcfg.NodeView{}, nil, withGone(tt.gone...))
self := makeNode(99, withName("self"))
var b *stubBackend
if !tt.init {
self := makeNode(99, withName("self"))
b = newStubBackend(self, tt.peers, withGone(tt.gone...))
b = newStubBackend(self, tt.peers,
withGone(t, tt.gone...))
} else {
// The backend is initialized without a NetMap,
// which gets “retrieved” after a delay.
b = newStubBackend(self, tt.peers,
withGone(t, tt.gone...),
withDelay(t, 10*time.Second))
}
t.Cleanup(func() { b.Close() })
c, err := routecheck.NewClient(t.Logf, b, b, b)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := c.Report()
now := time.Now() // synctest will freeze time.
if tt.init {
// This callback simulates the delay between
// connecting to the backend and receiving the NetMap.
donef := func() { c.NotifyNetMapAvailable(b.NetMapWithPeers()) }
b.donef.Store(&donef)
}
before := time.Now()
got, err := c.Refresh(t.Context(), routecheck.DefaultTimeout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now() // synctest will freeze time.
var want *routecheck.Report
peers := makeDB(tt.peers)
if !tt.init {
want = &routecheck.Report{
Done: now,
want := &routecheck.Report{
Done: after,
}
for _, nid := range tt.want {
mak.Set(&want.Reachable, nid, peers[nid])
}
for _, nodes := range c.RoutersByPrefix() {
if len(nodes) <= 1 {
continue // no choice
}
for _, nid := range tt.want {
mak.Set(&want.Reachable, nid, peers[nid])
for _, n := range nodes {
ts := before
if tt.init {
ts = after // waiting for netmap
}
if slices.Contains(tt.gone, n.ID()) {
ts = after // ping timed out
}
mak.Set(&want.LastProbed, n.ID(), ts)
}
}
@@ -350,6 +387,7 @@ func TestRoutersByPrefix(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
self := makeNode(99, withName("self"))
b := newStubBackend(self, tt.peers)
t.Cleanup(func() { b.Close() })
c, err := routecheck.NewClient(t.Logf, b, b, b)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -412,14 +450,26 @@ type stubBackend struct {
self tailcfg.NodeView
peers []tailcfg.NodeView
gone set.Set[tailcfg.NodeID]
delay context.Context
cancel context.CancelFunc
donef atomic.Pointer[func()]
}
type backendOptFunc func(*stubBackend)
func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...backendOptFunc) *stubBackend {
if !self.Valid() {
panic("invalid self")
}
delay, cancel := context.WithTimeout(context.Background(), 0) // No delay
b := &stubBackend{
self: self,
peers: slices.Clone(peers),
self: self,
peers: slices.Clone(peers),
delay: delay,
cancel: cancel,
}
for _, opt := range opts {
opt(b)
@@ -427,8 +477,16 @@ func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...bac
return b
}
func (b *stubBackend) Close() error {
if b.cancel != nil {
b.cancel()
}
return nil
}
func (b *stubBackend) NetMapNoPeers() *netmap.NetworkMap {
if !b.self.Valid() {
if b.delay.Err() == nil {
// Simulate the delay between startup and receiving the NetMap.
return nil
}
return &netmap.NetworkMap{
@@ -479,8 +537,25 @@ func (b *stubBackend) Ping(ip netip.Addr, pingType tailcfg.PingType, size int, c
}
}
func withGone(gone ...tailcfg.NodeID) backendOptFunc {
func withDelay(t *testing.T, d time.Duration) backendOptFunc {
return func(b *stubBackend) {
t.Helper()
var stopf func() bool
ctx, cancel := context.WithTimeout(t.Context(), d)
stopf = context.AfterFunc(ctx, func() {
if donef := b.donef.Load(); donef != nil {
(*donef)()
}
cancel()
stopf()
})
b.delay = ctx
}
}
func withGone(t *testing.T, gone ...tailcfg.NodeID) backendOptFunc {
return func(b *stubBackend) {
t.Helper()
b.gone = set.SetOf(gone)
}
+1 -2
View File
@@ -39,11 +39,10 @@ func (c *Client) RoutersByPrefix() RoutersByPrefix {
// The result omits any prefix that is one of the nodes local addresses.
func routes(n tailcfg.NodeView) []netip.Prefix {
var routes []netip.Prefix
AllowedIPs:
for _, pfx := range n.AllowedIPs().All() {
// Routers never forward their own local addresses.
if views.SliceContains(n.Addresses(), pfx) {
continue AllowedIPs
continue
}
routes = append(routes, pfx)
}
+2 -1
View File
@@ -202,13 +202,14 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/ipn/localapi+
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal+
tailscale.com/net/tsaddr from tailscale.com/client/web+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy