// 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"` DevStub OIDCDevStubConfig `mapstructure:"dev_stub"` } // OIDCDevStubConfig controls the local-dev OIDC stub server. It must never // be enabled in production (see AS1). When disabled (the default), nothing // starts and no listener opens. type OIDCDevStubConfig struct { Enabled bool `mapstructure:"enabled"` Bind string `mapstructure:"bind" validate:"required_if=Enabled true,omitempty,loopback_bind"` TokenTTL time.Duration `mapstructure:"token_ttl" validate:"omitempty,gt=0"` } // 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:` (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("auth.oidc.dev_stub.token_ttl", 24*time.Hour) 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) }