util: add syspolicy package (#9550)

Add a more generalized package for getting policies.
Updates tailcale/corp#10967

Signed-off-by: Claire Wang <claire@tailscale.com>
Co-authored-by: Adrian Dewhurst <adrian@tailscale.com>
This commit is contained in:
Claire Wang 2023-09-29 13:40:35 -04:00 committed by GitHub
parent d71184d674
commit 32c0156311
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 685 additions and 0 deletions

52
util/syspolicy/handler.go Normal file
View File

@ -0,0 +1,52 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"errors"
"sync/atomic"
)
var (
handlerUsed atomic.Bool
handler Handler = defaultHandler{}
)
// Handler reads system policies from OS-specific storage.
type Handler interface {
// ReadString reads the policy settings value string given the key.
ReadString(key string) (string, error)
// ReadUInt64 reads the policy settings uint64 value given the key.
ReadUInt64(key string) (uint64, error)
}
// ErrNoSuchKey is returned when the specified key does not have a value set.
var ErrNoSuchKey = errors.New("no such key")
// defaultHandler is the catch all syspolicy type for anything that isn't windows or apple.
type defaultHandler struct{}
func (defaultHandler) ReadString(_ string) (string, error) {
return "", ErrNoSuchKey
}
func (defaultHandler) ReadUInt64(_ string) (uint64, error) {
return 0, ErrNoSuchKey
}
// markHandlerInUse is called before handler methods are called.
func markHandlerInUse() {
handlerUsed.Store(true)
}
// RegisterHandler initializes the policy handler and ensures registration will happen once.
func RegisterHandler(h Handler) {
// Technically this assignment is not concurrency safe, but in the
// event that there was any risk of a data race, we will panic due to
// the CompareAndSwap failing.
handler = h
if !handlerUsed.CompareAndSwap(false, true) {
panic("handler was already used before registration")
}
}

View File

@ -0,0 +1,19 @@
// 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)
}
}

View File

@ -0,0 +1,32 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"errors"
"tailscale.com/util/winutil"
)
type windowsHandler struct{}
func init() {
RegisterHandler(windowsHandler{})
}
func (windowsHandler) ReadString(key string) (string, error) {
s, err := winutil.GetPolicyString(key)
if errors.Is(err, winutil.ErrNoValue) {
err = ErrNoSuchKey
}
return s, err
}
func (windowsHandler) ReadUInt64(key string) (uint64, error) {
value, err := winutil.GetPolicyInteger(key)
if errors.Is(err, winutil.ErrNoValue) {
err = ErrNoSuchKey
}
return value, err
}

View File

@ -0,0 +1,35 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
type Key string
const (
// Keys with a string value
ControlURL Key = "LoginURL" // default ""; if blank, ipn uses ipn.DefaultControlURL.
LogTarget Key = "LogTarget" // default ""; if blank logging uses logtail.DefaultHost.
// Keys with a string value that specifies an option: "always", "never", "user-decides".
// The default is "user-decides" unless otherwise stated.
EnableIncomingConnections Key = "AllowIncomingConnections"
EnableServerMode Key = "UnattendedMode"
// Keys with a string value that controls visibility: "show", "hide".
// The default is "show" unless otherwise stated.
AdminConsoleVisibility Key = "AdminConsole"
NetworkDevicesVisibility Key = "NetworkDevices"
TestMenuVisibility Key = "TestMenu"
UpdateMenuVisibility Key = "UpdateMenu"
RunExitNodeVisibility Key = "RunExitNode"
PreferencesMenuVisibility Key = "PreferencesMenu"
// Keys with a string value formatted for use with time.ParseDuration().
KeyExpirationNoticeTime Key = "KeyExpirationNotice" // default 24 hours
// Boolean Keys that are only applicable on Windows. Booleans are stored in the registry as
// DWORD or QWORD (either is acceptable). 0 means false, and anything else means true.
// The default is 0 unless otherwise stated.
LogSCMInteractions Key = "LogSCMInteractions"
FlushDNSOnSessionUnlock Key = "FlushDNSOnSessionUnlock"
)

172
util/syspolicy/syspolicy.go Normal file
View File

