// Package config handles application configuration and validation package config import ( "bytes" "encoding/json" "fmt" "net" "net/url" "os" "strconv" "strings" "time" "github.com/Turbootzz/vaultwarden-api/internal/auth" ) // Config holds all application configuration type Config struct { // Server settings Port string Environment string ReadTimeout time.Duration WriteTimeout time.Duration // Security APIKeys []auth.APIKey AllowedIPs []string EnableGitHubIPRanges bool // Vaultwarden VaultwardenURL string VaultwardenToken string // Performance CacheTTL time.Duration CORSAllowedOrigins string // Rate limiting RateLimitMax int RateLimitWindow time.Duration } // Load reads configuration from environment variables func Load() (*Config, error) { cfg := &Config{ Port: getEnv("API_PORT", "8080"), Environment: getEnv("ENVIRONMENT", "development"), VaultwardenURL: os.Getenv("VAULTWARDEN_URL"), VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"), ReadTimeout: parseDuration(os.Getenv("READ_TIMEOUT"), "10s"), WriteTimeout: parseDuration(os.Getenv("WRITE_TIMEOUT"), "10s"), CacheTTL: parseDuration(os.Getenv("CACHE_TTL"), "5m"), CORSAllowedOrigins: getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:3000"), EnableGitHubIPRanges: getEnv("ENABLE_GITHUB_IP_RANGES", "false") == "true", RateLimitMax: parseInt(getEnv("RATE_LIMIT_MAX", "30"), 30), RateLimitWindow: parseDuration(os.Getenv("RATE_LIMIT_WINDOW"), "1m"), } // Load API keys from API_KEYS_FILE / API_KEYS / legacy API_KEY. apiKeys, err := loadAPIKeys() if err != nil { return nil, err } cfg.APIKeys = apiKeys // Parse allowed IPs if allowedIPsStr := os.Getenv("ALLOWED_IPS"); allowedIPsStr != "" { ips := strings.Split(allowedIPsStr, ",") for _, ip := range ips { trimmed := strings.TrimSpace(ip) if trimmed != "" { if err := validateIPOrCIDR(trimmed); err != nil { return nil, fmt.Errorf("invalid IP in ALLOWED_IPS (%s): %w", trimmed, err) } cfg.AllowedIPs = append(cfg.AllowedIPs, trimmed) } } } // Validate required fields if cfg.VaultwardenURL == "" { return nil, fmt.Errorf("VAULTWARDEN_URL is required") } // Validate and normalize URL parsedURL, err := url.Parse(cfg.VaultwardenURL) if err != nil { return nil, fmt.Errorf("invalid VAULTWARDEN_URL: %w", err) } if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return nil, fmt.Errorf("VAULTWARDEN_URL must use http or https scheme") } // Remove trailing slash for consistency cfg.VaultwardenURL = strings.TrimSuffix(cfg.VaultwardenURL, "/") return cfg, nil } // IsProd returns true if running in production func (c *Config) IsProd() bool { return c.Environment == "production" } // getEnv gets an environment variable with a fallback default value func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // parseDuration parses a duration string, falling back to the given default // string for empty or malformed input (the fallback is a known-good constant). func parseDuration(s, fallback string) time.Duration { if d, err := time.ParseDuration(s); err == nil { return d } d, _ := time.ParseDuration(fallback) return d } // parseInt parses a positive integer with a fallback for empty/invalid/non-positive input. func parseInt(s string, fallback int) int { n, err := strconv.Atoi(s) if err != nil || n <= 0 { return fallback } return n } // apiKeyJSON is the on-disk/env JSON schema for a scoped API key. type apiKeyJSON struct { Name string `json:"name"` Key string `json:"key"` Organizations []string `json:"organizations"` Collections []string `json:"collections"` } // loadAPIKeys assembles the configured keys from API_KEYS_FILE (preferred) or // API_KEYS (inline JSON), plus a legacy unscoped API_KEY if set. At least one // key is required and each must be at least 32 characters. func loadAPIKeys() ([]auth.APIKey, error) { var keys []auth.APIKey if path := os.Getenv("API_KEYS_FILE"); path != "" { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read API_KEYS_FILE: %w", err) } parsed, err := parseAPIKeysJSON(data, "API_KEYS_FILE") if err != nil { return nil, err } keys = append(keys, parsed...) } else if raw := os.Getenv("API_KEYS"); raw != "" { parsed, err := parseAPIKeysJSON([]byte(raw), "API_KEYS") if err != nil { return nil, err } keys = append(keys, parsed...) } // Legacy single key remains a full-access (unscoped) key. if legacy := os.Getenv("API_KEY"); legacy != "" { keys = append(keys, auth.APIKey{Name: "legacy", Key: legacy}) } if len(keys) == 0 { return nil, fmt.Errorf("no API keys configured: set API_KEY, API_KEYS, or API_KEYS_FILE") } seen := make(map[string]struct{}, len(keys)) for i, k := range keys { if len(k.Key) < 32 { return nil, fmt.Errorf("API key #%d (%q) must be at least 32 characters for security (run: openssl rand -base64 32)", i+1, k.Name) } if _, dup := seen[k.Key]; dup { return nil, fmt.Errorf("duplicate API key material for key #%d (%q): each key must be unique so it cannot silently override another key's scope", i+1, k.Name) } seen[k.Key] = struct{}{} } return keys, nil } // parseAPIKeysJSON parses a JSON array of scoped API keys. Unknown fields are // rejected so a misspelled scope field (e.g. "collection") fails loudly at // startup instead of silently leaving the key unscoped (full access). func parseAPIKeysJSON(data []byte, source string) ([]auth.APIKey, error) { dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() var entries []apiKeyJSON if err := dec.Decode(&entries); err != nil { return nil, fmt.Errorf("invalid JSON in %s: %w", source, err) } keys := make([]auth.APIKey, 0, len(entries)) for i, e := range entries { if e.Key == "" { return nil, fmt.Errorf("%s entry #%d is missing \"key\"", source, i+1) } keys = append(keys, auth.APIKey{ Name: e.Name, Key: e.Key, Scope: auth.Scope{ Organizations: e.Organizations, Collections: e.Collections, }, }) } return keys, nil } // validateIPOrCIDR validates if a string is a valid IP address or CIDR range func validateIPOrCIDR(s string) error { // Try parsing as CIDR first if _, _, err := net.ParseCIDR(s); err == nil { return nil } // Try parsing as IP address if net.ParseIP(s) != nil { return nil } return fmt.Errorf("not a valid IP address or CIDR range") }