Files
mc-proxy/PROJECT_PLAN.md
Kyle Isom 564e0a9c67 Add per-listener connection limits
Configurable maximum concurrent connections per listener. When the
limit is reached, new connections are closed immediately after accept.
0 means unlimited (default, preserving existing behavior).

Config: Listener gains max_connections field, validated non-negative.

DB: Migration 3 adds listeners.max_connections column.
UpdateListenerMaxConns method for runtime changes via gRPC.
CreateListener updated to persist max_connections on seed.

Server: ListenerState/ListenerData gain MaxConnections. Limit checked
in serve() after Accept but before handleConn — if ActiveConnections
>= MaxConnections, connection is closed and the accept loop continues.
SetMaxConnections method for runtime updates.

Proto: SetListenerMaxConnections RPC added. ListenerStatus gains
max_connections field. Generated code regenerated.

gRPC server: SetListenerMaxConnections implements write-through
(DB first, then in-memory update). GetStatus includes max_connections.

Client: SetListenerMaxConnections method, MaxConnections in
ListenerStatus.

Tests: DB CRUD and UpdateListenerMaxConns, server connection limit
enforcement (accept 2, reject 3rd, close one, accept again), gRPC
SetListenerMaxConnections round-trip with DB persistence, not-found
error handling.

Also updates PROJECT_PLAN.md with phases 6-8 and PROGRESS.md with
tracking for the new features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:42:53 -07:00

377 lines
14 KiB
Markdown

