Initial implementation of mc-proxy
Layer 4 TLS SNI proxy with global firewall (IP/CIDR/GeoIP blocking), per-listener route tables, bidirectional TCP relay with half-close propagation, and a gRPC admin API (routes, firewall, status) with TLS/mTLS support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
internal/config/config.go
Normal file
115
internal/config/config.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Listeners []Listener `toml:"listeners"`
|
||||
GRPC GRPC `toml:"grpc"`
|
||||
Firewall Firewall `toml:"firewall"`
|
||||
Proxy Proxy `toml:"proxy"`
|
||||
Log Log `toml:"log"`
|
||||
}
|
||||
|
||||
type GRPC struct {
|
||||
Addr string `toml:"addr"`
|
||||
TLSCert string `toml:"tls_cert"`
|
||||
TLSKey string `toml:"tls_key"`
|
||||
ClientCA string `toml:"client_ca"`
|
||||
}
|
||||
|
||||
type Listener struct {
|
||||
Addr string `toml:"addr"`
|
||||
Routes []Route `toml:"routes"`
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
Hostname string `toml:"hostname"`
|
||||
Backend string `toml:"backend"`
|
||||
}
|
||||
|
||||
type Firewall struct {
|
||||
GeoIPDB string `toml:"geoip_db"`
|
||||
BlockedIPs []string `toml:"blocked_ips"`
|
||||
BlockedCIDRs []string `toml:"blocked_cidrs"`
|
||||
BlockedCountries []string `toml:"blocked_countries"`
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
ConnectTimeout Duration `toml:"connect_timeout"`
|
||||
IdleTimeout Duration `toml:"idle_timeout"`
|
||||
ShutdownTimeout Duration `toml:"shutdown_timeout"`
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
Level string `toml:"level"`
|
||||
}
|
||||
|
||||
// Duration wraps time.Duration for TOML string unmarshalling.
|
||||
type Duration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
func (d *Duration) UnmarshalText(text []byte) error {
|
||||
var err error
|
||||
d.Duration, err = time.ParseDuration(string(text))
|
||||
return err
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if len(c.Listeners) == 0 {
|
||||
return fmt.Errorf("at least one listener is required")
|
||||
}
|
||||
|
||||
for i, l := range c.Listeners {
|
||||
if l.Addr == "" {
|
||||
return fmt.Errorf("listener %d: addr is required", i)
|
||||
}
|
||||
if len(l.Routes) == 0 {
|
||||
return fmt.Errorf("listener %d (%s): at least one route is required", i, l.Addr)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for j, r := range l.Routes {
|
||||
if r.Hostname == "" {
|
||||
return fmt.Errorf("listener %d (%s), route %d: hostname is required", i, l.Addr, j)
|
||||
}
|
||||
if r.Backend == "" {
|
||||
return fmt.Errorf("listener %d (%s), route %d: backend is required", i, l.Addr, j)
|
||||
}
|
||||
if seen[r.Hostname] {
|
||||
return fmt.Errorf("listener %d (%s), route %d: duplicate hostname %q", i, l.Addr, j, r.Hostname)
|
||||
}
|
||||
seen[r.Hostname] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Firewall.BlockedCountries) > 0 && c.Firewall.GeoIPDB == "" {
|
||||
return fmt.Errorf("firewall: geoip_db is required when blocked_countries is set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
186
internal/config/config_test.go
Normal file
186
internal/config/config_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadValid(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"
|
||||
|
||||
[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 TestLoadNoListeners(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.toml")
|
||||
|
||||
data := `
|
||||
[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.Fatal("expected error for missing listeners")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNoRoutes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.toml")
|
||||
|
||||
data := `
|
||||
[[listeners]]
|
||||
addr = ":443"
|
||||
`
|
||||
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 routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDuplicateHostnames(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"
|
||||
|
||||
[[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 := `
|
||||
[[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 := `
|
||||
[[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 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user