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.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user