cmd/tailscaled, util/syspolicy: add JSON syspolicy file support
Tailscaled had no way to seed device-scope syspolicy settings short of environment variables or a custom store wired up out of tree. Add a --syspolicy-file flag whose default points at a well-known JSON file that, when present, is parsed as a map[string]any and registered as a device-scope policy source. The default path is /etc/tailscale/syspolicy.json on every non-Windows platform (Linux, the BSDs, illumos/Solaris, and tailscaled-without-the-GUI on macOS) and %ProgramData%\Tailscale\syspolicy.json on Windows. The flag lets users running tailscaled by hand (development, custom installs) point it at an alternate file, and "" disables the load entirely. JSON values map to setting types as expected: strings to StringValue/PreferenceOptionValue/VisibilityValue/DurationValue (e.g. "24h" parsed by time.ParseDuration), booleans to BooleanValue, numbers to IntegerValue, and string arrays to StringListValue. The file is validated against the registered setting definitions at load time so unknown keys and value/type mismatches fail startup loudly rather than producing surprising defaults at first read. When HuJSON support is linked into the build (default; opt out with ts_omit_hujsonconf), the file may use HuJSON (comments, trailing commas). With ts_omit_hujsonconf it must be pure standard JSON. This mirrors the pattern used by ipn/conffile. On Windows the JSON file and the existing HKLM registry store both register at DeviceScope. rsop merges later-registered same-scope sources over earlier ones, so per-key values in the file override the registry while keys absent from the file fall back to the registry. The loader is registered via a feature.Hook from a file gated by !ts_omit_syspolicy, and called from main after flag parsing. tsnet still does not depend on the root syspolicy package, so embedders don't pick this up implicitly. Fixes #20305 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: Ie6326461c14efb226979ac162998a9c6373ce493
This commit is contained in:
committed by
Brad Fitzpatrick
parent
07cefc083d
commit
b6e17df646
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user