Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA is no longer embedded (web/embed.go deleted); it's a standalone static build served separately (make ui / make deploy-ui). The api process adds CORS and drops the dev-open auth bypass — every route now needs a real bearer token, including SSE (?token= query param, EventSource can't set headers) and api's own /agent proxy to nomos (previously unauthenticated by omission). nomos was an unauthenticated client of api's /mcp and approval-decision endpoints; closing dev-open would have broken it, so it now sends Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api. SPA gets a runtime config module (config.ts) and a Config.svelte first-launch/reconfigure page, reachable afterwards via a "Connection" entry in the sidebar footer. Every fetch() in api.ts routes through fetchWithAuth so the same build works same-origin (browser prod, Vite dev proxy) or cross-origin (future Wails webview, remote access). Six gaps found against the plan and the live Caddy topology while implementing — documented in the plan's "Plan review" section, most notably: api's own /agent mount was never behind combinedAuth (fixed), and production's Authentik forward-auth needs a bearer-token bypass for API routes that this repo's Caddyfile.oikos reference copy now has, but the real dtoro/caddy-conf deploy does not yet. Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE query-token auth, and localStorage persistence all confirmed working in-browser. Full Go test suite and npm run build pass with no regressions against the pre-change baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
396 lines
9.7 KiB
Go
396 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/dtoro/oikos/internal/config"
|
|
"github.com/dtoro/oikos/internal/db"
|
|
"github.com/dtoro/oikos/internal/httpapi"
|
|
"github.com/dtoro/oikos/internal/knowledge"
|
|
"github.com/dtoro/oikos/internal/notifier"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/dtoro/oikos/internal/scheduler"
|
|
"github.com/dtoro/oikos/internal/secrets"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
var schedulerRunner = scheduler.RunnerForMain()
|
|
var notifierRunner = notifier.RunnerForMain()
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
role := os.Args[1]
|
|
cfg := config.FromEnv()
|
|
|
|
// Structured logging (slog)
|
|
logger := observability.NewLogger(cfg.Debug)
|
|
slog.SetDefault(logger)
|
|
|
|
slog.Info("starting oikos", "role", role, "config", cfg)
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(),
|
|
syscall.SIGTERM, syscall.SIGINT)
|
|
defer cancel()
|
|
|
|
switch role {
|
|
case "migrate":
|
|
if err := runMigrate(ctx, cfg); err != nil {
|
|
slog.Error("migrate failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "seed":
|
|
if err := runSeed(ctx, cfg); err != nil {
|
|
slog.Error("seed failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "export":
|
|
if err := runExport(ctx, cfg); err != nil {
|
|
slog.Error("export failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "api":
|
|
if err := runAPI(ctx, cfg); err != nil {
|
|
slog.Error("api failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "scheduler":
|
|
runWithPool(ctx, cfg, "scheduler", schedulerRunner)
|
|
case "notifier":
|
|
runWithPool(ctx, cfg, "notifier", notifierRunner)
|
|
case "all":
|
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
slog.Error("connect db", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
|
|
if err := pool.Migrate(ctx); err != nil {
|
|
slog.Error("migrate", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
go schedulerRunner(ctx, pool, cfg)
|
|
go notifierRunner(ctx, pool, cfg)
|
|
|
|
slog.Info("all: starting api with scheduler + notifier in background")
|
|
if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
|
|
slog.Error("api failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
case "version":
|
|
fmt.Println("oikos dev (Phase 1)")
|
|
case "secret":
|
|
runSecret(ctx, cfg)
|
|
case "help", "--help", "-h":
|
|
usage()
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown role: %s\n", role)
|
|
usage()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Println(`oikos — the homelab OS
|
|
|
|
Usage: oikos <role> [flags]
|
|
|
|
Roles:
|
|
migrate Run database migrations (forward-only, idempotent)
|
|
seed Ingest seed YAML files into the database
|
|
export Export DB state back to seed YAMLs (DR / version control)
|
|
api Run the REST + MCP API server
|
|
scheduler Run the observe loop
|
|
notifier Run the notification service (Matrix alerts)
|
|
all Run all roles in one process (dev mode)
|
|
secret Secret management (Infisical)
|
|
knowledge Convert wiki to knowledge seed (one-shot)
|
|
version Print version info
|
|
|
|
The operator interface is Nomos (MCP agent) — no CLI needed.
|
|
Environment:
|
|
OIKOS_DATABASE_URL Postgres connection string
|
|
OIKOS_API_LISTEN API listen address (default :8090)
|
|
OIKOS_ENV Environment (dev, prod)
|
|
OIKOS_DEBUG Enable verbose logging (true/1)
|
|
OIKOS_SEEDS_DIR Path to seeds directory (default: seeds)
|
|
OIKOS_MCP_BEARER_TOKEN Shared secret for MCP auth`)
|
|
}
|
|
|
|
func runMigrate(ctx context.Context, cfg config.Config) error {
|
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
|
|
slog.Info("running migrations")
|
|
if err := pool.Migrate(ctx); err != nil {
|
|
return err
|
|
}
|
|
slog.Info("migrations complete")
|
|
return nil
|
|
}
|
|
|
|
func runSeed(ctx context.Context, cfg config.Config) error {
|
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
|
|
// Ensure migrations are applied first
|
|
if err := pool.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("migrations: %w", err)
|
|
}
|
|
|
|
seedsDir := cfg.SeedsDir
|
|
if seedsDir == "" {
|
|
seedsDir = "seeds"
|
|
}
|
|
|
|
// Ingest ontology seed
|
|
ontoContent, err := os.ReadFile(seedsDir + "/ontology.yaml")
|
|
if err != nil {
|
|
return fmt.Errorf("read ontology seed: %w", err)
|
|
}
|
|
err = pool.SeedIngest(ctx, "ontology.yaml", ontoContent,
|
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
|
r, err := db.IngestOntologySeed(ctx, tx, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slog.Info("ontology ingested",
|
|
"lifecycles", r.Lifecycles,
|
|
"entity_types", r.EntityTypes,
|
|
"relationship_types", r.RelationshipTypes)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ingest inventory seed
|
|
invContent, err := os.ReadFile(seedsDir + "/inventory.yaml")
|
|
if err != nil {
|
|
return fmt.Errorf("read inventory seed: %w", err)
|
|
}
|
|
err = pool.SeedIngest(ctx, "inventory.yaml", invContent,
|
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
|
r, err := db.IngestInventorySeed(ctx, tx, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slog.Info("inventory ingested",
|
|
"entities", r.Entities,
|
|
"relationships", r.Relationships)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ingest policy seed
|
|
polContent, err := os.ReadFile(seedsDir + "/policy.yaml")
|
|
if err != nil {
|
|
return fmt.Errorf("read policy seed: %w", err)
|
|
}
|
|
err = pool.SeedIngest(ctx, "policy.yaml", polContent,
|
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
|
r, err := db.IngestPolicySeed(ctx, tx, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slog.Info("policy ingested",
|
|
"risk_classes", r.RiskClasses,
|
|
"approval_rules", r.ApprovalRules,
|
|
"autonomy_settings", r.AutonomySettings)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ingest knowledge seed (documents, investigations, runbooks)
|
|
knContent, err := os.ReadFile(seedsDir + "/knowledge.yaml")
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
slog.Info("knowledge seed not found, skipping")
|
|
} else {
|
|
return fmt.Errorf("read knowledge seed: %w", err)
|
|
}
|
|
} else {
|
|
err = pool.SeedIngest(ctx, "knowledge.yaml", knContent,
|
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
|
r, err := knowledge.Ingest(ctx, tx, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slog.Info("knowledge ingested",
|
|
"documents", r.Documents,
|
|
"investigations", r.Investigations,
|
|
"runbooks", r.Runbooks)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
slog.Info("seed ingest complete")
|
|
return nil
|
|
}
|
|
|
|
func runAPI(ctx context.Context, cfg config.Config) error {
|
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
|
|
if err := pool.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("migrations: %w", err)
|
|
}
|
|
|
|
err = httpapi.ListenAndServe(ctx, pool, cfg)
|
|
if err == http.ErrServerClosed {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func runWithPool(ctx context.Context, cfg config.Config, name string, fn func(context.Context, *db.Pool, config.Config)) {
|
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
slog.Error("connect db", "role", name, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
|
|
if err := pool.Migrate(ctx); err != nil {
|
|
slog.Error("migrate", "role", name, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fn(ctx, pool, cfg)
|
|
}
|
|
|
|
func runSecret(ctx context.Context, cfg config.Config) {
|
|
if len(os.Args) < 3 {
|
|
fmt.Fprintln(os.Stderr, "usage: oikos secret <list|migrate|export-sops>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
sub := os.Args[2]
|
|
secretsDir := cfg.SecretsDir
|
|
if secretsDir == "" {
|
|
secretsDir = "archive/secrets-sops-backup"
|
|
}
|
|
sopsBackend := secrets.NewSOPSBackend(secretsDir)
|
|
|
|
switch sub {
|
|
case "list":
|
|
keys, err := sopsBackend.List(ctx)
|
|
if err != nil {
|
|
slog.Error("secret list", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
for _, k := range keys {
|
|
fmt.Println(k)
|
|
}
|
|
|
|
case "migrate":
|
|
infCfg := secrets.InfisicalConfig{
|
|
SiteURL: cfg.InfisicalSiteURL,
|
|
ClientID: cfg.InfisicalClientID,
|
|
ClientSecret: cfg.InfisicalClientSecret,
|
|
ProjectID: cfg.InfisicalProjectID,
|
|
SecretPath: "/",
|
|
Env: cfg.InfisicalEnv,
|
|
}
|
|
if infCfg.Env == "" {
|
|
infCfg.Env = "dev"
|
|
}
|
|
if infCfg.SiteURL == "" {
|
|
fmt.Fprintln(os.Stderr, "error: OIKOS_INFISICAL_SITE_URL not set")
|
|
os.Exit(1)
|
|
}
|
|
|
|
infBackend := secrets.NewInfisicalBackend(infCfg)
|
|
keys, err := sopsBackend.List(ctx)
|
|
if err != nil {
|
|
slog.Error("migrate: read sops", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
migrated, failed := 0, 0
|
|
for _, key := range keys {
|
|
val, err := sopsBackend.Get(ctx, key)
|
|
if err != nil {
|
|
slog.Warn("migrate: skip", "key", key, "error", err)
|
|
failed++
|
|
continue
|
|
}
|
|
sKey := strings.ReplaceAll(key, "/", "_")
|
|
if err := infBackend.Set(ctx, sKey, val); err != nil {
|
|
slog.Warn("migrate: push failed", "key", sKey, "error", err)
|
|
failed++
|
|
continue
|
|
}
|
|
migrated++
|
|
fmt.Printf("migrated: %s → %s\n", key, sKey)
|
|
}
|
|
fmt.Printf("migrated %d, failed %d\n", migrated, failed)
|
|
|
|
case "export-sops":
|
|
keys, err := sopsBackend.List(ctx)
|
|
if err != nil {
|
|
slog.Error("export-sops: read", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("# SOPS DR fallback — %d keys — export date: now\n", len(keys))
|
|
fmt.Printf("# Store in a secure offline location.\n\n")
|
|
for _, k := range keys {
|
|
fmt.Printf("%s: <sops-encrypted>\n", k)
|
|
}
|
|
fmt.Printf("\n# To restore: sops -d secrets/*.yaml\n")
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown secret command: %s\n", sub)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func runExport(ctx context.Context, cfg config.Config) error {
|
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
|
|
exports, err := db.ExportToYAML(ctx, pool)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for name, content := range exports {
|
|
path := cfg.SeedsDir + "/" + name
|
|
if err := os.WriteFile(path, content, 0644); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
slog.Info("exported", "file", path, "bytes", len(content))
|
|
}
|
|
return nil
|
|
} |