Files
tailscale/feature/README.md
T
Brad FitzpatrickandBrad Fitzpatrick 7965d496a6 feature: add README explaining the modular feature system
We had an internal Google doc about this (Tailscalars:
http://go/clientmod) but that doesn't help open source contributors or
agents.

So move the docs to git.

Updates #12614

Change-Id: I0b0e9f0286b23b4fb1b51ff3d41eba75edf62cdf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-09 13:30:47 -07:00

367 lines
17 KiB
Markdown

# Modular Tailscale features
This directory contains Tailscale's modular feature system. The Tailscale
client has grown large; not every user wants every feature (an IoT device on
a few-dollar chip does not need Taildrop, WebDAV, ACME, or SSH). The
`feature/` tree is how we make individual features conditionally linkable so
that resource-constrained builds can omit them, and so that new code lives
in small self-contained packages instead of being dumped into
`LocalBackend`.
## New code lives here
**The preferred home for new functionality is a `feature/<name>` package.**
If there is any plausible user who might not want your feature in their
build (which is almost always the case), it should be modular from the
start. A feature package should install itself into the rest of Tailscale
via hooks, extensions, and registrations from its own `init` (see
"Hooks and registration" below). It should not export API used by callers
elsewhere in the tree; callers reach the feature through the hook, not by
importing it.
Think your functionality is so core that it must always be linked in?
Imagine any plausible user who might not want it. If you cannot,
[ask](#asking-questions) before dumping code into `ipn/ipnlocal` or
similar.
## The half-migrated reality
Much of the tree is in a **half-migrated state**. A given feature may:
- Have a `ts_omit_<name>` build tag declared in
[`featuretags/featuretags.go`](featuretags/featuretags.go), and
- Have some of its code moved to `feature/<name>/`, but
- Still have significant code living in `ipn/ipnlocal`, `cmd/tailscaled`,
or elsewhere, either:
- Behind a build-tag-conditional file (a whole `.go` file gated with
`//go:build !ts_omit_<name>`), or
- Guarded at runtime by an `if buildfeatures.HasFoo { ... }` block that
the compiler and linker dead-code-eliminate when the tag is set.
When you touch one of these features, prefer to keep migrating code into
its `feature/<name>` package rather than adding more to the old location.
But do not feel obligated to finish the migration in one PR.
## The registry: `feature/featuretags`
[`featuretags/featuretags.go`](featuretags/featuretags.go) is the single
source of truth. It declares the [`Features`](https://pkg.go.dev/tailscale.com/feature/featuretags#Features)
map: for each feature tag (a
lowercase string like `acme` or `taildrop`), it records the exported symbol
name used by generated constants, a human-readable description, and any
other features it depends on (forming a DAG).
Adding a new feature starts with adding an entry there. See the top of the
file for the tag naming convention: feature `foo` corresponds to build tag
`ts_omit_foo` (opt-out). The one exception is `cli`, which is opt-in via
`ts_include_cli`; see [`FeatureTag.IsOmittable`](https://pkg.go.dev/tailscale.com/feature/featuretags#FeatureTag.IsOmittable).
Features can be marked [`ImplementationDetail: true`](https://pkg.go.dev/tailscale.com/feature/featuretags#FeatureMeta)
when they are internal
plumbing (e.g. `dbus`, `c2n`) that users would not select directly; they
exist only so that user-visible features can depend on them.
## Generated constants: `feature/buildfeatures`
After editing `featuretags.go`, run:
./tool/go generate ./feature/buildfeatures
(or the meta `make generate`). That regenerates two files per feature in
[`buildfeatures/`](buildfeatures/): `feature_<name>_enabled.go` and
`feature_<name>_disabled.go`, gated with `//go:build !ts_omit_<name>` and
`//go:build ts_omit_<name>` respectively. Each pair exports a single
boolean constant, e.g. `HasACME`. Because these are Go constants, code of
the form:
if buildfeatures.HasACME {
// ...
}
is dead-code-eliminated by the compiler and linker when the feature is
omitted. Constants can eliminate *code* but they cannot remove *imports*;
for that you still need a build-tag-gated file.
Use constants when moving code to `feature/<name>` is impractical (e.g.
the code has to sit inside `LocalBackend`), or when a small conditional
inside otherwise-shared logic is clearer than splitting into two files.
Prefer separate packages when you can.
## `feature/condregister`: opt-out registration
[`condregister/`](condregister/) is the one central package that
`tailscaled` (and the macOS/iOS closed-source client) empty-imports so
that all "on by default" features get registered. Every feature that
should ship in `tailscaled` by default has a `maybe_<name>.go` file here
of the form:
//go:build !ts_omit_<name>
package condregister
import _ "tailscale.com/feature/<name>"
That is the whole file. The build tag is the only mechanism that decides
whether the feature is compiled in. Because `tailscaled` imports
`condregister`, all these `maybe_*` files pull in their respective
`feature/<name>` packages by default; adding `ts_omit_<name>` to the
build removes the file, and with it the import, and with it the
package's `init` and everything it transitively depends on.
Some `maybe_*` files carry additional build tags (e.g.
`//go:build !ios && !ts_omit_capture`) when a feature should be excluded
on specific platforms.
### The `feature/<name>` package should not carry `ts_omit_*` tags
The `ts_omit_<name>` tag lives only on the `condregister/maybe_<name>.go`
(and analogous `tsnet/maybe_<name>.go`) import shim, not on the
`feature/<name>/*.go` files themselves. Keeping the feature package
free of its own omit tag lets any program directly opt in with a blank
import regardless of what `ts_omit_*` tags are set on the top-level
build:
import _ "tailscale.com/feature/foo"
That is how a `tsnet`-using application pulls a feature in on its own
terms without having to reason about which `ts_omit_*` tags are in
effect.
OS-specific build tags inside `feature/<name>/` are fine, and often
desirable: if the feature only makes sense on some platforms, gating
its files with `//go:build linux` (or `!ios`, etc.) both keeps
`go test ./...` passing on every GOOS and prevents a `tsnet` user on an
unsupported platform from accidentally blank-importing a package that
would fail to compile or run there.
## `tsnet` does NOT depend on `condregister`
`tsnet` is a library, not a daemon, and it links in a different (and
generally smaller) default feature set than `tailscaled`. `tsnet` has its
own top-level `maybe_*.go` files that decide which features it opts in
to, and its own `depaware.txt`.
**Consequence:** you cannot assume `buildfeatures.HasFoo == true` means
feature `foo` was actually linked. In a `tsnet` build, `ts_omit_foo` may
not be set (so `HasFoo` is `true`), yet `tsnet` may have never imported
`feature/foo`, so its `init` never ran and no hooks were installed.
`buildfeatures.HasFoo` really means "not explicitly omitted by build
tag." Whether the feature is actually present is
[`feature.IsRegistered("foo")`](https://pkg.go.dev/tailscale.com/feature#IsRegistered).
The idiom is:
buildfeatures.HasFoo && feature.IsRegistered("foo")
`HasFoo` lets the compiler DCE the whole expression in `ts_omit_foo`
builds; `IsRegistered` guards against the `tsnet` case where the tag is
unset but the package was never imported.
## The `feature` API
Package [`feature`](https://pkg.go.dev/tailscale.com/feature) itself
exposes the small runtime API used by feature packages and their callers:
- [`feature.Register(name)`](https://pkg.go.dev/tailscale.com/feature#Register):
a feature package calls this from its `init` to record that it was
linked in. Callers use
[`feature.IsRegistered(name)`](https://pkg.go.dev/tailscale.com/feature#IsRegistered)
to check.
- [`feature.Hook[Func]`](https://pkg.go.dev/tailscale.com/feature#Hook):
a single-writer, single-reader hook. The
extension point declares a `var HookX feature.Hook[func(...)]`, the
feature package `Set`s it once from `init`, and callers use
`GetOk`/`GetOrNil`/`IsSet` to invoke it. `Set` panics if called twice.
- [`feature.Hooks[Func]`](https://pkg.go.dev/tailscale.com/feature#Hooks):
a slice of hooks for extension points that may
legitimately have multiple registrants.
- Common cross-feature hooks (auto-update, proxy, TPM, SSH host keys,
hardware attestation) live in [`hooks.go`](hooks.go) alongside their
small dispatcher functions like
[`feature.CanAutoUpdate()`](https://pkg.go.dev/tailscale.com/feature#CanAutoUpdate)
and
[`feature.TPMAvailable()`](https://pkg.go.dev/tailscale.com/feature#TPMAvailable).
Add hooks here only sparingly: every hook in this package is loaded
by every consumer of `feature`, so its function signature must not
reference types from "heavy" packages. By heavy we mean any package
whose presence in this signature would unnecessarily grow
[`cmd/tailscaled/depaware-min.txt`](../cmd/tailscaled/depaware-min.txt):
packages with a large API/dependency footprint (e.g. `net/http`,
`crypto/tls`, `golang.org/x/crypto/ssh`, `k8s.io/...`), or packages
that trigger expensive runtime features like `reflect`-based
registration. The minimal build's depaware file is sacred and must
not grow without good reason. Prefer primitives, small `types/...`
packages, or interfaces. If your hook needs a wide type, keep the
hook (and its dispatcher) in the caller's package instead of
promoting it here.
[`ipnext.RegisterExtension`](https://pkg.go.dev/tailscale.com/ipn/ipnext#RegisterExtension)
(in `ipn/ipnext`) is the corresponding
mechanism for features that need to hook into `LocalBackend`; see the
next section.
## IPN extensions: `ipn/ipnext`
For features that need to attach state and behavior to `LocalBackend`,
the plain `feature.Hook` mechanism is not enough; you also need
per-`LocalBackend` state and a well-defined lifecycle. That is what
[`ipn/ipnext`](https://pkg.go.dev/tailscale.com/ipn/ipnext) provides.
Per-`LocalBackend` state matters because a single process can have more
than one `LocalBackend` alive at once: a `tsnet` program can host many
`tsnet.Server` nodes concurrently, each with its own `LocalBackend`.
Package-global variables in a feature package would be shared across all
of them, which should be avoided unless it is otherwise infeasible. An
[`ipnext.Extension`](https://pkg.go.dev/tailscale.com/ipn/ipnext#Extension)
is instantiated once *per* `LocalBackend`, so each node gets its own
copy of the extension's state.
(A related, not-yet-realized goal is letting different `tsnet` nodes in
the same process opt in to different sets of hooks. Today the hook
registration is still process-global (the `init` runs once and the hook
is on for every `LocalBackend`), but the extension design leaves room to
make this per-node in the future. Don't rely on the process-global
behavior; keep state on the extension instance, not in globals.)
An `ipnext.Extension` is created once per `LocalBackend`. From `init`, a
feature package registers a factory:
func init() {
feature.Register("taildrop")
ipnext.RegisterExtension("taildrop", newExtension)
}
When `LocalBackend.Start` runs, it instantiates each registered
extension and calls its
`Init(host `[`ipnext.Host`](https://pkg.go.dev/tailscale.com/ipn/ipnext#Host)`) error`
method. `Init` is where the extension wires up its per-backend hooks
against `host.Hooks()`:
func (e *extension) Init(h ipnext.Host) error {
h.Hooks().ProfileStateChange.Add(e.onChangeProfile)
h.Hooks().OnSelfChange.Add(e.onSelfChange)
h.Hooks().MutateNotifyLocked.Add(e.setNotifyFilesWaiting)
h.Hooks().SetPeerStatus.Add(e.setPeerStatus)
h.Hooks().BackendStateChange.Add(e.onBackendStateChange)
return nil
}
[`ipnext.Hooks`](https://pkg.go.dev/tailscale.com/ipn/ipnext#Hooks)
is a struct of `feature.Hook`/`feature.Hooks` fields
covering `LocalBackend`'s extension points: backend and profile state
changes, netmap toggles, self-node changes, notify mutation, peer
status, packet-filter hooks, audit logging, and more. Read the type in
`ipn/ipnext/ipnext.go` for the current list; new hooks are added there
as needed.
Extension hooks run synchronously with the triggering `LocalBackend`
operation and can influence its outcome. Because multiple extensions may
touch the same shared state (prefs, active profile, exit node, etc.),
new hooks should be designed with a clear conflict-resolution story
rather than assuming a single writer.
To get back to your extension from a `LocalBackend` inside a LocalAPI or
C2N handler, use
[`ipnlocal.GetExt[*yourExtType](b)`](https://pkg.go.dev/tailscale.com/ipn/ipnlocal#GetExt).
Canonical examples:
- [`feature/taildrop/ext.go`](taildrop/ext.go): full extension with
per-backend state.
- [`feature/acme/acme.go`](acme/acme.go): extension that also `Set`s a
handful of `ipnlocal.Hook*` singletons.
The `ipnext` mechanism is a work in progress and not every part of
`LocalBackend` has been converted to hooks yet. If you need a new
extension point, [ask](#asking-questions).
## Tests: `featuretags` and `depaware` / `depchecker`
Tests come in three flavors:
1. **`feature/featuretags` self-tests** ([`featuretags_test.go`](featuretags/featuretags_test.go))
validate the registry itself: that every declared dependency exists,
that there are no cycles, that `Requires`/`RequiredBy` return the
expected sets, and that every `ts_omit_*` string that appears
anywhere in the tree (via `git grep`) is declared in the map.
2. **`depaware`** ([github.com/tailscale/depaware](https://github.com/tailscale/depaware))
snapshots the full set of Go packages linked into each binary into
a checked-in `depaware.txt` file. Its job is right there in the
name: to force us to be *aware of our deps*. Every change to the
dependency graph shows up as a diff to `depaware.txt` in the same
PR that introduced it, so reviewers can see, prominently, exactly
what got pulled in or dropped. We use this to have an auditable
history of the dependency footprint of key programs and libraries.
We track several flavors:
- full `tailscaled` on all GOOSes
- full `tailscale` CLI on all GOOSes
- `cmd/derper`
- minimal `tailscaled`+CLI (`depaware-min.txt`, `depaware-minbox.txt`)
- `tsnet` (has its own `depaware.txt`)
- `k8s-operator`, which uses `tsnet`
When reviewing a PR, look at the depaware diff to see what got pulled
in or dropped, and make sure the changes make sense.
3. **[`deptest.DepChecker`](https://pkg.go.dev/tailscale.com/tstest/deptest#DepChecker)**
lets you *lock down* omissions once you've
achieved them. Rather than hoping nobody accidentally adds a
`taildrop` import back into a `ts_omit_taildrop` build, you write:
func TestOmitACME(t *testing.T) {
deptest.DepChecker{
GOOS: "linux",
GOARCH: "amd64",
Tags: "ts_omit_acme,ts_include_cli",
OnDep: func(dep string) {
if strings.Contains(dep, "/acme") {
t.Errorf("unexpected dep with ts_omit_acme: %q", dep)
}
},
}.Check(t)
}
`cmd/tailscaled/deps_test.go` has many examples; add new ones there
or wherever they fit. `tsnet` has its own `TestDeps` in
[`tsnet_test.go`](../tsnet/tsnet_test.go) with a `BadDeps` map
asserting that things like `feature/remoteconfig`,
`feature/syspolicy`, and `x/crypto/ssh` never sneak into `tsnet`.
## Tooling
- `cmd/featuretags` constructs valid sets of Go omit build tags. Start
from a minimal build and add features with `--min --add=...`, or start
from a full build and remove with `--remove=...`; either way it
respects the declared dependency DAG.
- `build_dist.sh` uses `cmd/featuretags` under the hood, so you do not
need to hand-maintain its build-tag lists as new features are added.
## Summary of what to do for a new feature
1. Add an entry to `Features` in `feature/featuretags/featuretags.go`,
including any `Deps`.
2. Run `./tool/go generate ./feature/buildfeatures` (or `make generate`).
3. Create `feature/<name>/` with the code, and register hooks and any
`ipnext.Extension` from its `init`. Call `feature.Register("<name>")`
from `init` too.
4. If the feature should be on by default in `tailscaled`, add
`feature/condregister/maybe_<name>.go` with `//go:build !ts_omit_<name>`
and a blank import of `tailscale.com/feature/<name>`.
5. If `tsnet` should also link it, add an equivalent `maybe_<name>.go`
under `tsnet/`. Otherwise remember `buildfeatures.HasFoo` alone is not
enough; pair it with `feature.IsRegistered("foo")`.
6. Add a `deptest.DepChecker` test to lock down what does *not* get
linked when the feature is omitted.
7. Regenerate depaware and review the diff.
## Asking questions
If something in this document is unclear, if you are not sure whether
your work should be modular, or if you need a new extension point:
- **Tailscale employees:** ask in the `#client` Slack channel.
- **Open source contributors:** file a public GitHub issue with your
proposal or question at
[github.com/tailscale/tailscale/issues](https://github.com/tailscale/tailscale/issues).