Add container deployment model (ADR-020) and fix review issues

- ADR-020: Document defense-in-depth rationale for running in a minimal
  Docker container (memory-safe language + container isolation), flexible
  upstream addressing (Docker DNS, loopback, LAN, tunnel endpoints),
  file-primary logging for fail2ban, and volume mount strategy
- ADR-016: Add allow_wildcard_bind override for container deployments
  where 0.0.0.0 is correct inside the container network namespace
- operations.md: Add container deployment section with Docker Compose
  example, networking table, volume mounts, and health check integration;
  flip logging to file-primary for fail2ban reliability; note systemd as
  alternative to container deployment
- config.md: Restructure logging fields into nested LoggingConfig (matching
  TOML [logging] section), add allow_wildcard_bind, shutdown_timeout_secs,
  and log_file_path fields; clarify upstream addressing supports Docker
  DNS and tunnel endpoints; update validation rule for 0.0.0.0 override
- overview.md: Update architecture diagram for container model with Docker
  networking and volume mounts; add ADR-020 reference
- proxy.md: Clarify X-Forwarded-Proto is determined by listener port, not
  hardcoded 80/443
- ADR-013: Fix health_check_port default contradiction (default is 9900,
  not 0/disabled as previously stated)
This commit is contained in:
2026-06-11 10:10:32 +00:00
parent 346754fb2b
commit fecc385d75
8 changed files with 385 additions and 48 deletions

View File

@@ -51,6 +51,7 @@ certificate via ACME.
| [017](decisions/017-upstream-connection-defaults.md) | Upstream Connection Defaults | 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 |
| [020](decisions/020-container-deployment.md) | Container Deployment Model | Accepted |
## Open Questions

View File

