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