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:
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -27,13 +28,19 @@ type GRPC struct {
|
||||
}
|
||||
|
||||
type Listener struct {
|
||||
Addr string `toml:"addr"`
|
||||
Routes []Route `toml:"routes"`
|
||||
Addr string `toml:"addr"`
|
||||
ProxyProtocol bool `toml:"proxy_protocol"`
|
||||
Routes []Route `toml:"routes"`
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
Hostname string `toml:"hostname"`
|
||||
Backend string `toml:"backend"`
|
||||
Hostname string `toml:"hostname"`
|
||||
Backend string `toml:"backend"`
|
||||
Mode string `toml:"mode"` // "l4" (default) or "l7"
|
||||
TLSCert string `toml:"tls_cert"` // PEM certificate path (L7 only)
|
||||
TLSKey string `toml:"tls_key"` // PEM private key path (L7 only)
|
||||
BackendTLS bool `toml:"backend_tls"` // re-encrypt to backend (L7 only)
|
||||
SendProxyProtocol bool `toml:"send_proxy_protocol"` // send PROXY v2 header to backend
|
||||
}
|
||||
|
||||
type Firewall struct {
|
||||
@@ -163,12 +170,14 @@ func (c *Config) validate() error {
|
||||
}
|
||||
|
||||
// Validate listeners if provided (used for seeding on first run).
|
||||
for i, l := range c.Listeners {
|
||||
for i := range c.Listeners {
|
||||
l := &c.Listeners[i]
|
||||
if l.Addr == "" {
|
||||
return fmt.Errorf("listener %d: addr is required", i)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for j, r := range l.Routes {
|
||||
for j := range l.Routes {
|
||||
r := &l.Routes[j]
|
||||
if r.Hostname == "" {
|
||||
return fmt.Errorf("listener %d (%s), route %d: hostname is required", i, l.Addr, j)
|
||||
}
|
||||
@@ -179,6 +188,27 @@ func (c *Config) validate() error {
|
||||
return fmt.Errorf("listener %d (%s), route %d: duplicate hostname %q", i, l.Addr, j, r.Hostname)
|
||||
}
|
||||
seen[r.Hostname] = true
|
||||
|
||||
// Normalize mode: empty defaults to "l4".
|
||||
if r.Mode == "" {
|
||||
r.Mode = "l4"
|
||||
}
|
||||
if r.Mode != "l4" && r.Mode != "l7" {
|
||||
return fmt.Errorf("listener %d (%s), route %d (%s): mode must be \"l4\" or \"l7\", got %q",
|
||||
i, l.Addr, j, r.Hostname, r.Mode)
|
||||
}
|
||||
|
||||
// L7 routes require TLS cert and key.
|
||||
if r.Mode == "l7" {
|
||||
if r.TLSCert == "" || r.TLSKey == "" {
|
||||
return fmt.Errorf("listener %d (%s), route %d (%s): L7 routes require tls_cert and tls_key",
|
||||
i, l.Addr, j, r.Hostname)
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(r.TLSCert, r.TLSKey); err != nil {
|
||||
return fmt.Errorf("listener %d (%s), route %d (%s): loading TLS cert/key: %w",
|
||||
i, l.Addr, j, r.Hostname, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -391,3 +391,147 @@ path = "/tmp/test.db"
|
||||
t.Fatalf("got grpc.addr %q, want %q", cfg.GRPC.Addr, "/var/run/override.sock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadL4ModeDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.toml")
|
||||
|
||||
data := `
|
||||
[database]
|
||||
path = "/tmp/test.db"
|
||||
|
||||
[[listeners]]
|
||||
addr = ":443"
|
||||
|
||||
[[listeners.routes]]
|
||||
hostname = "example.com"
|
||||
backend = "127.0.0.1:8443"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Mode should be normalized to "l4" when unset.
|
||||
if cfg.Listeners[0].Routes[0].Mode != "l4" {
|
||||
t.Fatalf("got mode %q, want %q", cfg.Listeners[0].Routes[0].Mode, "l4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.toml")
|
||||
|
||||
data := `
|
||||
[database]
|
||||
path = "/tmp/test.db"
|
||||
|
||||
[[listeners]]
|
||||
addr = ":443"
|
||||
|
||||
[[listeners.routes]]
|
||||
hostname = "example.com"
|
||||
backend = "127.0.0.1:8443"
|
||||
mode = "l5"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadL7RequiresCertKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.toml")
|
||||
|
||||
data := `
|
||||
[database]
|
||||
path = "/tmp/test.db"
|
||||
|
||||
[[listeners]]
|
||||
addr = ":443"
|
||||
|
||||
[[listeners.routes]]
|
||||
hostname = "example.com"
|
||||
backend = "127.0.0.1:8080"
|
||||
mode = "l7"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for L7 route without cert/key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadL7InvalidCertKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.toml")
|
||||
|
||||
data := `
|
||||
[database]
|
||||
path = "/tmp/test.db"
|
||||
|
||||
[[listeners]]
|
||||
addr = ":443"
|
||||
|
||||
[[listeners.routes]]
|
||||
hostname = "example.com"
|
||||
backend = "127.0.0.1:8080"
|
||||
mode = "l7"
|
||||
tls_cert = "/nonexistent/cert.pem"
|
||||
tls_key = "/nonexistent/key.pem"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for L7 route with nonexistent cert/key files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProxyProtocol(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.toml")
|
||||
|
||||
data := `
|
||||
[database]
|
||||
path = "/tmp/test.db"
|
||||
|
||||
[[listeners]]
|
||||
addr = ":443"
|
||||
proxy_protocol = true
|
||||
|
||||
[[listeners.routes]]
|
||||
hostname = "example.com"
|
||||
backend = "127.0.0.1:8443"
|
||||
send_proxy_protocol = true
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !cfg.Listeners[0].ProxyProtocol {
|
||||
t.Fatal("expected proxy_protocol = true")
|
||||
}
|
||||
if !cfg.Listeners[0].Routes[0].SendProxyProtocol {
|
||||
t.Fatal("expected send_proxy_protocol = true")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user