@@ -86,10 +86,19 @@ 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) |
| `allow_wildcard_bind` | `bool` | Allow `0.0.0.0` as a bind address. Required for container deployments. Default: `false` (see ADR-016, ADR-020) |
| `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 |
| `shutdown_timeout_secs` | `u64` | Maximum seconds to wait for in-flight requests during graceful shutdown (default: `30`) |
| `logging` | `LoggingConfig` | Logging configuration (see below) |
**LoggingConfig** (nested in `[logging]` TOML section):
| Field | Type | Description |
|-------|------|-------------|
| `level` | `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"` | Logging verbosity |
| `format` | `"text"` or `"json"` | Log output format |
| `log_file_path` | `String` | Path to log file. When set, structured logs are written to this file in addition to stdout/stderr. Strongly recommended for fail2ban integration in container deployments (see ADR-020). Default: not set (file logging disabled) |
**ListenerConfig** (per-listener static config):
@@ -127,7 +136,7 @@ connections immediately.
| 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` | `String` | Upstream address. Supports Docker DNS (`gitea:3000`), loopback (`127.0.0.1:3000`), LAN IPs, and tunnel endpoints. No assumption about upstream locality (see ADR-020) |
| `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_request_timeout_secs` | `u64` | Full request timeout in seconds (default: `60`; see ADR-015, ADR-017) |
@@ -195,6 +204,7 @@ admin_socket_path = "/run/reverse-proxy/admin.sock" # Empty string to disable
[logging]
level = "info"
format = "text" # "text" or "json"
# log_file_path = "/var/log/reverse-proxy/access.log" # Optional; always-on when set
[rate_limit]
requests_per_second = 10
@@ -251,6 +261,7 @@ admin_socket_path = "/run/reverse-proxy/admin.sock"
[logging]
level = "info"
format = "text"
# log_file_path = "/var/log/reverse-proxy/access.log" # Optional; always-on when set
[rate_limit]
requests_per_second = 10
@@ -285,7 +296,7 @@ upstream = "127.0.0.1:8080"
On startup, the config is validated:
1. At least one `[[listeners]]` entry must exist
2. Each listener's `bind_addr` is not `0.0.0.0` (must be explicit; see ADR-016)
2. Each listener's `bind_addr` is not `0.0.0.0` unless `allow_wildcard_bind = true` (in config) or `--allow-wildcard-bind` (CLI flag) is set (see ADR-016, ADR-020)
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
@@ -321,6 +332,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [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 |
| [019](decisions/019-multi-config-listeners.md) | Multi-config listeners | `[[listeners]]` supporting both dedicated-IP and shared-IP deployment models |
| [020](decisions/020-container-deployment.md) | Container deployment model | Flexible upstream addressing; `allow_wildcard_bind` override for containers |
## Open Questions

View File

@@ -29,9 +29,10 @@ Add a configurable health check port that binds to `127.0.0.1` only (localhost),
serving `/health` over plain HTTP. This is a separate listener from the main
HTTP and HTTPS listeners.
The port is configurable via `health_check_port` in StaticConfig. Setting it
to `0` (default) disables the separate health check listener, and `/health`
remains available on the main HTTPS listener as a fallback.
The port is configurable via `health_check_port` in StaticConfig. The default
value is `9900` (enabled, localhost only). Setting it to `0` disables the
separate health check listener, and `/health` remains available on the main
HTTPS listener as a fallback.
## Rationale

View File

@@ -19,8 +19,15 @@ deployment.
## Decision
The `bind_addr` field on each `[[listeners]]` entry must be an explicit IP
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`.
address. `0.0.0.0` is rejected during config validation unless explicitly
overridden. The proxy will not start if any listener's `bind_addr` is
`0.0.0.0` and the override is not enabled.
**Override mechanism**: `allow_wildcard_bind = true` in the config file, or
`--allow-wildcard-bind` on the command line. This flag permits `0.0.0.0` as a
valid bind address. It is intended for container deployments where the
container's network namespace is isolated and `0.0.0.0` is the correct address
(Docker publishes only the explicitly mapped ports).
## Rationale
@@ -31,24 +38,31 @@ start if any listener's `bind_addr` is `0.0.0.0`.
- `0.0.0.0` is often a default in example configurations and can be deployed
without the operator realizing the service is accessible on all interfaces
- Rejecting it at validation time prevents this common mistake
- If a deployment genuinely needs to bind all interfaces, `0.0.0.0` can be
overridden with an explicit flag, but this should be a deliberate choice
- In a container, `0.0.0.0` is correct and safe because the container's
network namespace isolates it — only Docker-published ports are reachable
from outside the container
- The override is opt-in so it must be a deliberate choice, not an accident
- This matches the principle of explicit over implicit for security-sensitive
configuration
## Consequences
**Positive:**
- Prevents accidental exposure on unintended network interfaces
- Prevents accidental exposure on unintended network interfaces in bare-metal
deployments
- Forces operators to be deliberate about which interface the proxy serves
- Config validation catches the mistake before deployment
- Container deployments work correctly with `0.0.0.0` when the override is
explicitly enabled
**Negative:**
- Not suitable for deployments that genuinely need to bind all interfaces
(mitigated by explicit override if needed in the future)
- Slightly more configuration required (operator must know their public IP)
- Container deployments require one extra config flag (`allow_wildcard_bind =
true`) — a minor operational step
- Slightly more configuration required for bare-metal (operator must know
their public IP)
## References
- [ADR-020: Container Deployment Model](020-container-deployment.md)
- [config.md](../config.md)
- [overview.md](../overview.md)

View File

