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>
This commit is contained in:
Brad Fitzpatrick
2025-11-15 13:28:29 -08:00
committed by Brad Fitzpatrick
parent 98aadbaf54
commit 653d0738f9
25 changed files with 292 additions and 462 deletions

View File

@@ -74,7 +74,7 @@ const (
NotifyInitialPrefs NotifyWatchOpt = 1 << 2 // if set, the first Notify message (sent immediately) will contain the current Prefs
NotifyInitialNetMap NotifyWatchOpt = 1 << 3 // if set, the first Notify message (sent immediately) will contain the current NetMap
NotifyNoPrivateKeys NotifyWatchOpt = 1 << 4 // if set, private keys that would normally be sent in updates are zeroed out
NotifyNoPrivateKeys NotifyWatchOpt = 1 << 4 // (no-op) it used to redact private keys; now they always are and this does nothing
NotifyInitialDriveShares NotifyWatchOpt = 1 << 5 // if set, the first Notify message (sent immediately) will contain the current Taildrive Shares
NotifyInitialOutgoingFiles NotifyWatchOpt = 1 << 6 // if set, the first Notify message (sent immediately) will contain the current Taildrop OutgoingFiles

View File

@@ -179,7 +179,6 @@ func handleC2NDebugNetMap(b *LocalBackend, w http.ResponseWriter, r *http.Reques
}
field.SetZero()
}
nm, _ = redactNetmapPrivateKeys(nm)
return json.Marshal(nm)
}

View File

