Files
upaas/internal/config/config.go
T
sneak bf3048aaae
Check / check (pull_request) Skipped
Add docker-compose.yml for deploying upaas (closes #223)
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
2026-09-28 09:44:14 +00:00

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, &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("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"
}