Check / check (pull_request) Skipped
The compose file builds the image from this repo, mounts the Docker socket and HOST_DATA_DIR (passed to upaas as UPAAS_HOST_DATA_DIR), reads settings from .env, and restarts unless stopped. The port is published on 127.0.0.1 only, for a TLS-terminating proxy in front, and PORT is pinned to 8080 so .env cannot move upaas off the port mapping and the healthcheck, which uses the runtime image's busybox wget. upaas now refuses to start when UPAAS_HOST_DATA_DIR is set to a relative path; when unset it still falls back to the data directory. The README's plain-HTTP Compose example becomes a short deploy section that points at the file. .env is added to .dockerignore. Model: opus-5-5
227 lines
5.9 KiB
Go
227 lines
5.9 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"
|
|
|
|
"sneak.berlin/go/upaas/internal/globals"
|
|
"sneak.berlin/go/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
|
|
)
|
|
|
|
// errHostDataDirNotAbsolute is returned when UPAAS_HOST_DATA_DIR is set to a
|
|
// relative path, which the Docker daemon cannot resolve for app bind mounts.
|
|
var errHostDataDirNotAbsolute = errors.New(
|
|
"UPAAS_HOST_DATA_DIR must be an absolute path",
|
|
)
|
|
|
|
// 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 (Docker bind mounts in container)
|
|
DockerHost string
|
|
SentryDSN string
|
|
MaintenanceMode bool
|
|
PlaintextHTTP bool // clients reach µPaaS over plain HTTP (no TLS-terminating proxy)
|
|
MetricsUsername string
|
|
MetricsPassword string
|
|
SessionSecret string `json:"-"`
|
|
CORSOrigins 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("PLAINTEXT_HTTP", false)
|
|
viper.SetDefault("METRICS_USERNAME", "")
|
|
viper.SetDefault("METRICS_PASSWORD", "")
|
|
viper.SetDefault("SESSION_SECRET", "")
|
|
viper.SetDefault("CORS_ORIGINS", "")
|
|
}
|
|
|
|
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 != "" && !filepath.IsAbs(hostDataDir) {
|
|
return nil, fmt.Errorf("%w, got %q", errHostDataDirNotAbsolute, hostDataDir)
|
|
}
|
|
|
|
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"),
|
|
PlaintextHTTP: viper.GetBool("PLAINTEXT_HTTP"),
|
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
|
SessionSecret: viper.GetString("SESSION_SECRET"),
|
|
CORSOrigins: viper.GetString("CORS_ORIGINS"),
|
|
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"
|
|
}
|