Browse Source

util: add syspolicy package (#9550)

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

Signed-off-by: Claire Wang <[email protected]>
Co-authored-by: Adrian Dewhurst <[email protected]>
Claire Wang 2 years ago
parent
commit
32c0156311

+ 52 - 0
util/syspolicy/handler.go

@@ -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")
+	}
+}

+ 19 - 0
util/syspolicy/handler_test.go

@@ -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)
+	}
+}

+ 32 - 0
util/syspolicy/handler_windows.go

@@ -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
+}

+ 35 - 0
util/syspolicy/policy_keys.go

@@ -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 - 0
util/syspolicy/syspolicy.go

@@ -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
+}

+ 375 - 0
util/syspolicy/syspolicy_test.go

@@ -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)
+		}
+	}
+}