@@ -0,0 +1,97 @@
# ADR-020: Container Deployment Model
## Status
Accepted
## Context
The reverse proxy replaces an nginx instance running directly on the host, which
is vulnerable to multiple actively-exploited CVEs (including CVE-2026-42945,
unauthenticated RCE). Rust's memory safety eliminates the bug class responsible
for most nginx CVEs, but containerization adds a second independent barrier.
Running the proxy in a minimal Docker container means that even if an attacker
finds a logic-level vulnerability in the proxy, they must also escape the
container boundary. The probability of chaining a Rust proxy exploit with a
container escape is materially lower than exploiting any single layer alone.
Additionally, the proxy must support flexible upstream addressing. While the
initial deployment runs all services on the same host (Gitea, Postgres, the
proxy itself in containers sharing a Docker network), upstreams may also be on
different machines — reachable via LAN IP, VPN, or SSH-based tunnel (alknet).
The configuration must not assume same-host deployment.
Finally, fail2ban integration requires reliable log access. Docker log drivers
(journald, json-file) introduce complexity and fragility for log parsing. A
log file on a volume mount is simpler, more reliable, and easier for fail2ban
to consume directly from the host filesystem.
## Decision
1. **The proxy runs in a minimal Docker container** (multi-stage build:
compile in `rust:alpine`, run in `alpine` or `scratch`). The container
image contains only the static binary and necessary runtime files.
2. **Bind address `0.0.0.0` is allowed inside containers** via an explicit
opt-in flag (`allow_wildcard_bind = true` in config, or
`--allow-wildcard-bind` CLI flag). The default remains: reject `0.0.0.0`
as a bind address. In a container, binding `0.0.0.0` is correct and safe
because the container's network namespace is isolated — it only exposes
interfaces Docker publishes via `-p` flags. See ADR-016 for the original
rationale and this override.
3. **Upstream addressing is unopinionated**. The `upstream` field in
`SiteConfig` accepts any `host:port` combination:
- Docker DNS names (`gitea:3000`) for containers on the same Docker network
- Loopback addresses (`127.0.0.1:3000`) for host-network or sidecar
deployments
- LAN IPs (`10.0.0.5:3000`) for same-network services
- VPN or tunnel endpoints as needed
The proxy makes no assumptions about upstream locality.
4. **Logging is file-primary for fail2ban integration.** The proxy writes
structured logs to a file (default: `/var/log/reverse-proxy/access.log`)
on a volume mount. stdout/stderr logging is always-on (for `docker logs`
and `journalctl`). File logging is the authoritative source for fail2ban
because it avoids the fragility of Docker log driver parsing.
5. **ACME state and admin socket are volume-mounted.** The ACME cache directory
(`/var/lib/reverse-proxy/acme-cache/`) and admin socket
(`/run/reverse-proxy/admin.sock`) are mounted as volumes so state persists
across container restarts and the host can send reload commands.
6. **Health checks use Docker's native mechanism.** The health check endpoint
on port 9900 (localhost only) is used directly by Docker's `HEALTHCHECK`
directive. No port publishing needed.
7. **SSH passthrough for Gitea** remains on the host. The proxy does not handle
SSH traffic. Port 22 traffic is routed directly to the Gitea container by
Docker, not through the reverse proxy. This matches the current nginx
deployment where SSH is independent.
## Consequences
**Positive:**
- Defense-in-depth: memory-safe language + container isolation + minimal image
- Flexible upstream addressing supports same-host, LAN, VPN, and tunnel
deployments without config changes
- File-based logging is simple and reliable for fail2ban — no Docker log
driver parsing required
- ACME state persists across container restarts via volume mounts
- Health check integrates natively with Docker's orchestration
**Negative:**
- Slightly more complex deployment (Docker Compose, volume mounts, network
configuration) compared to a bare binary
- `0.0.0.0` override must be documented clearly to prevent misuse in
non-container deployments
- File logging requires a volume mount, adding a deployment step
- Container rebuilds are needed for binary updates (mitigated by CI/CD)
## References
- [ADR-016: Explicit Bind Address Requirement](016-explicit-bind-address.md)
- [overview.md](../overview.md)
- [operations.md](../operations.md)
- [config.md](../config.md)

View File

