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
+42
View File
@@ -0,0 +1,42 @@
package server
import (
"context"
"fmt"
"strings"
)
// DNSProvider manages the TXT records ACME DNS-01 challenges need.
type DNSProvider interface {
// CreateTXT adds a TXT record with the given value at name.
CreateTXT(ctx context.Context, name, value string) error
// DeleteTXT removes every TXT record at name (best effort cleanup).
DeleteTXT(ctx context.Context, name string) error
}
// challengeRecordName is the TXT record name a hostname's DNS-01 challenge
// lives at; the wildcard of a hostname shares the same record.
func challengeRecordName(hostname string) string {
return "_acme-challenge." + hostname
}
// relativeRecordName strips the zone suffix from a record name, as DNS
// provider APIs expect names relative to the zone.
func relativeRecordName(name, zone string) (string, error) {
suffix := "." + zone
if !strings.HasSuffix(name, suffix) {
return "", fmt.Errorf("record %q is not under zone %q", name, zone)
}
return strings.TrimSuffix(name, suffix), nil
}
// dnsZone guesses the registrable zone that hosts the tunnel hostnames:
// the domain minus its first label when it has three or more (tunnel.tzgyn.dev
// is served by the tzgyn.dev zone). Override with DNS_ZONE when wrong.
func DNSZone(domain string) string {
labels := strings.Split(domain, ".")
if len(labels) >= 3 {
return strings.Join(labels[1:], ".")
}
return domain
}