Files
godoxy/internal/autocert
yusing 8d56c61826 fix(autocert): rebuild SNI matcher after ObtainCertAll operations
The ObtainCertAll method was missing a call to rebuildSNIMatcher(),
which could leave the SNI configuration stale after certificate
renewals. Both ObtainCertIfNotExistsAll and ObtainCertAll now
consistently rebuild the SNI matcher after their operations.

This was introduced in 3ad6e98a17,
not a bug fix for previous version
2026-01-31 18:57:15 +08:00
..

Autocert Package

Automated SSL certificate management using the ACME protocol (Let's Encrypt and compatible CAs).

Overview

Purpose

This package provides complete SSL certificate lifecycle management:

  • ACME account registration and management
  • Certificate issuance via DNS-01 challenge
  • Automatic renewal scheduling (1 month before expiry)
  • SNI-based certificate selection for multi-domain setups

Primary Consumers

  • goutils/server - TLS handshake certificate provider
  • internal/api/v1/cert/ - REST API for certificate management
  • Configuration loading via internal/config/

Non-goals

  • HTTP-01 challenge support
  • Certificate transparency log monitoring
  • OCSP stapling
  • Private CA support (except via custom CADirURL)

Stability

Internal package with stable public APIs. ACME protocol compliance depends on lego library.

Public API

Config (config.go)

type Config struct {
    Email       string                       // ACME account email
    Domains     []string                     // Domains to certify
    CertPath    string                       // Output cert path
    KeyPath     string                       // Output key path
    Extra       []ConfigExtra                // Additional cert configs
    ACMEKeyPath string                       // ACME account private key
    Provider    string                       // DNS provider name
    Options     map[string]strutils.Redacted // Provider options
    Resolvers   []string                     // DNS resolvers
    CADirURL    string                       // Custom ACME CA directory
    CACerts     []string                     // Custom CA certificates
    EABKid      string                       // External Account Binding Key ID
    EABHmac     string                       // External Account Binding HMAC
}

// Merge extra config with main provider
func MergeExtraConfig(mainCfg *Config, extraCfg *ConfigExtra) ConfigExtra

Provider (provider.go)

type Provider struct {
    logger       zerolog.Logger
    cfg          *Config
    user         *User
    legoCfg      *lego.Config
    client       *lego.Client
    lastFailure  time.Time
    legoCert     *certificate.Resource
    tlsCert      *tls.Certificate
    certExpiries CertExpiries
    extraProviders []*Provider
    sniMatcher   sniMatcher
}

// Create new provider (initializes extras atomically)
func NewProvider(cfg *Config, user *User, legoCfg *lego.Config) (*Provider, error)

// TLS certificate getter for SNI
func (p *Provider) GetCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error)

// Certificate info for API
func (p *Provider) GetCertInfos() ([]CertInfo, error)

// Provider name ("main" or "extra[N]")
func (p *Provider) GetName() string

// Obtain certificate if not exists
func (p *Provider) ObtainCertIfNotExistsAll() error

// Force immediate renewal
func (p *Provider) ForceExpiryAll() bool

// Schedule automatic renewal
func (p *Provider) ScheduleRenewalAll(parent task.Parent)

// Print expiry dates
func (p *Provider) PrintCertExpiriesAll()

User (user.go)

type User struct {
    Email        string                    // Account email
    Registration *registration.Resource    // ACME registration
    Key          crypto.PrivateKey         // Account key
}

Architecture

Certificate Lifecycle

flowchart TD
    A[Start] --> B[Load Existing Cert]
    B --> C{Cert Exists?}
    C -->|Yes| D[Load Cert from Disk]
    C -->|No| E[Obtain New Cert]

    D --> F{Valid & Not Expired?}
    F -->|Yes| G[Schedule Renewal]
    F -->|No| H{Renewal Time?}
    H -->|Yes| I[Renew Certificate]
    H -->|No| G

    E --> J[Init ACME Client]
    J --> K[Register Account]
    K --> L[DNS-01 Challenge]
    L --> M[Complete Challenge]
    M --> N[Download Certificate]
    N --> O[Save to Disk]
    O --> G

    G --> P[Wait Until Renewal Time]
    P --> Q[Trigger Renewal]
    Q --> I

    I --> R[Renew via ACME]
    R --> S{Same Domains?}
    S -->|Yes| T[Bundle & Save]
    S -->|No| U[Re-obtain Certificate]
    U --> T

    T --> V[Update SNI Matcher]
    V --> G

    style E fill:#22553F,color:#fff
    style I fill:#8B8000,color:#fff
    style N fill:#22553F,color:#fff
    style U fill:#84261A,color:#fff

SNI Matching Flow

