diff --git a/AGENTS.md b/AGENTS.md index eaf8c19..c9bfd93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,11 @@ the operator to run `homelab client add ` from an existing client. - `/opt/homelab-context/infrastructure/mesh.md` — Tailscale → Netbird state. Both meshes are accepted today; Netbird is preferred for new traffic. - `/opt/homelab-context/infrastructure/dns.md` — split-horizon DNS via - dnsmasq on LXC 124. `*.hubris.network` resolves to 192.168.x.x on the LAN - and to mesh addresses off-LAN. + Technitium on [dns (107)](../containers/107-dns.md). `*.hubris.network` + resolves to 192.168.x.x on the LAN and to mesh addresses off-LAN. - `/opt/homelab-context/operations/commands.md` — the operator's cheatsheet - for pct, caddy, dnsmasq. Use these verbs when you take actions. + for pct, caddy, DNS, and the Oikos command surface. Use these verbs when + you take actions. ## 3. The MCP server @@ -49,8 +50,15 @@ Available tools: get_service_status(service), tail_log(service, lines=200), list_lxcs(), get_lxc_state(lxc), ping_service(service) + Oikos (read-only; see OIKOS.md): + explain(service) — compact context card, cheaper than search_docs+get_page + preflight(service) — risk class, approval requirement, verification command + get_relations(entity) — ontology blast-radius query (host: or service: id) + get_change_history(entity, limit=20) — change-ledger entries + get_state_snapshot() — last scheduler Observe-pass (health, disk, drift count) + Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with -operator confirmation. +operator confirmation — see OIKOS.md's risk classes and approval flow. **When to prefer MCP over grepping the clone:** any time you need to resolve a name to an address, look up service status, or search the wiki by content. @@ -81,10 +89,14 @@ Grep is fine for browsing or when MCP is unreachable. demand using the per-client age key at `/etc/age/key.txt`. Secrets ARE available in this system — `list_my_secrets()` (MCP) shows what you can decrypt. -- **Mutations** (restart, edit configs, etc.): the `homelab` CLI's mutating - subcommands ask for confirmation. For ad-hoc work, SSH and edit directly — - but commit changes that touch tracked configs (caddy, gitea custom, - artifacto, mule-image, etc.; see `infrastructure/auto-deploy.md`). +- **Mutations** (restart, edit configs, etc.): classify against + `oikos/policy.yaml` first (`homelab decide `). + `reversible_low` actions just need the interactive confirmation prompt; + `config_mutation`/`destructive` actions are mechanically refused without + a valid `--approval-id` from `homelab approval request` — see OIKOS.md. + For ad-hoc work, SSH and edit directly — but commit changes that touch + tracked configs (caddy, gitea custom, artifacto, mule-image, etc.; see + `infrastructure/auto-deploy.md`). - **Wiki updates**: same-session rule applies to any meaningful state change this client makes. diff --git a/OIKOS.md b/OIKOS.md index 3ed9ed8..1274957 100644 --- a/OIKOS.md +++ b/OIKOS.md @@ -24,8 +24,9 @@ one pass through **Observe → Orient → Decide → Act**: 3. **Decide** — the classifier scores **risk class × blast radius × confidence** and routes: - **auto-act**: within autonomy policy, high confidence, contained radius - - **escalate**: operator approval via Matrix (✅/❌ reaction; destructive - actions additionally need a typed confirmation phrase) + - **escalate**: operator approval via Matrix (✅/❌ reaction) or the + Oikos Console's `/approvals` page (destructive actions additionally + need a typed confirmation phrase either way) - **queue**: informational — console + reports The classifier can only *lower* autonomy relative to policy, never raise it. When in doubt, escalate. @@ -80,7 +81,8 @@ the `archaeology:` section. Each transition is a runbook checklist; deprecation completes only when inbound edges reach zero. Generated views: [infrastructure/topology.md](infrastructure/topology.md) -(Mermaid, regenerated from inventory). +(Mermaid, regenerated from inventory) and the live, clickable version at +`oikos.hubris.network/graph` once the Console is deployed. ## Conventions carried forward @@ -112,10 +114,31 @@ Generated views: [infrastructure/topology.md](infrastructure/topology.md) temperature isn't probed at all yet (no confirmed sensor path on hubris/strong). DNS-vs-inventory and generic tracked-config-cleanliness drift checks are also deferred (see `oikos/drift.py` docstring). -- **Week 4**: Oikos Console (oikos.hubris.network, behind Authentik with - step-up re-auth on approvals), per-agent age-key-signed approval - requests (upgrading from Week 3's shared-HMAC), docs pass, 60/90-day - backlog. +- **Week 4**: Oikos Console v0 shipped — signals landing page, service + grid + detail, node/blast-radius view, live Mermaid graph, drift view, + approvals queue (approve/deny, destructive confirmation-phrase + enforced), daily/weekly reports. Server-rendered FastAPI + Jinja2, no + SPA build chain, tested end-to-end against live production data (see + `oikos/console/`). Deploys as a third webhook on `dtoro/Homelab-Docs` + (`/opt/oikos-console`, port :9831) — see + [oikos/console/deploy/README.md](oikos/console/deploy/README.md) for + the Caddy route and Gitea webhook registration this repo can't do for + itself. Approval grants are now single-use (a second `check_grant` call + for the same request fails even within the TTL) and already exact-bound + to request id + entity + action. + **Not shipped as originally planned:** per-agent *age-key-signed* + request authentication — age has no signing primitive (it's an + encryption-only keypair format), so "age-key-signed" wasn't + buildable as stated. The real alternative (SSH-key signing via + `ssh-keygen -Y sign`/`-Y verify`, using each host's already-provisioned + SSH key) is real and buildable, but needs SSH public keys recorded in + inventory first — not there today. Moved to the 60/90-day backlog. + Authentik step-up re-auth on the approve/deny route is documented but + needs a live Authentik instance to configure — also backlog. + Docs pass done (this file, AGENTS.md, operations/commands.md); found + and fixed two more stale references while at it (DNS section still + pointed at destroyed LXC 124/dnsmasq instead of Technitium on 107, and + a `claudio-monitor` reference that's been deprecated since 2026-06-04). ### Real drift found while building Week 3 (unresolved, needs operator action) @@ -138,3 +161,59 @@ each is a `config_mutation`/`destructive`-class decision: - Three `lifecycle-pve-id-reuse` info findings (100, 106, 107 each shared between an active host and an archaeology entry) — expected/benign ID reuse after destroy, no action needed. + +## 60/90-day backlog + +Derived from gaps observed while building the 30-day roadmap, not +guesswork. Roughly ordered by what unblocks the most: + +- **SSH-key-signed approval requests.** Replaces the design note in + Week 4: age keys can't sign (encryption-only format), so per-agent + request authentication needs `ssh-keygen -Y sign`/`-Y verify` against + each host's existing SSH key. Blocked on a schema gap: inventory + doesn't record SSH public keys today, only ports/users. First step is + populating that field on enrollment, then wiring `oikos/approve.py` to + require and verify a signature over the request payload. +- **Authentik step-up re-auth** on the Console's `/approvals` POST route + — needs a live Authentik `PromptStage`/reauth flow scoped to that path; + not configurable without a running instance to test against. +- **Prometheus provisioning** (see + [plans/2026-07-05-oikos-prometheus-lxc.md](plans/2026-07-05-oikos-prometheus-lxc.md)) + — unblocks trend signals (disk-full prediction, temp creep) and real + sparklines in the Console; investigate the undocumented `pve_id 131` + on hubris first. +- **CPU/NVMe temperature probing** in the scheduler — needs a confirmed + sensor path on hubris and strong (lm-sensors vs vendor tool) before a + real check can be written; guessing one risks a probe that silently + never fires. +- **DNS-vs-inventory drift check** — compare Technitium zone records + against `services.*.url`/`public_host`; not implemented (`oikos/drift.py` + has no Technitium API wiring yet). +- **Generic tracked-config-cleanliness drift check** — today only caddy's + `/etc/caddy` git-checkout path is hardcoded in `oikos/drift.py`; every + other service with a `config_repo` needs its local checkout path + recorded (a `mutation_path`-style field, same gap Week 1's service + contract flagged but didn't backfill) before this generalizes. +- **Per-service policy overrides** (`oikos/policy.yaml` + `service_overrides`) — schema is ready (caddy/dns already use it); + populate more as specific services turn out to need non-default risk + classes. +- **Incident timeline generator** — stitch ledger + signal history into + a single narrative for `investigations/` entries instead of writing + them by hand. +- **Secret access audit** — who-can-decrypt-what report from + `.sops.yaml` + inventory `age_pubkey`s, extending what + `oikos/drift.py`'s SOPS check already partially does. +- **Restore drills** — exercise `backs-up-to` (once populated) by + actually restoring from a backup target on a schedule, not just + checking freshness. +- **Multi-agent delegation model** — more than one agent acting + concurrently; needs the ledger's `agent` field to carry real identity + (age pubkey, not just hostname) consistently, which it mostly does + already but hasn't been stress-tested with concurrent writers. +- **Grafana** — only if the Console's own Prometheus-backed sparklines + turn out to be insufficient once Prometheus ships. +- **"Generalize later" extraction** — the original decision was personal- + first, generalize-later (see Week 1). Once patterns stabilize, extract + a config-driven Oikos core with no `hubris.network`/`hubris`/`strong` + hardcoding, so it's installable on a different homelab. diff --git a/bin/homelab b/bin/homelab index 0cf06f1..3a6d002 100755 --- a/bin/homelab +++ b/bin/homelab @@ -1862,7 +1862,7 @@ def main() -> int: ap_req.set_defaults(func=cmd_approval_request) ap_list = apsub.add_parser("list") - ap_list.add_argument("--state", choices=["pending", "approved", "denied", "expired"]) + ap_list.add_argument("--state", choices=["pending", "approved", "denied", "expired", "executed"]) ap_list.set_defaults(func=cmd_approval_list) ap_reply = apsub.add_parser("reply") diff --git a/infrastructure/auto-deploy.md b/infrastructure/auto-deploy.md index fbe87c4..d5ff553 100644 --- a/infrastructure/auto-deploy.md +++ b/infrastructure/auto-deploy.md @@ -44,8 +44,9 @@ The app repo at `/opt/` is the working tree, but the deploy tooling (`web | `dtoro/Homelab-Docs` → homelab-mcp | [apps (105)](../containers/105-apps.md) `/opt/homelab-mcp/` | B | `http://192.168.8.205:9811/deploy` | 10 | reinstalls `homelab-mcp.service` + restart | | `dtoro/Homelab-Docs` → secrets-issuance | [apps (105)](../containers/105-apps.md) `/opt/secrets-issuance/` | B | `http://192.168.8.205:9821/deploy` | 11 | reinstalls `secrets-issuance.service` + restart | | `dtoro/terminalito` | [trmnl (128)](../containers/128-trmnl.md) `/opt/terminalito/` | B | `http://192.168.8.211:9797/deploy` | 12 | reinstalls units + `systemctl restart trmnl-plugins` | +| `dtoro/Homelab-Docs` → oikos-console | [apps (105)](../containers/105-apps.md) `/opt/oikos-console/` | B | `http://192.168.8.205:9831/deploy` | (not yet registered) | reinstalls `oikos-console.service` + restart — see [oikos/console/deploy/README.md](../oikos/console/deploy/README.md) | -> Note: `dtoro/Homelab-Docs` has **two webhooks** firing on the same push. +> Note: `dtoro/Homelab-Docs` has **three webhooks** firing on the same push. > Each owns its own clone on LXC 105. They don't conflict because each > deploy.sh only touches its own service unit + venv. diff --git a/oikos/approve.py b/oikos/approve.py index 6f30c21..a10fd47 100644 --- a/oikos/approve.py +++ b/oikos/approve.py @@ -48,7 +48,7 @@ APPROVALS_DIR = REPO / "approvals" HMAC_SECRET = REPO / "secrets" / "oikos-approval-hmac.yaml" AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt")) -VALID_STATES = ("pending", "approved", "denied", "expired") +VALID_STATES = ("pending", "approved", "denied", "expired", "executed") REQUEST_TTL_HOURS = 24 GRANT_TTL_MINUTES = 15 @@ -219,12 +219,13 @@ def reply(request_id: str, decision: str, *, phrase: str | None = None, def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]: - """Verify a request carries a valid, unexpired, matching grant. Returns - (ok, reason). Callers (homelab CLI mutating commands) must call this - immediately before executing — grants are single-use in spirit (the - 60/90-day backlog adds exact command+target binding + replay - prevention; today re-checking the same still-valid grant twice is - possible, so keep grant TTLs short).""" + """Verify a request carries a valid, unexpired, matching grant, AND + consume it — a grant is exact-bound (this exact request id + entity + + action) and single-use: this call both checks and marks it "executed" + in the same step, so a second call for the same request_id fails with + "not approved" even if the grant's TTL hasn't expired yet. Callers + (homelab CLI mutating commands) must call this immediately before + executing, exactly once.""" entry = current(request_id) if entry is None: return False, "unknown approval id" @@ -238,6 +239,7 @@ def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]: expected = _sign(request_id, entity, action, entry["grant_expires"]) if not hmac.compare_digest(expected, entry.get("grant_token", "")): return False, "grant signature invalid" + _append({**entry, "ts": now_s, "state": "executed"}) return True, "ok" diff --git a/oikos/console/__init__.py b/oikos/console/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oikos/console/app.py b/oikos/console/app.py new file mode 100644 index 0000000..3615da3 --- /dev/null +++ b/oikos/console/app.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""oikos/console/app.py — Oikos Console v0. + +Read-mostly, server-rendered web UI over the same data every other Oikos +surface uses (inventory.yaml, oikos/state.json, signals/, approvals/, +ledger/). No SPA build chain — FastAPI + Jinja2, a little vanilla JS for +the approve/deny forms. Deploys to LXC 105 (apps) as its own webhook +checkout (/opt/oikos-console), reading HOMELAB_CONTEXT_DIR=/opt/homelab- +context for data, the same pattern homelab-mcp already uses — see +oikos/console/deploy/. + +Mutation surface is intentionally tiny: signal ack/resolve/mute and +approval reply (approve/deny). Nothing here executes a `homelab` command +directly — approving here does exactly what approving via Matrix does +(issues a grant token); actually running the gated action still goes +through the `homelab` CLI with that grant. + +Run locally for development: + uvicorn oikos.console.app:app --reload --port 8091 +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +# This app deploys to its own webhook checkout (/opt/oikos-console, see +# oikos/console/deploy/) SEPARATE from /opt/homelab-context, the clone +# every other Oikos surface (scheduler, bin/homelab, mcp/server.py) reads +# and writes signals/approvals/ledger data in. `oikos.*` imports MUST +# resolve to THAT copy, not this checkout's own oikos/ directory — two +# separate copies of oikos/signal.py would silently fork the data (this +# app writing to /opt/oikos-console/signals/ while the scheduler writes to +# /opt/homelab-context/signals/). Mirrors mcp/server.py's CONTEXT_DIR +# pattern exactly, for exactly this reason. +CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context")) +sys.path.insert(0, str(CONTEXT_DIR)) +from oikos import approve as oikos_approve # noqa: E402 +from oikos import gen_topology_lib # noqa: E402 +from oikos import ledger as oikos_ledger # noqa: E402 +from oikos import policy as oikos_policy # noqa: E402 +from oikos import relations as oikos_relations # noqa: E402 +from oikos import report as oikos_report # noqa: E402 +from oikos import scheduler as oikos_scheduler # noqa: E402 +from oikos import signal as oikos_signal # noqa: E402 + +app = FastAPI(title="Oikos Console") +HERE = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=str(HERE / "templates")) +app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static") + + +def _inventory() -> dict: + return gen_topology_lib.load_inventory() + + +def _commit_push(subpath: str, message: str) -> None: + """Commit + push a mutation immediately, same convention as + oikos/scheduler.py's run-scheduler.sh wrapper and bin/homelab's + _record_change(). Without this, the console's writes would sit + uncommitted in the same /opt/homelab-context clone the 5-min sync + timer pulls into — risking a conflict on the next sync.""" + try: + subprocess.run(["git", "add", subpath], check=True, cwd=CONTEXT_DIR) + if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=CONTEXT_DIR).returncode != 0: + subprocess.run(["git", "commit", "-m", message], check=True, cwd=CONTEXT_DIR) + subprocess.run(["git", "push"], check=True, cwd=CONTEXT_DIR) + except subprocess.CalledProcessError: + pass # best-effort; the write itself already succeeded locally + + +def _open_signals() -> list[dict]: + sigs = oikos_signal.list_signals() + return [s for s in sigs if s.get("state") in ("raised", "acknowledged", "acting")] + + +def _nav_counts() -> dict: + return { + "open_signals": len(_open_signals()), + "pending_approvals": len(oikos_approve.list_approvals(state="pending")), + } + + +@app.get("/", response_class=HTMLResponse) +def landing(request: Request): + open_sigs = sorted(_open_signals(), key=lambda s: ( + {"critical": 0, "warning": 1, "info": 2}.get(s.get("severity"), 3), s.get("ts", "") + )) + state = oikos_scheduler.read_state() + health_summary = None + if state: + checked = [s for s in state.get("services", {}).values() if s.get("checked")] + healthy = sum(1 for s in checked if s.get("ok")) + health_summary = {"healthy": healthy, "total": len(checked), + "as_of": state.get("generated_at")} + return templates.TemplateResponse("landing.html", { + "request": request, "nav": _nav_counts(), "signals": open_sigs, + "health_summary": health_summary, + }) + + +@app.get("/services", response_class=HTMLResponse) +def services(request: Request): + inv = _inventory() + state = oikos_scheduler.read_state() + rows = [] + for name, entry in sorted(inv.get("services", {}).items()): + if not isinstance(entry, dict): + continue + cached = (state or {}).get("services", {}).get(name, {}) + rows.append({ + "name": name, "backend": entry.get("backend"), + "url": entry.get("url") or entry.get("endpoint"), + "ok": cached.get("ok"), "checked": cached.get("checked"), + "risk_notes": entry.get("risk_notes"), + }) + return templates.TemplateResponse("services.html", { + "request": request, "nav": _nav_counts(), "services": rows, + "as_of": (state or {}).get("generated_at"), + }) + + +@app.get("/services/{name}", response_class=HTMLResponse) +def service_detail(request: Request, name: str): + inv = _inventory() + entry = inv.get("services", {}).get(name) + if entry is None: + return HTMLResponse(f"unknown service: {name}", status_code=404) + rel = oikos_relations.relations(f"service:{name}", inv) + actions = oikos_policy.safe_actions_for_service(name, entry) + history = oikos_ledger.history(f"service:{name}", limit=20) + cached = oikos_scheduler.cached_service_health(name) + return templates.TemplateResponse("service_detail.html", { + "request": request, "nav": _nav_counts(), "name": name, "entry": entry, + "relations": rel, "actions": actions, "history": history, "health": cached, + }) + + +@app.get("/nodes/{name}", response_class=HTMLResponse) +def node_detail(request: Request, name: str): + inv = _inventory() + entry = inv.get("hosts", {}).get(name) + if entry is None: + return HTMLResponse(f"unknown host: {name}", status_code=404) + rel = oikos_relations.relations(f"host:{name}", inv) + history = oikos_ledger.history(f"host:{name}", limit=20) + return templates.TemplateResponse("node_detail.html", { + "request": request, "nav": _nav_counts(), "name": name, "entry": entry, + "relations": rel, "history": history, + }) + + +@app.get("/graph", response_class=HTMLResponse) +def graph(request: Request): + inv = _inventory() + # compute_view() wraps its output in ``` fences for the markdown doc; + # strip them for browser-side mermaid.js, which wants raw diagram syntax. + view_lines = gen_topology_lib.compute_view(inv) + mermaid_src = "\n".join(line for line in view_lines if not line.startswith("```")) + return templates.TemplateResponse("graph.html", { + "request": request, "nav": _nav_counts(), "mermaid_src": mermaid_src, + }) + + +@app.get("/drift", response_class=HTMLResponse) +def drift(request: Request): + from oikos import drift as oikos_drift + findings = oikos_drift.run_all(raise_signals=False) + return templates.TemplateResponse("drift.html", { + "request": request, "nav": _nav_counts(), "findings": findings, + }) + + +@app.get("/approvals", response_class=HTMLResponse) +def approvals(request: Request): + pending = oikos_approve.list_approvals(state="pending") + return templates.TemplateResponse("approvals.html", { + "request": request, "nav": _nav_counts(), "approvals": pending, + }) + + +@app.post("/approvals/{approval_id}/reply") +def approval_reply(approval_id: str, decision: str = Form(...), phrase: str = Form("")): + # NOTE: this route is where Authentik step-up re-auth (Week-4 plan) + # belongs once deployed behind live Authentik — a forward_auth policy + # requiring fresh authentication specifically on this path, not + # something buildable/verifiable without a live Authentik instance. + # Today it's protected the same way the rest of the console is: the + # ingress-level forward-auth gate. + try: + entry = oikos_approve.reply(approval_id, decision, phrase=phrase or None, + decided_by="oikos-console") + except (ValueError, RuntimeError) as e: + return PlainTextResponse(f"error: {e}", status_code=400) + _commit_push("approvals/", f"approval: {approval_id} {entry['state']} via console") + return RedirectResponse("/approvals", status_code=303) + + +@app.post("/signals/{signal_id}/{action}") +def signal_action(signal_id: str, action: str, note: str = Form("")): + if action == "ack": + oikos_signal.acknowledge(signal_id, note or None) + elif action == "resolve": + oikos_signal.resolve(signal_id, note or None) + elif action == "mute": + oikos_signal.mute(signal_id, 24, note or None) + else: + return PlainTextResponse("unknown action", status_code=400) + _commit_push("signals/", f"signal: {signal_id} {action} via console") + return RedirectResponse("/", status_code=303) + + +@app.get("/reports/{kind}", response_class=PlainTextResponse) +def reports(kind: str): + if kind == "daily": + return oikos_report.daily_brief() + if kind == "weekly": + return oikos_report.weekly_report() + return PlainTextResponse("unknown report", status_code=404) diff --git a/oikos/console/deploy/README.md b/oikos/console/deploy/README.md new file mode 100644 index 0000000..0de4db3 --- /dev/null +++ b/oikos/console/deploy/README.md @@ -0,0 +1,66 @@ +# Oikos Console — deploy notes + +Deploys the same way `homelab-mcp` and `secrets-issuance` already do: +Shape B webhook (own checkout, own systemd units, own deploy secret) on +LXC 105 (apps), reading `HOMELAB_CONTEXT_DIR=/opt/homelab-context` for +all data. See [infrastructure/auto-deploy.md](../../../infrastructure/auto-deploy.md) +for the general pattern; webhook ids 10 (homelab-mcp, :9811) and 11 +(secrets-issuance, :9821) are the direct precedent — this is a third +webhook on `dtoro/Homelab-Docs`, port :9831. + +## One-time setup on apps (105) + +```bash +git clone https://git.hubris.network/dtoro/Homelab-Docs.git /opt/oikos-console +cd /opt/oikos-console +./oikos/console/deploy/deploy.sh # first install +./oikos/console/deploy/webhook/install.sh # generates the deploy secret, prints it +systemctl enable --now oikos-console.service oikos-console-deploy.service +``` + +Then register the Gitea webhook (`dtoro/Homelab-Docs` → Settings → +Webhooks) with the URL/secret `install.sh` printed, same as webhooks 10/11. + +## Caddy route — NOT in this repo, needs manual addition to `dtoro/caddy-conf` + +The console binds `127.0.0.1:8091` only (see `oikos-console.service` — +`ProtectSystem=strict`, no LAN listener). Caddy on LXC 121 needs a new +site block proxying to it, forward-auth gated the same way +`paperless`/other LAN-only services are (via the shared `(authentik)` +snippet referenced in +[containers/106-auth-outpost.md](../../../containers/106-auth-outpost.md)). +Confirm the exact snippet name/import syntax against the live +`dtoro/caddy-conf` repo — this is the shape, not verified against it: + +```caddyfile +oikos.hubris.network { + import (authentik) + reverse_proxy 192.168.8.205:8091 +} +``` + +Add `oikos.hubris.network` to the split-horizon DNS zone (Technitium, +LXC 107) pointing at Caddy's LAN IP, same as every other `*.hubris.network` +host. + +## Authentik step-up re-auth on approval actions — deferred, needs live Authentik + +The Week-4 plan calls for the `/approvals/{id}/reply` POST specifically +to require fresh re-authentication (not just an existing session), so a +stolen session cookie can't approve a mutation. That's an Authentik +policy binding (a `PromptStage`/reauth flow scoped to that path), which +needs a live Authentik instance to configure and test — not buildable or +verifiable from a repo checkout alone. Today the whole console (including +this route) is protected the same way every other console-with-a-forward- +auth-gate service is: the ingress-level Authentik check, not a per-action +step-up. Tracked in the 60/90-day backlog (OIKOS.md). + +## What this deploy does NOT do + +- Does not run any `homelab` command directly. Approving in the console + issues a grant token exactly like approving via Matrix would — actually + executing the gated action still goes through the `homelab` CLI on + whichever host runs it, with `--approval-id`. +- Does not touch inventory.yaml, secrets/, or anything outside + signals/ and approvals/ (both committed+pushed immediately on write, + see `oikos/console/app.py`'s `_commit_push()`). diff --git a/oikos/console/deploy/deploy.sh b/oikos/console/deploy/deploy.sh new file mode 100755 index 0000000..ac44449 --- /dev/null +++ b/oikos/console/deploy/deploy.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Install/update the Oikos Console on LXC 105 (apps). Idempotent. +# Triggered by the gitea webhook or run by hand. Mirrors mcp/deploy/deploy.sh. +set -euo pipefail + +REPO_DIR=${REPO_DIR:-/opt/oikos-console} + +cd "$REPO_DIR" +echo "[deploy] git pull" +git pull --ff-only + +echo "[deploy] ensure python venv + deps" +if [ ! -d "$REPO_DIR/.venv" ]; then + python3 -m venv "$REPO_DIR/.venv" +fi +"$REPO_DIR/.venv/bin/pip" install --quiet --upgrade pip +"$REPO_DIR/.venv/bin/pip" install --quiet \ + "fastapi==0.139.*" "starlette<1" "jinja2<4" "uvicorn" "python-multipart" pyyaml + +echo "[deploy] install systemd units" +install -m 644 oikos/console/deploy/oikos-console.service \ + /etc/systemd/system/oikos-console.service +install -m 644 oikos/console/deploy/webhook/oikos-console-deploy.service \ + /etc/systemd/system/oikos-console-deploy.service + +echo "[deploy] context clone check" +# Reads inventory/signals/ledger/approvals from the same synced clone +# every client has (see infrastructure/homelab-context.md), NOT from this +# deploy-only checkout. +if [ ! -d /opt/homelab-context/.git ]; then + echo " /opt/homelab-context is not a git clone — run bootstrap.sh first." >&2 + exit 1 +fi + +systemctl daemon-reload +if systemctl is-active --quiet oikos-console.service; then + systemctl restart oikos-console.service +fi +if systemctl is-active --quiet oikos-console-deploy.service; then + systemctl restart oikos-console-deploy.service +fi + +echo "[deploy] done" +echo "First-time enable:" +echo " systemctl enable --now oikos-console.service oikos-console-deploy.service" +echo "Webhook first-time setup (generates secret):" +echo " $REPO_DIR/oikos/console/deploy/webhook/install.sh" +echo "Caddy route (dtoro/caddy-conf, NOT this repo) still needs adding — see" +echo " oikos/console/deploy/README.md for the exact snippet." diff --git a/oikos/console/deploy/oikos-console.service b/oikos/console/deploy/oikos-console.service new file mode 100644 index 0000000..9bd82b9 --- /dev/null +++ b/oikos/console/deploy/oikos-console.service @@ -0,0 +1,24 @@ +[Unit] +Description=Oikos Console v0 (read-mostly web UI) +After=network-online.target homelab-context-sync.service +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/oikos-console +Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context +ExecStart=/opt/oikos-console/.venv/bin/uvicorn oikos.console.app:app --host 127.0.0.1 --port 8091 +Restart=on-failure +RestartSec=5 +# Stay confined. Bound to loopback only — Caddy (dtoro/caddy-conf) is the +# one that terminates TLS + forward-auth and proxies to 127.0.0.1:8091; +# this unit never listens on the LAN interface directly. +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +NoNewPrivileges=true +ReadOnlyPaths=/opt/homelab-context /opt/oikos-console +ReadWritePaths=/opt/homelab-context/signals /opt/homelab-context/approvals /opt/homelab-context/ledger /opt/homelab-context/oikos + +[Install] +WantedBy=multi-user.target diff --git a/oikos/console/deploy/webhook/install.sh b/oikos/console/deploy/webhook/install.sh new file mode 100755 index 0000000..4fce25b --- /dev/null +++ b/oikos/console/deploy/webhook/install.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# First-time setup for the Oikos Console deploy webhook. Generates a +# secret, installs the systemd unit, and starts it. Re-run is safe (won't +# regenerate the secret if one exists). Mirrors mcp/deploy/webhook/install.sh. +set -euo pipefail + +SECRET_DIR=/etc/oikos-console-deploy +SECRET=$SECRET_DIR/secret +UNIT=oikos-console-deploy.service + +install -d -m 700 "$SECRET_DIR" +if [ ! -s "$SECRET" ]; then + head -c 32 /dev/urandom | base64 > "$SECRET" + chmod 600 "$SECRET" + echo "[install] generated webhook secret at $SECRET" +fi + +systemctl daemon-reload +systemctl enable --now "$UNIT" +systemctl status "$UNIT" --no-pager | head -10 + +cat <:9831/deploy + HTTP Method: POST + Content-Type: application/json + Secret: $(cat $SECRET) + Trigger: Push events +EOF diff --git a/oikos/console/deploy/webhook/oikos-console-deploy.service b/oikos/console/deploy/webhook/oikos-console-deploy.service new file mode 100644 index 0000000..783443e --- /dev/null +++ b/oikos/console/deploy/webhook/oikos-console-deploy.service @@ -0,0 +1,13 @@ +[Unit] +Description=Gitea deploy webhook for dtoro/Homelab-Docs → Oikos Console +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/oikos-console/oikos/console/deploy/webhook/webhook.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/oikos/console/deploy/webhook/webhook.py b/oikos/console/deploy/webhook/webhook.py new file mode 100644 index 0000000..679f1ce --- /dev/null +++ b/oikos/console/deploy/webhook/webhook.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Deploy webhook for dtoro/Homelab-Docs -> Oikos Console, on LXC 105 (apps). + +Listens on 0.0.0.0:9831/deploy. Validates Gitea HMAC, runs deploy.sh. +Port numbering follows the existing convention for this repo's two +webhook targets (homelab-mcp=9811, secrets-issuance=9821): 9831. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +BIND_HOST = "0.0.0.0" +BIND_PORT = 9831 +SECRET_PATH = "/etc/oikos-console-deploy/secret" +DEPLOY_CMD = ["/opt/oikos-console/oikos/console/deploy/deploy.sh"] +TARGET_REF = "refs/heads/main" + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("oikos-console-deploy") + +_deploy_lock = threading.Lock() + + +def load_secret() -> bytes: + with open(SECRET_PATH, "rb") as f: + return f.read().strip() + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + log.info("%s - %s", self.address_string(), fmt % args) + + def _reply(self, status: int, msg: str = "") -> None: + self.send_response(status) + self.send_header("Content-Type", "text/plain") + self.end_headers() + if msg: + self.wfile.write(msg.encode()) + + def do_POST(self) -> None: + if self.path != "/deploy": + self._reply(404, "not found") + return + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) if length else b"" + sig_header = self.headers.get("X-Gitea-Signature", "") + secret = load_secret() + expected = hmac.new(secret, body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, sig_header): + log.warning("signature mismatch") + self._reply(403, "bad signature") + return + try: + payload = json.loads(body) + except json.JSONDecodeError: + self._reply(400, "bad json") + return + if payload.get("ref", "") != TARGET_REF: + self._reply(204) + return + if not _deploy_lock.acquire(blocking=False): + self._reply(202, "already running") + return + try: + log.info("running deploy: %s", DEPLOY_CMD) + result = subprocess.run(DEPLOY_CMD, capture_output=True, text=True, timeout=180) + if result.returncode != 0: + log.error("deploy failed: %s\n%s", result.stdout, result.stderr) + self._reply(500, "deploy failed") + return + self._reply(204) + finally: + _deploy_lock.release() + + +def main() -> int: + if not os.path.exists(SECRET_PATH): + log.error("secret file missing: %s", SECRET_PATH) + return 1 + server = HTTPServer((BIND_HOST, BIND_PORT), Handler) + log.info("listening on %s:%d", BIND_HOST, BIND_PORT) + server.serve_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/oikos/console/static/style.css b/oikos/console/static/style.css new file mode 100644 index 0000000..6d5c580 --- /dev/null +++ b/oikos/console/static/style.css @@ -0,0 +1,124 @@ +:root { + --bg: #0f1115; + --panel: #171a21; + --border: #262b36; + --text: #e6e8ec; + --muted: #8b93a7; + --accent: #5b8cff; + --ok: #35c47a; + --warn: #e0a638; + --crit: #e05a5a; + --info: #5b8cff; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.5 -apple-system, "Segoe UI", Roboto, sans-serif; +} + +nav { + display: flex; + align-items: center; + gap: 1.25rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + border-bottom: 1px solid var(--border); +} + +nav a { + color: var(--muted); + text-decoration: none; + font-size: 13px; +} + +nav a:hover { color: var(--text); } + +nav .brand { + color: var(--text); + font-weight: 600; + font-size: 15px; + margin-right: 0.5rem; +} + +main { + max-width: 1100px; + margin: 0 auto; + padding: 1.5rem; +} + +h1 { font-size: 1.3rem; margin: 0 0 1rem; } +h2 { font-size: 1rem; color: var(--muted); margin: 1.5rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.04em; } + +.badge { + display: inline-block; + background: var(--accent); + color: #fff; + border-radius: 10px; + padding: 0 6px; + font-size: 11px; + font-weight: 600; +} +.badge-warn { background: var(--warn); } +.badge-crit { background: var(--crit); } + +table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; } +th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; } +tr:hover { background: rgba(255,255,255,0.02); } + +a.link { color: var(--accent); text-decoration: none; } +a.link:hover { text-decoration: underline; } + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; } +.dot-ok { background: var(--ok); } +.dot-warning { background: var(--warn); } +.dot-critical { background: var(--crit); } +.dot-info { background: var(--info); } +.dot-unknown { background: var(--muted); } + +.muted { color: var(--muted); } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } + +.pill { + display: inline-block; + border: 1px solid var(--border); + border-radius: 12px; + padding: 1px 8px; + font-size: 11.5px; + color: var(--muted); +} + +form.inline { display: inline; } +button { + background: var(--accent); + color: #fff; + border: none; + border-radius: 5px; + padding: 0.3rem 0.7rem; + font-size: 12.5px; + cursor: pointer; +} +button.deny { background: var(--crit); } +button.secondary { background: transparent; border: 1px solid var(--border); color: var(--muted); } + +pre.report { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + white-space: pre-wrap; +} + +.empty { color: var(--muted); padding: 2rem 0; text-align: center; } diff --git a/oikos/console/templates/approvals.html b/oikos/console/templates/approvals.html new file mode 100644 index 0000000..37782e3 --- /dev/null +++ b/oikos/console/templates/approvals.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Oikos — approvals{% endblock %} +{% block content %} +