# PROJECT_PLAN.md
Implementation plan for L7 HTTP/2 proxying and PROXY protocol support as
described in ARCHITECTURE.md. The plan brings the existing L4-only codebase
to the dual-mode (L4/L7) architecture with PROXY protocol for multi-hop
deployments.
## Guiding Principles
- Each phase produces a working, testable system. No phase leaves the
codebase in a broken state.
- The existing L4 path must remain fully functional throughout. All current
tests must continue to pass at every phase boundary.
- Database migrations are forward-only and non-destructive (`ALTER TABLE ADD
COLUMN` with defaults).
- New packages are introduced incrementally: `internal/proxyproto/` in
phase 2, `internal/l7/` in phase 3.
---
## Phase 1: Database & Config Foundation
Extend the config structs, database schema, and seeding logic to support
the new fields. No behavioral changes -- the proxy continues to operate
as L4-only, but the data model is ready for phases 2-3.
### 1.1 Config struct updates
Update `internal/config/config.go`:
- `Listener`: add `ProxyProtocol bool` field (`toml:"proxy_protocol"`).
- `Route`: add fields:
- `Mode string` (`toml:"mode"`) -- `"l4"` (default) or `"l7"`.
- `TLSCert string` (`toml:"tls_cert"`) -- path to PEM certificate.
- `TLSKey string` (`toml:"tls_key"`) -- path to PEM private key.
- `BackendTLS bool` (`toml:"backend_tls"`) -- re-encrypt to backend.
- `SendProxyProtocol bool` (`toml:"send_proxy_protocol"`) -- send PROXY v2.
### 1.2 Config validation updates
Update `config.validate()`:
- `Route.Mode` must be `""` (treated as `"l4"`), `"l4"`, or `"l7"`.
- If `Mode == "l7"`: `TLSCert` and `TLSKey` are required and must point to
readable files. Validate the cert/key pair loads with `tls.LoadX509KeyPair`.
- If `Mode == "l4"`: warn (log) if `TLSCert` or `TLSKey` are set.
- `BackendTLS` only meaningful for L7 (ignored on L4, no error).
### 1.3 Database migration (v2)
Add migration in `internal/db/migrations.go`:
```sql
ALTER TABLE listeners ADD COLUMN proxy_protocol INTEGER NOT NULL DEFAULT 0;
ALTER TABLE routes ADD COLUMN mode TEXT NOT NULL DEFAULT 'l4'
CHECK(mode IN ('l4', 'l7'));
ALTER TABLE routes ADD COLUMN tls_cert TEXT NOT NULL DEFAULT '';
ALTER TABLE routes ADD COLUMN tls_key TEXT NOT NULL DEFAULT '';
ALTER TABLE routes ADD COLUMN backend_tls INTEGER NOT NULL DEFAULT 0;
ALTER TABLE routes ADD COLUMN send_proxy_protocol INTEGER NOT NULL DEFAULT 0;
```
### 1.4 DB struct and CRUD updates
- `db.Listener`: add `ProxyProtocol bool`.
- `db.Route`: add `Mode`, `TLSCert`, `TLSKey`, `BackendTLS`,
`SendProxyProtocol` fields.
- Update `ListListeners`, `CreateListener`, `ListRoutes`, `CreateRoute` to
read/write new columns.
- Update `Seed()` to persist new fields from config.
### 1.5 Server data loading
Update `cmd/mc-proxy/server.go` and `internal/server/`:
- `ListenerData`: add `ProxyProtocol bool`.
- `ListenerState`: store `ProxyProtocol`.
- Route lookup returns a `RouteInfo` struct (hostname, backend, mode,
tls_cert, tls_key, backend_tls, send_proxy_protocol) instead of just
a backend string. This is the key internal API change that phases 2-3
build on.
### 1.6 Tests
- Config tests: valid L7 route, missing cert/key, invalid mode.
- DB tests: migration v2, CRUD with new fields, seed with new fields.
- Server tests: existing tests pass unchanged (all routes default to L4).
---
## Phase 2: PROXY Protocol
Implement PROXY protocol v1/v2 parsing and v2 writing. Integrate into the
server pipeline: receive on listeners, send on routes (L4 path only in this
phase; L7 sending is added in phase 3).
### 2.1 `internal/proxyproto/` package
New package with:
**Parser (receive):**
- `Parse(r io.Reader, deadline time.Time) (Header, error)` -- reads and
parses a PROXY protocol header (v1 or v2) from the connection.
- `Header` struct: `Version` (1 or 2), `SrcAddr`/`DstAddr` (`netip.AddrPort`),
`Command` (PROXY or LOCAL).
- v1 parsing: text-based, `PROXY TCP4/TCP6 srcip dstip srcport dstport\r\n`.
- v2 parsing: binary signature (12 bytes), version/command, family/protocol,
address length, addresses.
- Enforce maximum header size (536 bytes, per spec).
- 5-second deadline for reading the header.
**Writer (send):**
- `WriteV2(w io.Writer, src, dst netip.AddrPort) error` -- writes a PROXY
protocol v2 header.
- Always writes PROXY command (not LOCAL).
- Supports IPv4 and IPv6.
**Tests:**
- Parse v1 TCP4 and TCP6 headers.
- Parse v2 TCP4 and TCP6 headers.
- Parse v2 LOCAL command.
- Reject malformed headers (bad signature, truncated, invalid family).
- Round-trip: write v2, parse it back.
- Timeout handling.
### 2.2 Server integration (receive)
Update `internal/server/server.go` `handleConn()`:
After accept, before firewall check:
1. If `ls.ProxyProtocol` is true, call `proxyproto.Parse()` with a 5-second
deadline.
2. On success: use `Header.SrcAddr.Addr()` as the real client IP for
firewall checks and logging.
3. On failure: reset the connection (malformed or timeout).
4. If `ls.ProxyProtocol` is false: use TCP source IP (existing behavior).
No PROXY header parsing -- a connection starting with a PROXY header
will fail SNI extraction and be reset (correct behavior).
### 2.3 Server integration (send, L4)
Update the L4 relay path in `handleConn()`:
After dialing the backend, before writing the peeked ClientHello:
1. If `route.SendProxyProtocol` is true, call `proxyproto.WriteV2()` with
the real client IP (from step 2.2 or TCP source) and the backend address.
2. Then write the peeked ClientHello bytes.
3. Then relay as before.
### 2.4 Tests
- Server test: listener with `proxy_protocol = true`, client sends v2
header + TLS ClientHello, verify backend receives the connection.
- Server test: listener with `proxy_protocol = true`, client sends garbage
instead of PROXY header, verify RST.
- Server test: route with `send_proxy_protocol = true`, verify backend
receives PROXY v2 header before the ClientHello.
- Server test: route with `send_proxy_protocol = false`, verify no PROXY
header sent.
- Firewall test: verify the real client IP (from PROXY header) is used for
firewall evaluation, not the TCP source IP.
---
## Phase 3: L7 Proxying
Implement TLS termination and HTTP/2 reverse proxying. This is the largest
phase.
### 3.1 `internal/l7/` package
**`prefixconn.go`:**
- `PrefixConn` struct: wraps `net.Conn`, prepends buffered bytes before
reading from the underlying connection.
- `Read()`: returns from buffer first, then from underlying conn.
- All other `net.Conn` methods delegate to the underlying conn.
**`reverseproxy.go`:**
- `Handler` struct: holds route config, backend transport, logger.
- `NewHandler(route RouteInfo, logger *slog.Logger) *Handler` -- creates
an HTTP handler that reverse proxies to the route's backend.
- Uses `httputil.ReverseProxy` internally with:
- `Director` function: sets scheme, host, injects `X-Forwarded-For`,
`X-Forwarded-Proto`, `X-Real-IP` from real client IP.
- `Transport`: configured for h2c (when `backend_tls = false`) or
h2-over-TLS (when `backend_tls = true`).
- `ErrorHandler`: returns 502 with minimal body.
- h2c transport: `http2.Transport` with `AllowHTTP: true` and custom
`DialTLSContext` that returns a plain TCP connection.
- TLS transport: `http2.Transport` with standard TLS config.
**`serve.go`:**
- `Serve(ctx context.Context, conn net.Conn, peeked []byte, route RouteInfo, clientAddr netip.Addr, logger *slog.Logger) error`
- Main entry point called from `server.handleConn()` for L7 routes.
- Creates `PrefixConn` wrapping `conn` with `peeked` bytes.
- Creates `tls.Conn` using `tls.Server()` with the route's certificate.
- Completes the TLS handshake (with timeout).
- Creates an HTTP/2 server (`http2.Server`) and serves a single
connection using the reverse proxy handler.
- Injects real client IP into request context for header injection.
- Returns when the connection is closed.
**Tests:**
- `prefixconn_test.go`: verify buffered bytes are read first, then
underlying conn. Verify `Close()`, `RemoteAddr()`, etc. delegate.
- `reverseproxy_test.go`: HTTP/2 reverse proxy to h2c backend, verify
request forwarding, header injection, error handling (502 on dial
failure).
- `serve_test.go`: full TLS termination test -- client sends TLS
ClientHello, proxy terminates and forwards to plaintext backend.
- gRPC-through-L7 test: gRPC client → L7 proxy → h2c gRPC backend,
verify unary RPC, server streaming, and trailers.
### 3.2 Server integration
Update `internal/server/server.go` `handleConn()`:
After route lookup, branch on `route.Mode`:
- `"l4"` (or `""`): existing behavior (dial, optional PROXY send,
forward ClientHello, relay).
- `"l7"`: call `l7.Serve(ctx, conn, peeked, route, realClientIP, logger)`.
The L7 path handles its own backend dialing and PROXY protocol sending
internally.
### 3.3 PROXY protocol sending (L7)
In `l7.Serve()`, after dialing the backend but before starting HTTP
traffic: if `route.SendProxyProtocol` is true, write a PROXY v2 header
on the backend connection. The HTTP transport then uses the connection
with the header already sent.
### 3.4 Tests
- End-to-end: L4 and L7 routes on the same listener, verify both work.
- L7 with h2c backend serving HTTP/2 responses.
- L7 with `backend_tls = true` (re-encrypt).
- L7 with `send_proxy_protocol = true`.
- L7 TLS handshake failure (expired cert, wrong hostname).
- L7 backend unreachable (verify 502).
- Existing L4 tests unchanged.
---
## Phase 4: gRPC API & CLI Updates
Update the admin API, proto definitions, client library, and CLI tools to
support the new route and listener fields.
### 4.1 Proto updates
Update `proto/mc_proxy/v1/admin.proto`:
- `Route` message: add `mode`, `tls_cert`, `tls_key`, `backend_tls`,
`send_proxy_protocol` fields.
- `AddRouteRequest`: add the same fields.
- `ListenerStatus` message: add `proxy_protocol` field.
- Regenerate code with `make proto`.
### 4.2 gRPC server updates
Update `internal/grpcserver/grpcserver.go`:
- `AddRoute`: accept and validate new fields. L7 routes require valid
cert/key paths. Persist all fields via `db.CreateRoute()`.
- `ListRoutes`: return full route info including new fields.
- `GetStatus`: include `proxy_protocol` in listener status.
### 4.3 Client package updates
Update `client/mcproxy/client.go`:
- `Route` struct: add `Mode`, `TLSCert`, `TLSKey`, `BackendTLS`,
`SendProxyProtocol`.
- `AddRoute()`: accept and pass new fields.
- `ListRoutes()`: return full route info.
- `ListenerStatus`: add `ProxyProtocol`.
### 4.4 mcproxyctl updates
Update `cmd/mcproxyctl/routes.go`:
- `routes add`: accept `--mode`, `--tls-cert`, `--tls-key`,
`--backend-tls`, `--send-proxy-protocol` flags.
- `routes list`: display mode and other fields in output.
### 4.5 Tests
- gRPC server tests: add/list L7 routes, validation of cert paths.
- Client tests: round-trip new fields.
- Verify backward compatibility: adding a route without new fields defaults
to L4 with no PROXY protocol.
---
## Phase 5: Integration & Polish
End-to-end validation, dev config updates, and documentation cleanup.
### 5.1 Dev config update
Update `srv/mc-proxy.toml` with example L7 routes and generate test
certificates for local development.
### 5.2 Multi-hop integration test
Test the edge→origin deployment pattern:
- Two mc-proxy instances (different configs).
- Edge: L4 passthrough with `send_proxy_protocol = true`.
- Origin: `proxy_protocol = true` listener, mix of L4 and L7 routes.
- Verify real client IP flows through for firewall and `X-Forwarded-For`.
### 5.3 gRPC-through-L7 validation
Test that gRPC services (unary, server-streaming, client-streaming,
bidirectional) work correctly through the L7 reverse proxy, including:
- Trailer propagation (gRPC status codes).
- Large messages.
- Deadline/timeout propagation.
### 5.4 Web UI through L7 validation
Test that htmx-based web UIs work through the L7 proxy:
- Standard HTTP/1.1 and HTTP/2 requests.
- SSE (server-sent events) if used.
- Static asset serving.
### 5.5 Documentation
- Verify ARCHITECTURE.md matches final implementation.
- Update CLAUDE.md if any package structure or rules changed.
- Update Makefile if new build targets are needed.
---
## Phase 6: Per-Listener Connection Limits
Add configurable maximum concurrent connection limits per listener.
### 6.1 Config: `MaxConnections int64` on `Listener` (0 = unlimited)
### 6.2 DB: migration 3 adds `listeners.max_connections`, CRUD updates
### 6.3 Server: enforce limit in `serve()` after Accept, before handleConn
### 6.4 Proto/gRPC: `SetListenerMaxConnections` RPC, `max_connections` in `ListenerStatus`
### 6.5 Client/CLI: `SetListenerMaxConnections` method, status display
### 6.6 Tests: DB CRUD, server limit enforcement, gRPC round-trip
---
## Phase 7: L7 Policies
Per-route HTTP blocking rules for L7 routes: user-agent blocking
(substring match) and required header enforcement.
### 7.1 Config: `L7Policy` struct (`type` + `value`), `L7Policies` on Route
### 7.2 DB: migration 4 creates `l7_policies` table, new `l7policies.go` CRUD
### 7.3 L7 middleware: `PolicyMiddleware` in `internal/l7/policy.go`
### 7.4 Server/L7 integration: thread policies from RouteInfo to RouteConfig
### 7.5 Proto/gRPC: `L7Policy` message, `ListL7Policies`/`AddL7Policy`/`RemoveL7Policy` RPCs
### 7.6 Client/CLI: policy methods, `mcproxyctl policies` subcommand
### 7.7 Startup: load L7 policies per route in `loadListenersFromDB`
### 7.8 Tests: middleware unit tests, DB CRUD + cascade, gRPC round-trip, e2e
---
## Phase 8: Prometheus Metrics
Instrument the proxy with Prometheus-compatible metrics exposed via a
separate HTTP endpoint.
### 8.1 Dependency: add `prometheus/client_golang`
### 8.2 Config: `Metrics` section (`addr`, `path`)
### 8.3 Package: `internal/metrics/` with metric definitions and HTTP server
### 8.4 Instrumentation: connections, firewall blocks, dial latency, bytes, HTTP status codes, policy blocks
### 8.5 Firewall: add `BlockedWithReason()` method
### 8.6 L7: status recording wrapper on ResponseWriter
### 8.7 Startup: conditionally start metrics server
### 8.8 Tests: metric sanity, server endpoint, `BlockedWithReason`