util/syspolicy, ipn/ipnlocal: update syspolicy package to utilize syspolicy/rsop

In this PR, we update the syspolicy package to utilize syspolicy/rsop under the hood,
and remove syspolicy.CachingHandler, syspolicy.windowsHandler and related code
which is no longer used.

We mark the syspolicy.Handler interface and RegisterHandler/SetHandlerForTest functions
as deprecated, but keep them temporarily until they are no longer used in other repos.

We also update the package to register setting definitions for all existing policy settings
and to register the Registry-based, Windows-specific policy stores when running on Windows.

Finally, we update existing internal and external tests to use the new API and add a few more
tests and benchmarks.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
tomhjp/k8s-diagrams
Nick Khyl 1 month ago committed by Nick Khyl
parent 7fe6e50858
commit e815ae0ec4

@ -164,11 +164,16 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/slicesx from tailscale.com/cmd/derper+ tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/syspolicy from tailscale.com/ipn tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
tailscale.com/util/testenv from tailscale.com/util/syspolicy+
tailscale.com/util/usermetric from tailscale.com/health tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+ W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+ tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/envknob+ tailscale.com/version/distro from tailscale.com/envknob+
@ -189,7 +194,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+ golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
W golang.org/x/exp/constraints from tailscale.com/util/winutil W golang.org/x/exp/constraints from tailscale.com/util/winutil
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting+
L golang.org/x/net/bpf from github.com/mdlayher/netlink+ L golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http golang.org/x/net/http/httpguts from net/http
@ -250,7 +255,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
encoding/pem from crypto/tls+ encoding/pem from crypto/tls+
errors from bufio+ errors from bufio+
expvar from github.com/prometheus/client_golang/prometheus+ expvar from github.com/prometheus/client_golang/prometheus+
flag from tailscale.com/cmd/derper flag from tailscale.com/cmd/derper+
fmt from compress/flate+ fmt from compress/flate+
go/token from google.golang.org/protobuf/internal/strs go/token from google.golang.org/protobuf/internal/strs
hash from crypto+ hash from crypto+
@ -284,7 +289,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
os from crypto/rand+ os from crypto/rand+
os/exec from github.com/coreos/go-iptables/iptables+ os/exec from github.com/coreos/go-iptables/iptables+
os/signal from tailscale.com/cmd/derper os/signal from tailscale.com/cmd/derper
W os/user from tailscale.com/util/winutil W os/user from tailscale.com/util/winutil+
path from github.com/prometheus/client_golang/prometheus/internal+ path from github.com/prometheus/client_golang/prometheus/internal+
path/filepath from crypto/x509+ path/filepath from crypto/x509+
reflect from crypto/x509+ reflect from crypto/x509+

@ -812,8 +812,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/slicesx from tailscale.com/appc+ tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/syspolicy from tailscale.com/control/controlclient+ tailscale.com/util/syspolicy from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
tailscale.com/util/systemd from tailscale.com/control/controlclient+ tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/testenv from tailscale.com/control/controlclient+ tailscale.com/util/testenv from tailscale.com/control/controlclient+
@ -823,7 +826,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/vizerror from tailscale.com/tailcfg+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+ 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+ W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns+
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+ tailscale.com/util/zstdframe from tailscale.com/control/controlclient+

@ -174,14 +174,18 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+ tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
tailscale.com/util/syspolicy from tailscale.com/ipn tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
tailscale.com/util/usermetric from tailscale.com/health tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
W 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+ W 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/client/web+ tailscale.com/version from tailscale.com/client/web+
tailscale.com/version/distro from tailscale.com/client/web+ tailscale.com/version/distro from tailscale.com/client/web+

@ -401,8 +401,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+ tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+ tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
tailscale.com/util/systemd from tailscale.com/control/controlclient+ tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+ tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
@ -412,7 +415,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/vizerror from tailscale.com/tailcfg+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+ 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+ W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns+
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+ tailscale.com/util/zstdframe from tailscale.com/control/controlclient+

@ -54,6 +54,8 @@ import (
"tailscale.com/util/must" "tailscale.com/util/must"
"tailscale.com/util/set" "tailscale.com/util/set"
"tailscale.com/util/syspolicy" "tailscale.com/util/syspolicy"
"tailscale.com/util/syspolicy/setting"
"tailscale.com/util/syspolicy/source"
"tailscale.com/wgengine" "tailscale.com/wgengine"
"tailscale.com/wgengine/filter" "tailscale.com/wgengine/filter"
"tailscale.com/wgengine/wgcfg" "tailscale.com/wgengine/wgcfg"
@ -1559,94 +1561,6 @@ func dnsResponse(domain, address string) []byte {
return must.Get(b.Finish()) return must.Get(b.Finish())
} }
type errorSyspolicyHandler struct {
t *testing.T
err error
key syspolicy.Key
allowKeys map[syspolicy.Key]*string
}
func (h *errorSyspolicyHandler) ReadString(key string) (string, error) {
sk := syspolicy.Key(key)
if _, ok := h.allowKeys[sk]; !ok {
h.t.Errorf("ReadString: %q is not in list of permitted keys", h.key)
}
if sk == h.key {
return "", h.err
}
return "", syspolicy.ErrNoSuchKey
}
func (h *errorSyspolicyHandler) ReadUInt64(key string) (uint64, error) {
h.t.Errorf("ReadUInt64(%q) unexpectedly called", key)
return 0, syspolicy.ErrNoSuchKey
}
func (h *errorSyspolicyHandler) ReadBoolean(key string) (bool, error) {
h.t.Errorf("ReadBoolean(%q) unexpectedly called", key)
return false, syspolicy.ErrNoSuchKey
}
func (h *errorSyspolicyHandler) ReadStringArray(key string) ([]string, error) {
h.t.Errorf("ReadStringArray(%q) unexpectedly called", key)
return nil, syspolicy.ErrNoSuchKey
}
type mockSyspolicyHandler struct {
t *testing.T
// stringPolicies is the collection of policies that we expect to see
// queried by the current test. If the policy is expected but unset, then
// use nil, otherwise use a string equal to the policy's desired value.
stringPolicies map[syspolicy.Key]*string
// stringArrayPolicies is the collection of policies that we expected to see
// queries by the current test, that return policy string arrays.
stringArrayPolicies map[syspolicy.Key][]string
// failUnknownPolicies is set if policies other than those in stringPolicies
// (uint64 or bool policies are not supported by mockSyspolicyHandler yet)
// should be considered a test failure if they are queried.
failUnknownPolicies bool
}
func (h *mockSyspolicyHandler) ReadString(key string) (string, error) {
if s, ok := h.stringPolicies[syspolicy.Key(key)]; ok {
if s == nil {
return "", syspolicy.ErrNoSuchKey
}
return *s, nil
}
if h.failUnknownPolicies {
h.t.Errorf("ReadString(%q) unexpectedly called", key)
}
return "", syspolicy.ErrNoSuchKey
}
func (h *mockSyspolicyHandler) ReadUInt64(key string) (uint64, error) {
if h.failUnknownPolicies {
h.t.Errorf("ReadUInt64(%q) unexpectedly called", key)
}
return 0, syspolicy.ErrNoSuchKey
}
func (h *mockSyspolicyHandler) ReadBoolean(key string) (bool, error) {
if h.failUnknownPolicies {
h.t.Errorf("ReadBoolean(%q) unexpectedly called", key)
}
return false, syspolicy.ErrNoSuchKey
}
func (h *mockSyspolicyHandler) ReadStringArray(key string) ([]string, error) {
if h.failUnknownPolicies {
h.t.Errorf("ReadStringArray(%q) unexpectedly called", key)
}
if s, ok := h.stringArrayPolicies[syspolicy.Key(key)]; ok {
if s == nil {
return []string{}, syspolicy.ErrNoSuchKey
}
return s, nil
}
return nil, syspolicy.ErrNoSuchKey
}
func TestSetExitNodeIDPolicy(t *testing.T) { func TestSetExitNodeIDPolicy(t *testing.T) {
pfx := netip.MustParsePrefix pfx := netip.MustParsePrefix
tests := []struct { tests := []struct {
@ -1856,23 +1770,18 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
}, },
} }
syspolicy.RegisterWellKnownSettingsForTest(t)
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
b := newTestBackend(t) b := newTestBackend(t)
msh := &mockSyspolicyHandler{
t: t, policyStore := source.NewTestStoreOf(t,
stringPolicies: map[syspolicy.Key]*string{ source.TestSettingOf(syspolicy.ExitNodeID, test.exitNodeID),
syspolicy.ExitNodeID: nil, source.TestSettingOf(syspolicy.ExitNodeIP, test.exitNodeIP),
syspolicy.ExitNodeIP: nil, )
}, syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
}
if test.exitNodeIDKey {
msh.stringPolicies[syspolicy.ExitNodeID] = &test.exitNodeID
}
if test.exitNodeIPKey {
msh.stringPolicies[syspolicy.ExitNodeIP] = &test.exitNodeIP
}
syspolicy.SetHandlerForTest(t, msh)
if test.nm == nil { if test.nm == nil {
test.nm = new(netmap.NetworkMap) test.nm = new(netmap.NetworkMap)
} }
@ -1994,13 +1903,13 @@ func TestUpdateNetmapDeltaAutoExitNode(t *testing.T) {
report: report, report: report,
}, },
} }
msh := &mockSyspolicyHandler{
t: t, syspolicy.RegisterWellKnownSettingsForTest(t)
stringPolicies: map[syspolicy.Key]*string{ policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
syspolicy.ExitNodeID: ptr.To("auto:any"), syspolicy.ExitNodeID, "auto:any",
}, ))
} syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
syspolicy.SetHandlerForTest(t, msh)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
b := newTestLocalBackend(t) b := newTestLocalBackend(t)
@ -2049,13 +1958,11 @@ func TestAutoExitNodeSetNetInfoCallback(t *testing.T) {
} }
cc = newClient(t, opts) cc = newClient(t, opts)
b.cc = cc b.cc = cc
msh := &mockSyspolicyHandler{ syspolicy.RegisterWellKnownSettingsForTest(t)
t: t, policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
stringPolicies: map[syspolicy.Key]*string{ syspolicy.ExitNodeID, "auto:any",
syspolicy.ExitNodeID: ptr.To("auto:any"), ))
}, syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
}
syspolicy.SetHandlerForTest(t, msh)
peer1 := makePeer(1, withCap(26), withDERP(3), withSuggest(), withExitRoutes()) peer1 := makePeer(1, withCap(26), withDERP(3), withSuggest(), withExitRoutes())
peer2 := makePeer(2, withCap(26), withDERP(2), withSuggest(), withExitRoutes()) peer2 := makePeer(2, withCap(26), withDERP(2), withSuggest(), withExitRoutes())
selfNode := tailcfg.Node{ selfNode := tailcfg.Node{
@ -2160,13 +2067,11 @@ func TestSetControlClientStatusAutoExitNode(t *testing.T) {
DERPMap: derpMap, DERPMap: derpMap,
} }
b := newTestLocalBackend(t) b := newTestLocalBackend(t)
msh := &mockSyspolicyHandler{ syspolicy.RegisterWellKnownSettingsForTest(t)
t: t, policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
stringPolicies: map[syspolicy.Key]*string{ syspolicy.ExitNodeID, "auto:any",
syspolicy.ExitNodeID: ptr.To("auto:any"), ))
}, syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
}
syspolicy.SetHandlerForTest(t, msh)
b.netMap = nm b.netMap = nm
b.lastSuggestedExitNode = peer1.StableID() b.lastSuggestedExitNode = peer1.StableID()
b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, report) b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, report)
@ -2400,17 +2305,16 @@ func TestApplySysPolicy(t *testing.T) {
}, },
} }
syspolicy.RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
msh := &mockSyspolicyHandler{ settings := make([]source.TestSetting[string], 0, len(tt.stringPolicies))
t: t,
stringPolicies: make(map[syspolicy.Key]*string, len(tt.stringPolicies)),
}
for p, v := range tt.stringPolicies { for p, v := range tt.stringPolicies {
v := v // construct a unique pointer for each policy value settings = append(settings, source.TestSettingOf(p, v))
msh.stringPolicies[p] = &v
} }
syspolicy.SetHandlerForTest(t, msh) policyStore := source.NewTestStoreOf(t, settings...)
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
t.Run("unit", func(t *testing.T) { t.Run("unit", func(t *testing.T) {
prefs := tt.prefs.Clone() prefs := tt.prefs.Clone()
@ -2546,35 +2450,19 @@ func TestPreferencePolicyInfo(t *testing.T) {
}, },
} }
syspolicy.RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
for _, pp := range preferencePolicies { for _, pp := range preferencePolicies {
t.Run(string(pp.key), func(t *testing.T) { t.Run(string(pp.key), func(t *testing.T) {
var h syspolicy.Handler s := source.TestSetting[string]{
Key: pp.key,
allPolicies := make(map[syspolicy.Key]*string, len(preferencePolicies)+1) Error: tt.policyError,
allPolicies[syspolicy.ControlURL] = nil Value: tt.policyValue,
for _, pp := range preferencePolicies {
allPolicies[pp.key] = nil
}
if tt.policyError != nil {
h = &errorSyspolicyHandler{
t: t,
err: tt.policyError,
key: pp.key,
allowKeys: allPolicies,
} }
} else { policyStore := source.NewTestStoreOf(t, s)
msh := &mockSyspolicyHandler{ syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
t: t,
stringPolicies: allPolicies,
failUnknownPolicies: true,
}
msh.stringPolicies[pp.key] = &tt.policyValue
h = msh
}
syspolicy.SetHandlerForTest(t, h)
prefs := defaultPrefs.AsStruct() prefs := defaultPrefs.AsStruct()
pp.set(prefs, tt.initialValue) pp.set(prefs, tt.initialValue)
@ -3825,15 +3713,16 @@ func TestShouldAutoExitNode(t *testing.T) {
expectedBool: false, expectedBool: false,
}, },
} }
syspolicy.RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
msh := &mockSyspolicyHandler{ policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
t: t, syspolicy.ExitNodeID, tt.exitNodeIDPolicyValue,
stringPolicies: map[syspolicy.Key]*string{ ))
syspolicy.ExitNodeID: ptr.To(tt.exitNodeIDPolicyValue), syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
},
}
syspolicy.SetHandlerForTest(t, msh)
got := shouldAutoExitNode() got := shouldAutoExitNode()
if got != tt.expectedBool { if got != tt.expectedBool {
t.Fatalf("expected %v got %v for %v policy value", tt.expectedBool, got, tt.exitNodeIDPolicyValue) t.Fatalf("expected %v got %v for %v policy value", tt.expectedBool, got, tt.exitNodeIDPolicyValue)
@ -3971,17 +3860,13 @@ func TestFillAllowedSuggestions(t *testing.T) {
want: []tailcfg.StableNodeID{"ABC", "def", "gHiJ"}, want: []tailcfg.StableNodeID{"ABC", "def", "gHiJ"},
}, },
} }
syspolicy.RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mh := mockSyspolicyHandler{ policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
t: t, syspolicy.AllowedSuggestedExitNodes, tt.allowPolicy,
} ))
if tt.allowPolicy != nil { syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
mh.stringArrayPolicies = map[syspolicy.Key][]string{
syspolicy.AllowedSuggestedExitNodes: tt.allowPolicy,
}
}
syspolicy.SetHandlerForTest(t, &mh)
got := fillAllowedSuggestions() got := fillAllowedSuggestions()
if got == nil { if got == nil {

@ -1,122 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"errors"
"sync"
)
// CachingHandler is a handler that reads policies from an underlying handler the first time each key is requested
// and permanently caches the result unless there is an error. If there is an ErrNoSuchKey error, that result is cached,
// otherwise the actual error is returned and the next read for that key will retry using the handler.
type CachingHandler struct {
mu sync.Mutex
strings map[string]string
uint64s map[string]uint64
bools map[string]bool
strArrs map[string][]string
notFound map[string]bool
handler Handler
}
// NewCachingHandler creates a CachingHandler given a handler.
func NewCachingHandler(handler Handler) *CachingHandler {
return &CachingHandler{
handler: handler,
strings: make(map[string]string),
uint64s: make(map[string]uint64),
bools: make(map[string]bool),
strArrs: make(map[string][]string),
notFound: make(map[string]bool),
}
}
// ReadString reads the policy settings value string given the key.
// ReadString first reads from the handler's cache before resorting to using the handler.
func (ch *CachingHandler) ReadString(key string) (string, error) {
ch.mu.Lock()
defer ch.mu.Unlock()
if val, ok := ch.strings[key]; ok {
return val, nil
}
if notFound := ch.notFound[key]; notFound {
return "", ErrNoSuchKey
}
val, err := ch.handler.ReadString(key)
if errors.Is(err, ErrNoSuchKey) {
ch.notFound[key] = true
return "", err
} else if err != nil {
return "", err
}
ch.strings[key] = val
return val, nil
}
// ReadUInt64 reads the policy settings uint64 value given the key.
// ReadUInt64 first reads from the handler's cache before resorting to using the handler.
func (ch *CachingHandler) ReadUInt64(key string) (uint64, error) {
ch.mu.Lock()
defer ch.mu.Unlock()
if val, ok := ch.uint64s[key]; ok {
return val, nil
}
if notFound := ch.notFound[key]; notFound {
return 0, ErrNoSuchKey
}
val, err := ch.handler.ReadUInt64(key)
if errors.Is(err, ErrNoSuchKey) {
ch.notFound[key] = true
return 0, err
} else if err != nil {
return 0, err
}
ch.uint64s[key] = val
return val, nil
}
// ReadBoolean reads the policy settings boolean value given the key.
// ReadBoolean first reads from the handler's cache before resorting to using the handler.
func (ch *CachingHandler) ReadBoolean(key string) (bool, error) {
ch.mu.Lock()
defer ch.mu.Unlock()
if val, ok := ch.bools[key]; ok {
return val, nil
}
if notFound := ch.notFound[key]; notFound {
return false, ErrNoSuchKey
}
val, err := ch.handler.ReadBoolean(key)
if errors.Is(err, ErrNoSuchKey) {
ch.notFound[key] = true
return false, err
} else if err != nil {
return false, err
}
ch.bools[key] = val
return val, nil
}
// ReadBoolean reads the policy settings boolean value given the key.
// ReadBoolean first reads from the handler's cache before resorting to using the handler.
func (ch *CachingHandler) ReadStringArray(key string) ([]string, error) {
ch.mu.Lock()
defer ch.mu.Unlock()
if val, ok := ch.strArrs[key]; ok {
return val, nil
}
if notFound := ch.notFound[key]; notFound {
return nil, ErrNoSuchKey
}
val, err := ch.handler.ReadStringArray(key)
if errors.Is(err, ErrNoSuchKey) {
ch.notFound[key] = true
return nil, err
} else if err != nil {
return nil, err
}
ch.strArrs[key] = val
return val, nil
}

@ -1,262 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"testing"
)
func TestHandlerReadString(t *testing.T) {
tests := []struct {
name string
key string
handlerKey Key
handlerValue string
handlerError error
preserveHandler bool
wantValue string
wantErr error
strings map[string]string
expectedCalls int
}{
{
name: "read existing cached values",
key: "test",
handlerKey: "do not read",
strings: map[string]string{"test": "foo"},
wantValue: "foo",
expectedCalls: 0,
},
{
name: "read existing values not cached",
key: "test",
handlerKey: "test",
handlerValue: "foo",
wantValue: "foo",
expectedCalls: 1,
},
{
name: "error no such key",
key: "test",
handlerKey: "test",
handlerError: ErrNoSuchKey,
wantErr: ErrNoSuchKey,
expectedCalls: 1,
},
{
name: "other error",
key: "test",
handlerKey: "test",
handlerError: someOtherError,
wantErr: someOtherError,
preserveHandler: true,
expectedCalls: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testHandler := &testHandler{
t: t,
key: tt.handlerKey,
s: tt.handlerValue,
err: tt.handlerError,
}
cache := NewCachingHandler(testHandler)
if tt.strings != nil {
cache.strings = tt.strings
}
got, err := cache.ReadString(tt.key)
if err != tt.wantErr {
t.Errorf("err=%v want %v", err, tt.wantErr)
}
if got != tt.wantValue {
t.Errorf("got %v want %v", got, cache.strings[tt.key])
}
if !tt.preserveHandler {
testHandler.key, testHandler.s, testHandler.err = "do not read", "", nil
}
got, err = cache.ReadString(tt.key)
if err != tt.wantErr {
t.Errorf("repeat err=%v want %v", err, tt.wantErr)
}
if got != tt.wantValue {
t.Errorf("repeat got %v want %v", got, cache.strings[tt.key])
}
if testHandler.calls != tt.expectedCalls {
t.Errorf("calls=%v want %v", testHandler.calls, tt.expectedCalls)
}
})
}
}
func TestHandlerReadUint64(t *testing.T) {
tests := []struct {
name string
key string
handlerKey Key
handlerValue uint64
handlerError error
preserveHandler bool
wantValue uint64
wantErr error
uint64s map[string]uint64
expectedCalls int
}{
{
name: "read existing cached values",
key: "test",
handlerKey: "do not read",
uint64s: map[string]uint64{"test": 1},
wantValue: 1,
expectedCalls: 0,
},
{
name: "read existing values not cached",
key: "test",
handlerKey: "test",
handlerValue: 1,
wantValue: 1,
expectedCalls: 1,
},
{
name: "error no such key",
key: "test",
handlerKey: "test",
handlerError: ErrNoSuchKey,
wantErr: ErrNoSuchKey,
expectedCalls: 1,
},
{
name: "other error",
key: "test",
handlerKey: "test",
handlerError: someOtherError,
wantErr: someOtherError,
preserveHandler: true,
expectedCalls: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testHandler := &testHandler{
t: t,
key: tt.handlerKey,
u64: tt.handlerValue,
err: tt.handlerError,
}
cache := NewCachingHandler(testHandler)
if tt.uint64s != nil {
cache.uint64s = tt.uint64s
}
got, err := cache.ReadUInt64(tt.key)
if err != tt.wantErr {
t.Errorf("err=%v want %v", err, tt.wantErr)
}
if got != tt.wantValue {
t.Errorf("got %v want %v", got, cache.strings[tt.key])
}
if !tt.preserveHandler {
testHandler.key, testHandler.s, testHandler.err = "do not read", "", nil
}
got, err = cache.ReadUInt64(tt.key)
if err != tt.wantErr {
t.Errorf("repeat err=%v want %v", err, tt.wantErr)
}
if got != tt.wantValue {
t.Errorf("repeat got %v want %v", got, cache.strings[tt.key])
}
if testHandler.calls != tt.expectedCalls {
t.Errorf("calls=%v want %v", testHandler.calls, tt.expectedCalls)
}
})
}
}
func TestHandlerReadBool(t *testing.T) {
tests := []struct {
name string
key string
handlerKey Key
handlerValue bool
handlerError error
preserveHandler bool
wantValue bool
wantErr error
bools map[string]bool
expectedCalls int
}{
{
name: "read existing cached values",
key: "test",
handlerKey: "do not read",
bools: map[string]bool{"test": true},
wantValue: true,
expectedCalls: 0,
},
{
name: "read existing values not cached",
key: "test",
handlerKey: "test",
handlerValue: true,
wantValue: true,
expectedCalls: 1,
},
{
name: "error no such key",
key: "test",
handlerKey: "test",
handlerError: ErrNoSuchKey,
wantErr: ErrNoSuchKey,
expectedCalls: 1,
},
{
name: "other error",
key: "test",
handlerKey: "test",
handlerError: someOtherError,
wantErr: someOtherError,
preserveHandler: true,
expectedCalls: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testHandler := &testHandler{
t: t,
key: tt.handlerKey,
b: tt.handlerValue,
err: tt.handlerError,
}
cache := NewCachingHandler(testHandler)
if tt.bools != nil {
cache.bools = tt.bools
}
got, err := cache.ReadBoolean(tt.key)
if err != tt.wantErr {
t.Errorf("err=%v want %v", err, tt.wantErr)
}
if got != tt.wantValue {
t.Errorf("got %v want %v", got, cache.strings[tt.key])
}
if !tt.preserveHandler {
testHandler.key, testHandler.s, testHandler.err = "do not read", "", nil
}
got, err = cache.ReadBoolean(tt.key)
if err != tt.wantErr {
t.Errorf("repeat err=%v want %v", err, tt.wantErr)
}
if got != tt.wantValue {
t.Errorf("repeat got %v want %v", got, cache.strings[tt.key])
}
if testHandler.calls != tt.expectedCalls {
t.Errorf("calls=%v want %v", testHandler.calls, tt.expectedCalls)
}
})
}
}

