Skip to content

Commit 2336c34

Browse files
committed
util/syspolicy: implement a syspolicy store that reads settings from environment variables
In this PR, we implement (but do not use yet, pending tailscale#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 tailscale#13193 Updates tailscale#12687 Signed-off-by: Nick Khyl <[email protected]>
1 parent 1103044 commit 2336c34

File tree

5 files changed

+518
-5
lines changed

5 files changed

+518
-5
lines changed

util/syspolicy/internal/metrics/metrics.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ func SetHooksForTest(tb internal.TB, addMetric, setMetric metricFn) {
284284
}
285285

286286
func newSettingMetric(key setting.Key, scope setting.Scope, suffix string, typ clientmetric.Type) metric {
287-
name := strings.ReplaceAll(string(key), setting.KeyPathSeparator, "_")
287+
name := strings.ReplaceAll(string(key), string(setting.KeyPathSeparator), "_")
288288
return newMetric([]string{name, metricScopeName(scope), suffix}, typ)
289289
}
290290

util/syspolicy/setting/key.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ package setting
1010
type Key string
1111

1212
// KeyPathSeparator allows logical grouping of policy settings into categories.
13-
const KeyPathSeparator = "/"
13+
const KeyPathSeparator = '/'
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package source
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"strconv"
10+
"strings"
11+
"unicode/utf8"
12+
13+
"github.com/pkg/errors"
14+
"tailscale.com/util/syspolicy/setting"
15+
)
16+
17+
var lookupEnv = os.LookupEnv // test hook
18+
19+
var _ Store = (*EnvPolicyStore)(nil)
20+
21+
// EnvPolicyStore is a [Store] that reads policy settings from environment variables.
22+
type EnvPolicyStore struct{}
23+
24+
// ReadString implements [Store].
25+
func (s *EnvPolicyStore) ReadString(key setting.Key) (string, error) {
26+
_, str, err := s.lookupSettingVariable(key)
27+
if err != nil {
28+
return "", err
29+
}
30+
return str, nil
31+
}
32+
33+
// ReadUInt64 implements [Store].
34+
func (s *EnvPolicyStore) ReadUInt64(key setting.Key) (uint64, error) {
35+
name, str, err := s.lookupSettingVariable(key)
36+
if err != nil {
37+
return 0, err
38+
}
39+
if str == "" {
40+
return 0, setting.ErrNotConfigured
41+
}
42+
value, err := strconv.ParseUint(str, 0, 64)
43+
if err != nil {
44+
return 0, fmt.Errorf("%s: %w: %q is not a valid uint64", name, setting.ErrTypeMismatch, str)
45+
}
46+
return value, nil
47+
}
48+
49+
// ReadBoolean implements [Store].
50+
func (s *EnvPolicyStore) ReadBoolean(key setting.Key) (bool, error) {
51+
name, str, err := s.lookupSettingVariable(key)
52+
if err != nil {
53+
return false, err
54+
}
55+
if str == "" {
56+
return false, setting.ErrNotConfigured
57+
}
58+
value, err := strconv.ParseBool(str)
59+
if err != nil {
60+
return false, fmt.Errorf("%s: %w: %q is not a valid bool", name, setting.ErrTypeMismatch, str)
61+
}
62+
return value, nil
63+
}
64+
65+
// ReadStringArray implements [Store].
66+
func (s *EnvPolicyStore) ReadStringArray(key setting.Key) ([]string, error) {
67+
_, str, err := s.lookupSettingVariable(key)
68+
if err != nil || str == "" {
69+
return nil, err
70+
}
71+
var dst int
72+
res := strings.Split(str, ",")
73+
for src := range res {
74+
res[dst] = strings.TrimSpace(res[src])
75+
if res[dst] != "" {
76+
dst++
77+
}
78+
}
79+
return res[0:dst], nil
80+
}
81+
82+
func (s *EnvPolicyStore) lookupSettingVariable(key setting.Key) (name, value string, err error) {
83+
name, err = keyToEnvVarName(key)
84+
if err != nil {
85+
return "", "", err
86+
}
87+
value, ok := lookupEnv(name)
88+
if !ok {
89+
return name, "", setting.ErrNotConfigured
90+
}
91+
return name, value, nil
92+
}
93+
94+
var (
95+
errEmptyKey = errors.New("key must not be empty")
96+
errInvalidKey = errors.New("key must consist of alphanumeric characters and slashes")
97+
)
98+
99+
// keyToEnvVarName returns the environment variable name for a given policy
100+
// setting key, or an error if the key is invalid. It converts CamelCase keys into
101+
// underscore-separated words and prepends the variable name with the TS prefix.
102+
// For example: AuthKey => TS_AUTH_KEY, ExitNodeAllowLANAccess => TS_EXIT_NODE_ALLOW_LAN_ACCESS, etc.
103+
//
104+
// It's fine to use this in [EnvPolicyStore] without caching variable names since it's not a hot path.
105+
// [EnvPolicyStore] is not a [Changeable] policy store, so the conversion will only happen once.
106+
func keyToEnvVarName(key setting.Key) (string, error) {
107+
if len(key) == 0 {
108+
return "", errEmptyKey
109+
}
110+
111+
isLower := func(c byte) bool { return 'a' <= c && c <= 'z' }
112+
isUpper := func(c byte) bool { return 'A' <= c && c <= 'Z' }
113+
isLetter := func(c byte) bool { return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') }
114+
isDigit := func(c byte) bool { return '0' <= c && c <= '9' }
115+
116+
words := make([]string, 0, 8)
117+
words = append(words, "TS")
118+
var currentWord strings.Builder
119+
for i := 0; i < len(key); i++ {
120+
c := key[i]
121+
if c >= utf8.RuneSelf {
122+
return "", errInvalidKey
123+
}
124+
125+
var split bool
126+
switch {
127+
case isLower(c):
128+
c -= 'a' - 'A' // make upper
129+
split = currentWord.Len() > 0 && !isLetter(key[i-1])
130+
case isUpper(c):
131+
if currentWord.Len() > 0 {
132+
prevUpper := isUpper(key[i-1])
133+
nextLower := i < len(key)-1 && isLower(key[i+1])
134+
split = !prevUpper || nextLower // split on case transition
135+
}
136+
case isDigit(c):
137+
split = currentWord.Len() > 0 && !isDigit(key[i-1])
138+
case c == setting.KeyPathSeparator:
139+
words = append(words, currentWord.String())
140+
currentWord.Reset()
141+
continue
142+
default:
143+
return "", errInvalidKey
144+
}
145+
146+
if split {
147+
words = append(words, currentWord.String())
148+
currentWord.Reset()
149+
}
150+
151+
currentWord.WriteByte(c)
152+
}
153+
154+
if currentWord.Len() > 0 {
155+
words = append(words, currentWord.String())
156+
}
157+
158+
return strings.Join(words, "_"), nil
159+
}

0 commit comments

Comments
 (0)