- Add OIDC proxy endpoints (GET config, POST token) to API server - Implement PKCE Authorization Code flow in SPA - Enable Authentik login tab in Config page - Handle callback + auto-refresh + session restore - Add restart: unless-stopped to all persistent services - Configure OIDC issuer + client_id in docker-compose
244 lines
6.9 KiB
Go
244 lines
6.9 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
|
|
|
|
// 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
|
|
}
|
|
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),
|
|
)
|
|
}
|