kereru-forms/backend/internal/config/config.go

384 lines
12 KiB
Go
Raw Permalink Normal View History

// Package config loads application configuration from defaults, a YAML/JSON
// document, environment variables (optionally via a .env file) and CLI flags.
//
// Precedence, highest first:
// 1. CLI flags (--conf, --log-level, --listen-addr, --listen-addr6, --port)
// 2. Environment variables
// 3. .env file located next to the executable or in the working directory
// 4. Configuration file supplied with --conf
// 5. Built-in defaults
package config
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Config holds every runtime setting supported by kereru-forms.
type Config struct {
// LogLevel is the minimum log level (trace, debug, info, warn, error).
LogLevel string `yaml:"logLevel" json:"logLevel"`
// LogFormat is "json" or "console".
LogFormat string `yaml:"logFormat" json:"logFormat"`
// Environment is a free-form deployment label (development, staging, production).
Environment string `yaml:"environment" json:"environment"`
// HTTPAddr is the IPv4 address to listen on. Empty disables that listener.
HTTPAddr string `yaml:"listenAddr" json:"listenAddr"`
// HTTPAddr6 is the IPv6 address to listen on. Empty disables that listener.
HTTPAddr6 string `yaml:"listenAddr6" json:"listenAddr6"`
// Port is the TCP port shared by both listeners.
Port string `yaml:"port" json:"port"`
// DBDriver selects the storage backend. Currently "sqlite" is implemented.
DBDriver string `yaml:"dbDriver" json:"dbDriver"`
// DBDSN is the driver-specific connection string.
DBDSN string `yaml:"dbDsn" json:"dbDsn"`
// TelemetryEnabled turns on OpenTelemetry tracing/metrics export.
TelemetryEnabled bool `yaml:"telemetryEnabled" json:"telemetryEnabled"`
// OTLPEndpoint is the OTLP/HTTP collector endpoint (host:port or URL).
OTLPEndpoint string `yaml:"otlpEndpoint" json:"otlpEndpoint"`
// CORSOrigins is the list of allowed browser origins ("*" allows all).
CORSOrigins []string `yaml:"corsOrigins" json:"corsOrigins"`
// StaticDir optionally serves a built frontend from this directory.
StaticDir string `yaml:"staticDir" json:"staticDir"`
2026-10-23 19:36:02 +00:00
// Auth configures management/submission authentication.
Auth AuthConfig `yaml:"auth" json:"auth"`
}
// AuthConfig controls password fallback and OAuth/OIDC authentication.
type AuthConfig struct {
// LocalPassword is used when no OIDC issuer is configured. A bcrypt hash is
// also accepted. It is ignored when OIDC is enabled.
LocalPassword string `yaml:"localPassword" json:"localPassword"`
// RequireSubmissionAuth requires any authenticated user to list/open forms
// and submit responses. Management endpoints are always protected.
RequireSubmissionAuth bool `yaml:"requireSubmissionAuth" json:"requireSubmissionAuth"`
// LocalTokenSecret signs local password-mode JWTs. Empty generates a random
// secret at startup (tokens then do not survive a restart or multiple replicas).
LocalTokenSecret string `yaml:"localTokenSecret" json:"localTokenSecret"`
// LocalTokenTTLHours is the lifetime of local password-mode tokens.
LocalTokenTTLHours int `yaml:"localTokenTtlHours" json:"localTokenTtlHours"`
// OIDC configures an external OpenID Connect provider.
OIDC OIDCConfig `yaml:"oidc" json:"oidc"`
}
// OIDCConfig describes the external OpenID Connect provider used by the SPA.
type OIDCConfig struct {
IssuerURL string `yaml:"issuerUrl" json:"issuerUrl"`
ClientID string `yaml:"clientId" json:"clientId"`
Scopes []string `yaml:"scopes" json:"scopes"`
// RoleClaim is a dot-separated claim path, e.g. "roles" or
// "realm_access.roles". The value may be a string or an array of strings.
RoleClaim string `yaml:"roleClaim" json:"roleClaim"`
AdminRole string `yaml:"adminRole" json:"adminRole"`
ViewRole string `yaml:"viewRole" json:"viewRole"`
}
// OIDCEnabled reports whether external OIDC authentication is configured.
func (c AuthConfig) OIDCEnabled() bool {
return strings.TrimSpace(c.OIDC.IssuerURL) != ""
}
// Default returns the built-in configuration.
func Default() *Config {
return &Config{
LogLevel: "info",
LogFormat: "console",
Environment: "development",
HTTPAddr: "0.0.0.0",
HTTPAddr6: "",
Port: "8080",
DBDriver: "sqlite",
DBDSN: "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
TelemetryEnabled: false,
OTLPEndpoint: "localhost:4318",
CORSOrigins: []string{"http://localhost:5173"},
StaticDir: "",
2026-10-23 19:36:02 +00:00
Auth: AuthConfig{
LocalPassword: "admin",
LocalTokenTTLHours: 8,
OIDC: OIDCConfig{
Scopes: []string{"openid", "profile", "email"},
RoleClaim: "roles",
AdminRole: "admin",
ViewRole: "view",
},
},
}
}
// Load builds the configuration from args (usually os.Args[1:]).
func Load(args []string) (*Config, error) {
cfg := Default()
dotEnvPath := LoadDotEnv()
_ = dotEnvPath
confPath := scanConf(args)
if confPath != "" {
if err := loadFile(cfg, confPath); err != nil {
return nil, err
}
}
applyEnv(cfg)
fs := flag.NewFlagSet("kereru-forms", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
flagConf = fs.String("conf", confPath, "path to a YAML or JSON configuration file")
flagLevel = fs.String("log-level", cfg.LogLevel, "log level: trace, debug, info, warn, error")
flagFormat = fs.String("log-format", cfg.LogFormat, "log format: console or json")
flagAddr = fs.String("listen-addr", cfg.HTTPAddr, "IPv4 listen address (empty to disable)")
flagAddr6 = fs.String("listen-addr6", cfg.HTTPAddr6, "IPv6 listen address (empty to disable)")
flagPort = fs.String("port", cfg.Port, "TCP listen port")
flagDB = fs.String("db-dsn", cfg.DBDSN, "database connection string")
flagDriver = fs.String("db-driver", cfg.DBDriver, "database driver (sqlite)")
flagStatic = fs.String("static-dir", cfg.StaticDir, "directory containing a built frontend (optional)")
)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: kereru-forms [flags]\n\nFlags:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return nil, err
}
_ = flagConf
cfg.LogLevel = *flagLevel
cfg.LogFormat = *flagFormat
cfg.HTTPAddr = *flagAddr
cfg.HTTPAddr6 = *flagAddr6
cfg.Port = *flagPort
cfg.DBDSN = *flagDB
cfg.DBDriver = *flagDriver
cfg.StaticDir = *flagStatic
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate checks that the configuration is usable.
func (c *Config) Validate() error {
if strings.TrimSpace(c.Port) == "" {
return errors.New("port must not be empty")
}
if _, err := strconv.Atoi(c.Port); err != nil {
return fmt.Errorf("port %q is not a number", c.Port)
}
if c.HTTPAddr == "" && c.HTTPAddr6 == "" {
return errors.New("at least one of listenAddr or listenAddr6 must be set")
}
if strings.TrimSpace(c.DBDriver) == "" {
return errors.New("dbDriver must not be empty")
}
if strings.TrimSpace(c.DBDSN) == "" {
return errors.New("dbDsn must not be empty")
}
2026-10-23 19:36:02 +00:00
if c.Auth.LocalTokenTTLHours <= 0 {
return errors.New("auth.localTokenTtlHours must be greater than zero")
}
if strings.TrimSpace(c.Auth.OIDC.AdminRole) == "" {
return errors.New("auth.oidc.adminRole must not be empty")
}
if strings.TrimSpace(c.Auth.OIDC.ViewRole) == "" {
return errors.New("auth.oidc.viewRole must not be empty")
}
if strings.TrimSpace(c.Auth.OIDC.RoleClaim) == "" {
return errors.New("auth.oidc.roleClaim must not be empty")
}
if c.Auth.OIDCEnabled() {
if strings.TrimSpace(c.Auth.OIDC.ClientID) == "" {
return errors.New("auth.oidc.clientId is required when auth.oidc.issuerUrl is set")
}
hasOpenID := false
for _, scope := range c.Auth.OIDC.Scopes {
if strings.TrimSpace(scope) == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
return errors.New("auth.oidc.scopes must include openid")
}
} else if strings.TrimSpace(c.Auth.LocalPassword) == "" {
return errors.New("auth.localPassword must not be empty when OIDC is not configured")
}
return nil
}
// Addresses returns the listen addresses (host:port) for enabled listeners.
func (c *Config) Addresses() []string {
var addrs []string
if c.HTTPAddr != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr, c.Port))
}
if c.HTTPAddr6 != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr6, c.Port))
}
return addrs
}
func joinHostPort(host, port string) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
return "[" + host + "]:" + port
}
return host + ":" + port
}
func scanConf(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--conf" || arg == "-conf" {
if i+1 < len(args) {
return args[i+1]
}
}
if strings.HasPrefix(arg, "--conf=") {
return strings.TrimPrefix(arg, "--conf=")
}
if strings.HasPrefix(arg, "-conf=") {
return strings.TrimPrefix(arg, "-conf=")
}
}
return ""
}
func loadFile(cfg *Config, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if strings.EqualFold(filepath.Ext(path), ".json") {
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse JSON config %s: %w", path, err)
}
return nil
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse YAML config %s: %w", path, err)
}
return nil
}
func applyEnv(cfg *Config) {
setString(&cfg.LogLevel, "KERERU_LOG_LEVEL")
setString(&cfg.LogFormat, "KERERU_LOG_FORMAT")
setString(&cfg.Environment, "KERERU_ENVIRONMENT")
setString(&cfg.HTTPAddr, "KERERU_LISTEN_ADDR")
setString(&cfg.HTTPAddr6, "KERERU_LISTEN_ADDR6")
setString(&cfg.Port, "KERERU_PORT")
setString(&cfg.DBDriver, "KERERU_DB_DRIVER")
setString(&cfg.DBDSN, "KERERU_DB_DSN")
setString(&cfg.StaticDir, "KERERU_STATIC_DIR")
2026-10-23 19:36:02 +00:00
setString(&cfg.Auth.LocalPassword, "KERERU_AUTH_LOCAL_PASSWORD")
setString(&cfg.Auth.LocalTokenSecret, "KERERU_AUTH_LOCAL_TOKEN_SECRET")
if v, ok := os.LookupEnv("KERERU_AUTH_REQUIRE_SUBMISSION_AUTH"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Auth.RequireSubmissionAuth = b
}
}
if v, ok := os.LookupEnv("KERERU_AUTH_LOCAL_TOKEN_TTL_HOURS"); ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.Auth.LocalTokenTTLHours = n
}
}
setString(&cfg.Auth.OIDC.IssuerURL, "KERERU_OIDC_ISSUER_URL")
setString(&cfg.Auth.OIDC.ClientID, "KERERU_OIDC_CLIENT_ID")
setString(&cfg.Auth.OIDC.RoleClaim, "KERERU_OIDC_ROLE_CLAIM")
setString(&cfg.Auth.OIDC.AdminRole, "KERERU_OIDC_ADMIN_ROLE")
setString(&cfg.Auth.OIDC.ViewRole, "KERERU_OIDC_VIEW_ROLE")
if v, ok := os.LookupEnv("KERERU_OIDC_SCOPES"); ok {
var scopes []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
scopes = append(scopes, p)
}
}
if len(scopes) > 0 {
cfg.Auth.OIDC.Scopes = scopes
}
}
if v, ok := os.LookupEnv("KERERU_TELEMETRY_ENABLED"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.TelemetryEnabled = b
}
}
setString(&cfg.OTLPEndpoint, "OTEL_EXPORTER_OTLP_ENDPOINT")
if v, ok := os.LookupEnv("KERERU_CORS_ORIGINS"); ok {
var origins []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
origins = append(origins, p)
}
}
if len(origins) > 0 {
cfg.CORSOrigins = origins
}
}
}
func setString(dst *string, key string) {
if v, ok := os.LookupEnv(key); ok {
*dst = v
}
}
// LoadDotEnv reads a .env file from the directory containing the executable
// or, failing that, the working directory. Existing environment variables are never
// overwritten. It returns the path that was loaded, if any.
func LoadDotEnv() string {
candidateDirs := []string{}
if exe, err := os.Executable(); err == nil {
candidateDirs = append(candidateDirs, filepath.Dir(exe))
}
if wd, err := os.Getwd(); err == nil {
candidateDirs = append(candidateDirs, wd)
}
for _, dir := range candidateDirs {
path := filepath.Join(dir, ".env")
data, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
return path
}
return ""
}