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

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

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

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

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

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

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

View File

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