Resolve OQ-07: add multi-config listener support (ADR-019)

Introduce [[listeners]] configuration to support both dedicated-IP
(1 IP = 1 cert = 1 domain) and shared-IP (SAN certificate) deployment
models. Each listener is an independent TLS endpoint with its own bind
address, TLS config, and site routing. OQ-07 is now resolved.

Changes:
- Add ADR-019 for multi-config listener support
- Update config format from [server] to [[listeners]] entries
- Update tls.md for per-listener TLS and certificate provisioning
- Update overview.md architecture diagram and scope
- Update proxy.md for per-listener HTTP redirect
- Fix stale references in ADR-010, ADR-011, ADR-016
- Update OQ-05 resolution (per-listener bind_addr supersedes)
- Add unique-host rationale to config validation rules
- Architecture review: fix all 3 critical and 6 warning issues
This commit is contained in:
2026-06-11 09:35:24 +00:00
parent 9a2352e61c
commit 346754fb2b
10 changed files with 481 additions and 168 deletions

View File

@@ -50,6 +50,7 @@ certificate via ACME.
| [016](decisions/016-explicit-bind-address.md) | Explicit Bind Address Requirement | Accepted | | [016](decisions/016-explicit-bind-address.md) | Explicit Bind Address Requirement | Accepted |
| [017](decisions/017-upstream-connection-defaults.md) | Upstream Connection Defaults | Accepted | | [017](decisions/017-upstream-connection-defaults.md) | Upstream Connection Defaults | Accepted |
| [018](decisions/018-body-size-limit.md) | Request Body Size Limit | Accepted | | [018](decisions/018-body-size-limit.md) | Request Body Size Limit | Accepted |
| [019](decisions/019-multi-config-listeners.md) | Multi-Config Listener Support | Accepted |
## Open Questions ## Open Questions
@@ -63,7 +64,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
| ~~OQ-04~~ | ~~Config reload: SIGHUP only or also Unix socket API?~~ | ~~low~~ | **resolved** (ADR-014) | | ~~OQ-04~~ | ~~Config reload: SIGHUP only or also Unix socket API?~~ | ~~low~~ | **resolved** (ADR-014) |
| ~~OQ-05~~ | ~~Should the proxy bind to multiple addresses?~~ | ~~low~~ | **resolved** (single bind_addr sufficient) | | ~~OQ-05~~ | ~~Should the proxy bind to multiple addresses?~~ | ~~low~~ | **resolved** (single bind_addr sufficient) |
| ~~OQ-06~~ | ~~Should upstream timeouts be configurable per-site?~~ | ~~low~~ | **resolved** (ADR-015) | | ~~OQ-06~~ | ~~Should upstream timeouts be configurable per-site?~~ | ~~low~~ | **resolved** (ADR-015) |
| OQ-07 | Should per-site TLS overrides be supported for mixed ACME/manual domains? | low | open | | ~~OQ-07~~ | ~~Should per-site TLS overrides be supported for mixed ACME/manual domains?~~ | ~~low~~ | **resolved** (ADR-019) |
## Document Lifecycle ## Document Lifecycle

View File

