diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 32fa8901b..896225a8d 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -150,7 +150,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - github.com/tailscale/hujson from tailscale.com/ipn/conffile + github.com/tailscale/hujson from tailscale.com/ipn/conffile+ github.com/tailscale/web-client-prebuilt from tailscale.com/client/web github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli+ github.com/x448/float16 from github.com/fxamacker/cbor/v2 diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 3efd65338..cb497da84 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -187,7 +187,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - github.com/tailscale/hujson from tailscale.com/ipn/conffile + github.com/tailscale/hujson from tailscale.com/ipn/conffile+ L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+ L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink LD github.com/tailscale/peercred from tailscale.com/ipn/ipnauth @@ -477,7 +477,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/util/set from tailscale.com/control/controlclient+ tailscale.com/util/singleflight from tailscale.com/control/controlclient+ tailscale.com/util/slicesx from tailscale.com/appc+ - tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy + tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy+ tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+ tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source diff --git a/cmd/tailscaled/syspolicy.go b/cmd/tailscaled/syspolicy.go new file mode 100644 index 000000000..47e6294fd --- /dev/null +++ b/cmd/tailscaled/syspolicy.go @@ -0,0 +1,54 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_syspolicy + +package main + +import ( + "flag" + "log" + "os" + "path/filepath" + "runtime" + + "tailscale.com/util/syspolicy" +) + +// syspolicyFile is the path to a JSON syspolicy file, set via the +// --syspolicy-file flag. An empty value disables file-based syspolicy. +var syspolicyFile string + +// defaultSyspolicyFile returns the platform-specific default path for the +// --syspolicy-file flag. On Windows it sits next to the rest of Tailscale's +// machine state under %ProgramData%\Tailscale. On every other platform +// (Linux, the BSDs, illumos/Solaris, and tailscaled-without-the-GUI on +// macOS) it uses /etc/tailscale, which is where admin-provided +// configuration is conventionally placed. +// +// On Windows, when the file exists, its values take precedence over the +// HKLM registry-based platform store on a per-key basis (with the registry +// providing fallback values for keys the file does not set), because rsop +// merges later-registered same-scope sources over earlier ones. +func defaultSyspolicyFile() string { + if runtime.GOOS == "windows" { + if pd := os.Getenv("ProgramData"); pd != "" { + return filepath.Join(pd, "Tailscale", "syspolicy.json") + } + return "" + } + return "/etc/tailscale/syspolicy.json" +} + +func init() { + flag.StringVar(&syspolicyFile, "syspolicy-file", defaultSyspolicyFile(), + "path to a JSON syspolicy file applied as a device-scope policy source; empty disables") + loadSyspolicy.Set(func() { + if syspolicyFile == "" { + return + } + if err := syspolicy.LoadJSONPolicyFile("JSONFile", syspolicyFile); err != nil { + log.Printf("%v", err) + } + }) +} diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index 69f4ff5bc..f1f278062 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -183,6 +183,12 @@ var ( hookOutboundProxyListen feature.Hook[func() proxyStartFunc] ) +// loadSyspolicy, if set, loads a JSON-file-backed syspolicy source after +// command-line flags are parsed, using the path from --syspolicy-file. It +// is set when built without ts_omit_syspolicy (see syspolicy.go), and +// unset otherwise. +var loadSyspolicy feature.Hook[func()] + // proxyStartFunc is the type of the function returned by // outboundProxyListen, to start the servers on the Listeners // started by hookOutboundProxyListen. @@ -295,6 +301,12 @@ store state on filesystem.`) } } + // If syspolicy is built in, load the JSON syspolicy file (if any) now + // so its settings are visible before anything queries them. + if f, ok := loadSyspolicy.GetOk(); ok { + f() + } + if buildfeatures.HasTPM { handleTPMFlags() } diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 90836ef05..ad421937d 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -63,7 +63,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - github.com/tailscale/hujson from tailscale.com/ipn/conffile + github.com/tailscale/hujson from tailscale.com/ipn/conffile+ LD github.com/tailscale/peercred from tailscale.com/ipn/ipnauth github.com/tailscale/web-client-prebuilt from tailscale.com/client/web 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 1add83efe..13af95a81 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -63,7 +63,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - LDW github.com/tailscale/hujson from tailscale.com/ipn/conffile + LDW github.com/tailscale/hujson from tailscale.com/ipn/conffile+ LDAI github.com/tailscale/peercred from tailscale.com/ipn/ipnauth LDWI github.com/tailscale/web-client-prebuilt from tailscale.com/client/web 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ diff --git a/tstest/integration/tailscaled_deps_test_darwin.go b/tstest/integration/tailscaled_deps_test_darwin.go index 70e0d75fa..3132739f6 100644 --- a/tstest/integration/tailscaled_deps_test_darwin.go +++ b/tstest/integration/tailscaled_deps_test_darwin.go @@ -52,6 +52,7 @@ import ( _ "tailscale.com/util/clientmetric" _ "tailscale.com/util/eventbus" _ "tailscale.com/util/osshare" + _ "tailscale.com/util/syspolicy" _ "tailscale.com/util/syspolicy/pkey" _ "tailscale.com/util/syspolicy/policyclient" _ "tailscale.com/version" diff --git a/tstest/integration/tailscaled_deps_test_freebsd.go b/tstest/integration/tailscaled_deps_test_freebsd.go index 70e0d75fa..3132739f6 100644 --- a/tstest/integration/tailscaled_deps_test_freebsd.go +++ b/tstest/integration/tailscaled_deps_test_freebsd.go @@ -52,6 +52,7 @@ import ( _ "tailscale.com/util/clientmetric" _ "tailscale.com/util/eventbus" _ "tailscale.com/util/osshare" + _ "tailscale.com/util/syspolicy" _ "tailscale.com/util/syspolicy/pkey" _ "tailscale.com/util/syspolicy/policyclient" _ "tailscale.com/version" diff --git a/tstest/integration/tailscaled_deps_test_linux.go b/tstest/integration/tailscaled_deps_test_linux.go index 70e0d75fa..3132739f6 100644 --- a/tstest/integration/tailscaled_deps_test_linux.go +++ b/tstest/integration/tailscaled_deps_test_linux.go @@ -52,6 +52,7 @@ import ( _ "tailscale.com/util/clientmetric" _ "tailscale.com/util/eventbus" _ "tailscale.com/util/osshare" + _ "tailscale.com/util/syspolicy" _ "tailscale.com/util/syspolicy/pkey" _ "tailscale.com/util/syspolicy/policyclient" _ "tailscale.com/version" diff --git a/tstest/integration/tailscaled_deps_test_openbsd.go b/tstest/integration/tailscaled_deps_test_openbsd.go index 70e0d75fa..3132739f6 100644 --- a/tstest/integration/tailscaled_deps_test_openbsd.go +++ b/tstest/integration/tailscaled_deps_test_openbsd.go @@ -52,6 +52,7 @@ import ( _ "tailscale.com/util/clientmetric" _ "tailscale.com/util/eventbus" _ "tailscale.com/util/osshare" + _ "tailscale.com/util/syspolicy" _ "tailscale.com/util/syspolicy/pkey" _ "tailscale.com/util/syspolicy/policyclient" _ "tailscale.com/version" diff --git a/tstest/integration/tailscaled_deps_test_windows.go b/tstest/integration/tailscaled_deps_test_windows.go index 00768c99e..620d0ccd6 100644 --- a/tstest/integration/tailscaled_deps_test_windows.go +++ b/tstest/integration/tailscaled_deps_test_windows.go @@ -62,6 +62,7 @@ import ( _ "tailscale.com/util/eventbus" _ "tailscale.com/util/osdiag" _ "tailscale.com/util/osshare" + _ "tailscale.com/util/syspolicy" _ "tailscale.com/util/syspolicy/pkey" _ "tailscale.com/util/syspolicy/policyclient" _ "tailscale.com/util/winutil" diff --git a/util/syspolicy/load.go b/util/syspolicy/load.go new file mode 100644 index 000000000..89acaaabe --- /dev/null +++ b/util/syspolicy/load.go @@ -0,0 +1,42 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package syspolicy + +import ( + "errors" + "fmt" + "io/fs" + + "tailscale.com/util/syspolicy/rsop" + "tailscale.com/util/syspolicy/setting" + "tailscale.com/util/syspolicy/source" +) + +// LoadJSONPolicyFile loads policy settings from the JSON file at path and +// registers them as a [setting.DeviceScope] policy source under sourceName. +// +// If path does not exist, no source is registered and the function returns +// nil. Malformed JSON, unknown setting keys, or values that cannot be +// decoded as the registered type for their key all surface as errors here +// rather than at first use, and nothing is registered. +// +// LoadJSONPolicyFile is intended to be called once, early in process +// startup, after command-line flags are parsed but before any policy +// setting is read. +func LoadJSONPolicyFile(sourceName, path string) error { + store, err := source.NewJSONPolicyStoreFromFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("syspolicy: loading %s: %w", path, err) + } + if err := store.Validate(); err != nil { + return fmt.Errorf("syspolicy: invalid %s:\n%w", path, err) + } + if _, err := rsop.RegisterStore(sourceName, setting.DeviceScope, store); err != nil { + return fmt.Errorf("syspolicy: registering %s: %w", path, err) + } + return nil +} diff --git a/util/syspolicy/source/json_policy_store.go b/util/syspolicy/source/json_policy_store.go new file mode 100644 index 000000000..461e104f4 --- /dev/null +++ b/util/syspolicy/source/json_policy_store.go @@ -0,0 +1,226 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package source + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "sort" + "strconv" + + "tailscale.com/feature/buildfeatures" + "tailscale.com/util/syspolicy/pkey" + "tailscale.com/util/syspolicy/setting" +) + +// hujsonStandardize is set to hujson.Standardize by json_policy_store_hujson.go +// on platforms that have HuJSON support compiled in. When non-nil, the JSON +// policy file may use HuJSON (comments and trailing commas); when nil, the +// file must be pure standard JSON. +var hujsonStandardize func([]byte) ([]byte, error) + +var _ Store = (*JSONPolicyStore)(nil) + +// JSONPolicyStore is a [Store] backed by a JSON object that maps policy +// setting keys to values. It is a read-only snapshot; the underlying map +// is captured at construction time and never re-read. +// +// JSON values are mapped to policy setting types as follows: +// - strings map to [setting.StringValue], [setting.PreferenceOptionValue], +// [setting.VisibilityValue], and [setting.DurationValue]. For +// [setting.DurationValue], the string is parsed by [time.ParseDuration] +// elsewhere in the package (e.g. "24h", "5m"). +// - booleans map to [setting.BooleanValue]. +// - numbers map to [setting.IntegerValue]. Negative or non-integer values +// are rejected with [setting.ErrTypeMismatch]. +// - arrays of strings map to [setting.StringListValue]. +type JSONPolicyStore struct { + m map[string]any +} + +// NewJSONPolicyStore returns a new [JSONPolicyStore] backed by the given map. +// A nil or empty map results in a store that reports every key as +// [setting.ErrNotConfigured]. +func NewJSONPolicyStore(m map[string]any) *JSONPolicyStore { + return &JSONPolicyStore{m: m} +} + +// NewJSONPolicyStoreFromFile reads the file at path and returns a new +// [JSONPolicyStore] backed by its contents. The file must contain a JSON +// object at its top level. JSON numbers are decoded as [json.Number] to +// preserve precision for [setting.IntegerValue] settings. +func NewJSONPolicyStoreFromFile(path string) (*JSONPolicyStore, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return NewJSONPolicyStoreFromBytes(data) +} + +// NewJSONPolicyStoreFromBytes is like [NewJSONPolicyStoreFromFile] but reads +// from data instead of a file. When HuJSON support is linked into the build, +// data may be HuJSON (comments and trailing commas allowed); otherwise it +// must be pure standard JSON. +func NewJSONPolicyStoreFromBytes(data []byte) (*JSONPolicyStore, error) { + if buildfeatures.HasHuJSONConf && hujsonStandardize != nil { + std, err := hujsonStandardize(data) + if err != nil { + return nil, fmt.Errorf("syspolicy: parsing HuJSON/JSON: %w", err) + } + data = std + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var m map[string]any + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("syspolicy: parsing JSON: %w", err) + } + return &JSONPolicyStore{m: m}, nil +} + +// ReadString implements [Store]. +func (s *JSONPolicyStore) ReadString(key pkey.Key) (string, error) { + v, ok := s.m[string(key)] + if !ok { + return "", setting.ErrNotConfigured + } + str, ok := v.(string) + if !ok { + return "", fmt.Errorf("%w: %q is %T, want string", setting.ErrTypeMismatch, key, v) + } + return str, nil +} + +// ReadBoolean implements [Store]. +func (s *JSONPolicyStore) ReadBoolean(key pkey.Key) (bool, error) { + v, ok := s.m[string(key)] + if !ok { + return false, setting.ErrNotConfigured + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%w: %q is %T, want bool", setting.ErrTypeMismatch, key, v) + } + return b, nil +} + +// ReadUInt64 implements [Store]. +func (s *JSONPolicyStore) ReadUInt64(key pkey.Key) (uint64, error) { + v, ok := s.m[string(key)] + if !ok { + return 0, setting.ErrNotConfigured + } + switch n := v.(type) { + case json.Number: + u, err := strconv.ParseUint(n.String(), 10, 64) + if err != nil { + return 0, fmt.Errorf("%w: %q is not a uint64: %v", setting.ErrTypeMismatch, key, err) + } + return u, nil + case float64: + if n < 0 || n > math.MaxUint64 || n != math.Trunc(n) { + return 0, fmt.Errorf("%w: %q (%v) is not a uint64", setting.ErrTypeMismatch, key, n) + } + return uint64(n), nil + default: + return 0, fmt.Errorf("%w: %q is %T, want number", setting.ErrTypeMismatch, key, v) + } +} + +// ReadStringArray implements [Store]. +func (s *JSONPolicyStore) ReadStringArray(key pkey.Key) ([]string, error) { + v, ok := s.m[string(key)] + if !ok { + return nil, setting.ErrNotConfigured + } + arr, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("%w: %q is %T, want array", setting.ErrTypeMismatch, key, v) + } + res := make([]string, len(arr)) + for i, e := range arr { + str, ok := e.(string) + if !ok { + return nil, fmt.Errorf("%w: %q[%d] is %T, want string", setting.ErrTypeMismatch, key, i, e) + } + res[i] = str + } + return res, nil +} + +// Validate checks that every key in the parsed JSON corresponds to a +// registered policy setting (per [setting.Definitions]) and that its value +// can be successfully decoded as the registered setting's type. It joins +// all problems into a single error so callers see every issue at once +// instead of one per startup-then-runtime cycle. +// +// Validate triggers registration of any deferred setting definitions, so +// it should only be called after all init-time registrations have run. +func (s *JSONPolicyStore) Validate() error { + defs, err := setting.Definitions() + if err != nil { + return err + } + byKey := make(map[pkey.Key]*setting.Definition, len(defs)) + for _, d := range defs { + byKey[d.Key()] = d + } + + keys := make([]string, 0, len(s.m)) + for k := range s.m { + keys = append(keys, k) + } + sort.Strings(keys) + + var errs []error + for _, k := range keys { + def, ok := byKey[pkey.Key(k)] + if !ok { + errs = append(errs, fmt.Errorf("unknown policy setting %q", k)) + continue + } + if err := s.validateValue(def); err != nil { + errs = append(errs, fmt.Errorf("%q: %w", k, err)) + } + } + return errors.Join(errs...) +} + +// validateValue is like [readPolicySettingValue] but is stricter for the +// enum-like [setting.PreferenceOptionValue] and [setting.VisibilityValue] +// types: their runtime UnmarshalText silently coerces unknown strings to a +// default, which is fine at read time but defeats the point of load-time +// validation, so this checks the raw string against the known values. +func (s *JSONPolicyStore) validateValue(def *setting.Definition) error { + key := def.Key() + switch def.Type() { + case setting.PreferenceOptionValue: + str, err := s.ReadString(key) + if err != nil { + return err + } + switch str { + case "always", "never", "user-decides": + return nil + } + return fmt.Errorf(`%w: %q is not a valid PreferenceOption ("always", "never", or "user-decides")`, setting.ErrTypeMismatch, str) + case setting.VisibilityValue: + str, err := s.ReadString(key) + if err != nil { + return err + } + switch str { + case "show", "hide": + return nil + } + return fmt.Errorf(`%w: %q is not a valid Visibility ("show" or "hide")`, setting.ErrTypeMismatch, str) + default: + _, err := readPolicySettingValue(s, def) + return err + } +} diff --git a/util/syspolicy/source/json_policy_store_hujson.go b/util/syspolicy/source/json_policy_store_hujson.go new file mode 100644 index 000000000..8f1e981e8 --- /dev/null +++ b/util/syspolicy/source/json_policy_store_hujson.go @@ -0,0 +1,19 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ios && !android && !ts_omit_hujsonconf + +package source + +import "github.com/tailscale/hujson" + +// Only link the hujson package on platforms that use it, to reduce binary +// size & memory a bit. iOS and Android don't load syspolicy files from +// disk so they don't need HuJSON either. +// +// While the linker's deadcode mostly handles this today, this keeps us +// honest for the future. + +func init() { + hujsonStandardize = hujson.Standardize +} diff --git a/util/syspolicy/source/json_policy_store_test.go b/util/syspolicy/source/json_policy_store_test.go new file mode 100644 index 000000000..0f4e34798 --- /dev/null +++ b/util/syspolicy/source/json_policy_store_test.go @@ -0,0 +1,366 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package source + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "tailscale.com/util/syspolicy/pkey" + "tailscale.com/util/syspolicy/ptype" + "tailscale.com/util/syspolicy/setting" +) + +func TestJSONPolicyStoreReadString(t *testing.T) { + s := NewJSONPolicyStore(map[string]any{ + "ControlURL": "https://controlplane.example.com", + "AuthKey": "tskey-auth-xxx", + "Bool": true, + }) + + got, err := s.ReadString("ControlURL") + if err != nil { + t.Fatalf("ReadString(ControlURL) error: %v", err) + } + if want := "https://controlplane.example.com"; got != want { + t.Errorf("ReadString(ControlURL) = %q, want %q", got, want) + } + + if _, err := s.ReadString("Missing"); !errors.Is(err, setting.ErrNotConfigured) { + t.Errorf("ReadString(Missing) err = %v, want ErrNotConfigured", err) + } + + if _, err := s.ReadString("Bool"); !errors.Is(err, setting.ErrTypeMismatch) { + t.Errorf("ReadString(Bool) err = %v, want ErrTypeMismatch", err) + } +} + +func TestJSONPolicyStoreReadBoolean(t *testing.T) { + s := NewJSONPolicyStore(map[string]any{ + "AlwaysOn": true, + "Str": "yes", + }) + + got, err := s.ReadBoolean("AlwaysOn") + if err != nil { + t.Fatalf("ReadBoolean(AlwaysOn) error: %v", err) + } + if !got { + t.Errorf("ReadBoolean(AlwaysOn) = false, want true") + } + + if _, err := s.ReadBoolean("Missing"); !errors.Is(err, setting.ErrNotConfigured) { + t.Errorf("ReadBoolean(Missing) err = %v, want ErrNotConfigured", err) + } + + if _, err := s.ReadBoolean("Str"); !errors.Is(err, setting.ErrTypeMismatch) { + t.Errorf("ReadBoolean(Str) err = %v, want ErrTypeMismatch", err) + } +} + +func TestJSONPolicyStoreReadStringArray(t *testing.T) { + s := NewJSONPolicyStore(map[string]any{ + "AllowedSuggestedExitNodes": []any{"node-a", "node-b"}, + "Empty": []any{}, + "NotArray": "abc", + "WrongElem": []any{"a", 1}, + }) + + got, err := s.ReadStringArray("AllowedSuggestedExitNodes") + if err != nil { + t.Fatalf("ReadStringArray(AllowedSuggestedExitNodes) error: %v", err) + } + if want := []string{"node-a", "node-b"}; !reflect.DeepEqual(got, want) { + t.Errorf("ReadStringArray = %v, want %v", got, want) + } + + got, err = s.ReadStringArray("Empty") + if err != nil { + t.Fatalf("ReadStringArray(Empty) error: %v", err) + } + if len(got) != 0 { + t.Errorf("ReadStringArray(Empty) = %v, want empty", got) + } + + if _, err := s.ReadStringArray("Missing"); !errors.Is(err, setting.ErrNotConfigured) { + t.Errorf("ReadStringArray(Missing) err = %v, want ErrNotConfigured", err) + } + if _, err := s.ReadStringArray("NotArray"); !errors.Is(err, setting.ErrTypeMismatch) { + t.Errorf("ReadStringArray(NotArray) err = %v, want ErrTypeMismatch", err) + } + if _, err := s.ReadStringArray("WrongElem"); !errors.Is(err, setting.ErrTypeMismatch) { + t.Errorf("ReadStringArray(WrongElem) err = %v, want ErrTypeMismatch", err) + } +} + +func TestJSONPolicyStoreReadUInt64FromMap(t *testing.T) { + s := NewJSONPolicyStore(map[string]any{ + "Float": float64(42), + "Negative": float64(-1), + "Frac": float64(1.5), + "Str": "nope", + }) + + got, err := s.ReadUInt64("Float") + if err != nil { + t.Fatalf("ReadUInt64(Float) error: %v", err) + } + if got != 42 { + t.Errorf("ReadUInt64(Float) = %d, want 42", got) + } + + if _, err := s.ReadUInt64("Missing"); !errors.Is(err, setting.ErrNotConfigured) { + t.Errorf("ReadUInt64(Missing) err = %v, want ErrNotConfigured", err) + } + for _, k := range []pkey.Key{"Negative", "Frac", "Str"} { + if _, err := s.ReadUInt64(k); !errors.Is(err, setting.ErrTypeMismatch) { + t.Errorf("ReadUInt64(%s) err = %v, want ErrTypeMismatch", k, err) + } + } +} + +func TestJSONPolicyStoreReadUInt64FromJSON(t *testing.T) { + // json.Number path: bigger than float64 mantissa precision. + s, err := NewJSONPolicyStoreFromBytes([]byte(`{"Big": 18446744073709551610}`)) + if err != nil { + t.Fatalf("NewJSONPolicyStoreFromBytes: %v", err) + } + got, err := s.ReadUInt64("Big") + if err != nil { + t.Fatalf("ReadUInt64(Big) error: %v", err) + } + if want := uint64(18446744073709551610); got != want { + t.Errorf("ReadUInt64(Big) = %d, want %d", got, want) + } +} + +// TestJSONPolicyStoreDuration verifies that duration-typed settings, which +// the reader fetches via ReadString and parses with time.ParseDuration, work +// end-to-end with values like "24h". +func TestJSONPolicyStoreDuration(t *testing.T) { + s := NewJSONPolicyStore(map[string]any{ + "ReconnectAfter": "24h", + "Bad": "notaduration", + }) + def := setting.NewDefinition("ReconnectAfter", setting.DeviceSetting, setting.DurationValue) + v, err := readPolicySettingValue(s, def) + if err != nil { + t.Fatalf("readPolicySettingValue(ReconnectAfter) error: %v", err) + } + if got, want := v.(time.Duration), 24*time.Hour; got != want { + t.Errorf("ReconnectAfter = %v, want %v", got, want) + } + + badDef := setting.NewDefinition("Bad", setting.DeviceSetting, setting.DurationValue) + if _, err := readPolicySettingValue(s, badDef); err == nil { + t.Errorf("readPolicySettingValue(Bad) err = nil, want parse error") + } +} + +func TestJSONPolicyStorePreferenceOptionAndVisibility(t *testing.T) { + s := NewJSONPolicyStore(map[string]any{ + "CheckUpdates": "always", + "AdminConsoleVisibility": "hide", + }) + prefDef := setting.NewDefinition("CheckUpdates", setting.DeviceSetting, setting.PreferenceOptionValue) + v, err := readPolicySettingValue(s, prefDef) + if err != nil { + t.Fatalf("readPolicySettingValue(CheckUpdates) error: %v", err) + } + if got := v.(ptype.PreferenceOption); got != ptype.AlwaysByPolicy { + t.Errorf("CheckUpdates = %v, want AlwaysByPolicy", got) + } + + visDef := setting.NewDefinition("AdminConsoleVisibility", setting.UserSetting, setting.VisibilityValue) + v, err = readPolicySettingValue(s, visDef) + if err != nil { + t.Fatalf("readPolicySettingValue(AdminConsoleVisibility) error: %v", err) + } + if got := v.(ptype.Visibility); got != ptype.HiddenByPolicy { + t.Errorf("AdminConsoleVisibility = %v, want HiddenByPolicy", got) + } +} + +func TestJSONPolicyStoreValidate(t *testing.T) { + // Register a small set of definitions covering each type that the + // validator needs to exercise. + if err := setting.SetDefinitionsForTest(t, + setting.NewDefinition("ControlURL", setting.DeviceSetting, setting.StringValue), + setting.NewDefinition("AlwaysOn", setting.DeviceSetting, setting.BooleanValue), + setting.NewDefinition("AllowedSuggestedExitNodes", setting.DeviceSetting, setting.StringListValue), + setting.NewDefinition("ReconnectAfter", setting.DeviceSetting, setting.DurationValue), + setting.NewDefinition("CheckUpdates", setting.DeviceSetting, setting.PreferenceOptionValue), + setting.NewDefinition("AdminConsoleVisibility", setting.UserSetting, setting.VisibilityValue), + ); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + json map[string]any + wantOK bool + wantErrSubs []string // substrings the error must contain (only when wantOK is false) + }{ + { + name: "empty", + json: map[string]any{}, + wantOK: true, + }, + { + name: "all_valid", + json: map[string]any{ + "ControlURL": "https://controlplane.example.com", + "AlwaysOn": true, + "AllowedSuggestedExitNodes": []any{"node-a", "node-b"}, + "ReconnectAfter": "24h", + "CheckUpdates": "always", + "AdminConsoleVisibility": "hide", + }, + wantOK: true, + }, + { + name: "unknown_key", + json: map[string]any{ + "ControlURL": "https://controlplane.example.com", + "NoSuchThing": "whatever", + }, + wantErrSubs: []string{`unknown policy setting "NoSuchThing"`}, + }, + { + name: "wrong_type_string", + json: map[string]any{ + "ControlURL": true, + }, + wantErrSubs: []string{`"ControlURL"`, "type mismatch"}, + }, + { + name: "wrong_type_bool", + json: map[string]any{ + "AlwaysOn": "yes", + }, + wantErrSubs: []string{`"AlwaysOn"`, "type mismatch"}, + }, + { + name: "wrong_type_array", + json: map[string]any{ + "AllowedSuggestedExitNodes": "node-a", + }, + wantErrSubs: []string{`"AllowedSuggestedExitNodes"`, "type mismatch"}, + }, + { + name: "bad_duration", + json: map[string]any{ + "ReconnectAfter": "notaduration", + }, + wantErrSubs: []string{`"ReconnectAfter"`, "notaduration"}, + }, + { + name: "bad_preference_option", + json: map[string]any{ + "CheckUpdates": "sometimes", + }, + wantErrSubs: []string{`"CheckUpdates"`, "sometimes", "always"}, + }, + { + name: "bad_visibility", + json: map[string]any{ + "AdminConsoleVisibility": "maybe", + }, + wantErrSubs: []string{`"AdminConsoleVisibility"`, "maybe", "show"}, + }, + { + name: "multiple_errors_reported", + json: map[string]any{ + "AlwaysOn": "yes", + "NoSuchThing": 1, + }, + wantErrSubs: []string{`"AlwaysOn"`, `unknown policy setting "NoSuchThing"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewJSONPolicyStore(tt.json).Validate() + if tt.wantOK { + if err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + return + } + if err == nil { + t.Fatalf("Validate() = nil, want error containing %v", tt.wantErrSubs) + } + for _, sub := range tt.wantErrSubs { + if !strings.Contains(err.Error(), sub) { + t.Errorf("Validate() error = %q, want it to contain %q", err, sub) + } + } + }) + } +} + +func TestNewJSONPolicyStoreFromBytesHuJSON(t *testing.T) { + if hujsonStandardize == nil { + t.Skip("HuJSON support not linked into this build") + } + const hujsonInput = `{ + // The control plane URL. + "ControlURL": "https://controlplane.example.com", + "AlwaysOn": true, // trailing comma is OK in HuJSON +}` + s, err := NewJSONPolicyStoreFromBytes([]byte(hujsonInput)) + if err != nil { + t.Fatalf("NewJSONPolicyStoreFromBytes(HuJSON) error: %v", err) + } + got, err := s.ReadString("ControlURL") + if err != nil { + t.Fatalf("ReadString: %v", err) + } + if want := "https://controlplane.example.com"; got != want { + t.Errorf("ReadString = %q, want %q", got, want) + } + gotBool, err := s.ReadBoolean("AlwaysOn") + if err != nil { + t.Fatalf("ReadBoolean: %v", err) + } + if !gotBool { + t.Errorf("ReadBoolean = false, want true") + } +} + +func TestNewJSONPolicyStoreFromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "syspolicy.json") + if err := os.WriteFile(path, []byte(`{"ControlURL": "https://example.com"}`), 0600); err != nil { + t.Fatal(err) + } + s, err := NewJSONPolicyStoreFromFile(path) + if err != nil { + t.Fatalf("NewJSONPolicyStoreFromFile: %v", err) + } + got, err := s.ReadString("ControlURL") + if err != nil { + t.Fatalf("ReadString: %v", err) + } + if want := "https://example.com"; got != want { + t.Errorf("ReadString = %q, want %q", got, want) + } + + if _, err := NewJSONPolicyStoreFromFile(filepath.Join(dir, "missing.json")); err == nil { + t.Errorf("NewJSONPolicyStoreFromFile(missing) err = nil, want error") + } + + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte(`not json`), 0600); err != nil { + t.Fatal(err) + } + if _, err := NewJSONPolicyStoreFromFile(bad); err == nil { + t.Errorf("NewJSONPolicyStoreFromFile(bad) err = nil, want parse error") + } +}