ipn/ipnlocal, cmd/tailscale/cli: auto-renew TLS certs and warn while pending

The Tailscale daemon only refreshed TLS certs as a side effect of inbound
TLS handshakes or "tailscale cert" CLI calls. A node that doesn't see
inbound traffic during the renewal window silently rolls past expiry.

Add a once-per-hour background loop on LocalBackend that enumerates Serve
and Funnel HTTPS hostnames (filtered against the netmap's CertDomains so
we don't poke ACME for other nodes' service hostnames) and calls the
existing GetCertPEM path. The renewal decision (ARI window, then 2/3
expiry fallback) is unchanged; the loop just guarantees it runs.

For visibility during initial issuance or restart with a long-expired
cached cert, add a "tls-cert-pending" health Warnable that's set while
ACME is in flight and no usable cached cert exists. Async renewal of a
still-valid cert intentionally doesn't fire it. And then make the CLI "cert"
subcommand print out a warning if it's blocking due to a cert fetch
in flight, using that health info.

Fixes #19911
Fixes #19912

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I144e46c40e957b2e879587decace32a523a6eade
This commit is contained in:
Brad Fitzpatrick
2026-06-01 16:31:54 -07:00
committed by Brad Fitzpatrick
parent 92bfda580c
commit a6ab7efa4f
7 changed files with 457 additions and 0 deletions
+46
View File
@@ -23,7 +23,10 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"software.sslmate.com/src/go-pkcs12"
"tailscale.com/atomicfile"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/tsconst"
"tailscale.com/version"
)
@@ -112,6 +115,11 @@ func runCert(ctx context.Context, args []string) error {
certArgs.certFile = fileBase + ".crt"
certArgs.keyFile = fileBase + ".key"
}
if buildfeatures.HasHealth {
watchCtx, cancel := context.WithCancel(ctx)
defer cancel()
go watchCertPendingHealth(watchCtx, domain)
}
certPEM, keyPEM, err := localClient.CertPairWithValidity(ctx, domain, certArgs.minValidity)
if err != nil {
return err
@@ -167,6 +175,44 @@ func runCert(ctx context.Context, args []string) error {
return nil
}
// watchCertPendingHealth subscribes to the IPN bus and prints the
// [tsconst.HealthWarnableTLSCertPending] warning to stderr if it appears
// for domain while a cert fetch is in flight. It returns once it has
// printed the warning or ctx is done.
//
// Subscription is delayed 1 second so we don't print anything when the
// daemon returns a cached cert quickly.
func watchCertPendingHealth(ctx context.Context, domain string) {
select {
case <-time.After(1 * time.Second):
case <-ctx.Done():
return
}
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialHealthState|ipn.NotifyNoNetMap)
if err != nil {
return
}
defer watcher.Close()
for {
n, err := watcher.Next()
if err != nil {
return
}
if n.Health == nil {
continue
}
ws, ok := n.Health.Warnings[tsconst.HealthWarnableTLSCertPending]
if !ok {
continue
}
if !strings.Contains(ws.Args[health.ArgDomains], domain) {
continue
}
fmt.Fprintf(os.Stderr, "%s: %s\n", ws.Title, ws.Text)
return
}
}
func writeIfChanged(filename string, contents []byte, mode os.FileMode) (changed bool, err error) {
if filename == "-" {
Stdout.Write(contents)