Move all the FooForTest methods on LocalBackend to instead be methods on a new unexported forTest type which is then given out to callers in other packages via an exported ForTest method (panicking in non-test contexts) that returns that unexported type. This is unusual style (exported returning unexported) but declutters godoc and makes call sites both more explicit and easier to read without the "ForTest" suffix polluting the symbols. Now FooForTest() changes into ForTest().Foo(). This was motivated by a pending change moving a bunch of code out of LocalBackend into other packages that required adding more ForTest methods to LocalBackend to keep the tests (now in other packages) working. Instead, do this refactor now so the future change is prettier. Updates #12614 Updates #cleanup Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: Ib25e6d76d48dc8622ac3a955e0b1220d582e63a8
326 lines
9.5 KiB
Go
326 lines
9.5 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package ipnlocal
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"reflect"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"tailscale.com/control/controlclient"
|
|
"tailscale.com/feature"
|
|
"tailscale.com/feature/buildfeatures"
|
|
"tailscale.com/health"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/net/sockstats"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/netmap"
|
|
"tailscale.com/util/clientmetric"
|
|
"tailscale.com/util/goroutines"
|
|
"tailscale.com/util/httpm"
|
|
"tailscale.com/util/set"
|
|
"tailscale.com/version"
|
|
)
|
|
|
|
// c2nHandlers maps an HTTP method and URI path (without query parameters) to
|
|
// its handler. The exact method+path match is preferred, but if no entry
|
|
// exists for that, a map entry with an empty method is used as a fallback.
|
|
var c2nHandlers map[methodAndPath]c2nHandler
|
|
|
|
func init() {
|
|
c2nHandlers = map[methodAndPath]c2nHandler{}
|
|
if buildfeatures.HasC2N {
|
|
// Echo is the basic "ping" handler as used by the control plane to probe
|
|
// whether a node is reachable. In particular, it's important for
|
|
// high-availability subnet routers for the control plane to probe which of
|
|
// several candidate nodes is reachable and actually alive.
|
|
RegisterC2N("/echo", handleC2NEcho)
|
|
}
|
|
if buildfeatures.HasLogTail {
|
|
RegisterC2N("POST /logtail/flush", handleC2NLogtailFlush)
|
|
}
|
|
if buildfeatures.HasDebug {
|
|
RegisterC2N("POST /sockstats", handleC2NSockStats)
|
|
|
|
// pprof:
|
|
// we only expose a subset of typical pprof endpoints for security.
|
|
RegisterC2N("/debug/pprof/heap", handleC2NPprof)
|
|
RegisterC2N("/debug/pprof/allocs", handleC2NPprof)
|
|
|
|
RegisterC2N("/debug/goroutines", handleC2NDebugGoroutines)
|
|
RegisterC2N("/debug/prefs", handleC2NDebugPrefs)
|
|
RegisterC2N("/debug/metrics", handleC2NDebugMetrics)
|
|
RegisterC2N("/debug/component-logging", handleC2NDebugComponentLogging)
|
|
RegisterC2N("/debug/logheap", handleC2NDebugLogHeap)
|
|
RegisterC2N("/debug/netmap", handleC2NDebugNetMap)
|
|
RegisterC2N("/debug/health", handleC2NDebugHealth)
|
|
}
|
|
if runtime.GOOS == "linux" && buildfeatures.HasOSRouter {
|
|
RegisterC2N("POST /netfilter-kind", handleC2NSetNetfilterKind)
|
|
}
|
|
}
|
|
|
|
// RegisterC2N registers a new c2n handler for the given pattern.
|
|
//
|
|
// A pattern is like "GET /foo" (specific to an HTTP method) or "/foo" (all
|
|
// methods). It panics if the pattern is already registered.
|
|
func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
|
|
if !buildfeatures.HasC2N {
|
|
return
|
|
}
|
|
k := req(pattern)
|
|
if _, ok := c2nHandlers[k]; ok {
|
|
panic(fmt.Sprintf("c2n: duplicate handler for %q", pattern))
|
|
}
|
|
c2nHandlers[k] = h
|
|
}
|
|
|
|
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
|
|
|
|
type methodAndPath struct {
|
|
method string // empty string means fallback
|
|
path string // Request.URL.Path (without query string)
|
|
}
|
|
|
|
func req(s string) methodAndPath {
|
|
if m, p, ok := strings.Cut(s, " "); ok {
|
|
return methodAndPath{m, p}
|
|
}
|
|
return methodAndPath{"", s}
|
|
}
|
|
|
|
// c2nHandlerPaths is all the set of paths from c2nHandlers, without their HTTP methods.
|
|
// It's used to detect requests with a non-matching method.
|
|
var c2nHandlerPaths = set.Set[string]{}
|
|
|
|
func init() {
|
|
for k := range c2nHandlers {
|
|
c2nHandlerPaths.Add(k.path)
|
|
}
|
|
}
|
|
|
|
func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
|
|
// First try to match by both method and path,
|
|
if h, ok := c2nHandlers[methodAndPath{r.Method, r.URL.Path}]; ok {
|
|
h(b, w, r)
|
|
return
|
|
}
|
|
// Then try to match by just path.
|
|
if h, ok := c2nHandlers[methodAndPath{path: r.URL.Path}]; ok {
|
|
h(b, w, r)
|
|
return
|
|
}
|
|
if c2nHandlerPaths.Contains(r.URL.Path) {
|
|
http.Error(w, "bad method", http.StatusMethodNotAllowed)
|
|
} else {
|
|
http.Error(w, "unknown c2n path", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func handleC2NEcho(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
// Test handler.
|
|
body, _ := io.ReadAll(r.Body)
|
|
w.Write(body)
|
|
}
|
|
|
|
func handleC2NLogtailFlush(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if b.TryFlushLogs() {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
} else {
|
|
http.Error(w, "no log flusher wired up", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func handleC2NDebugHealth(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
var st *health.State
|
|
if buildfeatures.HasDebug && b.health != nil {
|
|
st = b.health.CurrentState()
|
|
}
|
|
writeJSON(w, st)
|
|
}
|
|
|
|
func handleC2NDebugNetMap(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if !buildfeatures.HasDebug {
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
if r.Method != httpm.POST && r.Method != httpm.GET {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
b.logf("c2n: %s /debug/netmap received", r.Method)
|
|
|
|
// redactAndMarshal redacts private keys from the given netmap, clears fields
|
|
// that should be omitted, and marshals it to JSON.
|
|
redactAndMarshal := func(nm *netmap.NetworkMap, omitFields []string) (json.RawMessage, error) {
|
|
for _, f := range omitFields {
|
|
field := reflect.ValueOf(nm).Elem().FieldByName(f)
|
|
if !field.IsValid() {
|
|
b.logf("c2n: /debug/netmap: unknown field %q in omitFields", f)
|
|
continue
|
|
}
|
|
field.SetZero()
|
|
}
|
|
return json.Marshal(nm)
|
|
}
|
|
|
|
var omitFields []string
|
|
resp := &tailcfg.C2NDebugNetmapResponse{}
|
|
|
|
if r.Method == httpm.POST {
|
|
var req tailcfg.C2NDebugNetmapRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to decode request body: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
omitFields = req.OmitFields
|
|
|
|
if req.Candidate != nil {
|
|
cand, err := controlclient.NetmapFromMapResponseForDebug(ctx, b.unsanitizedPersist(), req.Candidate)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to convert candidate MapResponse: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
candJSON, err := redactAndMarshal(cand, omitFields)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to marshal candidate netmap: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
resp.Candidate = candJSON
|
|
}
|
|
}
|
|
|
|
var err error
|
|
resp.Current, err = redactAndMarshal(b.currentNode().netMapWithPeers(), omitFields)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to marshal current netmap: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
func handleC2NDebugGoroutines(_ *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if !buildfeatures.HasDebug {
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write(goroutines.ScrubbedGoroutineDump(true))
|
|
}
|
|
|
|
func handleC2NDebugPrefs(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if !buildfeatures.HasDebug {
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
return
|
|
}
|
|
writeJSON(w, b.Prefs())
|
|
}
|
|
|
|
func handleC2NDebugMetrics(_ *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if !buildfeatures.HasDebug {
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
clientmetric.WritePrometheusExpositionFormat(w)
|
|
}
|
|
|
|
func handleC2NDebugComponentLogging(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if !buildfeatures.HasDebug {
|
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
return
|
|
}
|
|
component := r.FormValue("component")
|
|
secs, _ := strconv.Atoi(r.FormValue("secs"))
|
|
if secs == 0 {
|
|
secs -= 1
|
|
}
|
|
until := b.clock.Now().Add(time.Duration(secs) * time.Second)
|
|
err := b.SetComponentDebugLogging(component, until)
|
|
var res struct {
|
|
Error string `json:",omitempty"`
|
|
}
|
|
if err != nil {
|
|
res.Error = err.Error()
|
|
}
|
|
writeJSON(w, res)
|
|
}
|
|
|
|
var c2nLogHeap func(http.ResponseWriter, *http.Request) // non-nil on most platforms (c2n_pprof.go)
|
|
|
|
func handleC2NDebugLogHeap(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if c2nLogHeap == nil {
|
|
// Not implemented on platforms trying to optimize for binary size or
|
|
// reduced memory usage.
|
|
http.Error(w, "not implemented", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
c2nLogHeap(w, r)
|
|
}
|
|
|
|
var c2nPprof func(http.ResponseWriter, *http.Request, string) // non-nil on most platforms (c2n_pprof.go)
|
|
|
|
func handleC2NPprof(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
if c2nPprof == nil {
|
|
// Not implemented on platforms trying to optimize for binary size or
|
|
// reduced memory usage.
|
|
http.Error(w, "not implemented", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
_, profile := path.Split(r.URL.Path)
|
|
c2nPprof(w, r, profile)
|
|
}
|
|
|
|
func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
if b.sockstatLogger == nil {
|
|
http.Error(w, "no sockstatLogger", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
b.sockstatLogger.Flush()
|
|
fmt.Fprintf(w, "logid: %s\n", b.sockstatLogger.LogID())
|
|
fmt.Fprintf(w, "debug info: %v\n", sockstats.DebugInfo())
|
|
}
|
|
|
|
func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
b.logf("c2n: POST /netfilter-kind received")
|
|
|
|
if version.OS() != "linux" {
|
|
http.Error(w, "netfilter kind only settable on linux", http.StatusNotImplemented)
|
|
}
|
|
|
|
kind := r.FormValue("kind")
|
|
b.logf("c2n: switching netfilter to %s", kind)
|
|
|
|
_, err := b.EditPrefs(&ipn.MaskedPrefs{
|
|
NetfilterKindSet: true,
|
|
Prefs: ipn.Prefs{
|
|
NetfilterKind: kind,
|
|
},
|
|
})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
b.authReconfig()
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|