@@ -31,54 +31,84 @@ config.toml
└──────────┬───────────┘ └──────────┬───────────┘
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ StaticConfig │ │ DynamicConfig │ │ StaticConfig │
│ (immutable) │ │ (hot-reloadable) │ │ (immutable) │
│ │ │ │ │ │
bind_addr │ │ sites[] health_check_port
http_port │ │ rate_limit admin_socket_path
https_port │ │ body_limit log_level
health_check_port │ │ proxy_headers log_format
admin_socket_path │ │ │ │
tls.mode │ │ ← ArcSwap → listeners[]
tls.acme_domains │ │ ┌────────────────┐
tls.cert_path │ │ ← ArcSwap → │ Listener 1 │
tls.key_path ConfigReloadHandle │ bind_addr │ │
tls.cache_dir │ │ .reload(new_config) │ http_port │
log_level │ │ │ https_port │
log_format │ └───────────────────────┘ │ tls.mode │ │
│ │ tls.acme_domains│ │
│ │ tls.acme_cache_dir│ │
│ │ tls.acme_directory│ │
│ │ tls.cert_path │ │
│ │ tls.key_path │ │
│ └────────────────┘ │
│ ┌────────────────┐ │
│ │ Listener N │ │
│ │ ... │ │
│ └────────────────┘ │
└──────────────────────┘ └──────────────────────┘
┌──────────────────────┐
│ DynamicConfig │
│ (hot-reloadable) │
│ │
│ sites[] │
│ rate_limit │
│ body_limit │
│ │
│ ← ArcSwap → │
│ ConfigReloadHandle │
│ .reload(new_config) │
└───────────────────────┘
``` ```
## Static vs Dynamic Configuration ## Static vs Dynamic Configuration
This split follows the pattern established in alknet (ADR-030) and adapted This split follows the pattern established in alknet (ADR-030) and adapted
for our simpler use case. for our simpler use case. See ADR-019 for the rationale behind the
`[[listeners]]` configuration format.
### StaticConfig ### StaticConfig
Immutable after startup. Changes require a process restart. Immutable after startup. Changes require a process restart.
| Field | Type | Description |
|-------|------|-------------|
| `listeners` | `Vec<ListenerConfig>` | Independent TLS endpoints, each with its own bind address and TLS config (see ADR-019) |
| `health_check_port` | `u16` | Port for local health check endpoint (default: `9900`; set to `0` to disable; see ADR-013) |
| `admin_socket_path` | `String` | Unix domain socket path for admin API (default: `/run/reverse-proxy/admin.sock`; empty string to disable; see ADR-014) |
| `log_level` | `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"` | Logging verbosity |
| `log_format` | `"text"` or `"json"` | Log output format |
**ListenerConfig** (per-listener static config):
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `bind_addr` | `String` | IP address to bind to (must be explicit, no `0.0.0.0`; see ADR-016) | | `bind_addr` | `String` | IP address to bind to (must be explicit, no `0.0.0.0`; see ADR-016) |
| `http_port` | `u16` | Port for HTTP→HTTPS redirect (default: `80`; set to `0` to disable) | | `http_port` | `u16` | Port for HTTP→HTTPS redirect (default: `80`; set to `0` to disable) |
| `https_port` | `u16` | Port for TLS listener (default: `443`) | | `https_port` | `u16` | Port for TLS listener (default: `443`) |
| `health_check_port` | `u16` | Port for local health check endpoint (default: `9900`; set to `0` to disable; see ADR-013) |
| `admin_socket_path` | `String` | Unix domain socket path for admin API (default: `/run/reverse-proxy/admin.sock`; empty string to disable; see ADR-014) |
| `tls.mode` | `"acme"` or `"manual"` | Certificate provisioning mode | | `tls.mode` | `"acme"` or `"manual"` | Certificate provisioning mode |
| `tls.acme_domains` | `Vec<String>` | Domains for ACME SAN certificate (ACME mode only) | | `tls.acme_domains` | `Vec<String>` | Domains for ACME SAN certificate (ACME mode only) |
| `tls.acme_cache_dir` | `String` | ACME state cache directory | | `tls.acme_cache_dir` | `String` | ACME state cache directory |
| `tls.acme_directory` | `"production"` or `"staging"` | Let's Encrypt directory | | `tls.acme_directory` | `"production"` or `"staging"` | Let's Encrypt directory |
| `tls.cert_path` | `String` | Certificate file path (manual mode only) | | `tls.cert_path` | `String` | Certificate file path (manual mode only) |
| `tls.key_path` | `String` | Private key 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 **Why listeners are static:** Each listener requires binding a TCP socket and
static/dynamic split. In summary: changing bind addresses, ports, or TLS mode constructing a TLS acceptor — operations that fundamentally require a restart.
requires creating new listeners and TLS configurations — operations that Changing a listener's bind address, TLS mode, or certificate configuration
fundamentally require a restart. cannot be done without creating new listeners. See ADR-008 and ADR-019.
### DynamicConfig ### DynamicConfig
@@ -100,7 +130,12 @@ connections immediately.
| `upstream` | `String` | Upstream address (e.g., `"127.0.0.1:3000"`) | | `upstream` | `String` | Upstream address (e.g., `"127.0.0.1:3000"`) |
| `upstream_scheme` | `"http"` or `"https"` | Protocol for upstream connection (default: `"http"`) | | `upstream_scheme` | `"http"` or `"https"` | Protocol for upstream connection (default: `"http"`) |
| `upstream_connect_timeout_secs` | `u64` | TCP connect timeout in seconds (default: `5`; see ADR-015, ADR-017) | | `upstream_connect_timeout_secs` | `u64` | TCP connect timeout in seconds (default: `5`; see ADR-015, ADR-017) |
| `upstream_request_timeout_secs` | `u64` | Full request timeout in seconds (default: `60`; see ADR-015, ADR-017) | | | `upstream_request_timeout_secs` | `u64` | Full request timeout in seconds (default: `60`; see ADR-015, ADR-017) |
Sites are defined per listener in the `[[listeners]]` entries. Each listener
routes its own sites independently. The `DynamicConfig` collects all sites
across all listeners for hot-reload via `ArcSwap`. When a config reload
occurs, all listener site mappings are updated atomically.
**Why these are dynamic:** See ADR-008 for the rationale. Site definitions **Why these are dynamic:** See ADR-008 for the rationale. Site definitions
and rate limits are per-request concerns that should not require restarting and rate limits are per-request concerns that should not require restarting
@@ -145,28 +180,19 @@ Both mechanisms converge on the same code path:
## TOML Config Format ## TOML Config Format
### Multi-Config (Dedicated-IP Per Domain)
The primary deployment model — each listener on its own IP with its own TLS
certificate:
```toml ```toml
# reverse-proxy config # reverse-proxy config
[server] # Global settings
bind_addr = "203.0.113.10" # Replace with actual bind address
http_port = 80
https_port = 443
health_check_port = 9900 # Local health check (0 to disable) health_check_port = 9900 # Local health check (0 to disable)
admin_socket_path = "/run/reverse-proxy/admin.sock" # Empty string to disable admin_socket_path = "/run/reverse-proxy/admin.sock" # Empty string to disable
[server.tls] [logging]
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" level = "info"
format = "text" # "text" or "json" format = "text" # "text" or "json"
@@ -177,31 +203,100 @@ burst = 20
[body] [body]
limit_bytes = 104857600 # 100 MB limit_bytes = 104857600 # 100 MB
[[sites]] # Listener 1: git.alk.dev on its own IP
[[listeners]]
bind_addr = "203.0.113.10"
http_port = 80
https_port = 443
[listeners.tls]
mode = "acme"
acme_domains = ["git.alk.dev"]
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache-git"
acme_directory = "production"
[[listeners.sites]]
host = "git.alk.dev" host = "git.alk.dev"
upstream = "127.0.0.1:3000" upstream = "127.0.0.1:3000"
upstream_scheme = "http" upstream_scheme = "http"
# upstream_connect_timeout_secs = 5 # Default: 5s # upstream_connect_timeout_secs = 5 # Default: 5s
# upstream_request_timeout_secs = 60 # Default: 60s # upstream_request_timeout_secs = 60 # Default: 60s
[[sites]] # Listener 2: alk.dev on its own IP with a manual certificate
[[listeners]]
bind_addr = "203.0.113.11"
http_port = 80
https_port = 443
[listeners.tls]
mode = "manual"
cert_path = "/etc/ssl/alk.dev/fullchain.pem"
key_path = "/etc/ssl/alk.dev/privkey.pem"
[[listeners.sites]]
host = "alk.dev" host = "alk.dev"
upstream = "127.0.0.1:8080" upstream = "127.0.0.1:8080"
upstream_scheme = "http" upstream_scheme = "http"
``` ```
### Shared-IP Multi-Domain (SAN Certificate)
A single listener serving multiple domains with one SAN certificate:
```toml
# Global settings
health_check_port = 9900
admin_socket_path = "/run/reverse-proxy/admin.sock"
[logging]
level = "info"
format = "text"
[rate_limit]
requests_per_second = 10
burst = 20
[body]
limit_bytes = 104857600
# Single listener with multi-domain SAN certificate
[[listeners]]
bind_addr = "203.0.113.10"
http_port = 80
https_port = 443
[listeners.tls]
mode = "acme"
acme_domains = ["git.alk.dev", "alk.dev"]
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache"
acme_directory = "production"
[[listeners.sites]]
host = "git.alk.dev"
upstream = "127.0.0.1:3000"
[[listeners.sites]]
host = "alk.dev"
upstream = "127.0.0.1:8080"
```
### Validation ### Validation
On startup, the config is validated: On startup, the config is validated:
1. `bind_addr` is not `0.0.0.0` (must be explicit) 1. At least one `[[listeners]]` entry must exist
2. In ACME mode, `acme_domains` must be non-empty 2. Each listener's `bind_addr` is not `0.0.0.0` (must be explicit; see ADR-016)
3. In manual mode, `cert_path` and `key_path` must both be set and the files 3. Each listener's `bind_addr` and `https_port` combination must be unique
4. In ACME mode, `acme_domains` must be non-empty
5. In manual mode, `cert_path` and `key_path` must both be set and the files
must be readable must be readable
4. Each site must have a `host` and `upstream` 6. Each site must have a `host` and `upstream`
5. Site `host` values must be unique (no duplicate hostnames) 7. Site `host` values must be unique across all listeners (no duplicate
6. `rate_limit.requests_per_second` must be > 0 hostnames, even across different listeners). Duplicate hostnames would create
7. `body.limit_bytes` must be > 0 ambiguous routing — the proxy would not know which listener's upstream to
route a request to when the `Host` header matches multiple sites.
8. `rate_limit.requests_per_second` must be > 0
9. `body.limit_bytes` must be > 0
On SIGHUP reload, the same validation applies. If the new config fails 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 validation, the reload is rejected and the old config remains active. An error
@@ -225,6 +320,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [014](decisions/014-unix-socket-reload.md) | Unix domain socket config reload API | Programmatic reload with success/failure feedback | | [014](decisions/014-unix-socket-reload.md) | Unix domain socket config reload API | Programmatic reload with success/failure feedback |
| [015](decisions/015-per-site-timeouts.md) | Per-site upstream timeouts with defaults | 5s connect / 60s request defaults, per-site overrides | | [015](decisions/015-per-site-timeouts.md) | Per-site upstream timeouts with defaults | 5s connect / 60s request defaults, per-site overrides |
| [016](decisions/016-explicit-bind-address.md) | Explicit bind address required | Rejects `0.0.0.0` to prevent accidental exposure | | [016](decisions/016-explicit-bind-address.md) | Explicit bind address required | Rejects `0.0.0.0` to prevent accidental exposure |
| [019](decisions/019-multi-config-listeners.md) | Multi-config listeners | `[[listeners]]` supporting both dedicated-IP and shared-IP deployment models |
## Open Questions ## Open Questions
@@ -233,5 +329,5 @@ questions affecting this document:
- ~~**OQ-04**: Should config reload support a Unix domain socket API in addition - ~~**OQ-04**: Should config reload support a Unix domain socket API in addition
to SIGHUP?~~ (resolved — ADR-014: Unix domain socket admin API added) to SIGHUP?~~ (resolved — ADR-014: Unix domain socket admin API added)
- **OQ-07**: Should per-site TLS overrides be supported for mixed ACME/manual - ~~**OQ-07**: Should per-site TLS overrides be supported for mixed ACME/manual
domains? (open) domains?~~ (resolved — ADR-019: `[[listeners]]` with per-listener TLS config)