@ -0,0 +1,172 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package syspolicy provides functions to retrieve system settings of a device.
package syspolicy
import (
"errors"
"time"
)
func GetString(key Key, defaultValue string) (string, error) {
markHandlerInUse()
v, err := handler.ReadString(string(key))
if errors.Is(err, ErrNoSuchKey) {
return defaultValue, nil
}
return v, err
}
func GetUint64(key Key, defaultValue uint64) (uint64, error) {
markHandlerInUse()
v, err := handler.ReadUInt64(string(key))
if errors.Is(err, ErrNoSuchKey) {
return defaultValue, nil
}
return v, err
}
// PreferenceOption is a policy that governs whether a boolean variable
// is forcibly assigned an administrator-defined value, or allowed to receive
// a user-defined value.
type PreferenceOption int
const (
showChoiceByPolicy PreferenceOption = iota
neverByPolicy
alwaysByPolicy
)
// Show returns if the UI option that controls the choice administered by this
// policy should be shown. Currently this is true if and only if the policy is
// showChoiceByPolicy.
func (p PreferenceOption) Show() bool {
return p == showChoiceByPolicy
}
// ShouldEnable checks if the choice administered by this policy should be
// enabled. If the administrator has chosen a setting, the administrator's
// setting is returned, otherwise userChoice is returned.
func (p PreferenceOption) ShouldEnable(userChoice bool) bool {
switch p {
case neverByPolicy:
return false
case alwaysByPolicy:
return true
default:
return userChoice
}
}
// GetPreferenceOption loads a policy from the registry that can be
// managed by an enterprise policy management system and allows administrative
// overrides of users' choices in a way that we do not want tailcontrol to have
// the authority to set. It describes user-decides/always/never options, where
// "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.
func GetPreferenceOption(name Key) (PreferenceOption, error) {
opt, err := GetString(name, "user-decides")
if err != nil {
return showChoiceByPolicy, err
}
switch opt {
case "always":
return alwaysByPolicy, nil
case "never":
return neverByPolicy, nil
default:
return showChoiceByPolicy, nil
}
}
// Visibility is a policy that controls whether or not a particular
// component of a user interface is to be shown.
type Visibility byte
const (
visibleByPolicy Visibility = 'v'
hiddenByPolicy Visibility = 'h'
)
// Show reports whether the UI option administered by this policy should be shown.
// Currently this is true if and only if the policy is visibleByPolicy.
func (p Visibility) Show() bool {
return p == visibleByPolicy
}
// GetVisibility loads a policy from the registry that can be managed
// by an enterprise policy management system and describes show/hide decisions
// for UI elements. The registry value should be a string set to "show" (return
// true) or "hide" (return true). If not present or set to a different value,
// "show" (return false) is the default.
func GetVisibility(name Key) (Visibility, error) {
opt, err := GetString(name, "show")
if err != nil {
return visibleByPolicy, err
}
switch opt {
case "hide":
return hiddenByPolicy, nil
default:
return visibleByPolicy, nil
}
}
// GetDuration loads a policy from the registry that can be managed
// by an enterprise policy management system and describes a duration for some
// action. The registry value should be a string that time.ParseDuration
// understands. If the registry value is "" or can not be processed,
// defaultValue is returned instead.
func GetDuration(name Key, defaultValue time.Duration) (time.Duration, error) {
opt, err := GetString(name, "")
if opt == "" || err != nil {
return defaultValue, err
}
v, err := time.ParseDuration(opt)
if err != nil || v < 0 {
return defaultValue, nil
}
return v, nil
}
// SelectControlURL returns the ControlURL to use based on a value in
// the registry (LoginURL) and the one on disk (in the GUI's
// prefs.conf). If both are empty, it returns a default value. (It
// always return a non-empty value)
//
// See https://github.com/tailscale/tailscale/issues/2798 for some background.
func SelectControlURL(reg, disk string) string {
const def = "https://controlplane.tailscale.com"
// Prior to Dec 2020's commit 739b02e6, the installer
// wrote a LoginURL value of https://login.tailscale.com to the registry.
const oldRegDef = "https://login.tailscale.com"
// If they have an explicit value in the registry, use it,
// unless it's an old default value from an old installer.
// Then we have to see which is better.
if reg != "" {
if reg != oldRegDef {
// Something explicit in the registry that we didn't
// set ourselves by the installer.
return reg
}
if disk == "" {
// Something in the registry is better than nothing on disk.
return reg
}
if disk != def && disk != oldRegDef {
// The value in the registry is the old
// default (login.tailscale.com) but the value
// on disk is neither our old nor new default
// value, so it must be some custom thing that
// the user cares about. Prefer the disk value.
return disk
}
}
if disk != "" {
return disk
}
return def
}

