// Package config loads and validates the lethe server configuration from a
// YAML file plus LETHE_* environment overrides.
//
// The Config struct is split into substructs (Server, Database, Auth, Logging,
// Ingest); each substruct is tagged with `config-section:""` so that steward
// (Phase 4+) can inject them by type into individual services.
//
// Validation runs in fail-fast mode: any unknown YAML key, missing required
// field, or out-of-range value rejects the load with a culpa-wrapped error
// carrying a machine-readable code (CONFIG_NOT_FOUND, CONFIG_PARSE,
// CONFIG_VALIDATE). There are no silent fallbacks: the only defaults applied
// are the ones explicitly listed in registerDefaults.
package config
import (
"errors"
"io/fs"
"os"
"regexp"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/viper"
"go.bigb.es/auxilia/culpa"
)
// Config is the root configuration object. Each substruct is exposed to
// steward as a config section via the `config-section:""` tag.
type Config struct {
Server ServerConfig `mapstructure:"server" config-section:""`
Database DatabaseConfig `mapstructure:"database" config-section:""`
Auth AuthConfig `mapstructure:"auth" config-section:""`
Logging LoggingConfig `mapstructure:"logging" config-section:""`
Ingest IngestConfig `mapstructure:"ingest" config-section:""`
}
// ServerConfig controls the HTTP listener.
type ServerConfig struct {
Bind string `mapstructure:"bind" validate:"required,loopback_bind"`
ShutdownGrace time.Duration `mapstructure:"shutdown_grace" validate:"gt=0"`
}
// DatabaseConfig points at the SQLite file and tunes its busy timeout.
type DatabaseConfig struct {
Path string `mapstructure:"path" validate:"required"`
BusyTimeout time.Duration `mapstructure:"busy_timeout" validate:"gt=0"`
}
// AuthConfig is the authentication policy. At least one of ForwardAuth or
// OIDC must be enabled (enforced by the auth_at_least_one struct-level
// validator) and Admins must be a subset of AllowedUsers (enforced by
// admins_subset_of_allowed).
type AuthConfig struct {
AllowedUsers []string `mapstructure:"allowed_users" validate:"min=1,dive,required"`
Admins []string `mapstructure:"admins" validate:"dive,required"`
ForwardAuth ForwardAuthConfig `mapstructure:"forward_auth"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// ForwardAuthConfig consumes a trusted reverse-proxy header.
type ForwardAuthConfig struct {
Enabled bool `mapstructure:"enabled"`
UserHeader string `mapstructure:"user_header"`
}
// OIDCConfig validates bearer tokens against an OIDC issuer.
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
Issuer string `mapstructure:"issuer" validate:"required_if=Enabled true,omitempty,url"`
Audience string `mapstructure:"audience" validate:"required_if=Enabled true"`
UsernameClaim string `mapstructure:"username_claim"`
}
// LoggingConfig selects log level and formatter.
type LoggingConfig struct {
Level string `mapstructure:"level" validate:"required,oneof=debug info warn error"`
Format string `mapstructure:"format" validate:"required,oneof=tint json"`
}
// IngestConfig caps payload sizes and chunking on the ingest path.
type IngestConfig struct {
MaxBodyBytes int64 `mapstructure:"max_body_bytes" validate:"gt=0"`
MaxTurnContentBytes int64 `mapstructure:"max_turn_content_bytes" validate:"gt=0,ltefield=MaxBodyBytes"`
ChunkSize int `mapstructure:"chunk_size" validate:"gt=0"`
}
// loopbackBindRe matches `127.0.0.1` or `127.0.0.1:<port>` (1-5 digits).
// Other interfaces are rejected so the listener never binds publicly.
var loopbackBindRe = regexp.MustCompile(`^127\.0\.0\.1(?::\d{1,5})?$`)
// Load reads YAML from path, applies env overrides, fills documented
// defaults, and validates. Errors carry a culpa code.
func Load(path string) (*Config, error) {
v := viper.New()
v.SetConfigFile(path)
v.SetEnvPrefix("LETHE")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
registerDefaults(v)
if err := v.ReadInConfig(); err != nil {
// viper returns *fs.PathError for missing files; treat any read
// failure as CONFIG_NOT_FOUND vs CONFIG_PARSE based on type.
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND")
}
// Distinguish "missing file" (os PathError) from a parse failure.
if isFileMissing(err) {
return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND")
}
return nil, culpa.WithCode(culpa.Wrap(err, "parse config"), "CONFIG_PARSE")
}
// Stitch comma-separated env overrides for slice fields. viper does
// not split string env values into []string by itself, so we do it
// here for the known slice fields under auth.
splitCSVEnv(v, "auth.allowed_users", "LETHE_AUTH_ALLOWED_USERS")
splitCSVEnv(v, "auth.admins", "LETHE_AUTH_ADMINS")
var cfg Config
if err := v.UnmarshalExact(
&cfg,
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
)),
func(c *mapstructure.DecoderConfig) {
c.ErrorUnused = true
},
); err != nil {
return nil, culpa.WithCode(culpa.Wrap(err, "decode config"), "CONFIG_PARSE")
}
val := newValidator()
if err := val.Struct(&cfg); err != nil {
return nil, culpa.WithCode(culpa.Wrap(err, "validate config"), "CONFIG_VALIDATE")
}
return &cfg, nil
}
// MustLoad calls Load and panics on error. Used by main.go where a bad
// config means the process cannot start anyway.
func MustLoad(path string) *Config {
cfg, err := Load(path)
if err != nil {
panic(err)
}
return cfg
}
// registerDefaults sets the documented defaults. Notably, neither
// auth.forward_auth.enabled nor auth.oidc.enabled has a default — the
// operator must explicitly enable at least one mode.
func registerDefaults(v *viper.Viper) {
v.SetDefault("server.shutdown_grace", 10*time.Second)
v.SetDefault("database.busy_timeout", 5*time.Second)
v.SetDefault("auth.forward_auth.user_header", "Remote-User")
v.SetDefault("auth.oidc.username_claim", "preferred_username")
v.SetDefault("ingest.max_body_bytes", int64(16*1024*1024))
v.SetDefault("ingest.max_turn_content_bytes", int64(4*1024*1024))
v.SetDefault("ingest.chunk_size", 500)
}
// splitCSVEnv converts a comma-separated environment variable into a
// []string and stores it on viper under key. We cannot rely on viper's
// AutomaticEnv to do this for slice destinations because the env value
// arrives as a single string after viper resolves it.
func splitCSVEnv(v *viper.Viper, key, envName string) {
raw, ok := os.LookupEnv(envName)
if !ok {
return
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
v.Set(key, out)
}
// newValidator builds a *validator.Validate with our custom rules
// registered.
func newValidator() *validator.Validate {
val := validator.New(validator.WithRequiredStructEnabled())
// "loopback_bind" is a field-level rule so it can be expressed via the
// validate tag on ServerConfig.Bind alongside required.
_ = val.RegisterValidation("loopback_bind", func(fl validator.FieldLevel) bool {
return loopbackBindRe.MatchString(fl.Field().String())
})
val.RegisterStructValidation(authStructValidator, AuthConfig{})
return val
}
// authStructValidator enforces both auth_at_least_one and
// admins_subset_of_allowed. Struct-level is the right level: both rules
// straddle multiple fields of AuthConfig.
func authStructValidator(sl validator.StructLevel) {
auth, ok := sl.Current().Interface().(AuthConfig)
if !ok {
return
}
if !auth.ForwardAuth.Enabled && !auth.OIDC.Enabled {
sl.ReportError(auth.ForwardAuth.Enabled, "ForwardAuth.Enabled", "forward_auth.enabled", "auth_at_least_one", "")
}
allowed := make(map[string]struct{}, len(auth.AllowedUsers))
for _, u := range auth.AllowedUsers {
allowed[u] = struct{}{}
}
for _, admin := range auth.Admins {
if _, found := allowed[admin]; !found {
sl.ReportError(auth.Admins, "Admins", "admins", "admins_subset_of_allowed", "")
return
}
}
}
// isFileMissing reports whether err describes a missing-file condition
// from viper.ReadInConfig. viper wraps the underlying os error.
func isFileMissing(err error) bool {
var pe *fs.PathError
if errors.As(err, &pe) {
return errors.Is(pe.Err, fs.ErrNotExist)
}
return errors.Is(err, fs.ErrNotExist)
}