upaas/internal/config/config.go

202 lines
5.0 KiB
Go

// 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
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, &params)
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
}
// Build config struct
cfg := &Config{
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"),
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"
}