Auto-SSL
Auto-SSL Plugin
On-demand TLS certificate provisioning using ACME (Let's Encrypt). Certificates are obtained automatically when the first HTTPS request arrives for a configured domain — no manual certificate management required.
How it works
TLS ClientHello (SNI = "example.com")
│
├── cert_map has real cert? → serve it (O(1))
│
├── NOT in allowed_domains? → serve default self-signed cert
│
├── state = Pending? → serve fallback (worker running)
│
├── state = Failed AND elapsed < 24h? → serve fallback (cooldown)
│
└── otherwise → dedup insert + spawn bg worker + serve fallback
│
└── Worker completes → cert loaded into memory → next handshake gets real cert ✅
Three triggers
| Trigger | When | Behavior |
|---|---|---|
| TLS handshake | First request for a domain in config | Serve fallback cert immediately, spawn background ACME worker |
| Config change | auto_ssl.domains list updated | Update allowed domains list only — no cert fetched |
| Daily | Every 24 hours | Renew certs expiring within renewal_days_before, retry 1 failed cert |
On-demand flow
- A TLS ClientHello arrives with SNI =
example.com - Gateway checks the in-memory
cert_map— no cert found - Gateway checks
allowed_domains— domain is in the config - Gateway checks
cert_state— no state yet (first request) - Gateway inserts domain into
provisioning_set(dedup guard) - Gateway spawns a background ACME worker
- Gateway returns a self-signed fallback cert — connection succeeds, traffic flows
- Background worker obtains a real cert from Let's Encrypt
- Cert is loaded into
cert_mapand persisted to disk - Next TLS handshake for this domain gets the real cert ✅
Deduplication
A DashSet<String> ensures only one ACME worker per domain runs at a time. If 100 requests arrive simultaneously for the same domain without a cert, only the first spawns a worker. The rest get the fallback cert.
Failed cert handling
When cert provisioning fails:
| Field | Value |
|---|---|
| State | Failed |
| Error | Stored in memory |
| Timestamp | failed_at recorded |
| Retry | Only when: new request arrives AND failed_at >= 24h |
No aggressive retries. A failed cert stays failed until:
- A new TLS handshake arrives for that domain AND 24 hours have passed
- The daily renewal cycle retries one failed cert per cycle
Configuration
{
"plugins": {
"auto_ssl": {
"enabled": true,
"email": "admin@example.com",
"domains": [
"example.com",
"www.example.com",
"api.example.com"
],
"cert_storage_path": "/var/lib/quayel/certs",
"staging": false,
"renewal_days_before": 30
}
}
}
Config fields
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled | bool | true | — | Enable/disable Auto-SSL |
email | string | — | ✅ | Email for Let's Encrypt account registration |
domains | string | [] | ✅ | Domain names to provision certificates for |
cert_storage_path | string | "/var/lib/quayel/certs" | — | Directory to store PEM files (write-through cache) |
staging | bool | false | — | Use Let's Encrypt staging environment (for testing) |
renewal_days_before | int | 30 | — | Renew certificates this many days before expiry |
email
Required. Used to register a Let's Encrypt account. Let's Encrypt sends expiry warnings to this address.
domains
List of domain names the gateway should obtain certificates for. Certificates are only provisioned when the first TLS handshake arrives for a domain — adding a domain to this list does not immediately trigger cert fetching.
cert_storage_path
Directory where PEM files are stored as a write-through cache. On startup, cached certs are loaded from disk into memory. This is for restart recovery only — all runtime lookups use in-memory data structures.
Directory structure:
/var/lib/quayel/certs/
├── example.com/
│ ├── cert.pem
│ └── key.pem
├── www.example.com/
│ ├── cert.pem
│ └── key.pem
└── api.example.com/
├── cert.pem
└── key.pem
staging
When true, uses Let's Encrypt's staging environment (acme-staging-v02.api.letsencrypt.org). Staging certificates are not trusted by browsers but have no rate limits. Use this for testing.
| Environment | URL | Rate Limits | Trusted |
|---|---|---|---|
| Production | acme-v02.api.letsencrypt.org | 50 certs/domain/week | ✅ |
| Staging | acme-staging-v02.api.letsencrypt.org | Unlimited | ❌ |
renewal_days_before
The daily renewal check renews certificates that will expire within this many days. Default is 30 days (Let's Encrypt certs are valid for 90 days).
In-memory architecture
All runtime state lives in Arc-wrapped concurrent data structures. No disk I/O on the hot path.
| Store | Type | Purpose | Lookup |
|---|---|---|---|
cert_map | DashMap<String, Arc<CertifiedKey>> | Domain → TLS certificate | O(1) |
cert_state | DashMap<String, CertState> | Domain → provisioning state | O(1) |
allowed_domains | RwLock<HashSet<String>> | Config-sourced whitelist | O(1) |
challenge_tokens | DashMap<String, String> | HTTP-01 token → key-auth | O(1) |
provisioning_set | DashSet<String> | Dedup guard (one worker/domain) | O(1) |
Cert states
| State | Meaning | Retry Behavior |
|---|---|---|
Valid | Cert loaded, has expires_at | — |
Pending | ACME worker in progress | — |
Failed | Error + timestamp + attempt count | Next request after 24h, or daily cycle |
Expired | Detected by daily check | Triggers re-provision |
Disk I/O
| Operation | When | Pattern |
|---|---|---|
| Read | Startup only | Load cached PEMs from cert_storage_path into cert_map |
| Write | After cert obtained | Fire-and-forget tokio::spawn — save PEM to disk in background |
Memory is the source of truth. Disk is a write-through cache for restart recovery.
HTTP-01 challenge
The ACME HTTP-01 challenge works by serving a token at a well-known URL:
http://DOMAIN/.well-known/acme-challenge/TOKEN
When Let's Encrypt verifies domain ownership:
- The ACME client stores the token + key-authorization in
challenge_tokens - The gateway's
request_filterintercepts/.well-known/acme-challenge/*requests - The gateway serves the key-authorization as plain text
- Let's Encrypt verifies the token and issues the cert
- The token is removed from memory
Startup behavior
The gateway starts serving traffic immediately. HTTPS uses a self-signed fallback cert until real certs are provisioned.
main.rs:
HTTP listener starts (:8080) ← traffic flows immediately
HTTPS listener starts (:8443) ← uses fallback cert
if auto_ssl.enabled:
load cached certs from disk
(no blocking — provisioning happens on first request)
Non-blocking. The gateway does not wait for certificates to be provisioned before starting. Traffic flows immediately with fallback certs.
Config hot-reload
When the config file changes:
- Config watcher detects SHA-256 hash change
- New config is validated and compiled
auto_ssl.domainslist is updated in memory- No certs are fetched — that happens on the next TLS handshake
Adding a new domain to the config only updates the allowed list. The cert is provisioned when the first request arrives for that domain.
Daily renewal
A background task runs every 24 hours:
- Check all domains in
allowed_domains - If a cert expires within
renewal_days_beforedays → re-provision - If a cert is expired → re-provision
- If a cert is failed AND
failed_at >= 24h→ retry (max 1 failed cert per cycle)
Prerequisites
- Port 80 must be accessible from the internet (for HTTP-01 challenges)
- DNS must point the configured domains to the gateway's public IP
- Let's Encrypt rate limits: 50 certificates per registered domain per week (production)
Examples
Basic setup
{
"plugins": {
"auto_ssl": {
"enabled": true,
"email": "admin@example.com",
"domains": ["example.com", "www.example.com"],
"cert_storage_path": "/var/lib/quayel/certs",
"staging": false,
"renewal_days_before": 30
}
}
}
Testing with staging
{
"plugins": {
"auto_ssl": {
"enabled": true,
"email": "dev@example.com",
"domains": ["staging.example.com"],
"staging": true,
"renewal_days_before": 7
}
}
}
Multiple domains
{
"plugins": {
"auto_ssl": {
"enabled": true,
"email": "ops@example.com",
"domains": [
"example.com",
"www.example.com",
"api.example.com",
"admin.example.com",
"cdn.example.com"
],
"cert_storage_path": "/var/lib/quayel/certs",
"staging": false,
"renewal_days_before": 30
}
}
}
Full gateway config with Auto-SSL
{
"gateway_id": "gw-prod-01",
"gateway_name": "production",
"server_name": "example.com",
"config_version": "2026-01-01-001",
"listen": {
"http": "0.0.0.0:80",
"https": "0.0.0.0:443"
},
"client_ip": {
"source": "header",
"header": "X-Forwarded-For"
},
"plugins": {
"ip_intel": { "enabled": true },
"auto_ssl": {
"enabled": true,
"email": "admin@example.com",
"domains": ["example.com", "www.example.com"],
"cert_storage_path": "/var/lib/quayel/certs",
"staging": false,
"renewal_days_before": 30
},
"firewall": {
"enabled": true,
"rules": [
{
"id": "block-tor",
"name": "Block Tor Exit Nodes",
"enabled": true,
"conditions": [
{ "left": "ip.is_tor", "operator": "equals", "value": true }
],
"action": { "type": "block", "status": 403 }
}
]
},
"load_balancer": {
"enabled": true,
"load_balancers": [
{
"id": "default",
"name": "Backend",
"enabled": true,
"conditions": [],
"algorithm": { "type": "round_robin" },
"targets": [
{
"id": "backend-1",
"host": "127.0.0.1",
"port": 3000,
"protocol": "http",
"weight": 100,
"enabled": true
}
],
"host_header": { "mode": "visitor" },
"health_check": { "mode": "off" }
}
]
},
"logging": {
"enabled": true,
"mode": "file",
"file": {
"path": "/var/log/quayel/access.jsonl",
"buffer_size": 8192,
"flush_interval_ms": 1000
}
}
}
}
Troubleshooting
Cert not being provisioned
- Check that port 80 is accessible from the internet
- Check that DNS points to the gateway's IP
- Check logs for ACME errors:
RUST_LOG=debug - Try with
"staging": truefirst to avoid rate limits
Challenge verification fails
Let's Encrypt needs to reach http://DOMAIN/.well-known/acme-challenge/TOKEN. Common issues:
- Port 80 is blocked by a firewall
- DNS is not pointing to the gateway
- Another service is listening on port 80
- The domain is behind a CDN that doesn't proxy port 80
Cert provisioning takes too long
ACME provisioning typically takes 5-30 seconds. If it's taking longer:
- Check network connectivity to
acme-v02.api.letsencrypt.org - Check logs for timeout errors
- The challenge poll timeout is 60 seconds (30 polls × 2s interval)
Fallback cert shown in browser
If browsers show a certificate warning:
- The real cert hasn't been provisioned yet (first request)
- The cert provisioning failed (check logs)
- The domain is not in the
auto_ssl.domainslist
Performance
| Metric | Value |
|---|---|
| Cert lookup | O(1) — DashMap |
| SNI resolution | O(1) — in-memory |
| Disk I/O on hot path | None — memory is source of truth |
| Startup delay | None — non-blocking |
| Memory per cert | ~4 KB (CertifiedKey + metadata) |
| ACME provisioning time | 5-30 seconds (first request only) |
Limitations
- HTTP-01 only: Only HTTP-01 challenges are supported (not DNS-01 or TLS-ALPN-01)
- Single domain per cert: Each certificate covers one domain (no multi-SAN)
- No wildcard certs: Wildcard certificates require DNS-01 challenges
- Port 80 required: HTTP-01 challenges need port 80 accessible from the internet
- Let's Encrypt only: Currently hardcoded to Let's Encrypt (not other ACME CAs)