Instrument mc-proxy with prometheus/client_golang. New internal/metrics/ package defines counters, gauges, and histograms for connection totals, active connections, firewall blocks by reason, backend dial latency, bytes transferred, L7 HTTP status codes, and L7 policy blocks. Optional [metrics] config section starts a scrape endpoint. Firewall gains BlockedWithReason() to report block cause. L7 handler wraps ResponseWriter to record status codes per hostname. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
595 lines
12 KiB
Go
595 lines
12 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")
|
|
|
|
// Invalid duration env overrides are silently ignored by the
|
|
// mcdsl reflection-based loader. The config loads successfully
|
|
// with the zero value for the field.
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Proxy.IdleTimeout.Duration != 0 {
|
|
t.Fatalf("idle_timeout = %v, want 0 (invalid env ignored)", cfg.Proxy.IdleTimeout.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")
|
|
}
|
|
}
|
|
|
|
func TestLoadMetricsConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[metrics]
|
|
addr = "127.0.0.1:9090"
|
|
path = "/metrics"
|
|
`
|
|
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.Metrics.Addr != "127.0.0.1:9090" {
|
|
t.Fatalf("got metrics.addr %q, want %q", cfg.Metrics.Addr, "127.0.0.1:9090")
|
|
}
|
|
if cfg.Metrics.Path != "/metrics" {
|
|
t.Fatalf("got metrics.path %q, want %q", cfg.Metrics.Path, "/metrics")
|
|
}
|
|
}
|
|
|
|
func TestValidateMetricsInvalidPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
|
|
data := `
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[metrics]
|
|
addr = "127.0.0.1:9090"
|
|
path = "no-slash"
|
|
`
|
|
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 metrics.path without leading slash")
|
|
}
|
|
}
|