Add L7/PROXY protocol data model, config, and architecture docs

Extend the config, database schema, and server internals to support
per-route L4/L7 mode selection and PROXY protocol fields. This is the
foundation for L7 HTTP/2 reverse proxying and multi-hop PROXY protocol
support described in the updated ARCHITECTURE.md.

Config: Listener gains ProxyProtocol; Route gains Mode, TLSCert,
TLSKey, BackendTLS, SendProxyProtocol. L7 routes validated at load
time (cert/key pair must exist and parse). Mode defaults to "l4".

DB: Migration v2 adds columns to listeners and routes tables. CRUD
and seeding updated to persist all new fields.

Server: RouteInfo replaces bare backend string in route lookup.
handleConn dispatches on route.Mode (L7 path stubbed with error).
ListenerState and ListenerData carry ProxyProtocol flag.

All existing L4 tests pass unchanged. New tests cover migration v2,
L7 field persistence, config validation for mode/cert/key, and
proxy_protocol flag round-tripping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:15:51 -07:00
parent 666d55018c
commit ed94548dfa
17 changed files with 1283 additions and 205 deletions

View File

@@ -1,56 +1,88 @@
# ARCHITECTURE.md
mc-proxy is a Layer 4 TLS proxy and router for Metacircular Dynamics
services. It inspects the SNI field of incoming TLS ClientHello messages to
determine the target backend, then proxies raw TCP between the client and
the appropriate container. A global firewall evaluates every connection
before routing.
mc-proxy is a TLS proxy and router for Metacircular Dynamics services. It
operates in two modes on a per-route basis:
- **L4 (passthrough):** Reads the SNI field from the TLS ClientHello and
proxies the raw TCP stream to the matched backend without terminating TLS.
This is the default mode and the original behavior.
- **L7 (terminating):** Terminates the client TLS connection, then reverse
proxies HTTP/2 (and HTTP/1.1) traffic to the backend. This enables
HTTP-level features: header inspection, user-agent blocking, request
routing, and `X-Forwarded-For` injection.
A global firewall evaluates every connection before routing. PROXY protocol
support enables multi-hop deployments where a public edge mc-proxy forwards
through a private network (e.g., Tailscale) to an origin mc-proxy while
preserving the real client IP.
## Table of Contents
1. [System Overview](#system-overview)
2. [Connection Lifecycle](#connection-lifecycle)
3. [Firewall](#firewall)
4. [Routing](#routing)
5. [Configuration](#configuration)
6. [Storage](#storage)
7. [Deployment](#deployment)
8. [Security Model](#security-model)
9. [Future Work](#future-work)
3. [PROXY Protocol](#proxy-protocol)
4. [Firewall](#firewall)
5. [Routing](#routing)
6. [L7 Proxying](#l7-proxying)
7. [gRPC Admin API](#grpc-admin-api)
8. [Configuration](#configuration)
9. [Storage](#storage)
10. [Deployment](#deployment)
11. [Security Model](#security-model)
12. [Future Work](#future-work)
---
## System Overview
```
┌─────────────────────────────────────┐
┌──────────────────────────────────────────────
│ mc-proxy │
Clients ──────┐ │ │
│ │ ┌──────────┐ ┌───────┐ ┌─────┐ ┌────────────┐
├────▶│ │ Firewall │─▶│ SNI │──▶│Route│─│────▶│ Backend A
│ │ │ (global) │ │Extract│ │Table│ │ :8443
├────▶│ └──────────┘ └───────┘ └─────┘ │ ├────────────┤
│ │ │ RST Backend B
Clients ──────┘ │ ▼ └────│────▶│ :9443
│ (blocked) └────────────┘
└─────────────────────────────────────┘
│ │ ┌─────── ┌──────┐ ┌───┐ ┌─────┐
├────▶│ │ PROXY │─▶│ Fire │─▶│SNI─▶│Route│──┐
│ │ │ proto │ │ wall │ │ │ │Table│
├────▶│ └───────┘ └──────┘ └───┘ └─────┘
│ │ │ RST │ │
Clients ──────┘ ││ │ │
(optional) (blocked)
│ │ │ │ │
│ ┌──────────────────────┘ │ │ │
│ │ L4 route │ │ │
│ ▼ │ │ │ ┌────────────┐
│ ┌──────────┐ │ │ │────▶│ Backend A │
│ │ Raw TCP │───────────────────│────│─────│ │ :8443 (TLS)│
│ │ Relay │ │ │ │ └────────────┘
│ └──────────┘ │ │ │
│ │ │ │
│ ┌─────────────────────────┘ │ │
│ │ L7 route │ │
│ ▼ │ │ ┌────────────┐
│ ┌──────────┐ ┌──────────┐ │ │────▶│ Backend B │
│ │ TLS Term │─▶│ HTTP/2 │──────────│─────│ │ :8080 (h2c)│
│ │ (certs) │ │ Rev Proxy│ │ │ └────────────┘
│ └──────────┘ └──────────┘ │ │
└──────────────────────────────────────────────┘
Listener 1 (:443) ─┐
Listener 2 (:8443) ─┼─ Each listener runs the same pipeline
Listener 2 (:8443) ─┼─ Each runs the same pipeline; L4/L7 is per-route
Listener N (:9443) ─┘
```
Key properties:
- **Layer 4 only.** mc-proxy never terminates TLS. It reads just enough of
the ClientHello to extract the SNI hostname, then proxies the raw TCP
stream to the matched backend. The backend handles TLS termination.
- **TLS-only.** Non-TLS connections are not supported. If the first bytes of
a connection are not a TLS ClientHello, the connection is reset.
- **Dual mode, per-route.** Each route is independently configured as L4
(passthrough) or L7 (terminating). Both modes coexist on the same listener.
- **TLS-only on ingest.** Non-TLS connections are not supported. If the first
bytes of a connection are not a TLS ClientHello (after any PROXY protocol
header), the connection is reset.
- **Multiple listeners.** A single mc-proxy instance binds to one or more
ports. Each listener runs the same firewall → SNI → route pipeline.
ports. Each listener runs the same pipeline.
- **Global firewall.** Firewall rules apply to all listeners uniformly.
There are no per-route firewall rules.
- **PROXY protocol.** Listeners can accept PROXY protocol v1/v2 headers to
learn the real client IP from an upstream proxy. Routes can send PROXY
protocol v2 headers to downstream backends or proxies.
- **No authentication.** mc-proxy is pre-auth infrastructure. It sits in
front of services that handle their own authentication via MCIAS.
@@ -58,42 +90,81 @@ Key properties:
## Connection Lifecycle
Every inbound connection follows this sequence:
Every inbound connection follows this sequence. Steps 1-5 are common to all
connections. The route's mode determines whether the L4 or L7 path is taken.
```
1. ACCEPT Listener accepts TCP connection.
2. FIREWALL Check source IP against blocklists:
a. IP/CIDR block check.
b. GeoIP country block check.
2. PROXY PROTOCOL If listener has proxy_protocol enabled:
Parse PROXY v1/v2 header to extract real client IP.
If malformed or missing → RST, done.
Otherwise: use TCP source IP directly.
3. FIREWALL Check source IP (real, from step 2) against blocklists:
a. Per-IP rate limit check.
b. IP/CIDR block check.
c. GeoIP country block check.
If blocked → RST, done.
3. SNI EXTRACT Read the TLS ClientHello (without consuming it).
4. SNI EXTRACT Read the TLS ClientHello (without consuming it).
Extract the SNI hostname.
If no valid ClientHello or no SNI → RST, done.
4. ROUTE LOOKUP Match SNI hostname against the route table.
5. ROUTE LOOKUP Match SNI hostname against the route table.
If no match → RST, done.
5. BACKEND DIAL Open TCP connection to the matched backend address.
Route determines mode (L4 or L7).
── L4 path (passthrough) ──────────────────────────────────────────────
6a. BACKEND DIAL Open TCP connection to the matched backend.
If dial fails → RST, done.
6. PROXY Bidirectional byte copy: client ↔ backend.
The buffered ClientHello bytes are forwarded first,
then both directions copy concurrently.
7. CLOSE Either side closes → half-close propagation → done.
7a. PROXY HEADER If route has send_proxy_protocol enabled:
Send PROXY v2 header with real client IP to backend.
8a. FORWARD Write the buffered ClientHello to the backend.
9a. RELAY Bidirectional byte copy: client ↔ backend.
10a. CLOSE Either side closes → half-close propagation → done.
── L7 path (terminating) ──────────────────────────────────────────────
6b. TLS HANDSHAKE Complete TLS handshake with the client.
The buffered ClientHello is replayed into crypto/tls
via a prefixConn wrapper. The server certificate is
selected from the route's configured cert/key pair
using tls.Config.GetCertificate keyed on SNI.
If handshake fails → close, done.
7b. HTTP PARSE Read HTTP/2 (or HTTP/1.1) request from the TLS conn.
8b. L7 POLICY Apply HTTP-level policies:
- User-agent blocking (future)
- Header inspection (future)
If blocked → HTTP error response, done.
9b. BACKEND DIAL Open connection to the matched backend.
If backend_tls: TLS connection to backend.
Otherwise: plaintext (h2c or HTTP/1.1).
If dial fails → HTTP 502, done.
10b. PROXY HEADER If route has send_proxy_protocol enabled:
Send PROXY v2 header with real client IP to backend.
11b. REVERSE PROXY Forward HTTP request to backend, stream response to
client. 1:1 connection mapping (no pooling).
12b. CLOSE Connection closed normally or on error.
```
### SNI Extraction
The proxy peeks at the initial bytes of the connection without consuming
them. It parses just enough of the TLS record layer and ClientHello to
extract the `server_name` extension. The full ClientHello (including the
SNI) is then forwarded to the backend so the backend's TLS handshake
proceeds normally.
extract the `server_name` extension.
- **L4 routes:** The full ClientHello (including the SNI) is forwarded to
the backend so the backend's TLS handshake proceeds normally.
- **L7 routes:** The buffered ClientHello is replayed into `crypto/tls.Server`
via a `prefixConn` wrapper (a `net.Conn` that returns the buffered bytes
before reading from the underlying connection). The TLS library sees the
complete ClientHello and performs a normal handshake.
If the ClientHello spans multiple TCP segments, the proxy buffers up to
16 KiB (the maximum TLS record size) before giving up.
### Bidirectional Copy
### Bidirectional Copy (L4)
After the backend connection is established, the proxy runs two concurrent
copy loops (clientbackend and backendclient). When either direction
copy loops (client->backend and backend->client). When either direction
encounters an EOF or error:
1. The write side of the opposite direction is half-closed.
@@ -105,14 +176,84 @@ accumulating indefinitely (see [Configuration](#configuration)).
---
## PROXY Protocol
mc-proxy supports [PROXY protocol](https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt)
for multi-hop deployments where the real client IP must be preserved across
proxy boundaries.
### Use Case: Edge-to-Origin
A common deployment has a public-facing mc-proxy on a VPS forwarding
through Tailscale to a private-network mc-proxy:
```
Client ──→ [edge mc-proxy] ──PROXY v2 + TLS──→ Tailscale ──→ [origin mc-proxy] ──→ backend
VPS, :443 private network
proxy_protocol = false proxy_protocol = true
send_proxy_protocol = true send_proxy_protocol = false
```
Without PROXY protocol, the origin mc-proxy sees the Tailscale peer IP as
the client address. With it, the real client IP is preserved for firewall
evaluation, logging, and `X-Forwarded-For` injection on L7 routes.
### Receiving (Listener Side)
When `proxy_protocol = true` on a listener:
1. After accepting the TCP connection, read the PROXY protocol header
**before** any TLS data.
2. Accept both v1 (text) and v2 (binary) formats.
3. Extract the real client IP and port from the header.
4. Use the real client IP for all subsequent processing (firewall, logging,
`X-Forwarded-For`).
5. If the header is malformed, missing, or the connection times out waiting
for it, reset the connection.
When `proxy_protocol = false` (default), the TCP source IP is used directly.
No PROXY header is expected or tolerated — a connection that starts with a
PROXY header will fail SNI extraction (it's not a TLS ClientHello) and be
reset. This prevents a non-PROXY listener from accidentally trusting
attacker-supplied headers.
### Sending (Route Side)
When `send_proxy_protocol = true` on a route:
1. After connecting to the backend, send a PROXY protocol v2 header before
any other data.
2. The header contains the real client IP (from the PROXY header on the
listener, or the TCP source IP if the listener is not PROXY-enabled).
3. For L4 routes: the v2 header is sent, then the buffered ClientHello,
then raw relay.
4. For L7 routes: the v2 header is sent on the backend connection before
HTTP traffic begins.
mc-proxy always sends v2 (binary format) when `send_proxy_protocol` is
enabled. v2 is more compact, supports IPv6 natively, and is the modern
standard.
### Header Timeout
A hardcoded 5-second timeout applies to reading the PROXY protocol header.
If the header is not fully received within this window, the connection is
reset. This prevents slowloris-style attacks on PROXY-enabled listeners.
---
## Firewall
The firewall is a global, ordered rule set evaluated on every new
connection before SNI extraction. Rules are evaluated in definition order;
the first matching rule determines the outcome. If no rule matches, the
connection is **allowed** (default allow the firewall is an explicit
connection is **allowed** (default allow -- the firewall is an explicit
blocklist, not an allowlist).
The source IP used for firewall evaluation is the **real client IP** --
either from a PROXY protocol header (if the listener is PROXY-enabled) or
the TCP source address.
### Rule Types
| Config Field | Match Field | Example |
@@ -124,7 +265,7 @@ blocklist, not an allowlist).
### Blocked Connection Handling
Blocked connections receive a TCP RST. No TLS alert, no HTTP error page, no
indication of why the connection was refused. This is intentional blocked
indication of why the connection was refused. This is intentional -- blocked
sources should receive minimal information.
### GeoIP Database
@@ -146,15 +287,19 @@ to start.
Each listener has its own route table mapping SNI hostnames to backend
addresses. A route entry consists of:
| Field | Type | Description |
|-------|------|-------------|
| `hostname` | string | Exact SNI hostname to match (e.g. `metacrypt.metacircular.net`) |
| `backend` | string | Backend address as `host:port` (e.g. `127.0.0.1:8443`) |
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `hostname` | string | *(required)* | Exact SNI hostname to match (e.g. `metacrypt.metacircular.net`) |
| `backend` | string | *(required)* | Backend address as `host:port` (e.g. `127.0.0.1:8443`) |
| `mode` | string | `"l4"` | `"l4"` for passthrough, `"l7"` for TLS-terminating reverse proxy |
| `tls_cert` | string | `""` | Path to TLS certificate file (PEM). Required for L7 routes. |
| `tls_key` | string | `""` | Path to TLS private key file (PEM). Required for L7 routes. |
| `backend_tls` | bool | `false` | If true, connect to backend with TLS (re-encrypt). If false, plaintext h2c/HTTP. |
| `send_proxy_protocol` | bool | `false` | If true, send PROXY protocol v2 header to backend. |
Routes are scoped to the listener that accepted the connection. The same
hostname can appear on different listeners with different backends, allowing
the proxy to route the same service name to different backends depending
on which port the client connected to.
hostname can appear on different listeners with different backends and
different modes.
### Match Semantics
@@ -172,12 +317,92 @@ seeded from the TOML configuration. On subsequent runs, the database is
the source of truth. Routes can be added or removed at runtime via the
gRPC admin API.
### Validation
- L7 routes must have `tls_cert` and `tls_key` set and pointing to
readable files. The proxy validates certificate/key pairs at startup and
refuses to start if any are invalid.
- L4 routes must not have `tls_cert` or `tls_key` set (they are ignored
and produce a warning if present).
- `backend_tls` is only meaningful for L7 routes. On L4 routes it is
ignored (the raw TLS stream passes through regardless).
---
## L7 Proxying
L7 routes terminate TLS at the proxy and reverse proxy HTTP traffic to the
backend. This enables HTTP-level features that are impossible in L4 mode.
### TLS Termination
The proxy uses `crypto/tls.Server` with a `GetCertificate` callback that
selects the certificate based on the SNI hostname. Since SNI has already
been extracted in step 4 of the connection lifecycle, the route (and its
cert/key) is known before the handshake begins.
The `prefixConn` wrapper replays the buffered ClientHello bytes, then reads
from the underlying `net.Conn`. From `crypto/tls`'s perspective, it is
reading a normal TCP connection.
TLS configuration:
- Minimum version: TLS 1.2 (TLS 1.3 preferred).
- Cipher suites: Go's default secure set (no manual override).
- ALPN: `h2` and `http/1.1` advertised. The negotiated protocol determines
whether the connection uses HTTP/2 or HTTP/1.1.
### HTTP/2 Reverse Proxy
After the TLS handshake, the proxy serves HTTP using Go's `net/http` server
with HTTP/2 enabled. Each request is forwarded to the backend using a
reverse proxy.
**Backend transports:**
| `backend_tls` | Protocol | Description |
|----------------|----------|-------------|
| `false` | h2c | HTTP/2 over plaintext TCP. Used for localhost/trusted-network backends. Supports gRPC and standard HTTP. |
| `true` | h2 (TLS) | HTTP/2 over TLS to backend. Used when backend is remote or requires encryption. |
The reverse proxy handles:
- **HTTP/2 and HTTP/1.1**: Protocol negotiated via ALPN on the client side.
Backend connections use h2c (or h2 if `backend_tls`). If the backend only
speaks HTTP/1.1, Go's transport handles the protocol translation.
- **gRPC**: gRPC is HTTP/2 with `content-type: application/grpc`. The
reverse proxy forwards HTTP/2 frames, including **trailers** (critical
for gRPC status codes and error details).
- **Streaming**: Server-side, client-side, and bidirectional gRPC streaming
work through the reverse proxy. Request and response bodies are streamed,
not buffered.
- **Header injection**: `X-Forwarded-For`, `X-Forwarded-Proto`, and
`X-Real-IP` are set from the real client IP (PROXY protocol or TCP source).
- **Hop-by-hop headers**: `Connection`, `Transfer-Encoding`, and other
hop-by-hop headers are stripped per RFC 7230.
### Connection Model
L7 uses 1:1 connection mapping: each client connection maps to one backend
connection. There is no connection pooling. This keeps the implementation
simple and the connection lifecycle predictable. Pooling may be added later
if backend connection setup becomes a bottleneck.
### Error Handling
| Condition | Client Response |
|-----------|----------------|
| TLS handshake failure | Connection closed (TCP RST) |
| Backend dial failure | HTTP 502 Bad Gateway |
| Backend timeout | HTTP 504 Gateway Timeout |
| Backend returns error | Error response forwarded as-is |
---
## gRPC Admin API
The admin API is optional (disabled if `[grpc]` is omitted from the config).
It listens on a Unix domain socket for security access is controlled via
It listens on a Unix domain socket for security -- access is controlled via
filesystem permissions (0600, owner-only). The API provides runtime management
of routes and firewall rules without restarting the proxy.
@@ -199,18 +424,20 @@ of routes and firewall rules without restarting the proxy.
The admin API validates all inputs before persisting:
- **Route backends** must be valid `host:port` tuples.
- **Route mode** must be `"l4"` or `"l7"`.
- **L7 routes** must include valid `tls_cert` and `tls_key` paths.
- **IP firewall rules** must be valid IP addresses (`netip.ParseAddr`).
- **CIDR firewall rules** must be valid prefixes in canonical form.
- **Country firewall rules** must be exactly 2 uppercase letters (ISO 3166-1 alpha-2).
### Security
The gRPC admin API has no MCIAS integration mc-proxy is pre-auth
The gRPC admin API has no MCIAS integration -- mc-proxy is pre-auth
infrastructure. Access control relies on Unix socket filesystem permissions:
- Socket is created with mode `0600` (read/write for owner only)
- Only processes running as the same user can connect
- No network exposure the API is not accessible over TCP
- No network exposure -- the API is not accessible over TCP
---
@@ -227,14 +454,28 @@ path = "/srv/mc-proxy/mc-proxy.db"
# Listeners. Each has its own route table (seeds DB on first run).
[[listeners]]
addr = ":443"
proxy_protocol = false # accept PROXY protocol headers (default: false)
# L4 route: passthrough, no TLS termination.
[[listeners.routes]]
hostname = "metacrypt.metacircular.net"
backend = "127.0.0.1:18443"
mode = "l4"
# L7 route: terminate TLS, reverse proxy HTTP/2.
[[listeners.routes]]
hostname = "api.metacircular.net"
backend = "127.0.0.1:8080"
mode = "l7"
tls_cert = "/srv/mc-proxy/certs/api.crt"
tls_key = "/srv/mc-proxy/certs/api.key"
# L4 route with PROXY protocol to downstream proxy.
[[listeners.routes]]
hostname = "mcias.metacircular.net"
backend = "127.0.0.1:28443"
backend = "10.0.0.5:443"
mode = "l4"
send_proxy_protocol = true
[[listeners]]
addr = ":8443"
@@ -243,14 +484,7 @@ addr = ":8443"
hostname = "metacrypt.metacircular.net"
backend = "127.0.0.1:18443"
[[listeners]]
addr = ":9443"
[[listeners.routes]]
hostname = "mcias.metacircular.net"
backend = "127.0.0.1:28443"
# gRPC admin API. Optional — omit or leave addr empty to disable.
# gRPC admin API. Optional -- omit or leave addr empty to disable.
# Listens on a Unix socket; access controlled via filesystem permissions.
[grpc]
addr = "/var/run/mc-proxy.sock"
@@ -261,6 +495,8 @@ geoip_db = "/srv/mc-proxy/GeoLite2-Country.mmdb"
blocked_ips = ["192.0.2.1"]
blocked_cidrs = ["198.51.100.0/24"]
blocked_countries = ["KP", "CN", "IN", "IL"]
rate_limit = 20 # max new connections per IP per rate_window
rate_window = "10s"
# Proxy behavior.
[proxy]
@@ -283,7 +519,7 @@ MCPROXY_LOG_LEVEL=debug
MCPROXY_PROXY_IDLE_TIMEOUT=600s
```
Environment variables cannot define listeners, routes, or firewall rules
Environment variables cannot define listeners, routes, or firewall rules --
these are structural and must be in the TOML file.
---
@@ -315,7 +551,8 @@ state is not modified.
```sql
CREATE TABLE listeners (
id INTEGER PRIMARY KEY,
addr TEXT NOT NULL UNIQUE
addr TEXT NOT NULL UNIQUE,
proxy_protocol INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE routes (
@@ -323,6 +560,11 @@ CREATE TABLE routes (
listener_id INTEGER NOT NULL REFERENCES listeners(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
backend TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'l4' CHECK(mode IN ('l4', 'l7')),
tls_cert TEXT NOT NULL DEFAULT '',
tls_key TEXT NOT NULL DEFAULT '',
backend_tls INTEGER NOT NULL DEFAULT 0,
send_proxy_protocol INTEGER NOT NULL DEFAULT 0,
UNIQUE(listener_id, hostname)
);
@@ -341,13 +583,16 @@ CREATE TABLE firewall_rules (
├── mc-proxy.toml Configuration
├── mc-proxy.db SQLite database
├── mc-proxy.sock Unix socket for gRPC admin API
├── certs/ TLS certificates for L7 routes
│ ├── api.crt
│ └── api.key
├── GeoLite2-Country.mmdb GeoIP database (if using country blocks)
└── backups/ Database snapshots
```
mc-proxy does not terminate TLS on any listener. The proxy listeners pass
through raw TLS streams, and the gRPC admin API uses a Unix socket
(filesystem permissions for access control).
L4 routes do not use any TLS material -- the raw TLS stream passes through.
L7 routes require certificate/key pairs in the `certs/` directory (or any
path specified in the route config). The gRPC admin API uses a Unix socket.
---
@@ -356,7 +601,8 @@ through raw TLS streams, and the gRPC admin API uses a Unix socket
### Binary
Single static binary, built with `CGO_ENABLED=0`. No runtime dependencies
beyond the configuration file and optional GeoIP database.
beyond the configuration file, optional GeoIP database, and TLS
certificates for any L7 routes.
### Container
@@ -380,6 +626,26 @@ in the systemd unit rather than running as root.
Standard security hardening directives apply per engineering standards
(`NoNewPrivileges=true`, `ProtectSystem=strict`, etc.).
### Multi-Hop Deployment
A typical production topology uses two mc-proxy instances:
```
Internet ──→ [edge mc-proxy] ──Tailscale──→ [origin mc-proxy] ──→ backends
VPS, :443 private network
```
**Edge mc-proxy** (public VPS):
- Listeners with `proxy_protocol = false` (clients connect directly).
- All routes are L4 passthrough with `send_proxy_protocol = true`.
- Firewall rules for GeoIP and IP blocking applied here.
**Origin mc-proxy** (private network):
- Listeners with `proxy_protocol = true` (receives real client IP from edge).
- Mix of L4 and L7 routes depending on the backend service.
- L7 routes terminate TLS and reverse proxy HTTP/2 to local backends.
- Firewall rules applied using the real client IP from the PROXY header.
### Graceful Shutdown
On `SIGINT` or `SIGTERM`:
@@ -410,21 +676,30 @@ It has no authentication or authorization of its own.
| Threat | Mitigation |
|--------|------------|
| SNI spoofing | Backend performs its own TLS handshake — a spoofed SNI will fail certificate validation at the backend. mc-proxy does not trust SNI for security decisions beyond routing. |
| Resource exhaustion (connection flood) | Idle timeout closes stale connections. Per-listener connection limits (future). Rate limiting (future). |
| SNI spoofing | L4: Backend performs its own TLS handshake -- spoofed SNI fails certificate validation at the backend. L7: The proxy validates the certificate against the SNI during its own handshake, so a mismatched SNI results in a handshake failure before reaching the backend. |
| TLS private key compromise (L7) | Keys are stored on the filesystem with restrictive permissions. The proxy process runs as a dedicated non-root user. Keys are loaded into memory at startup; the file can be made read-only after startup. Consider integration with metacrypt for key storage (future). |
| Client IP spoofing via PROXY protocol | PROXY protocol headers are only trusted on listeners explicitly configured with `proxy_protocol = true`. Non-PROXY listeners reject connections that start with a PROXY header (they fail SNI extraction). Edge listeners facing the public internet should never enable `proxy_protocol`. |
| Resource exhaustion (connection flood) | Idle timeout closes stale connections. Per-IP rate limiting. Per-listener connection limits (future). |
| GeoIP evasion via IPv6 | GeoLite2 database includes IPv6 mappings. Both IPv4 and IPv6 source addresses are checked. |
| GeoIP evasion via VPN/proxy | Accepted risk. GeoIP blocking is a compliance measure, not a security boundary. Determined adversaries will bypass it. |
| Slowloris / slow ClientHello | Hardcoded 10-second timeout on the SNI extraction phase. If a complete ClientHello is not received within this window, the connection is reset. |
| Backend unavailability | Connect timeout prevents indefinite hangs. Connection is reset if the backend is unreachable. |
| Information leakage | Blocked connections receive only a TCP RST. No version strings, no error messages, no TLS alerts. |
| Slowloris / slow ClientHello | Hardcoded 10-second timeout on the SNI extraction phase. 5-second timeout on PROXY protocol header parsing. |
| Backend unavailability | Connect timeout prevents indefinite hangs. L4: connection reset. L7: HTTP 502 response. |
| Information leakage (L4) | Blocked connections receive only a TCP RST. No version strings, no error messages, no TLS alerts. |
| Information leakage (L7) | Error responses are minimal (502/504 with no detail). The proxy does not expose its version or software name in HTTP headers. |
| HTTP request smuggling (L7) | Go's `net/http` server handles HTTP parsing. The reverse proxy uses `httputil.ReverseProxy` which strips hop-by-hop headers. HTTP/2 framing prevents classic smuggling vectors. |
### Security Invariants
1. mc-proxy never terminates TLS. It cannot read application-layer traffic.
2. mc-proxy never modifies the byte stream between client and backend.
1. **L4 routes never terminate TLS.** The proxy cannot read application-layer
traffic on L4 routes. It never modifies the byte stream.
2. **L7 routes terminate TLS at the proxy.** The proxy can read HTTP headers
and request metadata on L7 routes. It cannot read encrypted backend
traffic when `backend_tls = true`.
3. Firewall rules are always evaluated before any routing decision.
4. The proxy never logs connection content — only metadata (source IP,
SNI hostname, backend, timestamps, bytes transferred).
4. PROXY protocol is only parsed on explicitly enabled listeners.
5. The proxy never logs request or response bodies -- only metadata (source
IP, SNI hostname, backend, timestamps, bytes transferred, HTTP status
codes on L7 routes).
---
@@ -434,10 +709,10 @@ Items are listed roughly in priority order:
| Item | Description |
|------|-------------|
| **ACME integration** | Automatic certificate provisioning via Let's Encrypt for L7 routes, removing the need for manual cert management. |
| **L7 policies** | User-agent blocking, header-based routing, request rate limiting per endpoint. Requires L7 mode. |
| **MCP integration** | Wire the gRPC admin API into the Metacircular Control Plane for centralized management. |
| **L7 HTTPS support** | TLS-terminating mode for selected routes, enabling HTTP-level features (user-agent blocking, header inspection, request routing). |
| **ACME integration** | Automatic certificate provisioning via Let's Encrypt for L7 routes. |
| **User-agent blocking** | Block connections based on user-agent string (requires L7 mode). |
| **Connection rate limiting** | Per-source-IP rate limits to mitigate connection floods. |
| **Connection pooling** | Pool backend connections for L7 routes to reduce connection setup overhead under high request volume. |
| **Per-listener connection limits** | Cap maximum concurrent connections per listener. |
| **Metrics** | Prometheus-compatible metrics: connections per listener, firewall blocks by rule, backend dial latency, active connections. |
| **Metrics** | Prometheus-compatible metrics: connections per listener, firewall blocks by rule, backend dial latency, active connections, HTTP status code distributions. |
| **Metacrypt key storage** | Store L7 TLS private keys in metacrypt rather than on the filesystem. |

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
mc-proxy is a Layer 4 TLS SNI proxy and router for Metacircular Dynamics services. It reads the SNI hostname from incoming TLS ClientHello messages and proxies the raw TCP stream to the matched backend. It does not terminate TLS. A global firewall (IP, CIDR, GeoIP country blocking) is evaluated before routing. See `ARCHITECTURE.md` for full design.
mc-proxy is a TLS proxy and router for Metacircular Dynamics services. It operates in two per-route modes: **L4 passthrough** (reads SNI, proxies raw TCP without terminating TLS) and **L7 terminating** (terminates TLS, reverse proxies HTTP/2 and HTTP/1.1 traffic including gRPC). A global firewall (IP, CIDR, GeoIP, rate limiting) is evaluated before routing. PROXY protocol support enables multi-hop deployments preserving real client IPs. See `ARCHITECTURE.md` for full design.
## Build Commands
@@ -28,7 +28,9 @@ go test ./internal/sni -run TestExtract
- **Module path**: `git.wntrmute.dev/kyle/mc-proxy`
- **Go with CGO_ENABLED=0**, statically linked, Alpine containers
- **gRPC admin API** — manages routes and firewall rules at runtime; TLS with optional mTLS; optional (disabled if `[grpc]` section omitted from config)
- **Dual mode, per-route** — L4 (passthrough) and L7 (TLS-terminating HTTP/2 reverse proxy) coexist on the same listener
- **PROXY protocol** — listeners accept v1/v2; routes send v2. Enables edge→origin deployments over Tailscale
- **gRPC admin API** — manages routes and firewall rules at runtime; Unix socket only; optional (disabled if `[grpc]` section omitted from config)
- **No auth on proxy listeners** — this is pre-auth infrastructure; services behind it handle their own MCIAS auth
- **SQLite database** — persists listeners, routes, and firewall rules; pure-Go driver (`modernc.org/sqlite`); seeded from TOML on first run, DB is source of truth thereafter
- **Write-through pattern** — gRPC mutations write to DB first, then update in-memory state
@@ -41,9 +43,11 @@ go test ./internal/sni -run TestExtract
- `internal/config/` — TOML config loading and validation
- `internal/db/` — SQLite database: migrations, CRUD for listeners/routes/firewall rules, seeding, snapshots
- `internal/sni/` — TLS ClientHello parser; extracts SNI hostname without consuming bytes
- `internal/firewall/` — global blocklist evaluation (IP, CIDR, GeoIP via MaxMind GeoLite2); thread-safe mutations and GeoIP reload
- `internal/proxy/` — bidirectional TCP relay with half-close propagation and idle timeout
- `internal/server/` — orchestrates listeners → firewall → SNI → route → proxy pipeline; per-listener state with connection tracking
- `internal/firewall/` — global blocklist evaluation (IP, CIDR, GeoIP via MaxMind GeoLite2); rate limiting; thread-safe mutations and GeoIP reload
- `internal/proxy/` L4 bidirectional TCP relay with half-close propagation and idle timeout
- `internal/proxyproto/` — PROXY protocol v1/v2 parser and v2 writer
- `internal/l7/` — L7 TLS termination, `prefixConn`, HTTP/2 reverse proxy with h2c backend transport
- `internal/server/` — orchestrates listeners → PROXY protocol → firewall → SNI → route → L4/L7 dispatch; per-listener state with connection tracking
- `internal/grpcserver/` — gRPC admin API: route/firewall CRUD, status, write-through to DB
- `proto/mc_proxy/v1/` — protobuf definitions; `gen/mc_proxy/v1/` has generated code
@@ -54,8 +58,10 @@ go test ./internal/sni -run TestExtract
## Critical Rules
- mc-proxy never terminates TLS and never modifies the byte stream.
- L4 routes never terminate TLS and never modify the byte stream.
- L7 routes terminate TLS at the proxy and reverse proxy HTTP/2 (including gRPC) to backends.
- Firewall rules are always evaluated before any routing decision.
- PROXY protocol is only parsed on explicitly enabled listeners.
- SNI matching is exact and case-insensitive.
- Blocked connections get a TCP RST — no error messages, no TLS alerts.
- Database writes must succeed before in-memory state is updated (write-through).

66
PROGRESS.md Normal file
View File

@@ -0,0 +1,66 @@
# PROGRESS.md
Tracks implementation status against PROJECT_PLAN.md. Updated as work
proceeds. Each item is marked:
- `[ ]` not started
- `[~]` in progress
- `[x]` complete
- `[—]` skipped (with reason)
---
## Phase 1: Database & Config Foundation
- [x] 1.1 Config struct updates (`Listener.ProxyProtocol`, `Route.Mode/TLSCert/TLSKey/BackendTLS/SendProxyProtocol`)
- [x] 1.2 Config validation updates (L7 requires cert/key, mode enum, cert/key pair loading)
- [x] 1.3 Database migration v2 (new columns on `listeners` and `routes`)
- [x] 1.4 DB struct and CRUD updates (new fields in `Listener`, `Route`, all queries)
- [x] 1.5 Server data loading (`RouteInfo` struct replaces bare backend string in route lookup)
- [x] 1.6 Tests (config, DB migration, CRUD, server unchanged)
## Phase 2: PROXY Protocol
- [ ] 2.1 `internal/proxyproto/` package (v1/v2 parser, v2 writer)
- [ ] 2.2 Server integration — receive (parse PROXY header before firewall on enabled listeners)
- [ ] 2.3 Server integration — send on L4 (write PROXY v2 header before ClientHello on enabled routes)
- [ ] 2.4 Tests (receive, send, firewall uses real IP, malformed header rejection)
## Phase 3: L7 Proxying
- [ ] 3.1 `internal/l7/` package (`PrefixConn`, HTTP/2 reverse proxy with h2c, `Serve` entry point)
- [ ] 3.2 Server integration (dispatch to L4 or L7 based on `route.Mode` in `handleConn`)
- [ ] 3.3 PROXY protocol sending in L7 path
- [ ] 3.4 Tests (TLS termination, h2c backend, re-encrypt, mixed L4/L7 listener, gRPC through L7)
## Phase 4: gRPC API & CLI Updates
- [ ] 4.1 Proto updates (new fields on `Route`, `AddRouteRequest`, `ListenerStatus`)
- [ ] 4.2 gRPC server updates (accept/validate/persist new route fields)
- [ ] 4.3 Client package updates (new fields on `Route`, `ListenerStatus`)
- [ ] 4.4 mcproxyctl updates (flags for `routes add`, display in `routes list`)
- [ ] 4.5 Tests (gRPC round-trip with new fields, backward compatibility)
## Phase 5: Integration & Polish
- [ ] 5.1 Dev config update (`srv/mc-proxy.toml` with L7 routes, test certs)
- [ ] 5.2 Multi-hop integration test (edge→origin via PROXY protocol)
- [ ] 5.3 gRPC-through-L7 validation (unary, streaming, trailers, deadlines)
- [ ] 5.4 Web UI through L7 validation (HTTP/1.1, HTTP/2, static assets)
- [ ] 5.5 Documentation (verify ARCHITECTURE.md, CLAUDE.md, Makefile)
---
## Current State
The codebase is L4-only. All existing functionality is working and tested:
- SNI extraction and raw TCP relay
- Global firewall (IP, CIDR, GeoIP country blocking, per-IP rate limiting)
- SQLite persistence with write-through pattern
- gRPC admin API (Unix socket) for route and firewall CRUD
- CLI tools (`mc-proxy server/status/snapshot`, `mcproxyctl`)
- `make all` passes (vet, lint, test, build)
ARCHITECTURE.md and CLAUDE.md have been updated to describe the target state.
PROJECT_PLAN.md describes the implementation path. This file tracks progress.

331
PROJECT_PLAN.md Normal file
View File

@@ -0,0 +1,331 @@
# 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.

View File

@@ -80,13 +80,17 @@ func setupTestClient(t *testing.T) *Client {
if err != nil {
t.Fatalf("list routes: %v", err)
}
routes := make(map[string]string, len(dbRoutes))
routes := make(map[string]server.RouteInfo, len(dbRoutes))
for _, r := range dbRoutes {
routes[r.Hostname] = r.Backend
routes[r.Hostname] = server.RouteInfo{
Backend: r.Backend,
Mode: r.Mode,
}
}
listenerData = append(listenerData, server.ListenerData{
ID: l.ID,
Addr: l.Addr,
ProxyProtocol: l.ProxyProtocol,
Routes: routes,
})
}

View File

@@ -130,13 +130,21 @@ func loadListenersFromDB(store *db.Store) ([]server.ListenerData, error) {
if err != nil {
return nil, fmt.Errorf("loading routes for listener %q: %w", l.Addr, err)
}
routes := make(map[string]string, len(dbRoutes))
routes := make(map[string]server.RouteInfo, len(dbRoutes))
for _, r := range dbRoutes {
routes[strings.ToLower(r.Hostname)] = r.Backend
routes[strings.ToLower(r.Hostname)] = server.RouteInfo{
Backend: r.Backend,
Mode: r.Mode,
TLSCert: r.TLSCert,
TLSKey: r.TLSKey,
BackendTLS: r.BackendTLS,
SendProxyProtocol: r.SendProxyProtocol,
}
}
result = append(result, server.ListenerData{
ID: l.ID,
Addr: l.Addr,
ProxyProtocol: l.ProxyProtocol,
Routes: routes,
})
}

View File

@@ -1,6 +1,7 @@
package config
import (
"crypto/tls"
"fmt"
"os"
"strings"
@@ -28,12 +29,18 @@ type GRPC struct {
type Listener struct {
Addr string `toml:"addr"`
ProxyProtocol bool `toml:"proxy_protocol"`
Routes []Route `toml:"routes"`
}
type Route struct {
Hostname string `toml:"hostname"`
Backend string `toml:"backend"`
Mode string `toml:"mode"` // "l4" (default) or "l7"
TLSCert string `toml:"tls_cert"` // PEM certificate path (L7 only)
TLSKey string `toml:"tls_key"` // PEM private key path (L7 only)
BackendTLS bool `toml:"backend_tls"` // re-encrypt to backend (L7 only)
SendProxyProtocol bool `toml:"send_proxy_protocol"` // send PROXY v2 header to backend
}
type Firewall struct {
@@ -163,12 +170,14 @@ func (c *Config) validate() error {
}
// Validate listeners if provided (used for seeding on first run).
for i, l := range c.Listeners {
for i := range c.Listeners {
l := &c.Listeners[i]
if l.Addr == "" {
return fmt.Errorf("listener %d: addr is required", i)
}
seen := make(map[string]bool)
for j, r := range l.Routes {
for j := range l.Routes {
r := &l.Routes[j]
if r.Hostname == "" {
return fmt.Errorf("listener %d (%s), route %d: hostname is required", i, l.Addr, j)
}
@@ -179,6 +188,27 @@ func (c *Config) validate() error {
return fmt.Errorf("listener %d (%s), route %d: duplicate hostname %q", i, l.Addr, j, r.Hostname)
}
seen[r.Hostname] = true
// Normalize mode: empty defaults to "l4".
if r.Mode == "" {
r.Mode = "l4"
}
if r.Mode != "l4" && r.Mode != "l7" {
return fmt.Errorf("listener %d (%s), route %d (%s): mode must be \"l4\" or \"l7\", got %q",
i, l.Addr, j, r.Hostname, r.Mode)
}
// L7 routes require TLS cert and key.
if r.Mode == "l7" {
if r.TLSCert == "" || r.TLSKey == "" {
return fmt.Errorf("listener %d (%s), route %d (%s): L7 routes require tls_cert and tls_key",
i, l.Addr, j, r.Hostname)
}
if _, err := tls.LoadX509KeyPair(r.TLSCert, r.TLSKey); err != nil {
return fmt.Errorf("listener %d (%s), route %d (%s): loading TLS cert/key: %w",
i, l.Addr, j, r.Hostname, err)
}
}
}
}

View File

@@ -391,3 +391,147 @@ path = "/tmp/test.db"
t.Fatalf("got grpc.addr %q, want %q", cfg.GRPC.Addr, "/var/run/override.sock")
}
}
func TestLoadL4ModeDefault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
[[listeners.routes]]
hostname = "example.com"
backend = "127.0.0.1:8443"
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Mode should be normalized to "l4" when unset.
if cfg.Listeners[0].Routes[0].Mode != "l4" {
t.Fatalf("got mode %q, want %q", cfg.Listeners[0].Routes[0].Mode, "l4")
}
}
func TestLoadInvalidMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
[[listeners.routes]]
hostname = "example.com"
backend = "127.0.0.1:8443"
mode = "l5"
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error for invalid mode")
}
}
func TestLoadL7RequiresCertKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
[[listeners.routes]]
hostname = "example.com"
backend = "127.0.0.1:8080"
mode = "l7"
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error for L7 route without cert/key")
}
}
func TestLoadL7InvalidCertKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
[[listeners.routes]]
hostname = "example.com"
backend = "127.0.0.1:8080"
mode = "l7"
tls_cert = "/nonexistent/cert.pem"
tls_key = "/nonexistent/key.pem"
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error for L7 route with nonexistent cert/key files")
}
}
func TestLoadProxyProtocol(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
proxy_protocol = true
[[listeners.routes]]
hostname = "example.com"
backend = "127.0.0.1:8443"
send_proxy_protocol = true
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !cfg.Listeners[0].ProxyProtocol {
t.Fatal("expected proxy_protocol = true")
}
if !cfg.Listeners[0].Routes[0].SendProxyProtocol {
t.Fatal("expected send_proxy_protocol = true")
}
}

View File

@@ -41,7 +41,7 @@ func TestIsEmpty(t *testing.T) {
t.Fatal("expected empty database")
}
if _, err := store.CreateListener(":443"); err != nil {
if _, err := store.CreateListener(":443", false); err != nil {
t.Fatalf("create listener: %v", err)
}
@@ -57,7 +57,7 @@ func TestIsEmpty(t *testing.T) {
func TestListenerCRUD(t *testing.T) {
store := openTestDB(t)
id, err := store.CreateListener(":443")
id, err := store.CreateListener(":443", false)
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -75,6 +75,9 @@ func TestListenerCRUD(t *testing.T) {
if listeners[0].Addr != ":443" {
t.Fatalf("got addr %q, want %q", listeners[0].Addr, ":443")
}
if listeners[0].ProxyProtocol {
t.Fatal("expected proxy_protocol = false")
}
l, err := store.GetListenerByAddr(":443")
if err != nil {
@@ -97,13 +100,33 @@ func TestListenerCRUD(t *testing.T) {
}
}
func TestListenerProxyProtocol(t *testing.T) {
store := openTestDB(t)
id, err := store.CreateListener(":443", true)
if err != nil {
t.Fatalf("create: %v", err)
}
l, err := store.GetListenerByAddr(":443")
if err != nil {
t.Fatalf("get by addr: %v", err)
}
if l.ID != id {
t.Fatalf("got ID %d, want %d", l.ID, id)
}
if !l.ProxyProtocol {
t.Fatal("expected proxy_protocol = true")
}
}
func TestListenerDuplicateAddr(t *testing.T) {
store := openTestDB(t)
if _, err := store.CreateListener(":443"); err != nil {
if _, err := store.CreateListener(":443", false); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := store.CreateListener(":443"); err == nil {
if _, err := store.CreateListener(":443", false); err == nil {
t.Fatal("expected error for duplicate addr")
}
}
@@ -111,12 +134,12 @@ func TestListenerDuplicateAddr(t *testing.T) {
func TestRouteCRUD(t *testing.T) {
store := openTestDB(t)
listenerID, err := store.CreateListener(":443")
listenerID, err := store.CreateListener(":443", false)
if err != nil {
t.Fatalf("create listener: %v", err)
}
routeID, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443")
routeID, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443", "l4", "", "", false, false)
if err != nil {
t.Fatalf("create route: %v", err)
}
@@ -134,6 +157,9 @@ func TestRouteCRUD(t *testing.T) {
if routes[0].Hostname != "example.com" {
t.Fatalf("got hostname %q, want %q", routes[0].Hostname, "example.com")
}
if routes[0].Mode != "l4" {
t.Fatalf("got mode %q, want %q", routes[0].Mode, "l4")
}
if err := store.DeleteRoute(listenerID, "example.com"); err != nil {
t.Fatalf("delete route: %v", err)
@@ -148,14 +174,51 @@ func TestRouteCRUD(t *testing.T) {
}
}
func TestRouteL7Fields(t *testing.T) {
store := openTestDB(t)
listenerID, _ := store.CreateListener(":443", false)
_, err := store.CreateRoute(listenerID, "api.example.com", "127.0.0.1:8080", "l7",
"/certs/api.crt", "/certs/api.key", false, true)
if err != nil {
t.Fatalf("create L7 route: %v", err)
}
routes, err := store.ListRoutes(listenerID)
if err != nil {
t.Fatalf("list routes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("got %d routes, want 1", len(routes))
}
r := routes[0]
if r.Mode != "l7" {
t.Fatalf("mode = %q, want %q", r.Mode, "l7")
}
if r.TLSCert != "/certs/api.crt" {
t.Fatalf("tls_cert = %q, want %q", r.TLSCert, "/certs/api.crt")
}
if r.TLSKey != "/certs/api.key" {
t.Fatalf("tls_key = %q, want %q", r.TLSKey, "/certs/api.key")
}
if r.BackendTLS {
t.Fatal("expected backend_tls = false")
}
if !r.SendProxyProtocol {
t.Fatal("expected send_proxy_protocol = true")
}
}
func TestRouteDuplicateHostname(t *testing.T) {
store := openTestDB(t)
listenerID, _ := store.CreateListener(":443")
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443"); err != nil {
listenerID, _ := store.CreateListener(":443", false)
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443", "l4", "", "", false, false); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:9443"); err == nil {
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:9443", "l4", "", "", false, false); err == nil {
t.Fatal("expected error for duplicate hostname on same listener")
}
}
@@ -163,9 +226,9 @@ func TestRouteDuplicateHostname(t *testing.T) {
func TestRouteCascadeDelete(t *testing.T) {
store := openTestDB(t)
listenerID, _ := store.CreateListener(":443")
store.CreateRoute(listenerID, "a.example.com", "127.0.0.1:8443")
store.CreateRoute(listenerID, "b.example.com", "127.0.0.1:9443")
listenerID, _ := store.CreateListener(":443", false)
store.CreateRoute(listenerID, "a.example.com", "127.0.0.1:8443", "l4", "", "", false, false)
store.CreateRoute(listenerID, "b.example.com", "127.0.0.1:9443", "l4", "", "", false, false)
if err := store.DeleteListener(listenerID); err != nil {
t.Fatalf("delete listener: %v", err)
@@ -237,14 +300,15 @@ func TestSeed(t *testing.T) {
{
Addr: ":443",
Routes: []config.Route{
{Hostname: "a.example.com", Backend: "127.0.0.1:8443"},
{Hostname: "a.example.com", Backend: "127.0.0.1:8443", Mode: "l4"},
{Hostname: "b.example.com", Backend: "127.0.0.1:9443"},
},
},
{
Addr: ":8443",
ProxyProtocol: true,
Routes: []config.Route{
{Hostname: "c.example.com", Backend: "127.0.0.1:18443"},
{Hostname: "c.example.com", Backend: "127.0.0.1:18443", Mode: "l4", SendProxyProtocol: true},
},
},
}
@@ -266,6 +330,9 @@ func TestSeed(t *testing.T) {
if len(dbListeners) != 2 {
t.Fatalf("got %d listeners, want 2", len(dbListeners))
}
if !dbListeners[1].ProxyProtocol {
t.Fatal("expected listener 2 proxy_protocol = true")
}
routes, err := store.ListRoutes(dbListeners[0].ID)
if err != nil {
@@ -275,6 +342,25 @@ func TestSeed(t *testing.T) {
t.Fatalf("got %d routes for listener 0, want 2", len(routes))
}
// Verify mode defaults to "l4" even when empty in config.
for _, r := range routes {
if r.Mode != "l4" {
t.Fatalf("route %q mode = %q, want %q", r.Hostname, r.Mode, "l4")
}
}
// Verify send_proxy_protocol on listener 2's route.
routes2, err := store.ListRoutes(dbListeners[1].ID)
if err != nil {
t.Fatalf("list routes listener 2: %v", err)
}
if len(routes2) != 1 {
t.Fatalf("got %d routes for listener 1, want 1", len(routes2))
}
if !routes2[0].SendProxyProtocol {
t.Fatal("expected send_proxy_protocol = true on listener 2 route")
}
rules, err := store.ListFirewallRules()
if err != nil {
t.Fatalf("list firewall rules: %v", err)
@@ -287,7 +373,7 @@ func TestSeed(t *testing.T) {
func TestSnapshot(t *testing.T) {
store := openTestDB(t)
store.CreateListener(":443")
store.CreateListener(":443", false)
dest := filepath.Join(t.TempDir(), "backup.db")
if err := store.Snapshot(dest); err != nil {
@@ -329,3 +415,57 @@ func TestDeleteNonexistent(t *testing.T) {
t.Fatal("expected error deleting nonexistent firewall rule")
}
}
// TestMigrationV2Upgrade verifies that migration v2 adds new columns
// to an existing v1 database without data loss.
func TestMigrationV2Upgrade(t *testing.T) {
dir := t.TempDir()
store, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { store.Close() })
// Run full migrations (v1 + v2).
if err := store.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
// Insert a listener and route with defaults to verify new columns work.
lid, err := store.CreateListener(":443", false)
if err != nil {
t.Fatalf("create listener: %v", err)
}
_, err = store.CreateRoute(lid, "test.example.com", "127.0.0.1:8443", "l4", "", "", false, false)
if err != nil {
t.Fatalf("create route: %v", err)
}
// Read back and verify defaults.
routes, err := store.ListRoutes(lid)
if err != nil {
t.Fatalf("list routes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("got %d routes, want 1", len(routes))
}
r := routes[0]
if r.Mode != "l4" {
t.Fatalf("mode = %q, want %q", r.Mode, "l4")
}
if r.TLSCert != "" || r.TLSKey != "" {
t.Fatalf("expected empty cert/key, got cert=%q key=%q", r.TLSCert, r.TLSKey)
}
if r.BackendTLS || r.SendProxyProtocol {
t.Fatal("expected false for backend_tls and send_proxy_protocol")
}
listeners, err := store.ListListeners()
if err != nil {
t.Fatalf("list listeners: %v", err)
}
if listeners[0].ProxyProtocol {
t.Fatal("expected proxy_protocol = false")
}
}

View File

@@ -6,11 +6,12 @@ import "fmt"
type Listener struct {
ID int64
Addr string
ProxyProtocol bool
}
// ListListeners returns all listeners.
func (s *Store) ListListeners() ([]Listener, error) {
rows, err := s.db.Query("SELECT id, addr FROM listeners ORDER BY id")
rows, err := s.db.Query("SELECT id, addr, proxy_protocol FROM listeners ORDER BY id")
if err != nil {
return nil, fmt.Errorf("querying listeners: %w", err)
}
@@ -19,7 +20,7 @@ func (s *Store) ListListeners() ([]Listener, error) {
var listeners []Listener
for rows.Next() {
var l Listener
if err := rows.Scan(&l.ID, &l.Addr); err != nil {
if err := rows.Scan(&l.ID, &l.Addr, &l.ProxyProtocol); err != nil {
return nil, fmt.Errorf("scanning listener: %w", err)
}
listeners = append(listeners, l)
@@ -28,8 +29,11 @@ func (s *Store) ListListeners() ([]Listener, error) {
}
// CreateListener inserts a listener and returns its ID.
func (s *Store) CreateListener(addr string) (int64, error) {
result, err := s.db.Exec("INSERT INTO listeners (addr) VALUES (?)", addr)
func (s *Store) CreateListener(addr string, proxyProtocol bool) (int64, error) {
result, err := s.db.Exec(
"INSERT INTO listeners (addr, proxy_protocol) VALUES (?, ?)",
addr, proxyProtocol,
)
if err != nil {
return 0, fmt.Errorf("inserting listener: %w", err)
}
@@ -52,8 +56,8 @@ func (s *Store) DeleteListener(id int64) error {
// GetListenerByAddr returns a listener by its address.
func (s *Store) GetListenerByAddr(addr string) (Listener, error) {
var l Listener
err := s.db.QueryRow("SELECT id, addr FROM listeners WHERE addr = ?", addr).
Scan(&l.ID, &l.Addr)
err := s.db.QueryRow("SELECT id, addr, proxy_protocol FROM listeners WHERE addr = ?", addr).
Scan(&l.ID, &l.Addr, &l.ProxyProtocol)
if err != nil {
return Listener{}, fmt.Errorf("querying listener by addr %q: %w", addr, err)
}

View File

@@ -13,6 +13,7 @@ type migration struct {
var migrations = []migration{
{1, "create_core_tables", migrate001CreateCoreTables},
{2, "add_proxy_protocol_and_l7_fields", migrate002AddL7Fields},
}
// Migrate runs all unapplied migrations sequentially.
@@ -91,3 +92,21 @@ func migrate001CreateCoreTables(tx *sql.Tx) error {
}
return nil
}
func migrate002AddL7Fields(tx *sql.Tx) error {
stmts := []string{
`ALTER TABLE listeners ADD COLUMN proxy_protocol INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN mode TEXT NOT NULL DEFAULT 'l4'`,
`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`,
}
for _, stmt := range stmts {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return nil
}

View File

@@ -8,12 +8,18 @@ type Route struct {
ListenerID int64
Hostname string
Backend string
Mode string // "l4" or "l7"
TLSCert string
TLSKey string
BackendTLS bool
SendProxyProtocol bool
}
// ListRoutes returns all routes for a listener.
func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
rows, err := s.db.Query(
"SELECT id, listener_id, hostname, backend FROM routes WHERE listener_id = ? ORDER BY hostname",
`SELECT id, listener_id, hostname, backend, mode, tls_cert, tls_key, backend_tls, send_proxy_protocol
FROM routes WHERE listener_id = ? ORDER BY hostname`,
listenerID,
)
if err != nil {
@@ -24,7 +30,8 @@ func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
var routes []Route
for rows.Next() {
var r Route
if err := rows.Scan(&r.ID, &r.ListenerID, &r.Hostname, &r.Backend); err != nil {
if err := rows.Scan(&r.ID, &r.ListenerID, &r.Hostname, &r.Backend,
&r.Mode, &r.TLSCert, &r.TLSKey, &r.BackendTLS, &r.SendProxyProtocol); err != nil {
return nil, fmt.Errorf("scanning route: %w", err)
}
routes = append(routes, r)
@@ -33,10 +40,11 @@ func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
}
// CreateRoute inserts a route and returns its ID.
func (s *Store) CreateRoute(listenerID int64, hostname, backend string) (int64, error) {
func (s *Store) CreateRoute(listenerID int64, hostname, backend, mode, tlsCert, tlsKey string, backendTLS, sendProxyProtocol bool) (int64, error) {
result, err := s.db.Exec(
"INSERT INTO routes (listener_id, hostname, backend) VALUES (?, ?, ?)",
listenerID, hostname, backend,
`INSERT INTO routes (listener_id, hostname, backend, mode, tls_cert, tls_key, backend_tls, send_proxy_protocol)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
listenerID, hostname, backend, mode, tlsCert, tlsKey, backendTLS, sendProxyProtocol,
)
if err != nil {
return 0, fmt.Errorf("inserting route: %w", err)

View File

@@ -17,16 +17,25 @@ func (s *Store) Seed(listeners []config.Listener, fw config.Firewall) error {
defer tx.Rollback()
for _, l := range listeners {
result, err := tx.Exec("INSERT INTO listeners (addr) VALUES (?)", l.Addr)
result, err := tx.Exec(
"INSERT INTO listeners (addr, proxy_protocol) VALUES (?, ?)",
l.Addr, l.ProxyProtocol,
)
if err != nil {
return fmt.Errorf("seeding listener %q: %w", l.Addr, err)
}
listenerID, _ := result.LastInsertId()
for _, r := range l.Routes {
mode := r.Mode
if mode == "" {
mode = "l4"
}
_, err := tx.Exec(
"INSERT INTO routes (listener_id, hostname, backend) VALUES (?, ?, ?)",
`INSERT INTO routes (listener_id, hostname, backend, mode, tls_cert, tls_key, backend_tls, send_proxy_protocol)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
listenerID, strings.ToLower(r.Hostname), r.Backend,
mode, r.TLSCert, r.TLSKey, r.BackendTLS, r.SendProxyProtocol,
)
if err != nil {
return fmt.Errorf("seeding route %q on listener %q: %w", r.Hostname, l.Addr, err)

View File

@@ -88,10 +88,10 @@ func (a *AdminServer) ListRoutes(_ context.Context, req *pb.ListRoutesRequest) (
resp := &pb.ListRoutesResponse{
ListenerAddr: ls.Addr,
}
for hostname, backend := range routes {
for hostname, route := range routes {
resp.Routes = append(resp.Routes, &pb.Route{
Hostname: hostname,
Backend: backend,
Backend: route.Backend,
})
}
return resp, nil
@@ -119,11 +119,11 @@ func (a *AdminServer) AddRoute(_ context.Context, req *pb.AddRouteRequest) (*pb.
hostname := strings.ToLower(req.Route.Hostname)
// Write-through: DB first, then memory.
if _, err := a.store.CreateRoute(ls.ID, hostname, req.Route.Backend); err != nil {
if _, err := a.store.CreateRoute(ls.ID, hostname, req.Route.Backend, "l4", "", "", false, false); err != nil {
return nil, status.Errorf(codes.AlreadyExists, "%v", err)
}
if err := ls.AddRoute(hostname, req.Route.Backend); err != nil {
if err := ls.AddRoute(hostname, server.RouteInfo{Backend: req.Route.Backend, Mode: "l4"}); err != nil {
// DB succeeded but memory failed (should not happen since DB enforces uniqueness).
a.logger.Error("inconsistency: DB write succeeded but memory update failed", "error", err)
}

View File

@@ -87,13 +87,17 @@ func setup(t *testing.T) *testEnv {
if err != nil {
t.Fatalf("list routes: %v", err)
}
routes := make(map[string]string, len(dbRoutes))
routes := make(map[string]server.RouteInfo, len(dbRoutes))
for _, r := range dbRoutes {
routes[r.Hostname] = r.Backend
routes[r.Hostname] = server.RouteInfo{
Backend: r.Backend,
Mode: r.Mode,
}
}
listenerData = append(listenerData, server.ListenerData{
ID: l.ID,
Addr: l.Addr,
ProxyProtocol: l.ProxyProtocol,
Routes: routes,
})
}

View File

@@ -17,11 +17,22 @@ import (
"git.wntrmute.dev/kyle/mc-proxy/internal/sni"
)
// RouteInfo holds the full configuration for a single route.
type RouteInfo struct {
Backend string
Mode string // "l4" or "l7"
TLSCert string
TLSKey string
BackendTLS bool
SendProxyProtocol bool
}
// ListenerState holds the mutable state for a single proxy listener.
type ListenerState struct {
ID int64 // database primary key
Addr string
routes map[string]string // lowercase hostname → backend addr
ProxyProtocol bool
routes map[string]RouteInfo // lowercase hostname → route info
mu sync.RWMutex
ActiveConnections atomic.Int64
activeConns map[net.Conn]struct{} // tracked for forced shutdown
@@ -29,11 +40,11 @@ type ListenerState struct {
}
// Routes returns a snapshot of the listener's route table.
func (ls *ListenerState) Routes() map[string]string {
func (ls *ListenerState) Routes() map[string]RouteInfo {
ls.mu.RLock()
defer ls.mu.RUnlock()
m := make(map[string]string, len(ls.routes))
m := make(map[string]RouteInfo, len(ls.routes))
for k, v := range ls.routes {
m[k] = v
}
@@ -42,7 +53,7 @@ func (ls *ListenerState) Routes() map[string]string {
// AddRoute adds a route to the listener. Returns an error if the hostname
// already exists.
func (ls *ListenerState) AddRoute(hostname, backend string) error {
func (ls *ListenerState) AddRoute(hostname string, info RouteInfo) error {
key := strings.ToLower(hostname)
ls.mu.Lock()
@@ -51,7 +62,7 @@ func (ls *ListenerState) AddRoute(hostname, backend string) error {
if _, ok := ls.routes[key]; ok {
return fmt.Errorf("route %q already exists", hostname)
}
ls.routes[key] = backend
ls.routes[key] = info
return nil
}
@@ -70,19 +81,20 @@ func (ls *ListenerState) RemoveRoute(hostname string) error {
return nil
}
func (ls *ListenerState) lookupRoute(hostname string) (string, bool) {
func (ls *ListenerState) lookupRoute(hostname string) (RouteInfo, bool) {
ls.mu.RLock()
defer ls.mu.RUnlock()
backend, ok := ls.routes[hostname]
return backend, ok
info, ok := ls.routes[hostname]
return info, ok
}
// ListenerData holds the data needed to construct a ListenerState.
type ListenerData struct {
ID int64
Addr string
Routes map[string]string // lowercase hostname → backend
ProxyProtocol bool
Routes map[string]RouteInfo // lowercase hostname → route info
}
// Server is the mc-proxy server. It manages listeners, firewall evaluation,
@@ -104,6 +116,7 @@ func New(cfg *config.Config, fw *firewall.Firewall, listenerData []ListenerData,
listeners = append(listeners, &ListenerState{
ID: ld.ID,
Addr: ld.Addr,
ProxyProtocol: ld.ProxyProtocol,
routes: ld.Routes,
activeConns: make(map[net.Conn]struct{}),
})
@@ -264,20 +277,32 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn, ls *ListenerStat
return
}
backend, ok := ls.lookupRoute(hostname)
route, ok := ls.lookupRoute(hostname)
if !ok {
s.logger.Debug("no route for hostname", "addr", addr, "hostname", hostname)
return
}
backendConn, err := net.DialTimeout("tcp", backend, s.cfg.Proxy.ConnectTimeout.Duration)
// Dispatch based on route mode. L7 will be implemented in a later phase.
switch route.Mode {
case "l7":
s.logger.Error("L7 mode not yet implemented", "hostname", hostname)
return
default:
s.handleL4(ctx, conn, ls, addr, hostname, route, peeked)
}
}
// handleL4 handles an L4 (passthrough) connection.
func (s *Server) handleL4(ctx context.Context, conn net.Conn, _ *ListenerState, addr netip.Addr, hostname string, route RouteInfo, peeked []byte) {
backendConn, err := net.DialTimeout("tcp", route.Backend, s.cfg.Proxy.ConnectTimeout.Duration)
if err != nil {
s.logger.Error("backend dial failed", "hostname", hostname, "backend", backend, "error", err)
s.logger.Error("backend dial failed", "hostname", hostname, "backend", route.Backend, "error", err)
return
}
defer backendConn.Close()
s.logger.Debug("proxying", "addr", addr, "hostname", hostname, "backend", backend)
s.logger.Debug("proxying", "addr", addr, "hostname", hostname, "backend", route.Backend)
result, err := proxy.Relay(ctx, conn, backendConn, peeked, s.cfg.Proxy.IdleTimeout.Duration)
if err != nil && ctx.Err() == nil {

View File

@@ -14,6 +14,11 @@ import (
"git.wntrmute.dev/kyle/mc-proxy/internal/firewall"
)
// l4Route creates a RouteInfo for an L4 passthrough route.
func l4Route(backend string) RouteInfo {
return RouteInfo{Backend: backend, Mode: "l4"}
}
// echoServer accepts one connection, copies everything back, then closes.
func echoServer(t *testing.T, ln net.Listener) {
t.Helper()
@@ -85,8 +90,8 @@ func TestProxyRoundTrip(t *testing.T) {
{
ID: 1,
Addr: proxyAddr,
Routes: map[string]string{
"echo.test": backendLn.Addr().String(),
Routes: map[string]RouteInfo{
"echo.test": l4Route(backendLn.Addr().String()),
},
},
})
@@ -141,8 +146,8 @@ func TestNoRouteResets(t *testing.T) {
{
ID: 1,
Addr: proxyAddr,
Routes: map[string]string{
"other.test": "127.0.0.1:1", // exists but won't match
Routes: map[string]RouteInfo{
"other.test": l4Route("127.0.0.1:1"), // exists but won't match
},
},
})
@@ -212,8 +217,8 @@ func TestFirewallBlocks(t *testing.T) {
{
ID: 1,
Addr: proxyAddr,
Routes: map[string]string{
"echo.test": backendLn.Addr().String(),
Routes: map[string]RouteInfo{
"echo.test": l4Route(backendLn.Addr().String()),
},
},
}, logger, "test")
@@ -267,7 +272,7 @@ func TestNotTLSResets(t *testing.T) {
{
ID: 1,
Addr: proxyAddr,
Routes: map[string]string{"x.test": "127.0.0.1:1"},
Routes: map[string]RouteInfo{"x.test": l4Route("127.0.0.1:1")},
},
})
@@ -325,8 +330,8 @@ func TestConnectionTracking(t *testing.T) {
{
ID: 1,
Addr: proxyAddr,
Routes: map[string]string{
"conn.test": backendLn.Addr().String(),
Routes: map[string]RouteInfo{
"conn.test": l4Route(backendLn.Addr().String()),
},
},
})
@@ -432,8 +437,8 @@ func TestMultipleListeners(t *testing.T) {
ln2.Close()
srv := newTestServer(t, []ListenerData{
{ID: 1, Addr: addr1, Routes: map[string]string{"svc.test": backendA.Addr().String()}},
{ID: 2, Addr: addr2, Routes: map[string]string{"svc.test": backendB.Addr().String()}},
{ID: 1, Addr: addr1, Routes: map[string]RouteInfo{"svc.test": l4Route(backendA.Addr().String())}},
{ID: 2, Addr: addr2, Routes: map[string]RouteInfo{"svc.test": l4Route(backendB.Addr().String())}},
})
stop := startAndStop(t, srv)
@@ -500,8 +505,8 @@ func TestCaseInsensitiveRouting(t *testing.T) {
{
ID: 1,
Addr: proxyAddr,
Routes: map[string]string{
"echo.test": backendLn.Addr().String(),
Routes: map[string]RouteInfo{
"echo.test": l4Route(backendLn.Addr().String()),
},
},
})
@@ -549,8 +554,8 @@ func TestBackendUnreachable(t *testing.T) {
{
ID: 1,
Addr: proxyAddr,
Routes: map[string]string{
"dead.test": deadAddr,
Routes: map[string]RouteInfo{
"dead.test": l4Route(deadAddr),
},
},
})
@@ -612,7 +617,7 @@ func TestGracefulShutdown(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := New(cfg, fw, []ListenerData{
{ID: 1, Addr: proxyAddr, Routes: map[string]string{"hold.test": backendLn.Addr().String()}},
{ID: 1, Addr: proxyAddr, Routes: map[string]RouteInfo{"hold.test": l4Route(backendLn.Addr().String())}},
}, logger, "test")
ctx, cancel := context.WithCancel(context.Background())
@@ -651,18 +656,18 @@ func TestListenerStateRoutes(t *testing.T) {
ls := &ListenerState{
ID: 1,
Addr: ":443",
routes: map[string]string{
"a.test": "127.0.0.1:1",
routes: map[string]RouteInfo{
"a.test": l4Route("127.0.0.1:1"),
},
}
// AddRoute
if err := ls.AddRoute("b.test", "127.0.0.1:2"); err != nil {
if err := ls.AddRoute("b.test", l4Route("127.0.0.1:2")); err != nil {
t.Fatalf("AddRoute: %v", err)
}
// AddRoute duplicate
if err := ls.AddRoute("b.test", "127.0.0.1:3"); err == nil {
if err := ls.AddRoute("b.test", l4Route("127.0.0.1:3")); err == nil {
t.Fatal("expected error for duplicate route")
}
@@ -686,8 +691,8 @@ func TestListenerStateRoutes(t *testing.T) {
if len(routes) != 1 {
t.Fatalf("expected 1 route, got %d", len(routes))
}
if routes["b.test"] != "127.0.0.1:2" {
t.Fatalf("expected b.test → 127.0.0.1:2, got %q", routes["b.test"])
if routes["b.test"].Backend != "127.0.0.1:2" {
t.Fatalf("expected b.test → 127.0.0.1:2, got %q", routes["b.test"].Backend)
}
}