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
+67
View File
@@ -4,6 +4,7 @@
package main
import (
"context"
"crypto/tls"
"flag"
"log"
@@ -12,6 +13,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
@@ -29,6 +31,15 @@ func envOr(key, fallback string) string {
return fallback
}
func envInt(key string, fallback int) int {
if value := os.Getenv(key); value != "" {
if n, err := strconv.Atoi(value); err == nil {
return n
}
}
return fallback
}
func defaultDataDir() string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "tunneld")
@@ -49,6 +60,15 @@ func main() {
dataPath := flag.String("data", envOr("TUNNELD_DATA", dataDefault), "tunnel store file (created 0600)")
caFile := flag.String("ca-file", envOr("LOCAL_CA_FILE", ""), "write the local CA certificate here for clients to trust")
adminToken := flag.String("admin-token", envOr("ADMIN_TOKEN", ""), "bearer token for the admin dashboard at / and /api/admin/overview; empty disables the admin API")
acmeDirectory := flag.String("acme-directory", envOr("ACME_DIRECTORY_URL", "https://acme-v02.api.letsencrypt.org/directory"), "ACME directory URL")
acmeEmail := flag.String("acme-email", envOr("ACME_EMAIL", ""), "contact email registered with the ACME account")
acmeCABundle := flag.String("acme-ca-bundle", envOr("ACME_CA_BUNDLE", ""), "extra CA roots for the ACME server's TLS (Pebble)")
acmePropagation := flag.Int("acme-propagation-seconds", envInt("ACME_PROPAGATION_TIMEOUT_SECONDS", 60), "wait this long for challenge TXT records to propagate; 0 skips the check")
dnsProvider := flag.String("dns-provider", envOr("DNS_PROVIDER", ""), `challenge DNS: "porkbun" (default when its keys are set) or "challtestsrv" (Pebble)`)
porkbunKey := flag.String("porkbun-api-key", envOr("PORKBUN_API_KEY", ""), "Porkbun API key for DNS-01 challenges")
porkbunSecret := flag.String("porkbun-secret-api-key", envOr("PORKBUN_SECRET_API_KEY", ""), "Porkbun secret API key")
dnsZone := flag.String("dns-zone", envOr("DNS_ZONE", ""), "zone hosting the challenge records (default: derived from the domain)")
challtestsrvURL := flag.String("challtestsrv-url", envOr("CHALLTESTSRV_URL", ""), "pebble-challtestsrv management URL for DNS_PROVIDER=challtestsrv")
flag.Parse()
if *caFile == "" {
@@ -75,6 +95,46 @@ func main() {
}
hub := server.NewHub(store)
// Certificate issuance: ACME (DNS-01) when a challenge DNS provider is
// configured, the built-in local CA otherwise.
issuerName := "local"
var issuer server.Issuer = server.NewLocalIssuer(store)
var dns server.DNSProvider
switch {
case *dnsProvider == "challtestsrv":
dns = &server.ChallengeTestSrvDNS{BaseURL: *challtestsrvURL}
case *dnsProvider == "porkbun", *dnsProvider == "" && *porkbunKey != "" && *porkbunSecret != "":
if *porkbunKey == "" || *porkbunSecret == "" {
log.Fatal("DNS_PROVIDER=porkbun needs PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY")
}
zone := *dnsZone
if zone == "" {
zone = server.DNSZone(*domain)
}
log.Printf("dns: Porkbun zone %s", zone)
dns = &server.PorkbunDNS{APIKey: *porkbunKey, SecretKey: *porkbunSecret, Zone: zone}
case *dnsProvider != "":
log.Fatalf("unknown DNS_PROVIDER %q (want porkbun or challtestsrv)", *dnsProvider)
}
if dns != nil {
acme, err := server.NewACMEIssuer(store, server.ACMEOptions{
DirectoryURL: *acmeDirectory,
Email: *acmeEmail,
CABundleFile: *acmeCABundle,
DNS: dns,
PropagationTimeout: time.Duration(*acmePropagation) * time.Second,
})
if err != nil {
log.Fatalf("acme issuer: %v", err)
}
issuer = acme
issuerName = *acmeDirectory
log.Printf("certificate issuer: ACME %s", *acmeDirectory)
} else {
log.Printf("certificate issuer: local CA (set PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY to use ACME)")
}
api := &server.API{
Store: store,
Hub: hub,
@@ -82,6 +142,8 @@ func main() {
AdminToken: *adminToken,
StartedAt: time.Now(),
Version: version,
Issuer: issuer,
IssuerName: issuerName,
}
if *adminToken == "" {
log.Printf("admin dashboard: disabled (set ADMIN_TOKEN to enable it at /)")
@@ -89,6 +151,10 @@ func main() {
log.Printf("admin dashboard: enabled at /")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
api.StartRenewal(ctx)
// Public TLS listener: SNI routing, with API terminations handed to the
// shared HTTP server.
ln, err := net.Listen("tcp", *tlsListen)
@@ -133,6 +199,7 @@ func main() {
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
sig := <-signals
log.Printf("received %s; draining bridges", sig)
cancel()
hub.DrainAll()
ln.Close()
if httpLn != nil {