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
+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
}