Files
tunnel/internal/server/issuer.go
T
TZGyn ebae420ab1 Add the native Go client and finish M3 hardening
The new cmd/tunnel client keeps the API URL and tunnel identity in
~/.config/tunnel/config.json (0600), provisions the key/CSR and
certificate, terminates TLS, proxies to local routes, reloads routes
without a restart, and runs as a background service. `tunnel import`
takes over an existing official CLI profile, preserving its tunnel ID,
token, key and certificate.

Server hardening: per-IP and per-tunnel rate limits for creates and
binds, a CA cool-down after repeated ACME failures, a startup sweep for
stale _acme-challenge records, admin metrics, a 60-second half-close
timeout, and separate prioritized control / ordered data bridge queues
(with `end` ordered after the final bytes). The dashboard shows the new
metrics.
2026-10-26 00:32:31 +08:00

408 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
cool cooldown
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), cool: newCooldown()}, 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) (result IssuedCert, err error) {
csr, err := parseCSR(csrPEM, hostname)
if err != nil {
return IssuedCert{}, err
}
if delay := a.cool.remaining(); delay > 0 {
return IssuedCert{}, fmt.Errorf("acme: CA cooling down for %s after repeated failures", delay.Round(time.Second))
}
select {
case a.sem <- struct{}{}:
case <-ctx.Done():
return IssuedCert{}, ctx.Err()
}
defer func() { <-a.sem }()
defer func() {
if err == nil {
a.cool.success()
return
}
if caFailure(err) {
if delay := a.cool.failure(); delay > 0 {
log.Printf("acme: pausing new orders for %s after repeated CA failures", delay)
}
}
}()
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)
},
}
}