Update
This commit is contained in:
1 parent
3530afb854
commit
6903fcff27
2146 files changed
+112256
-190703
No files matched your search
+442
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
+13
@@ -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
|
||||
+13
@@ -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
|
||||
+13
@@ -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
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
+13
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Generated
Vendored
+13
@@ -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
|
||||
+13
@@ -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
@@ -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
@@ -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
|
||||
+13
@@ -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
|
||||
+13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
+13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
+13
@@ -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
|
||||
+13
@@ -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
|
||||
+13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Generated
Vendored
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user