This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+442
View File
@@ -0,0 +1,442 @@
# 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.
## Runtime disabling: `TS_DISABLE_FEATURE`
The `TS_DISABLE_FEATURE` environment variable disables features at
runtime, even if they're compiled in. It is the runtime analog of the
`ts_omit_<name>` build tags: a mitigation knob (for when a feature has,
say, a security bug that most users don't need to be exposed to) and a
way to shrink attack surface on appliances.
TS_DISABLE_FEATURE=ssh,taildrop
Names are matched leniently: surrounding spaces, mixed case, a
`ts_omit_` prefix, and underscores in place of dashes are all accepted.
A name that this build doesn't know about, or that names a feature not
linked in, is ignored rather than fatal.
A disabled feature behaves as if it had not been linked: it is absent
from [`feature.IsRegistered`](https://pkg.go.dev/tailscale.com/feature#IsRegistered),
its hooks are unset, and its extensions and handlers are not
registered. The compile-time `buildfeatures.HasFoo` constants are
unaffected (the code is still in the binary), so keep pairing them with
`IsRegistered` as described above.
The mechanism, in order of authority:
1. [`feature.Register(name)`](https://pkg.go.dev/tailscale.com/feature#Register)
reports false and records nothing. Feature packages gate their
registration init on it:
func init() {
if !feature.Register("foo") {
return
}
// ... set hooks, register extensions and handlers ...
}
A package with more than one registration init should consolidate
them into one gated init, or guard the others with
[`feature.Disabled(name)`](https://pkg.go.dev/tailscale.com/feature#Disabled).
2. [`ipnext.RegisterExtension`](https://pkg.go.dev/tailscale.com/ipn/ipnext#RegisterExtension)
silently ignores a disabled feature's extension.
3. `feature.Hook.Set` and `feature.Hooks.Add` walk the call stack, and
if the caller is a package under `tailscale.com/feature/<x>` whose
feature is disabled, they silently skip the registration. This
catches sub-packages of a feature (such as
`feature/captiveportal/netcheckhook`), which cannot call `Register`
themselves without colliding with their parent feature, and future
packages whose authors never added the gate.
All of this happens during package init, so the variable must be set
when the process starts (in the systemd unit's `EnvironmentFile`, the
container's environment, and so on). The legacy per-feature knobs like
`TS_DISABLE_SSH_SERVER` and `TS_DISABLE_TAILDROP` keep working
independently of this one.
The state is visible in the `debug-optional-features` LocalAPI endpoint
(`tailscaled`'s registered set plus what `TS_DISABLE_FEATURE` listed),
and `feature/register_disable_test.go` locks the behavior down: it
disables every registered feature in a subprocess and fails if any of
them register anyway, so a feature that ignores the variable cannot
land.
Not everything with a `ts_omit_*` tag is runtime-disableable: code that
registers itself from outside a `feature/` package without going
through the mechanisms above (for example the `osrouter` router hooks)
is not covered.
## 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 and to find out whether it should register itself at all
(it reports false when disabled via
[`TS_DISABLE_FEATURE`](#runtime-disabling-ts_disable_feature), as
described below). 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`, gated as described in
["Runtime disabling: `TS_DISABLE_FEATURE`](#runtime-disabling-ts_disable_feature):
func init() {
if !feature.Register("<name>") {
return
}
// ... set hooks, register extensions and handlers ...
}
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).
+288
View File
@@ -0,0 +1,288 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package acme registers the ACME/TLS-cert feature and implements its
// associated [ipnext.Extension]. The extension owns the per-LocalBackend
// ACME serialization mutex, in-flight cert tracking, the refresh loop's
// cancel func, and the test-only cert override; together with the cert
// acquisition logic in this package, it is everything tailscaled needs
// to obtain and renew TLS certificates via ACME.
//
// In builds without ACME support (js or ts_omit_acme), this package is
// not linked in; [ipn/ipnlocal] then exposes only stub wrappers that
// return errNoCerts or no-op.
package acme
import (
"context"
"crypto/tls"
"errors"
"net/http"
"sync"
"sync/atomic"
"time"
"tailscale.com/feature"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/syncs"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/tsconst"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
// featureName is the name of the feature implemented by this package.
const featureName = "acme"
func init() {
if !feature.Register(featureName) {
return
}
ipnext.RegisterExtension(featureName, newExtension)
ipnlocal.HookGetCertPEM.Set(getCertPEMHook)
ipnlocal.HookGetACMETLSALPNCert.Set(getACMETLSALPNCertHook)
ipnlocal.HookGetACMETLSALPNProto.Set(getACMETLSALPNProtoHook)
ipnlocal.HookUpdateCertRefreshLoop.Set(updateCertRefreshLoopHook)
ipnlocal.HookShutdownCertRefreshLoop.Set(shutdownCertRefreshLoopHook)
ipnlocal.HookConfigureCertsForTest.Set(configureCertsForTestHook)
ipnlocal.HookHandleC2NTLSCertStatus.Set(handleC2NTLSCertStatus)
}
// errNoExt is returned when a hook is invoked on a [*ipnlocal.LocalBackend]
// that has no [extension] registered (shouldn't happen in practice, but
// guards against misuse from tests that swap extension registrations).
var errNoExt = errors.New("acme extension not registered on this LocalBackend")
// extension is the ACME/cert [ipnext.Extension]. It owns the
// per-[*ipnlocal.LocalBackend] state previously held in package-level
// globals and in [*ipnlocal.LocalBackend] fields.
//
// All methods that take a [*ipnlocal.LocalBackend] argument operate on
// the backend the extension was instantiated for; the argument is
// passed through from the hook in [ipn/ipnlocal] rather than stored on
// the extension, which keeps the extension's lifecycle independent of
// any specific backend reference.
type extension struct {
logf logger.Logf
// domainsMu guards domainLocks.
domainsMu syncs.Mutex
// domainLocks holds a per-domain mutex serialising ACME operations
// for that domain. Different domains run concurrently; the same
// domain still queues so the first goroutine fills the on-disk
// cache and the rest reuse it. Entries are never removed; a node
// only ever has a handful of domains so the map stays small.
domainLocks map[string]*syncs.Mutex
// accountMu serialises ACME account setup (key generation, LE
// registration) so two first-time issuances don't create separate
// accounts.
accountMu syncs.Mutex
// renewMu guards renewCertAt and renewingCertDomains.
// Lock order: per-domain lock before renewMu.
renewMu syncs.Mutex
renewCertAt map[string]time.Time // lazily initialized under renewMu
// renewingCertDomains tracks domains for which an async renewal is
// currently in flight.
renewingCertDomains set.Set[string]
// pendingACMETLSALPNCerts maps SNI names to short-lived ACME
// tls-alpn-01 challenge certificates while an ACME order is
// waiting for validation. Entries are deleted by the cleanup
// function returned from storeACMETLSALPNCert after the challenge
// validation path finishes, whether it succeeds or fails.
pendingACMETLSALPNCerts syncs.Map[string, *tls.Certificate]
// pendingCertDomains tracks the set of domains for which an ACME
// issuance is currently in flight with no usable cached cert. It
// backs the tls-cert-pending health Warnable.
// Guarded by pendingCertDomainsMu.
pendingCertDomainsMu sync.Mutex
pendingCertDomains set.Set[string]
// wg tracks all background goroutines spawned by this extension
// (async cert renewals, the cert refresh loop and its per-domain
// workers). [extension.Shutdown] waits on it.
wg sync.WaitGroup
// goroutinesStarted counts goroutines started via [extension.Go].
// Tests use it to assert whether an operation kicked off async work.
goroutinesStarted atomic.Int64
// mu guards the test/lifecycle fields below.
mu sync.Mutex
// getCertForTest is used to retrieve TLS certificates in tests.
// See [LocalBackend.ConfigureCertsForTest].
getCertForTest func(hostname string) (*ipnlocal.TLSCertKeyPair, error)
// certRefreshCancel cancels the background TLS cert refresh loop
// that periodically pokes [LocalBackend.GetCertPEM] so renewals
// happen on idle nodes. Non-nil while the loop is running.
certRefreshCancel context.CancelFunc
}
// lockDomain returns the mutex for domain, creating it on first use.
func (e *extension) lockDomain(domain string) *syncs.Mutex {
e.domainsMu.Lock()
defer e.domainsMu.Unlock()
if m, ok := e.domainLocks[domain]; ok {
return m
}
m := new(syncs.Mutex)
mak.Set(&e.domainLocks, domain, m)
return m
}
// Go runs f in a new goroutine tracked by e.wg. [extension.Shutdown]
// waits for all such goroutines to finish.
func (e *extension) Go(f func()) {
e.wg.Add(1)
e.goroutinesStarted.Add(1)
go func() {
defer e.wg.Done()
f()
}()
}
// newExtension is the [ipnext.NewExtensionFn] registered for this
// feature. It is called once per [*ipnlocal.LocalBackend].
func newExtension(logf logger.Logf, _ ipnext.SafeBackend) (ipnext.Extension, error) {
return &extension{
logf: logger.WithPrefix(logf, featureName+": "),
}, nil
}
// Name implements [ipnext.Extension].
func (e *extension) Name() string { return featureName }
// Init implements [ipnext.Extension].
func (e *extension) Init(ipnext.Host) error { return nil }
// Shutdown implements [ipnext.Extension]. It cancels the cert refresh
// loop if it's running, then waits for all in-flight goroutines
// (async renewals, refresh loop workers) to finish.
func (e *extension) Shutdown() error {
e.mu.Lock()
if e.certRefreshCancel != nil {
e.certRefreshCancel()
e.certRefreshCancel = nil
}
e.mu.Unlock()
e.wg.Wait()
return nil
}
// extFor returns the [*extension] for b, or an error if no acme
// extension is registered on b.
func extFor(b *ipnlocal.LocalBackend) (*extension, error) {
e, ok := ipnlocal.GetExt[*extension](b)
if !ok {
return nil, errNoExt
}
return e, nil
}
// Hook adapter funcs that thread (b *ipnlocal.LocalBackend) into the
// extension's methods. These are what get installed in
// [ipnlocal.Hook*] at init time.
func getCertPEMHook(ctx context.Context, b *ipnlocal.LocalBackend, domain string, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) {
e, err := extFor(b)
if err != nil {
return nil, err
}
pair, err := e.getCertPEMWithValidity(ctx, b, domain, minValidity)
if err != nil {
if ae, ok := errors.AsType[*xacme.Error](err); ok {
if d, ok := xacme.RateLimit(ae); ok {
return nil, certRateLimitedError{retryAfter: d, underlying: err}
}
}
return nil, err
}
return pair, nil
}
func getACMETLSALPNCertHook(b *ipnlocal.LocalBackend, hi *tls.ClientHelloInfo) (*tls.Certificate, bool) {
e, err := extFor(b)
if err != nil {
return nil, false
}
return e.getACMETLSALPNCert(hi)
}
func getACMETLSALPNProtoHook(b *ipnlocal.LocalBackend, hi *tls.ClientHelloInfo) (string, bool) {
e, err := extFor(b)
if err != nil {
return "", false
}
return e.getACMETLSALPNProto(hi)
}
func updateCertRefreshLoopHook(b *ipnlocal.LocalBackend, state ipn.State, sc ipn.ServeConfigView) {
e, err := extFor(b)
if err != nil {
return
}
e.updateCertRefreshLoop(b, state, sc)
}
func shutdownCertRefreshLoopHook(b *ipnlocal.LocalBackend) {
e, err := extFor(b)
if err != nil {
return
}
e.Shutdown()
}
func configureCertsForTestHook(b *ipnlocal.LocalBackend, getCert func(string) (*ipnlocal.TLSCertKeyPair, error)) {
e, err := extFor(b)
if err != nil {
panic(err)
}
e.mu.Lock()
defer e.mu.Unlock()
e.getCertForTest = getCert
}
func handleC2NTLSCertStatus(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
e, err := extFor(b)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
e.handleC2NTLSCertStatus(b, w, r)
}
// ACME / cert metrics. These are package-level (process-wide) because
// they aggregate across all [*extension]s in the process.
var (
metricACMEDNS01Start = clientmetric.NewCounter("cert_acme_dns01_start")
metricACMEDNS01Success = clientmetric.NewCounter("cert_acme_dns01_success")
metricACMEDNS01Failure = clientmetric.NewCounter("cert_acme_dns01_failure")
metricACMETLSALPN01Start = clientmetric.NewCounter("cert_acme_tls_alpn01_start")
metricACMETLSALPN01Success = clientmetric.NewCounter("cert_acme_tls_alpn01_success")
metricACMETLSALPN01Failure = clientmetric.NewCounter("cert_acme_tls_alpn01_failure")
)
// certPendingWarnable fires while ACME is fetching a TLS certificate
// for which no usable cached copy exists (initial issuance or after
// the cached cert has expired). Async renewal of a still-valid cert
// does not fire it.
var certPendingWarnable = health.Register(&health.Warnable{
Code: tsconst.HealthWarnableTLSCertPending,
Title: "Fetching TLS certificate",
Severity: health.SeverityLow,
Text: func(args health.Args) string {
return "Fetching TLS certificate via ACME for: " + args[health.ArgDomains]
},
})
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package acme
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"time"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tailcfg"
)
// handleC2NTLSCertStatus returns info about the last TLS certificate
// issued for the provided domain. It is the implementation of
// [ipnlocal.HookHandleC2NTLSCertStatus]; control calls it to clean up
// DNS TXT records when they're no longer needed by LetsEncrypt.
//
// It does not kick off a cert fetch or async refresh. It only reports
// anything that's already sitting on disk, and only reports metadata
// about the public cert (stuff that'd be the in CT logs anyway).
func (e *extension) handleC2NTLSCertStatus(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
cs, err := e.getCertStore(b)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
domain := r.FormValue("domain")
if domain == "" {
http.Error(w, "no 'domain'", http.StatusBadRequest)
return
}
ret := &tailcfg.C2NTLSCertInfo{}
pair, err := getCertPEMCached(cs, domain, b.Clock().Now())
ret.Valid = err == nil
if err != nil {
ret.Error = err.Error()
if errors.Is(err, errCertExpired) {
ret.Expired = true
} else if errors.Is(err, ipn.ErrStateNotExist) {
ret.Missing = true
ret.Error = "no certificate"
}
} else {
block, _ := pem.Decode(pair.CertPEM)
if block == nil {
ret.Error = "invalid PEM"
ret.Valid = false
} else {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
ret.Error = fmt.Sprintf("invalid certificate: %v", err)
ret.Valid = false
} else {
ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339)
ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339)
}
}
}
writeJSON(w, ret)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
+768
View File
@@ -0,0 +1,768 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package acme
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
randv2 "math/rand/v2"
"net"
"slices"
"strings"
"time"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tailcfg/nodecap"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/set"
"tailscale.com/util/slicesx"
"tailscale.com/util/testenv"
)
type acmeChallengeType string
const (
acmeChallengeDNS01 acmeChallengeType = "dns-01"
acmeChallengeTLSALPN01 acmeChallengeType = "tls-alpn-01"
)
var acmeDebug = envknob.RegisterBool("TS_DEBUG_ACME")
// getACMETLSALPNCert returns the short-lived ACME challenge certificate
// for hi.ServerName. The ok result reports whether hi offered acme-tls/1
// and an ACME order is actively waiting on that challenge for
// hi.ServerName.
func (e *extension) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (cert *tls.Certificate, ok bool) {
if hi == nil || hi.ServerName == "" || !slices.Contains(hi.SupportedProtos, xacme.ALPNProto) {
return nil, false
}
cert, ok = e.pendingACMETLSALPNCerts.Load(hi.ServerName)
return cert, ok
}
// getACMETLSALPNProto reports whether serveTLSConfig should advertise
// an ACME ALPN protocol for this ClientHello.
func (e *extension) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (proto string, ok bool) {
if _, ok := e.getACMETLSALPNCert(hi); !ok {
return "", false
}
return xacme.ALPNProto, true
}
// storeACMETLSALPNCert publishes cert to Serve TLS handshakes for domain
// until the returned cleanup function is called.
func (e *extension) storeACMETLSALPNCert(domain string, cert *tls.Certificate) (cleanup func()) {
e.pendingACMETLSALPNCerts.Store(domain, cert)
return func() {
e.pendingACMETLSALPNCerts.Delete(domain)
}
}
// getCertPEMWithValidity gets the TLSCertKeyPair for domain, either
// from cache or via ACME. ACME is used for new domain certs, existing
// expired certs, or existing certs that should be renewed sooner than
// minValidity.
func (e *extension) getCertPEMWithValidity(ctx context.Context, b *ipnlocal.LocalBackend, domain string, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) {
e.mu.Lock()
getCertForTest := e.getCertForTest
e.mu.Unlock()
if getCertForTest != nil {
testenv.AssertInTest()
return getCertForTest(domain)
}
// Trim a trailing dot from the domain (e.g. from an SNI ServerName of
// "host.ts.net.") before lookup. Per RFC 6066 §3 the SNI HostName has
// no trailing dot, but some clients send a fully-qualified name with
// one, and cert store names have no trailing dot. See
// https://github.com/tailscale/tailscale/issues/10233.
domain = strings.TrimSuffix(domain, ".")
if !validLookingCertDomain(domain) {
return nil, errors.New("invalid domain")
}
certDomain, err := e.resolveCertDomain(b, domain)
if err != nil {
return nil, err
}
logf := logger.WithPrefix(b.Logger(), fmt.Sprintf("cert(%q): ", domain))
now := b.Clock().Now()
traceACME := func(v any) {
if !acmeDebug() {
return
}
j, _ := json.MarshalIndent(v, "", "\t")
log.Printf("acme %T: %s", v, j)
}
cs, err := e.getCertStore(b)
if err != nil {
return nil, err
}
pair, cacheErr := getCertPEMCached(cs, certDomain, now)
if cacheErr == nil {
if envknob.IsCertShareReadOnlyMode() {
return pair, nil
}
// If we got here, we have a valid unexpired cert.
// Check whether we should start an async renewal.
shouldRenew, err := e.shouldStartDomainRenewal(b, cs, certDomain, now, pair, minValidity)
if err != nil {
logf("error checking for certificate renewal: %v", err)
// Renewal check failed, but the current cert is valid and not
// expired, so it's safe to return.
return pair, nil
}
if !shouldRenew {
return pair, nil
}
if minValidity == 0 {
// Start renewal in the background if there isn't one already in progress,
// return current valid cert.
if e.beginAsyncRenewal(certDomain) {
logf("starting async renewal")
e.Go(func() {
defer e.endAsyncRenewal(certDomain)
if _, err := getCertPEM(context.Background(), e, b, cs, logf, traceACME, certDomain, now, minValidity); err != nil {
logf("async renewal failed: getCertPem: %v", err)
}
})
}
return pair, nil
}
// If the caller requested a specific validity duration, fall through
// to synchronous renewal to fulfill that.
logf("starting sync renewal")
}
if envknob.IsCertShareReadOnlyMode() {
return nil, fmt.Errorf("retrieving cached TLS certificate failed and cert store is configured in read-only mode, not attempting to issue a new certificate: %w", cacheErr)
}
pair, err = getCertPEM(ctx, e, b, cs, logf, traceACME, certDomain, now, minValidity)
if err != nil {
logf("getCertPEM: %v", err)
return nil, err
}
return pair, nil
}
// shouldStartDomainRenewal reports whether the domain's cert should be
// renewed based on the current time, the cert's expiry, and the ARI
// check.
func (e *extension) shouldStartDomainRenewal(b *ipnlocal.LocalBackend, cs certStore, domain string, now time.Time, pair *ipnlocal.TLSCertKeyPair, minValidity time.Duration) (bool, error) {
if minValidity != 0 {
cert, err := parseCertificate(pair)
if err != nil {
return false, fmt.Errorf("parsing certificate: %w", err)
}
return cert.NotAfter.Sub(now) < minValidity, nil
}
e.renewMu.Lock()
defer e.renewMu.Unlock()
if renewAt, ok := e.renewCertAt[domain]; ok {
return now.After(renewAt), nil
}
renewTime, err := e.domainRenewalTimeByARI(b, cs, pair)
if err != nil {
// Log any ARI failure and fall back to checking for renewal by expiry.
b.Logger()("acme: ARI check failed: %v; falling back to expiry-based check", err)
renewTime, err = domainRenewalTimeByExpiry(pair)
if err != nil {
return false, err
}
}
mak.Set(&e.renewCertAt, domain, renewTime)
return now.After(renewTime), nil
}
func (e *extension) domainRenewed(domain string) {
e.renewMu.Lock()
defer e.renewMu.Unlock()
delete(e.renewCertAt, domain)
}
// beginAsyncRenewal marks a domain as having an async renewal in flight,
// and reports whether this caller started it.
//
// The caller must arrange for endAsyncRenewal(domain) when the renewal
// finishes, regardless of result.
func (e *extension) beginAsyncRenewal(domain string) bool {
e.renewMu.Lock()
defer e.renewMu.Unlock()
if e.renewingCertDomains.Contains(domain) {
return false
}
e.renewingCertDomains.Make()
e.renewingCertDomains.Add(domain)
return true
}
// endAsyncRenewal clears the in-flight marker for domain, allowing
// a subsequent renewal to begin.
//
// It should only be called by the caller that began the renewal.
func (e *extension) endAsyncRenewal(domain string) {
e.renewMu.Lock()
defer e.renewMu.Unlock()
e.renewingCertDomains.Delete(domain)
}
func domainRenewalTimeByExpiry(pair *ipnlocal.TLSCertKeyPair) (time.Time, error) {
cert, err := parseCertificate(pair)
if err != nil {
return time.Time{}, fmt.Errorf("parsing certificate: %w", err)
}
certLifetime := cert.NotAfter.Sub(cert.NotBefore)
if certLifetime < 0 {
return time.Time{}, fmt.Errorf("negative certificate lifetime %v", certLifetime)
}
// Per https://github.com/tailscale/tailscale/issues/8204, check
// whether we're more than 2/3 of the way through the certificate's
// lifetime, which is the officially-recommended best practice by Let's
// Encrypt.
renewalDuration := certLifetime * 2 / 3
renewAt := cert.NotBefore.Add(renewalDuration)
return renewAt, nil
}
func (e *extension) shouldUseACMETLSALPN01(b *ipnlocal.LocalBackend, domain string, previous *ipnlocal.TLSCertKeyPair, logf logger.Logf) bool {
if isWildcardDomain(domain) {
logf("acme: using dns-01: tls-alpn-01 does not support wildcard certificates")
return false
}
if !b.HasFunnelForHostPort(domain, 443) {
logf("acme: using dns-01: Funnel is not enabled for %s:443", domain)
return false
}
if e.isBYOFunnelDomain(b, domain) {
// BYO Funnel domain: dns-01 is not a viable path because control
// does not own the user's DNS zone. Use tls-alpn-01 even on
// first issuance.
logf("acme: using tls-alpn-01 (BYO Funnel domain)")
return true
}
if previous == nil {
logf("acme: using dns-01: no cached certificate for Funnel renewal")
return false
}
logf("acme: using tls-alpn-01")
return true
}
// isBYOFunnelDomain reports whether domain is a "bring your own" Funnel
// hostname: a domain that is not in the netmap's CertDomains but is
// referenced as a Funnel target on :443 by the local serve config.
// BYO domains can only be issued via tls-alpn-01 because control does
// not own their DNS zone.
func (e *extension) isBYOFunnelDomain(b *ipnlocal.LocalBackend, domain string) bool {
if domain == "" || isWildcardDomain(domain) {
return false
}
nm := b.NetMapNoPeers()
if nm != nil && slices.Contains(nm.DNS.CertDomains, domain) {
return false
}
return b.HasFunnelForHostPort(domain, 443)
}
func challengeByType(challenges []*xacme.Challenge, typ string) *xacme.Challenge {
for _, ch := range challenges {
if ch.Type == typ {
return ch
}
}
return nil
}
func isWildcardDomain(domain string) bool {
return strings.HasPrefix(domain, "*.")
}
func (e *extension) domainRenewalTimeByARI(b *ipnlocal.LocalBackend, cs certStore, pair *ipnlocal.TLSCertKeyPair) (time.Time, error) {
var blocks []*pem.Block
rest := pair.CertPEM
for len(rest) > 0 {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return time.Time{}, fmt.Errorf("parsing certificate PEM")
}
blocks = append(blocks, block)
}
if len(blocks) < 1 {
return time.Time{}, fmt.Errorf("could not parse certificate chain from certStore, got %d PEM block(s)", len(blocks))
}
ac, err := e.acmeClient(cs)
if err != nil {
return time.Time{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ri, err := ac.FetchRenewalInfo(ctx, blocks[0].Bytes)
if err != nil {
return time.Time{}, fmt.Errorf("failed to fetch renewal info from ACME server: %w", err)
}
if acmeDebug() {
b.Logger()("acme: ARI response: %+v", ri)
}
// Select a random time in the suggested window and renew if that time has
// passed. Time is randomized per recommendation in
// https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
start, end := ri.SuggestedWindow.Start, ri.SuggestedWindow.End
renewTime := start.Add(randv2.N(end.Sub(start)))
return renewTime, nil
}
// getCertPEM checks if a cert needs to be renewed and if so, renews it.
// domain is the resolved cert domain (e.g., "*.node.ts.net" for
// wildcards). It can be overridden in tests.
var getCertPEM = func(ctx context.Context, e *extension, b *ipnlocal.LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) {
dm := e.lockDomain(domain)
dm.Lock()
defer dm.Unlock()
// In case this method was triggered multiple times in parallel (when
// serving incoming requests), check whether one of the other goroutines
// already renewed the cert before us.
previous, err := getCertPEMCached(cs, domain, now)
if err == nil {
// shouldStartDomainRenewal caches its result so it's OK to call this
// frequently.
shouldRenew, err := e.shouldStartDomainRenewal(b, cs, domain, now, previous, minValidity)
if err != nil {
logf("error checking for certificate renewal: %v", err)
} else if !shouldRenew {
return previous, nil
}
} else if !errors.Is(err, ipn.ErrStateNotExist) && !errors.Is(err, errCertExpired) {
return nil, err
}
// If we have no usable cached cert (either nothing on disk, or what is
// on disk has expired or otherwise failed verification), surface a
// health warning to the user for the duration of the ACME flow. We
// don't fire the warning when previous is non-nil because then we have
// a working cert and the renewal is happening behind the scenes.
if previous == nil {
e.setCertPending(b, domain, true)
defer e.setCertPending(b, domain, false)
}
ac, err := e.acmeClient(cs)
if err != nil {
return nil, err
}
if !isDefaultDirectoryURL(ac.DirectoryURL) {
logf("acme: using Directory URL %q", ac.DirectoryURL)
}
a, err := e.ensureAccount(ctx, ac, logf, traceACME)
if err != nil {
return nil, err
}
if a.Status != xacme.StatusValid {
return nil, fmt.Errorf("unexpected ACME account status %q", a.Status)
}
// If we have a previous cert, include it in the order via the ARI
// "replaces" extension so LE classifies the new cert as a renewal
// and exempts it from the per-registered-domain rate limit. Stores
// that can tell the current ACME account did not issue the previous
// cert opt out via [ARIReplacesAllower]; see #18251.
var opts []xacme.OrderOption
if previous != nil && !envknob.Bool("TS_DEBUG_ACME_FORCE_RENEWAL") {
prevCrt, err := parseCertificate(previous)
if err == nil {
useReplaces := true
if a, ok := cs.(ARIReplacesAllower); ok {
if allowed, err := a.ShouldUseARIReplacesForRenewal(domain); err != nil {
// Fail open: an error means we couldn't check
// eligibility, not that the account is misaligned.
logf("acme: failed to check ARI 'replaces' eligibility for %q, defaulting to use it: %v", domain, err)
} else {
useReplaces = allowed
}
}
if useReplaces {
opts = append(opts, xacme.WithOrderReplacesCert(prevCrt))
} else {
logf("account key mismatch for previous cert; skipping ARI 'replaces' hint")
}
}
}
issueArgs := acmeCertIssueArgs{
cs: cs,
logf: logf,
traceACME: traceACME,
domain: domain,
opts: opts,
}
if e.shouldUseACMETLSALPN01(b, domain, previous, logf) {
issueArgs.challengeType = acmeChallengeTLSALPN01
pair, err := e.issueACMECert(ctx, b, ac, issueArgs)
if err == nil {
return pair, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if e.isBYOFunnelDomain(b, domain) {
// BYO domains have no working dns-01 path (control does not
// own the zone), so surface the tls-alpn-01 error instead of
// burning an ACME attempt on a guaranteed-to-fail fallback.
return nil, err
}
logf("acme: tls-alpn-01 failed; falling back to dns-01: %v", err)
}
issueArgs.challengeType = acmeChallengeDNS01
return e.issueACMECert(ctx, b, ac, issueArgs)
}
// ensureAccount returns a valid ACME account, registering one if
// needed. Locked so two first-time issuances don't both create one.
func (e *extension) ensureAccount(ctx context.Context, ac *xacme.Client, logf logger.Logf, traceACME func(any)) (*xacme.Account, error) {
e.accountMu.Lock()
defer e.accountMu.Unlock()
a, err := ac.GetReg(ctx, "" /* pre-RFC param */)
switch {
case err == nil:
logf("already had ACME account.")
return a, nil
case err == xacme.ErrNoAccount:
a, err = ac.Register(ctx, new(xacme.Account), xacme.AcceptTOS)
if err == xacme.ErrAccountAlreadyExists {
a, err = ac.GetReg(ctx, "" /* pre-RFC param */)
}
if err != nil {
return nil, fmt.Errorf("acme.Register: %w", err)
}
logf("registered ACME account.")
traceACME(a)
return a, nil
default:
return nil, fmt.Errorf("acme.GetReg: %w", err)
}
}
type acmeCertIssueArgs struct {
cs certStore // certificate and ACME account storage
logf logger.Logf // logs ACME progress and failures
traceACME func(any) // optional hook for logging ACME messages
domain string // certificate domain being issued
opts []xacme.OrderOption // ACME order options
challengeType acmeChallengeType // challenge type to fulfill
}
func (args acmeCertIssueArgs) baseDomain() string { return strings.TrimPrefix(args.domain, "*.") }
func (args acmeCertIssueArgs) isWildcard() bool { return isWildcardDomain(args.domain) }
func (e *extension) issueACMECert(ctx context.Context, b *ipnlocal.LocalBackend, ac *xacme.Client, args acmeCertIssueArgs) (ret *ipnlocal.TLSCertKeyPair, err error) {
if args.traceACME == nil {
args.traceACME = func(any) {}
}
switch args.challengeType {
case acmeChallengeTLSALPN01:
metricACMETLSALPN01Start.Add(1)
defer func() {
if err == nil {
metricACMETLSALPN01Success.Add(1)
} else {
metricACMETLSALPN01Failure.Add(1)
}
}()
case acmeChallengeDNS01:
metricACMEDNS01Start.Add(1)
defer func() {
if err == nil {
metricACMEDNS01Success.Add(1)
} else {
metricACMEDNS01Failure.Add(1)
}
}()
default:
return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType)
}
// For wildcards, we need to authorize both the wildcard and base domain.
var authzIDs []xacme.AuthzID
if args.isWildcard() {
authzIDs = []xacme.AuthzID{
{Type: "dns", Value: args.domain},
{Type: "dns", Value: args.baseDomain()},
}
} else {
authzIDs = []xacme.AuthzID{{Type: "dns", Value: args.domain}}
}
order, err := ac.AuthorizeOrder(ctx, authzIDs, args.opts...)
if err != nil {
return nil, err
}
args.traceACME(order)
for _, aurl := range order.AuthzURLs {
az, err := ac.GetAuthorization(ctx, aurl)
if err != nil {
return nil, err
}
args.traceACME(az)
switch args.challengeType {
case acmeChallengeTLSALPN01:
ch := challengeByType(az.Challenges, string(acmeChallengeTLSALPN01))
if ch == nil {
return nil, errors.New("tls-alpn-01 challenge not offered")
}
cert, err := ac.TLSALPN01ChallengeCert(ch.Token, az.Identifier.Value)
if err != nil {
return nil, fmt.Errorf("TLSALPN01ChallengeCert: %w", err)
}
cleanup := e.storeACMETLSALPNCert(az.Identifier.Value, &cert)
defer cleanup()
chal, err := ac.Accept(ctx, ch)
if err != nil {
return nil, fmt.Errorf("Accept: %v", err)
}
args.traceACME(chal)
case acmeChallengeDNS01:
if err := fulfillACMEDNS01Challenge(ctx, b, ac, az, args.logf, args.traceACME); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType)
}
}
orderURI := order.URI
order, err = ac.WaitOrder(ctx, orderURI)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if oe, ok := err.(*xacme.OrderError); ok {
args.logf("acme: WaitOrder: OrderError status %q", oe.Status)
} else {
args.logf("acme: WaitOrder error: %v", err)
}
return nil, err
}
args.traceACME(order)
certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
var privPEM bytes.Buffer
if err := encodeECDSAKey(&privPEM, certPrivKey); err != nil {
return nil, err
}
csr, err := certRequest(certPrivKey, args.domain, nil)
if err != nil {
return nil, err
}
args.logf("requesting cert...")
args.traceACME(csr)
der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
if err != nil {
return nil, fmt.Errorf("CreateOrder: %v", err)
}
args.logf("got cert")
var certPEM bytes.Buffer
for _, b := range der {
pb := &pem.Block{Type: "CERTIFICATE", Bytes: b}
if err := pem.Encode(&certPEM, pb); err != nil {
return nil, err
}
}
if err := args.cs.WriteTLSCertAndKey(args.domain, certPEM.Bytes(), privPEM.Bytes()); err != nil {
return nil, err
}
e.domainRenewed(args.domain)
return &ipnlocal.TLSCertKeyPair{CertPEM: certPEM.Bytes(), KeyPEM: privPEM.Bytes()}, nil
}
func fulfillACMEDNS01Challenge(ctx context.Context, b *ipnlocal.LocalBackend, ac *xacme.Client, az *xacme.Authorization, logf logger.Logf, traceACME func(any)) error {
for _, ch := range az.Challenges {
if ch.Type != string(acmeChallengeDNS01) {
continue
}
rec, err := ac.DNS01ChallengeRecord(ch.Token)
if err != nil {
return err
}
// For wildcards, the challenge is on the base domain.
// e.g., "*.node.ts.net" -> "_acme-challenge.node.ts.net"
key := "_acme-challenge." + strings.TrimPrefix(az.Identifier.Value, "*.")
// Do a best-effort lookup to see if we've already created this DNS name
// in a previous attempt. Don't burn too much time on it, though. Worst
// case we ask the server to create something that already exists.
var resolver net.Resolver
lookupCtx, lookupCancel := context.WithTimeout(ctx, 500*time.Millisecond)
txts, _ := resolver.LookupTXT(lookupCtx, key)
lookupCancel()
if slices.Contains(txts, rec) {
logf("TXT record already existed for %s", key)
} else {
logf("starting SetDNS call for %s...", key)
err = b.SetDNS(ctx, key, rec)
if err != nil {
return fmt.Errorf("SetDNS %q => %q: %w", key, rec, err)
}
logf("did SetDNS for %s", key)
}
chal, err := ac.Accept(ctx, ch)
if err != nil {
return fmt.Errorf("Accept: %v", err)
}
traceACME(chal)
return nil
}
return errors.New("dns-01 challenge not offered")
}
// validLookingCertDomain reports whether name looks like a valid domain
// name that we might be able to get a cert for.
//
// It's a light check primarily for double checking before it's used as
// part of a filesystem path. The actual validation happens in
// resolveCertDomain.
func validLookingCertDomain(name string) bool {
if name == "" ||
strings.Contains(name, "..") ||
strings.ContainsAny(name, ":/\\\x00") ||
!strings.Contains(name, ".") {
return false
}
// Only allow * as a wildcard prefix "*.domain.tld"
if rest, ok := strings.CutPrefix(name, "*."); ok {
if strings.Contains(rest, "*") || !strings.Contains(rest, ".") {
return false
}
} else if strings.Contains(name, "*") {
return false
}
return true
}
// resolveCertDomain validates a domain and returns the cert domain to use.
//
// - "node.ts.net" -> "node.ts.net" (exact CertDomain match)
// - "*.node.ts.net" -> "*.node.ts.net" (explicit wildcard, requires NodeAttrDNSSubdomainResolve)
// - "foo.com" -> "foo.com" (bring-your-own Funnel domain referenced by the
// local serve config; issued via tls-alpn-01 in getCertPEM)
//
// Subdomain requests like "app.node.ts.net" are rejected; callers should
// request "*.node.ts.net" explicitly for subdomain coverage.
func (e *extension) resolveCertDomain(b *ipnlocal.LocalBackend, domain string) (string, error) {
if domain == "" {
return "", errors.New("missing domain name")
}
// Read the netmap once to get both CertDomains and capabilities atomically.
nm := b.NetMapNoPeers()
if nm == nil {
return "", errors.New("no netmap available")
}
certDomains := nm.DNS.CertDomains
if len(certDomains) == 0 && !e.isBYOFunnelDomain(b, domain) {
return "", errors.New("your Tailscale account does not support getting TLS certs")
}
// Wildcard request like "*.node.ts.net".
if base, ok := strings.CutPrefix(domain, "*."); ok {
if !nm.AllCaps.Contains(nodecap.DNSSubdomainResolve) {
return "", fmt.Errorf("wildcard certificates are not enabled for this node")
}
if !slices.Contains(certDomains, base) {
return "", fmt.Errorf("invalid domain %q; wildcard certificates are not enabled for this domain", domain)
}
return domain, nil
}
// Exact CertDomain match.
if slices.Contains(certDomains, domain) {
return domain, nil
}
// Bring-your-own Funnel domain (e.g. "foo.com"). The serve config
// references the domain as a Funnel target on :443; cert acquisition
// happens via tls-alpn-01 in getCertPEM.
if e.isBYOFunnelDomain(b, domain) {
return domain, nil
}
return "", fmt.Errorf("invalid domain %q; must be one of %q", domain, certDomains)
}
// setCertPending sets or clears the in-flight ACME issuance state for
// domain and updates the [certPendingWarnable] to reflect the current
// set of pending domains.
func (e *extension) setCertPending(b *ipnlocal.LocalBackend, domain string, pending bool) {
e.pendingCertDomainsMu.Lock()
defer e.pendingCertDomainsMu.Unlock()
if pending {
e.pendingCertDomains.Make()
e.pendingCertDomains.Add(domain)
} else {
e.pendingCertDomains.Delete(domain)
}
if e.pendingCertDomains.Len() == 0 {
b.HealthTracker().SetHealthy(certPendingWarnable)
return
}
b.HealthTracker().SetUnhealthy(certPendingWarnable, health.Args{
health.ArgDomains: joinedPendingCertDomainsLocked(e.pendingCertDomains),
})
}
func joinedPendingCertDomainsLocked(s set.Set[string]) string {
ds := slicesx.MapKeys(s)
slices.Sort(ds)
return strings.Join(ds, ", ")
}
// parseCertificate returns the leaf certificate from the given
// TLSCertKeyPair's CertPEM.
func parseCertificate(kp *ipnlocal.TLSCertKeyPair) (*x509.Certificate, error) {
block, _ := pem.Decode(kp.CertPEM)
if block == nil {
return nil, fmt.Errorf("error parsing certificate PEM")
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("PEM block is %q, not a CERTIFICATE", block.Type)
}
return x509.ParseCertificate(block.Bytes)
}
+478
View File
@@ -0,0 +1,478 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package acme
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"tailscale.com/atomicfile"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/bakedroots"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/util/testenv"
"tailscale.com/version"
"tailscale.com/version/distro"
)
// certStore provides a way to perist and retrieve TLS certificates.
// As of 2023-02-01, we store certs in directories on disk everywhere
// except on Kubernetes, where we use the state store.
type certStore interface {
// Read returns the cert and key for domain, if they exist and are valid
// for now. If they're expired, it returns errCertExpired.
// If they don't exist, it returns ipn.ErrStateNotExist.
Read(domain string, now time.Time) (*ipnlocal.TLSCertKeyPair, error)
// ACMEKey returns the value previously stored via WriteACMEKey.
// It is a PEM encoded ECDSA key.
ACMEKey() ([]byte, error)
// WriteACMEKey stores the provided PEM encoded ECDSA key.
WriteACMEKey([]byte) error
// WriteTLSCertAndKey writes the cert and key for domain.
WriteTLSCertAndKey(domain string, cert, key []byte) error
}
var errCertExpired = errors.New("cert expired")
var testX509Roots *x509.CertPool // set non-nil by tests
// certDir returns (creating if needed) the directory in which cached
// cert keypairs are stored.
func certDir(b *ipnlocal.LocalBackend) (string, error) {
d := b.TailscaleVarRoot()
// As a workaround for Synology DSM6 not having a "var" directory, use the
// app's "etc" directory (on a small partition) to hold certs at least.
// See https://github.com/tailscale/tailscale/issues/4060#issuecomment-1186592251
if buildfeatures.HasSynology && d == "" && runtime.GOOS == "linux" && distro.Get() == distro.Synology && distro.DSMVersion() == 6 {
d = "/var/packages/Tailscale/etc" // base; we append "certs" below
}
if d == "" {
return "", errors.New("no TailscaleVarRoot")
}
full := filepath.Join(d, "certs")
if err := os.MkdirAll(full, 0700); err != nil {
return "", err
}
return full, nil
}
func (e *extension) getCertStore(b *ipnlocal.LocalBackend) (certStore, error) {
st := b.Sys().StateStore.Get()
switch st.(type) {
case *store.FileStore:
case *mem.Store:
default:
if hostinfo.GetEnvType() == hostinfo.Kubernetes {
// We're running in Kubernetes with a custom StateStore,
// use that instead of the cert directory.
// TODO(maisem): expand this to other environments?
return certStateStore{StateStore: st}, nil
}
}
dir, err := certDir(b)
if err != nil {
return nil, err
}
if testX509Roots != nil && !testenv.InTest() {
panic("use of test hook outside of tests")
}
return certFileStore{dir: dir, testRoots: testX509Roots}, nil
}
// certFileStore implements certStore by storing the cert & key files in
// the named directory.
type certFileStore struct {
dir string
// This field allows a test to override the CA root(s) for certificate
// verification. If nil the default system pool is used.
testRoots *x509.CertPool
}
const acmePEMName = "acme-account.key.pem"
func (f certFileStore) ACMEKey() ([]byte, error) {
pemName := filepath.Join(f.dir, acmePEMName)
v, err := os.ReadFile(pemName)
if err != nil {
if os.IsNotExist(err) {
return nil, ipn.ErrStateNotExist
}
return nil, err
}
return v, nil
}
func (f certFileStore) WriteACMEKey(b []byte) error {
pemName := filepath.Join(f.dir, acmePEMName)
return atomicfile.WriteFile(pemName, b, 0600)
}
func (f certFileStore) Read(domain string, now time.Time) (*ipnlocal.TLSCertKeyPair, error) {
certPEM, err := os.ReadFile(certFile(f.dir, domain))
if err != nil {
if os.IsNotExist(err) {
return nil, ipn.ErrStateNotExist
}
return nil, err
}
keyPEM, err := os.ReadFile(keyFile(f.dir, domain))
if err != nil {
if os.IsNotExist(err) {
return nil, ipn.ErrStateNotExist
}
return nil, err
}
if !validCertPEM(domain, keyPEM, certPEM, f.testRoots, now) {
return nil, errCertExpired
}
return &ipnlocal.TLSCertKeyPair{CertPEM: certPEM, KeyPEM: keyPEM, Cached: true}, nil
}
func (f certFileStore) WriteCert(domain string, cert []byte) error {
return atomicfile.WriteFile(certFile(f.dir, domain), cert, 0644)
}
func (f certFileStore) WriteKey(domain string, key []byte) error {
return atomicfile.WriteFile(keyFile(f.dir, domain), key, 0600)
}
func (f certFileStore) WriteTLSCertAndKey(domain string, cert, key []byte) error {
if err := f.WriteKey(domain, key); err != nil {
return err
}
return f.WriteCert(domain, cert)
}
// certStateStore implements certStore by storing the cert & key files
// in an ipn.StateStore.
type certStateStore struct {
ipn.StateStore
// This field allows a test to override the CA root(s) for certificate
// verification. If nil the default system pool is used.
testRoots *x509.CertPool
}
// TLSCertKeyReader is an interface implemented by state stores where it
// makes sense to read the TLS cert and key in a single operation that
// can be distinguished from generic state value reads. Currently this
// is only implemented by the kubestore.Store, which, in some cases,
// needs to read cert and key from a non-cached TLS Secret.
type TLSCertKeyReader interface {
ReadTLSCertAndKey(domain string) ([]byte, []byte, error)
}
// ARIReplacesAllower is optionally implemented by state stores to opt
// out of the ARI "replaces" hint on a per-domain basis at renewal time.
// When implemented and returning false, the renewal path submits a plain
// newOrder instead of claiming renewal exemption via "replaces".
//
// Used by the k8s cert-share store to prevent submitting a "replaces"
// claim that Let's Encrypt would reject because the current ACME
// account did not issue the previous cert (which can happen when a
// shared per-tailnet account key is adopted after a cert was already
// issued by a per-pod account). See #18251.
type ARIReplacesAllower interface {
// ShouldUseARIReplacesForRenewal reports whether the renewal order
// for domain should carry the ARI "replaces" hint. It is advisory:
// a non-nil error means eligibility could not be determined, not
// that the hint should be skipped, and callers fail open (use the
// hint) in that case.
ShouldUseARIReplacesForRenewal(domain string) (bool, error)
}
func (s certStateStore) Read(domain string, now time.Time) (*ipnlocal.TLSCertKeyPair, error) {
// If we're using a store that supports atomic reads, use that
if kr, ok := s.StateStore.(TLSCertKeyReader); ok {
cert, key, err := kr.ReadTLSCertAndKey(domain)
if err != nil {
return nil, err
}
if !validCertPEM(domain, key, cert, s.testRoots, now) {
return nil, errCertExpired
}
return &ipnlocal.TLSCertKeyPair{CertPEM: cert, KeyPEM: key, Cached: true}, nil
}
// Otherwise fall back to separate reads
certPEM, err := s.ReadState(ipn.StateKey(domain + ".crt"))
if err != nil {
return nil, err
}
keyPEM, err := s.ReadState(ipn.StateKey(domain + ".key"))
if err != nil {
return nil, err
}
if !validCertPEM(domain, keyPEM, certPEM, s.testRoots, now) {
return nil, errCertExpired
}
return &ipnlocal.TLSCertKeyPair{CertPEM: certPEM, KeyPEM: keyPEM, Cached: true}, nil
}
// ShouldUseARIReplacesForRenewal delegates to the underlying state store
// if it implements [ARIReplacesAllower]. Stores that don't opt in default
// to true (attempt "replaces"), preserving the pre-hook behaviour.
func (s certStateStore) ShouldUseARIReplacesForRenewal(domain string) (bool, error) {
if a, ok := s.StateStore.(ARIReplacesAllower); ok {
return a.ShouldUseARIReplacesForRenewal(domain)
}
return true, nil
}
func (s certStateStore) WriteCert(domain string, cert []byte) error {
return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".crt"), cert)
}
func (s certStateStore) WriteKey(domain string, key []byte) error {
return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".key"), key)
}
func (s certStateStore) ACMEKey() ([]byte, error) {
return s.ReadState(ipn.StateKey(acmePEMName))
}
func (s certStateStore) WriteACMEKey(key []byte) error {
return ipn.WriteState(s.StateStore, ipn.StateKey(acmePEMName), key)
}
// TLSCertKeyWriter is an interface implemented by state stores that can
// write the TLS cert and key in a single atomic operation. Currently
// this is only implemented by the kubestore.StoreKube.
type TLSCertKeyWriter interface {
WriteTLSCertAndKey(domain string, cert, key []byte) error
}
// WriteTLSCertAndKey writes the TLS cert and key for domain to the
// current LocalBackend's StateStore.
func (s certStateStore) WriteTLSCertAndKey(domain string, cert, key []byte) error {
// If we're using a store that supports atomic writes, use that.
if aw, ok := s.StateStore.(TLSCertKeyWriter); ok {
return aw.WriteTLSCertAndKey(domain, cert, key)
}
// Otherwise fall back to separate writes for cert and key.
if err := s.WriteKey(domain, key); err != nil {
return err
}
return s.WriteCert(domain, cert)
}
func keyFile(dir, domain string) string {
return filepath.Join(dir, strings.Replace(domain, "*.", "wildcard_.", 1)+".key")
}
func certFile(dir, domain string) string {
return filepath.Join(dir, strings.Replace(domain, "*.", "wildcard_.", 1)+".crt")
}
// getCertPEMCached returns a non-nil keyPair if a cached keypair for
// domain exists in the certStore that is valid at the provided now time.
//
// If the keypair is expired, it returns errCertExpired.
// If the keypair doesn't exist, it returns ipn.ErrStateNotExist.
func getCertPEMCached(cs certStore, domain string, now time.Time) (p *ipnlocal.TLSCertKeyPair, err error) {
if !validLookingCertDomain(domain) {
// Before we read files from disk using it, validate it's halfway
// reasonable looking.
return nil, fmt.Errorf("invalid domain %q", domain)
}
return cs.Read(domain, now)
}
// certRequest generates a CSR for the given domain and optional SANs.
func certRequest(key crypto.Signer, domain string, ext []pkix.Extension) ([]byte, error) {
dnsNames := []string{domain}
if base, ok := strings.CutPrefix(domain, "*."); ok {
// Wildcard cert must also include the base domain as a SAN.
// This is load-bearing: getCertPEMCached validates certs using
// the storage key (base domain), which only passes x509 verification
// if the base domain is in DNSNames.
dnsNames = append(dnsNames, base)
}
req := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: domain},
DNSNames: dnsNames,
ExtraExtensions: ext,
}
return x509.CreateCertificateRequest(rand.Reader, req, key)
}
func encodeECDSAKey(w io.Writer, key *ecdsa.PrivateKey) error {
b, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
pb := &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
return pem.Encode(w, pb)
}
// parsePrivateKey is a copy of x/crypto/acme's parsePrivateKey.
//
// Attempt to parse the given private key DER block. OpenSSL 0.9.8
// generates PKCS#1 private keys by default, while OpenSSL 1.0.0
// generates PKCS#8 keys. OpenSSL ecparam generates SEC1 EC private keys
// for ECDSA. We try all three.
//
// Inspired by parsePrivateKey in crypto/tls/tls.go.
func parsePrivateKey(der []byte) (crypto.Signer, error) {
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
switch key := key.(type) {
case *rsa.PrivateKey:
return key, nil
case *ecdsa.PrivateKey:
return key, nil
default:
return nil, errors.New("acme/autocert: unknown private key type in PKCS#8 wrapping")
}
}
if key, err := x509.ParseECPrivateKey(der); err == nil {
return key, nil
}
return nil, errors.New("acme/autocert: failed to parse private key")
}
func (e *extension) acmeKey(cs certStore) (crypto.Signer, error) {
// Lock so two callers don't both generate a key and race on the
// write.
e.accountMu.Lock()
defer e.accountMu.Unlock()
if v, err := cs.ACMEKey(); err == nil {
priv, _ := pem.Decode(v)
if priv == nil || !strings.Contains(priv.Type, "PRIVATE") {
return nil, errors.New("acme/autocert: invalid account key found in cache")
}
return parsePrivateKey(priv.Bytes)
} else if !errors.Is(err, ipn.ErrStateNotExist) {
return nil, err
}
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
var pemBuf bytes.Buffer
if err := encodeECDSAKey(&pemBuf, privKey); err != nil {
return nil, err
}
if err := cs.WriteACMEKey(pemBuf.Bytes()); err != nil {
return nil, err
}
return privKey, nil
}
func (e *extension) acmeClient(cs certStore) (*xacme.Client, error) {
key, err := e.acmeKey(cs)
if err != nil {
return nil, fmt.Errorf("acmeKey: %w", err)
}
// Note: if we add support for additional ACME providers (other than
// LetsEncrypt), we should make sure that they support ARI extension (see
// shouldStartDomainRenewalARI).
return &xacme.Client{
Key: key,
UserAgent: "tailscaled/" + version.Long(),
DirectoryURL: envknob.String("TS_DEBUG_ACME_DIRECTORY_URL"),
}, nil
}
// validCertPEM reports whether the given certificate is valid for
// domain at now.
//
// If roots != nil, it is used instead of the system root pool. This is
// meant to support testing; production code should pass roots == nil.
func validCertPEM(domain string, keyPEM, certPEM []byte, roots *x509.CertPool, now time.Time) bool {
if len(keyPEM) == 0 || len(certPEM) == 0 {
return false
}
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return false
}
var leaf *x509.Certificate
intermediates := x509.NewCertPool()
for i, certDER := range tlsCert.Certificate {
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return false
}
if i == 0 {
leaf = cert
} else {
intermediates.AddCert(cert)
}
}
return validateLeaf(leaf, intermediates, domain, now, roots)
}
// validateLeaf is a helper for [validCertPEM].
//
// If called with roots == nil, it will use the system root pool as well
// as the baked-in roots. If non-nil, only those roots are used.
func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain string, now time.Time, roots *x509.CertPool) bool {
if leaf == nil {
return false
}
_, err := leaf.Verify(x509.VerifyOptions{
DNSName: domain,
CurrentTime: now,
Roots: roots,
Intermediates: intermediates,
})
if err != nil && roots == nil {
// If validation failed and they specified nil for roots (meaning to use
// the system roots), then give it another chance to validate using the
// binary's baked-in roots (LetsEncrypt). See tailscale/tailscale#14690.
return validateLeaf(leaf, intermediates, domain, now, bakedroots.Get())
}
if err == nil {
return true
}
// When pointed at a non-prod ACME server, we don't expect to have the CA
// in our system or baked-in roots. Verify only throws UnknownAuthorityError
// after first checking the leaf cert's expiry, hostnames etc, so we know
// that the only reason for an error is to do with constructing a full chain.
// Allow this error so that cert caching still works in testing environments.
if errors.As(err, &x509.UnknownAuthorityError{}) {
acmeURL := envknob.String("TS_DEBUG_ACME_DIRECTORY_URL")
if !isDefaultDirectoryURL(acmeURL) {
return true
}
}
return false
}
func isDefaultDirectoryURL(u string) bool {
return u == "" || u == xacme.LetsEncryptURL
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package acme
import (
"net/http"
"strconv"
"time"
"tailscale.com/tsweb"
)
// certRateLimitedError is returned when the upstream ACME CA rate-limited
// the issuance. It exists so cert-fetching failures can surface as HTTP
// 429 responses via [tsweb.HTTPStatuser].
type certRateLimitedError struct {
retryAfter time.Duration
underlying error
}
func (e certRateLimitedError) Error() string { return e.underlying.Error() }
func (e certRateLimitedError) Unwrap() error { return e.underlying }
// HTTPStatus implements [tsweb.HTTPStatuser].
func (e certRateLimitedError) HTTPStatus() tsweb.HTTPError {
h := http.Header{}
if e.retryAfter > 0 {
h.Set("Retry-After", strconv.Itoa(int(e.retryAfter.Seconds())))
}
return tsweb.HTTPError{
Code: http.StatusTooManyRequests,
Msg: e.Error(),
Header: h,
}
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package acme
import (
"context"
"net"
"time"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/util/set"
)
// certRefreshInterval is how often the background loop iterates the set
// of applicable cert domains and pokes the renewal machinery. The loop
// is only started while there's at least one HTTPS Web entry in the
// ServeConfig, so this cadence doesn't tick on idle/mobile nodes.
const certRefreshInterval = time.Hour
// updateCertRefreshLoop starts or stops the background TLS cert refresh
// loop based on whether the backend currently has any HTTPS-serving
// hostname whose cert should be kept fresh. The loop runs only while:
//
// - the node is in [ipn.Running], and
// - the current [ipn.ServeConfig] has at least one HTTPS-serving entry.
//
// We deliberately don't keep an idle timer around on hosts that have no
// certs to maintain (e.g. mobile devices that never run Serve), so this
// is called whenever any of those inputs change: state transitions and
// ServeConfig reloads. The caller (in [ipn/ipnlocal]) holds b.mu when
// invoking this; we use our own e.mu for the refresh-loop bookkeeping.
func (e *extension) updateCertRefreshLoop(b *ipnlocal.LocalBackend, state ipn.State, sc ipn.ServeConfigView) {
shouldRun := state == ipn.Running && serveConfigUsesACMECerts(sc)
e.mu.Lock()
defer e.mu.Unlock()
switch {
case shouldRun && e.certRefreshCancel == nil:
ctx, cancel := context.WithCancel(context.Background())
e.certRefreshCancel = cancel
e.Go(func() { e.certRefreshLoop(ctx, b) })
case !shouldRun && e.certRefreshCancel != nil:
e.certRefreshCancel()
e.certRefreshCancel = nil
}
}
// certRefreshLoop periodically iterates the domains configured for
// Serve or Funnel HTTPS and calls GetCertPEM on each. The existing
// renewal machinery in getCertPEM decides whether anything needs to
// happen (ARI check or expiry-based fallback); the loop just ensures
// it runs even on nodes that see no inbound TLS traffic.
//
// The first iteration runs immediately so that a node coming back
// online with stale or absent certs starts ACME within seconds rather
// than waiting a full interval.
func (e *extension) certRefreshLoop(ctx context.Context, b *ipnlocal.LocalBackend) {
if envknob.IsCertShareReadOnlyMode() {
b.Logger()("cert refresh loop: cert-share read-only mode; loop is a no-op")
return
}
ticker, tickerCh := b.Clock().NewTicker(certRefreshInterval)
defer ticker.Stop()
for {
e.refreshApplicableCerts(ctx, b)
select {
case <-tickerCh:
case <-ctx.Done():
return
}
}
}
// refreshApplicableCerts is one iteration of the cert refresh loop.
//
// It enumerates the Serve/Funnel-configured HTTPS hostnames, keeps
// those that resolveCertDomain accepts (CertDomain, wildcard, or BYO
// Funnel domain), and calls [LocalBackend.GetCertPEM] for each. The
// renewal decision is delegated to the existing logic in getCertPEM.
func (e *extension) refreshApplicableCerts(ctx context.Context, b *ipnlocal.LocalBackend) {
sc := b.ServeConfig()
if !sc.Valid() {
return
}
want := set.Set[string]{}
consider := func(host string) {
if host == "" {
return
}
if _, err := e.resolveCertDomain(b, host); err != nil {
return
}
want.Add(host)
}
for hp := range sc.Webs() {
host, _, err := net.SplitHostPort(string(hp))
if err != nil {
continue
}
consider(host)
}
for _, tcp := range sc.TCPs() {
consider(tcp.TerminateTLS())
}
for _, svc := range sc.Services().All() {
for _, tcp := range svc.TCP().All() {
consider(tcp.TerminateTLS())
}
}
if want.Len() == 0 {
return
}
for d := range want {
e.Go(func() {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if _, err := e.getCertPEMWithValidity(ctx, b, d, 0); err != nil {
b.Logger()("cert refresh: %s: %v", d, err)
}
})
}
}
// serveConfigUsesACMECerts reports whether sc has any entry that
// causes tailscaled to obtain ACME-managed TLS certs: an HTTPS Web
// entry (background, foreground, or service) or a TCP handler with
// TerminateTLS set (`tailscale serve --tls-terminated-tcp`).
func serveConfigUsesACMECerts(sc ipn.ServeConfigView) bool {
if !sc.Valid() {
return false
}
for range sc.Webs() {
return true
}
for _, tcp := range sc.TCPs() {
if tcp.TerminateTLS() != "" {
return true
}
}
for _, svc := range sc.Services().All() {
for _, tcp := range svc.TCP().All() {
if tcp.TerminateTLS() != "" {
return true
}
}
}
return false
}
+5 -5
View File
@@ -1,10 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run gen.go
// The buildfeatures package contains boolean constants indicating which
// features were included in the binary (via build tags), for use in dead code
// elimination when using separate build tag protected files is impractical
// or undesirable.
// Package buildfeatures contains boolean constants indicating which features
// were included in the binary (via build tags), for use in dead code
// elimination when using separate build tag protected files is impractical or
// undesirable.
package buildfeatures
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_androidbin
package buildfeatures
// HasAndroidBin is whether the binary was built with support for modular feature "Support for running raw (non-GUI app) binaries on Android: netmon interface discovery under the app sandbox".
// Specifically, it's whether the binary was NOT built with the "ts_omit_androidbin" build tag.
// It's a const so it can be used for dead code elimination.
const HasAndroidBin = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_androidbin
package buildfeatures
// HasAndroidBin is whether the binary was built with support for modular feature "Support for running raw (non-GUI app) binaries on Android: netmon interface discovery under the app sandbox".
// Specifically, it's whether the binary was NOT built with the "ts_omit_androidbin" build tag.
// It's a const so it can be used for dead code elimination.
const HasAndroidBin = true
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_androiddns
package buildfeatures
// HasAndroidDNS is whether the binary was built with support for modular feature "DNS resolution via Android's dnsproxyd for standalone (non-app) binaries on Android".
// Specifically, it's whether the binary was NOT built with the "ts_omit_androiddns" build tag.
// It's a const so it can be used for dead code elimination.
const HasAndroidDNS = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_androiddns
package buildfeatures
// HasAndroidDNS is whether the binary was built with support for modular feature "DNS resolution via Android's dnsproxyd for standalone (non-app) binaries on Android".
// Specifically, it's whether the binary was NOT built with the "ts_omit_androiddns" build tag.
// It's a const so it can be used for dead code elimination.
const HasAndroidDNS = true
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_colorable
package buildfeatures
// HasColorable is whether the binary was built with support for modular feature "Colorized terminal output".
// Specifically, it's whether the binary was NOT built with the "ts_omit_colorable" build tag.
// It's a const so it can be used for dead code elimination.
const HasColorable = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_colorable
package buildfeatures
// HasColorable is whether the binary was built with support for modular feature "Colorized terminal output".
// Specifically, it's whether the binary was NOT built with the "ts_omit_colorable" build tag.
// It's a const so it can be used for dead code elimination.
const HasColorable = true
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_completion_scripts
package buildfeatures
// HasCompletionScripts is whether the binary was built with support for modular feature "embed CLI shell completion scripts".
// Specifically, it's whether the binary was NOT built with the "ts_omit_completion_scripts" build tag.
// It's a const so it can be used for dead code elimination.
const HasCompletionScripts = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_completion_scripts
package buildfeatures
// HasCompletionScripts is whether the binary was built with support for modular feature "embed CLI shell completion scripts".
// Specifically, it's whether the binary was NOT built with the "ts_omit_completion_scripts" build tag.
// It's a const so it can be used for dead code elimination.
const HasCompletionScripts = true
+13
View File
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_conn25
package buildfeatures
// HasConn25 is whether the binary was built with support for modular feature "Route traffic for configured domains through connector devices".
// Specifically, it's whether the binary was NOT built with the "ts_omit_conn25" build tag.
// It's a const so it can be used for dead code elimination.
const HasConn25 = false
+13
View File
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_conn25
package buildfeatures
// HasConn25 is whether the binary was built with support for modular feature "Route traffic for configured domains through connector devices".
// Specifically, it's whether the binary was NOT built with the "ts_omit_conn25" build tag.
// It's a const so it can be used for dead code elimination.
const HasConn25 = true
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_connreject
package buildfeatures
// HasConnReject is whether the binary was built with support for modular feature "Connection-rejection diagnostics (TSMP rejects, pendopen timeouts) exposed over debug-rejects LocalAPI and c2n endpoints".
// Specifically, it's whether the binary was NOT built with the "ts_omit_connreject" build tag.
// It's a const so it can be used for dead code elimination.
const HasConnReject = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_connreject
package buildfeatures
// HasConnReject is whether the binary was built with support for modular feature "Connection-rejection diagnostics (TSMP rejects, pendopen timeouts) exposed over debug-rejects LocalAPI and c2n endpoints".
// Specifically, it's whether the binary was NOT built with the "ts_omit_connreject" build tag.
// It's a const so it can be used for dead code elimination.
const HasConnReject = true
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_dnsresolvecache
package buildfeatures
// HasDNSResolveCache is whether the binary was built with support for modular feature "Persist successful DNS resolutions to disk for use on later boots with broken DNS".
// Specifically, it's whether the binary was NOT built with the "ts_omit_dnsresolvecache" build tag.
// It's a const so it can be used for dead code elimination.
const HasDNSResolveCache = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_dnsresolvecache
package buildfeatures
// HasDNSResolveCache is whether the binary was built with support for modular feature "Persist successful DNS resolutions to disk for use on later boots with broken DNS".
// Specifically, it's whether the binary was NOT built with the "ts_omit_dnsresolvecache" build tag.
// It's a const so it can be used for dead code elimination.
const HasDNSResolveCache = true
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_favorites
package buildfeatures
// HasFavorites is whether the binary was built with support for modular feature "Locally-pinned favorite devices, exit nodes, and services".
// Specifically, it's whether the binary was NOT built with the "ts_omit_favorites" build tag.
// It's a const so it can be used for dead code elimination.
const HasFavorites = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_favorites
package buildfeatures
// HasFavorites is whether the binary was built with support for modular feature "Locally-pinned favorite devices, exit nodes, and services".
// Specifically, it's whether the binary was NOT built with the "ts_omit_favorites" build tag.
// It's a const so it can be used for dead code elimination.
const HasFavorites = true
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_flashappliance
package buildfeatures
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' and 'pve-appliance' CLI commands for deploying Tailscale appliance images".
// Specifically, it's whether the binary was NOT built with the "ts_omit_flashappliance" build tag.
// It's a const so it can be used for dead code elimination.
const HasFlashAppliance = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_flashappliance
package buildfeatures
// HasFlashAppliance is whether the binary was built with support for modular feature "'tailscale configure flash-appliance' and 'pve-appliance' CLI commands for deploying Tailscale appliance images".
// Specifically, it's whether the binary was NOT built with the "ts_omit_flashappliance" build tag.
// It's a const so it can be used for dead code elimination.
const HasFlashAppliance = true
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+13
View File
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_ipnbus
package buildfeatures
// HasIPNBus is whether the binary was built with support for modular feature "IPN notification bus (watch-ipn-bus) support, used by GUIs, debugging, and nicer 'tailscale up' support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_ipnbus" build tag.
// It's a const so it can be used for dead code elimination.
const HasIPNBus = false
+13
View File
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_ipnbus
package buildfeatures
// HasIPNBus is whether the binary was built with support for modular feature "IPN notification bus (watch-ipn-bus) support, used by GUIs, debugging, and nicer 'tailscale up' support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_ipnbus" build tag.
// It's a const so it can be used for dead code elimination.
const HasIPNBus = true
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
-13
View File
@@ -1,13 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_lazywg
package buildfeatures
// HasLazyWG is whether the binary was built with support for modular feature "Lazy WireGuard configuration for memory-constrained devices with large netmaps".
// Specifically, it's whether the binary was NOT built with the "ts_omit_lazywg" build tag.
// It's a const so it can be used for dead code elimination.
const HasLazyWG = false
-13
View File
@@ -1,13 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_lazywg
package buildfeatures
// HasLazyWG is whether the binary was built with support for modular feature "Lazy WireGuard configuration for memory-constrained devices with large netmaps".
// Specifically, it's whether the binary was NOT built with the "ts_omit_lazywg" build tag.
// It's a const so it can be used for dead code elimination.
const HasLazyWG = true
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
Loaded 100 of 2146 files, more files were not shown because too many files have changed in this diff. Show more