mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-15 01:53:08 -04:00
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
227 lines
7.0 KiB
Go
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
|
|
}
|
|
}
|