# 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`