package config import ( "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/spf13/cobra" "github.com/spf13/viper" ) // outputWriter is used for logging so tests can capture output. var outputWriter io.Writer = os.Stderr func logf(format string, args ...interface{}) { _, _ = fmt.Fprintf(outputWriter, format+"\n", args...) } // Config holds the full application configuration. type Config struct { PG struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` Database string `mapstructure:"database"` SSLMode string `mapstructure:"sslmode"` ExcludeTables []string `mapstructure:"exclude_tables"` } `mapstructure:"pg"` Backup struct { Dir string `mapstructure:"dir"` RetentionDays int `mapstructure:"retention_days"` Cron string `mapstructure:"cron"` } `mapstructure:"backup"` PQScheme uint16 `mapstructure:"pq_scheme"` ClassicalScheme uint16 `mapstructure:"classical_scheme"` PQPublicKeyPath string `mapstructure:"pq_public_key_path"` ClassicalPublicKeyPath string `mapstructure:"classical_public_key_path"` Healthz struct { Port int `mapstructure:"port"` } `mapstructure:"healthz"` Log struct { Level string `mapstructure:"level"` } `mapstructure:"log"` ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` } // RegisterFlags adds all configuration flags to the provided Cobra command. func RegisterFlags(cmd *cobra.Command) { flags := cmd.Flags() flags.String("pg-host", "", "PostgreSQL host") flags.Int("pg-port", 0, "PostgreSQL port") flags.String("pg-user", "", "PostgreSQL user") flags.String("pg-database", "", "PostgreSQL database name") flags.String("pg-sslmode", "", "PostgreSQL SSL mode") flags.StringSlice("pg-exclude-tables", nil, "PostgreSQL tables to exclude from backup") flags.String("backup-dir", "", "Backup directory") flags.Int("backup-retention-days", 0, "Backup retention period in days") flags.String("backup-cron", "", "Cron expression for backup schedule") flags.Uint16("pq-scheme", 0, "Post-quantum KEM scheme ID") flags.Uint16("classical-scheme", 0, "Classical KEM scheme ID") flags.String("pq-public-key-path", "", "Path to post-quantum public key") flags.String("classical-public-key-path", "", "Path to classical public key") flags.Int("healthz-port", 0, "Health check HTTP port") flags.String("log-level", "", "Log level") flags.Duration("shutdown-timeout", 0, "Graceful shutdown timeout") } // Load reads configuration from all sources in priority order: // launch arguments (Cobra flags) → APP_* env variables → config file → defaults. func Load(cmd *cobra.Command) (*Config, error) { // Use a fresh Viper instance so that successive calls do not leak state. viperInstance := viper.New() // Defaults (lowest priority). viperInstance.SetDefault("pg.port", 5432) viperInstance.SetDefault("pg.sslmode", "prefer") viperInstance.SetDefault("pg.exclude_tables", []string{"e2e_one_time_keys_json"}) viperInstance.SetDefault("backup.retention_days", 180) viperInstance.SetDefault("backup.cron", "0 0 3 * * *") viperInstance.SetDefault("pq_scheme", uint16(0x0006)) viperInstance.SetDefault("classical_scheme", uint16(0x0007)) viperInstance.SetDefault("healthz.port", 8080) viperInstance.SetDefault("log.level", "info") viperInstance.SetDefault("shutdown_timeout", 30*time.Second) // Bind parsed Cobra flags to Viper keys. if cmd != nil { _ = viperInstance.BindPFlag("pg.host", cmd.Flags().Lookup("pg-host")) _ = viperInstance.BindPFlag("pg.port", cmd.Flags().Lookup("pg-port")) _ = viperInstance.BindPFlag("pg.user", cmd.Flags().Lookup("pg-user")) _ = viperInstance.BindPFlag("pg.database", cmd.Flags().Lookup("pg-database")) _ = viperInstance.BindPFlag("pg.sslmode", cmd.Flags().Lookup("pg-sslmode")) _ = viperInstance.BindPFlag("pg.exclude_tables", cmd.Flags().Lookup("pg-exclude-tables")) _ = viperInstance.BindPFlag("backup.dir", cmd.Flags().Lookup("backup-dir")) _ = viperInstance.BindPFlag("backup.retention_days", cmd.Flags().Lookup("backup-retention-days")) _ = viperInstance.BindPFlag("backup.cron", cmd.Flags().Lookup("backup-cron")) _ = viperInstance.BindPFlag("pq_scheme", cmd.Flags().Lookup("pq-scheme")) _ = viperInstance.BindPFlag("classical_scheme", cmd.Flags().Lookup("classical-scheme")) _ = viperInstance.BindPFlag("pq_public_key_path", cmd.Flags().Lookup("pq-public-key-path")) _ = viperInstance.BindPFlag("classical_public_key_path", cmd.Flags().Lookup("classical-public-key-path")) _ = viperInstance.BindPFlag("healthz.port", cmd.Flags().Lookup("healthz-port")) _ = viperInstance.BindPFlag("log.level", cmd.Flags().Lookup("log-level")) _ = viperInstance.BindPFlag("shutdown_timeout", cmd.Flags().Lookup("shutdown-timeout")) } // Config file search with logging. viperInstance.SetConfigName("config") viperInstance.SetConfigType("yaml") if envLoc := os.Getenv("APP_CONFIG_LOCATION"); envLoc != "" { viperInstance.SetConfigFile(envLoc) if err := viperInstance.ReadInConfig(); err == nil { logf("Config file found: %s (from APP_CONFIG_LOCATION)", viperInstance.ConfigFileUsed()) } else { return nil, fmt.Errorf("config file specified in APP_CONFIG_LOCATION not found: %s", envLoc) } } else { viperInstance.AddConfigPath(".") homeDir, _ := os.UserHomeDir() appName := "synapse-backupper" userConfigPath := filepath.Join(homeDir, ".config", appName) viperInstance.AddConfigPath(userConfigPath) exePath, _ := os.Executable() exeDir := filepath.Dir(exePath) viperInstance.AddConfigPath(exeDir) etcPath := filepath.Join("/etc", appName) viperInstance.AddConfigPath(etcPath) if err := viperInstance.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { logf("Configuration file not found. Using defaults and env/args.") } else { return nil, fmt.Errorf("error reading config: %w", err) } } else { logf("Config file found: %s", viperInstance.ConfigFileUsed()) } } // Environment variables. viperInstance.SetEnvPrefix("APP") viperInstance.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) viperInstance.AutomaticEnv() // pg.password is intentionally not exposed as a CLI flag (CWE-214), but // must still be loadable from APP_PG_PASSWORD. Viper needs an explicit // BindEnv for a nested key that has no bound flag. _ = viperInstance.BindEnv("pg.password") var cfg Config if err := viperInstance.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("config unmarshal failed: %w", err) } return &cfg, nil }