ipn/ipnlocal: add a C2N endpoint for fetching a netmap
For debugging purposes, add a new C2N endpoint returning the current netmap. Optionally, coordination server can send a new "candidate" map response, which the client will generate a separate netmap for. Coordination server can later compare two netmaps, detecting unexpected changes to the client state. Updates tailscale/corp#32095 Signed-off-by: Anton Tolchanov <anton@tailscale.com>
This commit is contained in:
committed by
Anton Tolchanov
parent
394718a4ca
commit
4a04161828
@@ -13,19 +13,23 @@ import (
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/sockstats"
|
||||
"tailscale.com/posture"
|
||||
"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/util/syspolicy/pkey"
|
||||
"tailscale.com/util/syspolicy/ptype"
|
||||
@@ -44,6 +48,7 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
|
||||
req("/debug/metrics"): handleC2NDebugMetrics,
|
||||
req("/debug/component-logging"): handleC2NDebugComponentLogging,
|
||||
req("/debug/logheap"): handleC2NDebugLogHeap,
|
||||
req("/debug/netmap"): handleC2NDebugNetMap,
|
||||
|
||||
// PPROF - We only expose a subset of typical pprof endpoints for security.
|
||||
req("/debug/pprof/heap"): handleC2NPprof,
|
||||
@@ -142,6 +147,66 @@ func handleC2NLogtailFlush(b *LocalBackend, w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
func handleC2NDebugNetMap(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
}
|
||||
nm, _ = redactNetmapPrivateKeys(nm)
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(goroutines.ScrubbedGoroutineDump(true))
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -18,8 +20,15 @@ import (
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/must"
|
||||
|
||||
gcmp "github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
func TestHandleC2NTLSCertStatus(t *testing.T) {
|
||||
@@ -132,3 +141,177 @@ func TestHandleC2NTLSCertStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// reflectNonzero returns a non-zero value for a given reflect.Value.
|
||||
func reflectNonzero(t reflect.Type) reflect.Value {
|
||||
switch t.Kind() {
|
||||
case reflect.Bool:
|
||||
return reflect.ValueOf(true)
|
||||
case reflect.String:
|
||||
if reflect.TypeFor[opt.Bool]() == t {
|
||||
return reflect.ValueOf("true").Convert(t)
|
||||
}
|
||||
return reflect.ValueOf("foo").Convert(t)
|
||||
case reflect.Int64:
|
||||
return reflect.ValueOf(int64(1)).Convert(t)
|
||||
case reflect.Slice:
|
||||
return reflect.MakeSlice(t, 1, 1)
|
||||
case reflect.Ptr:
|
||||
return reflect.New(t.Elem())
|
||||
case reflect.Map:
|
||||
return reflect.MakeMap(t)
|
||||
case reflect.Struct:
|
||||
switch t {
|
||||
case reflect.TypeFor[key.NodePrivate]():
|
||||
return reflect.ValueOf(key.NewNode())
|
||||
}
|
||||
}
|
||||
panic(fmt.Sprintf("unhandled %v", t))
|
||||
}
|
||||
|
||||
// setFieldsToRedact sets fields in the given netmap to non-zero values
|
||||
// according to the fieldMap, which maps field names to whether they
|
||||
// should be reset (true) or not (false).
|
||||
func setFieldsToRedact(t *testing.T, nm *netmap.NetworkMap, fieldMap map[string]bool) {
|
||||
t.Helper()
|
||||
v := reflect.ValueOf(nm).Elem()
|
||||
for i := range v.NumField() {
|
||||
name := v.Type().Field(i).Name
|
||||
f := v.Field(i)
|
||||
if !f.CanSet() {
|
||||
continue
|
||||
}
|
||||
shouldReset, ok := fieldMap[name]
|
||||
if !ok {
|
||||
t.Errorf("fieldMap missing field %q", name)
|
||||
}
|
||||
if shouldReset {
|
||||
f.Set(reflectNonzero(f.Type()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactNetmapPrivateKeys(t *testing.T) {
|
||||
fieldMap := map[string]bool{
|
||||
// Private fields (should be redacted):
|
||||
"PrivateKey": true,
|
||||
|
||||
// Public fields (should not be redacted):
|
||||
"AllCaps": false,
|
||||
"CollectServices": false,
|
||||
"DERPMap": false,
|
||||
"DNS": false,
|
||||
"DisplayMessages": false,
|
||||
"Domain": false,
|
||||
"DomainAuditLogID": false,
|
||||
"Expiry": false,
|
||||
"MachineKey": false,
|
||||
"Name": false,
|
||||
"NodeKey": false,
|
||||
"PacketFilter": false,
|
||||
"PacketFilterRules": false,
|
||||
"Peers": false,
|
||||
"SSHPolicy": false,
|
||||
"SelfNode": false,
|
||||
"TKAEnabled": false,
|
||||
"TKAHead": false,
|
||||
"UserProfiles": false,
|
||||
}
|
||||
|
||||
nm := &netmap.NetworkMap{}
|
||||
setFieldsToRedact(t, nm, fieldMap)
|
||||
|
||||
got, _ := redactNetmapPrivateKeys(nm)
|
||||
if !reflect.DeepEqual(got, &netmap.NetworkMap{}) {
|
||||
t.Errorf("redacted netmap is not empty: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleC2NDebugNetmap(t *testing.T) {
|
||||
nm := &netmap.NetworkMap{
|
||||
Name: "myhost",
|
||||
SelfNode: (&tailcfg.Node{
|
||||
ID: 100,
|
||||
Name: "myhost",
|
||||
StableID: "deadbeef",
|
||||
Key: key.NewNode().Public(),
|
||||
Hostinfo: (&tailcfg.Hostinfo{Hostname: "myhost"}).View(),
|
||||
}).View(),
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
ID: 101,
|
||||
Name: "peer1",
|
||||
StableID: "deadbeef",
|
||||
Key: key.NewNode().Public(),
|
||||
Hostinfo: (&tailcfg.Hostinfo{Hostname: "peer1"}).View(),
|
||||
}).View(),
|
||||
},
|
||||
PrivateKey: key.NewNode(),
|
||||
}
|
||||
withoutPrivateKey := *nm
|
||||
withoutPrivateKey.PrivateKey = key.NodePrivate{}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
req *tailcfg.C2NDebugNetmapRequest
|
||||
want *netmap.NetworkMap
|
||||
}{
|
||||
{
|
||||
name: "simple_get",
|
||||
want: &withoutPrivateKey,
|
||||
},
|
||||
{
|
||||
name: "post_no_omit",
|
||||
req: &tailcfg.C2NDebugNetmapRequest{},
|
||||
want: &withoutPrivateKey,
|
||||
},
|
||||
{
|
||||
name: "post_omit_peers_and_name",
|
||||
req: &tailcfg.C2NDebugNetmapRequest{OmitFields: []string{"Peers", "Name"}},
|
||||
want: &netmap.NetworkMap{
|
||||
SelfNode: nm.SelfNode,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "post_omit_nonexistent_field",
|
||||
req: &tailcfg.C2NDebugNetmapRequest{OmitFields: []string{"ThisFieldDoesNotExist"}},
|
||||
want: &withoutPrivateKey,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := newTestLocalBackend(t)
|
||||
b.currentNode().SetNetMap(nm)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/debug/netmap", nil)
|
||||
if tt.req != nil {
|
||||
b, err := json.Marshal(tt.req)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/debug/netmap", bytes.NewReader(b))
|
||||
}
|
||||
handleC2NDebugNetMap(b, rec, req)
|
||||
res := rec.Result()
|
||||
wantStatus := 200
|
||||
if res.StatusCode != wantStatus {
|
||||
t.Fatalf("status code = %v; want %v. Body: %s", res.Status, wantStatus, rec.Body.Bytes())
|
||||
}
|
||||
var resp tailcfg.C2NDebugNetmapResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
got := &netmap.NetworkMap{}
|
||||
if err := json.Unmarshal(resp.Current, got); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
|
||||
if diff := gcmp.Diff(tt.want, got,
|
||||
gcmp.AllowUnexported(netmap.NetworkMap{}, key.NodePublic{}, views.Slice[tailcfg.FilterRule]{}),
|
||||
cmpopts.EquateComparable(key.MachinePublic{}),
|
||||
); diff != "" {
|
||||
t.Errorf("netmap mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+24
-4
@@ -1223,6 +1223,13 @@ func (b *LocalBackend) sanitizedPrefsLocked() ipn.PrefsView {
|
||||
return stripKeysFromPrefs(b.pm.CurrentPrefs())
|
||||
}
|
||||
|
||||
// unsanitizedPersist returns the current PersistView, including any private keys.
|
||||
func (b *LocalBackend) unsanitizedPersist() persist.PersistView {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.pm.CurrentPrefs().Persist()
|
||||
}
|
||||
|
||||
// Status returns the latest status of the backend and its
|
||||
// sub-components.
|
||||
func (b *LocalBackend) Status() *ipnstate.Status {
|
||||
@@ -3257,21 +3264,34 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
|
||||
// listener.
|
||||
func filterPrivateKeys(fn func(roNotify *ipn.Notify) (keepGoing bool)) func(*ipn.Notify) bool {
|
||||
return func(n *ipn.Notify) bool {
|
||||
if n.NetMap == nil || n.NetMap.PrivateKey.IsZero() {
|
||||
redacted, changed := redactNetmapPrivateKeys(n.NetMap)
|
||||
if !changed {
|
||||
return fn(n)
|
||||
}
|
||||
|
||||
// The netmap in n is shared across all watchers, so to mutate it for a
|
||||
// single watcher we have to clone the notify and the netmap. We can
|
||||
// make shallow clones, at least.
|
||||
nm2 := *n.NetMap
|
||||
n2 := *n
|
||||
n2.NetMap = &nm2
|
||||
n2.NetMap.PrivateKey = key.NodePrivate{}
|
||||
n2.NetMap = redacted
|
||||
return fn(&n2)
|
||||
}
|
||||
}
|
||||
|
||||
// redactNetmapPrivateKeys returns a copy of nm with private keys zeroed out.
|
||||
// If no change was needed, it returns nm unmodified.
|
||||
func redactNetmapPrivateKeys(nm *netmap.NetworkMap) (redacted *netmap.NetworkMap, changed bool) {
|
||||
if nm == nil || nm.PrivateKey.IsZero() {
|
||||
return nm, false
|
||||
}
|
||||
|
||||
// The netmap might be shared across watchers, so make at least a shallow
|
||||
// clone before mutating it.
|
||||
nm2 := *nm
|
||||
nm2.PrivateKey = key.NodePrivate{}
|
||||
return &nm2, true
|
||||
}
|
||||
|
||||
// appendHealthActions returns an IPN listener func that wraps the supplied IPN
|
||||
// listener func and transforms health messages passed to the wrapped listener.
|
||||
// If health messages with PrimaryActions are present, it appends the label &
|
||||
|
||||
Reference in New Issue
Block a user