diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index c844f67f7..72136901f 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -166,7 +166,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/util/syspolicy/pkey from tailscale.com/ipn+ tailscale.com/util/syspolicy/policyclient from tailscale.com/ipn tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy/policyclient+ - tailscale.com/util/syspolicy/setting from tailscale.com/client/local + tailscale.com/util/syspolicy/setting from tailscale.com/client/local+ tailscale.com/util/testenv from tailscale.com/net/bakedroots+ tailscale.com/util/usermetric from tailscale.com/health tailscale.com/util/vizerror from tailscale.com/tailcfg+ diff --git a/ipn/backend.go b/ipn/backend.go index 4cfbe7eb1..91b67a846 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -19,6 +19,7 @@ "tailscale.com/types/netmap" "tailscale.com/types/structs" "tailscale.com/types/views" + "tailscale.com/util/syspolicy/policyclient" ) type State int @@ -173,6 +174,18 @@ type EngineStatus struct { // not request it. NotifyInProcessNoDisconnect NotifyWatchOpt = 1 << 16 + // NotifySysPolicyChanges, if set, causes the first Notify message, which is sent + // immediately, to contain the current effective [setting.Snapshot] in + // [Notify.Policy]. [Notify.Policy] is included in subsequent messages whenever + // the effective policy changes. + // + // The snapshot is scoped to the connected user's identity (on Windows, + // derived from the named-pipe token's SID). + // + // The [setting.Snapshot] that is delivered is a full snapshot on every + // change. + NotifySysPolicyChanges NotifyWatchOpt = 1 << 17 + // NotifyPeerWireGuardState, if set, opts the watcher into // WireGuard session state notifications via [Notify.PeerState]. // The first Notify sent to the watcher includes a dump of current @@ -220,6 +233,7 @@ func (o NotifyWatchOpt) String() string { try(NotifyInitialStatus, "NotifyInitialStatus") try(NotifyPeerPatches, "NotifyPeerPatches") try(NotifyInProcessNoDisconnect, "NotifyInProcessNoDisconnect") + try(NotifySysPolicyChanges, "NotifySysPolicyChanges") try(NotifyPeerWireGuardState, "NotifyPeerWireGuardState") if mask != o { @@ -453,6 +467,13 @@ type Notify struct { // be the best exit node for the current network conditions. SuggestedExitNode *tailcfg.StableNodeID `json:",omitzero"` + // Policy, if non-nil, is the effective policy snapshot for the + // connected user. It is scoped per-user: per-user policy settings + // are merged with device-wide settings, with device-wide taking + // precedence. Sent initially when [NotifySysPolicyChanges] is set, + // and on change thereafter. + Policy *policyclient.PolicySnapshot `json:",omitzero"` + // type is mirrored in xcode/IPN/Core/LocalAPI/Model/LocalAPIModel.swift } diff --git a/ipn/ipnlocal/bus.go b/ipn/ipnlocal/bus.go index e1c060d55..0672e0bd3 100644 --- a/ipn/ipnlocal/bus.go +++ b/ipn/ipnlocal/bus.go @@ -247,5 +247,6 @@ func isNotableNotify(n *ipn.Notify) bool { len(n.IncomingFiles) > 0 || len(n.OutgoingFiles) > 0 || n.FilesWaiting != nil || - n.SuggestedExitNode != nil + n.SuggestedExitNode != nil || + n.Policy != nil } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 867357871..aab2b792e 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -2330,8 +2330,8 @@ func (b *LocalBackend) applyExitNodeSysPolicyLocked(prefs *ipn.Prefs) (anyChange // registerSysPolicyWatch subscribes to syspolicy change notifications // and immediately applies the effective syspolicy settings to the current profile. func (b *LocalBackend) registerSysPolicyWatch() (unregister func(), err error) { - if unregister, err = b.polc.RegisterChangeCallback(b.sysPolicyChanged); err != nil { - return nil, fmt.Errorf("syspolicy: LocalBacked failed to register policy change callback: %v", err) + if unregister, err = b.polc.RegisterChangeCallback("", b.sysPolicyChanged); err != nil { + return nil, fmt.Errorf("syspolicy: LocalBackend failed to register policy change callback: %v", err) } if prefs, anyChange := b.reconcilePrefs(); anyChange { b.logf("syspolicy: changed initial profile prefs: %v", prefs.Pretty()) @@ -2392,6 +2392,28 @@ func (b *LocalBackend) sysPolicyChanged(policy policyclient.PolicyChange) { } } +// sysPolicyChangedForSession is called when the effective policy for a session's +// user changes. It fetches the new snapshot and sends it to that session. +func (b *LocalBackend) sysPolicyChangedForSession(sess *watchSession) { + b.mu.Lock() + defer b.mu.Unlock() + + snapshot, err := b.polc.GetPolicySnapshot("") + if err != nil || snapshot == nil { + return + } + + n := ipn.Notify{Policy: snapshot, Version: version.Long()} + for _, f := range b.extHost.Hooks().MutateNotifyLocked { + f(&n) + } + nForSess := b.notifyForSessionLocked(sess, &n) + select { + case sess.ch <- nForSess: + default: + } +} + var ( _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil) _ controlclient.PacketFilterUpdater = (*LocalBackend)(nil) @@ -3711,7 +3733,10 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A deadlockDone := b.CheckDeadlocks() b.mu.Lock() - const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialStatus | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion | ipn.NotifyPeerWireGuardState + const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | + ipn.NotifyInitialNetMap | ipn.NotifyInitialStatus | + ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | + ipn.NotifyInitialClientVersion | ipn.NotifySysPolicyChanges | ipn.NotifyPeerWireGuardState if mask&initialBits != 0 { cn := b.currentNode() ini = &ipn.Notify{Version: version.Long()} @@ -3755,6 +3780,13 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A ini.ClientVersion = b.lastClientVersion } } + if mask&ipn.NotifySysPolicyChanges != 0 { + var err error + ini.Policy, err = b.polc.GetPolicySnapshot("") + if err != nil { + b.logf("syspolicy: GetPolicySnapshot(\"\"): %v", err) + } + } } ctx, cancel := context.WithCancel(ctx) @@ -3775,6 +3807,16 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A b.mu.Unlock() deadlockDone() + if mask&ipn.NotifySysPolicyChanges != 0 { + if unreg, err := b.polc.RegisterChangeCallback("", func(_ policyclient.PolicyChange) { + b.sysPolicyChangedForSession(session) + }); err == nil { + defer unreg() + } else { + b.logf("syspolicy: RegisterChangeCallback(\"\"): %v", err) + } + } + metricCurrentWatchIPNBus.Add(1) defer metricCurrentWatchIPNBus.Add(-1) diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 07a9f65a2..34e36fab3 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -71,7 +71,10 @@ "tailscale.com/util/set" "tailscale.com/util/syspolicy" "tailscale.com/util/syspolicy/pkey" + "tailscale.com/util/syspolicy/policyclient" "tailscale.com/util/syspolicy/policytest" + "tailscale.com/util/syspolicy/rsop" + "tailscale.com/util/syspolicy/setting" "tailscale.com/util/syspolicy/source" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" @@ -8481,6 +8484,153 @@ func toStrings[T ~string](in []T) []string { return out } +func TestWatchNotificationsInitialPolicy(t *testing.T) { + setting.SetDefinitionsForTest(t, + setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue), + ) + store := source.NewTestStore(t) + rsop.RegisterStoreForTest(t, "TestStore", setting.DeviceScope, store) + + sys := tsd.NewSystem() + sys.PolicyClient.Set(testPolicyClient{}) + lb := newTestLocalBackendWithSys(t, sys) + + nw := newNotificationWatcher(t, lb, &ipnauth.TestActor{}) + nw.watch(ipn.NotifySysPolicyChanges, []wantedNotification{ + wantPolicyNotify(), + }) + nw.check() + + nw2 := newNotificationWatcher(t, lb, &ipnauth.TestActor{}) + nw2.watch(0, nil, unexpectedPolicy) + nw2.check() +} + +func TestPolicyChangeNotifiesWatcher(t *testing.T) { + setting.SetDefinitionsForTest(t, + setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue), + ) + store := source.NewTestStore(t) + rsop.RegisterStoreForTest(t, "TestStore", setting.DeviceScope, store) + + sys := tsd.NewSystem() + sys.PolicyClient.Set(testPolicyClient{}) + lb := newTestLocalBackendWithSys(t, sys) + if err := lb.Start(ipn.Options{}); err != nil { + t.Fatalf("Start: %v", err) + } + + nw := newNotificationWatcher(t, lb, &ipnauth.TestActor{}) + nw.watch(ipn.NotifySysPolicyChanges, []wantedNotification{ + wantPolicyNotify(), + wantPolicyWithSetting(pkey.AdminConsoleVisibility, "hide"), + }) + + store.SetStrings(source.TestSettingOf(pkey.AdminConsoleVisibility, "hide")) + + nw.check() +} + +func TestPolicyNotifyPerUser(t *testing.T) { + store := source.NewTestStore(t) + rsop.RegisterStoreForTest(t, "TestStore", setting.DeviceScope, store) + + sys := tsd.NewSystem() + sys.PolicyClient.Set(testPolicyClient{}) + lb := newTestLocalBackendWithSys(t, sys) + + actorA := &ipnauth.TestActor{UID: "S-1-5-21-1001"} + actorB := &ipnauth.TestActor{UID: "S-1-5-21-1002"} + + nwA := newNotificationWatcher(t, lb, actorA) + nwA.watch(ipn.NotifySysPolicyChanges, []wantedNotification{ + wantPolicyNotify(), + }) + nwA.check() + + nwB := newNotificationWatcher(t, lb, actorB) + nwB.watch(ipn.NotifySysPolicyChanges, []wantedNotification{ + wantPolicyNotify(), + }) + nwB.check() + + nwNoPolicy := newNotificationWatcher(t, lb, actorA) + nwNoPolicy.watch(0, nil, unexpectedPolicy) + + store.SetStrings(source.TestSettingOf(pkey.AdminConsoleVisibility, "hide")) + + nwNoPolicy.check() +} + +func wantPolicyNotify() wantedNotification { + return wantedNotification{ + name: "Policy", + cond: func(_ testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool { + return n.Policy != nil + }, + } +} + +func wantPolicyWithSetting(key pkey.Key, value string) wantedNotification { + return wantedNotification{ + name: fmt.Sprintf("Policy-%s=%s", key, value), + cond: func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool { + if n.Policy == nil { + return false + } + if n.Policy == nil { + return false + } + got := n.Policy.Get(key) + if got == nil { + return false + } + if fmt.Sprint(got) != value { + t.Errorf("Policy[%s] = %v (%T); want %q", key, got, got, value) + } + return true + }, + } +} + +func unexpectedPolicy(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool { + if n.Policy != nil { + t.Errorf("unexpected Policy notification") + return true + } + return false +} + +type testPolicyClient struct { + policyclient.NoPolicyClient +} + +func (testPolicyClient) GetPolicySnapshot(uid string) (*policyclient.PolicySnapshot, error) { + scope := setting.DefaultScope() + if uid != "" { + scope = setting.UserScopeOf(uid) + } + p, err := rsop.PolicyFor(scope) + if err != nil { + return nil, err + } + return p.Get(), nil +} + +func (testPolicyClient) RegisterChangeCallback(uid string, cb func(policyclient.PolicyChange)) (func(), error) { + scope := setting.DefaultScope() + if uid != "" { + scope = setting.UserScopeOf(uid) + } + p, err := rsop.PolicyFor(scope) + if err != nil { + return func() {}, err + } + return p.RegisterChangeCallback(func(change policyclient.PolicyChange) { + cb(change) + }), nil +} + type textUpdate struct { Advertise []string Unadvertise []string diff --git a/net/dns/manager_windows.go b/net/dns/manager_windows.go index 20102af86..1973f8d49 100644 --- a/net/dns/manager_windows.go +++ b/net/dns/manager_windows.go @@ -78,7 +78,7 @@ func NewOSConfigurator(logf logger.Logf, health *health.Tracker, bus *eventbus.B } var err error - if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback(ret.sysPolicyChanged); err != nil { + if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback("", ret.sysPolicyChanged); err != nil { logf("error registering policy change callback: %v", err) // non-fatal } diff --git a/util/syspolicy/policyclient/policy_snapshot.go b/util/syspolicy/policyclient/policy_snapshot.go new file mode 100644 index 000000000..eaa4953a2 --- /dev/null +++ b/util/syspolicy/policyclient/policy_snapshot.go @@ -0,0 +1,12 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_syspolicy + +package policyclient + +import "tailscale.com/util/syspolicy/setting" + +// PolicySnapshot is an alias for [settings.Snapshot] unless syspolicy is omitted +// from the build. +type policySnapshot = setting.Snapshot diff --git a/util/syspolicy/policyclient/policy_snapshot_omit.go b/util/syspolicy/policyclient/policy_snapshot_omit.go new file mode 100644 index 000000000..24b6b52f0 --- /dev/null +++ b/util/syspolicy/policyclient/policy_snapshot_omit.go @@ -0,0 +1,9 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ts_omit_syspolicy + +package policyclient + +// PolicySnapshot is a stub when syspolicy is omitted from the build. +type policySnapshot struct{} diff --git a/util/syspolicy/policyclient/policyclient.go b/util/syspolicy/policyclient/policyclient.go index e6ad208b0..ed5192ec2 100644 --- a/util/syspolicy/policyclient/policyclient.go +++ b/util/syspolicy/policyclient/policyclient.go @@ -62,11 +62,21 @@ type Client interface { HasAnyOf(keys ...pkey.Key) (bool, error) // RegisterChangeCallback registers a callback function that will be called - // whenever a policy change is detected. It returns a function to unregister - // the callback and an error if the registration fails. - RegisterChangeCallback(cb func(PolicyChange)) (unregister func(), err error) + // whenever a policy change is detected. If uid is empty, the callback fires + // for device-scope policy changes. If uid is non-empty, it fires for changes + // to that user's effective policy (device + user policies merged). It returns + // a function to unregister the callback and an error if the registration fails. + RegisterChangeCallback(uid string, cb func(PolicyChange)) (unregister func(), err error) + + // GetPolicySnapshot returns the effective policy snapshot for the given user ID. + // If uid is empty, returns the default-scope policy. Returns nil, nil if policy + // snapshots are not supported. + GetPolicySnapshot(uid string) (*PolicySnapshot, error) } +// PolicySnapshot is an alias for [setting.Snapshot] unless syspolicy is omitted from the build. +type PolicySnapshot = policySnapshot + // Get returns a non-nil [Client] implementation as a function of the // build tags. It returns a no-op implementation if the full syspolicy // package is omitted from the build, or in tests. @@ -140,6 +150,10 @@ func (NoPolicyClient) HasAnyOf(keys ...pkey.Key) (bool, error) { func (NoPolicyClient) SetDebugLoggingEnabled(enabled bool) {} -func (NoPolicyClient) RegisterChangeCallback(cb func(PolicyChange)) (unregister func(), err error) { +func (NoPolicyClient) RegisterChangeCallback(uid string, cb func(PolicyChange)) (unregister func(), err error) { return func() {}, nil } + +func (NoPolicyClient) GetPolicySnapshot(uid string) (*PolicySnapshot, error) { + return nil, nil +} diff --git a/util/syspolicy/policytest/policytest.go b/util/syspolicy/policytest/policytest.go index 9879a0fd3..e2208ff2b 100644 --- a/util/syspolicy/policytest/policytest.go +++ b/util/syspolicy/policytest/policytest.go @@ -226,7 +226,7 @@ func (c Config) HasAnyOf(keys ...pkey.Key) (bool, error) { return false, nil } -func (c Config) RegisterChangeCallback(callback func(policyclient.PolicyChange)) (func(), error) { +func (c Config) RegisterChangeCallback(uid string, callback func(policyclient.PolicyChange)) (func(), error) { w, ok := c[watchersKey].(*watchers) if !ok { return func() {}, nil @@ -242,3 +242,7 @@ func (c Config) RegisterChangeCallback(callback func(policyclient.PolicyChange)) } func (sp Config) SetDebugLoggingEnabled(enabled bool) {} + +func (c Config) GetPolicySnapshot(uid string) (*policyclient.PolicySnapshot, error) { + return nil, nil +} diff --git a/util/syspolicy/syspolicy.go b/util/syspolicy/syspolicy.go index 7451bde75..ed37b9a02 100644 --- a/util/syspolicy/syspolicy.go +++ b/util/syspolicy/syspolicy.go @@ -129,8 +129,12 @@ func getDuration(name pkey.Key, defaultValue time.Duration) (time.Duration, erro // registerChangeCallback adds a function that will be called whenever the effective policy // for the default scope changes. The returned function can be used to unregister the callback. -func registerChangeCallback(cb rsop.PolicyChangeCallback) (unregister func(), err error) { - effective, err := rsop.PolicyFor(setting.DefaultScope()) +func registerChangeCallback(uid string, cb rsop.PolicyChangeCallback) (unregister func(), err error) { + scope := setting.DefaultScope() + if uid != "" { + scope = setting.UserScopeOf(uid) + } + effective, err := rsop.PolicyFor(scope) if err != nil { return nil, err } @@ -259,6 +263,18 @@ func (globalSyspolicy) HasAnyOf(keys ...pkey.Key) (bool, error) { return hasAnyOf(keys...) } -func (globalSyspolicy) RegisterChangeCallback(cb func(policyclient.PolicyChange)) (unregister func(), err error) { - return registerChangeCallback(cb) +func (globalSyspolicy) RegisterChangeCallback(uid string, cb func(policyclient.PolicyChange)) (unregister func(), err error) { + return registerChangeCallback(uid, cb) +} + +func (globalSyspolicy) GetPolicySnapshot(uid string) (*policyclient.PolicySnapshot, error) { + scope := setting.DefaultScope() + if uid != "" { + scope = setting.UserScopeOf(uid) + } + p, err := rsop.PolicyFor(scope) + if err != nil { + return nil, err + } + return p.Get(), nil }