View File

@ -0,0 +1,375 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package syspolicy
import (
"errors"
"testing"
"time"
)
// 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
err error
}
var someOtherError = errors.New("error other than not found")
func setHandlerForTest(tb testing.TB, h Handler) {
tb.Helper()
oldHandler := handler
handler = h
tb.Cleanup(func() { handler = oldHandler })
}
func (th *testHandler) ReadString(key string) (string, error) {
if key != string(th.key) {
th.t.Errorf("ReadString(%q) want %q", key, th.key)
}
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)
}
return th.u64, th.err
}
func TestGetString(t *testing.T) {
tests := []struct {
name string
key Key
handlerValue string
handlerError error
defaultValue string
wantValue string
wantError error
}{
{
name: "read existing value",
key: AdminConsoleVisibility,
handlerValue: "hide",
wantValue: "hide",
},
{
name: "read non-existing value",
key: EnableServerMode,
handlerError: ErrNoSuchKey,
wantError: nil,
},
{
name: "read non-existing value, non-blank default",
key: EnableServerMode,
handlerError: ErrNoSuchKey,
defaultValue: "test",
wantValue: "test",
wantError: nil,
},
{
name: "reading value returns other error",
key: NetworkDevicesVisibility,
handlerError: someOtherError,
wantError: someOtherError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{
t: t,
key: tt.key,
s: tt.handlerValue,
err: tt.handlerError,
})
value, err := GetString(tt.key, tt.defaultValue)
if err != tt.wantError {
t.Errorf("err=%q, want %q", err, tt.wantError)
}
if value != tt.wantValue {
t.Errorf("value=%v, want %v", value, tt.wantValue)
}
})
}
}
func TestGetUint64(t *testing.T) {
tests := []struct {
name string
key Key
handlerValue uint64
handlerError error
defaultValue uint64
wantValue uint64
wantError error
}{
{
name: "read existing value",
key: KeyExpirationNoticeTime,
handlerValue: 1,
wantValue: 1,
},
{
name: "read non-existing value",
key: LogSCMInteractions,
handlerValue: 0,
handlerError: ErrNoSuchKey,
wantValue: 0,
},
{
name: "read non-existing value, non-zero default",
key: LogSCMInteractions,
defaultValue: 2,
handlerError: ErrNoSuchKey,
wantValue: 2,
},
{
name: "reading value returns other error",
key: FlushDNSOnSessionUnlock,
handlerError: someOtherError,
wantError: someOtherError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{
t: t,
key: tt.key,
u64: tt.handlerValue,
err: tt.handlerError,
})
value, err := GetUint64(tt.key, tt.defaultValue)
if err != tt.wantError {
t.Errorf("err=%q, want %q", err, tt.wantError)
}
if value != tt.wantValue {
t.Errorf("value=%v, want %v", value, tt.wantValue)
}
})
}
}
func TestGetPreferenceOption(t *testing.T) {
tests := []struct {
name string
key Key
handlerValue string
handlerError error
wantValue PreferenceOption
wantError error
}{
{
name: "always by policy",
key: EnableIncomingConnections,
handlerValue: "always",
wantValue: alwaysByPolicy,
},
{
name: "never by policy",
key: EnableIncomingConnections,
handlerValue: "never",
wantValue: neverByPolicy,
},
{
name: "use default",
key: EnableIncomingConnections,
handlerValue: "",
wantValue: showChoiceByPolicy,
},
{
name: "read non-existing value",
key: EnableIncomingConnections,
handlerError: ErrNoSuchKey,
wantValue: showChoiceByPolicy,
},
{
name: "other error is returned",
key: EnableIncomingConnections,
handlerError: someOtherError,
wantValue: showChoiceByPolicy,
wantError: someOtherError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{
t: t,
key: tt.key,
s: tt.handlerValue,
err: tt.handlerError,
})
option, err := GetPreferenceOption(tt.key)
if err != tt.wantError {
t.Errorf("err=%q, want %q", err, tt.wantError)
}
if option != tt.wantValue {
t.Errorf("option=%v, want %v", option, tt.wantValue)
}
})
}
}
func TestGetVisibility(t *testing.T) {
tests := []struct {
name string
key Key
handlerValue string
handlerError error
wantValue Visibility
wantError error
}{
{
name: "hidden by policy",
key: AdminConsoleVisibility,
handlerValue: "hide",
wantValue: hiddenByPolicy,
},
{
name: "visibility default",
key: AdminConsoleVisibility,
handlerValue: "show",
wantValue: visibleByPolicy,
},
{
name: "read non-existing value",
key: AdminConsoleVisibility,
handlerValue: "show",
handlerError: ErrNoSuchKey,
wantValue: visibleByPolicy,
},
{
name: "other error is returned",
key: AdminConsoleVisibility,
handlerValue: "show",
handlerError: someOtherError,
wantValue: visibleByPolicy,
wantError: someOtherError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{
t: t,
key: tt.key,
s: tt.handlerValue,
err: tt.handlerError,
})
visibility, err := GetVisibility(tt.key)
if err != tt.wantError {
t.Errorf("err=%q, want %q", err, tt.wantError)
}
if visibility != tt.wantValue {
t.Errorf("visibility=%v, want %v", visibility, tt.wantValue)
}
})
}
}
func TestGetDuration(t *testing.T) {
tests := []struct {
name string
key Key
handlerValue string
handlerError error
defaultValue time.Duration
wantValue time.Duration
wantError error
}{
{
name: "read existing value",
key: KeyExpirationNoticeTime,
handlerValue: "2h",
wantValue: 2 * time.Hour,
defaultValue: 24 * time.Hour,
},
{
name: "invalid duration value",
key: KeyExpirationNoticeTime,
handlerValue: "-20",
wantValue: 24 * time.Hour,
defaultValue: 24 * time.Hour,
},
{
name: "read non-existing value",
key: KeyExpirationNoticeTime,
handlerError: ErrNoSuchKey,
wantValue: 24 * time.Hour,
defaultValue: 24 * time.Hour,
},
{
name: "read non-existing value different default",
key: KeyExpirationNoticeTime,
handlerError: ErrNoSuchKey,
wantValue: 0 * time.Second,
defaultValue: 0 * time.Second,
},
{
name: "other error is returned",
key: KeyExpirationNoticeTime,
handlerError: someOtherError,
wantValue: 24 * time.Hour,
wantError: someOtherError,
defaultValue: 24 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setHandlerForTest(t, &testHandler{
t: t,
key: tt.key,
s: tt.handlerValue,
err: tt.handlerError,
})
duration, err := GetDuration(tt.key, tt.defaultValue)
if err != tt.wantError {
t.Errorf("err=%q, want %q", err, tt.wantError)
}
if duration != tt.wantValue {
t.Errorf("duration=%v, want %v", duration, tt.wantValue)
}
})
}
}
func TestSelectControlURL(t *testing.T) {
tests := []struct {
reg, disk, want string
}{
// Modern default case.
{"", "", "https://controlplane.tailscale.com"},
// For a user who installed prior to Dec 2020, with
// stuff in their registry.
{"https://login.tailscale.com", "", "https://login.tailscale.com"},
// Ignore pre-Dec'20 LoginURL from installer if prefs
// prefs overridden manually to an on-prem control
// server.
{"https://login.tailscale.com", "http://on-prem", "http://on-prem"},
// Something unknown explicitly set in the registry always wins.
{"http://explicit-reg", "", "http://explicit-reg"},
{"http://explicit-reg", "http://on-prem", "http://explicit-reg"},
{"http://explicit-reg", "https://login.tailscale.com", "http://explicit-reg"},
{"http://explicit-reg", "https://controlplane.tailscale.com", "http://explicit-reg"},
// If nothing in the registry, disk wins.
{"", "http://on-prem", "http://on-prem"},
}
for _, tt := range tests {
if got := SelectControlURL(tt.reg, tt.disk); got != tt.want {
t.Errorf("(reg %q, disk %q) = %q; want %q", tt.reg, tt.disk, got, tt.want)
}
}
}