- Add config validation: signing_key required, minimum 32 characters - Server now fails to start without valid signing_key (no more runtime errors) - Add config.example.yml with default whitelist hosts - Copy config to /etc/pixa/config.yml in Docker image - Update entrypoint to use --config /etc/pixa/config.yml - Add config.dev.yml for local Docker development - Mount dev config in make devserver
233 lines
5.6 KiB
Go
233 lines
5.6 KiB
Go
// Package config provides application configuration using smartconfig.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/pixa/internal/globals"
|
|
"sneak.berlin/go/pixa/internal/logger"
|
|
)
|
|
|
|
// Default configuration values.
|
|
const (
|
|
DefaultPort = 8080
|
|
DefaultStateDir = "/var/lib/pixa"
|
|
DefaultUpstreamConnectionsPerHost = 20
|
|
)
|
|
|
|
// Params defines dependencies for Config.
|
|
type Params struct {
|
|
fx.In
|
|
Globals *globals.Globals
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Config holds application configuration values.
|
|
type Config struct {
|
|
Debug bool
|
|
MaintenanceMode bool
|
|
MetricsPassword string
|
|
MetricsUsername string
|
|
Port int
|
|
SentryDSN string
|
|
StateDir string
|
|
DBURL string
|
|
|
|
// Image proxy settings
|
|
SigningKey string // HMAC signing key for URL signatures
|
|
WhitelistHosts []string // Hosts that don't require signatures
|
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
|
}
|
|
|
|
// New creates a new Config instance by loading configuration from file.
|
|
func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|
log := params.Logger.Get()
|
|
name := params.Globals.Appname
|
|
|
|
sc, err := loadConfigFile(log, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if sc == nil {
|
|
log.Info("no config file found, using defaults")
|
|
}
|
|
|
|
c := &Config{
|
|
Debug: getBool(sc, "debug", false),
|
|
MaintenanceMode: getBool(sc, "maintenance_mode", false),
|
|
Port: getInt(sc, "port", DefaultPort),
|
|
StateDir: getString(sc, "state_dir", DefaultStateDir),
|
|
SentryDSN: getString(sc, "sentry_dsn", ""),
|
|
MetricsUsername: getString(sc, "metrics.username", ""),
|
|
MetricsPassword: getString(sc, "metrics.password", ""),
|
|
SigningKey: getString(sc, "signing_key", ""),
|
|
WhitelistHosts: getStringSlice(sc, "whitelist_hosts"),
|
|
AllowHTTP: getBool(sc, "allow_http", false),
|
|
UpstreamConnectionsPerHost: getInt(sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
|
}
|
|
|
|
// Build DBURL from StateDir if not explicitly set
|
|
c.DBURL = getString(sc, "db_url", "")
|
|
if c.DBURL == "" {
|
|
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
|
}
|
|
|
|
if c.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
}
|
|
|
|
// Validate required configuration
|
|
if err := c.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// validate checks that all required configuration values are set.
|
|
func (c *Config) validate() error {
|
|
if c.SigningKey == "" {
|
|
return fmt.Errorf("signing_key is required")
|
|
}
|
|
|
|
// Minimum key length for security (32 bytes = 256 bits)
|
|
const minKeyLength = 32
|
|
if len(c.SigningKey) < minKeyLength {
|
|
return fmt.Errorf("signing_key must be at least %d characters", minKeyLength)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadConfigFile loads configuration from PIXA_CONFIG_PATH env var or standard locations.
|
|
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
|
|
// Check for explicit config path from environment
|
|
if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" {
|
|
sc, err := smartconfig.NewFromConfigPath(envPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config from %s: %w", envPath, err)
|
|
}
|
|
|
|
log.Info("loaded config file", "path", envPath)
|
|
|
|
return sc, nil
|
|
}
|
|
|
|
// Try loading config from standard locations
|
|
configPaths := []string{
|
|
fmt.Sprintf("/etc/%s/config.yml", appName),
|
|
fmt.Sprintf("/etc/%s/config.yaml", appName),
|
|
filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yml"),
|
|
filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yaml"),
|
|
"config.yml",
|
|
"config.yaml",
|
|
}
|
|
|
|
for _, path := range configPaths {
|
|
if _, statErr := os.Stat(path); statErr == nil {
|
|
sc, err := smartconfig.NewFromConfigPath(path)
|
|
if err != nil {
|
|
log.Warn("failed to parse config file", "path", path, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
log.Info("loaded config file", "path", path)
|
|
|
|
return sc, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
|
}
|
|
|
|
func getString(sc *smartconfig.Config, key, defaultVal string) string {
|
|
if sc == nil {
|
|
return defaultVal
|
|
}
|
|
|
|
val, err := sc.GetString(key)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
func getInt(sc *smartconfig.Config, key string, defaultVal int) int {
|
|
if sc == nil {
|
|
return defaultVal
|
|
}
|
|
|
|
val, err := sc.GetInt(key)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
func getBool(sc *smartconfig.Config, key string, defaultVal bool) bool {
|
|
if sc == nil {
|
|
return defaultVal
|
|
}
|
|
|
|
val, err := sc.GetBool(key)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
func getStringSlice(sc *smartconfig.Config, key string) []string {
|
|
if sc == nil {
|
|
return nil
|
|
}
|
|
|
|
val, ok := sc.Get(key)
|
|
if !ok || val == nil {
|
|
return nil
|
|
}
|
|
|
|
// Handle YAML list format
|
|
if slice, ok := val.([]interface{}); ok {
|
|
result := make([]string, 0, len(slice))
|
|
for _, item := range slice {
|
|
if str, ok := item.(string); ok {
|
|
trimmed := strings.TrimSpace(str)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Fall back to comma-separated string for backwards compatibility
|
|
if str, ok := val.(string); ok && str != "" {
|
|
parts := strings.Split(str, ",")
|
|
result := make([]string, 0, len(parts))
|
|
|
|
for _, part := range parts {
|
|
trimmed := strings.TrimSpace(part)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
return nil
|
|
}
|