@ -4,16 +4,17 @@
package syspolicy package syspolicy
import ( import (
"errors" "tailscale.com/util/syspolicy/internal"
"sync/atomic" "tailscale.com/util/syspolicy/rsop"
"tailscale.com/util/syspolicy/setting"
"tailscale.com/util/syspolicy/source"
) )
var ( // TODO(nickkhyl): delete this file once other repos are updated.
handlerUsed atomic.Bool
handler Handler = defaultHandler{}
)
// Handler reads system policies from OS-specific storage. // Handler reads system policies from OS-specific storage.
//
// Deprecated: implementing a [source.Store] should be preferred.
type Handler interface { type Handler interface {
// ReadString reads the policy setting's string value for the given key. // ReadString reads the policy setting's string value for the given key.
// It should return ErrNoSuchKey if the key does not have a value set. // It should return ErrNoSuchKey if the key does not have a value set.
@ -29,55 +30,88 @@ type Handler interface {
ReadStringArray(key string) ([]string, error) ReadStringArray(key string) ([]string, error)
} }
// ErrNoSuchKey is returned by a Handler when the specified key does not have a // RegisterHandler wraps and registers the specified handler as the device's
// value set. // policy [source.Store] for the program's lifetime.
var ErrNoSuchKey = errors.New("no such key") //
// Deprecated: using [RegisterStore] should be preferred.
func RegisterHandler(h Handler) {
rsop.RegisterStore("DeviceHandler", setting.DeviceScope, WrapHandler(h))
}
// defaultHandler is the catch all syspolicy type for anything that isn't windows or apple. // TB is a subset of testing.TB that we use to set up test helpers.
type defaultHandler struct{} // It's defined here to avoid pulling in the testing package.
type TB = internal.TB
func (defaultHandler) ReadString(_ string) (string, error) { // SetHandlerForTest wraps and sets the specified handler as the device's policy
return "", ErrNoSuchKey // [source.Store] for the duration of tb.
//
// Deprecated: using [MustRegisterStoreForTest] should be preferred.
func SetHandlerForTest(tb TB, h Handler) {
RegisterWellKnownSettingsForTest(tb)
MustRegisterStoreForTest(tb, "DeviceHandler-TestOnly", setting.DefaultScope(), WrapHandler(h))
} }
func (defaultHandler) ReadUInt64(_ string) (uint64, error) { var _ source.Store = (*handlerStore)(nil)
return 0, ErrNoSuchKey
// handlerStore is a [source.Store] that calls the underlying [Handler].
//
// TODO(nickkhyl): remove it when the corp and android repos are updated.
type handlerStore struct {
h Handler
} }
func (defaultHandler) ReadBoolean(_ string) (bool, error) { // WrapHandler returns a [source.Store] that wraps the specified [Handler].
return false, ErrNoSuchKey func WrapHandler(h Handler) source.Store {
return handlerStore{h}
} }
func (defaultHandler) ReadStringArray(_ string) ([]string, error) { // Lock implements [source.Lockable].
return nil, ErrNoSuchKey func (s handlerStore) Lock() error {
if lockable, ok := s.h.(source.Lockable); ok {
return lockable.Lock()
}
return nil
} }
// markHandlerInUse is called before handler methods are called. // Unlock implements [source.Lockable].
func markHandlerInUse() { func (s handlerStore) Unlock() {
handlerUsed.Store(true) if lockable, ok := s.h.(source.Lockable); ok {
lockable.Unlock()
}
} }
// RegisterHandler initializes the policy handler and ensures registration will happen once. // RegisterChangeCallback implements [source.Changeable].
func RegisterHandler(h Handler) { func (s handlerStore) RegisterChangeCallback(callback func()) (unregister func(), err error) {
// Technically this assignment is not concurrency safe, but in the if changeable, ok := s.h.(source.Changeable); ok {
// event that there was any risk of a data race, we will panic due to return changeable.RegisterChangeCallback(callback)
// the CompareAndSwap failing.
handler = h
if !handlerUsed.CompareAndSwap(false, true) {
panic("handler was already used before registration")
} }
return func() {}, nil
} }
// TB is a subset of testing.TB that we use to set up test helpers. // ReadString implements [source.Store].
// It's defined here to avoid pulling in the testing package. func (s handlerStore) ReadString(key setting.Key) (string, error) {
type TB interface { return s.h.ReadString(string(key))
Helper()
Cleanup(func())
} }
func SetHandlerForTest(tb TB, h Handler) { // ReadUInt64 implements [source.Store].
tb.Helper() func (s handlerStore) ReadUInt64(key setting.Key) (uint64, error) {
oldHandler := handler return s.h.ReadUInt64(string(key))
handler = h }
tb.Cleanup(func() { handler = oldHandler })
// ReadBoolean implements [source.Store].
func (s handlerStore) ReadBoolean(key setting.Key) (bool, error) {
return s.h.ReadBoolean(string(key))
}
// ReadStringArray implements [source.Store].
func (s handlerStore) ReadStringArray(key setting.Key) ([]string, error) {
return s.h.ReadStringArray(string(key))
}
// Done implements [source.Expirable].
func (s handlerStore) Done() <-chan struct{} {
if expirable, ok := s.h.(source.Expirable); ok {
return expirable.Done()
}
return nil
} }

