Files
oikos/cmd/oikos/main.go
dtoro 0c0f35a3a9
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat(web): split SPA from oikos binary, require auth on every route
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>
2026-07-12 15:49:42 +02:00

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
}