Files
oikos/internal/config/config.go
dtoro fa79c1ea25
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
0.28.0 — operational hardening (plan D1–D5): CI deploy gate, versioned images, rate limiting, resource limits, health probes
D1: deploy.sh CI gate — read-only SHA via git ls-remote, Gitea commit-status
    poll, portable mkdir deploy lock (macOS, no flock), TOCTOU guard, token
    passed via curl --config - (not argv), graceful misconfig tolerance.
D2: version-tagged images — OIKOS_VERSION=v$VERSION, keep-last-3 prune derived
    from 'docker compose config --images'; VERSION read after pull.
D3: per-IP rate limiting — new internal/httpapi/ratelimit.go (x/time/rate),
    rightmost-XFF, /healthz exempt, ctx-driven sweep; disabled by default.
D4: mem_limit/cpus on all 10 compose services.
D5: staleness-aware health probes — new internal/health package wired into
    scheduler (:8093) and notifier (:8094); nomos already had :8092.

Two /review passes hardened the deploy lock, TOCTOU guard, token hygiene,
and XFF handling.
2026-08-08 21:31:16 +02:00

260 lines
7.6 KiB
Go

package config
import (
"fmt"
"log/slog"
"os"
"strings"
"time"
)
// Config holds all runtime configuration for an Oikos role.
// Hierarchy: compiled defaults → config file → env vars → Infisical (secrets only).
type Config struct {
// Database
DatabaseURL string // postgres://user:pass@host:5432/oikos?sslmode=disable
// API
APIListen string // :8090
APIEnv string // dev, prod
// Auth (Phase 2: static bearer tokens + OIDC JWT)
APIToken string // operator/CI bearer token for the REST API
MCPBearerToken string // shared secret for Nomos→API MCP calls
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
OIDCClientID string // OIDC client ID (aud claim expected in JWT)
OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients)
// CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md
// 0.3). Needed for the Wails webview and local dev (Vite on a different
// port than the API); a no-op when the SPA and API share an origin.
CORSAllowedOrigin string
// Rate limiting (plan D3). APIRateLimit is the per-IP requests/sec cap;
// APIRateBurst is the token-bucket burst (defaults to 2x the limit when
// unset). A limit of 0 disables rate limiting entirely.
APIRateLimit int
APIRateBurst int
// Health probe HTTP listener (plan D5). Background-loop roles (scheduler,
// notifier) expose a staleness-aware /healthz here. Empty disables the
// health server (local/non-docker runs).
HealthListen string
// Observability
Debug bool // verbose logging, probe payloads, SQL
// Seeds directory (for ingest/export)
SeedsDir string
// Migrations directory (embedded at build time, but path for fallback)
MigrationsDir string
// Scheduler (Phase 3)
SchedulerInterval time.Duration // check loop interval (default 30s)
// Notifier (Phase 3)
MatrixHomeserver string // Matrix server URL
MatrixUserID string // bot user ID (e.g. @oikos:matrix.hubris.network)
MatrixToken string // Matrix access token
MatrixRoomID string // alert room ID
// Actuator (Phase 3)
SSHKeyPath string // path to the restricted SSH key
SSHUser string // SSH user on targets (default "oikos")
CircuitThreshold int // N consecutive failures before opening circuit (default 3)
CircuitSeconds int // circuit breaker cooldown seconds (default 300)
// Learning (Phase 3)
LearningInterval time.Duration // pattern extraction interval (default 3600s)
// Approval HMAC secret (Phase 3)
ApprovalHMACSecret string
// Nomos agent entity ID (Phase 4)
NomosAgentID string
NomosAgentSlug string
// Infisical (Phase 5)
InfisicalSiteURL string
InfisicalClientID string
InfisicalClientSecret string
InfisicalProjectID string
InfisicalEnv string
SecretsDir string
}
// Default returns a Config with compiled defaults.
func Default() Config {
return Config{
DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable",
APIListen: ":8090",
APIEnv: "dev",
CORSAllowedOrigin: "*",
SeedsDir: "seeds",
MigrationsDir: "migrations",
SchedulerInterval: 30 * time.Second,
SSHUser: "oikos",
CircuitThreshold: 3,
CircuitSeconds: 300,
LearningInterval: 3600 * time.Second,
}
}
// FromEnv loads config from environment variables, overlaying defaults.
func FromEnv() Config {
c := Default()
if v := os.Getenv("OIKOS_DATABASE_URL"); v != "" {
c.DatabaseURL = v
}
if v := os.Getenv("OIKOS_API_LISTEN"); v != "" {
c.APIListen = v
}
if v := os.Getenv("OIKOS_ENV"); v != "" {
c.APIEnv = v
}
if v := os.Getenv("OIKOS_OIDC_ISSUER"); v != "" {
c.OIDCIssuer = v
}
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
c.OIDCClientID = v
}
if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" {
c.OIDCClientSecret = v
}
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
c.APIToken = v
}
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
c.MCPBearerToken = v
}
if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" {
c.CORSAllowedOrigin = v
}
c.APIRateLimit = parseInt(os.Getenv("OIKOS_API_RATE_LIMIT"))
c.APIRateBurst = parseInt(os.Getenv("OIKOS_API_RATE_BURST"))
if v := os.Getenv("OIKOS_HEALTH_LISTEN"); v != "" {
c.HealthListen = v
}
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
c.SeedsDir = v
}
c.Debug = os.Getenv("OIKOS_DEBUG") == "true" || os.Getenv("OIKOS_DEBUG") == "1"
// Phase 3 config
if v := os.Getenv("OIKOS_SCHEDULER_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
c.SchedulerInterval = d
}
}
if v := os.Getenv("OIKOS_MATRIX_HOMESERVER"); v != "" {
c.MatrixHomeserver = v
}
if v := os.Getenv("OIKOS_MATRIX_USER"); v != "" {
c.MatrixUserID = v
}
if v := os.Getenv("OIKOS_MATRIX_TOKEN"); v != "" {
c.MatrixToken = v
}
if v := os.Getenv("OIKOS_MATRIX_ROOM"); v != "" {
c.MatrixRoomID = v
}
if v := os.Getenv("OIKOS_SSH_KEY_PATH"); v != "" {
c.SSHKeyPath = v
}
if v := os.Getenv("OIKOS_SSH_USER"); v != "" {
c.SSHUser = v
}
if v := os.Getenv("OIKOS_CIRCUIT_THRESHOLD"); v != "" {
c.CircuitThreshold = parseInt(v)
}
if v := os.Getenv("OIKOS_CIRCUIT_SECONDS"); v != "" {
c.CircuitSeconds = parseInt(v)
}
if v := os.Getenv("OIKOS_LEARNING_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
c.LearningInterval = d
}
}
if v := os.Getenv("OIKOS_APPROVAL_HMAC_SECRET"); v != "" {
c.ApprovalHMACSecret = v
}
if v := os.Getenv("OIKOS_NOMOS_AGENT_ID"); v != "" {
c.NomosAgentID = v
}
if v := os.Getenv("OIKOS_NOMOS_AGENT_SLUG"); v != "" {
c.NomosAgentSlug = v
}
// Phase 5: Infisical secrets
if v := os.Getenv("OIKOS_INFISICAL_SITE_URL"); v != "" {
c.InfisicalSiteURL = v
}
if v := os.Getenv("OIKOS_INFISICAL_CLIENT_ID"); v != "" {
c.InfisicalClientID = v
}
if v := os.Getenv("OIKOS_INFISICAL_CLIENT_SECRET"); v != "" {
c.InfisicalClientSecret = v
}
if v := os.Getenv("OIKOS_INFISICAL_PROJECT_ID"); v != "" {
c.InfisicalProjectID = v
}
if v := os.Getenv("OIKOS_INFISICAL_ENV"); v != "" {
c.InfisicalEnv = v
}
if v := os.Getenv("OIKOS_SECRETS_DIR"); v != "" {
c.SecretsDir = v
}
return c
}
// parseInt parses a decimal integer from an env var string. Returns 0 on error.
func parseInt(s string) int {
var n int
fmt.Sscanf(s, "%d", &n)
return n
}
// redactedDBURL masks credentials in a postgres:// URL.
func (c Config) redactedDBURL() string {
dbURL := c.DatabaseURL
if i := strings.Index(dbURL, "@"); i >= 0 {
if j := strings.Index(dbURL, "://"); j >= 0 && j < i {
dbURL = dbURL[:j+3] + "***" + dbURL[i:]
}
}
return dbURL
}
// String returns a human-safe representation (secrets redacted).
func (c Config) String() string {
token := ""
if c.MCPBearerToken != "" {
token = "***"
}
return fmt.Sprintf("Config{DB=%s Listen=%s Env=%s Debug=%v MCPToken=%s SeedsDir=%s OIDCIssuer=%s OIDCClientID=%s}",
c.redactedDBURL(), c.APIListen, c.APIEnv, c.Debug, token, c.SeedsDir, c.OIDCIssuer, c.OIDCClientID)
}
// LogValue implements slog.LogValuer so structured handlers (JSON) never
// serialize raw secrets — without this, slog marshals struct fields
// directly and String() is bypassed.
func (c Config) LogValue() slog.Value {
token := ""
if c.MCPBearerToken != "" {
token = "***"
}
return slog.GroupValue(
slog.String("db", c.redactedDBURL()),
slog.String("listen", c.APIListen),
slog.String("env", c.APIEnv),
slog.Bool("debug", c.Debug),
slog.String("mcp_token", token),
slog.String("seeds_dir", c.SeedsDir),
slog.String("oidc_issuer", c.OIDCIssuer),
slog.String("oidc_client_id", c.OIDCClientID),
)
}