WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
15 changed files with 729 additions and 5 deletions
Showing only changes of commit b6e17df646 - Show all commits
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+54
View File
@@ -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)
}
})
}
+12
View File
@@ -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()
}
+1 -1
View File
@@ -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+
+1 -1
View File
@@ -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+
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
+42
View File
@@ -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
}
+226
View File
@@ -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
}
}
@@ -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
}
@@ -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")
}
}