flowchart LR
    Client["TLS Client"] -->|ClientHello SNI| Proxy["GoDoxy Proxy"]
    Proxy -->|Certificate| Client

    subgraph "SNI Matching Process"
        direction TB
        A[Extract SNI from ClientHello] --> B{Normalize SNI}
        B --> C{Exact Match?}
        C -->|Yes| D[Return cert]
        C -->|No| E[Wildcard Suffix Tree]
        E --> F{Match Found?}
        F -->|Yes| D
        F -->|No| G[Return default cert]
    end

    style C fill:#27632A,color:#fff
    style E fill:#18597A,color:#fff
    style F fill:#836C03,color:#fff

Suffix Tree Structure

Certificate: *.example.com, example.com, *.api.example.com

exact:
  "example.com" -> Provider_A

root:
  └── "com"
      └── "example"
          ├── "*" -> Provider_A  [wildcard at *.example.com]
          └── "api"
              └── "*" -> Provider_B  [wildcard at *.api.example.com]

Configuration Surface

Provider Types

Type Description Use Case
local No ACME, use existing cert Pre-existing certificates
pseudo Mock provider for testing Development
ACME providers Let's Encrypt, ZeroSSL, etc. Production

Supported DNS Providers

Provider Name Required Options
Cloudflare cloudflare CF_API_TOKEN
Route 53 route53 AWS credentials
DigitalOcean digitalocean DO_API_TOKEN
GoDaddy godaddy GD_API_KEY, GD_API_SECRET
OVH ovh OVH_ENDPOINT, OVH_APP_KEY, etc.
CloudDNS clouddns GCP credentials
AzureDNS azuredns Azure credentials
DuckDNS duckdns DUCKDNS_TOKEN

Example Configuration

autocert:
  provider: cloudflare
  email: admin@example.com
  domains:
    - example.com
    - "*.example.com"
  options:
    auth_token: ${CF_API_TOKEN}
  resolvers:
    - 1.1.1.1:53

Extra Providers

autocert:
  provider: cloudflare
  email: admin@example.com
  domains:
    - example.com
    - "*.example.com"
  cert_path: certs/example.com.crt
  key_path: certs/example.com.key
  options:
    auth_token: ${CF_API_TOKEN}
  extra:
    - domains:
        - api.example.com
        - "*.api.example.com"
      cert_path: certs/api.example.com.crt
      key_path: certs/api.example.com.key

Dependency and Integration Map

External Dependencies

  • github.com/go-acme/lego/v4 - ACME protocol implementation
  • github.com/rs/zerolog - Structured logging

Internal Dependencies

  • internal/task/task.go - Lifetime management
  • internal/notif/ - Renewal notifications
  • internal/config/ - Configuration loading
  • internal/dnsproviders/ - DNS provider implementations

Observability

Logs

Level When
Info Certificate obtained/renewed
Info Registration reused
Warn Renewal failure
Error Certificate retrieval failure

Notifications

  • Certificate renewal success/failure
  • Service startup with expiry dates

Security Considerations

  • Account private key stored at certs/acme.key (mode 0600)
  • Certificate private keys stored at configured paths (mode 0600)
  • Certificate files world-readable (mode 0644)
  • ACME account email used for Let's Encrypt ToS
  • EAB credentials for zero-touch enrollment

Failure Modes and Recovery

Failure Mode Impact Recovery
DNS-01 challenge timeout Certificate issuance fails Check DNS provider API
Rate limiting (too many certs) 1-hour cooldown Wait or use different account
DNS provider API error Renewal fails 1-hour cooldown, retry
Certificate domains mismatch Must re-obtain Force renewal via API
Account key corrupted Must register new account New key, may lose certs

Failure Tracking

Last failure persisted per-certificate to prevent rate limiting:

File: <cert_dir>/.last_failure-<hash>
Where hash = SHA256(certPath|keyPath)[:6]

Usage Examples

Initial Setup

autocertCfg := state.AutoCert
user, legoCfg, err := autocertCfg.GetLegoConfig()
if err != nil {
    return err
}

provider, err := autocert.NewProvider(autocertCfg, user, legoCfg)
if err != nil {
    return fmt.Errorf("autocert error: %w", err)
}

if err := provider.ObtainCertIfNotExistsAll(); err != nil {
    return fmt.Errorf("failed to obtain certificates: %w", err)
}

provider.ScheduleRenewalAll(state.Task())
provider.PrintCertExpiriesAll()

Force Renewal via API

// WebSocket endpoint: GET /api/v1/cert/renew
if provider.ForceExpiryAll() {
    // Wait for renewal to complete
    provider.WaitRenewalDone(ctx)
}

Testing Notes

  • config_test.go - Configuration validation
  • provider_test/ - Provider functionality tests
  • sni_test.go - SNI matching tests
  • multi_cert_test.go - Extra provider tests
  • Integration tests require mock DNS provider