@ -1,19 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import "testing"
func TestDefaultHandlerReadValues(t *testing.T) {
var h defaultHandler
got, err := h.ReadString(string(AdminConsoleVisibility))
if got != "" || err != ErrNoSuchKey {
t.Fatalf("got %v err %v", got, err)
}
result, err := h.ReadUInt64(string(LogSCMInteractions))
if result != 0 || err != ErrNoSuchKey {
t.Fatalf("got %v err %v", result, err)
}
}

@ -1,105 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"errors"
"fmt"
"tailscale.com/util/clientmetric"
"tailscale.com/util/winutil"
)
var (
windowsErrors = clientmetric.NewCounter("windows_syspolicy_errors")
windowsAny = clientmetric.NewGauge("windows_syspolicy_any")
)
type windowsHandler struct{}
func init() {
RegisterHandler(NewCachingHandler(windowsHandler{}))
keyList := []struct {
isSet func(Key) bool
keys []Key
}{
{
isSet: func(k Key) bool {
_, err := handler.ReadString(string(k))
return err == nil
},
keys: stringKeys,
},
{
isSet: func(k Key) bool {
_, err := handler.ReadBoolean(string(k))
return err == nil
},
keys: boolKeys,
},
{
isSet: func(k Key) bool {
_, err := handler.ReadUInt64(string(k))
return err == nil
},
keys: uint64Keys,
},
}
var anySet bool
for _, l := range keyList {
for _, k := range l.keys {
if !l.isSet(k) {
continue
}
clientmetric.NewGauge(fmt.Sprintf("windows_syspolicy_%s", k)).Set(1)
anySet = true
}
}
if anySet {
windowsAny.Set(1)
}
}
func (windowsHandler) ReadString(key string) (string, error) {
s, err := winutil.GetPolicyString(key)
if errors.Is(err, winutil.ErrNoValue) {
err = ErrNoSuchKey
} else if err != nil {
windowsErrors.Add(1)
}
return s, err
}
func (windowsHandler) ReadUInt64(key string) (uint64, error) {
value, err := winutil.GetPolicyInteger(key)
if errors.Is(err, winutil.ErrNoValue) {
err = ErrNoSuchKey
} else if err != nil {
windowsErrors.Add(1)
}
return value, err
}
func (windowsHandler) ReadBoolean(key string) (bool, error) {
value, err := winutil.GetPolicyInteger(key)
if errors.Is(err, winutil.ErrNoValue) {
err = ErrNoSuchKey
} else if err != nil {
windowsErrors.Add(1)
}
return value != 0, err
}
func (windowsHandler) ReadStringArray(key string) ([]string, error) {
value, err := winutil.GetPolicyStringArray(key)
if errors.Is(err, winutil.ErrNoValue) {
err = ErrNoSuchKey
} else if err != nil {
windowsErrors.Add(1)
}
return value, err
}

