Files
TZGyn 437a2e447e Issue real certificates via ACME DNS-01
Tunnels now get a per-tunnel certificate from the configured ACME CA
(Let's Encrypt by default) with DNS-01 through Porkbun, keeping the
client's CSR and key. Issuance is asynchronous (issuing/ready/failed);
the account persists in the store, renewals run in a 30-day window with
retries, and a failed renewal keeps the old certificate serving.
Switching from the local CA reissues existing tunnels automatically,
and the admin dashboard shows the issuer and certificate state.

Verified against Pebble and Let's Encrypt staging.
2026-10-25 23:33:52 +08:00

71 lines
1.8 KiB
Go

package server
import (
"context"
"log"
"time"
)
// renewalWindow re-issues certificates this long before they expire.
const renewalWindow = 30 * 24 * time.Hour
// StartRenewal keeps certificates fresh: it re-issues those close to expiry,
// retries failures, recovers issuances interrupted by a restart, and migrates
// certificates from a previous issuer (say the local CA) once a real issuer
// is configured. It runs at startup and every 6 hours.
func (a *API) StartRenewal(ctx context.Context) {
go func() {
a.renewDue(true)
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.renewDue(false)
}
}
}()
}
func (a *API) renewDue(firstRun bool) {
current := a.issuerName()
for _, tunnel := range a.Store.Tunnels() {
cert := tunnel.Certificate
if cert == nil || cert.CSR == "" {
continue
}
switch {
case cert.State == "issuing":
// An in-flight issuance cannot survive a restart; retry on the
// first pass after startup.
if firstRun {
a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID)
}
case certReady(cert):
needs := normalizedIssuer(cert.Issuer) != current
if !needs && cert.Expiry != "" {
if expiry, err := time.Parse(time.RFC3339, cert.Expiry); err == nil {
needs = time.Until(expiry) < renewalWindow
}
}
if needs {
log.Printf("renewing certificate for %s (issuer %q, expires %s)", tunnel.Hostname, cert.Issuer, cert.Expiry)
a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID)
}
case cert.State == "failed":
a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID)
}
}
}
// normalizedIssuer treats certificates from before the issuer was recorded
// as local-CA certificates.
func normalizedIssuer(issuer string) string {
if issuer == "" {
return "local"
}
return issuer
}