@@ -87,14 +87,35 @@ REQUEST client_ip=203.0.113.50 host=git.alk.dev method=GET path=/user/repo statu
### Output
Logs are written to:
- **stdout/stderr**: For systemd/journald integration
- **File** (optional): For fail2ban consumption at
`/var/log/reverse-proxy/access.log`
Logs are written to two destinations simultaneously:
- **File** (primary): `/var/log/reverse-proxy/access.log` — the authoritative
source for fail2ban consumption. File logging is always enabled when the
`log_file_path` config is set. See ADR-020 for the rationale behind
file-primary logging.
- **stdout/stderr**: Always-on, for `docker logs`, `journalctl`, and
development use. Structured in the same format as the file output.
The `tracing-subscriber` layer configuration supports both simultaneously via
`Layer` composition.
### File Logging and fail2ban
File logging is the primary integration point for fail2ban. A log file on a
volume mount is simpler and more reliable than parsing Docker log drivers or
journald — no log driver configuration, no format conversion, no risk of
dropping events.
In container deployments, the log directory is volume-mounted so fail2ban on
the host can read it directly:
```yaml
volumes:
- /var/log/reverse-proxy:/var/log/reverse-proxy
```
A corresponding fail2ban filter definition and jail configuration are provided
as part of the deployment documentation.
### Log Levels
| Level | Use |
@@ -146,6 +167,9 @@ a separate concern that would produce 502/504 responses in the proxy handler.
## Systemd Integration
The proxy can also run as a bare binary via systemd (alternative to container
deployment). The systemd unit file is provided for this use case.
### Unit File
```ini
@@ -244,10 +268,181 @@ reverse-proxy [OPTIONS]
Options:
--config <PATH> Path to config file [default: /etc/reverse-proxy/config.toml]
--validate Validate config and exit
--allow-wildcard-bind Permit 0.0.0.0 as a bind address (for container deployments)
--help Show help
--version Show version
```
## Container Deployment
### Rationale
The proxy runs in a minimal Docker container for defense-in-depth. Even if an
attacker finds a logic-level vulnerability, they must also escape the container
boundary. Combined with Rust's memory safety, this provides two independent
barriers against exploitation. See ADR-020 for the full rationale.
### Container Image
Multi-stage build: compile in `rust:alpine`, run in `alpine` (or `scratch` for
absolute minimum). The final image contains only the static binary and
necessary runtime files. No shell, no package manager, no unnecessary tools.
The binary is compiled against the `x86_64-unknown-linux-musl` target for
static linking. The `aws_lc_rs` crypto provider is statically linked — no
OpenSSL dependency.
### Networking
The proxy supports flexible upstream addressing — no assumption about upstream
localality:
| Deployment | Upstream Address | Example |
|------------|-----------------|---------|
| Same-host, shared Docker network | Docker DNS name | `gitea:3000` |
| Same-host, host networking | Loopback | `127.0.0.1:3000` |
| Different host, LAN | LAN IP | `10.0.0.5:3000` |
| Different host, VPN/tunnel | Tunnel endpoint | Varies by tunnel config |
In container deployments, the proxy binds `0.0.0.0` inside the container and
Docker publishes specific ports to the host IP. The `allow_wildcard_bind`
override is required for this configuration (see ADR-016, ADR-020).
### Volume Mounts
| Container Path | Host Path | Purpose |
|---------------|-----------|---------|
| `/etc/reverse-proxy/config.toml` | Config file (read-only) | Proxy configuration |
| `/var/lib/reverse-proxy/acme-cache/` | ACME state directory | Certificate persistence across restarts |
| `/var/log/reverse-proxy/` | Log directory | fail2ban reads from host |
| `/run/reverse-proxy/admin.sock` | Admin socket | Host-side config reload commands |
### Docker Compose Example
This example shows the reverse proxy alongside a Gitea container on a shared
Docker network. Real IPs, secrets, and domain names are replaced with
placeholders.
```yaml
services:
reverse-proxy:
build: .
container_name: reverse-proxy
restart: unless-stopped
ports:
- "203.0.113.10:80:80" # HTTP redirect
- "203.0.113.10:443:443" # HTTPS
volumes:
- /etc/reverse-proxy/config.toml:/etc/reverse-proxy/config.toml:ro
- /var/lib/reverse-proxy/acme-cache:/var/lib/reverse-proxy/acme-cache
- /var/log/reverse-proxy:/var/log/reverse-proxy
- /run/reverse-proxy:/run/reverse-proxy
networks:
- proxy-net
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:9900/health"]
interval: 30s
timeout: 5s
retries: 3
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
ports:
- "203.0.113.10:22:2222" # Git SSH
volumes:
- /opt/gitea:/data
networks:
- proxy-net
- gitea-db-net
gitea-db:
image: postgres:16-alpine
container_name: gitea-db
restart: unless-stopped
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: gitea
volumes:
- gitea-db:/var/lib/postgresql/data
networks:
- gitea-db-net
networks:
proxy-net:
gitea-db-net:
volumes:
gitea-db:
```
Corresponding proxy config (inside the container):
```toml
allow_wildcard_bind = true
health_check_port = 9900
admin_socket_path = "/run/reverse-proxy/admin.sock"
[logging]
level = "info"
format = "text"
log_file_path = "/var/log/reverse-proxy/access.log"
[rate_limit]
requests_per_second = 10
burst = 20
[body]
limit_bytes = 104857600
[[listeners]]
bind_addr = "0.0.0.0"
http_port = 80
https_port = 443
[listeners.tls]
mode = "acme"
acme_domains = ["git.example.com"]
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache"
acme_directory = "production"
[[listeners.sites]]
host = "git.example.com"
upstream = "gitea:3000" # Docker DNS resolves this
```
### fail2ban Integration
In container deployments, fail2ban runs on the host and reads the proxy's log
file from the volume mount:
```
/var/log/reverse-proxy/access.log → fail2ban filter → iptables/nftables
```
This is simpler and more reliable than parsing Docker log drivers. The log
file is the authoritative source for rate limit events and access logs.
### Health Check
Docker's native `HEALTHCHECK` uses the local health endpoint:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -q --spider http://127.0.0.1:9900/health || exit 1
```
No port publishing is needed — the health check runs inside the container.
### SSH Traffic
SSH traffic for Git operations is not proxied through the reverse proxy. It
continues to be routed directly to the Gitea container via Docker port
publishing (e.g., `203.0.113.10:22:2222`), matching the current deployment
pattern.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
@@ -260,6 +455,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [009](decisions/009-signal-handling.md) | Signal handling strategy | signal-hook for SIGTERM/SIGINT/SIGHUP |
| [013](decisions/013-health-check-port.md) | Health check on separate local port | Localhost-only HTTP health check, configurable port |
| [014](decisions/014-unix-socket-reload.md) | Unix domain socket config reload API | Programmatic reload with success/failure feedback |
| [020](decisions/020-container-deployment.md) | Container deployment model | Defense-in-depth via container isolation; file-primary logging |
## Open Questions

View File

@@ -81,36 +81,51 @@ details.
## Architecture
```
┌────────────────────────────────────┐
reverse-proxy (Rust/axum)
config.toml ───────► │ StaticConfig + DynamicConfig │
│ (ArcSwap for hot-reload) │
│ │
│ ┌─ Listener 1 ─────────────────┐ │
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 │ │
└────────────────────────────────┘
┌─ Listener N ─────────────────┐
bind_addr_N:80 ───► │ HTTP → 301 redirect │
│ └────────────────────────────────┘
bind_addr_N:443 ───► │ TLS listener (tokio-rustls) │
│ ├─ Manual TLS cert
│ │ └─ axum router │ │
├─ alk.dev → :8080 │ │
└─ Rate limiting, headers │ │
└────────────────────────────────┘
│ /health → 200 OK (port 9900)
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ reverse-proxy container (Rust/axum)│
config.toml ───────► │ StaticConfig + DynamicConfig │
(volume mount) │ (ArcSwap for hot-reload) │
│ │
│ ┌─ Listener 1 ─────────────────┐ │
bind_addr:80 ────► │ │ HTTP → 301 redirect │ │
(published) │ └────────────────────────────────┘ │
bind_addr:443 ────► │ │ TLS listener (tokio-rustls) │ │
(published) │ │ ACME or Manual TLS config │ │
axum router │ │
│ │ ├─ Host-based routing │ │
│ │ git.alk.dev → gitea:3000 │ │
│ └─ Rate limiting, headers │
│ └────────────────────────────────┘
│ ┌─ Listener N ─────────────────┐
bind_addr_N:80 ───► │ HTTP → 301 redirect │
│ └────────────────────────────────┘
bind_addr_N:443 ───► │ │ TLS listener (tokio-rustls) │ │
├─ Manual TLS cert │ │
└─ axum router │ │
│ ├─ alk.dev → app:8080 │
└─ Rate limiting, headers │
│ └────────────────────────────────┘
│ │
│ /health → 200 OK (port 9900) │
└────────────────────────────────────┘
│ │
┌──────┘ └──────┐
│ │
Docker network Volume mounts:
(upstream DNS) ├─ config (ro)
├─ gitea:3000 ├─ ACME cache (rw)
├─ app:8080 ├─ log dir (rw, fail2ban)
└─ admin socket (rw)
```
In container deployments (ADR-020), the proxy runs in a minimal container with
`0.0.0.0` bind address and Docker port publishing. Upstream addresses use Docker
DNS names for same-host containers (e.g., `gitea:3000`) but also support
loopback, LAN, and tunnel endpoints for multi-host deployments.
## Crate Dependencies
### Core
@@ -180,6 +195,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [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 |
| [019](decisions/019-multi-config-listeners.md) | Multi-config listeners | `[[listeners]]` supporting both dedicated-IP and shared-IP deployment models |
| [020](decisions/020-container-deployment.md) | Container deployment model | Defense-in-depth via container isolation; file-primary logging; flexible upstream addressing |
## Open Questions

View File

@@ -92,7 +92,7 @@ from axum's `ConnectInfo` and the original request:
| `Host` | Original request `Host` header | Already present; preserved as-is |
| `X-Real-IP` | `ConnectInfo<SocketAddr>` remote IP | Set to client's IP address |
| `X-Forwarded-For` | Client IP, appended if header exists | Comma-separated list of proxies |
| `X-Forwarded-Proto` | Determined by listener | `https` on port 443, `http` on port 80 |
| `X-Forwarded-Proto` | Determined by which listener port received the request | `https` for requests on the listener's `https_port`, `http` for requests on the listener's `http_port` |
The `X-Forwarded-For` handling must append the client IP to any existing value
(rather than replacing it), to support chained proxies correctly.