// Package config provides application configuration via Viper. package config import ( "crypto/rand" "encoding/hex" "errors" "fmt" "log/slog" "os" "path/filepath" "github.com/spf13/viper" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/logger" ) // defaultPort is the default HTTP server port. const defaultPort = 8080 // sessionSecretFile is the filename for the persisted session secret. const sessionSecretFile = "session.key" // sessionSecretBytes is the number of random bytes for session secret. const sessionSecretBytes = 32 // File permission constants. const ( dirPermissions = 0o700 filePermissions = 0o600 ) // Params contains dependencies for Config. type Params struct { fx.In Globals *globals.Globals Logger *logger.Logger } // Config holds application configuration. type Config struct { Port int Debug bool DataDir string HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container) DockerHost string SentryDSN string MaintenanceMode bool MetricsUsername string MetricsPassword string SessionSecret string params *Params log *slog.Logger } // New creates a new Config instance from environment and config files. func New(_ fx.Lifecycle, params Params) (*Config, error) { log := params.Logger.Get() name := params.Globals.Appname if name == "" { name = "upaas" } setupViper(name) cfg, err := buildConfig(log, ¶ms) if err != nil { return nil, err } configureDebugLogging(cfg, params) return cfg, nil } func setupViper(name string) { // Config file settings viper.SetConfigName(name) viper.SetConfigType("yaml") viper.AddConfigPath("/etc/" + name) viper.AddConfigPath("$HOME/.config/" + name) viper.AddConfigPath(".") // Environment variables override everything viper.SetEnvPrefix("UPAAS") viper.AutomaticEnv() // Defaults // PORT is not prefixed with UPAAS_ for compatibility _ = viper.BindEnv("PORT", "PORT") viper.SetDefault("PORT", defaultPort) viper.SetDefault("DEBUG", false) viper.SetDefault("DATA_DIR", "./data") viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock") viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("MAINTENANCE_MODE", false) viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("SESSION_SECRET", "") } func buildConfig(log *slog.Logger, params *Params) (*Config, error) { // Read config file (optional) err := viper.ReadInConfig() if err != nil { var configFileNotFoundError viper.ConfigFileNotFoundError if !errors.As(err, &configFileNotFoundError) { log.Error("config file malformed", "error", err) return nil, fmt.Errorf("config file malformed: %w", err) } // Config file not found is OK } dataDir := viper.GetString("DATA_DIR") hostDataDir := viper.GetString("HOST_DATA_DIR") if hostDataDir == "" { hostDataDir = dataDir } // Build config struct cfg := &Config{ Port: viper.GetInt("PORT"), Debug: viper.GetBool("DEBUG"), DataDir: dataDir, HostDataDir: hostDataDir, DockerHost: viper.GetString("DOCKER_HOST"), SentryDSN: viper.GetString("SENTRY_DSN"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), SessionSecret: viper.GetString("SESSION_SECRET"), params: params, log: log, } // Load or generate session secret if cfg.SessionSecret == "" { secret, err := loadOrCreateSessionSecret(log, cfg.DataDir) if err != nil { return nil, fmt.Errorf("failed to initialize session secret: %w", err) } cfg.SessionSecret = secret } return cfg, nil } func loadOrCreateSessionSecret(log *slog.Logger, dataDir string) (string, error) { secretPath := filepath.Join(dataDir, sessionSecretFile) // Try to read existing secret //nolint:gosec // secretPath is constructed from trusted config, not user input data, err := os.ReadFile(secretPath) if err == nil { log.Info("loaded session secret from file", "path", secretPath) return string(data), nil } if !os.IsNotExist(err) { return "", fmt.Errorf("failed to read session secret file: %w", err) } // Generate new secret secretBytes := make([]byte, sessionSecretBytes) _, err = rand.Read(secretBytes) if err != nil { return "", fmt.Errorf("failed to generate random secret: %w", err) } secret := hex.EncodeToString(secretBytes) // Ensure data directory exists err = os.MkdirAll(dataDir, dirPermissions) if err != nil { return "", fmt.Errorf("failed to create data directory: %w", err) } // Write secret to file err = os.WriteFile(secretPath, []byte(secret), filePermissions) if err != nil { return "", fmt.Errorf("failed to write session secret file: %w", err) } log.Info("generated new session secret", "path", secretPath) return secret, nil } func configureDebugLogging(cfg *Config, params Params) { // Enable debug logging if configured if cfg.Debug { params.Logger.EnableDebugLogging() cfg.log = params.Logger.Get() } } // DatabasePath returns the full path to the SQLite database file. func (c *Config) DatabasePath() string { return c.DataDir + "/upaas.db" }