View File

@@ -31,7 +31,8 @@ and `rustls-acme` supports multi-domain certificates natively.
Move multi-site support from Phase 2 into Phase 1. The proxy supports multiple Move multi-site support from Phase 2 into Phase 1. The proxy supports multiple
sites from the initial release: sites from the initial release:
- `[[sites]]` array in config (already the planned format) - `[[listeners.sites]]` array in each listener config (after ADR-019; was
`[[sites]]` at top level)
- Host-based routing via axum's `Host` extractor (already the planned approach) - Host-based routing via axum's `Host` extractor (already the planned approach)
- Multi-domain ACME certificate provisioning via `rustls-acme` - Multi-domain ACME certificate provisioning via `rustls-acme`
- Each site maps a hostname to an upstream address - Each site maps a hostname to an upstream address
@@ -78,8 +79,7 @@ Phase 3 remains future enhancements.
- Slightly more testing surface (must verify correct routing with multiple - Slightly more testing surface (must verify correct routing with multiple
sites) sites)
- Must test multi-domain ACME provisioning (not just single-domain) - Must test multi-domain ACME provisioning (not just single-domain)
- Wildcard or fallback site behavior needs to be defined (addressed in - Wildcard or fallback site behavior is defined by the listener's site routing
OQ-07)
## References ## References

View File

