tailscale/util/syspolicy/source/env_policy_store.go
Nick Khyl 2336c340c4 util/syspolicy: implement a syspolicy store that reads settings from environment variables
In this PR, we implement (but do not use yet, pending #13727 review) a syspolicy/source.Store
that reads policy settings from environment variables. It converts a CamelCase setting.Key,
such as AuthKey or ExitNodeID, to a SCREAMING_SNAKE_CASE, TS_-prefixed environment
variable name, such as TS_AUTH_KEY and TS_EXIT_NODE_ID. It then looks up the variable
and attempts to parse it according to the expected value type. If the environment variable
is not set, the policy setting is considered not configured in this store (the syspolicy package
will still read it from other sources). Similarly, if the environment variable has an invalid value
for the setting type, it won't be used (though the reported/logged error will differ).

Updates #13193
Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-10-30 11:12:22 -05:00

160 lines
4.2 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package source
import (
"fmt"
"os"
"strconv"
"strings"
"unicode/utf8"
"github.com/pkg/errors"
"tailscale.com/util/syspolicy/setting"
)
var lookupEnv = os.LookupEnv // test hook
var _ Store = (*EnvPolicyStore)(nil)
// EnvPolicyStore is a [Store] that reads policy settings from environment variables.
type EnvPolicyStore struct{}
// ReadString implements [Store].
func (s *EnvPolicyStore) ReadString(key setting.Key) (string, error) {
_, str, err := s.lookupSettingVariable(key)
if err != nil {
return "", err
}
return str, nil
}
// ReadUInt64 implements [Store].
func (s *EnvPolicyStore) ReadUInt64(key setting.Key) (uint64, error) {
name, str, err := s.lookupSettingVariable(key)
if err != nil {
return 0, err
}
if str == "" {
return 0, setting.ErrNotConfigured
}
value, err := strconv.ParseUint(str, 0, 64)
if err != nil {
return 0, fmt.Errorf("%s: %w: %q is not a valid uint64", name, setting.ErrTypeMismatch, str)
}
return value, nil
}
// ReadBoolean implements [Store].
func (s *EnvPolicyStore) ReadBoolean(key setting.Key) (bool, error) {
name, str, err := s.lookupSettingVariable(key)
if err != nil {
return false, err
}
if str == "" {
return false, setting.ErrNotConfigured
}
value, err := strconv.ParseBool(str)
if err != nil {
return false, fmt.Errorf("%s: %w: %q is not a valid bool", name, setting.ErrTypeMismatch, str)
}
return value, nil
}
// ReadStringArray implements [Store].
func (s *EnvPolicyStore) ReadStringArray(key setting.Key) ([]string, error) {
_, str, err := s.lookupSettingVariable(key)
if err != nil || str == "" {
return nil, err
}
var dst int
res := strings.Split(str, ",")
for src := range res {
res[dst] = strings.TrimSpace(res[src])
if res[dst] != "" {
dst++
}
}
return res[0:dst], nil
}
func (s *EnvPolicyStore) lookupSettingVariable(key setting.Key) (name, value string, err error) {
name, err = keyToEnvVarName(key)
if err != nil {
return "", "", err
}
value, ok := lookupEnv(name)
if !ok {
return name, "", setting.ErrNotConfigured
}
return name, value, nil
}
var (
errEmptyKey = errors.New("key must not be empty")
errInvalidKey = errors.New("key must consist of alphanumeric characters and slashes")
)
// keyToEnvVarName returns the environment variable name for a given policy
// setting key, or an error if the key is invalid. It converts CamelCase keys into
// underscore-separated words and prepends the variable name with the TS prefix.
// For example: AuthKey => TS_AUTH_KEY, ExitNodeAllowLANAccess => TS_EXIT_NODE_ALLOW_LAN_ACCESS, etc.
//
// It's fine to use this in [EnvPolicyStore] without caching variable names since it's not a hot path.
// [EnvPolicyStore] is not a [Changeable] policy store, so the conversion will only happen once.
func keyToEnvVarName(key setting.Key) (string, error) {
if len(key) == 0 {
return "", errEmptyKey
}
isLower := func(c byte) bool { return 'a' <= c && c <= 'z' }
isUpper := func(c byte) bool { return 'A' <= c && c <= 'Z' }
isLetter := func(c byte) bool { return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') }
isDigit := func(c byte) bool { return '0' <= c && c <= '9' }
words := make([]string, 0, 8)
words = append(words, "TS")
var currentWord strings.Builder
for i := 0; i < len(key); i++ {
c := key[i]
if c >= utf8.RuneSelf {
return "", errInvalidKey
}
var split bool
switch {
case isLower(c):
c -= 'a' - 'A' // make upper
split = currentWord.Len() > 0 && !isLetter(key[i-1])
case isUpper(c):
if currentWord.Len() > 0 {
prevUpper := isUpper(key[i-1])
nextLower := i < len(key)-1 && isLower(key[i+1])
split = !prevUpper || nextLower // split on case transition
}
case isDigit(c):
split = currentWord.Len() > 0 && !isDigit(key[i-1])
case c == setting.KeyPathSeparator:
words = append(words, currentWord.String())
currentWord.Reset()
continue
default:
return "", errInvalidKey
}
if split {
words = append(words, currentWord.String())
currentWord.Reset()
}
currentWord.WriteByte(c)
}
if currentWord.Len() > 0 {
words = append(words, currentWord.String())
}
return strings.Join(words, "_"), nil
}