Promote multi-site support from Phase 2 to Phase 1 (ADR-010): the proxy must support git.alk.dev and alk.dev from initial release. Add multi-domain TLS configuration (ADR-011): acme_domains array replaces acme_domain string, single SAN certificate via rustls-acme. Key changes: - ADR-010: Multi-site in Phase 1 — avoids config format migration later - ADR-011: Multi-domain TLS — single SAN cert, acme_domains Vec<String> - ADR-002: Updated rationale for multi-site (one upstream per domain) - overview.md: Phase 1 now includes multi-site, alk.dev pass-through, dual licensing (MIT OR Apache-2.0), real IP removed - config.md: acme_domain → acme_domains, TOML example shows both sites, validation adds unique host check, real IP replaced with 203.0.113.10 - tls.md: Multi-domain SNI section moved from Future to current, manual mode uses ResolvesServerCert for SNI mapping, TOML header fixed - proxy.md: Updated for multi-site, removed single-domain language - operations.md: RFC 5737 documentation IPs, clarified rate limit eviction semantics (distinct scan interval vs eviction age) - open-questions.md: OQ-05 resolved (single bind_addr sufficient), new OQ-07 (per-site TLS overrides) Review fixes: - acme_domains (plural) consistently used across all docs and diagram - ADR-011 clearly scopes acme_domain as previous design - Inline decision rationale extracted: tls.md hot-reload → ADR-004 ref, config.md static/dynamic → ADR-008 ref - TOML section headers consistent (server.tls)
8.0 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-06-11 |
Configuration
What It Is
The configuration system defines how the proxy is configured, how configuration is loaded, and how dynamic configuration can be reloaded without restarting the process.
Why It Exists
The proxy needs to be configurable without hard-coding domains, upstream
addresses, or TLS settings. The configuration system separates immutable
startup parameters (bind addresses, TLS mode) from runtime-adjustable
parameters (site definitions, rate limits) using the ArcSwap pattern proven
in the alknet project.
Architecture
config.toml
│
▼
┌──────────────────────┐
│ serde::Deserialize │
│ (TOML → Config) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐ ┌──────────────────────┐
│ StaticConfig │ │ DynamicConfig │
│ (immutable) │ │ (hot-reloadable) │
│ │ │ │
│ bind_addr │ │ sites[] │
│ http_port │ │ rate_limit │
│ https_port │ │ body_limit │
│ tls.mode │ │ proxy_headers │
│ tls.acme_domains │ │ │
│ tls.cert_path │ │ ← ArcSwap → │
│ tls.key_path │ │ ConfigReloadHandle │
│ tls.cache_dir │ │ .reload(new_config) │
│ log_level │ │ │
│ log_format │ └───────────────────────┘
└──────────────────────┘
Static vs Dynamic Configuration
This split follows the pattern established in alknet (ADR-030) and adapted for our simpler use case.
StaticConfig
Immutable after startup. Changes require a process restart.
| Field | Type | Description |
|---|---|---|
bind_addr |
String |
IP address to bind to (must be explicit, no 0.0.0.0) |
http_port |
u16 |
Port for HTTP→HTTPS redirect (default: 80; set to 0 to disable) |
https_port |
u16 |
Port for TLS listener (default: 443) |
tls.mode |
"acme" or "manual" |
Certificate provisioning mode |
tls.acme_domains |
Vec<String> |
Domains for ACME SAN certificate (ACME mode only) |
tls.acme_cache_dir |
String |
ACME state cache directory |
tls.acme_directory |
"production" or "staging" |
Let's Encrypt directory |
tls.cert_path |
String |
Certificate file path (manual mode only) |
tls.key_path |
String |
Private key file path (manual mode only) |
log_level |
"trace", "debug", "info", "warn", "error" |
Logging verbosity |
log_format |
"text" or "json" |
Log output format |
Why these are static: See ADR-008 for the rationale behind the static/dynamic split. In summary: changing bind addresses, ports, or TLS mode requires creating new listeners and TLS configurations — operations that fundamentally require a restart.
DynamicConfig
Hot-reloadable at runtime via ArcSwap. Changes take effect for new
connections immediately.
| Field | Type | Description |
|---|---|---|
sites |
Vec<SiteConfig> |
Site definitions (hostname → upstream mapping) |
rate_limit.requests_per_second |
u32 |
Rate limit per IP (global in Phase 1) |
rate_limit.burst |
u32 |
Burst capacity (global in Phase 1) |
body_limit_bytes |
u64 |
Max request body size in bytes (global in Phase 1) |
SiteConfig:
| Field | Type | Description |
|---|---|---|
host |
String |
Hostname to match (e.g., "git.alk.dev") |
upstream |
String |
Upstream address (e.g., "127.0.0.1:3000") |
upstream_scheme |
"http" or "https" |
Protocol for upstream connection (default: "http") |
Why these are dynamic: See ADR-008 for the rationale. Site definitions and rate limits are per-request concerns that should not require restarting the proxy or dropping active connections. Rate limits and body limits are global settings in Phase 1; per-site configuration for these is deferred to Phase 2.
Config Reload
ArcSwap Pattern
DynamicConfig is wrapped in Arc<ArcSwap<DynamicConfig>>. This provides:
- Lock-free reads: Every handler reads the current config via a single
Arcdereference — no lock contention on the request hot path. - Atomic writes:
ConfigReloadHandle::reload(new_config)swaps the entire config atomically. All new requests see the new config immediately. - No partial updates: The entire config is swapped at once. There's no risk of reading a half-updated config.
See ADR-008 for the rationale behind this split.
Reload Trigger
The initial implementation uses SIGHUP as the reload trigger. When the process receives SIGHUP:
- Re-read the config file from disk
- Deserialize into
DynamicConfig - Validate (check upstream reachability is optional)
- Call
ConfigReloadHandle::reload(new_config)
Future implementations could add a Unix domain socket API or HTTP endpoint for config reload, but SIGHUP is sufficient for Phase 1.
TOML Config Format
# reverse-proxy config
[server]
bind_addr = "203.0.113.10" # Replace with actual bind address
http_port = 80
https_port = 443
[server.tls]
mode = "acme" # "acme" or "manual"
acme_domains = ["git.alk.dev", "alk.dev"]
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache"
acme_directory = "production" # "production" or "staging"
# Manual mode (uncomment and comment out ACME settings)
# mode = "manual"
# cert_path = "/etc/letsencrypt/live/git.alk.dev/fullchain.pem"
# key_path = "/etc/letsencrypt/live/git.alk.dev/privkey.pem"
[server.logging]
level = "info"
format = "text" # "text" or "json"
[rate_limit]
requests_per_second = 10
burst = 20
[body]
limit_bytes = 104857600 # 100 MB
[[sites]]
host = "git.alk.dev"
upstream = "127.0.0.1:3000"
upstream_scheme = "http"
[[sites]]
host = "alk.dev"
upstream = "127.0.0.1:8080"
upstream_scheme = "http"
Validation
On startup, the config is validated:
bind_addris not0.0.0.0(must be explicit)- In ACME mode,
acme_domainsmust be non-empty - In manual mode,
cert_pathandkey_pathmust both be set and the files must be readable - Each site must have a
hostandupstream - Site
hostvalues must be unique (no duplicate hostnames) rate_limit.requests_per_secondmust be > 0body.limit_bytesmust be > 0
On SIGHUP reload, the same validation applies. If the new config fails validation, the reload is rejected and the old config remains active. An error is logged.
On startup: If config validation fails, the process exits with a non-zero code and logs the validation errors. The proxy will not start with an invalid configuration.
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 003 | TOML configuration format | Rust-native, unambiguous, excellent serde support |
| 008 | Static/dynamic config split | Immutable StaticConfig, hot-reloadable DynamicConfig via ArcSwap |
| 010 | Multi-site in Phase 1 | Multiple domains from initial release |
| 011 | Multi-domain TLS config | Single SAN certificate covering all domains |
Open Questions
Open questions are tracked in open-questions.md. Key questions affecting this document:
- OQ-04: Should config reload support a Unix domain socket API in addition to SIGHUP? (open)
- OQ-07: Should per-site TLS overrides be supported for mixed ACME/manual domains? (open)