# Autocert Package Automated SSL certificate management using the ACME protocol (Let's Encrypt and compatible CAs). ## Architecture Overview ``` ┌────────────────────────────────────────────────────────────────────────────┐ │ GoDoxy Proxy │ ├────────────────────────────────────────────────────────────────────────────┤ │ ┌──────────────────────┐ ┌─────────────────────────────────────────┐ │ │ │ Config.State │────▶│ autocert.Provider │ │ │ │ (config loading) │ │ ┌───────────────────────────────────┐ │ │ │ └──────────────────────┘ │ │ main Provider │ │ │ │ │ │ - Primary certificate │ │ │ │ │ │ - SNI matcher │ │ │ │ │ │ - Renewal scheduler │ │ │ │ │ └───────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────┐ │ │ │ │ │ extraProviders[] │ │ │ │ │ │ - Additional certifictes │ │ │ │ │ │ - Different domains/A │ │ │ │ │ └───────────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌────────────────────────────────┐ │ │ │ TLS Handshake │ │ │ │ GetCert(ClientHelloInf) │ │ │ └────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────────────────┘ ``` ## Certificate Lifecycle ```mermaid --- config: theme: redux-dark-color --- 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:#90EE90 style I fill:#FFD700 style N fill:#90EE90 style U fill:#FFA07A ``` ## SNI Matching Flow When a TLS client connects with Server Name Indication (SNI), the proxy needs to select the correct certificate. ```mermaid 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:#90EE90 style E fill:#87CEEB style F fill:#FFD700 ``` ### Suffix Tree Structure The `sniMatcher` uses an optimized suffix tree for efficient wildcard matching: ``` 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] ``` ## Key Components ### Config Configuration for certificate management, loaded from `config/autocert.yml`. ```go type Config struct { Email string // ACME account email Domains []string // Domains to certifiy CertPath string // Output cert path KeyPath string // Output key path Extra []ConfigExtra // Additional cert configs ACMEKeyPath string // ACME account private key (shared by all extras) Provider string // DNS provider name Options map[string]strutils.Redacted // Provider-specific options Resolvers []string // DNS resolvers for DNS-01 CADirURL string // Custom ACME CA directory CACerts []string // Custom CA certificates EABKid string // External Account Binding Key ID EABHmac string // External Account Binding HMAC idx int // 0: main, 1+: extra[i] } type ConfigExtra Config ``` **Extra Provider Merging:** Extra configurations are merged with the main config using `MergeExtraConfig()`, inheriting most settings from the main provider while allowing per-certificate overrides for `Provider`, `Email`, `Domains`, `Options`, `Resolvers`, `CADirURL`, `CACerts`, `EABKid`, `EABHmac`, and `HTTPClient`. The `ACMEKeyPath` is shared across all providers. **Validation:** - Extra configs must have unique `cert_path` and `key_path` values (no duplicates across main or any extra provider) ### ConfigExtra Extra certificate configuration type. Uses `MergeExtraConfig()` to inherit settings from the main provider: ```go func MergeExtraConfig(mainCfg *Config, extraCfg *ConfigExtra) ConfigExtra ``` Fields that can be overridden per extra provider: - `Provider` - DNS provider name - `Email` - ACME account email - `Domains` - Certificate domains - `Options` - Provider-specific options - `Resolvers` - DNS resolvers - `CADirURL` - Custom ACME CA directory - `CACerts` - Custom CA certificates - `EABKid` / `EABHmac` - External Account Binding credentials - `HTTPClient` - Custom HTTP client Fields inherited from main config (shared): - `ACMEKeyPath` - ACME account private key (same for all) **Provider Types:** - `local` - No ACME, use existing certificate (default) - `pseudo` - Mock provider for testing - `custom` - Custom ACME CA with `CADirURL` ### Provider Main certificate management struct that handles: - Certificate issuance and renewal - Loading certificates from disk - SNI-based certificate selection - Renewal scheduling ```go type Provider struct { logger zerolog.Logger // Provider-scoped logger cfg *Config // Configuration user *User // ACME account legoCfg *lego.Config // LEGO client config client *lego.Client // ACME client lastFailure time.Time // Last renewal failure legoCert *certificate.Resource // Cached cert resource tlsCert *tls.Certificate // Parsed TLS certificate certExpiries CertExpiries // Domain → expiry map extraProviders []*Provider // Additional certificates sniMatcher sniMatcher // SNI → Provider mapping forceRenewalCh chan struct{} // Force renewal trigger channel scheduleRenewalOnce sync.Once // Prevents duplicate renewal scheduling } ``` **Logging:** Each provider has a scoped logger with provider name ("main" or "extra[N]") for consistent log context. **Key Methods:** - `NewProvider(cfg *Config, user *User, legoCfg *lego.Config) (*Provider, error)` - Creates provider and initializes extra providers atomically - `GetCert(hello *tls.ClientHelloInfo)` - Returns certificate for TLS handshake - `GetName()` - Returns provider name ("main" or "extra[N]") - `ObtainCert()` - Obtains new certificate via ACME - `ObtainCertAll()` - Renews/obtains certificates for main and all extra providers - `ObtainCertIfNotExistsAll()` - Obtains certificates only if they don't exist on disk - `ForceExpiryAll()` - Triggers forced certificate renewal for main and all extra providers - `ScheduleRenewalAll(parent task.Parent)` - Schedules automatic renewal for all providers - `PrintCertExpiriesAll()` - Logs certificate expiry dates for all providers ### User ACME account representation implementing lego's `acme.User` interface. ```go type User struct { Email string // Account email Registration *registration.Resource // ACME registration Key crypto.PrivateKey // Account key } ``` ### sniMatcher Efficient SNI-to-Provider lookup with exact and wildcard matching. ```go type sniMatcher struct { exact map[string]*Provider // Exact domain matches root sniTreeNode // Wildcard suffix tree } type sniTreeNode struct { children map[string]*sniTreeNode // DNS label → child node wildcard *Provider // Wildcard match at this level } ``` ## DNS Providers Supported DNS providers for DNS-01 challenge validation: | Provider | Name | Description | | ------------ | -------------- | ---------------------------------------- | | Cloudflare | `cloudflare` | Cloudflare DNS | | Route 53 | `route53` | AWS Route 53 | | DigitalOcean | `digitalocean` | DigitalOcean DNS | | GoDaddy | `godaddy` | GoDaddy DNS | | OVH | `ovh` | OVHcloud DNS | | CloudDNS | `clouddns` | Google Cloud DNS | | AzureDNS | `azuredns` | Azure DNS | | DuckDNS | `duckdns` | DuckDNS | | and more... | | See `internal/dnsproviders/providers.go` | ### Provider Configuration Each provider accepts configuration via the `options` map: ```yaml autocert: provider: cloudflare email: admin@example.com domains: - example.com - "*.example.com" options: CF_API_TOKEN: your-api-token CF_ZONE_API_TOKEN: your-zone-token resolvers: - 1.1.1.1:53 ``` ## ACME Integration ### Account Registration ```mermaid flowchart TD A[Load or Generate ACME Key] --> B[Init LEGO Client] B --> C[Resolve Account by Key] C --> D{Account Exists?} D -->|Yes| E[Continue with existing] D -->|No| F{Has EAB?} F -->|Yes| G[Register with EAB] F -->|No| H[Register with TOS Agreement] G --> I[Save Registration] H --> I ``` ### DNS-01 Challenge ```mermaid sequenceDiagram participant C as ACME CA participant P as GoDoxy participant D as DNS Provider P->>C: Request certificate for domain C->>P: Present DNS-01 challenge P->>D: Create TXT record _acme-challenge.domain D-->>P: Record created P->>C: Challenge ready C->>D: Verify DNS TXT record D-->>C: Verification success C->>P: Issue certificate P->>D: Clean up TXT record ``` ## Multi-Certificate Support The package supports multiple certificates through the `extra` configuration: ```yaml autocert: provider: cloudflare email: admin@example.com domains: - example.com - "*.example.com" cert_path: certs/example.com.crt key_path: certs/example.com.key extra: - domains: - api.example.com - "*.api.example.com" cert_path: certs/api.example.com.crt key_path: certs/api.example.com.key provider: cloudflare email: admin@api.example.com ``` ### Extra Provider Setup Extra providers are initialized atomically within `NewProvider()`: ```mermaid flowchart TD A[NewProvider] --> B{Merge Config with Extra} B --> C[Create Provider per Extra] C --> D[Build SNI Matcher] D --> E[Register in SNI Tree] style B fill:#87CEEB style C fill:#FFD700 ``` ## Renewal Scheduling ### Renewal Timing - **Initial Check**: Certificate expiry is checked at startup - **Renewal Window**: Renewal scheduled for 1 month before expiry - **Cooldown on Failure**: 1-hour cooldown after failed renewal - **Request Cooldown**: 15-second cooldown after startup (prevents rate limiting) - **Force Renewal**: `forceRenewalCh` channel allows triggering immediate renewal ### Force Renewal The `forceRenewalCh` channel (buffered size 1) enables immediate certificate renewal on demand: ```go // Trigger forced renewal for main and all extra providers provider.ForceExpiryAll() ``` ```mermaid flowchart TD A[Start] --> B[Calculate Renewal Time] B --> C[expiry - 30 days] C --> D[Start Timer] D --> E{Event?} E -->|forceRenewalCh| F[Force Renewal] E -->|Timer| G[Check Failure Cooldown] E -->|Context Done| H[Exit] G --> H1{Recently Failed?} H1 -->|Yes| I[Skip, Wait Next Event] H1 -->|No| J[Attempt Renewal] J --> K{Renewal Success?} K -->|Yes| L[Reset Failure, Notify Success] K -->|No| M[Update Failure Time, Notify Failure] L --> N[Reset Timer] I --> N M --> D N --> D style F fill:#FFD700 style J fill:#FFD700 style K fill:#90EE90 style M fill:#FFA07A ``` **Notifications:** Renewal success/failure triggers system notifications with provider name. ### CertState Certificate state tracking: ```go const ( CertStateValid // Certificate is valid and up-to-date CertStateExpired // Certificate has expired or needs renewal CertStateMismatch // Certificate domains don't match config ) ``` ### RenewMode Controls renewal behavior: ```go const ( renewModeForce // Force renewal, bypass cooldown and state check renewModeIfNeeded // Renew only if expired or domain mismatch ) ``` ## File Structure ``` internal/autocert/ ├── README.md # This file ├── config.go # Config struct and validation ├── provider.go # Provider implementation ├── setup.go # Extra provider setup ├── sni_matcher.go # SNI matching logic ├── providers.go # DNS provider registration ├── state.go # Certificate state enum ├── user.go # ACME user/account ├── paths.go # Default paths └── types/ └── provider.go # Provider interface ``` ## Default Paths | Constant | Default Value | Description | | -------------------- | ---------------- | ------------------------ | | `CertFileDefault` | `certs/cert.crt` | Default certificate path | | `KeyFileDefault` | `certs/priv.key` | Default private key path | | `ACMEKeyFileDefault` | `certs/acme.key` | Default ACME account key | Failure tracking file is generated per-certificate: `/.last_failure-` ## Error Handling The package uses structured error handling with `gperr`: - **ErrMissingField** - Required configuration field missing - **ErrDuplicatedPath** - Duplicate certificate/key paths in extras - **ErrInvalidDomain** - Invalid domain format - **ErrUnknownProvider** - Unknown DNS provider - **ErrGetCertFailure** - Certificate retrieval failed **Error Context:** All errors are prefixed with provider name ("main" or "extra[N]") via `fmtError()` for clear attribution. ### Failure Tracking Last failure is persisted per-certificate to prevent rate limiting: ```go // File: /.last_failure- where hash is SHA256(certPath|keyPath)[:6] ``` **Cooldown Checks:** Last failure is checked in `obtainCertIfNotExists()` (15-second startup cooldown) and `renew()` (1-hour failure cooldown). The `renewModeForce` bypasses cooldown checks entirely. ## Integration with GoDoxy The autocert package integrates with GoDoxy's configuration system: ```mermaid flowchart LR subgraph Config direction TB A[config.yml] --> B[Parse Config] B --> C[AutoCert Config] end subgraph State C --> D[NewProvider] D --> E[Schedule Renewal] E --> F[Set Active Provider] end subgraph Server F --> G[TLS Handshake] G --> H[GetCert via SNI] H --> I[Return Certificate] end ``` ### REST API Force certificate renewal via WebSocket endpoint: | Endpoint | Method | Description | | -------------------- | ------ | ----------------------------------------- | | `/api/v1/cert/renew` | GET | Triggers `ForceExpiryAll()` via WebSocket | The endpoint streams live logs during the renewal process. ## Usage Example ```yaml # config/config.yml autocert: provider: cloudflare email: admin@example.com domains: - example.com - "*.example.com" options: CF_API_TOKEN: ${CF_API_TOKEN} resolvers: - 1.1.1.1:53 - 8.8.8.8:53 ``` ```go // In config initialization 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() ```