@ -3,10 +3,24 @@
package syspolicy package syspolicy
import "tailscale.com/util/syspolicy/setting" import (
"tailscale.com/types/lazy"
"tailscale.com/util/syspolicy/internal"
"tailscale.com/util/syspolicy/setting"
"tailscale.com/util/testenv"
)
// Key is a string that uniquely identifies a policy and must remain unchanged
// once established and documented for a given policy setting. It may contain
// alphanumeric characters and zero or more [KeyPathSeparator]s to group
// individual policy settings into categories.
type Key = setting.Key type Key = setting.Key
// The const block below lists known policy keys.
// When adding a key to this list, remember to add a corresponding
// [setting.Definition] to [implicitDefinitions] below.
// Otherwise, the [TestKnownKeysRegistered] test will fail as a reminder.
const ( const (
// Keys with a string value // Keys with a string value
ControlURL Key = "LoginURL" // default ""; if blank, ipn uses ipn.DefaultControlURL. ControlURL Key = "LoginURL" // default ""; if blank, ipn uses ipn.DefaultControlURL.
@ -110,3 +124,90 @@ const (
// AllowedSuggestedExitNodes's string array value is a list of exit node IDs that restricts which exit nodes are considered when generating suggestions for exit nodes. // AllowedSuggestedExitNodes's string array value is a list of exit node IDs that restricts which exit nodes are considered when generating suggestions for exit nodes.
AllowedSuggestedExitNodes Key = "AllowedSuggestedExitNodes" AllowedSuggestedExitNodes Key = "AllowedSuggestedExitNodes"
) )
// implicitDefinitions is a list of [setting.Definition] that will be registered
// automatically when the policy setting definitions are first used by the syspolicy package hierarchy.
// This includes the first time a policy needs to be read from any source.
var implicitDefinitions = []*setting.Definition{
// Device policy settings (can only be configured on a per-device basis):
setting.NewDefinition(AllowedSuggestedExitNodes, setting.DeviceSetting, setting.StringListValue),
setting.NewDefinition(ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(AuthKey, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(CheckUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(ControlURL, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(DeviceSerialNumber, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(EnableIncomingConnections, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(EnableRunExitNode, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(EnableServerMode, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(EnableTailscaleDNS, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(EnableTailscaleSubnets, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(ExitNodeAllowLANAccess, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(ExitNodeID, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(ExitNodeIP, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(FlushDNSOnSessionUnlock, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(LogSCMInteractions, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(MachineCertificateSubject, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(PostureChecking, setting.DeviceSetting, setting.PreferenceOptionValue),
setting.NewDefinition(Tailnet, setting.DeviceSetting, setting.StringValue),
// User policy settings (can be configured on a user- or device-basis):
setting.NewDefinition(AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(AutoUpdateVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(ExitNodeMenuVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(KeyExpirationNoticeTime, setting.UserSetting, setting.DurationValue),
setting.NewDefinition(ManagedByCaption, setting.UserSetting, setting.StringValue),
setting.NewDefinition(ManagedByOrganizationName, setting.UserSetting, setting.StringValue),
setting.NewDefinition(ManagedByURL, setting.UserSetting, setting.StringValue),
setting.NewDefinition(NetworkDevicesVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(PreferencesMenuVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(ResetToDefaultsVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(RunExitNodeVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(SuggestedExitNodeVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(TestMenuVisibility, setting.UserSetting, setting.VisibilityValue),
setting.NewDefinition(UpdateMenuVisibility, setting.UserSetting, setting.VisibilityValue),
}
func init() {
internal.Init.MustDefer(func() error {
// Avoid implicit [setting.Definition] registration during tests.
// Each test should control which policy settings to register.
// Use [setting.SetDefinitionsForTest] to specify necessary definitions,
// or [setWellKnownSettingsForTest] to set implicit definitions for the test duration.
if testenv.InTest() {
return nil
}
for _, d := range implicitDefinitions {
setting.RegisterDefinition(d)
}
return nil
})
}
var implicitDefinitionMap lazy.SyncValue[setting.DefinitionMap]
// WellKnownSettingDefinition returns a well-known, implicit setting definition by its key,
// or an [ErrNoSuchKey] if a policy setting with the specified key does not exist
// among implicit policy definitions.
func WellKnownSettingDefinition(k Key) (*setting.Definition, error) {
m, err := implicitDefinitionMap.GetErr(func() (setting.DefinitionMap, error) {
return setting.DefinitionMapOf(implicitDefinitions)
})
if err != nil {
return nil, err
}
if d, ok := m[k]; ok {
return d, nil
}
return nil, ErrNoSuchKey
}
// RegisterWellKnownSettingsForTest registers all implicit setting definitions
// for the duration of the test.
func RegisterWellKnownSettingsForTest(tb TB) {
tb.Helper()
err := setting.SetDefinitionsForTest(tb, implicitDefinitions...)
if err != nil {
tb.Fatalf("Failed to register well-known settings: %v", err)
}
}

@ -0,0 +1,95 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"go/types"
"os"
"reflect"
"strconv"
"testing"
"tailscale.com/util/syspolicy/setting"
)
func TestKnownKeysRegistered(t *testing.T) {
keyConsts, err := listStringConsts[Key]("policy_keys.go")
if err != nil {
t.Fatalf("listStringConsts failed: %v", err)
}
m, err := setting.DefinitionMapOf(implicitDefinitions)
if err != nil {
t.Fatalf("definitionMapOf failed: %v", err)
}
for _, key := range keyConsts {
t.Run(string(key), func(t *testing.T) {
d := m[key]
if d == nil {
t.Fatalf("%q was not registered", key)
}
if d.Key() != key {
t.Fatalf("d.Key got: %s, want %s", d.Key(), key)
}
})
}
}
func TestNotAWellKnownSetting(t *testing.T) {
d, err := WellKnownSettingDefinition("TestSettingDoesNotExist")
if d != nil || err == nil {
t.Fatalf("got %v, %v; want nil, %v", d, err, ErrNoSuchKey)
}
}
func listStringConsts[T ~string](filename string) (map[string]T, error) {
fset := token.NewFileSet()
src, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
f, err := parser.ParseFile(fset, filename, src, 0)
if err != nil {
return nil, err
}
consts := make(map[string]T)
typeName := reflect.TypeFor[T]().Name()
for _, d := range f.Decls {
g, ok := d.(*ast.GenDecl)
if !ok || g.Tok != token.CONST {
continue
}
for _, s := range g.Specs {
vs, ok := s.(*ast.ValueSpec)
if !ok || len(vs.Names) != len(vs.Values) {
continue
}
if typ, ok := vs.Type.(*ast.Ident); !ok || typ.Name != typeName {
continue
}
for i, n := range vs.Names {
lit, ok := vs.Values[i].(*ast.BasicLit)
if !ok {
return nil, fmt.Errorf("unexpected string literal: %v = %v", n.Name, types.ExprString(vs.Values[i]))
}
val, err := strconv.Unquote(lit.Value)
if err != nil {
return nil, fmt.Errorf("unexpected string literal: %v = %v", n.Name, lit.Value)
}
consts[n.Name] = T(val)
}
}
}
return consts, nil
}

@ -1,38 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
var stringKeys = []Key{
ControlURL,
LogTarget,
Tailnet,
ExitNodeID,
ExitNodeIP,
EnableIncomingConnections,
EnableServerMode,
ExitNodeAllowLANAccess,
EnableTailscaleDNS,
EnableTailscaleSubnets,
AdminConsoleVisibility,
NetworkDevicesVisibility,
TestMenuVisibility,
UpdateMenuVisibility,
RunExitNodeVisibility,
PreferencesMenuVisibility,
ExitNodeMenuVisibility,
AutoUpdateVisibility,
ResetToDefaultsVisibility,
KeyExpirationNoticeTime,
PostureChecking,
ManagedByOrganizationName,
ManagedByCaption,
ManagedByURL,
}
var boolKeys = []Key{
LogSCMInteractions,
FlushDNSOnSessionUnlock,
}
var uint64Keys = []Key{}

@ -1,51 +1,82 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
// Package syspolicy provides functions to retrieve system settings of a device. // Package syspolicy facilitates retrieval of the current policy settings
// applied to the device or user and receiving notifications when the policy
// changes.
//
// It provides functions that return specific policy settings by their unique
// [setting.Key]s, such as [GetBoolean], [GetUint64], [GetString],
// [GetStringArray], [GetPreferenceOption], [GetVisibility] and [GetDuration].
package syspolicy package syspolicy
import ( import (
"errors" "errors"
"fmt"
"reflect"
"time" "time"
"tailscale.com/util/syspolicy/internal/loggerx" "tailscale.com/util/syspolicy/internal/loggerx"
"tailscale.com/util/syspolicy/rsop"
"tailscale.com/util/syspolicy/setting" "tailscale.com/util/syspolicy/setting"
"tailscale.com/util/syspolicy/source"
) )
func GetString(key Key, defaultValue string) (string, error) { var (
markHandlerInUse() // ErrNotConfigured is returned when the requested policy setting is not configured.
v, err := handler.ReadString(string(key)) ErrNotConfigured = setting.ErrNotConfigured
if errors.Is(err, ErrNoSuchKey) { // ErrTypeMismatch is returned when there's a type mismatch between the actual type
return defaultValue, nil // of the setting value and the expected type.
ErrTypeMismatch = setting.ErrTypeMismatch
// ErrNoSuchKey is returned by [setting.DefinitionOf] when no policy setting
// has been registered with the specified key.
//
// This error is also returned by a (now deprecated) [Handler] when the specified
// key does not have a value set. While the package maintains compatibility with this
// usage of ErrNoSuchKey, it is recommended to return [ErrNotConfigured] from newer
// [source.Store] implementations.
ErrNoSuchKey = setting.ErrNoSuchKey
)
// RegisterStore registers a new policy [source.Store] with the specified name and [setting.PolicyScope].
//
// It is a shorthand for [rsop.RegisterStore].
func RegisterStore(name string, scope setting.PolicyScope, store source.Store) (*rsop.StoreRegistration, error) {
return rsop.RegisterStore(name, scope, store)
} }
return v, err
// MustRegisterStoreForTest is like [rsop.RegisterStoreForTest], but it fails the test if the store could not be registered.
func MustRegisterStoreForTest(tb TB, name string, scope setting.PolicyScope, store source.Store) *rsop.StoreRegistration {
tb.Helper()
reg, err := rsop.RegisterStoreForTest(tb, name, scope, store)
if err != nil {
tb.Fatalf("Failed to register policy store %q as a %v policy source: %v", name, scope, err)
}
return reg
} }
func GetUint64(key Key, defaultValue uint64) (uint64, error) { // GetString returns a string policy setting with the specified key,
markHandlerInUse() // or defaultValue if it does not exist.
v, err := handler.ReadUInt64(string(key)) func GetString(key Key, defaultValue string) (string, error) {
if errors.Is(err, ErrNoSuchKey) { return getCurrentPolicySettingValue(key, defaultValue)
return defaultValue, nil
} }
return v, err
// GetUint64 returns a numeric policy setting with the specified key,
// or defaultValue if it does not exist.
func GetUint64(key Key, defaultValue uint64) (uint64, error) {
return getCurrentPolicySettingValue(key, defaultValue)
} }
// GetBoolean returns a boolean policy setting with the specified key,
// or defaultValue if it does not exist.
func GetBoolean(key Key, defaultValue bool) (bool, error) { func GetBoolean(key Key, defaultValue bool) (bool, error) {
markHandlerInUse() return getCurrentPolicySettingValue(key, defaultValue)
v, err := handler.ReadBoolean(string(key))
if errors.Is(err, ErrNoSuchKey) {
return defaultValue, nil
}
return v, err
} }
// GetStringArray returns a multi-string policy setting with the specified key,
// or defaultValue if it does not exist.
func GetStringArray(key Key, defaultValue []string) ([]string, error) { func GetStringArray(key Key, defaultValue []string) ([]string, error) {
markHandlerInUse() return getCurrentPolicySettingValue(key, defaultValue)
v, err := handler.ReadStringArray(string(key))
if errors.Is(err, ErrNoSuchKey) {
return defaultValue, nil
}
return v, err
} }
// GetPreferenceOption loads a policy from the registry that can be // GetPreferenceOption loads a policy from the registry that can be
@ -55,13 +86,7 @@ func GetStringArray(key Key, defaultValue []string) ([]string, error) {
// "always" and "never" remove the user's ability to make a selection. If not // "always" and "never" remove the user's ability to make a selection. If not
// present or set to a different value, "user-decides" is the default. // present or set to a different value, "user-decides" is the default.
func GetPreferenceOption(name Key) (setting.PreferenceOption, error) { func GetPreferenceOption(name Key) (setting.PreferenceOption, error) {
s, err := GetString(name, "user-decides") return getCurrentPolicySettingValue(name, setting.ShowChoiceByPolicy)
if err != nil {
return setting.ShowChoiceByPolicy, err
}
var opt setting.PreferenceOption
err = opt.UnmarshalText([]byte(s))
return opt, err
} }
// GetVisibility loads a policy from the registry that can be managed // GetVisibility loads a policy from the registry that can be managed
@ -70,13 +95,7 @@ func GetPreferenceOption(name Key) (setting.PreferenceOption, error) {
// true) or "hide" (return true). If not present or set to a different value, // true) or "hide" (return true). If not present or set to a different value,
// "show" (return false) is the default. // "show" (return false) is the default.
func GetVisibility(name Key) (setting.Visibility, error) { func GetVisibility(name Key) (setting.Visibility, error) {
s, err := GetString(name, "show") return getCurrentPolicySettingValue(name, setting.VisibleByPolicy)
if err != nil {
return setting.VisibleByPolicy, err
}
var visibility setting.Visibility
visibility.UnmarshalText([]byte(s))
return visibility, nil
} }
// GetDuration loads a policy from the registry that can be managed // GetDuration loads a policy from the registry that can be managed
@ -85,15 +104,58 @@ func GetVisibility(name Key) (setting.Visibility, error) {
// understands. If the registry value is "" or can not be processed, // understands. If the registry value is "" or can not be processed,
// defaultValue is returned instead. // defaultValue is returned instead.
func GetDuration(name Key, defaultValue time.Duration) (time.Duration, error) { func GetDuration(name Key, defaultValue time.Duration) (time.Duration, error) {
opt, err := GetString(name, "") d, err := getCurrentPolicySettingValue(name, defaultValue)
if opt == "" || err != nil { if err != nil {
return defaultValue, err return d, err
} }
v, err := time.ParseDuration(opt) if d < 0 {
if err != nil || v < 0 {
return defaultValue, nil return defaultValue, nil
} }
return v, nil return d, nil
}
// 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())
if err != nil {
return nil, err
}
return effective.RegisterChangeCallback(cb), nil
}
// getCurrentPolicySettingValue returns the value of the policy setting
// specified by its key from the [rsop.Policy] of the [setting.DefaultScope]. It
// returns def if the policy setting is not configured, or an error if it has
// an error or could not be converted to the specified type T.
func getCurrentPolicySettingValue[T setting.ValueType](key Key, def T) (T, error) {
effective, err := rsop.PolicyFor(setting.DefaultScope())
if err != nil {
return def, err
}
value, err := effective.Get().GetErr(key)
if err != nil {
if errors.Is(err, setting.ErrNotConfigured) || errors.Is(err, setting.ErrNoSuchKey) {
return def, nil
}
return def, err
}
if res, ok := value.(T); ok {
return res, nil
}
return convertPolicySettingValueTo(value, def)
}
func convertPolicySettingValueTo[T setting.ValueType](value any, def T) (T, error) {
// Convert [PreferenceOption], [Visibility], or [time.Duration] back to a string
// if someone requests a string instead of the actual setting's value.
// TODO(nickkhyl): check if this behavior is relied upon anywhere besides the old tests.
if reflect.TypeFor[T]().Kind() == reflect.String {
if str, ok := value.(fmt.Stringer); ok {
return any(str.String()).(T), nil
}
}
return def, fmt.Errorf("%w: got %T, want %T", setting.ErrTypeMismatch, value, def)
} }
// SelectControlURL returns the ControlURL to use based on a value in // SelectControlURL returns the ControlURL to use based on a value in

@ -9,57 +9,15 @@ import (
"testing" "testing"
"time" "time"
"tailscale.com/types/logger"
"tailscale.com/util/syspolicy/internal/loggerx"
"tailscale.com/util/syspolicy/internal/metrics"
"tailscale.com/util/syspolicy/setting" "tailscale.com/util/syspolicy/setting"
"tailscale.com/util/syspolicy/source"
) )
// testHandler encompasses all data types returned when testing any of the syspolicy
// methods that involve getting a policy value.
// For keys and the corresponding values, check policy_keys.go.
type testHandler struct {
t *testing.T
key Key
s string
u64 uint64
b bool
sArr []string
err error
calls int // used for testing reads from cache vs. handler
}
var someOtherError = errors.New("error other than not found") var someOtherError = errors.New("error other than not found")
func (th *testHandler) ReadString(key string) (string, error) {
if key != string(th.key) {
th.t.Errorf("ReadString(%q) want %q", key, th.key)
}
th.calls++
return th.s, th.err
}
func (th *testHandler) ReadUInt64(key string) (uint64, error) {
if key != string(th.key) {
th.t.Errorf("ReadUint64(%q) want %q", key, th.key)
}
th.calls++
return th.u64, th.err
}
func (th *testHandler) ReadBoolean(key string) (bool, error) {
if key != string(th.key) {
th.t.Errorf("ReadBool(%q) want %q", key, th.key)
}
th.calls++
return th.b, th.err
}
func (th *testHandler) ReadStringArray(key string) ([]string, error) {
if key != string(th.key) {
th.t.Errorf("ReadStringArray(%q) want %q", key, th.key)
}
th.calls++
return th.sArr, th.err
}
func TestGetString(t *testing.T) { func TestGetString(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -69,23 +27,28 @@ func TestGetString(t *testing.T) {
defaultValue string defaultValue string
wantValue string wantValue string
wantError error wantError error
wantMetrics []metrics.TestState
}{ }{
{ {
name: "read existing value", name: "read existing value",
key: AdminConsoleVisibility, key: AdminConsoleVisibility,
handlerValue: "hide", handlerValue: "hide",
wantValue: "hide", wantValue: "hide",
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_AdminConsole", Value: 1},
},
}, },
{ {
name: "read non-existing value", name: "read non-existing value",
key: EnableServerMode, key: EnableServerMode,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantError: nil, wantError: nil,
}, },
{ {
name: "read non-existing value, non-blank default", name: "read non-existing value, non-blank default",
key: EnableServerMode, key: EnableServerMode,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
defaultValue: "test", defaultValue: "test",
wantValue: "test", wantValue: "test",
wantError: nil, wantError: nil,
@ -95,24 +58,43 @@ func TestGetString(t *testing.T) {
key: NetworkDevicesVisibility, key: NetworkDevicesVisibility,
handlerError: someOtherError, handlerError: someOtherError,
wantError: someOtherError, wantError: someOtherError,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_errors", Value: 1},
{Name: "$os_syspolicy_NetworkDevices_error", Value: 1},
},
}, },
} }
RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
SetHandlerForTest(t, &testHandler{ h := metrics.NewTestHandler(t)
t: t, metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric)
key: tt.key,
s: tt.handlerValue, s := source.TestSetting[string]{
err: tt.handlerError, Key: tt.key,
}) Value: tt.handlerValue,
Error: tt.handlerError,
}
registerSingleSettingStoreForTest(t, s)
value, err := GetString(tt.key, tt.defaultValue) value, err := GetString(tt.key, tt.defaultValue)
if err != tt.wantError { if !errorsMatchForTest(err, tt.wantError) {
t.Errorf("err=%q, want %q", err, tt.wantError) t.Errorf("err=%q, want %q", err, tt.wantError)
} }
if value != tt.wantValue { if value != tt.wantValue {
t.Errorf("value=%v, want %v", value, tt.wantValue) t.Errorf("value=%v, want %v", value, tt.wantValue)
} }
wantMetrics := tt.wantMetrics
if !metrics.ShouldReport() {
// Check that metrics are not reported on platforms
// where they shouldn't be reported.
// As of 2024-09-04, syspolicy only reports metrics
// on Windows and Android.
wantMetrics = nil
}
h.MustEqual(wantMetrics...)
}) })
} }
} }
@ -129,7 +111,7 @@ func TestGetUint64(t *testing.T) {
}{ }{
{ {
name: "read existing value", name: "read existing value",
key: KeyExpirationNoticeTime, key: LogSCMInteractions,
handlerValue: 1, handlerValue: 1,
wantValue: 1, wantValue: 1,
}, },
@ -137,14 +119,14 @@ func TestGetUint64(t *testing.T) {
name: "read non-existing value", name: "read non-existing value",
key: LogSCMInteractions, key: LogSCMInteractions,
handlerValue: 0, handlerValue: 0,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantValue: 0, wantValue: 0,
}, },
{ {
name: "read non-existing value, non-zero default", name: "read non-existing value, non-zero default",
key: LogSCMInteractions, key: LogSCMInteractions,
defaultValue: 2, defaultValue: 2,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantValue: 2, wantValue: 2,
}, },
{ {
@ -157,14 +139,23 @@ func TestGetUint64(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
SetHandlerForTest(t, &testHandler{ // None of the policy settings tested here are integers.
t: t, // In fact, we don't have any integer policies as of 2024-10-08.
key: tt.key, // However, we can register each of them as an integer policy setting
u64: tt.handlerValue, // for the duration of the test, providing us with something to test against.
err: tt.handlerError, if err := setting.SetDefinitionsForTest(t, setting.NewDefinition(tt.key, setting.DeviceSetting, setting.IntegerValue)); err != nil {
}) t.Fatalf("SetDefinitionsForTest failed: %v", err)
}
s := source.TestSetting[uint64]{
Key: tt.key,
Value: tt.handlerValue,
Error: tt.handlerError,
}
registerSingleSettingStoreForTest(t, s)
value, err := GetUint64(tt.key, tt.defaultValue) value, err := GetUint64(tt.key, tt.defaultValue)
if err != tt.wantError { if !errorsMatchForTest(err, tt.wantError) {
t.Errorf("err=%q, want %q", err, tt.wantError) t.Errorf("err=%q, want %q", err, tt.wantError)
} }
if value != tt.wantValue { if value != tt.wantValue {
@ -183,45 +174,69 @@ func TestGetBoolean(t *testing.T) {
defaultValue bool defaultValue bool
wantValue bool wantValue bool
wantError error wantError error
wantMetrics []metrics.TestState
}{ }{
{ {
name: "read existing value", name: "read existing value",
key: FlushDNSOnSessionUnlock, key: FlushDNSOnSessionUnlock,
handlerValue: true, handlerValue: true,
wantValue: true, wantValue: true,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_FlushDNSOnSessionUnlock", Value: 1},
},
}, },
{ {
name: "read non-existing value", name: "read non-existing value",
key: LogSCMInteractions, key: LogSCMInteractions,
handlerValue: false, handlerValue: false,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantValue: false, wantValue: false,
}, },
{ {
name: "reading value returns other error", name: "reading value returns other error",
key: FlushDNSOnSessionUnlock, key: FlushDNSOnSessionUnlock,
handlerError: someOtherError, handlerError: someOtherError,
wantError: someOtherError, wantError: someOtherError, // expect error...
defaultValue: true, defaultValue: true,
wantValue: false, wantValue: true, // ...AND default value if the handler fails.
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_errors", Value: 1},
{Name: "$os_syspolicy_FlushDNSOnSessionUnlock_error", Value: 1},
},
}, },
} }
RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
SetHandlerForTest(t, &testHandler{ h := metrics.NewTestHandler(t)
t: t, metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric)
key: tt.key,
b: tt.handlerValue, s := source.TestSetting[bool]{
err: tt.handlerError, Key: tt.key,
}) Value: tt.handlerValue,
Error: tt.handlerError,
}
registerSingleSettingStoreForTest(t, s)
value, err := GetBoolean(tt.key, tt.defaultValue) value, err := GetBoolean(tt.key, tt.defaultValue)
if err != tt.wantError { if !errorsMatchForTest(err, tt.wantError) {
t.Errorf("err=%q, want %q", err, tt.wantError) t.Errorf("err=%q, want %q", err, tt.wantError)
} }
if value != tt.wantValue { if value != tt.wantValue {
t.Errorf("value=%v, want %v", value, tt.wantValue) t.Errorf("value=%v, want %v", value, tt.wantValue)
} }
wantMetrics := tt.wantMetrics
if !metrics.ShouldReport() {
// Check that metrics are not reported on platforms
// where they shouldn't be reported.
// As of 2024-09-04, syspolicy only reports metrics
// on Windows and Android.
wantMetrics = nil
}
h.MustEqual(wantMetrics...)
}) })
} }
} }
@ -234,29 +249,42 @@ func TestGetPreferenceOption(t *testing.T) {
handlerError error handlerError error
wantValue setting.PreferenceOption wantValue setting.PreferenceOption
wantError error wantError error
wantMetrics []metrics.TestState
}{ }{
{ {
name: "always by policy", name: "always by policy",
key: EnableIncomingConnections, key: EnableIncomingConnections,
handlerValue: "always", handlerValue: "always",
wantValue: setting.AlwaysByPolicy, wantValue: setting.AlwaysByPolicy,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_AllowIncomingConnections", Value: 1},
},
}, },
{ {
name: "never by policy", name: "never by policy",
key: EnableIncomingConnections, key: EnableIncomingConnections,
handlerValue: "never", handlerValue: "never",
wantValue: setting.NeverByPolicy, wantValue: setting.NeverByPolicy,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_AllowIncomingConnections", Value: 1},
},
}, },
{ {
name: "use default", name: "use default",
key: EnableIncomingConnections, key: EnableIncomingConnections,
handlerValue: "", handlerValue: "",
wantValue: setting.ShowChoiceByPolicy, wantValue: setting.ShowChoiceByPolicy,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_AllowIncomingConnections", Value: 1},
},
}, },
{ {
name: "read non-existing value", name: "read non-existing value",
key: EnableIncomingConnections, key: EnableIncomingConnections,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantValue: setting.ShowChoiceByPolicy, wantValue: setting.ShowChoiceByPolicy,
}, },
{ {
@ -265,24 +293,43 @@ func TestGetPreferenceOption(t *testing.T) {
handlerError: someOtherError, handlerError: someOtherError,
wantValue: setting.ShowChoiceByPolicy, wantValue: setting.ShowChoiceByPolicy,
wantError: someOtherError, wantError: someOtherError,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_errors", Value: 1},
{Name: "$os_syspolicy_AllowIncomingConnections_error", Value: 1},
},
}, },
} }
RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
SetHandlerForTest(t, &testHandler{ h := metrics.NewTestHandler(t)
t: t, metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric)
key: tt.key,
s: tt.handlerValue, s := source.TestSetting[string]{
err: tt.handlerError, Key: tt.key,
}) Value: tt.handlerValue,
Error: tt.handlerError,
}
registerSingleSettingStoreForTest(t, s)
option, err := GetPreferenceOption(tt.key) option, err := GetPreferenceOption(tt.key)
if err != tt.wantError { if !errorsMatchForTest(err, tt.wantError) {
t.Errorf("err=%q, want %q", err, tt.wantError) t.Errorf("err=%q, want %q", err, tt.wantError)
} }
if option != tt.wantValue { if option != tt.wantValue {
t.Errorf("option=%v, want %v", option, tt.wantValue) t.Errorf("option=%v, want %v", option, tt.wantValue)
} }
wantMetrics := tt.wantMetrics
if !metrics.ShouldReport() {
// Check that metrics are not reported on platforms
// where they shouldn't be reported.
// As of 2024-09-04, syspolicy only reports metrics
// on Windows and Android.
wantMetrics = nil
}
h.MustEqual(wantMetrics...)
}) })
} }
} }
@ -295,24 +342,33 @@ func TestGetVisibility(t *testing.T) {
handlerError error handlerError error
wantValue setting.Visibility wantValue setting.Visibility
wantError error wantError error
wantMetrics []metrics.TestState
}{ }{
{ {
name: "hidden by policy", name: "hidden by policy",
key: AdminConsoleVisibility, key: AdminConsoleVisibility,
handlerValue: "hide", handlerValue: "hide",
wantValue: setting.HiddenByPolicy, wantValue: setting.HiddenByPolicy,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_AdminConsole", Value: 1},
},
}, },
{ {
name: "visibility default", name: "visibility default",
key: AdminConsoleVisibility, key: AdminConsoleVisibility,
handlerValue: "show", handlerValue: "show",
wantValue: setting.VisibleByPolicy, wantValue: setting.VisibleByPolicy,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_AdminConsole", Value: 1},
},
}, },
{ {
name: "read non-existing value", name: "read non-existing value",
key: AdminConsoleVisibility, key: AdminConsoleVisibility,
handlerValue: "show", handlerValue: "show",
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantValue: setting.VisibleByPolicy, wantValue: setting.VisibleByPolicy,
}, },
{ {
@ -322,24 +378,43 @@ func TestGetVisibility(t *testing.T) {
handlerError: someOtherError, handlerError: someOtherError,
wantValue: setting.VisibleByPolicy, wantValue: setting.VisibleByPolicy,
wantError: someOtherError, wantError: someOtherError,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_errors", Value: 1},
{Name: "$os_syspolicy_AdminConsole_error", Value: 1},
},
}, },
} }
RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
SetHandlerForTest(t, &testHandler{ h := metrics.NewTestHandler(t)
t: t, metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric)
key: tt.key,
s: tt.handlerValue, s := source.TestSetting[string]{
err: tt.handlerError, Key: tt.key,
}) Value: tt.handlerValue,
Error: tt.handlerError,
}
registerSingleSettingStoreForTest(t, s)
visibility, err := GetVisibility(tt.key) visibility, err := GetVisibility(tt.key)
if err != tt.wantError { if !errorsMatchForTest(err, tt.wantError) {
t.Errorf("err=%q, want %q", err, tt.wantError) t.Errorf("err=%q, want %q", err, tt.wantError)
} }
if visibility != tt.wantValue { if visibility != tt.wantValue {
t.Errorf("visibility=%v, want %v", visibility, tt.wantValue) t.Errorf("visibility=%v, want %v", visibility, tt.wantValue)
} }
wantMetrics := tt.wantMetrics
if !metrics.ShouldReport() {
// Check that metrics are not reported on platforms
// where they shouldn't be reported.
// As of 2024-09-04, syspolicy only reports metrics
// on Windows and Android.
wantMetrics = nil
}
h.MustEqual(wantMetrics...)
}) })
} }
} }
@ -353,6 +428,7 @@ func TestGetDuration(t *testing.T) {
defaultValue time.Duration defaultValue time.Duration
wantValue time.Duration wantValue time.Duration
wantError error wantError error
wantMetrics []metrics.TestState
}{ }{
{ {
name: "read existing value", name: "read existing value",
@ -360,25 +436,34 @@ func TestGetDuration(t *testing.T) {
handlerValue: "2h", handlerValue: "2h",
wantValue: 2 * time.Hour, wantValue: 2 * time.Hour,
defaultValue: 24 * time.Hour, defaultValue: 24 * time.Hour,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_KeyExpirationNotice", Value: 1},
},
}, },
{ {
name: "invalid duration value", name: "invalid duration value",
key: KeyExpirationNoticeTime, key: KeyExpirationNoticeTime,
handlerValue: "-20", handlerValue: "-20",
wantValue: 24 * time.Hour, wantValue: 24 * time.Hour,
wantError: errors.New(`time: missing unit in duration "-20"`),
defaultValue: 24 * time.Hour, defaultValue: 24 * time.Hour,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_errors", Value: 1},
{Name: "$os_syspolicy_KeyExpirationNotice_error", Value: 1},
},
}, },
{ {
name: "read non-existing value", name: "read non-existing value",
key: KeyExpirationNoticeTime, key: KeyExpirationNoticeTime,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantValue: 24 * time.Hour, wantValue: 24 * time.Hour,
defaultValue: 24 * time.Hour, defaultValue: 24 * time.Hour,
}, },
{ {
name: "read non-existing value different default", name: "read non-existing value different default",
key: KeyExpirationNoticeTime, key: KeyExpirationNoticeTime,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantValue: 0 * time.Second, wantValue: 0 * time.Second,
defaultValue: 0 * time.Second, defaultValue: 0 * time.Second,
}, },
@ -389,24 +474,43 @@ func TestGetDuration(t *testing.T) {
wantValue: 24 * time.Hour, wantValue: 24 * time.Hour,
wantError: someOtherError, wantError: someOtherError,
defaultValue: 24 * time.Hour, defaultValue: 24 * time.Hour,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_errors", Value: 1},
{Name: "$os_syspolicy_KeyExpirationNotice_error", Value: 1},
},
}, },
} }
RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
SetHandlerForTest(t, &testHandler{ h := metrics.NewTestHandler(t)
t: t, metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric)
key: tt.key,
s: tt.handlerValue, s := source.TestSetting[string]{
err: tt.handlerError, Key: tt.key,
}) Value: tt.handlerValue,
Error: tt.handlerError,
}
registerSingleSettingStoreForTest(t, s)
duration, err := GetDuration(tt.key, tt.defaultValue) duration, err := GetDuration(tt.key, tt.defaultValue)
if err != tt.wantError { if !errorsMatchForTest(err, tt.wantError) {
t.Errorf("err=%q, want %q", err, tt.wantError) t.Errorf("err=%q, want %q", err, tt.wantError)
} }
if duration != tt.wantValue { if duration != tt.wantValue {
t.Errorf("duration=%v, want %v", duration, tt.wantValue) t.Errorf("duration=%v, want %v", duration, tt.wantValue)
} }
wantMetrics := tt.wantMetrics
if !metrics.ShouldReport() {
// Check that metrics are not reported on platforms
// where they shouldn't be reported.
// As of 2024-09-04, syspolicy only reports metrics
// on Windows and Android.
wantMetrics = nil
}
h.MustEqual(wantMetrics...)
}) })
} }
} }
@ -420,23 +524,28 @@ func TestGetStringArray(t *testing.T) {
defaultValue []string defaultValue []string
wantValue []string wantValue []string
wantError error wantError error
wantMetrics []metrics.TestState
}{ }{
{ {
name: "read existing value", name: "read existing value",
key: AllowedSuggestedExitNodes, key: AllowedSuggestedExitNodes,
handlerValue: []string{"foo", "bar"}, handlerValue: []string{"foo", "bar"},
wantValue: []string{"foo", "bar"}, wantValue: []string{"foo", "bar"},
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_any", Value: 1},
{Name: "$os_syspolicy_AllowedSuggestedExitNodes", Value: 1},
},
}, },
{ {
name: "read non-existing value", name: "read non-existing value",
key: AllowedSuggestedExitNodes, key: AllowedSuggestedExitNodes,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
wantError: nil, wantError: nil,
}, },
{ {
name: "read non-existing value, non nil default", name: "read non-existing value, non nil default",
key: AllowedSuggestedExitNodes, key: AllowedSuggestedExitNodes,
handlerError: ErrNoSuchKey, handlerError: ErrNotConfigured,
defaultValue: []string{"foo", "bar"}, defaultValue: []string{"foo", "bar"},
wantValue: []string{"foo", "bar"}, wantValue: []string{"foo", "bar"},
wantError: nil, wantError: nil,
@ -446,28 +555,68 @@ func TestGetStringArray(t *testing.T) {
key: AllowedSuggestedExitNodes, key: AllowedSuggestedExitNodes,
handlerError: someOtherError, handlerError: someOtherError,
wantError: someOtherError, wantError: someOtherError,
wantMetrics: []metrics.TestState{
{Name: "$os_syspolicy_errors", Value: 1},
{Name: "$os_syspolicy_AllowedSuggestedExitNodes_error", Value: 1},
},
}, },
} }
RegisterWellKnownSettingsForTest(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
SetHandlerForTest(t, &testHandler{ h := metrics.NewTestHandler(t)
t: t, metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric)
key: tt.key,
sArr: tt.handlerValue, s := source.TestSetting[[]string]{
err: tt.handlerError, Key: tt.key,
}) Value: tt.handlerValue,
Error: tt.handlerError,
}
registerSingleSettingStoreForTest(t, s)
value, err := GetStringArray(tt.key, tt.defaultValue) value, err := GetStringArray(tt.key, tt.defaultValue)
if err != tt.wantError { if !errorsMatchForTest(err, tt.wantError) {
t.Errorf("err=%q, want %q", err, tt.wantError) t.Errorf("err=%q, want %q", err, tt.wantError)
} }
if !slices.Equal(tt.wantValue, value) { if !slices.Equal(tt.wantValue, value) {
t.Errorf("value=%v, want %v", value, tt.wantValue) t.Errorf("value=%v, want %v", value, tt.wantValue)
} }
wantMetrics := tt.wantMetrics
if !metrics.ShouldReport() {
// Check that metrics are not reported on platforms
// where they shouldn't be reported.
// As of 2024-09-04, syspolicy only reports metrics
// on Windows and Android.
wantMetrics = nil
}
h.MustEqual(wantMetrics...)
}) })
} }
} }
func registerSingleSettingStoreForTest[T source.TestValueType](tb TB, s source.TestSetting[T]) {
policyStore := source.NewTestStoreOf(tb, s)
MustRegisterStoreForTest(tb, "TestStore", setting.DeviceScope, policyStore)
}
func BenchmarkGetString(b *testing.B) {
loggerx.SetForTest(b, logger.Discard, logger.Discard)
RegisterWellKnownSettingsForTest(b)
wantControlURL := "https://login.tailscale.com"
registerSingleSettingStoreForTest(b, source.TestSettingOf(ControlURL, wantControlURL))
b.ResetTimer()
for i := 0; i < b.N; i++ {
gotControlURL, _ := GetString(ControlURL, "https://controlplane.tailscale.com")
if gotControlURL != wantControlURL {
b.Fatalf("got %v; want %v", gotControlURL, wantControlURL)
}
}
}
func TestSelectControlURL(t *testing.T) { func TestSelectControlURL(t *testing.T) {
tests := []struct { tests := []struct {
reg, disk, want string reg, disk, want string
@ -499,3 +648,13 @@ func TestSelectControlURL(t *testing.T) {
} }
} }
} }
func errorsMatchForTest(got, want error) bool {
if got == nil && want == nil {
return true
}
if got == nil || want == nil {
return false
}
return errors.Is(got, want) || got.Error() == want.Error()
}

