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