@@ -30,23 +30,22 @@ certificate covering all proxied domains. Manual mode uses certificate file
paths (single cert file with all domains, or one cert per domain resolved via paths (single cert file with all domains, or one cert per domain resolved via
SNI). SNI).
The config format changes from the previous single-domain format: With ADR-019, TLS configuration lives inside `[[listeners]]` entries. Each
listener has its own TLS mode and domain list. The config format is:
```toml ```toml
# Previous (single-domain) format — no longer used # Current format (after ADR-019)
[tls] [[listeners]]
mode = "acme" bind_addr = "203.0.113.10"
acme_domain = "git.alk.dev" # single string
```
To the current multi-domain format: [listeners.tls]
```toml
[tls]
mode = "acme" mode = "acme"
acme_domains = ["git.alk.dev", "alk.dev"] # array of strings acme_domains = ["git.alk.dev", "alk.dev"] # array of strings
``` ```
The previous single-listener format (pre-ADR-019) used a `[server.tls]` section
which is no longer valid.
In ACME mode, `rustls-acme` provisions a single certificate covering all In ACME mode, `rustls-acme` provisions a single certificate covering all
listed domains via Subject Alternative Names (SAN). This is the standard listed domains via Subject Alternative Names (SAN). This is the standard
Let's Encrypt approach for multi-domain certificates. Let's Encrypt approach for multi-domain certificates.
@@ -82,7 +81,7 @@ certificate or separate certificates resolved via SNI).
domains must be validated) — mitigated by Let's Encrypt's domain-level domains must be validated) — mitigated by Let's Encrypt's domain-level
validation validation
- Per-site TLS configuration (e.g., a domain with a manual cert) requires a - Per-site TLS configuration (e.g., a domain with a manual cert) requires a
future config extension (OQ-07) future config extension — addressed by ADR-019 (multi-config listeners)
## References ## References
@@ -90,3 +89,4 @@ certificate or separate certificates resolved via SNI).
- [config.md](../config.md) - [config.md](../config.md)
- ADR-010 (multi-site in Phase 1) - ADR-010 (multi-site in Phase 1)
- ADR-004 (ACME-primary certificate management) - ADR-004 (ACME-primary certificate management)
- ADR-019 (multi-config listener support)

View File

@@ -18,8 +18,9 @@ deployment.
## Decision ## Decision
The `bind_addr` field must be an explicit IP address. `0.0.0.0` is rejected The `bind_addr` field on each `[[listeners]]` entry must be an explicit IP
during config validation. The proxy will not start if `bind_addr` is `0.0.0.0`. address. `0.0.0.0` is rejected during config validation. The proxy will not
start if any listener's `bind_addr` is `0.0.0.0`.
## Rationale ## Rationale

View File

@@ -0,0 +1,171 @@
# ADR-019: Multi-Config Listener Support
## Status
Accepted
## Context
OQ-07 asked whether per-site TLS overrides should be supported for mixed
ACME/manual domains. The original framing assumed a single listener with one
TLS configuration, where the question was about mixing certificate sources
within that listener.
However, there are two distinct deployment models for multi-domain TLS:
1. **Shared-IP multi-domain**: One IP address, one TLS listener, one SAN
certificate covering multiple domains via SNI. All domains share the same
ACME configuration. This is what the current architecture documents describe.
2. **Dedicated-IP single-domain (multi-config)**: Each IP address gets its own
SSL certificate for its own domain. In this model, 1 IP = 1 SSL = 1 domain.
Each listener is independently configured with its own bind address, TLS
certificate, and site mapping. No SNI multiplexing is needed because each
domain has its own IP.
The actual deployment uses model 2 (dedicated-IP single-domain). The proxy
should support both models, and the choice between them should be a deployment
concern, not an architectural limitation.
There are two approaches to supporting model 2:
- **Multiple instances**: Run separate `reverse-proxy` processes, each with
their own config file binding to a different IP. Simple, isolated, no
code changes needed.
- **Multi-listener single process**: One process with a `[[listeners]]`
config that defines multiple independent listeners, each with their own
bind address, TLS config, and site mapping. More complex but shares
resources (process overhead, connection pool, logging).
## Decision
Support both deployment models by extending the configuration format with
`[[listeners]]`, where each listener is an independent TLS endpoint with its
own bind address, TLS configuration, and site routing.
The config format changes from a single implicit listener to explicit
`[[listeners]]` entries:
```toml
# Multi-config: two listeners, each on a different IP
[[listeners]]
bind_addr = "203.0.113.10"
http_port = 80
https_port = 443
[listeners.tls]
mode = "acme"
acme_domains = ["git.alk.dev"]
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache"
acme_directory = "production"
[[listeners.sites]]
host = "git.alk.dev"
upstream = "127.0.0.1:3000"
[[listeners]]
bind_addr = "203.0.113.11"
http_port = 80
https_port = 443
[listeners.tls]
mode = "manual"
cert_path = "/etc/ssl/alk.dev/fullchain.pem"
key_path = "/etc/ssl/alk.dev/privkey.pem"
[[listeners.sites]]
host = "alk.dev"
upstream = "127.0.0.1:8080"
```
Each `[[listeners]]` entry is an independent TLS endpoint with:
- Its own `bind_addr` (required, no `0.0.0.0`; see ADR-016)
- Its own `http_port` and `https_port`
- Its own `tls` configuration (ACME or manual)
- Its own `[[sites]]` array for host-based routing
The single-listener case is a natural subset of this format — a config with
one `[[listeners]]` entry behaves identically to the current single-listener
design. The multi-domain SAN case (one IP, one cert, multiple domains) is
also supported: a single listener with multiple domains in `acme_domains`
and multiple `[[sites]]`.
Global settings (logging, rate limiting, body limits, health check, admin
socket) are top-level configuration. Listener-specific settings (bind address,
ports, TLS, sites) live inside each `[[listeners]]` entry.
Example configuration:
```toml
# Global settings
health_check_port = 9900
admin_socket_path = "/run/reverse-proxy/admin.sock"
[logging]
level = "info"
format = "text"
[rate_limit]
requests_per_second = 10
burst = 20
[body]
limit_bytes = 104857600
# Listener definitions
[[listeners]]
bind_addr = "203.0.113.10"
# ... listener config ...
```
## Rationale
- The dedicated-IP model (1 IP = 1 SSL = 1 domain) is our actual deployment.
The architecture should natively support it.
- The shared-IP multi-domain model (SAN certificate) is also common and useful.
Supporting both is straightforward with the `[[listeners]]` format.
- Multiple instances would work but wastes resources (separate processes,
separate connection pools, separate logging pipelines) for what is
fundamentally the same binary doing the same job.
- A single process with multiple listeners is more efficient: one process, one
log pipeline, one config reload mechanism, one health check endpoint.
- The `[[listeners]]` format is a natural extension — each listener is
independent, but global concerns (logging, rate limiting, health checks)
are shared, which is correct since they're all the same proxy.
- The single-listener case is a degenerate case of `[[listeners]]` with one
entry, so existing configurations translate trivially.
## Consequences
**Positive:**
- Natively supports both deployment models (dedicated-IP and shared-IP)
- Single process for all listeners: shared logging, config reload, health
checks, rate limiting
- Each listener is fully independent — different IPs, different TLS configs,
different domains
- The config format is regular and predictable — `[[listeners]]` is a
natural TOML array of tables
- Mixed ACME/manual configurations are naturally supported: each listener
chooses its own TLS mode
- The single-listener, single-domain case is trivially supported
**Negative:**
- Config format change from `[server]` with implicit single listener to
`[[listeners]]` with explicit listener entries
- The proxy must manage multiple TCP listeners and TLS acceptors, which
adds implementation complexity
- Each listener needs its own ACME state machine (in ACME mode), increasing
resource usage proportional to listener count
- Global rate limiting applies across all listeners (per-IP rate limiting
doesn't distinguish which listener received the request — this is correct
behavior since the IP is the same regardless of listener)
## References
- [config.md](../config.md)
- [tls.md](../tls.md)
- [overview.md](../overview.md)
- ADR-008 (static/dynamic config split)
- ADR-011 (multi-domain TLS config)
- ADR-016 (explicit bind address)
- OQ-05 (multiple bind addresses — now resolved via per-listener `bind_addr`)

