You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/util/syspolicy/source/env_policy_store.go

160 lines
4.1 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package source
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"unicode/utf8"
"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
}