Files
tunnel/internal/server/issuer.go
T
TZGyn 437a2e447e 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.
2026-10-25 23:33:52 +08:00

392 lines
12 KiB
Go

package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/acme"
)
// IssuedCert is a certificate produced for a tunnel's CSR.
type IssuedCert struct {
Certificate string // leaf PEM
Chain string // issuer chain PEM
Expiry time.Time
}
// Issuer turns a tunnel's CSR into a certificate. The local CA is instant;
// ACME takes a DNS-01 round trip, so callers run those asynchronously.
type Issuer interface {
Instant() bool
Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error)
}
// ---- local CA issuer (development and self-hosting without ACME).
type localIssuer struct{ store *Store }
// NewLocalIssuer signs tunnel CSRs with the store's built-in CA.
func NewLocalIssuer(store *Store) Issuer { return &localIssuer{store: store} }
func (l *localIssuer) Instant() bool { return true }
func (l *localIssuer) Issue(_ context.Context, csrPEM, hostname string) (IssuedCert, error) {
ca, err := l.store.ca()
if err != nil {
return IssuedCert{}, err
}
certPEM, chainPEM, expiry, err := ca.signCSR(csrPEM, hostname)
if err != nil {
return IssuedCert{}, err
}
return IssuedCert{Certificate: string(certPEM), Chain: string(chainPEM), Expiry: expiry}, nil
}
// ---- ACME issuer (Let's Encrypt, ZeroSSL, Pebble) with DNS-01 challenges.
type ACMEOptions struct {
DirectoryURL string
Email string
CABundleFile string // extra roots for the ACME server's TLS (Pebble)
DNS DNSProvider
// PropagationTimeout waits until public resolvers see the challenge
// records; 0 skips the check (used with pebble-challtestsrv).
PropagationTimeout time.Duration
}
type acmeIssuer struct {
store *Store
opts ACMEOptions
sem chan struct{} // bounds concurrent ACME orders
accountMu sync.Mutex // serializes first-use account registration
}
// NewACMEIssuer returns an issuer that orders certificates over ACME and
// answers DNS-01 challenges through the given DNS provider.
func NewACMEIssuer(store *Store, opts ACMEOptions) (Issuer, error) {
if opts.DirectoryURL == "" {
return nil, errors.New("acme: directory URL is required")
}
if opts.DNS == nil {
return nil, errors.New("acme: a DNS provider is required")
}
return &acmeIssuer{store: store, opts: opts, sem: make(chan struct{}, 2)}, nil
}
func (a *acmeIssuer) Instant() bool { return false }
// client returns an ACME client with the persisted account (registering one
// on first use).
func (a *acmeIssuer) client(ctx context.Context) (*acme.Client, error) {
a.accountMu.Lock()
defer a.accountMu.Unlock()
keyPEM, accountURL, directory := a.store.acmeAccount()
storedKey, storedURL, storedDirectory := keyPEM, accountURL, directory
if directory != a.opts.DirectoryURL {
// The persisted account belongs to another ACME server (for example
// staging); register it with this directory instead.
accountURL = ""
}
var key *ecdsa.PrivateKey
if keyPEM != "" {
block, _ := pem.Decode([]byte(keyPEM))
if block == nil {
return nil, errors.New("acme: unreadable account key")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("acme: account key: %w", err)
}
ec, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, errors.New("acme: account key is not ECDSA")
}
key = ec
} else {
generated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
key = generated
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, err
}
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
}
httpClient := &http.Client{Timeout: 60 * time.Second}
if a.opts.CABundleFile != "" {
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
bundle, err := os.ReadFile(a.opts.CABundleFile)
if err != nil {
return nil, fmt.Errorf("acme: CA bundle: %w", err)
}
if !pool.AppendCertsFromPEM(bundle) {
return nil, errors.New("acme: CA bundle has no certificates")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{RootCAs: pool}
httpClient.Transport = transport
}
client := &acme.Client{Key: key, DirectoryURL: a.opts.DirectoryURL, HTTPClient: httpClient}
if accountURL != "" {
// Reuse the persisted account without a GetAccount round trip.
client.KID = acme.KeyID(accountURL)
}
if accountURL == "" {
account := &acme.Account{}
if a.opts.Email != "" {
account.Contact = []string{"mailto:" + a.opts.Email}
}
registered, err := client.Register(ctx, account, acme.AcceptTOS)
switch {
case errors.Is(err, acme.ErrAccountAlreadyExists):
// The key is already registered with this CA; Register cached the
// account URL in client.KID.
accountURL = string(client.KID)
case err != nil:
return nil, fmt.Errorf("acme: register account: %w", err)
default:
accountURL = registered.URI
}
}
if keyPEM != storedKey || accountURL != storedURL || a.opts.DirectoryURL != storedDirectory {
if err := a.store.setACMEAccount(keyPEM, accountURL, a.opts.DirectoryURL); err != nil {
return nil, err
}
}
return client, nil
}
// Issue orders a certificate for the tunnel's names (hostname and, when the
// CSR asks for it, its wildcard) using one DNS-01 challenge record shared by
// both, then returns the certificate for the client's key.
func (a *acmeIssuer) Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error) {
csr, err := parseCSR(csrPEM, hostname)
if err != nil {
return IssuedCert{}, err
}
select {
case a.sem <- struct{}{}:
case <-ctx.Done():
return IssuedCert{}, ctx.Err()
}
defer func() { <-a.sem }()
client, err := a.client(ctx)
if err != nil {
return IssuedCert{}, err
}
ids := []acme.AuthzID{{Type: "dns", Value: hostname}}
for _, name := range csr.DNSNames {
if name == "*."+hostname {
ids = append(ids, acme.AuthzID{Type: "dns", Value: name})
}
}
order, err := client.AuthorizeOrder(ctx, ids)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: authorize order: %w", err)
}
if order.URI == "" || order.FinalizeURL == "" {
return IssuedCert{}, fmt.Errorf("acme: order is missing its URI or finalize URL (status %q)", order.Status)
}
record := challengeRecordName(hostname)
var values []string
created := false
defer func() {
if !created {
return
}
// Best-effort cleanup, even when the attempt failed or the context is
// done; a leftover record would block the next attempt.
cleanCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := a.opts.DNS.DeleteTXT(cleanCtx, record); err != nil {
log.Printf("acme: cleanup TXT %s: %v", record, err)
}
}()
for _, authzURL := range order.AuthzURLs {
authz, err := client.GetAuthorization(ctx, authzURL)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: read authorization: %w", err)
}
if authz.Status == acme.StatusValid {
continue
}
if authz.Status != acme.StatusPending {
return IssuedCert{}, authzFailure(authz)
}
challenge := dns01Challenge(authz)
if challenge == nil {
return IssuedCert{}, fmt.Errorf("acme: no dns-01 challenge for %s", authz.Identifier.Value)
}
value, err := client.DNS01ChallengeRecord(challenge.Token)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: challenge record: %w", err)
}
if err := a.opts.DNS.CreateTXT(ctx, record, value); err != nil {
return IssuedCert{}, fmt.Errorf("dns: create %s: %w", record, err)
}
created = true
values = append(values, value)
}
if a.opts.PropagationTimeout > 0 {
if err := waitForTXT(ctx, record, values, a.opts.PropagationTimeout); err != nil {
return IssuedCert{}, err
}
}
for _, authzURL := range order.AuthzURLs {
authz, err := client.GetAuthorization(ctx, authzURL)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: read authorization: %w", err)
}
if authz.Status == acme.StatusValid {
continue
}
if authz.Status != acme.StatusPending {
return IssuedCert{}, authzFailure(authz)
}
challenge := dns01Challenge(authz)
if challenge == nil {
continue
}
if _, err := client.Accept(ctx, challenge); err != nil {
return IssuedCert{}, fmt.Errorf("acme: accept challenge: %w", err)
}
if _, err := client.WaitAuthorization(ctx, authzURL); err != nil {
return IssuedCert{}, fmt.Errorf("acme: authorization: %w", err)
}
}
// All authorizations are satisfied; wait for the CA to mark the order
// ready before submitting the CSR (Let's Encrypt rejects finalization
// while the order is still pending).
order, err = client.WaitOrder(ctx, order.URI)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: wait order: %w", err)
}
var der [][]byte
if order.Status == acme.StatusValid && order.CertURL != "" {
der, err = client.FetchCert(ctx, order.CertURL, true)
} else {
der, _, err = client.CreateCertFromOrder(ctx, order, csr.Raw, true)
}
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: finalize order: %w", err)
}
if len(der) == 0 {
return IssuedCert{}, errors.New("acme: empty certificate chain")
}
leaf, err := x509.ParseCertificate(der[0])
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: parse certificate: %w", err)
}
var certPEM, chainPEM strings.Builder
for i, certDER := range der {
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if i == 0 {
certPEM.Write(block)
} else {
chainPEM.Write(block)
}
}
return IssuedCert{Certificate: certPEM.String(), Chain: chainPEM.String(), Expiry: leaf.NotAfter}, nil
}
func dns01Challenge(authz *acme.Authorization) *acme.Challenge {
for _, challenge := range authz.Challenges {
if challenge.Type == "dns-01" {
return challenge
}
}
return nil
}
// authzFailure explains why an authorization cannot be used, including the
// CA's validation error when it reported one.
func authzFailure(authz *acme.Authorization) error {
for _, challenge := range authz.Challenges {
if challenge.Error != nil {
return fmt.Errorf("acme: authorization for %s is %s: %w", authz.Identifier.Value, authz.Status, challenge.Error)
}
}
return fmt.Errorf("acme: authorization for %s is %s", authz.Identifier.Value, authz.Status)
}
// waitForTXT polls until every challenge value is visible at the record name.
// It tries the system resolver first, then public resolvers directly, so a
// cached negative answer on the container's resolver cannot hide a record
// that the CA will see.
func waitForTXT(ctx context.Context, record string, values []string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
resolvers := []*net.Resolver{net.DefaultResolver, resolverAt("1.1.1.1:53"), resolverAt("8.8.8.8:53")}
for {
for _, resolver := range resolvers {
queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
seen, err := resolver.LookupTXT(queryCtx, record)
cancel()
if err != nil {
continue
}
present := 0
for _, want := range values {
for _, got := range seen {
if got == want {
present++
break
}
}
}
if present == len(values) {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("dns: TXT record %s did not propagate within %s", record, timeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
// resolverAt queries one DNS server directly instead of the system resolver.
func resolverAt(address string) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
dialer := net.Dialer{Timeout: 4 * time.Second}
return dialer.DialContext(ctx, network, address)
},
}
}