Resolve open questions: - OQ-01: Restrict cipher suites to match nginx scope (4 ECDHE-AES-GCM suites for TLS 1.2 + all TLS 1.3 suites) — ADR-012 - OQ-03: Health check on separate local port (default 9900, localhost only) — ADR-013 - OQ-04: Add Unix domain socket admin API for config reload alongside SIGHUP, with structured success/failure responses — ADR-014 - OQ-06: Per-site upstream timeouts with defaults (5s connect, 60s request), overridable in SiteConfig — ADR-015 Document previously undocumented decisions flagged by architecture review: - ADR-016: Explicit bind address requirement (reject 0.0.0.0) - ADR-017: Upstream connection defaults (HTTP/1.1, no redirects, pooling) - ADR-018: 100 MB body size limit (matches nginx, Gitea compatibility) OQ-07 (per-site TLS overrides) remains open for future consideration. Spec updates: - config.md: add health_check_port, admin_socket_path, per-site timeout fields, update TOML example and validation rules - proxy.md: reference ADR-015/017/018 for timeouts, connection defaults, and body limit decisions - tls.md: replace OQ-01 cipher suite section with ADR-012 decision - operations.md: add local health check port section, admin socket reload - overview.md: update Phase 1 scope with new features, add ADR references - open-questions.md: resolve OQ-01/03/04/06, keep OQ-07 open
7.2 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-06-11 |
Proxy Handler
What It Is
The proxy handler is the core component that receives an incoming HTTP request on the TLS-terminated connection, applies middleware (rate limiting, header injection, body size limits), and forwards it to the upstream service.
Why It Exists
This component replaces nginx's proxy_pass directive. For our use case —
one upstream per domain across multiple domains, no load balancing, no HTTP/2
proxying — a custom handler is simpler and more maintainable than a
general-purpose proxy library (ADR-002, ADR-010).
Architecture
Incoming HTTPS request
│
▼
┌─────────────────┐
│ axum Router │
│ (Host-based) │─── /health → 200 OK
│ │
│ match Host │
│ header on │
│ incoming req │
└───────┬─────────┘
│
▼
┌─────────────────┐
│ Rate Limiting │ ← tower middleware layer
│ Middleware │
└───────┬─────────┘
│
▼
┌─────────────────┐
│ Proxy Header │ ← custom middleware / handler
│ Injection │
│ │
│ X-Real-IP │ ← connect_info remote_addr
│ X-Forwarded-For │ ← append to existing or set
│ X-Forwarded-Proto │ ← "https" (or "http" on port 80)
│ Host │ ← original host header (already set)
└───────┬─────────┘
│
▼
┌─────────────────┐
│ Body Size Limit │ ← DefaultBodyLimit(100 MB)
│ Middleware │
└───────┬─────────┘
│
▼
┌─────────────────┐
│ Reverse Proxy │ ← hyper Client request forwarding
│ Handler │
│ │
│ 1. Build upstream│
│ URI from │
│ original req │
│ 2. Forward req │
│ to upstream │
│ 3. Stream │
│ response back │
└─────────────────┘
Request Flow
1. Host-Based Routing
The axum router uses a Host extractor to match incoming requests to site
definitions from DynamicConfig. Each site definition maps a hostname to an
upstream address.
Where host_based_proxy reads the Host header, looks up the site in
DynamicConfig.sites, and either proxies to the upstream or returns 404.
2. Proxy Header Injection
Headers are injected before forwarding. The handler reads connection metadata
from axum's ConnectInfo and the original request:
| Header | Value Source | Notes |
|---|---|---|
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 |
The X-Forwarded-For handling must append the client IP to any existing value
(rather than replacing it), to support chained proxies correctly.
3. Request Forwarding
The proxy handler constructs a new request to the upstream:
- Build the upstream URI using the site's
upstream_schemeandupstreamaddress, preserving the original path and query string - Copy the request method, headers, and body from the original
- Inject proxy headers (X-Real-IP, X-Forwarded-For, X-Forwarded-Proto)
- Send the request via a shared hyper Client instance
- Stream the response back to the client
The hyper Client is created once at startup and shared via axum's State. It
must be configured with (see ADR-017 for rationale):
- Connection pooling (hyper default behavior)
- HTTP/1.1 only for upstream connections (HTTP/2 proxying is out of scope)
- No redirect following (proxies should not follow redirects)
Per-site timeout overrides are available via upstream_connect_timeout_secs
and upstream_request_timeout_secs in SiteConfig (see ADR-015). When not
specified, defaults of 5s connect and 60s request are used.
4. Error Handling
| Upstream Condition | Response | Notes |
|---|---|---|
| Upstream reachable | Stream response as-is | Headers, status, body all forwarded |
| Upstream unreachable | 502 Bad Gateway | Logged at warn level |
| Upstream timeout | 504 Gateway Timeout | Logged at warn level |
| Request body too large | 413 Payload Too Large | From DefaultBodyLimit middleware |
| Rate limit exceeded | 429 Too Many Requests | Logged at info level |
| Unknown Host header | 404 Not Found | No matching site definition |
5. HTTP → HTTPS Redirect
A separate HTTP listener on port 80 handles redirect. It reads the Host
header from the incoming request and returns a 301 Permanent Redirect 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.
Upstream Connection
The upstream connection scheme defaults to http:// since the proxy and backend
services typically run on the same host (e.g., 127.0.0.1:3000). The
upstream_scheme field in each site's configuration allows specifying https://
for upstreams that require TLS (e.g., separate hosts or secure internal services).
For the initial deployment, upstream connections use plain HTTP (e.g.,
git.alk.dev → 127.0.0.1:3000, alk.dev → 127.0.0.1:8080) since TLS
between the proxy and backend services on loopback is unnecessary.
Body Size Limit
axum's DefaultBodyLimit layer sets the maximum request body size. The default
of 100 MB (104,857,600 bytes) matches our current nginx configuration and
accommodates Gitea's push operations with large pack files (see ADR-018). In
Phase 1, the body limit is a global setting; Phase 2 may add per-site body
limits.
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 002 | Custom proxy handler | One upstream per domain — simpler than a general proxy library |
| 007 | Custom structured log format | key=value pairs with RATE_LIMIT prefix for fail2ban |
| 010 | Multi-site in Phase 1 | Multiple domains from initial release |
| 015 | Per-site upstream timeouts with defaults | 5s connect / 60s request defaults, per-site overrides |
| 017 | Upstream connection defaults | HTTP/1.1, no redirects, connection pooling |
| 018 | Request body size limit | 100 MB default matching nginx, Gitea push compatibility |
Open Questions
Open questions are tracked in open-questions.md. Key questions affecting this document:
OQ-06: Should upstream timeouts be configurable per-site?(resolved — ADR-015: per-site timeout overrides with defaults)