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"` TLSCert string `toml:"tls_cert"` TLSKey string `toml:"tls_key"` CACert string `toml:"ca_cert"` // CA cert for verifying the server (client-side) ClientCA string `toml:"client_ca"` // CA cert for verifying clients (server-side mTLS) } 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 } // IsUnixSocket returns true if the gRPC address refers to a Unix domain socket. func (g GRPC) IsUnixSocket() bool { path := strings.TrimPrefix(g.Addr, "unix:") return strings.Contains(path, "/") } // SocketPath returns the filesystem path for a Unix socket address, // 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.validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } return &cfg, 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, TLS cert and key are required // (unless using a Unix socket, which doesn't need TLS). if c.GRPC.Addr != "" && !c.GRPC.IsUnixSocket() { if c.GRPC.TLSCert == "" || c.GRPC.TLSKey == "" { return fmt.Errorf("grpc: tls_cert and tls_key are required when grpc.addr is a TCP address") } } // 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 }