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>
538 lines
11 KiB
Go
538 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadValid(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"
|
|
|
|
[proxy]
|
|
connect_timeout = "5s"
|
|
idle_timeout = "300s"
|
|
shutdown_timeout = "30s"
|
|
|
|
[log]
|
|
level = "info"
|
|
`
|
|
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 len(cfg.Listeners) != 1 {
|
|
t.Fatalf("got %d listeners, want 1", len(cfg.Listeners))
|
|
}
|
|
if cfg.Listeners[0].Addr != ":443" {
|
|
t.Fatalf("got listener addr %q, want %q", cfg.Listeners[0].Addr, ":443")
|
|
}
|
|
if len(cfg.Listeners[0].Routes) != 1 {
|
|
t.Fatalf("got %d routes, want 1", len(cfg.Listeners[0].Routes))
|
|
}
|
|
if cfg.Listeners[0].Routes[0].Hostname != "example.com" {
|
|
t.Fatalf("got hostname %q, want %q", cfg.Listeners[0].Routes[0].Hostname, "example.com")
|
|
}
|
|
}
|
|
|
|
func TestLoadNoDatabasePath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[[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)
|
|
}
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing database path")
|
|
}
|
|
}
|
|
|
|
func TestLoadNoListenersValid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
// No listeners is valid — DB may already have them.
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[log]
|
|
level = "info"
|
|
`
|
|
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
_, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadDuplicateHostnames(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"
|
|
|
|
[[listeners.routes]]
|
|
hostname = "example.com"
|
|
backend = "127.0.0.1:9443"
|
|
`
|
|
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 duplicate hostnames")
|
|
}
|
|
}
|
|
|
|
func TestLoadGeoIPRequiredWithCountries(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"
|
|
|
|
[firewall]
|
|
blocked_countries = ["CN"]
|
|
`
|
|
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 blocked_countries without geoip_db")
|
|
}
|
|
}
|
|
|
|
func TestLoadMultipleListeners(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[[listeners]]
|
|
addr = ":443"
|
|
|
|
[[listeners.routes]]
|
|
hostname = "public.example.com"
|
|
backend = "127.0.0.1:8443"
|
|
|
|
[[listeners]]
|
|
addr = ":8443"
|
|
|
|
[[listeners.routes]]
|
|
hostname = "internal.example.com"
|
|
backend = "127.0.0.1:9443"
|
|
`
|
|
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 len(cfg.Listeners) != 2 {
|
|
t.Fatalf("got %d listeners, want 2", len(cfg.Listeners))
|
|
}
|
|
if cfg.Listeners[0].Routes[0].Hostname != "public.example.com" {
|
|
t.Fatalf("listener 0 hostname = %q, want %q", cfg.Listeners[0].Routes[0].Hostname, "public.example.com")
|
|
}
|
|
if cfg.Listeners[1].Routes[0].Hostname != "internal.example.com" {
|
|
t.Fatalf("listener 1 hostname = %q, want %q", cfg.Listeners[1].Routes[0].Hostname, "internal.example.com")
|
|
}
|
|
}
|
|
|
|
func TestGRPCSocketPath(t *testing.T) {
|
|
tests := []struct {
|
|
addr string
|
|
want string
|
|
}{
|
|
{"/var/run/mc-proxy.sock", "/var/run/mc-proxy.sock"},
|
|
{"unix:/var/run/mc-proxy.sock", "/var/run/mc-proxy.sock"},
|
|
}
|
|
for _, tt := range tests {
|
|
g := GRPC{Addr: tt.addr}
|
|
if got := g.SocketPath(); got != tt.want {
|
|
t.Fatalf("SocketPath(%q) = %q, want %q", tt.addr, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateGRPCUnixSocket(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[grpc]
|
|
addr = "/var/run/mc-proxy.sock"
|
|
`
|
|
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
_, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("expected Unix socket to be valid, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateGRPCRejectsTCPAddr(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[grpc]
|
|
addr = "127.0.0.1:9090"
|
|
`
|
|
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 TCP gRPC addr")
|
|
}
|
|
}
|
|
|
|
func TestValidateRateLimitRequiresWindow(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[firewall]
|
|
rate_limit = 100
|
|
`
|
|
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 rate_limit without rate_window")
|
|
}
|
|
}
|
|
|
|
func TestValidateRateLimitWithWindow(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[firewall]
|
|
rate_limit = 100
|
|
rate_window = "1m"
|
|
`
|
|
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.Firewall.RateLimit != 100 {
|
|
t.Fatalf("got rate_limit %d, want 100", cfg.Firewall.RateLimit)
|
|
}
|
|
}
|
|
|
|
func TestDuration(t *testing.T) {
|
|
var d Duration
|
|
if err := d.UnmarshalText([]byte("5s")); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if d.Duration.Seconds() != 5 {
|
|
t.Fatalf("got %v, want 5s", d.Duration)
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrides(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[proxy]
|
|
idle_timeout = "60s"
|
|
|
|
[log]
|
|
level = "info"
|
|
`
|
|
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
// Set env overrides.
|
|
t.Setenv("MCPROXY_LOG_LEVEL", "debug")
|
|
t.Setenv("MCPROXY_PROXY_IDLE_TIMEOUT", "600s")
|
|
t.Setenv("MCPROXY_DATABASE_PATH", "/override/test.db")
|
|
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if cfg.Log.Level != "debug" {
|
|
t.Fatalf("got log.level %q, want %q", cfg.Log.Level, "debug")
|
|
}
|
|
if cfg.Proxy.IdleTimeout.Duration.Seconds() != 600 {
|
|
t.Fatalf("got idle_timeout %v, want 600s", cfg.Proxy.IdleTimeout.Duration)
|
|
}
|
|
if cfg.Database.Path != "/override/test.db" {
|
|
t.Fatalf("got database.path %q, want %q", cfg.Database.Path, "/override/test.db")
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrideInvalidDuration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
`
|
|
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
t.Setenv("MCPROXY_PROXY_IDLE_TIMEOUT", "not-a-duration")
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid duration")
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrideGRPCAddr(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
`
|
|
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
t.Setenv("MCPROXY_GRPC_ADDR", "/var/run/override.sock")
|
|
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if 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")
|
|
}
|
|
}
|