f5eac39ea ("feature/acme, ipn/ipnlocal: start moving ACME/cert state
into an extension") started to move the cert code into feature/acme
but was meant as a baby step.
This goes further, moving almost everything, leaving only some hooks
in ipnlocal.
When we later move "serve" support out to feature/serve, this will
look a bit different in that the hooks currently in ipnlocal will move
to feature/serve (cert support already depends on serve).
As part of this, cert-related tests move to feaure/acme too, which
means some test infra from ipnlocal now moves to shared ipnlocaltest.
(it's not big at the moment, but I imagine it growing)
Updates #12614
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9ea89aa9754f12d54b81751b6bd830f2664241ff
251 lines
8.4 KiB
Go
251 lines
8.4 KiB
Go
// 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"
|
|
"tailscale.com/tsconst"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/clientmetric"
|
|
"tailscale.com/util/set"
|
|
)
|
|
|
|
// featureName is the name of the feature implemented by this package.
|
|
const featureName = "acme"
|
|
|
|
func init() {
|
|
feature.Register(featureName)
|
|
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
|
|
|
|
// acmeMu serializes ACME operations so concurrent requests for
|
|
// certs don't slam ACME. The first goroutine through populates the
|
|
// on-disk cache and the rest reuse it.
|
|
acmeMu syncs.Mutex
|
|
|
|
// renewMu guards renewCertAt.
|
|
// Lock order: acmeMu before renewMu.
|
|
renewMu syncs.Mutex
|
|
renewCertAt map[string]time.Time // lazily initialized under renewMu
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return e.getCertPEMWithValidity(ctx, b, domain, minValidity)
|
|
}
|
|
|
|
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]
|
|
},
|
|
})
|