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:
445
ARCHITECTURE.md
445
ARCHITECTURE.md
@@ -1,56 +1,88 @@
|
|||||||
# ARCHITECTURE.md
|
# ARCHITECTURE.md
|
||||||
|
|
||||||
mc-proxy is a Layer 4 TLS proxy and router for Metacircular Dynamics
|
mc-proxy is a TLS proxy and router for Metacircular Dynamics services. It
|
||||||
services. It inspects the SNI field of incoming TLS ClientHello messages to
|
operates in two modes on a per-route basis:
|
||||||
determine the target backend, then proxies raw TCP between the client and
|
|
||||||
the appropriate container. A global firewall evaluates every connection
|
- **L4 (passthrough):** Reads the SNI field from the TLS ClientHello and
|
||||||
before routing.
|
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
|
## Table of Contents
|
||||||
|
|
||||||
1. [System Overview](#system-overview)
|
1. [System Overview](#system-overview)
|
||||||
2. [Connection Lifecycle](#connection-lifecycle)
|
2. [Connection Lifecycle](#connection-lifecycle)
|
||||||
3. [Firewall](#firewall)
|
3. [PROXY Protocol](#proxy-protocol)
|
||||||
4. [Routing](#routing)
|
4. [Firewall](#firewall)
|
||||||
5. [Configuration](#configuration)
|
5. [Routing](#routing)
|
||||||
6. [Storage](#storage)
|
6. [L7 Proxying](#l7-proxying)
|
||||||
7. [Deployment](#deployment)
|
7. [gRPC Admin API](#grpc-admin-api)
|
||||||
8. [Security Model](#security-model)
|
8. [Configuration](#configuration)
|
||||||
9. [Future Work](#future-work)
|
9. [Storage](#storage)
|
||||||
|
10. [Deployment](#deployment)
|
||||||
|
11. [Security Model](#security-model)
|
||||||
|
12. [Future Work](#future-work)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## System Overview
|
## System Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ mc-proxy │
|
│ mc-proxy │
|
||||||
Clients ──────┐ │ │
|
Clients ──────┐ │ │
|
||||||
│ │ ┌──────────┐ ┌───────┐ ┌─────┐ │ ┌────────────┐
|
│ │ ┌───────┐ ┌──────┐ ┌───┐ ┌─────┐ │
|
||||||
├────▶│ │ Firewall │──▶│ SNI │──▶│Route│─│────▶│ Backend A │
|
├────▶│ │ PROXY │─▶│ Fire │─▶│SNI│─▶│Route│──┐ │
|
||||||
│ │ │ (global) │ │Extract│ │Table│ │ │ :8443 │
|
│ │ │ proto │ │ wall │ │ │ │Table│ │ │
|
||||||
├────▶│ └──────────┘ └───────┘ └─────┘ │ ├────────────┤
|
├────▶│ └───────┘ └──────┘ └───┘ └─────┘ │ │
|
||||||
│ │ │ RST │ │ │ Backend B │
|
│ │ │ │ RST │ │ │ │
|
||||||
Clients ──────┘ │ ▼ └────│────▶│ :9443 │
|
Clients ──────┘ │ │ ▼ │ │ │ │
|
||||||
│ (blocked) │ └────────────┘
|
│ (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 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) ─┘
|
Listener N (:9443) ─┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Key properties:
|
Key properties:
|
||||||
|
|
||||||
- **Layer 4 only.** mc-proxy never terminates TLS. It reads just enough of
|
- **Dual mode, per-route.** Each route is independently configured as L4
|
||||||
the ClientHello to extract the SNI hostname, then proxies the raw TCP
|
(passthrough) or L7 (terminating). Both modes coexist on the same listener.
|
||||||
stream to the matched backend. The backend handles TLS termination.
|
- **TLS-only on ingest.** Non-TLS connections are not supported. If the first
|
||||||
- **TLS-only.** Non-TLS connections are not supported. If the first bytes of
|
bytes of a connection are not a TLS ClientHello (after any PROXY protocol
|
||||||
a connection are not a TLS ClientHello, the connection is reset.
|
header), the connection is reset.
|
||||||
- **Multiple listeners.** A single mc-proxy instance binds to one or more
|
- **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.
|
- **Global firewall.** Firewall rules apply to all listeners uniformly.
|
||||||
There are no per-route firewall rules.
|
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
|
- **No authentication.** mc-proxy is pre-auth infrastructure. It sits in
|
||||||
front of services that handle their own authentication via MCIAS.
|
front of services that handle their own authentication via MCIAS.
|
||||||
|
|
||||||
@@ -58,42 +90,81 @@ Key properties:
|
|||||||
|
|
||||||
## Connection Lifecycle
|
## 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.
|
1. ACCEPT Listener accepts TCP connection.
|
||||||
2. FIREWALL Check source IP against blocklists:
|
2. PROXY PROTOCOL If listener has proxy_protocol enabled:
|
||||||
a. IP/CIDR block check.
|
Parse PROXY v1/v2 header to extract real client IP.
|
||||||
b. GeoIP country block check.
|
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.
|
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.
|
Extract the SNI hostname.
|
||||||
If no valid ClientHello or no SNI → RST, done.
|
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.
|
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.
|
If dial fails → RST, done.
|
||||||
6. PROXY Bidirectional byte copy: client ↔ backend.
|
7a. PROXY HEADER If route has send_proxy_protocol enabled:
|
||||||
The buffered ClientHello bytes are forwarded first,
|
Send PROXY v2 header with real client IP to backend.
|
||||||
then both directions copy concurrently.
|
8a. FORWARD Write the buffered ClientHello to the backend.
|
||||||
7. CLOSE Either side closes → half-close propagation → done.
|
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
|
### SNI Extraction
|
||||||
|
|
||||||
The proxy peeks at the initial bytes of the connection without consuming
|
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
|
them. It parses just enough of the TLS record layer and ClientHello to
|
||||||
extract the `server_name` extension. The full ClientHello (including the
|
extract the `server_name` extension.
|
||||||
SNI) is then forwarded to the backend so the backend's TLS handshake
|
|
||||||
proceeds normally.
|
- **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
|
If the ClientHello spans multiple TCP segments, the proxy buffers up to
|
||||||
16 KiB (the maximum TLS record size) before giving up.
|
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
|
After the backend connection is established, the proxy runs two concurrent
|
||||||
copy loops (client→backend and backend→client). When either direction
|
copy loops (client->backend and backend->client). When either direction
|
||||||
encounters an EOF or error:
|
encounters an EOF or error:
|
||||||
|
|
||||||
1. The write side of the opposite direction is half-closed.
|
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
|
## Firewall
|
||||||
|
|
||||||
The firewall is a global, ordered rule set evaluated on every new
|
The firewall is a global, ordered rule set evaluated on every new
|
||||||
connection before SNI extraction. Rules are evaluated in definition order;
|
connection before SNI extraction. Rules are evaluated in definition order;
|
||||||
the first matching rule determines the outcome. If no rule matches, the
|
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).
|
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
|
### Rule Types
|
||||||
|
|
||||||
| Config Field | Match Field | Example |
|
| Config Field | Match Field | Example |
|
||||||
@@ -124,7 +265,7 @@ blocklist, not an allowlist).
|
|||||||
### Blocked Connection Handling
|
### Blocked Connection Handling
|
||||||
|
|
||||||
Blocked connections receive a TCP RST. No TLS alert, no HTTP error page, no
|
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.
|
sources should receive minimal information.
|
||||||
|
|
||||||
### GeoIP Database
|
### GeoIP Database
|
||||||
@@ -146,15 +287,19 @@ to start.
|
|||||||
Each listener has its own route table mapping SNI hostnames to backend
|
Each listener has its own route table mapping SNI hostnames to backend
|
||||||
addresses. A route entry consists of:
|
addresses. A route entry consists of:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `hostname` | string | Exact SNI hostname to match (e.g. `metacrypt.metacircular.net`) |
|
| `hostname` | string | *(required)* | Exact SNI hostname to match (e.g. `metacrypt.metacircular.net`) |
|
||||||
| `backend` | string | Backend address as `host:port` (e.g. `127.0.0.1:8443`) |
|
| `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
|
Routes are scoped to the listener that accepted the connection. The same
|
||||||
hostname can appear on different listeners with different backends, allowing
|
hostname can appear on different listeners with different backends and
|
||||||
the proxy to route the same service name to different backends depending
|
different modes.
|
||||||
on which port the client connected to.
|
|
||||||
|
|
||||||
### Match Semantics
|
### 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
|
the source of truth. Routes can be added or removed at runtime via the
|
||||||
gRPC admin API.
|
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
|
## gRPC Admin API
|
||||||
|
|
||||||
The admin API is optional (disabled if `[grpc]` is omitted from the config).
|
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
|
filesystem permissions (0600, owner-only). The API provides runtime management
|
||||||
of routes and firewall rules without restarting the proxy.
|
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:
|
The admin API validates all inputs before persisting:
|
||||||
|
|
||||||
- **Route backends** must be valid `host:port` tuples.
|
- **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`).
|
- **IP firewall rules** must be valid IP addresses (`netip.ParseAddr`).
|
||||||
- **CIDR firewall rules** must be valid prefixes in canonical form.
|
- **CIDR firewall rules** must be valid prefixes in canonical form.
|
||||||
- **Country firewall rules** must be exactly 2 uppercase letters (ISO 3166-1 alpha-2).
|
- **Country firewall rules** must be exactly 2 uppercase letters (ISO 3166-1 alpha-2).
|
||||||
|
|
||||||
### Security
|
### 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:
|
infrastructure. Access control relies on Unix socket filesystem permissions:
|
||||||
|
|
||||||
- Socket is created with mode `0600` (read/write for owner only)
|
- Socket is created with mode `0600` (read/write for owner only)
|
||||||
- Only processes running as the same user can connect
|
- 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. Each has its own route table (seeds DB on first run).
|
||||||
[[listeners]]
|
[[listeners]]
|
||||||
addr = ":443"
|
addr = ":443"
|
||||||
|
proxy_protocol = false # accept PROXY protocol headers (default: false)
|
||||||
|
|
||||||
|
# L4 route: passthrough, no TLS termination.
|
||||||
[[listeners.routes]]
|
[[listeners.routes]]
|
||||||
hostname = "metacrypt.metacircular.net"
|
hostname = "metacrypt.metacircular.net"
|
||||||
backend = "127.0.0.1:18443"
|
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]]
|
[[listeners.routes]]
|
||||||
hostname = "mcias.metacircular.net"
|
hostname = "mcias.metacircular.net"
|
||||||
backend = "127.0.0.1:28443"
|
backend = "10.0.0.5:443"
|
||||||
|
mode = "l4"
|
||||||
|
send_proxy_protocol = true
|
||||||
|
|
||||||
[[listeners]]
|
[[listeners]]
|
||||||
addr = ":8443"
|
addr = ":8443"
|
||||||
@@ -243,14 +484,7 @@ addr = ":8443"
|
|||||||
hostname = "metacrypt.metacircular.net"
|
hostname = "metacrypt.metacircular.net"
|
||||||
backend = "127.0.0.1:18443"
|
backend = "127.0.0.1:18443"
|
||||||
|
|
||||||
[[listeners]]
|
# gRPC admin API. Optional -- omit or leave addr empty to disable.
|
||||||
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.
|
|
||||||
# Listens on a Unix socket; access controlled via filesystem permissions.
|
# Listens on a Unix socket; access controlled via filesystem permissions.
|
||||||
[grpc]
|
[grpc]
|
||||||
addr = "/var/run/mc-proxy.sock"
|
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_ips = ["192.0.2.1"]
|
||||||
blocked_cidrs = ["198.51.100.0/24"]
|
blocked_cidrs = ["198.51.100.0/24"]
|
||||||
blocked_countries = ["KP", "CN", "IN", "IL"]
|
blocked_countries = ["KP", "CN", "IN", "IL"]
|
||||||
|
rate_limit = 20 # max new connections per IP per rate_window
|
||||||
|
rate_window = "10s"
|
||||||
|
|
||||||
# Proxy behavior.
|
# Proxy behavior.
|
||||||
[proxy]
|
[proxy]
|
||||||
@@ -283,7 +519,7 @@ MCPROXY_LOG_LEVEL=debug
|
|||||||
MCPROXY_PROXY_IDLE_TIMEOUT=600s
|
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.
|
these are structural and must be in the TOML file.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -315,7 +551,8 @@ state is not modified.
|
|||||||
```sql
|
```sql
|
||||||
CREATE TABLE listeners (
|
CREATE TABLE listeners (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
addr TEXT NOT NULL UNIQUE
|
addr TEXT NOT NULL UNIQUE,
|
||||||
|
proxy_protocol INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE routes (
|
CREATE TABLE routes (
|
||||||
@@ -323,6 +560,11 @@ CREATE TABLE routes (
|
|||||||
listener_id INTEGER NOT NULL REFERENCES listeners(id) ON DELETE CASCADE,
|
listener_id INTEGER NOT NULL REFERENCES listeners(id) ON DELETE CASCADE,
|
||||||
hostname TEXT NOT NULL,
|
hostname TEXT NOT NULL,
|
||||||
backend 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)
|
UNIQUE(listener_id, hostname)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -341,13 +583,16 @@ CREATE TABLE firewall_rules (
|
|||||||
├── mc-proxy.toml Configuration
|
├── mc-proxy.toml Configuration
|
||||||
├── mc-proxy.db SQLite database
|
├── mc-proxy.db SQLite database
|
||||||
├── mc-proxy.sock Unix socket for gRPC admin API
|
├── 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)
|
├── GeoLite2-Country.mmdb GeoIP database (if using country blocks)
|
||||||
└── backups/ Database snapshots
|
└── backups/ Database snapshots
|
||||||
```
|
```
|
||||||
|
|
||||||
mc-proxy does not terminate TLS on any listener. The proxy listeners pass
|
L4 routes do not use any TLS material -- the raw TLS stream passes through.
|
||||||
through raw TLS streams, and the gRPC admin API uses a Unix socket
|
L7 routes require certificate/key pairs in the `certs/` directory (or any
|
||||||
(filesystem permissions for access control).
|
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
|
### Binary
|
||||||
|
|
||||||
Single static binary, built with `CGO_ENABLED=0`. No runtime dependencies
|
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
|
### Container
|
||||||
|
|
||||||
@@ -380,6 +626,26 @@ in the systemd unit rather than running as root.
|
|||||||
Standard security hardening directives apply per engineering standards
|
Standard security hardening directives apply per engineering standards
|
||||||
(`NoNewPrivileges=true`, `ProtectSystem=strict`, etc.).
|
(`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
|
### Graceful Shutdown
|
||||||
|
|
||||||
On `SIGINT` or `SIGTERM`:
|
On `SIGINT` or `SIGTERM`:
|
||||||
@@ -410,21 +676,30 @@ It has no authentication or authorization of its own.
|
|||||||
|
|
||||||
| Threat | Mitigation |
|
| 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. |
|
| 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. |
|
||||||
| Resource exhaustion (connection flood) | Idle timeout closes stale connections. Per-listener connection limits (future). Rate limiting (future). |
|
| 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 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. |
|
| 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. |
|
| 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. Connection is reset if the backend is unreachable. |
|
| Backend unavailability | Connect timeout prevents indefinite hangs. L4: connection reset. L7: HTTP 502 response. |
|
||||||
| Information leakage | Blocked connections receive only a TCP RST. No version strings, no error messages, no TLS alerts. |
|
| 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
|
### Security Invariants
|
||||||
|
|
||||||
1. mc-proxy never terminates TLS. It cannot read application-layer traffic.
|
1. **L4 routes never terminate TLS.** The proxy cannot read application-layer
|
||||||
2. mc-proxy never modifies the byte stream between client and backend.
|
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.
|
3. Firewall rules are always evaluated before any routing decision.
|
||||||
4. The proxy never logs connection content — only metadata (source IP,
|
4. PROXY protocol is only parsed on explicitly enabled listeners.
|
||||||
SNI hostname, backend, timestamps, bytes transferred).
|
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 |
|
| 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. |
|
| **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). |
|
| **Connection pooling** | Pool backend connections for L7 routes to reduce connection setup overhead under high request volume. |
|
||||||
| **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. |
|
|
||||||
| **Per-listener connection limits** | Cap maximum concurrent connections per listener. |
|
| **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. |
|
||||||
|
|||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## 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
|
## Build Commands
|
||||||
|
|
||||||
@@ -28,7 +28,9 @@ go test ./internal/sni -run TestExtract
|
|||||||
|
|
||||||
- **Module path**: `git.wntrmute.dev/kyle/mc-proxy`
|
- **Module path**: `git.wntrmute.dev/kyle/mc-proxy`
|
||||||
- **Go with CGO_ENABLED=0**, statically linked, Alpine containers
|
- **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
|
- **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
|
- **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
|
- **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/config/` — TOML config loading and validation
|
||||||
- `internal/db/` — SQLite database: migrations, CRUD for listeners/routes/firewall rules, seeding, snapshots
|
- `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/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/firewall/` — global blocklist evaluation (IP, CIDR, GeoIP via MaxMind GeoLite2); rate limiting; thread-safe mutations and GeoIP reload
|
||||||
- `internal/proxy/` — bidirectional TCP relay with half-close propagation and idle timeout
|
- `internal/proxy/` — L4 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/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
|
- `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
|
- `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
|
## 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.
|
- 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.
|
- SNI matching is exact and case-insensitive.
|
||||||
- Blocked connections get a TCP RST — no error messages, no TLS alerts.
|
- Blocked connections get a TCP RST — no error messages, no TLS alerts.
|
||||||
- Database writes must succeed before in-memory state is updated (write-through).
|
- Database writes must succeed before in-memory state is updated (write-through).
|
||||||
|
|||||||
66
PROGRESS.md
Normal file
66
PROGRESS.md
Normal 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
331
PROJECT_PLAN.md
Normal 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.
|
||||||
@@ -80,13 +80,17 @@ func setupTestClient(t *testing.T) *Client {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list routes: %v", err)
|
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 {
|
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{
|
listenerData = append(listenerData, server.ListenerData{
|
||||||
ID: l.ID,
|
ID: l.ID,
|
||||||
Addr: l.Addr,
|
Addr: l.Addr,
|
||||||
|
ProxyProtocol: l.ProxyProtocol,
|
||||||
Routes: routes,
|
Routes: routes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,13 +130,21 @@ func loadListenersFromDB(store *db.Store) ([]server.ListenerData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("loading routes for listener %q: %w", l.Addr, err)
|
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 {
|
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{
|
result = append(result, server.ListenerData{
|
||||||
ID: l.ID,
|
ID: l.ID,
|
||||||
Addr: l.Addr,
|
Addr: l.Addr,
|
||||||
|
ProxyProtocol: l.ProxyProtocol,
|
||||||
Routes: routes,
|
Routes: routes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,12 +29,18 @@ type GRPC struct {
|
|||||||
|
|
||||||
type Listener struct {
|
type Listener struct {
|
||||||
Addr string `toml:"addr"`
|
Addr string `toml:"addr"`
|
||||||
|
ProxyProtocol bool `toml:"proxy_protocol"`
|
||||||
Routes []Route `toml:"routes"`
|
Routes []Route `toml:"routes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Route struct {
|
type Route struct {
|
||||||
Hostname string `toml:"hostname"`
|
Hostname string `toml:"hostname"`
|
||||||
Backend string `toml:"backend"`
|
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 {
|
type Firewall struct {
|
||||||
@@ -163,12 +170,14 @@ func (c *Config) validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate listeners if provided (used for seeding on first run).
|
// 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 == "" {
|
if l.Addr == "" {
|
||||||
return fmt.Errorf("listener %d: addr is required", i)
|
return fmt.Errorf("listener %d: addr is required", i)
|
||||||
}
|
}
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for j, r := range l.Routes {
|
for j := range l.Routes {
|
||||||
|
r := &l.Routes[j]
|
||||||
if r.Hostname == "" {
|
if r.Hostname == "" {
|
||||||
return fmt.Errorf("listener %d (%s), route %d: hostname is required", i, l.Addr, j)
|
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)
|
return fmt.Errorf("listener %d (%s), route %d: duplicate hostname %q", i, l.Addr, j, r.Hostname)
|
||||||
}
|
}
|
||||||
seen[r.Hostname] = true
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -391,3 +391,147 @@ path = "/tmp/test.db"
|
|||||||
t.Fatalf("got grpc.addr %q, want %q", cfg.GRPC.Addr, "/var/run/override.sock")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func TestIsEmpty(t *testing.T) {
|
|||||||
t.Fatal("expected empty database")
|
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)
|
t.Fatalf("create listener: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ func TestIsEmpty(t *testing.T) {
|
|||||||
func TestListenerCRUD(t *testing.T) {
|
func TestListenerCRUD(t *testing.T) {
|
||||||
store := openTestDB(t)
|
store := openTestDB(t)
|
||||||
|
|
||||||
id, err := store.CreateListener(":443")
|
id, err := store.CreateListener(":443", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,9 @@ func TestListenerCRUD(t *testing.T) {
|
|||||||
if listeners[0].Addr != ":443" {
|
if listeners[0].Addr != ":443" {
|
||||||
t.Fatalf("got addr %q, want %q", 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")
|
l, err := store.GetListenerByAddr(":443")
|
||||||
if err != nil {
|
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) {
|
func TestListenerDuplicateAddr(t *testing.T) {
|
||||||
store := openTestDB(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)
|
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")
|
t.Fatal("expected error for duplicate addr")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,12 +134,12 @@ func TestListenerDuplicateAddr(t *testing.T) {
|
|||||||
func TestRouteCRUD(t *testing.T) {
|
func TestRouteCRUD(t *testing.T) {
|
||||||
store := openTestDB(t)
|
store := openTestDB(t)
|
||||||
|
|
||||||
listenerID, err := store.CreateListener(":443")
|
listenerID, err := store.CreateListener(":443", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create listener: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("create route: %v", err)
|
t.Fatalf("create route: %v", err)
|
||||||
}
|
}
|
||||||
@@ -134,6 +157,9 @@ func TestRouteCRUD(t *testing.T) {
|
|||||||
if routes[0].Hostname != "example.com" {
|
if routes[0].Hostname != "example.com" {
|
||||||
t.Fatalf("got hostname %q, want %q", 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 {
|
if err := store.DeleteRoute(listenerID, "example.com"); err != nil {
|
||||||
t.Fatalf("delete route: %v", err)
|
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) {
|
func TestRouteDuplicateHostname(t *testing.T) {
|
||||||
store := openTestDB(t)
|
store := openTestDB(t)
|
||||||
|
|
||||||
listenerID, _ := store.CreateListener(":443")
|
listenerID, _ := store.CreateListener(":443", false)
|
||||||
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443"); err != nil {
|
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443", "l4", "", "", false, false); err != nil {
|
||||||
t.Fatalf("first create: %v", err)
|
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")
|
t.Fatal("expected error for duplicate hostname on same listener")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,9 +226,9 @@ func TestRouteDuplicateHostname(t *testing.T) {
|
|||||||
func TestRouteCascadeDelete(t *testing.T) {
|
func TestRouteCascadeDelete(t *testing.T) {
|
||||||
store := openTestDB(t)
|
store := openTestDB(t)
|
||||||
|
|
||||||
listenerID, _ := store.CreateListener(":443")
|
listenerID, _ := store.CreateListener(":443", false)
|
||||||
store.CreateRoute(listenerID, "a.example.com", "127.0.0.1:8443")
|
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")
|
store.CreateRoute(listenerID, "b.example.com", "127.0.0.1:9443", "l4", "", "", false, false)
|
||||||
|
|
||||||
if err := store.DeleteListener(listenerID); err != nil {
|
if err := store.DeleteListener(listenerID); err != nil {
|
||||||
t.Fatalf("delete listener: %v", err)
|
t.Fatalf("delete listener: %v", err)
|
||||||
@@ -237,14 +300,15 @@ func TestSeed(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Addr: ":443",
|
Addr: ":443",
|
||||||
Routes: []config.Route{
|
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"},
|
{Hostname: "b.example.com", Backend: "127.0.0.1:9443"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Addr: ":8443",
|
Addr: ":8443",
|
||||||
|
ProxyProtocol: true,
|
||||||
Routes: []config.Route{
|
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 {
|
if len(dbListeners) != 2 {
|
||||||
t.Fatalf("got %d listeners, want 2", len(dbListeners))
|
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)
|
routes, err := store.ListRoutes(dbListeners[0].ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -275,6 +342,25 @@ func TestSeed(t *testing.T) {
|
|||||||
t.Fatalf("got %d routes for listener 0, want 2", len(routes))
|
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()
|
rules, err := store.ListFirewallRules()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list firewall rules: %v", err)
|
t.Fatalf("list firewall rules: %v", err)
|
||||||
@@ -287,7 +373,7 @@ func TestSeed(t *testing.T) {
|
|||||||
func TestSnapshot(t *testing.T) {
|
func TestSnapshot(t *testing.T) {
|
||||||
store := openTestDB(t)
|
store := openTestDB(t)
|
||||||
|
|
||||||
store.CreateListener(":443")
|
store.CreateListener(":443", false)
|
||||||
|
|
||||||
dest := filepath.Join(t.TempDir(), "backup.db")
|
dest := filepath.Join(t.TempDir(), "backup.db")
|
||||||
if err := store.Snapshot(dest); err != nil {
|
if err := store.Snapshot(dest); err != nil {
|
||||||
@@ -329,3 +415,57 @@ func TestDeleteNonexistent(t *testing.T) {
|
|||||||
t.Fatal("expected error deleting nonexistent firewall rule")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import "fmt"
|
|||||||
type Listener struct {
|
type Listener struct {
|
||||||
ID int64
|
ID int64
|
||||||
Addr string
|
Addr string
|
||||||
|
ProxyProtocol bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListListeners returns all listeners.
|
// ListListeners returns all listeners.
|
||||||
func (s *Store) ListListeners() ([]Listener, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying listeners: %w", err)
|
return nil, fmt.Errorf("querying listeners: %w", err)
|
||||||
}
|
}
|
||||||
@@ -19,7 +20,7 @@ func (s *Store) ListListeners() ([]Listener, error) {
|
|||||||
var listeners []Listener
|
var listeners []Listener
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var l Listener
|
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)
|
return nil, fmt.Errorf("scanning listener: %w", err)
|
||||||
}
|
}
|
||||||
listeners = append(listeners, l)
|
listeners = append(listeners, l)
|
||||||
@@ -28,8 +29,11 @@ func (s *Store) ListListeners() ([]Listener, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateListener inserts a listener and returns its ID.
|
// CreateListener inserts a listener and returns its ID.
|
||||||
func (s *Store) CreateListener(addr string) (int64, error) {
|
func (s *Store) CreateListener(addr string, proxyProtocol bool) (int64, error) {
|
||||||
result, err := s.db.Exec("INSERT INTO listeners (addr) VALUES (?)", addr)
|
result, err := s.db.Exec(
|
||||||
|
"INSERT INTO listeners (addr, proxy_protocol) VALUES (?, ?)",
|
||||||
|
addr, proxyProtocol,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("inserting listener: %w", err)
|
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.
|
// GetListenerByAddr returns a listener by its address.
|
||||||
func (s *Store) GetListenerByAddr(addr string) (Listener, error) {
|
func (s *Store) GetListenerByAddr(addr string) (Listener, error) {
|
||||||
var l Listener
|
var l Listener
|
||||||
err := s.db.QueryRow("SELECT id, addr FROM listeners WHERE addr = ?", addr).
|
err := s.db.QueryRow("SELECT id, addr, proxy_protocol FROM listeners WHERE addr = ?", addr).
|
||||||
Scan(&l.ID, &l.Addr)
|
Scan(&l.ID, &l.Addr, &l.ProxyProtocol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Listener{}, fmt.Errorf("querying listener by addr %q: %w", addr, err)
|
return Listener{}, fmt.Errorf("querying listener by addr %q: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type migration struct {
|
|||||||
|
|
||||||
var migrations = []migration{
|
var migrations = []migration{
|
||||||
{1, "create_core_tables", migrate001CreateCoreTables},
|
{1, "create_core_tables", migrate001CreateCoreTables},
|
||||||
|
{2, "add_proxy_protocol_and_l7_fields", migrate002AddL7Fields},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate runs all unapplied migrations sequentially.
|
// Migrate runs all unapplied migrations sequentially.
|
||||||
@@ -91,3 +92,21 @@ func migrate001CreateCoreTables(tx *sql.Tx) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,18 @@ type Route struct {
|
|||||||
ListenerID int64
|
ListenerID int64
|
||||||
Hostname string
|
Hostname string
|
||||||
Backend string
|
Backend string
|
||||||
|
Mode string // "l4" or "l7"
|
||||||
|
TLSCert string
|
||||||
|
TLSKey string
|
||||||
|
BackendTLS bool
|
||||||
|
SendProxyProtocol bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRoutes returns all routes for a listener.
|
// ListRoutes returns all routes for a listener.
|
||||||
func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
|
func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
|
||||||
rows, err := s.db.Query(
|
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,
|
listenerID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -24,7 +30,8 @@ func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
|
|||||||
var routes []Route
|
var routes []Route
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r Route
|
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)
|
return nil, fmt.Errorf("scanning route: %w", err)
|
||||||
}
|
}
|
||||||
routes = append(routes, r)
|
routes = append(routes, r)
|
||||||
@@ -33,10 +40,11 @@ func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateRoute inserts a route and returns its ID.
|
// 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(
|
result, err := s.db.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)
|
||||||
listenerID, hostname, backend,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
listenerID, hostname, backend, mode, tlsCert, tlsKey, backendTLS, sendProxyProtocol,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("inserting route: %w", err)
|
return 0, fmt.Errorf("inserting route: %w", err)
|
||||||
|
|||||||
@@ -17,16 +17,25 @@ func (s *Store) Seed(listeners []config.Listener, fw config.Firewall) error {
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
for _, l := range listeners {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("seeding listener %q: %w", l.Addr, err)
|
return fmt.Errorf("seeding listener %q: %w", l.Addr, err)
|
||||||
}
|
}
|
||||||
listenerID, _ := result.LastInsertId()
|
listenerID, _ := result.LastInsertId()
|
||||||
|
|
||||||
for _, r := range l.Routes {
|
for _, r := range l.Routes {
|
||||||
|
mode := r.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = "l4"
|
||||||
|
}
|
||||||
_, err := tx.Exec(
|
_, 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,
|
listenerID, strings.ToLower(r.Hostname), r.Backend,
|
||||||
|
mode, r.TLSCert, r.TLSKey, r.BackendTLS, r.SendProxyProtocol,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("seeding route %q on listener %q: %w", r.Hostname, l.Addr, err)
|
return fmt.Errorf("seeding route %q on listener %q: %w", r.Hostname, l.Addr, err)
|
||||||
|
|||||||
@@ -88,10 +88,10 @@ func (a *AdminServer) ListRoutes(_ context.Context, req *pb.ListRoutesRequest) (
|
|||||||
resp := &pb.ListRoutesResponse{
|
resp := &pb.ListRoutesResponse{
|
||||||
ListenerAddr: ls.Addr,
|
ListenerAddr: ls.Addr,
|
||||||
}
|
}
|
||||||
for hostname, backend := range routes {
|
for hostname, route := range routes {
|
||||||
resp.Routes = append(resp.Routes, &pb.Route{
|
resp.Routes = append(resp.Routes, &pb.Route{
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Backend: backend,
|
Backend: route.Backend,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -119,11 +119,11 @@ func (a *AdminServer) AddRoute(_ context.Context, req *pb.AddRouteRequest) (*pb.
|
|||||||
hostname := strings.ToLower(req.Route.Hostname)
|
hostname := strings.ToLower(req.Route.Hostname)
|
||||||
|
|
||||||
// Write-through: DB first, then memory.
|
// 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)
|
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).
|
// 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)
|
a.logger.Error("inconsistency: DB write succeeded but memory update failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,13 +87,17 @@ func setup(t *testing.T) *testEnv {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list routes: %v", err)
|
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 {
|
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{
|
listenerData = append(listenerData, server.ListenerData{
|
||||||
ID: l.ID,
|
ID: l.ID,
|
||||||
Addr: l.Addr,
|
Addr: l.Addr,
|
||||||
|
ProxyProtocol: l.ProxyProtocol,
|
||||||
Routes: routes,
|
Routes: routes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,22 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mc-proxy/internal/sni"
|
"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.
|
// ListenerState holds the mutable state for a single proxy listener.
|
||||||
type ListenerState struct {
|
type ListenerState struct {
|
||||||
ID int64 // database primary key
|
ID int64 // database primary key
|
||||||
Addr string
|
Addr string
|
||||||
routes map[string]string // lowercase hostname → backend addr
|
ProxyProtocol bool
|
||||||
|
routes map[string]RouteInfo // lowercase hostname → route info
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ActiveConnections atomic.Int64
|
ActiveConnections atomic.Int64
|
||||||
activeConns map[net.Conn]struct{} // tracked for forced shutdown
|
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.
|
// 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()
|
ls.mu.RLock()
|
||||||
defer ls.mu.RUnlock()
|
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 {
|
for k, v := range ls.routes {
|
||||||
m[k] = v
|
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
|
// AddRoute adds a route to the listener. Returns an error if the hostname
|
||||||
// already exists.
|
// already exists.
|
||||||
func (ls *ListenerState) AddRoute(hostname, backend string) error {
|
func (ls *ListenerState) AddRoute(hostname string, info RouteInfo) error {
|
||||||
key := strings.ToLower(hostname)
|
key := strings.ToLower(hostname)
|
||||||
|
|
||||||
ls.mu.Lock()
|
ls.mu.Lock()
|
||||||
@@ -51,7 +62,7 @@ func (ls *ListenerState) AddRoute(hostname, backend string) error {
|
|||||||
if _, ok := ls.routes[key]; ok {
|
if _, ok := ls.routes[key]; ok {
|
||||||
return fmt.Errorf("route %q already exists", hostname)
|
return fmt.Errorf("route %q already exists", hostname)
|
||||||
}
|
}
|
||||||
ls.routes[key] = backend
|
ls.routes[key] = info
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +81,20 @@ func (ls *ListenerState) RemoveRoute(hostname string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ls *ListenerState) lookupRoute(hostname string) (string, bool) {
|
func (ls *ListenerState) lookupRoute(hostname string) (RouteInfo, bool) {
|
||||||
ls.mu.RLock()
|
ls.mu.RLock()
|
||||||
defer ls.mu.RUnlock()
|
defer ls.mu.RUnlock()
|
||||||
|
|
||||||
backend, ok := ls.routes[hostname]
|
info, ok := ls.routes[hostname]
|
||||||
return backend, ok
|
return info, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenerData holds the data needed to construct a ListenerState.
|
// ListenerData holds the data needed to construct a ListenerState.
|
||||||
type ListenerData struct {
|
type ListenerData struct {
|
||||||
ID int64
|
ID int64
|
||||||
Addr string
|
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,
|
// 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{
|
listeners = append(listeners, &ListenerState{
|
||||||
ID: ld.ID,
|
ID: ld.ID,
|
||||||
Addr: ld.Addr,
|
Addr: ld.Addr,
|
||||||
|
ProxyProtocol: ld.ProxyProtocol,
|
||||||
routes: ld.Routes,
|
routes: ld.Routes,
|
||||||
activeConns: make(map[net.Conn]struct{}),
|
activeConns: make(map[net.Conn]struct{}),
|
||||||
})
|
})
|
||||||
@@ -264,20 +277,32 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn, ls *ListenerStat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
backend, ok := ls.lookupRoute(hostname)
|
route, ok := ls.lookupRoute(hostname)
|
||||||
if !ok {
|
if !ok {
|
||||||
s.logger.Debug("no route for hostname", "addr", addr, "hostname", hostname)
|
s.logger.Debug("no route for hostname", "addr", addr, "hostname", hostname)
|
||||||
return
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
defer backendConn.Close()
|
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)
|
result, err := proxy.Relay(ctx, conn, backendConn, peeked, s.cfg.Proxy.IdleTimeout.Duration)
|
||||||
if err != nil && ctx.Err() == nil {
|
if err != nil && ctx.Err() == nil {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mc-proxy/internal/firewall"
|
"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.
|
// echoServer accepts one connection, copies everything back, then closes.
|
||||||
func echoServer(t *testing.T, ln net.Listener) {
|
func echoServer(t *testing.T, ln net.Listener) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -85,8 +90,8 @@ func TestProxyRoundTrip(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Addr: proxyAddr,
|
Addr: proxyAddr,
|
||||||
Routes: map[string]string{
|
Routes: map[string]RouteInfo{
|
||||||
"echo.test": backendLn.Addr().String(),
|
"echo.test": l4Route(backendLn.Addr().String()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -141,8 +146,8 @@ func TestNoRouteResets(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Addr: proxyAddr,
|
Addr: proxyAddr,
|
||||||
Routes: map[string]string{
|
Routes: map[string]RouteInfo{
|
||||||
"other.test": "127.0.0.1:1", // exists but won't match
|
"other.test": l4Route("127.0.0.1:1"), // exists but won't match
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -212,8 +217,8 @@ func TestFirewallBlocks(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Addr: proxyAddr,
|
Addr: proxyAddr,
|
||||||
Routes: map[string]string{
|
Routes: map[string]RouteInfo{
|
||||||
"echo.test": backendLn.Addr().String(),
|
"echo.test": l4Route(backendLn.Addr().String()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, logger, "test")
|
}, logger, "test")
|
||||||
@@ -267,7 +272,7 @@ func TestNotTLSResets(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Addr: proxyAddr,
|
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,
|
ID: 1,
|
||||||
Addr: proxyAddr,
|
Addr: proxyAddr,
|
||||||
Routes: map[string]string{
|
Routes: map[string]RouteInfo{
|
||||||
"conn.test": backendLn.Addr().String(),
|
"conn.test": l4Route(backendLn.Addr().String()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -432,8 +437,8 @@ func TestMultipleListeners(t *testing.T) {
|
|||||||
ln2.Close()
|
ln2.Close()
|
||||||
|
|
||||||
srv := newTestServer(t, []ListenerData{
|
srv := newTestServer(t, []ListenerData{
|
||||||
{ID: 1, Addr: addr1, Routes: map[string]string{"svc.test": backendA.Addr().String()}},
|
{ID: 1, Addr: addr1, Routes: map[string]RouteInfo{"svc.test": l4Route(backendA.Addr().String())}},
|
||||||
{ID: 2, Addr: addr2, Routes: map[string]string{"svc.test": backendB.Addr().String()}},
|
{ID: 2, Addr: addr2, Routes: map[string]RouteInfo{"svc.test": l4Route(backendB.Addr().String())}},
|
||||||
})
|
})
|
||||||
|
|
||||||
stop := startAndStop(t, srv)
|
stop := startAndStop(t, srv)
|
||||||
@@ -500,8 +505,8 @@ func TestCaseInsensitiveRouting(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Addr: proxyAddr,
|
Addr: proxyAddr,
|
||||||
Routes: map[string]string{
|
Routes: map[string]RouteInfo{
|
||||||
"echo.test": backendLn.Addr().String(),
|
"echo.test": l4Route(backendLn.Addr().String()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -549,8 +554,8 @@ func TestBackendUnreachable(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Addr: proxyAddr,
|
Addr: proxyAddr,
|
||||||
Routes: map[string]string{
|
Routes: map[string]RouteInfo{
|
||||||
"dead.test": deadAddr,
|
"dead.test": l4Route(deadAddr),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -612,7 +617,7 @@ func TestGracefulShutdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
srv := New(cfg, fw, []ListenerData{
|
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")
|
}, logger, "test")
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -651,18 +656,18 @@ func TestListenerStateRoutes(t *testing.T) {
|
|||||||
ls := &ListenerState{
|
ls := &ListenerState{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Addr: ":443",
|
Addr: ":443",
|
||||||
routes: map[string]string{
|
routes: map[string]RouteInfo{
|
||||||
"a.test": "127.0.0.1:1",
|
"a.test": l4Route("127.0.0.1:1"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRoute
|
// 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)
|
t.Fatalf("AddRoute: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRoute duplicate
|
// 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")
|
t.Fatal("expected error for duplicate route")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,8 +691,8 @@ func TestListenerStateRoutes(t *testing.T) {
|
|||||||
if len(routes) != 1 {
|
if len(routes) != 1 {
|
||||||
t.Fatalf("expected 1 route, got %d", len(routes))
|
t.Fatalf("expected 1 route, got %d", len(routes))
|
||||||
}
|
}
|
||||||
if routes["b.test"] != "127.0.0.1:2" {
|
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"])
|
t.Fatalf("expected b.test → 127.0.0.1:2, got %q", routes["b.test"].Backend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user