View File

@@ -27,19 +27,19 @@ last_updated: 2026-06-11
See ADR-007. See ADR-007.
- **Cross-references**: ADR-007 - **Cross-references**: ADR-007
### OQ-07: Should per-site TLS overrides be supported for mixed ACME/manual domains? ### ~~OQ-07: Should per-site TLS overrides be supported for mixed ACME/manual domains?~~
- **Origin**: [tls.md](tls.md), [config.md](config.md) - **Origin**: [tls.md](tls.md), [config.md](config.md)
- **Status**: open - **Status**: resolved
- **Priority**: low - **Priority**: low
- **Context**: Phase 1 uses a single TLS configuration (ACME or manual) for all - **Resolution**: Resolved by introducing `[[listeners]]` configuration. Each
domains. All domains share the same ACME config and certificate. If a future listener is an independent TLS endpoint with its own bind address, TLS config,
domain needs a manual certificate (e.g., a corporate CA cert) while other and site routing. This supports both deployment models: (1) shared-IP
domains use ACME, a per-site TLS override would be needed. This would require multi-domain (one listener, SAN certificate, SNI routing) and (2) dedicated-IP
a custom `ResolvesServerCert` that combines ACME-provisioned certs with single-domain (multiple listeners, each with its own IP/cert/domain). Mixed
manually loaded certs. For now, all proxied domains use the same ACME config, ACME/manual configurations are naturally supported since each listener has its
so this is not needed. own TLS mode. See ADR-019.
- **Cross-references**: ADR-011 - **Cross-references**: ADR-011, ADR-019
## Logging and Monitoring ## Logging and Monitoring
@@ -73,11 +73,12 @@ last_updated: 2026-06-11
- **Origin**: [overview.md](overview.md) - **Origin**: [overview.md](overview.md)
- **Status**: resolved - **Status**: resolved
- **Priority**: low - **Priority**: low
- **Resolution**: A single `bind_addr` is sufficient. The proxy binds to one - **Resolution**: A single `bind_addr` per listener entry is sufficient. ADR-019
explicit IP address (not `0.0.0.0`). Multi-address binding is not needed for introduced `[[listeners]]`, where each listener has its own `bind_addr`. This
this single-server deployment. If needed in the future, `bind_addr` could be supports multiple bind addresses in a single process — one per listener —
extended to an array. See config.md for the `bind_addr` field. without needing an array of addresses on a single listener. See ADR-016 and
- **Cross-references**: ADR-016 ADR-019.
- **Cross-references**: ADR-016, ADR-019
## Proxy ## Proxy

View File

