140 lines
5.0 KiB
Go
140 lines
5.0 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package feature
|
|
|
|
import (
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"tailscale.com/envknob"
|
|
"tailscale.com/util/set"
|
|
)
|
|
|
|
// disabledEnv is the TS_DISABLE_FEATURE environment variable: a comma
|
|
// separated list of feature names to disable in this process, even if
|
|
// they're compiled in. It's a mitigation and attack-surface-reduction
|
|
// knob, the runtime analog of the ts_omit_<name> build tags. For
|
|
// example, on a system where the tailscaled unit loads environment
|
|
// from /etc/default/tailscaled:
|
|
//
|
|
// TS_DISABLE_FEATURE=ssh,taildrop
|
|
//
|
|
// The value is read once, at first use, which in practice is during
|
|
// package initialization; setting it later has no effect, because
|
|
// features register themselves from init.
|
|
var disabledEnv = envknob.RegisterString("TS_DISABLE_FEATURE")
|
|
|
|
// disabledByEnv is the set of normalized feature names listed in
|
|
// TS_DISABLE_FEATURE, parsed once on first use.
|
|
var disabledByEnv = sync.OnceValue(func() set.Set[string] {
|
|
return set.SetOf(parseDisabledList(disabledEnv()))
|
|
})
|
|
|
|
// parseDisabledList splits a TS_DISABLE_FEATURE value into normalized
|
|
// feature names, skipping empty entries.
|
|
func parseDisabledList(s string) []string {
|
|
var names []string
|
|
for _, ent := range strings.Split(s, ",") {
|
|
if name := normalizeFeatureName(ent); name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// normalizeFeatureName canonicalizes a feature name whether it came
|
|
// from TS_DISABLE_FEATURE or from code. It trims spaces, lowercases,
|
|
// drops an optional ts_omit_ build-tag prefix, and maps underscores to
|
|
// dashes. Registered names use dashes by convention (for example
|
|
// "desktop-sessions"), but their corresponding build tags use
|
|
// underscores, so both spellings are accepted.
|
|
func normalizeFeatureName(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.ToLower(s)
|
|
s = strings.TrimPrefix(s, "ts_omit_")
|
|
return strings.ReplaceAll(s, "_", "-")
|
|
}
|
|
|
|
// Disabled reports whether the named feature has been disabled for
|
|
// this process via the TS_DISABLE_FEATURE environment variable. A
|
|
// disabled feature behaves as if it had not been linked in: [Register]
|
|
// reports false, and hooks and extensions registered by its package
|
|
// are ignored.
|
|
func Disabled(name string) bool {
|
|
return disabledByEnv().Contains(normalizeFeatureName(name))
|
|
}
|
|
|
|
// EnvDisabled returns the sorted feature names listed in the
|
|
// TS_DISABLE_FEATURE environment variable, whether or not they name
|
|
// features that this build contains. It is for diagnostics, such as
|
|
// the debug-features LocalAPI endpoint.
|
|
func EnvDisabled() []string {
|
|
names := disabledByEnv().Slice()
|
|
slices.Sort(names)
|
|
return names
|
|
}
|
|
|
|
// featurePkgPrefix is the import path prefix of the feature package
|
|
// tree. The name of a package under it is the first path element after
|
|
// the prefix, so sub-packages of a feature (such as
|
|
// feature/captiveportal/netcheckhook) count as their parent feature.
|
|
const featurePkgPrefix = "tailscale.com/feature/"
|
|
|
|
// mechanismPkgPrefix is the prefix of symbol names belonging to the
|
|
// registration machinery itself (this package), which the stack walk
|
|
// in callerFeatureName steps over to find the registering feature. It
|
|
// matches both plain functions (tailscale.com/feature.callerFeatureName)
|
|
// and methods (tailscale.com/feature.(*Hook[...]).Set).
|
|
const mechanismPkgPrefix = "tailscale.com/feature."
|
|
|
|
// callerFeatureDisabled reports whether the feature package calling
|
|
// into this package has been disabled via TS_DISABLE_FEATURE. It backs
|
|
// the silent skip in [Hook.Set] and [Hooks.Add] so that a feature
|
|
// package which registers hooks without first consulting [Register]
|
|
// still gets disabled. If the caller is not a feature package, it
|
|
// reports false.
|
|
func callerFeatureDisabled() bool {
|
|
name, ok := callerFeatureName()
|
|
return ok && Disabled(name)
|
|
}
|
|
|
|
// callerFeatureName walks up the call stack, starting just above this
|
|
// package's own frames, and returns the name of the first calling
|
|
// package under tailscale.com/feature/. The second result is false if
|
|
// no calling feature package is found, which means the caller is
|
|
// ordinary code and its registration is always wanted.
|
|
//
|
|
// Frames are matched by symbol name prefix rather than by parsing out
|
|
// package paths, because the symbol name of a Hook.Set frame includes
|
|
// its type parameters, which themselves contain import paths.
|
|
func callerFeatureName() (string, bool) {
|
|
var pcs [16]uintptr
|
|
n := runtime.Callers(1, pcs[:])
|
|
frames := runtime.CallersFrames(pcs[:n])
|
|
for {
|
|
frame, more := frames.Next()
|
|
fn := frame.Function
|
|
switch {
|
|
case fn == "":
|
|
return "", false
|
|
case strings.HasPrefix(fn, mechanismPkgPrefix):
|
|
// A frame of the registration machinery; look above it.
|
|
case strings.HasPrefix(fn, featurePkgPrefix):
|
|
rest := strings.TrimPrefix(fn, featurePkgPrefix)
|
|
if i := strings.IndexAny(rest, "/."); i >= 0 {
|
|
rest = rest[:i]
|
|
}
|
|
return rest, true
|
|
default:
|
|
// A frame outside the feature tree; the search is over.
|
|
return "", false
|
|
}
|
|
if !more {
|
|
return "", false
|
|
}
|
|
}
|
|
}
|