Pending approvals

+

Mirrors what would be posted to Matrix. Approving/denying here calls the same +oikos/approve.py engine — actually executing the gated action still goes through the +homelab CLI with the resulting grant.

+ +{% if approvals %} +{% for a in approvals %} +
+

{{ a.id }} {{ a.risk }}

+

{{ a.entity }} — {{ a.action }}

+

{{ a.evidence }}

+ {% if a.verification %}

verify: {{ a.verification }}

{% endif %} +
+ + {% if a.requires_phrase %} + + {% endif %} + +
+
+ + +
+
+{% endfor %} +{% else %} +
No pending approvals.
+{% endif %} +{% endblock %} diff --git a/oikos/console/templates/base.html b/oikos/console/templates/base.html new file mode 100644 index 0000000..a9ace24 --- /dev/null +++ b/oikos/console/templates/base.html @@ -0,0 +1,23 @@ + + + + + {% block title %}Oikos{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/oikos/console/templates/drift.html b/oikos/console/templates/drift.html new file mode 100644 index 0000000..c3c5a42 --- /dev/null +++ b/oikos/console/templates/drift.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Oikos — drift{% endblock %} +{% block content %} +

Drift findings

+

Live run of oikos/drift.py detectors — not raised as Signals from here (that's the scheduler's job).

+ +{% if findings %} + + + {% for f in findings %} + + + + + + + {% endfor %} +
KindEntityEvidence
{{ f.kind }}{{ f.entity }}{{ f.evidence }}
+{% else %} +
No drift found.
+{% endif %} +{% endblock %} diff --git a/oikos/console/templates/graph.html b/oikos/console/templates/graph.html new file mode 100644 index 0000000..01c290c --- /dev/null +++ b/oikos/console/templates/graph.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Oikos — graph{% endblock %} +{% block content %} +

Topology

+

Same Mermaid source as infrastructure/topology.md, rendered live from inventory.yaml.

+
+{{ mermaid_src }} +
+ + +{% endblock %} diff --git a/oikos/console/templates/landing.html b/oikos/console/templates/landing.html new file mode 100644 index 0000000..f9fd11d --- /dev/null +++ b/oikos/console/templates/landing.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Oikos — signals{% endblock %} +{% block content %} +

What needs attention?

+ +{% if health_summary %} +

{{ health_summary.healthy }}/{{ health_summary.total }} services healthy + · as of {{ health_summary.as_of }}

+{% else %} +

no scheduler snapshot yet — has oikos-scheduler.timer run?

+{% endif %} + +{% if signals %} + + + {% for s in signals %} + + + + + + + + + {% endfor %} +
KindEntityEvidenceRaised
{{ s.kind }}{{ s.entity }}{{ s.evidence }}{{ s.ts }} +
+ +
+
+ +
+
+{% else %} +
No open signals. All quiet.
+{% endif %} +{% endblock %} diff --git a/oikos/console/templates/node_detail.html b/oikos/console/templates/node_detail.html new file mode 100644 index 0000000..5c635a2 --- /dev/null +++ b/oikos/console/templates/node_detail.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Oikos — {{ name }}{% endblock %} +{% block content %} +

{{ name }}

+ +
+

host:{{ name }} {{ entry.kind }} + {{ entry.get('state', 'active') }}

+ {% if entry.host %}

runs on: {{ entry.host }}

{% endif %} + {% if entry.role %}

role: {{ entry.role }}

{% endif %} + {% if entry.lan_ip %}

address: {{ entry.lan_ip }}

{% endif %} + {% if entry.mounts %}

mounts: {% for m in entry.mounts %}{{ m }} {% endfor %}

{% endif %} +
+ +

Blast radius

+
+

impacts: {% for e in relations.impacts %}{{ e }} {% else %}none{% endfor %}

+

affected by: {% for e in relations.affected_by %}{{ e }} {% else %}none{% endfor %}

+ {% if relations.blast_radius %} +

full blast radius: {% for e in relations.blast_radius %}{{ e }} {% endfor %}

+ {% endif %} +
+ +

Recent changes

+{% if history %} + + + {% for h in history %} + + {% endfor %} +
TimeActionRiskResult
{{ h.ts }}{{ h.action }}{{ h.risk }}{{ h.result }}
+{% else %} +

(none yet)

+{% endif %} +{% endblock %} diff --git a/oikos/console/templates/service_detail.html b/oikos/console/templates/service_detail.html new file mode 100644 index 0000000..5ad466d --- /dev/null +++ b/oikos/console/templates/service_detail.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Oikos — {{ name }}{% endblock %} +{% block content %} +

{{ name }}

+ +
+

service:{{ name }} + {% if health %} + {{ 'ok' if health.ok else 'unhealthy' }} as of {{ health.as_of }} + {% endif %} +

+ {% if entry.backend %}

backend: {{ entry.backend }}

{% endif %} + {% if entry.url or entry.endpoint %}

url: {{ entry.url or entry.endpoint }}

{% endif %} + {% if entry.doc_page %}

doc: {{ entry.doc_page }}

{% endif %} + {% if entry.config_repo %}

config repo: {{ entry.config_repo }}

{% endif %} + {% if entry.risk_notes %}

{{ entry.risk_notes }}

{% endif %} +
+ +

Blast radius

+
+

impacts: {% for e in relations.impacts %}{{ e }} {% else %}none{% endfor %}

+

affected by: {% for e in relations.affected_by %}{{ e }} {% else %}none{% endfor %}

+
+ +

Safe actions

+ + + {% for a in actions %} + + {% endfor %} +
ActionRiskApproval
{{ a.action }}{{ a.risk }}{{ a.approval }}
+ +

Recent changes

+{% if history %} + + + {% for h in history %} + + {% endfor %} +
TimeActionRiskResult
{{ h.ts }}{{ h.action }}{{ h.risk }}{{ h.result }}
+{% else %} +

(none yet)

+{% endif %} +{% endblock %} diff --git a/oikos/console/templates/services.html b/oikos/console/templates/services.html new file mode 100644 index 0000000..a3dbf67 --- /dev/null +++ b/oikos/console/templates/services.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Oikos — services{% endblock %} +{% block content %} +

Services

+{% if as_of %}

cached health as of {{ as_of }}

{% endif %} + + + + {% for s in services %} + + + + + + + + {% endfor %} +
ServiceBackendURLRisk notes
+ {% if s.checked %} + + {% else %} + + {% endif %} + {{ s.name }}{{ s.backend }}{{ s.url or "—" }}{{ s.risk_notes or "" }}
+{% endblock %} diff --git a/oikos/gen-topology.py b/oikos/gen-topology.py index 473810b..87505d8 100644 --- a/oikos/gen-topology.py +++ b/oikos/gen-topology.py @@ -36,6 +36,7 @@ except ImportError: # pragma: no cover REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO)) +from oikos import gen_topology_lib as lib # noqa: E402 from oikos import ledger as oikos_ledger # noqa: E402 from oikos import policy as oikos_policy # noqa: E402 from oikos import relations as oikos_relations # noqa: E402 @@ -49,96 +50,14 @@ BANNER = ( "\n" ) - -def node_id(name: str) -> str: - """Mermaid-safe node id.""" - return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_") - - -def guest_label(name: str, entry: dict) -> str: - pve = entry.get("pve_id") - role = entry.get("role", "") - tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \ - f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "") - ip = entry.get("lan_ip", "") - parts = [name, tag, role, ip] - return "
".join(str(p) for p in parts if p) - - -def compute_view(inv: dict) -> list[str]: - hosts = inv.get("hosts", {}) - services = inv.get("services", {}) - lines = ["```mermaid", "flowchart LR"] - - hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"} - guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")} - others = {n: e for n, e in hosts.items() - if e.get("kind") in ("workstation", "external")} - - for hv in hypervisors: - lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]') - for g, e in guests.items(): - if e.get("host") == hv: - lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') - lines.append(" end") - - # guests without a parent hypervisor recorded (e.g. rclone) - for g, e in guests.items(): - if e.get("host") not in hypervisors: - lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') - - for n, e in others.items(): - shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]" - lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}') - - # ingress: public URL -> backend (routes-to) - for svc, e in sorted(services.items()): - if not isinstance(e, dict): - continue - backend = e.get("backend") - url = e.get("url") or ( - f'https://{e["public_host"]}' if e.get("public_host") else None) - if backend and url and backend in hosts: - host = url.removeprefix("https://").removeprefix("http://") - # hypervisors are rendered as subgraphs; point edges at the subgraph id - target = node_id(backend) + ("_sub" if backend in hypervisors else "") - lines.append( - f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}') - - lines.append("```") - return lines - - -def storage_view(inv: dict) -> list[str]: - hosts = inv.get("hosts", {}) - lines = ["```mermaid", "flowchart LR"] - pools: set[str] = set() - edges: list[str] = [] - - for name, e in hosts.items(): - for mount in e.get("mounts", []): - pools.add(mount) - edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}') - - for pool in sorted(pools): - lines.append(f' {node_id(pool)}[("{pool}")]') - lines.extend(sorted(set(edges))) - lines.append("```") - return lines - - -def archaeology_table(inv: dict) -> list[str]: - arch = inv.get("archaeology", {}) - if not arch: - return [] - lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"] - entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")), - reverse=True) - for name, e in entries: - lines.append( - f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} ' - f'| {e.get("reason", "")} |') - return lines +# View/graph logic lives in oikos/gen_topology_lib.py (importable — this +# file's hyphenated name can't be). Re-exported here so existing call +# sites in this module don't need a rename. +node_id = lib.node_id +guest_label = lib.guest_label +compute_view = lib.compute_view +storage_view = lib.storage_view +archaeology_table = lib.archaeology_table def _host_card(name: str, entry: dict, inv: dict) -> str: diff --git a/oikos/gen_topology_lib.py b/oikos/gen_topology_lib.py new file mode 100644 index 0000000..84c3e12 --- /dev/null +++ b/oikos/gen_topology_lib.py @@ -0,0 +1,112 @@ +"""oikos/gen_topology_lib.py — shared Mermaid-view logic. + +Split out of oikos/gen-topology.py so it's importable (a hyphenated +filename can't be `import`ed as a module). oikos/gen-topology.py is the +CLI entrypoint that writes infrastructure/topology.md + oikos/cards/; +oikos/console/app.py imports this module directly to render the live +/graph page without shelling out. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parent.parent +INVENTORY = REPO / "inventory.yaml" + + +def load_inventory() -> dict: + return yaml.safe_load(INVENTORY.read_text()) + + +def node_id(name: str) -> str: + """Mermaid-safe node id.""" + return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_") + + +def guest_label(name: str, entry: dict) -> str: + pve = entry.get("pve_id") + role = entry.get("role", "") + tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \ + f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "") + ip = entry.get("lan_ip", "") + parts = [name, tag, role, ip] + return "
".join(str(p) for p in parts if p) + + +def compute_view(inv: dict) -> list[str]: + hosts = inv.get("hosts", {}) + services = inv.get("services", {}) + lines = ["```mermaid", "flowchart LR"] + + hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"} + guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")} + others = {n: e for n, e in hosts.items() + if e.get("kind") in ("workstation", "external")} + + for hv in hypervisors: + lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]') + for g, e in guests.items(): + if e.get("host") == hv: + lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') + lines.append(" end") + + # guests without a parent hypervisor recorded (e.g. rclone) + for g, e in guests.items(): + if e.get("host") not in hypervisors: + lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') + + for n, e in others.items(): + shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]" + lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}') + + # ingress: public URL -> backend (routes-to) + for svc, e in sorted(services.items()): + if not isinstance(e, dict): + continue + backend = e.get("backend") + url = e.get("url") or ( + f'https://{e["public_host"]}' if e.get("public_host") else None) + if backend and url and backend in hosts: + host = url.removeprefix("https://").removeprefix("http://") + # hypervisors are rendered as subgraphs; point edges at the subgraph id + target = node_id(backend) + ("_sub" if backend in hypervisors else "") + lines.append( + f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}') + + lines.append("```") + return lines + + +def storage_view(inv: dict) -> list[str]: + hosts = inv.get("hosts", {}) + lines = ["```mermaid", "flowchart LR"] + pools: set[str] = set() + edges: list[str] = [] + + for name, e in hosts.items(): + for mount in e.get("mounts", []): + pools.add(mount) + edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}') + + for pool in sorted(pools): + lines.append(f' {node_id(pool)}[("{pool}")]') + lines.extend(sorted(set(edges))) + lines.append("```") + return lines + + +def archaeology_table(inv: dict) -> list[str]: + arch = inv.get("archaeology", {}) + if not arch: + return [] + lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"] + entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")), + reverse=True) + for name, e in entries: + lines.append( + f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} ' + f'| {e.get("reason", "")} |') + return lines diff --git a/operations/commands.md b/operations/commands.md index ddd8cdc..d2821d3 100644 --- a/operations/commands.md +++ b/operations/commands.md @@ -14,7 +14,7 @@ Run from the [hubris host](../hosts/hubris.md) as root. When working from `/root | `pvesm status` | Storage pools status | | `pvesh get /nodes --output-format json` | Node summary as JSON | | `pvesh get /nodes/hubris/lxc//status/current` | Live container status | -| `pvesh get /cluster/resources --type vm --output-format json` | Bulk per-LXC CPU/mem/disk (used by [claudio-monitor](../infrastructure/monitoring.md)) | +| `pvesh get /cluster/resources --type vm --output-format json` | Bulk per-LXC CPU/mem/disk (used by the `homelab-health-watchdog` Hermes cron — see [monitoring](../infrastructure/monitoring.md); the old `claudio-monitor` this once fed is deprecated) | | `pveversion` | PVE version | | `journalctl -u pve-cluster -n 100` | PVE service logs | @@ -34,8 +34,9 @@ Run from the [hubris host](../hosts/hubris.md) as root. When working from `/root ## DNS -- Split-horizon entries: `/etc/dnsmasq.d/hubris-split.conf` on [LXC 124](../containers/124-authentik.md). Hard restart on edit: `pct exec 124 -- systemctl restart dnsmasq`. SIGHUP isn't reliable. -- Verify: `dig @192.168.8.180 +short .hubris.network`. +- Split-horizon authority: [Technitium DNS](https://technitium.com) on [dns (107)](../containers/107-dns.md) at `192.168.8.2:53`. Web UI at `http://192.168.8.2`. (Formerly dnsmasq on the now-destroyed LXC 124 — decommissioned 2026-06-04.) +- Add/edit records in the Technitium UI; the NetBird managed zone sync (`scripts/dns-sync.py` cron on 107) picks changes up within ~10 minutes. +- Verify: `dig @192.168.8.2 +short .hubris.network`. - See [DNS](../infrastructure/dns.md). ## Web access @@ -65,6 +66,22 @@ Two `homelab` subcommands wrap the common patterns; both fan out to hubris + eve PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm. +## Oikos (agent OS layer) + +See [OIKOS.md](../OIKOS.md) for the operating model. Quick reference: + +| Command | What it does | +| --- | --- | +| `homelab service explain\|health\|docs\|log\|actions\|history` | Service Console v0 — context card, cached health (`--live` to force a probe), docs, logs, safe actions + risk class, ledger history | +| `homelab node relations` | Ontology blast-radius query: what this host/service impacts, is affected by, and its full transitive blast radius | +| `homelab change preflight ` | Dry-run report before mutating: risk class, current health, config repo, verification command | +| `homelab decide ` | Decision classifier: risk × blast radius × confidence → auto-act or escalate | +| `homelab signal list\|raise\|ack\|resolve\|mute` | The attention layer — pending updates, thresholds, drift, anything needing attention | +| `homelab approval request\|list\|reply\|check` | Escalate-route grants (Matrix-delivered via Hermes, or the Oikos Console's `/approvals` page) | +| `homelab restart [--approval-id ]` | `--approval-id` is required whenever the service's risk class needs approval (e.g. `caddy`, `dns`) — refuses mechanically without a valid grant | + +Oikos Console (read-mostly dashboard): `oikos.hubris.network` once deployed — see [oikos/console/deploy/README.md](../oikos/console/deploy/README.md). + ## Related - [Hubris host](../hosts/hubris.md) - [Containers index](../containers/index.md)