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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user