- TOTP second factor (RFC 6238, secret encrypted under the master key), ten hashed single-use recovery codes, two-step login with a pending token bound to the client address, account enrolment/disable, admin reset - tests/security: prompt-injection corpus never acts or creates evidence-free beliefs; roles and CSRF on form and API paths; cross-mind isolation (demo scenario 5); stored-XSS escaping and CSP across pages; secrets never echoed (API, pages, database, archives); hostile archive import; session lifecycle and security headers - OpenAPI 3.1 document generated from the route table, served at /api/v1/openapi.json and kept complete by a test - gosec clean (justified #nosec annotations, cookie/redirect hardening, bounded multipart parsing, UUID packing without narrowing conversions); govulncheck clean with toolchain pinned to go1.26.8 - THREAT_MODEL verification pointers, SECURITY.md testing section, README, CHANGELOG 0.1.0, docs/RELEASE_NOTES.md, PLAN status
247 lines
7.6 KiB
Go
247 lines
7.6 KiB
Go
// Package config loads daemon configuration with a predictable precedence:
|
|
// built-in defaults, then an optional JSON file, then NOEMA_* environment
|
|
// variables. Runtime tunables that live in the database are handled by the
|
|
// settings store, not here.
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config is the fully resolved daemon configuration.
|
|
type Config struct {
|
|
HTTPAddr string `json:"http_addr"`
|
|
Env string `json:"env"` // development | production
|
|
LogLevel string `json:"log_level"`
|
|
LogFormat string `json:"log_format"` // text | json
|
|
|
|
DatabaseURL string `json:"database_url"`
|
|
MasterKey string `json:"master_key"` // base64, 32 bytes; secret
|
|
|
|
BootstrapAdminUser string `json:"bootstrap_admin_user"`
|
|
BootstrapAdminPassword string `json:"bootstrap_admin_password"` // secret
|
|
|
|
ResearchDir string `json:"research_dir"`
|
|
|
|
MaxWorkers int `json:"max_workers"`
|
|
MaxConcurrentLLM int `json:"max_concurrent_llm"`
|
|
DailyTokenBudget int64 `json:"daily_token_budget"`
|
|
|
|
SessionIdleTTL time.Duration `json:"session_idle_ttl"`
|
|
SessionAbsoluteTTL time.Duration `json:"session_absolute_ttl"`
|
|
MaxRequestBody int64 `json:"max_request_body"`
|
|
}
|
|
|
|
// Defaults returns the built-in configuration.
|
|
func Defaults() Config {
|
|
return Config{
|
|
HTTPAddr: "127.0.0.1:8080",
|
|
Env: "development",
|
|
LogLevel: "info",
|
|
LogFormat: "text",
|
|
BootstrapAdminUser: "admin",
|
|
ResearchDir: "./research",
|
|
MaxWorkers: 8,
|
|
MaxConcurrentLLM: 4,
|
|
DailyTokenBudget: 2_000_000,
|
|
SessionIdleTTL: 12 * time.Hour,
|
|
SessionAbsoluteTTL: 7 * 24 * time.Hour,
|
|
MaxRequestBody: 1 << 20,
|
|
}
|
|
}
|
|
|
|
// Load resolves configuration from defaults, the optional file at path, and
|
|
// the environment provided by lookup (normally os.LookupEnv).
|
|
func Load(path string, lookup func(string) (string, bool)) (Config, error) {
|
|
c := Defaults()
|
|
if path != "" {
|
|
b, err := os.ReadFile(path) // #nosec G304 -- operator-supplied config path from the command line
|
|
if err != nil {
|
|
return c, fmt.Errorf("config: read %s: %w", path, err)
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.DisallowUnknownFields()
|
|
var f fileConfig
|
|
if err := dec.Decode(&f); err != nil {
|
|
return c, fmt.Errorf("config: parse %s: %w", path, err)
|
|
}
|
|
f.apply(&c)
|
|
}
|
|
if err := c.applyEnv(lookup); err != nil {
|
|
return c, err
|
|
}
|
|
return c, c.Validate()
|
|
}
|
|
|
|
// fileConfig mirrors Config but with durations as strings so JSON stays readable.
|
|
type fileConfig struct {
|
|
HTTPAddr *string `json:"http_addr"`
|
|
Env *string `json:"env"`
|
|
LogLevel *string `json:"log_level"`
|
|
LogFormat *string `json:"log_format"`
|
|
DatabaseURL *string `json:"database_url"`
|
|
MasterKey *string `json:"master_key"`
|
|
BootstrapAdminUser *string `json:"bootstrap_admin_user"`
|
|
BootstrapAdminPassword *string `json:"bootstrap_admin_password"`
|
|
ResearchDir *string `json:"research_dir"`
|
|
MaxWorkers *int `json:"max_workers"`
|
|
MaxConcurrentLLM *int `json:"max_concurrent_llm"`
|
|
DailyTokenBudget *int64 `json:"daily_token_budget"`
|
|
SessionIdleTTL *string `json:"session_idle_ttl"`
|
|
SessionAbsoluteTTL *string `json:"session_absolute_ttl"`
|
|
MaxRequestBody *int64 `json:"max_request_body"`
|
|
}
|
|
|
|
func (f fileConfig) apply(c *Config) {
|
|
setS := func(dst *string, src *string) {
|
|
if src != nil {
|
|
*dst = *src
|
|
}
|
|
}
|
|
setS(&c.HTTPAddr, f.HTTPAddr)
|
|
setS(&c.Env, f.Env)
|
|
setS(&c.LogLevel, f.LogLevel)
|
|
setS(&c.LogFormat, f.LogFormat)
|
|
setS(&c.DatabaseURL, f.DatabaseURL)
|
|
setS(&c.MasterKey, f.MasterKey)
|
|
setS(&c.BootstrapAdminUser, f.BootstrapAdminUser)
|
|
setS(&c.BootstrapAdminPassword, f.BootstrapAdminPassword)
|
|
setS(&c.ResearchDir, f.ResearchDir)
|
|
if f.MaxWorkers != nil {
|
|
c.MaxWorkers = *f.MaxWorkers
|
|
}
|
|
if f.MaxConcurrentLLM != nil {
|
|
c.MaxConcurrentLLM = *f.MaxConcurrentLLM
|
|
}
|
|
if f.DailyTokenBudget != nil {
|
|
c.DailyTokenBudget = *f.DailyTokenBudget
|
|
}
|
|
if f.MaxRequestBody != nil {
|
|
c.MaxRequestBody = *f.MaxRequestBody
|
|
}
|
|
if f.SessionIdleTTL != nil {
|
|
if d, err := time.ParseDuration(*f.SessionIdleTTL); err == nil {
|
|
c.SessionIdleTTL = d
|
|
}
|
|
}
|
|
if f.SessionAbsoluteTTL != nil {
|
|
if d, err := time.ParseDuration(*f.SessionAbsoluteTTL); err == nil {
|
|
c.SessionAbsoluteTTL = d
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Config) applyEnv(lookup func(string) (string, bool)) error {
|
|
str := func(key string, dst *string) {
|
|
if v, ok := lookup("NOEMA_" + key); ok {
|
|
*dst = v
|
|
}
|
|
}
|
|
var errs []error
|
|
integer := func(key string, dst *int) {
|
|
if v, ok := lookup("NOEMA_" + key); ok {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("NOEMA_%s: %w", key, err))
|
|
return
|
|
}
|
|
*dst = n
|
|
}
|
|
}
|
|
integer64 := func(key string, dst *int64) {
|
|
if v, ok := lookup("NOEMA_" + key); ok {
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("NOEMA_%s: %w", key, err))
|
|
return
|
|
}
|
|
*dst = n
|
|
}
|
|
}
|
|
dur := func(key string, dst *time.Duration) {
|
|
if v, ok := lookup("NOEMA_" + key); ok {
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("NOEMA_%s: %w", key, err))
|
|
return
|
|
}
|
|
*dst = d
|
|
}
|
|
}
|
|
str("HTTP_ADDR", &c.HTTPAddr)
|
|
str("ENV", &c.Env)
|
|
str("LOG_LEVEL", &c.LogLevel)
|
|
str("LOG_FORMAT", &c.LogFormat)
|
|
str("DATABASE_URL", &c.DatabaseURL)
|
|
str("MASTER_KEY", &c.MasterKey)
|
|
str("BOOTSTRAP_ADMIN_USER", &c.BootstrapAdminUser)
|
|
str("BOOTSTRAP_ADMIN_PASSWORD", &c.BootstrapAdminPassword)
|
|
str("RESEARCH_DIR", &c.ResearchDir)
|
|
integer("MAX_WORKERS", &c.MaxWorkers)
|
|
integer("MAX_CONCURRENT_LLM", &c.MaxConcurrentLLM)
|
|
integer64("DAILY_TOKEN_BUDGET", &c.DailyTokenBudget)
|
|
integer64("MAX_REQUEST_BODY", &c.MaxRequestBody)
|
|
dur("SESSION_IDLE_TTL", &c.SessionIdleTTL)
|
|
dur("SESSION_ABSOLUTE_TTL", &c.SessionAbsoluteTTL)
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// Validate checks invariants that would otherwise fail later and less clearly.
|
|
func (c Config) Validate() error {
|
|
var errs []error
|
|
if c.Env != "development" && c.Env != "production" {
|
|
errs = append(errs, fmt.Errorf("env must be development or production, got %q", c.Env))
|
|
}
|
|
switch c.LogLevel {
|
|
case "debug", "info", "warn", "error":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("log_level %q invalid", c.LogLevel))
|
|
}
|
|
if c.LogFormat != "text" && c.LogFormat != "json" {
|
|
errs = append(errs, fmt.Errorf("log_format %q invalid", c.LogFormat))
|
|
}
|
|
if c.DatabaseURL == "" {
|
|
errs = append(errs, errors.New("database_url is required"))
|
|
} else if _, err := url.Parse(c.DatabaseURL); err != nil {
|
|
errs = append(errs, fmt.Errorf("database_url: %w", err))
|
|
}
|
|
if c.MaxWorkers < 1 || c.MaxConcurrentLLM < 1 {
|
|
errs = append(errs, errors.New("max_workers and max_concurrent_llm must be >= 1"))
|
|
}
|
|
if c.SessionIdleTTL <= 0 || c.SessionAbsoluteTTL < c.SessionIdleTTL {
|
|
errs = append(errs, errors.New("session ttls must be positive and absolute >= idle"))
|
|
}
|
|
if c.MaxRequestBody < 4096 {
|
|
errs = append(errs, errors.New("max_request_body too small"))
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// Production reports whether secure-by-default production behaviour applies.
|
|
func (c Config) Production() bool { return c.Env == "production" }
|
|
|
|
// Redacted returns a copy safe for logging: secrets and DSN passwords removed.
|
|
func (c Config) Redacted() Config {
|
|
r := c
|
|
if r.MasterKey != "" {
|
|
r.MasterKey = "[set]"
|
|
}
|
|
if r.BootstrapAdminPassword != "" {
|
|
r.BootstrapAdminPassword = "[set]"
|
|
}
|
|
if u, err := url.Parse(r.DatabaseURL); err == nil && u.User != nil {
|
|
if _, has := u.User.Password(); has {
|
|
u.User = url.UserPassword(u.User.Username(), "xxxxx")
|
|
r.DatabaseURL = u.String()
|
|
}
|
|
}
|
|
return r
|
|
}
|