Files
tailscale/util/syspolicy/source/json_policy_store.go
Brad Fitzpatrick b6e17df646 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
2026-06-30 13:10:16 -07:00

227 lines
7.0 KiB
Go

// 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
}
}