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

No files matched your search

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