Files
mc-proxy/internal/config/config.go
Kyle Isom f24fa2a2b0 Switch gRPC admin API to Unix socket only, add client package
- Remove TCP listener support from gRPC server; Unix socket is now the
  only transport for the admin API (access controlled via filesystem
  permissions)
- Add standard gRPC health check service (grpc.health.v1.Health)
- Implement MCPROXY_* environment variable overrides for config
- Create client/mcproxy package with full API coverage and tests
- Update ARCHITECTURE.md and dev config (srv/mc-proxy.toml)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-19 07:48:11 -07:00

220 lines
5.6 KiB
Go

package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
type Config struct {
Listeners []Listener `toml:"listeners"`
Database Database `toml:"database"`
GRPC GRPC `toml:"grpc"`
Firewall Firewall `toml:"firewall"`
Proxy Proxy `toml:"proxy"`
Log Log `toml:"log"`
}
type Database struct {
Path string `toml:"path"`
}
type GRPC struct {
Addr string `toml:"addr"` // Unix socket path (e.g., "/var/run/mc-proxy.sock")
}
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"`
RateLimit int64 `toml:"rate_limit"`
RateWindow Duration `toml:"rate_window"`
}
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
}
// SocketPath returns the filesystem path for the Unix socket,
// stripping any "unix:" prefix.
func (g GRPC) SocketPath() string {
return strings.TrimPrefix(g.Addr, "unix:")
}
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.applyEnvOverrides(); err != nil {
return nil, fmt.Errorf("applying env overrides: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
// applyEnvOverrides applies environment variable overrides to the config.
// Variables use the MCPROXY_ prefix with underscore-separated paths.
func (c *Config) applyEnvOverrides() error {
// Database
if v := os.Getenv("MCPROXY_DATABASE_PATH"); v != "" {
c.Database.Path = v
}
// gRPC
if v := os.Getenv("MCPROXY_GRPC_ADDR"); v != "" {
c.GRPC.Addr = v
}
// Firewall
if v := os.Getenv("MCPROXY_FIREWALL_GEOIP_DB"); v != "" {
c.Firewall.GeoIPDB = v
}
if v := os.Getenv("MCPROXY_FIREWALL_RATE_LIMIT"); v != "" {
var n int64
if _, err := fmt.Sscanf(v, "%d", &n); err != nil {
return fmt.Errorf("MCPROXY_FIREWALL_RATE_LIMIT: %w", err)
}
c.Firewall.RateLimit = n
}
if v := os.Getenv("MCPROXY_FIREWALL_RATE_WINDOW"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return fmt.Errorf("MCPROXY_FIREWALL_RATE_WINDOW: %w", err)
}
c.Firewall.RateWindow = Duration{d}
}
// Proxy timeouts
if v := os.Getenv("MCPROXY_PROXY_CONNECT_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return fmt.Errorf("MCPROXY_PROXY_CONNECT_TIMEOUT: %w", err)
}
c.Proxy.ConnectTimeout = Duration{d}
}
if v := os.Getenv("MCPROXY_PROXY_IDLE_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return fmt.Errorf("MCPROXY_PROXY_IDLE_TIMEOUT: %w", err)
}
c.Proxy.IdleTimeout = Duration{d}
}
if v := os.Getenv("MCPROXY_PROXY_SHUTDOWN_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return fmt.Errorf("MCPROXY_PROXY_SHUTDOWN_TIMEOUT: %w", err)
}
c.Proxy.ShutdownTimeout = Duration{d}
}
// Log
if v := os.Getenv("MCPROXY_LOG_LEVEL"); v != "" {
c.Log.Level = v
}
return nil
}
func (c *Config) validate() error {
if c.Database.Path == "" {
return fmt.Errorf("database.path is required")
}
// Validate listeners if provided (used for seeding on first run).
for i, l := range c.Listeners {
if l.Addr == "" {
return fmt.Errorf("listener %d: addr is required", i)
}
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")
}
if c.Firewall.RateLimit < 0 {
return fmt.Errorf("firewall.rate_limit must not be negative")
}
if c.Firewall.RateWindow.Duration < 0 {
return fmt.Errorf("firewall.rate_window must not be negative")
}
if c.Firewall.RateLimit > 0 && c.Firewall.RateWindow.Duration == 0 {
return fmt.Errorf("firewall.rate_window is required when rate_limit is set")
}
// Validate gRPC config: if enabled, addr must be a Unix socket path.
if c.GRPC.Addr != "" {
path := c.GRPC.SocketPath()
if !strings.Contains(path, "/") {
return fmt.Errorf("grpc.addr must be a Unix socket path (e.g., /var/run/mc-proxy.sock)")
}
}
// Validate timeouts are non-negative.
if c.Proxy.ConnectTimeout.Duration < 0 {
return fmt.Errorf("proxy.connect_timeout must not be negative")
}
if c.Proxy.IdleTimeout.Duration < 0 {
return fmt.Errorf("proxy.idle_timeout must not be negative")
}
if c.Proxy.ShutdownTimeout.Duration < 0 {
return fmt.Errorf("proxy.shutdown_timeout must not be negative")
}
return nil
}