From fecc385d7592ead80f43ca1063313cee99f8bf92 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 11 Jun 2026 10:10:32 +0000 Subject: [PATCH] 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) --- docs/architecture/README.md | 1 + docs/architecture/config.md | 20 +- .../decisions/013-health-check-port.md | 7 +- .../decisions/016-explicit-bind-address.md | 30 ++- .../decisions/020-container-deployment.md | 97 +++++++++ docs/architecture/operations.md | 204 +++++++++++++++++- docs/architecture/overview.md | 72 ++++--- docs/architecture/proxy.md | 2 +- 8 files changed, 385 insertions(+), 48 deletions(-) create mode 100644 docs/architecture/decisions/020-container-deployment.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7dfb0a2..9e88fb5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 diff --git a/docs/architecture/config.md b/docs/architecture/config.md index 593b9ac..2b1ea64 100644 --- a/docs/architecture/config.md +++ b/docs/architecture/config.md @@ -86,10 +86,19 @@ Immutable after startup. Changes require a process restart. | Field | Type | Description | |-------|------|-------------| | `listeners` | `Vec` | 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 diff --git a/docs/architecture/decisions/013-health-check-port.md b/docs/architecture/decisions/013-health-check-port.md index 83124d1..8c8a41e 100644 --- a/docs/architecture/decisions/013-health-check-port.md +++ b/docs/architecture/decisions/013-health-check-port.md @@ -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 diff --git a/docs/architecture/decisions/016-explicit-bind-address.md b/docs/architecture/decisions/016-explicit-bind-address.md index a120ddb..0cca3fc 100644 --- a/docs/architecture/decisions/016-explicit-bind-address.md +++ b/docs/architecture/decisions/016-explicit-bind-address.md @@ -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) \ No newline at end of file diff --git a/docs/architecture/decisions/020-container-deployment.md b/docs/architecture/decisions/020-container-deployment.md new file mode 100644 index 0000000..70dea81 --- /dev/null +++ b/docs/architecture/decisions/020-container-deployment.md @@ -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) \ No newline at end of file diff --git a/docs/architecture/operations.md b/docs/architecture/operations.md index 17735a0..8d59ad4 100644 --- a/docs/architecture/operations.md +++ b/docs/architecture/operations.md @@ -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 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 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index bc2c852..a62de37 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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 diff --git a/docs/architecture/proxy.md b/docs/architecture/proxy.md index 8c10eaa..58d213b 100644 --- a/docs/architecture/proxy.md +++ b/docs/architecture/proxy.md @@ -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` 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.