feature/acme, ipn/ipnlocal: start moving ACME/cert state into an extension

The ACME serialization mutex (acmeMu) was a package-level global, and
several ACME-related fields lived on LocalBackend even though the
cert code is conditional and not linked into every binary. With
multiple tsnet.Servers in one process (each its own LocalBackend),
a process-wide acmeMu also serialized unrelated backends.

Introduce a new feature/acme extension that owns the per-LocalBackend
ACME/cert state in an ipnlocal.CertState value:

  - acmeMu, renewMu, renewCertAt (previously package globals)
  - pendingACMETLSALPNCerts, pendingCertDomains{,Mu},
    getCertForTest, certRefreshCancel (previously LocalBackend
    fields, only meaningful when ACME was compiled in)

ipnlocal/cert.go now reaches the state through b.certState(), which
is routed by a feature.Hook installed at init by feature/acme. The
CertState type lives in ipnlocal so cert.go can access its fields
directly without a method explosion; the extension in feature/acme
constructs and owns it.

This is a baby step. The end goal is for the entire cert/ACME code
to live in feature/acme, with ipnlocal only retaining whatever thin
hooks the rest of LocalBackend needs to call into it. The current
split (CertState and most of cert.go in ipnlocal, extension wrapper
in feature/acme) is a deliberately temporary middle ground that
keeps this PR small while making the next moves mechanical.

The package is named feature/acme to match the existing HasACME /
ts_omit_acme naming. condregister/maybe_acme.go wires it in for
non-js builds.

Updates #12614
Updates #20248
Updates #20249

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I520909f24ad11a9622ef33c2290fe36ad44d6f71
This commit is contained in:
Brad Fitzpatrick
2026-06-26 09:48:24 -07:00
committed by Brad Fitzpatrick
parent 8379d5955f
commit f5eac39ea7
8 changed files with 264 additions and 76 deletions
+14 -32
View File
@@ -9,7 +9,6 @@ import (
"bufio"
"cmp"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
@@ -272,13 +271,6 @@ type LocalBackend struct {
// is never called.
getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn))
// 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] // "foo.bar.com" => challenge cert
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool] // TODO(nickkhyl): move to nodeBackend
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool] // TODO(nickkhyl): move to nodeBackend
shouldInterceptVIPServicesTCPPortAtomic syncs.AtomicValue[func(netip.AddrPort) bool] // TODO(nickkhyl): move to nodeBackend
@@ -454,18 +446,6 @@ type LocalBackend struct {
// (sending false).
needsCaptiveDetection chan bool
// certRefreshCancel cancels the background TLS cert refresh loop that
// periodically pokes [LocalBackend.GetCertPEM] so renewals happen on
// idle nodes. It is protected by mu and is non-nil while the loop is
// running.
certRefreshCancel context.CancelFunc
// 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]
// overrideAlwaysOn is whether [pkey.AlwaysOn] is overridden by the user
// and should have no impact on the WantRunning state until the policy changes,
// or the user re-connects manually, switches to a different profile, etc.
@@ -495,10 +475,6 @@ type LocalBackend struct {
// bind the node identity to this device.
hardwareAttested atomic.Bool
// getCertForTest is used to retrieve TLS certificates in tests.
// See [LocalBackend.ConfigureCertsForTest].
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
// existsPendingAuthReconfig tracks if a goroutine is waiting to
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
// It is used to prevent goroutines from piling up to do the same
@@ -1361,9 +1337,11 @@ func (b *LocalBackend) Shutdown() {
b.captiveCancel()
}
if buildfeatures.HasACME && b.certRefreshCancel != nil {
b.certRefreshCancel()
b.certRefreshCancel = nil
if buildfeatures.HasACME {
if state := b.certState(); state != nil && state.certRefreshCancel != nil {
state.certRefreshCancel()
state.certRefreshCancel = nil
}
}
b.stopReconnectTimerLocked()
@@ -7571,18 +7549,22 @@ func (b *LocalBackend) updateCertRefreshLoopLocked() {
if !buildfeatures.HasACME {
return
}
state := b.certState()
if state == nil {
return
}
shouldRun := hookCertRefreshLoop.IsSet() &&
b.state == ipn.Running &&
serveConfigUsesACMECerts(b.serveConfig)
switch {
case shouldRun && b.certRefreshCancel == nil:
case shouldRun && state.certRefreshCancel == nil:
ctx, cancel := context.WithCancel(b.ctx)
b.certRefreshCancel = cancel
state.certRefreshCancel = cancel
b.goTracker.Go(func() { hookCertRefreshLoop.Get()(b, ctx) })
case !shouldRun && b.certRefreshCancel != nil:
b.certRefreshCancel()
b.certRefreshCancel = nil
case !shouldRun && state.certRefreshCancel != nil:
state.certRefreshCancel()
state.certRefreshCancel = nil
}
}