mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-22 11:35:12 -04:00
The ts_omit_<name> build tags omit a feature at build time; there has
been no way to do the same at runtime. Some users (either proactively
or in response to a security announcement) might like a way to disable
a feature that's linked-in in their binaries that they're not using.
Then a mitigation announcement can say "set this env var" without
asking users to rebuild or wait for a new release.
This adds env var TS_DISABLE_FEATURE, a comma-separated list of
feature names to disable, and the listed set is reported by the
debug-optional-features LocalAPI endpoint next to the registered set.
The legacy per-feature knobs such as TS_DISABLE_SSH_SERVER and
TS_DISABLE_TAILDROP keep working independently.
A disabled feature behaves as if it had not been linked: it is absent
from feature.IsRegistered, its hooks are unset, and its extensions and
handlers are not registered. Three pieces make that happen:
* feature.Register now returns bool, false when disabled, and
feature packages gate their registration init on it. It was added
to the feature packages that never called it (including taildrop
and ssh), which also completes the picture reported by
debug-optional-features. taildrop, routecheck, favorites, and
serviceclientprefs had registration split across several inits and
now register from one gated init.
* ipnext.RegisterExtension ignores a disabled feature's extension.
* feature.Hook.Set and feature.Hooks.Add walk the call stack and
silently skip when the calling package under feature/<name> is
disabled. This covers sub-packages such as
feature/captiveportal/netcheckhook, which cannot call Register
themselves without colliding with their parent, and future
packages whose authors forget the gate.
ssh/tailssh's registrations moved from its inits into tailssh.Register,
called from feature/ssh's gated init. The aws and kube state stores and
syspolicy's Windows store registration are gated too.
feature/register_disable_test.go runs this test binary as a child
process (it links condregister, as tailscaled does) with
TS_DISABLE_FEATURE set to every registered feature at once, and fails
if any of them register anyway, so a feature that ignores the variable
cannot land.
Updates #12614
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I720af6ccab844ae060a9dfd1539fee577fd483e3
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
|
|
}
|
|
}
|
|
}
|