@ -0,0 +1,92 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"errors"
"fmt"
"os/user"
"tailscale.com/util/syspolicy/internal"
"tailscale.com/util/syspolicy/rsop"
"tailscale.com/util/syspolicy/setting"
"tailscale.com/util/syspolicy/source"
"tailscale.com/util/testenv"
)
func init() {
// On Windows, we should automatically register the Registry-based policy
// store for the device. If we are running in a user's security context
// (e.g., we're the GUI), we should also register the Registry policy store for
// the user. In the future, we should register (and unregister) user policy
// stores whenever a user connects to (or disconnects from) the local backend.
// This ensures the backend is aware of the user's policy settings and can send
// them to the GUI/CLI/Web clients on demand or whenever they change.
//
// Other platforms, such as macOS, iOS and Android, should register their
// platform-specific policy stores via [RegisterStore]
// (or [RegisterHandler] until they implement the [source.Store] interface).
//
// External code, such as the ipnlocal package, may choose to register
// additional policy stores, such as config files and policies received from
// the control plane.
internal.Init.MustDefer(func() error {
// Do not register or use default policy stores during tests.
// Each test should set up its own necessary configurations.
if testenv.InTest() {
return nil
}
return configureSyspolicy(nil)
})
}
// configureSyspolicy configures syspolicy for use on Windows,
// either in test or regular builds depending on whether tb has a non-nil value.
func configureSyspolicy(tb internal.TB) error {
const localSystemSID = "S-1-5-18"
// Always create and register a machine policy store that reads
// policy settings from the HKEY_LOCAL_MACHINE registry hive.
machineStore, err := source.NewMachinePlatformPolicyStore()
if err != nil {
return fmt.Errorf("failed to create the machine policy store: %v", err)
}
if tb == nil {
_, err = rsop.RegisterStore("Platform", setting.DeviceScope, machineStore)
} else {
_, err = rsop.RegisterStoreForTest(tb, "Platform", setting.DeviceScope, machineStore)
}
if err != nil {
return err
}
// Check whether the current process is running as Local System or not.
u, err := user.Current()
if err != nil {
return err
}
if u.Uid == localSystemSID {
return nil
}
// If it's not a Local System's process (e.g., it's the GUI rather than the tailscaled service),
// we should create and use a policy store for the current user that reads
// policy settings from that user's registry hive (HKEY_CURRENT_USER).
userStore, err := source.NewUserPlatformPolicyStore(0)
if err != nil {
return fmt.Errorf("failed to create the current user's policy store: %v", err)
}
if tb == nil {
_, err = rsop.RegisterStore("Platform", setting.CurrentUserScope, userStore)
} else {
_, err = rsop.RegisterStoreForTest(tb, "Platform", setting.CurrentUserScope, userStore)
}
if err != nil {
return err
}
// And also set [setting.CurrentUserScope] as the [setting.DefaultScope], so [GetString],
// [GetVisibility] and similar functions would be returning a merged result
// of the machine's and user's policies.
if !setting.SetDefaultScope(setting.CurrentUserScope) {
return errors.New("current scope already set")
}
return nil
}
Loading…
Cancel
Save