384 lines
12 KiB
Go
384 lines
12 KiB
Go
// 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"`
|
|
|
|
// 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: "",
|
|
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")
|
|
}
|
|
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")
|
|
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 ""
|
|
}
|