@@ -38,8 +38,11 @@ details.
### In Scope ### In Scope
- **Phase 1**: Multi-site reverse proxy with TLS termination - **Phase 1**: Multi-site reverse proxy with TLS termination
- TLS termination with ACME (Let's Encrypt) multi-domain certificate management - Multiple independent TLS listeners via `[[listeners]]` configuration
- Manual certificate paths as fallback mode - Each listener has its own bind address, TLS config, and site routing
- Supports both dedicated-IP (1 IP = 1 cert = 1 domain) and shared-IP
(SAN certificate) deployment models (ADR-019)
- TLS termination with ACME (Let's Encrypt) and manual certificate management
- Cipher suite restriction matching nginx scope (ECDHE-AES-GCM + TLS 1.3) - Cipher suite restriction matching nginx scope (ECDHE-AES-GCM + TLS 1.3)
- HTTP → HTTPS redirect - HTTP → HTTPS redirect
- Host-based routing to multiple upstream services - Host-based routing to multiple upstream services
@@ -49,7 +52,7 @@ details.
- Per-site upstream timeouts with sensible defaults (5s connect, 60s request) - Per-site upstream timeouts with sensible defaults (5s connect, 60s request)
- Request rate limiting with fail2ban-compatible logging (global per-IP) - Request rate limiting with fail2ban-compatible logging (global per-IP)
- 100 MB body size limit (global) - 100 MB body size limit (global)
- Configurable bind address (must be explicit, no `0.0.0.0`) - Configurable bind addresses (must be explicit, no `0.0.0.0`)
- Local health check endpoint on separate port (default: 9900, localhost only) - Local health check endpoint on separate port (default: 9900, localhost only)
- Unix domain socket admin API for config reload with feedback - Unix domain socket admin API for config reload with feedback
- Graceful shutdown (SIGTERM handling) - Graceful shutdown (SIGTERM handling)
@@ -64,7 +67,6 @@ details.
- **Phase 3**: Future enhancements - **Phase 3**: Future enhancements
- Wildcard subdomain support - Wildcard subdomain support
- Per-site TLS overrides (manual certs for specific domains)
### Out of Scope ### Out of Scope
@@ -75,36 +77,37 @@ details.
- Static file serving - Static file serving
- Access control beyond rate limiting (no auth, no IP allowlists in Phase 1) - Access control beyond rate limiting (no auth, no IP allowlists in Phase 1)
- CGI, SCGI, uWSGI, FastCGI - CGI, SCGI, uWSGI, FastCGI
- Per-site TLS configuration (all domains share one ACME config in Phase 1)
## Architecture ## Architecture
``` ```
┌────────────────────────────────────┐ ┌────────────────────────────────────┐
│ reverse-proxy (Rust/axum) │ │ reverse-proxy (Rust/axum) │
config.toml ──────► │ StaticConfig + DynamicConfig │ config.toml ──────► │ StaticConfig + DynamicConfig │
│ (ArcSwap for hot-reload) │ │ (ArcSwap for hot-reload) │
│ │ │ │
bind_addr:80 ──► │ HTTP listener → 301 redirect │ ┌─ Listener 1 ─────────────────┐
│ to HTTPS bind_addr_1:80 ───► │ │ HTTP → 301 redirect
│ └────────────────────────────────┘ │
bind_addr_1:443 ───► │ │ TLS listener (tokio-rustls) │ │
│ │ ├─ ACME or Manual TLS config │ │
│ │ └─ axum router │ │
│ │ ├─ Host-based routing │ │
│ │ ├─ git.alk.dev → :3000 │ │
│ │ └─ Rate limiting, headers │ │
│ └────────────────────────────────┘ │
│ │ │ │
bind_addr:443 ──► │ TLS listener (tokio-rustls) │ ┌─ Listener N ─────────────────┐
│ ├─ ACME mode: rustls-acme resolver bind_addr_N:80 ───► │ HTTP → 301 redirect │
│ (multi-domain SAN cert, └────────────────────────────────┘
│ │ auto-provision & renew) bind_addr_N:443 ───► │ │ TLS listener (tokio-rustls)
─ Manual mode: cert/key file paths │ ├─ Manual TLS cert │
│ │ └─ axum router │ │
│ │ ├─ alk.dev → :8080 │ │
│ │ └─ Rate limiting, headers │ │
│ └────────────────────────────────┘ │
│ │ │ │
axum router /health → 200 OK (port 9900)
│ ├─ Host-based routing │
│ │ ├─ git.alk.dev → :3000 │
│ │ └─ alk.dev → :8080 │
│ ├─ Rate limiting middleware │
│ ├─ Proxy header injection │
│ ├─ Body size limit (100MB) │
│ └─ Reverse proxy handler │
│ └─ hyper Client → upstream │
│ │
│ /health → 200 OK │
└────────────────────────────────────┘ └────────────────────────────────────┘
``` ```
@@ -176,6 +179,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [016](decisions/016-explicit-bind-address.md) | Explicit bind address required | Rejects `0.0.0.0` to prevent accidental exposure | | [016](decisions/016-explicit-bind-address.md) | Explicit bind address required | Rejects `0.0.0.0` to prevent accidental exposure |
| [017](decisions/017-upstream-connection-defaults.md) | Upstream connection defaults | HTTP/1.1, no redirects, connection pooling | | [017](decisions/017-upstream-connection-defaults.md) | Upstream connection defaults | HTTP/1.1, no redirects, connection pooling |
| [018](decisions/018-body-size-limit.md) | Request body size limit | 100 MB default matching nginx, Gitea push compatibility | | [018](decisions/018-body-size-limit.md) | Request body size limit | 100 MB default matching nginx, Gitea push compatibility |
| [019](decisions/019-multi-config-listeners.md) | Multi-config listeners | `[[listeners]]` supporting both dedicated-IP and shared-IP deployment models |
## Open Questions ## Open Questions
@@ -184,4 +188,4 @@ questions affecting this document:
- ~~**OQ-01**: Should cipher suites be restricted beyond rustls defaults?~~ (resolved — ADR-012) - ~~**OQ-01**: Should cipher suites be restricted beyond rustls defaults?~~ (resolved — ADR-012)
- ~~**OQ-03**: Should the health check endpoint be on a separate port?~~ (resolved — ADR-013) - ~~**OQ-03**: Should the health check endpoint be on a separate port?~~ (resolved — ADR-013)
- **OQ-07**: Should per-site TLS overrides be supported for mixed ACME/manual domains? (open) - ~~**OQ-07**: Should per-site TLS overrides be supported for mixed ACME/manual domains?~~ (resolved — ADR-019: `[[listeners]]` with per-listener TLS config)

View File

@@ -131,11 +131,11 @@ specified, defaults of 5s connect and 60s request are used.
### 5. HTTP → HTTPS Redirect ### 5. HTTP → HTTPS Redirect
A separate HTTP listener on port 80 handles redirect. It reads the `Host` A separate HTTP listener on port 80 (per listener) handles redirect. It reads
header from the incoming request and returns a 301 Permanent Redirect to the the `Host` header from the incoming request and returns a 301 Permanent Redirect
HTTPS equivalent URL (preserving the path and query string). to the HTTPS equivalent URL (preserving the path and query string).
This listener runs on the same bind address as the TLS listener but on port 80. Each listener has its own HTTP redirect on its own bind address.
## Upstream Connection ## Upstream Connection

View File

@@ -19,36 +19,50 @@ upstream services. It replaces nginx's `ssl_certificate`, `ssl_protocols`, and
## Architecture ## Architecture
The proxy supports multiple independent TLS listeners, each with its own bind
address, TLS configuration, and site routing. See ADR-019 for the rationale.
``` ```
┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────┐
│ TLS Termination │ │ TLS Termination │
│ │ │ │
bind_addr:443 ──► │ TcpListener::bind(bind_addr) │ ┌─ Listener 1 ─────────────────────────┐
│ │ bind_addr_1:443
│ TcpListener::bind(bind_addr_1)
tokio-rustls::TlsAcceptor │ │ │
├─ ACME mode: tokio-rustls::TlsAcceptor
│ │ rustls-acme::ResolvesServerCertAcme
│ (auto-provisions & renews certs) ACME or Manual TLS config │
│ (per-listener TLS mode)
└─ Manual mode:
rustls::ServerConfig
.with_single_cert(cert_chain, key) │ TlsStream<TcpStream> │
│ │ │ │ │
│ │ ▼ │ │
│ │ axum router (per-listener sites) │ │
│ └───────────────────────────────────────┘ │
│ │ │ │
┌─ Listener N ─────────────────────────┐
│ bind_addr_N:443
TlsStream<TcpStream> │ ... (same structure)
└───────────────────────────────────────┘
│ ▼ │
│ hyper::service_fn → axum router │
└──────────────────────────────────────────┘ └──────────────────────────────────────────┘
bind_addr:80 ──► HTTP listener (redirect to HTTPS, no TLS) bind_addr:80 ──► HTTP listener (redirect to HTTPS, no TLS)
``` ```
Each listener is independently configured. This supports two deployment models:
1. **Shared-IP multi-domain**: One listener with multiple domains in
`acme_domains`, using a single SAN certificate and SNI routing.
2. **Dedicated-IP single-domain**: Multiple listeners, each with its own IP,
its own TLS certificate, and its own site. No SNI needed.
## Certificate Provisioning ## Certificate Provisioning
Each listener has its own TLS mode (ACME or manual), configured independently.
### ACME Mode (Primary) ### ACME Mode (Primary)
Uses `rustls-acme` for automatic certificate provisioning and renewal through Uses `rustls-acme` for automatic certificate provisioning and renewal through
@@ -57,12 +71,14 @@ no deploy hooks.
**How it works:** **How it works:**
1. `AcmeCertProvider` configures the ACME client with the domain list, cache 1. Each listener in ACME mode creates its own `AcmeCertProvider` with the
directory, and Let's Encrypt directory (staging or production). listener's domain list, cache directory, and Let's Encrypt directory.
2. `AcmeConfig::new(domains)` creates an ACME configuration for all listed 2. `AcmeConfig::new(domains)` creates an ACME configuration for the domains
domains. Let's Encrypt will issue a single SAN certificate covering all listed in that listener's `acme_domains`. Let's Encrypt will issue a
domains. certificate covering those domains (a single SAN certificate or a
3. The ACME state machine runs as a background tokio task, handling: single-domain certificate, depending on how many domains are listed).
3. The ACME state machine runs as a background tokio task per listener,
handling:
- Account registration with Let's Encrypt - Account registration with Let's Encrypt
- Certificate ordering - Certificate ordering
- TLS-ALPN-01 challenge (or HTTP-01 challenge) - TLS-ALPN-01 challenge (or HTTP-01 challenge)
@@ -73,10 +89,13 @@ no deploy hooks.
5. When a new certificate is issued, the resolver updates atomically — no 5. When a new certificate is issued, the resolver updates atomically — no
restart or signal handling needed. restart or signal handling needed.
**Configuration:** **Configuration (within a `[[listeners]]` entry):**
```toml ```toml
[server.tls] [[listeners]]
bind_addr = "203.0.113.10"
[listeners.tls]
mode = "acme" mode = "acme"
acme_domains = ["git.alk.dev", "alk.dev"] acme_domains = ["git.alk.dev", "alk.dev"]
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache" acme_cache_dir = "/var/lib/reverse-proxy/acme-cache"
@@ -85,7 +104,8 @@ acme_directory = "production" # or "staging" for testing
**Cache directory:** The `DirCache` from rustls-acme persists ACME account data, **Cache directory:** The `DirCache` from rustls-acme persists ACME account data,
private keys, and certificates between restarts. This avoids re-provisioning on private keys, and certificates between restarts. This avoids re-provisioning on
every restart. every restart. Each listener should use its own cache directory to avoid conflicts
between separate ACME state machines.
### Manual Mode (Fallback) ### Manual Mode (Fallback)
@@ -94,10 +114,13 @@ corporate CAs, or BYO certificates), the proxy loads certificates from file
paths at startup. paths at startup.
```toml ```toml
[tls] [[listeners]]
bind_addr = "203.0.113.11"
[listeners.tls]
mode = "manual" mode = "manual"
cert_path = "/etc/letsencrypt/live/git.alk.dev/fullchain.pem" cert_path = "/etc/ssl/alk.dev/fullchain.pem"
key_path = "/etc/letsencrypt/live/git.alk.dev/privkey.pem" key_path = "/etc/ssl/alk.dev/privkey.pem"
``` ```
Certificate files are loaded once at startup using `rustls_pemfile`. Manual Certificate files are loaded once at startup using `rustls_pemfile`. Manual
@@ -138,27 +161,42 @@ parity during migration.
### ServerConfig Construction ### ServerConfig Construction
Each listener constructs its own `ServerConfig` based on its TLS mode.
For manual mode, the `ServerConfig` is built with `with_no_client_auth()` and For manual mode, the `ServerConfig` is built with `with_no_client_auth()` and
a custom `ResolvesServerCert` implementation that maps SNI hostnames to the loaded certificate chain and private key. If the listener serves multiple
certificate/key pairs loaded from disk. domains from a single listener, a custom `ResolvesServerCert` implementation
maps SNI hostnames to certificate/key pairs loaded from disk.
For ACME mode, the `ServerConfig` is built with `with_cert_resolver()`, passing For ACME mode, the `ServerConfig` is built with `with_cert_resolver()`, passing
the `ResolvesServerCertAcme` resolver. The ACME configuration includes all the `ResolvesServerCertAcme` resolver. The ACME configuration includes the
domains listed in `acme_domains`, and the resolver manages a single SAN domains listed in that listener's `acme_domains`, and the resolver manages the
certificate covering all of them. The ACME TLS-ALPN-01 protocol identifier certificate. The ACME TLS-ALPN-01 protocol identifier (`acme-tls/1`) must be
(`acme-tls/1`) must be registered in the `alpn_protocols` list so the server registered in the `alpn_protocols` list so the server can respond to
can respond to TLS-ALPN-01 challenges. TLS-ALPN-01 challenges.
Both modes use the `aws_lc_rs` crypto provider with safe default protocol Both modes use the `aws_lc_rs` crypto provider with safe default protocol
versions (TLS 1.2 and TLS 1.3). versions (TLS 1.2 and TLS 1.3).
## SNI-Based Certificate Selection ## SNI-Based Certificate Selection
### ACME Mode (Multi-Domain) ### Dedicated-IP Single-Domain (Multi-Config)
In the dedicated-IP model, each listener binds to its own IP address and serves
exactly one domain with one certificate. SNI is not required for certificate
selection — the listener's TLS config already has the correct certificate.
This is the simplest case: one IP, one listener, one certificate, one domain.
No SNI resolution logic is needed.
### Shared-IP Multi-Domain (SAN Certificate)
In the shared-IP model, a single listener serves multiple domains using a SAN
certificate. SNI-based certificate selection is required.
In ACME mode, `rustls-acme` manages a single SAN certificate covering all In ACME mode, `rustls-acme` manages a single SAN certificate covering all
configured domains. The `ResolvesServerCertAcme` resolver automatically serves configured domains for that listener. The `ResolvesServerCertAcme` resolver
the correct certificate during the TLS handshake. automatically serves the correct certificate during the TLS handshake.
1. **TLS handshake**: The client sends the SNI extension indicating which 1. **TLS handshake**: The client sends the SNI extension indicating which
hostname it's connecting to. hostname it's connecting to.
@@ -172,10 +210,11 @@ This is the same pattern nginx uses — SNI selects the cert during TLS, then
`Host` header selects the server block. ACME mode handles this automatically `Host` header selects the server block. ACME mode handles this automatically
through the cert resolver. through the cert resolver.
### Manual Mode (Multi-Domain) ### Manual Mode with Multiple Domains
In manual mode, a custom `ResolvesServerCert` implementation is required to In manual mode on a shared-IP listener, a custom `ResolvesServerCert`
map SNI hostnames to the correct `CertifiedKey`. This implementation: implementation maps SNI hostnames to the correct `CertifiedKey`. This
implementation:
1. Loads certificate files at startup (or on SIGHUP for reload) 1. Loads certificate files at startup (or on SIGHUP for reload)
2. Maps each domain name to its certificate chain and private key 2. Maps each domain name to its certificate chain and private key
@@ -183,17 +222,17 @@ map SNI hostnames to the correct `CertifiedKey`. This implementation:
matching `CertifiedKey` matching `CertifiedKey`
The custom resolver must handle the case where no matching certificate exists The custom resolver must handle the case where no matching certificate exists
for the SNI hostname — in this case, the handshake fails, which is the for the SNI hostname — in this case, the handshake fails, which is the correct
correct behavior (we don't serve a default certificate for unknown domains). behavior (we don't serve a default certificate for unknown domains).
See [open-questions.md](open-questions.md) OQ-07 for per-site TLS overrides.
## HTTP Listener (Port 80) ## HTTP Listener (Port 80)
The HTTP listener on port 80 is a plain TCP listener with no TLS. It has one Each listener has its own HTTP listener on port 80 (or the configured
job: redirect all requests to the HTTPS equivalent. `http_port`). It is a plain TCP listener with no TLS. It has one job: redirect
all requests to the HTTPS equivalent.
The listener binds to the same IP address as the TLS listener, but on port 80. Each HTTP listener binds to the same IP address as its corresponding TLS
listener, but on port 80.
### ACME Challenge Type ### ACME Challenge Type
@@ -225,6 +264,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [010](decisions/010-multi-site-phase1.md) | Multi-site in Phase 1 | Multiple domains from initial release | | [010](decisions/010-multi-site-phase1.md) | Multi-site in Phase 1 | Multiple domains from initial release |
| [011](decisions/011-multi-domain-tls.md) | Multi-domain TLS config | Single SAN certificate covering all domains via rustls-acme | | [011](decisions/011-multi-domain-tls.md) | Multi-domain TLS config | Single SAN certificate covering all domains via rustls-acme |
| [012](decisions/012-cipher-suite-restriction.md) | Restrict cipher suites | Match nginx scope: four ECDHE-AES-GCM suites for TLS 1.2, all TLS 1.3 suites | | [012](decisions/012-cipher-suite-restriction.md) | Restrict cipher suites | Match nginx scope: four ECDHE-AES-GCM suites for TLS 1.2, all TLS 1.3 suites |
| [019](decisions/019-multi-config-listeners.md) | Multi-config listeners | `[[listeners]]` supporting both dedicated-IP and shared-IP deployment models |
## Open Questions ## Open Questions
@@ -232,5 +272,4 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key
questions affecting this document: questions affecting this document:
- ~~**OQ-01**: Should cipher suites be restricted beyond rustls defaults?~~ (resolved — ADR-012: restrict to nginx scope) - ~~**OQ-01**: Should cipher suites be restricted beyond rustls defaults?~~ (resolved — ADR-012: restrict to nginx scope)
- **OQ-07**: Should per-site TLS overrides be supported for mixed ACME/manual - ~~**OQ-07**: Should per-site TLS overrides be supported for mixed ACME/manual domains?~~ (resolved — ADR-019: `[[listeners]]` with per-listener TLS config)
domains? (open)