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:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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/netns from tailscale.com/derp/derphttp
|
||||||
tailscale.com/net/netutil from tailscale.com/client/local
|
tailscale.com/net/netutil from tailscale.com/client/local
|
||||||
tailscale.com/net/netx from tailscale.com/net/dnscache+
|
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/sockstats from tailscale.com/derp/derphttp
|
||||||
tailscale.com/net/stun from tailscale.com/net/stunserver
|
tailscale.com/net/stun from tailscale.com/net/stunserver
|
||||||
tailscale.com/net/stunserver from tailscale.com/cmd/derper
|
tailscale.com/net/stunserver from tailscale.com/cmd/derper
|
||||||
L tailscale.com/net/tcpinfo from tailscale.com/derp/derpserver
|
L tailscale.com/net/tcpinfo from tailscale.com/derp/derpserver
|
||||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
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/tsaddr from tailscale.com/ipn+
|
||||||
tailscale.com/net/udprelay/status from tailscale.com/client/local
|
tailscale.com/net/udprelay/status from tailscale.com/client/local
|
||||||
tailscale.com/net/wsconn from tailscale.com/derp/derpserver
|
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/key from tailscale.com/client/local+
|
||||||
tailscale.com/types/lazy from tailscale.com/version+
|
tailscale.com/types/lazy from tailscale.com/version+
|
||||||
tailscale.com/types/logger from tailscale.com/cmd/derper+
|
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/opt from tailscale.com/envknob+
|
||||||
tailscale.com/types/persist from tailscale.com/ipn+
|
tailscale.com/types/persist from tailscale.com/ipn+
|
||||||
tailscale.com/types/preftype 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
|
go/token from google.golang.org/protobuf/internal/strs
|
||||||
hash from crypto+
|
hash from crypto+
|
||||||
hash/crc32 from compress/gzip+
|
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+
|
hash/maphash from go4.org/mem+
|
||||||
html from net/http/pprof+
|
html from net/http/pprof+
|
||||||
html/template from tailscale.com/cmd/derper+
|
html/template from tailscale.com/cmd/derper+
|
||||||
|
|||||||
@@ -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 from tailscale.com/feature/portmapper
|
||||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
||||||
tailscale.com/net/proxymux from tailscale.com/tsnet
|
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/sockopts from tailscale.com/wgengine/magicsock
|
||||||
tailscale.com/net/socks5 from tailscale.com/tsnet
|
tailscale.com/net/socks5 from tailscale.com/tsnet
|
||||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||||
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
||||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
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/tsaddr from tailscale.com/client/web+
|
||||||
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
||||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
||||||
|
|||||||
@@ -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/ping from tailscale.com/net/netcheck
|
||||||
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
|
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
|
||||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
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/sockstats from tailscale.com/control/controlhttp+
|
||||||
tailscale.com/net/stun from tailscale.com/net/netcheck
|
tailscale.com/net/stun from tailscale.com/net/netcheck
|
||||||
tailscale.com/net/tlsdial from tailscale.com/cmd/tailscale/cli+
|
tailscale.com/net/tlsdial from tailscale.com/cmd/tailscale/cli+
|
||||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
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/tsaddr from tailscale.com/client/web+
|
||||||
tailscale.com/net/tsdial from tailscale.com/cmd/tailscale/cli+
|
tailscale.com/net/tsdial from tailscale.com/cmd/tailscale/cli+
|
||||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
💣 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 from compress/zlib+
|
||||||
hash/adler32 from compress/zlib
|
hash/adler32 from compress/zlib
|
||||||
hash/crc32 from compress/gzip+
|
hash/crc32 from compress/gzip+
|
||||||
|
hash/fnv from tailscale.com/net/traffic
|
||||||
hash/maphash from go4.org/mem
|
hash/maphash from go4.org/mem
|
||||||
html from html/template+
|
html from html/template+
|
||||||
html/template from tailscale.com/util/eventbus
|
html/template from tailscale.com/util/eventbus
|
||||||
|
|||||||
@@ -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/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/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/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 from github.com/go-ole/go-ole/oleutil+
|
||||||
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
|
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
|
||||||
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+
|
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 from tailscale.com/feature/portmapper+
|
||||||
tailscale.com/net/portmapper/portmappertype 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/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/routetable from tailscale.com/doctor/routetable
|
||||||
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+
|
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+
|
||||||
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
|
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
|
||||||
|
|||||||
@@ -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 from tailscale.com/feature/portmapper
|
||||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
||||||
tailscale.com/net/proxymux from tailscale.com/tsnet
|
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/sockopts from tailscale.com/wgengine/magicsock
|
||||||
tailscale.com/net/socks5 from tailscale.com/tsnet
|
tailscale.com/net/socks5 from tailscale.com/tsnet
|
||||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||||
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
||||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
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/tsaddr from tailscale.com/client/web+
|
||||||
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
||||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
||||||
|
|||||||
@@ -5,9 +5,23 @@ package routecheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"tailscale.com/ipn/ipnext"
|
"tailscale.com/ipn/ipnext"
|
||||||
|
"tailscale.com/ipn/ipnlocal"
|
||||||
"tailscale.com/net/routecheck"
|
"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].
|
// NodeBackender is a shim between [ipnext.Host] and [routecheck.NodeBackender].
|
||||||
type nodeBackender struct{ ipnext.Host }
|
type nodeBackender struct{ ipnext.Host }
|
||||||
|
|
||||||
|
|||||||
@@ -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 doesn’t 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
|
||||||
|
}
|
||||||
@@ -50,6 +50,12 @@ func (c *Client) probe(ctx context.Context, nodes iter.Seq[probed], limit int, t
|
|||||||
var mu syncs.Mutex
|
var mu syncs.Mutex
|
||||||
r := &Report{}
|
r := &Report{}
|
||||||
|
|
||||||
|
timestampProbe := func(n probed) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
mak.Set(&r.LastProbed, n.ID(), time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
markReachable := func(n probed) {
|
markReachable := func(n probed) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
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
|
// TODO(sfllaw): Add a mechanism to mark a node as unreachable
|
||||||
// because it fails of establish a new WireGuard connection.
|
// because it fails of establish a new WireGuard connection.
|
||||||
if n.IsWireGuardOnly() {
|
if n.IsWireGuardOnly() {
|
||||||
|
timestampProbe(n)
|
||||||
markReachable(n)
|
markReachable(n)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
metricPing.Add(1)
|
metricPing.Add(1)
|
||||||
|
|
||||||
|
// We record the timestamp of each node’s 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 haven’t implemented batching or rate-limiting yet.
|
||||||
|
defer timestampProbe(n)
|
||||||
|
|
||||||
// TODO(sfllaw): Why did we choose Disco ping instead of TSMP ping?
|
// 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
|
// After all, a TSMP ping proves that the peer Tailscale node is there
|
||||||
// and that both nodes know each other’s WireGuard keys,
|
// and that both nodes know each other’s WireGuard keys,
|
||||||
|
|||||||
@@ -4,10 +4,16 @@
|
|||||||
package routecheck
|
package routecheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
"github.com/go-json-experiment/json/jsontext"
|
||||||
|
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/util/clientmetric"
|
"tailscale.com/util/clientmetric"
|
||||||
)
|
)
|
||||||
@@ -26,9 +32,9 @@ func (c *Client) Report() *Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO(sfllaw): Return the latest snapshot produced by background probing.
|
// 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 {
|
if err != nil {
|
||||||
c.logf("reachability report error: %v", err)
|
c.logf("%v", err)
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -36,26 +42,61 @@ func (c *Client) Report() *Report {
|
|||||||
// Report contains the result of a single routecheck.
|
// Report contains the result of a single routecheck.
|
||||||
type Report struct {
|
type Report struct {
|
||||||
// Done is the time when the report was finished.
|
// 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
|
// 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.
|
// 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 entry’s
|
||||||
|
// presence doesn’t imply that it is reachable.
|
||||||
|
LastProbed map[tailcfg.NodeID]time.Time `json:"-"` // not marshaled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node represents a node in the reachability report.
|
// Node represents a node in the reachability report.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID tailcfg.NodeID
|
ID tailcfg.NodeID `json:"id"`
|
||||||
|
|
||||||
// Name is the FQDN of the node.
|
// Name is the FQDN of the node.
|
||||||
// It is also the MagicDNS name for the node.
|
// It is also the MagicDNS name for the node.
|
||||||
// It has a trailing dot.
|
// It has a trailing dot.
|
||||||
// e.g. "host.tail-scale.ts.net."
|
// e.g. "host.tail-scale.ts.net."
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
|
|
||||||
// Addr is the IP address that was probed.
|
// 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 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ package routecheck
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
"tailscale.com/types/netmap"
|
"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
|
// 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 doesn’t 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.
|
// Close immediately stops all active probes.
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
package routecheck_test
|
package routecheck_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,7 +26,7 @@ import (
|
|||||||
"tailscale.com/util/set"
|
"tailscale.com/util/set"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReport(t *testing.T) {
|
func TestRefresh(t *testing.T) {
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name string
|
||||||
init bool // true before the netmap has been loaded
|
init bool // true before the netmap has been loaded
|
||||||
@@ -33,9 +35,13 @@ func TestReport(t *testing.T) {
|
|||||||
want []tailcfg.NodeID // Report.Reachable nodes
|
want []tailcfg.NodeID // Report.Reachable nodes
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "before-netmap",
|
name: "wait-for-netmap",
|
||||||
init: true,
|
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",
|
name: "no-peers",
|
||||||
@@ -126,28 +132,59 @@ func TestReport(t *testing.T) {
|
|||||||
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// The backend is initialized without a NetMap.
|
self := makeNode(99, withName("self"))
|
||||||
b := newStubBackend(tailcfg.NodeView{}, nil, withGone(tt.gone...))
|
var b *stubBackend
|
||||||
if !tt.init {
|
if !tt.init {
|
||||||
self := makeNode(99, withName("self"))
|
b = newStubBackend(self, tt.peers,
|
||||||
b = newStubBackend(self, tt.peers, withGone(tt.gone...))
|
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)
|
c, err := routecheck.NewClient(t.Logf, b, b, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := c.Report()
|
if tt.init {
|
||||||
now := time.Now() // synctest will freeze time.
|
// 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)
|
peers := makeDB(tt.peers)
|
||||||
if !tt.init {
|
want := &routecheck.Report{
|
||||||
want = &routecheck.Report{
|
Done: after,
|
||||||
Done: now,
|
}
|
||||||
|
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 {
|
for _, n := range nodes {
|
||||||
mak.Set(&want.Reachable, nid, peers[nid])
|
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) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
self := makeNode(99, withName("self"))
|
self := makeNode(99, withName("self"))
|
||||||
b := newStubBackend(self, tt.peers)
|
b := newStubBackend(self, tt.peers)
|
||||||
|
t.Cleanup(func() { b.Close() })
|
||||||
c, err := routecheck.NewClient(t.Logf, b, b, b)
|
c, err := routecheck.NewClient(t.Logf, b, b, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -412,14 +450,26 @@ type stubBackend struct {
|
|||||||
self tailcfg.NodeView
|
self tailcfg.NodeView
|
||||||
peers []tailcfg.NodeView
|
peers []tailcfg.NodeView
|
||||||
gone set.Set[tailcfg.NodeID]
|
gone set.Set[tailcfg.NodeID]
|
||||||
|
|
||||||
|
delay context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
donef atomic.Pointer[func()]
|
||||||
}
|
}
|
||||||
|
|
||||||
type backendOptFunc func(*stubBackend)
|
type backendOptFunc func(*stubBackend)
|
||||||
|
|
||||||
func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...backendOptFunc) *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{
|
b := &stubBackend{
|
||||||
self: self,
|
self: self,
|
||||||
peers: slices.Clone(peers),
|
peers: slices.Clone(peers),
|
||||||
|
delay: delay,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(b)
|
opt(b)
|
||||||
@@ -427,8 +477,16 @@ func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...bac
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *stubBackend) Close() error {
|
||||||
|
if b.cancel != nil {
|
||||||
|
b.cancel()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *stubBackend) NetMapNoPeers() *netmap.NetworkMap {
|
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 nil
|
||||||
}
|
}
|
||||||
return &netmap.NetworkMap{
|
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) {
|
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)
|
b.gone = set.SetOf(gone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,10 @@ func (c *Client) RoutersByPrefix() RoutersByPrefix {
|
|||||||
// The result omits any prefix that is one of the node’s local addresses.
|
// The result omits any prefix that is one of the node’s local addresses.
|
||||||
func routes(n tailcfg.NodeView) []netip.Prefix {
|
func routes(n tailcfg.NodeView) []netip.Prefix {
|
||||||
var routes []netip.Prefix
|
var routes []netip.Prefix
|
||||||
AllowedIPs:
|
|
||||||
for _, pfx := range n.AllowedIPs().All() {
|
for _, pfx := range n.AllowedIPs().All() {
|
||||||
// Routers never forward their own local addresses.
|
// Routers never forward their own local addresses.
|
||||||
if views.SliceContains(n.Addresses(), pfx) {
|
if views.SliceContains(n.Addresses(), pfx) {
|
||||||
continue AllowedIPs
|
continue
|
||||||
}
|
}
|
||||||
routes = append(routes, pfx)
|
routes = append(routes, pfx)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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 from tailscale.com/feature/portmapper
|
||||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
||||||
tailscale.com/net/proxymux from tailscale.com/tsnet
|
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/sockopts from tailscale.com/wgengine/magicsock
|
||||||
tailscale.com/net/socks5 from tailscale.com/tsnet
|
tailscale.com/net/socks5 from tailscale.com/tsnet
|
||||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||||
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
||||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
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/tsaddr from tailscale.com/client/web+
|
||||||
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
||||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
||||||
|
|||||||
Reference in New Issue
Block a user