Files
tailscale/ipn/ipnlocal/c2n.go
Brad Fitzpatrick 653d0738f9 types/netmap: remove PrivateKey from NetworkMap
It's an unnecessary nuisance having it. We go out of our way to redact
it in so many places when we don't even need it there anyway.

Updates #12639

Change-Id: I5fc72e19e9cf36caeb42cf80ba430873f67167c3
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-11-16 15:32:51 -08:00

349 lines
10 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// 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.HasSSH {
RegisterC2N("/ssh/usernames", handleC2NSSHUsernames)
}
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 handleC2NSSHUsernames(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasSSH {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
var req tailcfg.C2NSSHUsernamesRequest
if r.Method == "POST" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
res, err := b.getSSHUsernames(&req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, res)
}
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)
}