@@ -13,21 +13,17 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/ipproto"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/must"
"tailscale.com/util/set"
"tailscale.com/wgengine/filter/filtertype"
gcmp "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@@ -144,338 +140,6 @@ func TestHandleC2NTLSCertStatus(t *testing.T) {
}
// eachStructField calls cb for each struct field in struct type tp, recursively.
func eachStructField(tp reflect.Type, cb func(reflect.Type, reflect.StructField)) {
if !strings.HasPrefix(tp.PkgPath(), "tailscale.com/") {
// Stop traversing when we reach a non-tailscale type.
return
}
for i := range tp.NumField() {
cb(tp, tp.Field(i))
switch tp.Field(i).Type.Kind() {
case reflect.Struct:
eachStructField(tp.Field(i).Type, cb)
case reflect.Slice, reflect.Array, reflect.Ptr, reflect.Map:
if tp.Field(i).Type.Elem().Kind() == reflect.Struct {
eachStructField(tp.Field(i).Type.Elem(), cb)
}
}
}
}
// eachStructValue calls cb for each struct field in the struct value v, recursively.
func eachStructValue(v reflect.Value, cb func(reflect.Type, reflect.StructField, reflect.Value)) {
if v.IsZero() {
return
}
for i := range v.NumField() {
cb(v.Type(), v.Type().Field(i), v.Field(i))
switch v.Type().Field(i).Type.Kind() {
case reflect.Struct:
eachStructValue(v.Field(i), cb)
case reflect.Slice, reflect.Array, reflect.Ptr, reflect.Map:
if v.Field(i).Type().Elem().Kind() == reflect.Struct {
eachStructValue(v.Field(i).Addr().Elem(), cb)
}
}
}
}
// TestRedactNetmapPrivateKeys tests that redactNetmapPrivateKeys redacts all private keys
// and other private fields from a netmap.NetworkMap, and only those fields.
func TestRedactNetmapPrivateKeys(t *testing.T) {
type field struct {
t reflect.Type
f string
}
f := func(t any, f string) field {
return field{reflect.TypeOf(t), f}
}
// fields is a map of all struct fields in netmap.NetworkMap and its
// sub-structs, marking each field as private (true) or public (false).
// If you add a new field to netmap.NetworkMap or its sub-structs,
// you must add it to this list, marking it as private or public.
fields := map[field]bool{
// Private fields to be redacted.
f(netmap.NetworkMap{}, "PrivateKey"): true,
// All other fields are public.
f(netmap.NetworkMap{}, "AllCaps"): false,
f(netmap.NetworkMap{}, "CollectServices"): false,
f(netmap.NetworkMap{}, "DERPMap"): false,
f(netmap.NetworkMap{}, "DNS"): false,
f(netmap.NetworkMap{}, "DisplayMessages"): false,
f(netmap.NetworkMap{}, "Domain"): false,
f(netmap.NetworkMap{}, "DomainAuditLogID"): false,
f(netmap.NetworkMap{}, "Expiry"): false,
f(netmap.NetworkMap{}, "MachineKey"): false,
f(netmap.NetworkMap{}, "Name"): false,
f(netmap.NetworkMap{}, "NodeKey"): false,
f(netmap.NetworkMap{}, "PacketFilter"): false,
f(netmap.NetworkMap{}, "PacketFilterRules"): false,
f(netmap.NetworkMap{}, "Peers"): false,
f(netmap.NetworkMap{}, "SSHPolicy"): false,
f(netmap.NetworkMap{}, "SelfNode"): false,
f(netmap.NetworkMap{}, "TKAEnabled"): false,
f(netmap.NetworkMap{}, "TKAHead"): false,
f(netmap.NetworkMap{}, "UserProfiles"): false,
f(filtertype.CapMatch{}, "Cap"): false,
f(filtertype.CapMatch{}, "Dst"): false,
f(filtertype.CapMatch{}, "Values"): false,
f(filtertype.Match{}, "Caps"): false,
f(filtertype.Match{}, "Dsts"): false,
f(filtertype.Match{}, "IPProto"): false,
f(filtertype.Match{}, "SrcCaps"): false,
f(filtertype.Match{}, "Srcs"): false,
f(filtertype.Match{}, "SrcsContains"): false,
f(filtertype.NetPortRange{}, "Net"): false,
f(filtertype.NetPortRange{}, "Ports"): false,
f(filtertype.PortRange{}, "First"): false,
f(filtertype.PortRange{}, "Last"): false,
f(key.DiscoPublic{}, "k"): false,
f(key.MachinePublic{}, "k"): false,
f(key.NodePrivate{}, "_"): false,
f(key.NodePrivate{}, "k"): false,
f(key.NodePublic{}, "k"): false,
f(tailcfg.CapGrant{}, "CapMap"): false,
f(tailcfg.CapGrant{}, "Caps"): false,
f(tailcfg.CapGrant{}, "Dsts"): false,
f(tailcfg.DERPHomeParams{}, "RegionScore"): false,
f(tailcfg.DERPMap{}, "HomeParams"): false,
f(tailcfg.DERPMap{}, "OmitDefaultRegions"): false,
f(tailcfg.DERPMap{}, "Regions"): false,
f(tailcfg.DNSConfig{}, "CertDomains"): false,
f(tailcfg.DNSConfig{}, "Domains"): false,
f(tailcfg.DNSConfig{}, "ExitNodeFilteredSet"): false,
f(tailcfg.DNSConfig{}, "ExtraRecords"): false,
f(tailcfg.DNSConfig{}, "FallbackResolvers"): false,
f(tailcfg.DNSConfig{}, "Nameservers"): false,
f(tailcfg.DNSConfig{}, "Proxied"): false,
f(tailcfg.DNSConfig{}, "Resolvers"): false,
f(tailcfg.DNSConfig{}, "Routes"): false,
f(tailcfg.DNSConfig{}, "TempCorpIssue13969"): false,
f(tailcfg.DNSRecord{}, "Name"): false,
f(tailcfg.DNSRecord{}, "Type"): false,
f(tailcfg.DNSRecord{}, "Value"): false,
f(tailcfg.DisplayMessageAction{}, "Label"): false,
f(tailcfg.DisplayMessageAction{}, "URL"): false,
f(tailcfg.DisplayMessage{}, "ImpactsConnectivity"): false,
f(tailcfg.DisplayMessage{}, "PrimaryAction"): false,
f(tailcfg.DisplayMessage{}, "Severity"): false,
f(tailcfg.DisplayMessage{}, "Text"): false,
f(tailcfg.DisplayMessage{}, "Title"): false,
f(tailcfg.FilterRule{}, "CapGrant"): false,
f(tailcfg.FilterRule{}, "DstPorts"): false,
f(tailcfg.FilterRule{}, "IPProto"): false,
f(tailcfg.FilterRule{}, "SrcBits"): false,
f(tailcfg.FilterRule{}, "SrcIPs"): false,
f(tailcfg.HostinfoView{}, "ж"): false,
f(tailcfg.Hostinfo{}, "AllowsUpdate"): false,
f(tailcfg.Hostinfo{}, "App"): false,
f(tailcfg.Hostinfo{}, "AppConnector"): false,
f(tailcfg.Hostinfo{}, "BackendLogID"): false,
f(tailcfg.Hostinfo{}, "Cloud"): false,
f(tailcfg.Hostinfo{}, "Container"): false,
f(tailcfg.Hostinfo{}, "Desktop"): false,
f(tailcfg.Hostinfo{}, "DeviceModel"): false,
f(tailcfg.Hostinfo{}, "Distro"): false,
f(tailcfg.Hostinfo{}, "DistroCodeName"): false,
f(tailcfg.Hostinfo{}, "DistroVersion"): false,
f(tailcfg.Hostinfo{}, "Env"): false,
f(tailcfg.Hostinfo{}, "ExitNodeID"): false,
f(tailcfg.Hostinfo{}, "FrontendLogID"): false,
f(tailcfg.Hostinfo{}, "GoArch"): false,
f(tailcfg.Hostinfo{}, "GoArchVar"): false,
f(tailcfg.Hostinfo{}, "GoVersion"): false,
f(tailcfg.Hostinfo{}, "Hostname"): false,
f(tailcfg.Hostinfo{}, "IPNVersion"): false,
f(tailcfg.Hostinfo{}, "IngressEnabled"): false,
f(tailcfg.Hostinfo{}, "Location"): false,
f(tailcfg.Hostinfo{}, "Machine"): false,
f(tailcfg.Hostinfo{}, "NetInfo"): false,
f(tailcfg.Hostinfo{}, "NoLogsNoSupport"): false,
f(tailcfg.Hostinfo{}, "OS"): false,
f(tailcfg.Hostinfo{}, "OSVersion"): false,
f(tailcfg.Hostinfo{}, "Package"): false,
f(tailcfg.Hostinfo{}, "PushDeviceToken"): false,
f(tailcfg.Hostinfo{}, "RequestTags"): false,
f(tailcfg.Hostinfo{}, "RoutableIPs"): false,
f(tailcfg.Hostinfo{}, "SSH_HostKeys"): false,
f(tailcfg.Hostinfo{}, "Services"): false,
f(tailcfg.Hostinfo{}, "ServicesHash"): false,
f(tailcfg.Hostinfo{}, "ShareeNode"): false,
f(tailcfg.Hostinfo{}, "ShieldsUp"): false,
f(tailcfg.Hostinfo{}, "StateEncrypted"): false,
f(tailcfg.Hostinfo{}, "TPM"): false,
f(tailcfg.Hostinfo{}, "Userspace"): false,
f(tailcfg.Hostinfo{}, "UserspaceRouter"): false,
f(tailcfg.Hostinfo{}, "WireIngress"): false,
f(tailcfg.Hostinfo{}, "WoLMACs"): false,
f(tailcfg.Location{}, "City"): false,
f(tailcfg.Location{}, "CityCode"): false,
f(tailcfg.Location{}, "Country"): false,
f(tailcfg.Location{}, "CountryCode"): false,
f(tailcfg.Location{}, "Latitude"): false,
f(tailcfg.Location{}, "Longitude"): false,
f(tailcfg.Location{}, "Priority"): false,
f(tailcfg.NetInfo{}, "DERPLatency"): false,
f(tailcfg.NetInfo{}, "FirewallMode"): false,
f(tailcfg.NetInfo{}, "HavePortMap"): false,
f(tailcfg.NetInfo{}, "LinkType"): false,
f(tailcfg.NetInfo{}, "MappingVariesByDestIP"): false,
f(tailcfg.NetInfo{}, "OSHasIPv6"): false,
f(tailcfg.NetInfo{}, "PCP"): false,
f(tailcfg.NetInfo{}, "PMP"): false,
f(tailcfg.NetInfo{}, "PreferredDERP"): false,
f(tailcfg.NetInfo{}, "UPnP"): false,
f(tailcfg.NetInfo{}, "WorkingICMPv4"): false,
f(tailcfg.NetInfo{}, "WorkingIPv6"): false,
f(tailcfg.NetInfo{}, "WorkingUDP"): false,
f(tailcfg.NetPortRange{}, "Bits"): false,
f(tailcfg.NetPortRange{}, "IP"): false,
f(tailcfg.NetPortRange{}, "Ports"): false,
f(tailcfg.NetPortRange{}, "_"): false,
f(tailcfg.NodeView{}, "ж"): false,
f(tailcfg.Node{}, "Addresses"): false,
f(tailcfg.Node{}, "AllowedIPs"): false,
f(tailcfg.Node{}, "Cap"): false,
f(tailcfg.Node{}, "CapMap"): false,
f(tailcfg.Node{}, "Capabilities"): false,
f(tailcfg.Node{}, "ComputedName"): false,
f(tailcfg.Node{}, "ComputedNameWithHost"): false,
f(tailcfg.Node{}, "Created"): false,
f(tailcfg.Node{}, "DataPlaneAuditLogID"): false,
f(tailcfg.Node{}, "DiscoKey"): false,
f(tailcfg.Node{}, "Endpoints"): false,
f(tailcfg.Node{}, "ExitNodeDNSResolvers"): false,
f(tailcfg.Node{}, "Expired"): false,
f(tailcfg.Node{}, "HomeDERP"): false,
f(tailcfg.Node{}, "Hostinfo"): false,
f(tailcfg.Node{}, "ID"): false,
f(tailcfg.Node{}, "IsJailed"): false,
f(tailcfg.Node{}, "IsWireGuardOnly"): false,
f(tailcfg.Node{}, "Key"): false,
f(tailcfg.Node{}, "KeyExpiry"): false,
f(tailcfg.Node{}, "KeySignature"): false,
f(tailcfg.Node{}, "LastSeen"): false,
f(tailcfg.Node{}, "LegacyDERPString"): false,
f(tailcfg.Node{}, "Machine"): false,
f(tailcfg.Node{}, "MachineAuthorized"): false,
f(tailcfg.Node{}, "Name"): false,
f(tailcfg.Node{}, "Online"): false,
f(tailcfg.Node{}, "PrimaryRoutes"): false,
f(tailcfg.Node{}, "SelfNodeV4MasqAddrForThisPeer"): false,
f(tailcfg.Node{}, "SelfNodeV6MasqAddrForThisPeer"): false,
f(tailcfg.Node{}, "Sharer"): false,
f(tailcfg.Node{}, "StableID"): false,
f(tailcfg.Node{}, "Tags"): false,
f(tailcfg.Node{}, "UnsignedPeerAPIOnly"): false,
f(tailcfg.Node{}, "User"): false,
f(tailcfg.Node{}, "computedHostIfDifferent"): false,
f(tailcfg.PortRange{}, "First"): false,
f(tailcfg.PortRange{}, "Last"): false,
f(tailcfg.SSHPolicy{}, "Rules"): false,
f(tailcfg.Service{}, "Description"): false,
f(tailcfg.Service{}, "Port"): false,
f(tailcfg.Service{}, "Proto"): false,
f(tailcfg.Service{}, "_"): false,
f(tailcfg.TPMInfo{}, "FamilyIndicator"): false,
f(tailcfg.TPMInfo{}, "FirmwareVersion"): false,
f(tailcfg.TPMInfo{}, "Manufacturer"): false,
f(tailcfg.TPMInfo{}, "Model"): false,
f(tailcfg.TPMInfo{}, "SpecRevision"): false,
f(tailcfg.TPMInfo{}, "Vendor"): false,
f(tailcfg.UserProfileView{}, "ж"): false,
f(tailcfg.UserProfile{}, "DisplayName"): false,
f(tailcfg.UserProfile{}, "ID"): false,
f(tailcfg.UserProfile{}, "LoginName"): false,
f(tailcfg.UserProfile{}, "ProfilePicURL"): false,
f(views.Slice[ipproto.Proto]{}, "ж"): false,
f(views.Slice[tailcfg.FilterRule]{}, "ж"): false,
}
t.Run("field_list_is_complete", func(t *testing.T) {
seen := set.Set[field]{}
eachStructField(reflect.TypeOf(netmap.NetworkMap{}), func(rt reflect.Type, sf reflect.StructField) {
f := field{rt, sf.Name}
seen.Add(f)
if _, ok := fields[f]; !ok {
// Fail the test if netmap has a field not in the list. If you see this test
// failure, please add the new field to the fields map above, marking it as private or public.
t.Errorf("netmap field has not been declared as private or public: %v.%v", rt, sf.Name)
}
})
for want := range fields {
if !seen.Contains(want) {
// Fail the test if the list has a field not in netmap. If you see this test
// failure, please remove the field from the fields map above.
t.Errorf("field declared that has not been found in netmap: %v.%v", want.t, want.f)
}
}
})
// tests is a list of test cases, each with a non-redacted netmap and the expected redacted netmap.
// If you add a new private field to netmap.NetworkMap or its sub-structs, please add a test case
// here that has that field set in nm, and the expected redacted value in wantRedacted.
tests := []struct {
name string
nm *netmap.NetworkMap
wantRedacted *netmap.NetworkMap
}{
{
name: "redact_private_key",
nm: &netmap.NetworkMap{
PrivateKey: key.NewNode(),
},
wantRedacted: &netmap.NetworkMap{},
},
}
// confirmedRedacted is a set of all private fields that have been covered by the tests above.
confirmedRedacted := set.Set[field]{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Record which of the private fields are set in the non-redacted netmap.
eachStructValue(reflect.ValueOf(tt.nm).Elem(), func(tt reflect.Type, sf reflect.StructField, v reflect.Value) {
f := field{tt, sf.Name}
if shouldRedact := fields[f]; shouldRedact && !v.IsZero() {
confirmedRedacted.Add(f)
}
})
got, _ := redactNetmapPrivateKeys(tt.nm)
if !reflect.DeepEqual(got, tt.wantRedacted) {
t.Errorf("unexpected redacted netmap: %+v", got)
}
// Check that all private fields in the redacted netmap are zero.
eachStructValue(reflect.ValueOf(got).Elem(), func(tt reflect.Type, sf reflect.StructField, v reflect.Value) {
f := field{tt, sf.Name}
if shouldRedact := fields[f]; shouldRedact && !v.IsZero() {
t.Errorf("field not redacted: %v.%v", tt, sf.Name)
}
})
})
}
// Check that all private fields in netmap.NetworkMap and its sub-structs
// are covered by the tests above. If you see a test failure here,
// please add a test case above that has that field set in nm.
for f, shouldRedact := range fields {
if shouldRedact {
if !confirmedRedacted.Contains(f) {
t.Errorf("field not covered by tests: %v.%v", f.t, f.f)
}
}
}
}
func TestHandleC2NDebugNetmap(t *testing.T) {
nm := &netmap.NetworkMap{
Name: "myhost",
@@ -495,10 +159,7 @@ func TestHandleC2NDebugNetmap(t *testing.T) {
Hostinfo: (&tailcfg.Hostinfo{Hostname: "peer1"}).View(),
}).View(),
},
PrivateKey: key.NewNode(),
}
withoutPrivateKey := *nm
withoutPrivateKey.PrivateKey = key.NodePrivate{}
for _, tt := range []struct {
name string
@@ -507,12 +168,12 @@ func TestHandleC2NDebugNetmap(t *testing.T) {
}{
{
name: "simple_get",
want: &withoutPrivateKey,
want: nm,
},
{
name: "post_no_omit",
req: &tailcfg.C2NDebugNetmapRequest{},
want: &withoutPrivateKey,
want: nm,
},
{
name: "post_omit_peers_and_name",
@@ -524,7 +185,7 @@ func TestHandleC2NDebugNetmap(t *testing.T) {
{
name: "post_omit_nonexistent_field",
req: &tailcfg.C2NDebugNetmapRequest{OmitFields: []string{"ThisFieldDoesNotExist"}},
want: &withoutPrivateKey,
want: nm,
},
} {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -3052,9 +3052,6 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.Actor, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool)) {
ch := make(chan *ipn.Notify, 128)
sessionID := rands.HexString(16)
if mask&ipn.NotifyNoPrivateKeys != 0 {
fn = filterPrivateKeys(fn)
}
if mask&ipn.NotifyHealthActions == 0 {
// if UI does not support PrimaryAction in health warnings, append
// action URLs to the warning text instead.
@@ -3154,39 +3151,6 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
sender.Run(ctx, ch)
}
// filterPrivateKeys returns an IPN listener func that wraps the supplied IPN
// listener and zeroes out the PrivateKey in the NetMap passed to the wrapped
// listener.
func filterPrivateKeys(fn func(roNotify *ipn.Notify) (keepGoing bool)) func(*ipn.Notify) bool {
return func(n *ipn.Notify) bool {
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.
n2 := *n
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 &
@@ -5087,7 +5051,12 @@ func (b *LocalBackend) authReconfigLocked() {
}
}
cfg, err := nmcfg.WGCfg(nm, b.logf, flags, prefs.ExitNodeID())
priv := b.pm.CurrentPrefs().Persist().PrivateNodeKey()
if !priv.IsZero() && priv.Public() != nm.NodeKey {
priv = key.NodePrivate{}
}
cfg, err := nmcfg.WGCfg(priv, nm, b.logf, flags, prefs.ExitNodeID())
if err != nil {
b.logf("wgcfg: %v", err)
return

View File

@@ -20,6 +20,7 @@ import (
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -49,6 +50,7 @@ import (
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/tstest/deptest"
"tailscale.com/tstest/typewalk"
"tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/types/ipproto"
@@ -57,6 +59,7 @@ import (
"tailscale.com/types/logid"
"tailscale.com/types/netmap"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
@@ -7112,3 +7115,104 @@ func eqUpdate(want appctype.RouteUpdate) func(appctype.RouteUpdate) error {
return nil
}
}
type fakeAttestationKey struct{ key.HardwareAttestationKey }
func (f *fakeAttestationKey) Clone() key.HardwareAttestationKey {
return &fakeAttestationKey{}
}
// TestStripKeysFromPrefs tests that LocalBackend's [stripKeysFromPrefs] (as used
// by sendNotify etc) correctly removes all private keys from an ipn.Notify.
//
// It does so by testing the the two ways that Notifys are sent: via sendNotify,
// and via extension hooks.
func TestStripKeysFromPrefs(t *testing.T) {
// genNotify generates a sample ipn.Notify with various private keys set
// at a certain path through the Notify data structure.
genNotify := map[string]func() ipn.Notify{
"Notify.Prefs.ж.Persist.PrivateNodeKey": func() ipn.Notify {
return ipn.Notify{
Prefs: ptr.To((&ipn.Prefs{
Persist: &persist.Persist{PrivateNodeKey: key.NewNode()},
}).View()),
}
},
"Notify.Prefs.ж.Persist.OldPrivateNodeKey": func() ipn.Notify {
return ipn.Notify{
Prefs: ptr.To((&ipn.Prefs{
Persist: &persist.Persist{OldPrivateNodeKey: key.NewNode()},
}).View()),
}
},
"Notify.Prefs.ж.Persist.NetworkLockKey": func() ipn.Notify {
return ipn.Notify{
Prefs: ptr.To((&ipn.Prefs{
Persist: &persist.Persist{NetworkLockKey: key.NewNLPrivate()},
}).View()),
}
},
"Notify.Prefs.ж.Persist.AttestationKey": func() ipn.Notify {
return ipn.Notify{
Prefs: ptr.To((&ipn.Prefs{
Persist: &persist.Persist{AttestationKey: new(fakeAttestationKey)},
}).View()),
}
},
}
private := key.PrivateTypesForTest()
for path := range typewalk.MatchingPaths(reflect.TypeFor[ipn.Notify](), private.Contains) {
t.Run(path.Name, func(t *testing.T) {
gen, ok := genNotify[path.Name]
if !ok {
t.Fatalf("no genNotify function for path %q", path.Name)
}
withKey := gen()
if path.Walk(reflect.ValueOf(withKey)).IsZero() {
t.Fatalf("generated notify does not have non-zero value at path %q", path.Name)
}
h := &ExtensionHost{}
ch := make(chan *ipn.Notify, 1)
b := &LocalBackend{
extHost: h,
notifyWatchers: map[string]*watchSession{
"test": {ch: ch},
},
}
var okay atomic.Int32
testNotify := func(via string) func(*ipn.Notify) {
return func(n *ipn.Notify) {
if n == nil {
t.Errorf("notify from %s is nil", via)
return
}
if !path.Walk(reflect.ValueOf(*n)).IsZero() {
t.Errorf("notify from %s has non-zero value at path %q; key not stripped", via, path.Name)
} else {
okay.Add(1)
}
}
}
h.Hooks().MutateNotifyLocked.Add(testNotify("MutateNotifyLocked hook"))
b.send(withKey)
select {
case n := <-ch:
testNotify("watchSession")(n)
default:
t.Errorf("no notify sent to watcher channel")
}
if got := okay.Load(); got != 2 {
t.Errorf("notify passed validation %d times; want 2", got)
}
})
}
}

View File

@@ -24,6 +24,7 @@ import (
"tailscale.com/types/persist"
"tailscale.com/util/clientmetric"
"tailscale.com/util/eventbus"
"tailscale.com/util/testenv"
)
var debug = envknob.RegisterBool("TS_DEBUG_PROFILES")
@@ -849,6 +850,7 @@ func (pm *profileManager) CurrentPrefs() ipn.PrefsView {
// ReadStartupPrefsForTest reads the startup prefs from disk. It is only used for testing.
func ReadStartupPrefsForTest(logf logger.Logf, store ipn.StateStore) (ipn.PrefsView, error) {
testenv.AssertInTest()
bus := eventbus.New()
defer bus.Close()
ht := health.NewTracker(bus) // in tests, don't care about the health status

View File

@@ -877,14 +877,6 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
}
mask = ipn.NotifyWatchOpt(v)
}
// Users with only read access must request private key filtering. If they
// don't filter out private keys, require write access.
if (mask & ipn.NotifyNoPrivateKeys) == 0 {
if !h.PermitWrite {
http.Error(w, "watch IPN bus access denied, must set ipn.NotifyNoPrivateKeys when not running as admin/root or operator", http.StatusForbidden)
return
}
}
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()

View File

@@ -263,13 +263,17 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
})
}
// TestServeWatchIPNBus used to test that various WatchIPNBus mask flags
// changed the permissions required to access the endpoint.
// However, since the removal of the NotifyNoPrivateKeys flag requirement
// for read-only users, this test now only verifies that the endpoint
// behaves correctly based on the PermitRead and PermitWrite settings.
func TestServeWatchIPNBus(t *testing.T) {
tstest.Replace(t, &validLocalHostForTesting, true)
tests := []struct {
desc string
permitRead, permitWrite bool
mask ipn.NotifyWatchOpt // extra bits in addition to ipn.NotifyInitialState
wantStatus int
}{
{
@@ -279,20 +283,13 @@ func TestServeWatchIPNBus(t *testing.T) {
wantStatus: http.StatusForbidden,
},
{
desc: "read-initial-state",
desc: "read-only",
permitRead: true,
permitWrite: false,
wantStatus: http.StatusForbidden,
},
{
desc: "read-initial-state-no-private-keys",
permitRead: true,
permitWrite: false,
mask: ipn.NotifyNoPrivateKeys,
wantStatus: http.StatusOK,
},
{
desc: "read-initial-state-with-private-keys",
desc: "read-and-write",
permitRead: true,
permitWrite: true,
wantStatus: http.StatusOK,
@@ -311,7 +308,7 @@ func TestServeWatchIPNBus(t *testing.T) {
c := s.Client()
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, ipn.NotifyInitialState|tt.mask), nil)
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, ipn.NotifyInitialState), nil)
if err != nil {
t.Fatal(err)
}