diff --git a/util/syspolicy/handler.go b/util/syspolicy/handler.go new file mode 100644 index 000000000..3457f2426 --- /dev/null +++ b/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") + } +} diff --git a/util/syspolicy/handler_test.go b/util/syspolicy/handler_test.go new file mode 100644 index 000000000..39b18936f --- /dev/null +++ b/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) + } +} diff --git a/util/syspolicy/handler_windows.go b/util/syspolicy/handler_windows.go new file mode 100644 index 000000000..612e60b77 --- /dev/null +++ b/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 +} diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go new file mode 100644 index 000000000..08162ab9e --- /dev/null +++ b/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" +) diff --git a/util/syspolicy/syspolicy.go b/util/syspolicy/syspolicy.go new file mode 100644 index 000000000..656fb50b0 --- /dev/null +++ b/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 +} diff --git a/util/syspolicy/syspolicy_test.go b/util/syspolicy/syspolicy_test.go new file mode 100644 index 000000000..2ff4249af --- /dev/null +++ b/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) + } + } +}