package config import ( "fmt" "os" "strings" "time" "northwest.io/muxstr/internal/nostr" "gopkg.in/yaml.v3" ) // Config holds all configuration for the relay. type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` Auth AuthConfig `yaml:"auth"` RateLimit RateLimitConfig `yaml:"rate_limit"` Metrics MetricsConfig `yaml:"metrics"` Logging LoggingConfig `yaml:"logging"` Storage StorageConfig `yaml:"storage"` } // ServerConfig holds server configuration. type ServerConfig struct { GrpcAddr string `yaml:"grpc_addr"` HttpAddr string `yaml:"http_addr"` PublicURL string `yaml:"public_url"` ReadTimeout time.Duration `yaml:"read_timeout"` WriteTimeout time.Duration `yaml:"write_timeout"` } // DatabaseConfig holds database configuration. type DatabaseConfig struct { Path string `yaml:"path"` // Note: SQLite connection pooling is handled internally in the storage layer. // SQLite works best with a single connection due to its single-writer architecture. } // AuthConfig holds authentication configuration. type AuthConfig struct { Enabled bool `yaml:"enabled"` Required bool `yaml:"required"` TimestampWindow int64 `yaml:"timestamp_window"` AllowedNpubsRead []string `yaml:"allowed_npubs_read"` // npub format only (bech32) - normalized to hex internally AllowedNpubsWrite []string `yaml:"allowed_npubs_write"` // npub format only (bech32) - normalized to hex internally SkipMethods []string `yaml:"skip_methods"` } // RateLimitConfig holds rate limiting configuration. type RateLimitConfig struct { Enabled bool `yaml:"enabled"` DefaultRPS float64 `yaml:"default_rps"` DefaultBurst int `yaml:"default_burst"` IPRPS float64 `yaml:"ip_rps"` IPBurst int `yaml:"ip_burst"` Methods map[string]MethodLimit `yaml:"methods"` Users map[string]UserLimit `yaml:"users"` SkipMethods []string `yaml:"skip_methods"` SkipUsers []string `yaml:"skip_users"` CleanupInterval time.Duration `yaml:"cleanup_interval"` MaxIdleTime time.Duration `yaml:"max_idle_time"` } // MethodLimit defines rate limits for a specific method. type MethodLimit struct { RPS float64 `yaml:"rps"` Burst int `yaml:"burst"` } // UserLimit defines rate limits for a specific user. type UserLimit struct { RPS float64 `yaml:"rps"` Burst int `yaml:"burst"` Methods map[string]MethodLimit `yaml:"methods"` } // MetricsConfig holds metrics configuration. type MetricsConfig struct { Enabled bool `yaml:"enabled"` Addr string `yaml:"addr"` Path string `yaml:"path"` Namespace string `yaml:"namespace"` Subsystem string `yaml:"subsystem"` } // LoggingConfig holds logging configuration. type LoggingConfig struct { Level string `yaml:"level"` Format string `yaml:"format"` Output string `yaml:"output"` } // StorageConfig holds storage configuration. type StorageConfig struct { AutoCompact bool `yaml:"auto_compact"` CompactInterval time.Duration `yaml:"compact_interval"` MaxEventAge time.Duration `yaml:"max_event_age"` } // Default returns the default configuration. func Default() *Config { return &Config{ Server: ServerConfig{ GrpcAddr: ":50051", HttpAddr: ":8080", ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, }, Database: DatabaseConfig{ Path: "relay.db", }, Auth: AuthConfig{ Enabled: false, Required: false, TimestampWindow: 60, }, RateLimit: RateLimitConfig{ Enabled: false, DefaultRPS: 10, DefaultBurst: 20, IPRPS: 5, IPBurst: 10, CleanupInterval: 5 * time.Minute, MaxIdleTime: 10 * time.Minute, }, Metrics: MetricsConfig{ Enabled: true, Addr: ":9090", Path: "/metrics", Namespace: "muxstr", Subsystem: "relay", }, Logging: LoggingConfig{ Level: "info", Format: "json", Output: "stdout", }, Storage: StorageConfig{ AutoCompact: true, CompactInterval: 24 * time.Hour, MaxEventAge: 0, // unlimited }, } } // Load loads configuration from a YAML file and applies environment variable overrides. func Load(filename string) (*Config, error) { // Start with defaults cfg := Default() // Read file if provided if filename != "" { data, err := os.ReadFile(filename) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) } } // Apply environment variable overrides applyEnvOverrides(cfg) // Normalize npubs to hex pubkeys if err := normalizeNpubs(cfg); err != nil { return nil, fmt.Errorf("failed to normalize npubs: %w", err) } // Validate if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } return cfg, nil } // normalizeNpubs converts all npub (bech32) pubkeys to hex format. // Config only accepts npub format (human-readable), which is converted // to hex format (computer-readable) for internal use. func normalizeNpubs(cfg *Config) error { var err error // Normalize read allowlist cfg.Auth.AllowedNpubsRead, err = normalizeNpubList(cfg.Auth.AllowedNpubsRead) if err != nil { return fmt.Errorf("allowed_npubs_read: %w", err) } // Normalize write allowlist cfg.Auth.AllowedNpubsWrite, err = normalizeNpubList(cfg.Auth.AllowedNpubsWrite) if err != nil { return fmt.Errorf("allowed_npubs_write: %w", err) } return nil } // normalizeNpubList converts a list of npubs to hex pubkeys. func normalizeNpubList(npubs []string) ([]string, error) { if len(npubs) == 0 { return nil, nil } normalized := make([]string, 0, len(npubs)) for _, npub := range npubs { // Skip empty strings npub = strings.TrimSpace(npub) if npub == "" { continue } // Validate npub format if !strings.HasPrefix(npub, "npub1") { return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) } // Parse npub to get hex pubkey key, err := nostr.ParsePublicKey(npub) if err != nil { return nil, fmt.Errorf("invalid npub %q: %w", npub, err) } // Get the hex representation for internal use normalized = append(normalized, key.Public()) } return normalized, nil } // Validate validates the configuration. func (c *Config) Validate() error { // Validate server addresses if c.Server.GrpcAddr == "" { return fmt.Errorf("server.grpc_addr is required") } if c.Server.HttpAddr == "" { return fmt.Errorf("server.http_addr is required") } // Validate database path if c.Database.Path == "" { return fmt.Errorf("database.path is required") } // Validate metrics config if enabled if c.Metrics.Enabled { if c.Metrics.Addr == "" { return fmt.Errorf("metrics.addr is required when metrics enabled") } if c.Metrics.Namespace == "" { return fmt.Errorf("metrics.namespace is required when metrics enabled") } } // Validate logging validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} if !validLevels[c.Logging.Level] { return fmt.Errorf("invalid logging.level: %s (must be debug, info, warn, or error)", c.Logging.Level) } validFormats := map[string]bool{"json": true, "text": true} if !validFormats[c.Logging.Format] { return fmt.Errorf("invalid logging.format: %s (must be json or text)", c.Logging.Format) } return nil } // applyEnvOverrides applies environment variable overrides to the configuration. // Environment variables follow the pattern: MUXSTR_
_ func applyEnvOverrides(cfg *Config) { // Server if val := os.Getenv("MUXSTR_SERVER_GRPC_ADDR"); val != "" { cfg.Server.GrpcAddr = val } if val := os.Getenv("MUXSTR_SERVER_HTTP_ADDR"); val != "" { cfg.Server.HttpAddr = val } if val := os.Getenv("MUXSTR_SERVER_PUBLIC_URL"); val != "" { cfg.Server.PublicURL = val } if val := os.Getenv("MUXSTR_SERVER_READ_TIMEOUT"); val != "" { if d, err := time.ParseDuration(val); err == nil { cfg.Server.ReadTimeout = d } } if val := os.Getenv("MUXSTR_SERVER_WRITE_TIMEOUT"); val != "" { if d, err := time.ParseDuration(val); err == nil { cfg.Server.WriteTimeout = d } } // Database if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" { cfg.Database.Path = val } // Auth if val := os.Getenv("MUXSTR_AUTH_ENABLED"); val != "" { cfg.Auth.Enabled = parseBool(val) } if val := os.Getenv("MUXSTR_AUTH_REQUIRED"); val != "" { cfg.Auth.Required = parseBool(val) } if val := os.Getenv("MUXSTR_AUTH_TIMESTAMP_WINDOW"); val != "" { var n int64 if _, err := fmt.Sscanf(val, "%d", &n); err == nil { cfg.Auth.TimestampWindow = n } } if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_READ"); val != "" { cfg.Auth.AllowedNpubsRead = strings.Split(val, ",") } if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_WRITE"); val != "" { cfg.Auth.AllowedNpubsWrite = strings.Split(val, ",") } // Rate limit if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" { cfg.RateLimit.Enabled = parseBool(val) } if val := os.Getenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS"); val != "" { var n float64 if _, err := fmt.Sscanf(val, "%f", &n); err == nil { cfg.RateLimit.DefaultRPS = n } } if val := os.Getenv("MUXSTR_RATE_LIMIT_DEFAULT_BURST"); val != "" { var n int if _, err := fmt.Sscanf(val, "%d", &n); err == nil { cfg.RateLimit.DefaultBurst = n } } // Metrics if val := os.Getenv("MUXSTR_METRICS_ENABLED"); val != "" { cfg.Metrics.Enabled = parseBool(val) } if val := os.Getenv("MUXSTR_METRICS_ADDR"); val != "" { cfg.Metrics.Addr = val } if val := os.Getenv("MUXSTR_METRICS_PATH"); val != "" { cfg.Metrics.Path = val } // Logging if val := os.Getenv("MUXSTR_LOGGING_LEVEL"); val != "" { cfg.Logging.Level = val } if val := os.Getenv("MUXSTR_LOGGING_FORMAT"); val != "" { cfg.Logging.Format = val } if val := os.Getenv("MUXSTR_LOGGING_OUTPUT"); val != "" { cfg.Logging.Output = val } } // parseBool parses a boolean from a string. func parseBool(s string) bool { s = strings.ToLower(s) return s == "true" || s == "1" || s == "yes" || s == "on" } // Save saves the configuration to a YAML file. func (c *Config) Save(filename string) error { data, err := yaml.Marshal(c) if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } if err := os.WriteFile(filename, data, 0644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil }