package config import ( "fmt" "os" "strings" "time" "gopkg.in/yaml.v3" "northwest.io/muxstr/internal/auth" "northwest.io/muxstr/internal/metrics" "northwest.io/muxstr/internal/ratelimit" "northwest.io/nostr" ) 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"` } 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"` } type DatabaseConfig struct { Path string `yaml:"path"` } type AuthConfig struct { Read auth.AuthOperationConfig `yaml:"read"` Write auth.AuthOperationConfig `yaml:"write"` TimestampWindow int64 `yaml:"timestamp_window"` SkipMethods []string `yaml:"skip_methods"` } 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"` } type MethodLimit struct { RPS float64 `yaml:"rps"` Burst int `yaml:"burst"` } type UserLimit struct { RPS float64 `yaml:"rps"` Burst int `yaml:"burst"` Methods map[string]MethodLimit `yaml:"methods"` } type MetricsConfig struct { Enabled bool `yaml:"enabled"` Addr string `yaml:"addr"` Path string `yaml:"path"` Namespace string `yaml:"namespace"` Subsystem string `yaml:"subsystem"` } type LoggingConfig struct { Level string `yaml:"level"` Format string `yaml:"format"` Output string `yaml:"output"` } 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{ Read: auth.AuthOperationConfig{ Enabled: false, AllowedNpubs: nil, }, Write: auth.AuthOperationConfig{ Enabled: false, AllowedNpubs: nil, }, 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 }, } } func Load(filename string) (*Config, error) { cfg := Default() 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) } } applyEnvOverrides(cfg) if err := normalizeNpubs(cfg); err != nil { return nil, fmt.Errorf("failed to normalize npubs: %w", err) } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } return cfg, nil } // normalizeNpubs converts npub (bech32) to hex format for internal use. func normalizeNpubs(cfg *Config) error { var err error cfg.Auth.Read.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Read.AllowedNpubs) if err != nil { return fmt.Errorf("auth.read.allowed_npubs: %w", err) } cfg.Auth.Write.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Write.AllowedNpubs) if err != nil { return fmt.Errorf("auth.write.allowed_npubs: %w", err) } return nil } func normalizeNpubList(npubs []string) ([]string, error) { if len(npubs) == 0 { return nil, nil } normalized := make([]string, 0, len(npubs)) for _, npub := range npubs { npub = strings.TrimSpace(npub) if npub == "" { continue } if !strings.HasPrefix(npub, "npub1") { return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) } key, err := nostr.ParsePublicKey(npub) if err != nil { return nil, fmt.Errorf("invalid npub %q: %w", npub, err) } normalized = append(normalized, key.Public()) } return normalized, nil } func (c *Config) Validate() error { if c.Server.GrpcAddr == "" { return fmt.Errorf("server.grpc_addr is required") } if c.Server.HttpAddr == "" { return fmt.Errorf("server.http_addr is required") } if c.Database.Path == "" { return fmt.Errorf("database.path is required") } 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") } } 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 } func applyEnvOverrides(cfg *Config) { 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 } } if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" { cfg.Database.Path = val } if val := os.Getenv("MUXSTR_AUTH_READ_ENABLED"); val != "" { cfg.Auth.Read.Enabled = parseBool(val) } if val := os.Getenv("MUXSTR_AUTH_READ_ALLOWED_NPUBS"); val != "" { cfg.Auth.Read.AllowedNpubs = strings.Split(val, ",") } if val := os.Getenv("MUXSTR_AUTH_WRITE_ENABLED"); val != "" { cfg.Auth.Write.Enabled = parseBool(val) } if val := os.Getenv("MUXSTR_AUTH_WRITE_ALLOWED_NPUBS"); val != "" { cfg.Auth.Write.AllowedNpubs = strings.Split(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_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 } } 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 } 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, 0o644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil } func (r *RateLimitConfig) ToRateLimiter() *ratelimit.Config { rlConfig := &ratelimit.Config{ RequestsPerSecond: r.DefaultRPS, BurstSize: r.DefaultBurst, IPRequestsPerSecond: r.IPRPS, IPBurstSize: r.IPBurst, SkipMethods: r.SkipMethods, SkipUsers: r.SkipUsers, CleanupInterval: r.CleanupInterval, MaxIdleTime: r.MaxIdleTime, } if r.Methods != nil { rlConfig.MethodLimits = make(map[string]ratelimit.MethodLimit, len(r.Methods)) for method, limit := range r.Methods { rlConfig.MethodLimits[method] = ratelimit.MethodLimit{ RequestsPerSecond: limit.RPS, BurstSize: limit.Burst, } } } if r.Users != nil { rlConfig.UserLimits = make(map[string]ratelimit.UserLimit, len(r.Users)) for user, limit := range r.Users { userLimit := ratelimit.UserLimit{ RequestsPerSecond: limit.RPS, BurstSize: limit.Burst, } if limit.Methods != nil { userLimit.MethodLimits = make(map[string]ratelimit.MethodLimit, len(limit.Methods)) for method, methodLimit := range limit.Methods { userLimit.MethodLimits[method] = ratelimit.MethodLimit{ RequestsPerSecond: methodLimit.RPS, BurstSize: methodLimit.Burst, } } } rlConfig.UserLimits[user] = userLimit } } return rlConfig } func (m *MetricsConfig) ToMetrics() *metrics.Config { return &metrics.Config{ Namespace: m.Namespace, Subsystem: m.Subsystem, } }