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:
2026-10-25 23:33:52 +08:00
parent 05d326f47f
commit 437a2e447e
25 changed files with 1491 additions and 79 deletions
+40 -10
View File
@@ -12,14 +12,23 @@ import (
"time"
)
// storedCert is one issued certificate. With the local issuer, issuance is
// synchronous, so a bound CSR always has its certificate populated.
// storedCert is one certificate for a tunnel. Issuance is asynchronous with
// ACME: the cert starts as "issuing", and either becomes ready (Certificate
// set) or failed with a reason.
type storedCert struct {
ID string `json:"id"`
CSR string `json:"csr"`
Certificate string `json:"certificate"`
Chain string `json:"chain"`
Expiry string `json:"expiry"`
State string `json:"state,omitempty"` // "issuing" | "failed"; empty or Certificate set means ready
Reason string `json:"reason,omitempty"` // when State is "failed"
Issuer string `json:"issuer,omitempty"` // "local" or the ACME directory URL
Certificate string `json:"certificate,omitempty"`
Chain string `json:"chain,omitempty"`
Expiry string `json:"expiry,omitempty"`
}
// certReady reports whether the certificate can serve TLS now.
func certReady(cert *storedCert) bool {
return cert != nil && cert.Certificate != ""
}
type storedTunnel struct {
@@ -33,11 +42,14 @@ type storedTunnel struct {
// storeData is the on-disk shape. The CA key is a secret, so the file is
// written 0600.
type storeData struct {
CAKeyPEM string `json:"caKeyPem"`
CACertPEM string `json:"caCertPEM"`
ServerKey string `json:"serverKey"`
ServerCrt string `json:"serverCrt"`
Tunnels map[string]storedTunnel `json:"tunnels"`
CAKeyPEM string `json:"caKeyPem"`
CACertPEM string `json:"caCertPEM"`
ServerKey string `json:"serverKey"`
ServerCrt string `json:"serverCrt"`
ACMEKey string `json:"acmeKey,omitempty"`
ACMEAccountURL string `json:"acmeAccountUrl,omitempty"`
ACMEDirectory string `json:"acmeDirectory,omitempty"`
Tunnels map[string]storedTunnel `json:"tunnels"`
}
// Store is a JSON-file-backed tunnel store: adequate for a single-node
@@ -170,6 +182,24 @@ func (s *Store) bindCertificate(id string, cert storedCert) error {
return s.saveLocked()
}
// acmeAccount returns the persisted ACME account key (PEM), account URL, and
// the directory they belong to; any of them may be empty before first use.
func (s *Store) acmeAccount() (keyPEM, accountURL, directory string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.ACMEKey, s.data.ACMEAccountURL, s.data.ACMEDirectory
}
// setACMEAccount persists the ACME account key, URL, and directory.
func (s *Store) setACMEAccount(keyPEM, accountURL, directory string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.ACMEKey = keyPEM
s.data.ACMEAccountURL = accountURL
s.data.ACMEDirectory = directory
return s.saveLocked()
}
// remove deletes the tunnel.
func (s *Store) remove(id string) error {
s.mu.Lock()