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
---
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.
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.
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_pathandkey_pathvalues (no duplicates across main or any extra provider)
ConfigExtra
Extra certificate configuration type. Uses MergeExtraConfig() to inherit settings from the main provider:
func MergeExtraConfig(mainCfg *Config, extraCfg *ConfigExtra) ConfigExtra
Fields that can be overridden per extra provider:
Provider- DNS provider nameEmail- ACME account emailDomains- Certificate domainsOptions- Provider-specific optionsResolvers- DNS resolversCADirURL- Custom ACME CA directoryCACerts- Custom CA certificatesEABKid/EABHmac- External Account Binding credentialsHTTPClient- 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 testingcustom- Custom ACME CA withCADirURL
Provider
Main certificate management struct that handles:
- Certificate issuance and renewal
- Loading certificates from disk
- SNI-based certificate selection
- Renewal scheduling
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 atomicallyGetCert(hello *tls.ClientHelloInfo)- Returns certificate for TLS handshakeGetName()- Returns provider name ("main" or "extra[N]")ObtainCert()- Obtains new certificate via ACMEObtainCertAll()- Renews/obtains certificates for main and all extra providersObtainCertIfNotExistsAll()- Obtains certificates only if they don't exist on diskForceExpiryAll()- Triggers forced certificate renewal for main and all extra providersScheduleRenewalAll(parent task.Parent)- Schedules automatic renewal for all providersPrintCertExpiriesAll()- Logs certificate expiry dates for all providers
User
ACME account representation implementing lego's acme.User interface.
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.
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:
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
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
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:
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():
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:
forceRenewalChchannel allows triggering immediate renewal
Force Renewal
The forceRenewalCh channel (buffered size 1) enables immediate certificate renewal on demand:
// Trigger forced renewal for main and all extra providers
provider.ForceExpiryAll()
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:
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:
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: <cert_dir>/.last_failure-<hash>
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:
// File: <cert_dir>/.last_failure-<hash> 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:
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
# 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
// 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()