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.
392 lines
12 KiB
Go
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)
|
|
},
|
|
}
|
|
}
|