Decompose architecture into 23 atomic tasks across 7 parallel generations

Task graph covers all Phase 1 concerns: config system, TLS termination,
proxy handler, operations (rate limiting, logging, health check, admin
socket, signals, shutdown, body size limit), deployment artifacts, and
two review checkpoints.

No circular dependencies. Critical path length of 7. Risk distribution:
3 high-risk (ACME, TLS listener setup, startup orchestration), 7 medium,
11 low, 2 trivial.
This commit is contained in:
2026-06-11 11:21:10 +00:00
parent ceb59ad9b9
commit 309878c561
23 changed files with 1676 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
---
id: proxy/error-responses
name: Implement proxy error responses with plain text bodies and correct status codes
status: pending
depends_on: [proxy/host-routing]
scope: single
risk: trivial
impact: isolated
level: implementation
---
## Description
Implement the error response types for the proxy handler. All error responses use plain text bodies with no proxy version or identity information. No upstream error details are included.
### Error Response Table
| Upstream Condition | Response | Body |
|-------------------|----------|------|
| Upstream reachable | Stream response as-is | (upstream body) |
| Upstream unreachable | 502 Bad Gateway | `Bad Gateway` |
| Upstream timeout | 504 Gateway Timeout | `Gateway Timeout` |
| Request body too large | 413 Payload Too Large | `Payload Too Large` |
| Rate limit exceeded | 429 Too Many Requests | `Too Many Requests` |
| Unknown Host header | 404 Not Found | `Not Found` |
| Missing Host header | 400 Bad Request | `Bad Request` |
### Response Format
- Content-Type: `text/plain; charset=utf-8`
- Body: Brief status text matching the HTTP status
- No proxy version or identity information
- No upstream error details leaked
### Logging
- 502 and 504 responses logged at `warn` level with structured fields
- 429 responses logged at `info` level with RATE_LIMIT prefix
- 404 and 400 responses not specially logged (normal routing)
## Acceptance Criteria
- [ ] Error response type/enum covering all cases in the table
- [ ] All error responses use `text/plain; charset=utf-8` Content-Type
- [ ] Error bodies are brief status text with no version or identity info
- [ ] 502 logged at `warn` level with host and upstream
- [ ] 504 logged at `warn` level with host and upstream
- [ ] 429 logged at `info` level with RATE_LIMIT prefix
- [ ] Unit tests for each error response type
## References
- docs/architecture/proxy.md — error handling section
## Notes
> This is a small but important task — correct error responses without information leakage are a security concern. Implementation agents should not add extra detail to error bodies.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,89 @@
---
id: proxy/headers-and-forwarding
name: Implement proxy header injection, hop-by-hop removal, and request forwarding with hyper Client
status: pending
depends_on: [proxy/host-routing]
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Implement the core reverse proxy logic: inject proxy headers, remove hop-by-hop headers, and forward requests to the upstream via a shared `hyper::Client`.
### Proxy Header Injection
The proxy is an **edge proxy** — it sits directly in front of the internet with no trusted proxies upstream. This means existing `X-Forwarded-For` headers from the client cannot be trusted.
| Header | Value Source | Behavior |
|--------|-------------|----------|
| `Host` | Original request `Host` header | Preserved as-is |
| `X-Real-IP` | `ConnectInfo<SocketAddr>` remote IP | Set to client's IP address |
| `X-Forwarded-For` | `ConnectInfo<SocketAddr>` remote IP | **Replaced**, not appended |
| `X-Forwarded-Proto` | Determined by listener port | `https` for `https_port`, `http` for `http_port` |
### Hop-by-Hop Header Removal
Remove these headers before forwarding to upstream (RFC 2616 §13.5.1):
- `Connection`, `Keep-Alive`, `Proxy-Authorization`, `Proxy-Authenticate`
- `TE`, `Trailers`, `Transfer-Encoding`, `Upgrade`
Also remove these from upstream responses before sending to client.
### Request Forwarding
1. Build the upstream URI: `{upstream_scheme}://{upstream}{path}?{query}`
2. Copy request method, headers (with proxy headers injected, hop-by-hop removed), and body
3. Send via shared `hyper::Client` with per-site timeout overrides
4. Stream response back to client (chunk-by-chunk, not buffered)
5. Handle client disconnect (log at debug, close upstream connection)
6. Handle upstream disconnect (send whatever was already sent, close connection)
### hyper Client Configuration
- Created once at startup, shared via axum State
- HTTP/1.1 only for upstream connections
- No redirect following (proxies should not follow redirects)
- Connection pooling (hyper default behavior)
- Per-site timeout overrides: `upstream_connect_timeout_secs` (default 5s), `upstream_request_timeout_secs` (default 60s)
### Upstream Scheme
Default is `http://`. When `upstream_scheme` is `"https"`, validate the upstream's TLS certificate using the system's native TLS root certificates. Certificate validation failures result in `502 Bad Gateway`.
## Acceptance Criteria
- [ ] `X-Real-IP` set from `ConnectInfo<SocketAddr>` remote IP
- [ ] `X-Forwarded-For` **replaced** (not appended) with client IP
- [ ] `X-Forwarded-Proto` set to `https` or `http` based on listener port
- [ ] `Host` header preserved as-is
- [ ] Hop-by-hop headers removed before forwarding to upstream
- [ ] Hop-by-hop headers removed from upstream response before sending to client
- [ ] No `Server` header added to responses
- [ ] No `Via` header added in Phase 1
- [ ] Request body streamed (not buffered) to upstream
- [ ] Response body streamed (not buffered) to client
- [ ] Client disconnect logged at debug level, upstream connection closed
- [ ] Upstream disconnect: client receives whatever was already sent
- [ ] Per-site timeout overrides applied to hyper Client requests
- [ ] `upstream_scheme: "https"` validates upstream TLS certificate with system roots
- [ ] Shared `hyper::Client` instance via axum State
- [ ] Unit tests for header injection and removal
- [ ] Integration test: proxy request to upstream, verify headers and response
## References
- docs/architecture/proxy.md — header injection, request forwarding, error handling
- docs/architecture/decisions/002-custom-proxy-handler.md — custom handler rationale
- docs/architecture/decisions/017-upstream-connection-defaults.md — HTTP/1.1, no redirects
- docs/architecture/decisions/021-x-forwarded-for-edge-proxy.md — edge proxy model
## Notes
> The `X-Forwarded-For: replace, don't append` behavior is critical. The proxy is the edge — there are no trusted proxies upstream. Existing `X-Forwarded-For` values from the client could be spoofed and must not be trusted.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,56 @@
---
id: proxy/host-routing
name: Implement Host-based routing with global routing table from DynamicConfig
status: pending
depends_on: [config/dynamic-config]
scope: narrow
risk: low
impact: component
level: implementation
---
## Description
Implement the host-based routing that matches incoming requests to site definitions. Sites are defined per-listener in TOML but collected into a single global routing table in `DynamicConfig`.
### Routing Logic
1. Check for `/health` path — if matched, return 200 OK with empty body (regardless of Host)
2. Extract `Host` header from request
3. If no `Host` header, return `400 Bad Request`
4. Normalize `Host` to lowercase, strip port component (e.g., `git.alk.dev:443``git.alk.dev`)
5. Look up normalized host in the global routing table
6. If found, forward to the matching `SiteConfig`'s upstream
7. If not found, return `404 Not Found`
### Global Routing Table
The routing table is a `HashMap<String, SiteConfig>` (or similar) in `DynamicConfig`, built by collecting all sites from all listeners. Hostnames must be unique — validation enforces this.
The routing table is part of `DynamicConfig` and is swapped atomically on config reload. This means a config reload can add, remove, or change site routing without restarting.
## Acceptance Criteria
- [ ] Host-based routing extracts `Host` header and normalizes to lowercase
- [ ] Port component stripped from `Host` header before matching
- [ ] `/health` path matches regardless of `Host` header, returns 200 OK
- [ ] Missing `Host` header returns `400 Bad Request`
- [ ] Unknown host returns `404 Not Found`
- [ ] Global routing table built from all listeners' site definitions
- [ ] Routing table updates atomically on config reload via ArcSwap
- [ ] Case-insensitive host matching per RFC 7230 §2.7.3
- [ ] Unit tests for host normalization (case, port stripping)
- [ ] Unit tests for routing table lookup (match, no match)
## References
- docs/architecture/proxy.md — Host-based routing section
- docs/architecture/config.md — DynamicConfig, global routing table
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion