diff --git a/.gitignore b/.gitignore index 1337004..3797339 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,14 @@ __pycache__/ *.pyc -# Regenerated every scheduler run (every 10 min); no audit value in the -# diff. Signals (signals/*.jsonl) ARE tracked — this is just the ephemeral -# health-probe cache. See oikos/scheduler.py. +# Regenerated every scheduler run; ephemeral health-probe cache. oikos/state.json -.worktrees/bin/ +# Compiled binaries (Go rewrite — bin/oikos, bin/hermes) bin/oikos +bin/hermes + +# Legacy Python oikos (superseded by cmd/oikos Go binary — Phase 1-6 rewrite). +# oikos/ kernel files are still imported by bin/homelab for operational CLI +# commands (ssh, pct, logs, restart, status, open, secret, client, sync, mcp). +# Remove oikos/* when bin/homelab is ported to Go. diff --git a/bin/hermes b/bin/hermes deleted file mode 100755 index 86366b1..0000000 Binary files a/bin/hermes and /dev/null differ diff --git a/mcp/server.py b/mcp/server.py deleted file mode 100755 index 6b2ca2b..0000000 --- a/mcp/server.py +++ /dev/null @@ -1,411 +0,0 @@ -#!/usr/bin/env python3 -""" -Homelab MCP server. - -Reads the canonical state from /opt/homelab-context/ (a git clone of -dtoro/Homelab-Docs) and exposes structured tools to any MCP-capable agent. - -Two tool groups: - - Context (pure read of the clone, no shell-outs) - - Management (read-only ssh/pct/systemctl/curl, NO mutations) - -Mutations live in the `homelab` CLI on each client, behind operator -confirmation. The MCP server never restarts, edits, or executes arbitrary -commands. -""" - -from __future__ import annotations - -import os -import re -import shlex -import subprocess -import sys -from functools import lru_cache -from pathlib import Path -from typing import Any - -import yaml -from mcp.server.fastmcp import FastMCP # noqa: E402 — must precede the sys.path - # insert below: CONTEXT_DIR contains its - # own top-level "mcp/" directory, which - # would shadow the real `mcp` package if - # inserted first. - -CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context")) -INVENTORY = CONTEXT_DIR / "inventory.yaml" -HOSTS_DIR = CONTEXT_DIR / "hosts" -CARDS_DIR = CONTEXT_DIR / "oikos" / "cards" - -sys.path.insert(0, str(CONTEXT_DIR)) -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 -# All management tools proxy through hubris (the Proxmox host) via a single -# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on -# hubris validates each command against a strict read-only allowlist. -SSH_IDENTITY = os.environ.get("HOMELAB_MCP_SSH_KEY", "/etc/homelab-mcp/mcp-reader.key") -SSH_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "root") -SSH_KNOWN_HOSTS = os.environ.get("HOMELAB_MCP_SSH_KNOWN_HOSTS", - "/etc/homelab-mcp/known_hosts") -HUBRIS_HOST = os.environ.get("HOMELAB_MCP_HUBRIS_HOST", "192.168.8.77") -SSH_TIMEOUT = int(os.environ.get("HOMELAB_MCP_SSH_TIMEOUT", "10")) - -mcp = FastMCP("homelab") -mcp.settings.host = os.environ.get("HOMELAB_MCP_HOST", "0.0.0.0") -mcp.settings.port = int(os.environ.get("HOMELAB_MCP_PORT", "9810")) -# FastMCP's DNS-rebinding protection only whitelists 127.0.0.1 / localhost / [::1] -# by default, which breaks any LAN/mesh client. We're already mesh+LAN-gated at -# nftables and the browser-attack threat doesn't apply to mesh-only services. -mcp.settings.transport_security.enable_dns_rebinding_protection = False - - -def _load_inventory() -> dict: - if not INVENTORY.exists(): - raise RuntimeError(f"inventory not found: {INVENTORY}") - return yaml.safe_load(INVENTORY.read_text()) - - -@lru_cache(maxsize=1) -def _inventory_cache_key() -> int: - """Cache buster keyed on mtime so we re-read after each sync pull.""" - return INVENTORY.stat().st_mtime_ns - - -def inventory() -> dict: - _inventory_cache_key.cache_clear() - _ = _inventory_cache_key() # warm - return _load_inventory() - - -def _run_via_hubris(remote_host: str, cmd: list[str], - timeout: int | None = None) -> subprocess.CompletedProcess: - """SSH to hubris (single channel, restricted-shell key) and run `cmd`. - If remote_host is an LXC, the command is wrapped in `pct exec --` - so hubris executes it inside the LXC. The wrapper at /usr/local/bin/ - mcp-reader-shell on hubris validates the final command against an - allowlist before execution. - """ - inv_hosts = inventory().get("hosts", {}) - if remote_host == "hubris": - full = cmd - else: - host_entry = inv_hosts.get(remote_host) - if host_entry is None: - raise ValueError(f"unknown host: {remote_host}") - pve_id = host_entry.get("pve_id") - if pve_id is None: - raise ValueError(f"{remote_host} has no pve_id; can't pct-exec into it") - full = ["pct", "exec", str(pve_id), "--", *cmd] - # The restricted shell on hubris reads the joined command from - # $SSH_ORIGINAL_COMMAND and validates it as a single string. - joined = " ".join(full) - ssh_args = [ - "ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes", - # The systemd unit runs with ProtectHome=true so ~/.ssh is unreachable. - # Use a pre-populated known_hosts in /etc/homelab-mcp/. - "-o", f"UserKnownHostsFile={SSH_KNOWN_HOSTS}", - "-o", "StrictHostKeyChecking=yes", - "-o", f"ConnectTimeout={SSH_TIMEOUT}", - f"{SSH_USER}@{HUBRIS_HOST}", - joined, - ] - proc = subprocess.run( - ssh_args, capture_output=True, text=True, - timeout=timeout or SSH_TIMEOUT * 3, - ) - if proc.returncode != 0: - import logging as _l - _l.getLogger("homelab-mcp").warning( - "ssh failed rc=%s host=%s cmd=%r stderr=%r", - proc.returncode, remote_host, joined, proc.stderr.strip(), - ) - return proc - - -def _service_to_host(service: str) -> str: - """Resolve a service name to its backend host name.""" - svc = inventory().get("services", {}).get(service) - if not svc: - raise ValueError(f"unknown service: {service}") - return svc["backend"] - - -# --------------------------------------------------------------------------- -# Context tools -# --------------------------------------------------------------------------- - -@mcp.tool() -def get_host(name: str) -> dict: - """Return the structured record for a host (LXC, VM, workstation, or hubris).""" - inv = inventory() - entry = inv.get("hosts", {}).get(name) - if entry is None: - raise ValueError(f"unknown host: {name}") - out = {"name": name, **entry} - yaml_path = HOSTS_DIR / f"{name}.yaml" - if yaml_path.exists(): - out["host_yaml_path"] = str(yaml_path) - return out - - -@mcp.tool() -def list_services() -> dict: - """List every service registered in inventory.yaml.""" - return inventory().get("services", {}) - - -@mcp.tool() -def find_service(query: str) -> list[dict]: - """Find services by name substring, role, or backend host name.""" - q = query.lower() - inv = inventory() - out = [] - for name, entry in inv.get("services", {}).items(): - haystack = " ".join([ - name, - str(entry.get("backend", "")), - str(entry.get("role", "")), - str(entry.get("note", "")), - ]).lower() - if q in haystack: - out.append({"service": name, **entry}) - return out - - -@mcp.tool() -def get_topology() -> dict: - """Return the full inventory (hosts + services + mesh).""" - return inventory() - - -@mcp.tool() -def search_docs(query: str, max_results: int = 10) -> list[dict]: - """Ripgrep the markdown wiki for query, return file:line hits.""" - rg = subprocess.run( - ["rg", "--no-heading", "-n", "-i", "--type", "md", "-m", "5", query, str(CONTEXT_DIR)], - capture_output=True, text=True, timeout=10, - ) - results = [] - for line in rg.stdout.splitlines()[: max_results * 5]: - m = re.match(r"^(.+?):(\d+):(.*)$", line) - if m: - results.append({ - "path": str(Path(m.group(1)).relative_to(CONTEXT_DIR)), - "line": int(m.group(2)), - "text": m.group(3).strip(), - }) - if len(results) >= max_results: - break - return results - - -@mcp.tool() -def get_page(path: str) -> str: - """Return a markdown page verbatim. Path is relative to the repo root.""" - full = (CONTEXT_DIR / path).resolve() - if not str(full).startswith(str(CONTEXT_DIR.resolve())): - raise ValueError("path escapes repo") - if not full.exists(): - raise ValueError(f"no such page: {path}") - return full.read_text() - - -@mcp.tool() -def get_changelog(path: str, since: str | None = None) -> str: - """Extract the trailing ## Changelog section from a page; optionally filter.""" - text = get_page(path) - m = re.search(r"^##\s+Changelog\s*\n(.*)", text, re.MULTILINE | re.DOTALL) - if not m: - return "" - body = m.group(1).strip() - if since: - kept = [] - for entry in re.split(r"^###\s+", body, flags=re.MULTILINE): - if not entry.strip(): - continue - head = entry.split("\n", 1)[0] - date_m = re.match(r"(\d{4}-\d{2}-\d{2})", head) - if not date_m or date_m.group(1) >= since: - kept.append("### " + entry.rstrip()) - return "\n\n".join(kept).strip() - return body - - -@mcp.tool() -def whoami(hostname: str) -> dict: - """Given a hostname, return that host's full yaml record from hosts/.yaml.""" - candidate = HOSTS_DIR / f"{hostname}.yaml" - if not candidate.exists(): - raise ValueError(f"no host record for {hostname}") - return yaml.safe_load(candidate.read_text()) - - -@mcp.tool() -def explain(service: str) -> str: - """Return the compact context card for a service: identity, blast - radius, safe actions + risk class, doc pointer, recent ledger history. - Card-first — cheaper for agent orientation than search_docs + get_page. - """ - card = CARDS_DIR / f"service-{service}.md" - if not card.exists(): - raise ValueError(f"no context card for {service} — has gen-topology.py run?") - return card.read_text() - - -@mcp.tool() -def preflight(service: str) -> dict: - """Dry-run report before mutating a service: risk class, approval - requirement, current health, config repo, and the verification command - to run after the change.""" - inv_svc = inventory().get("services", {}).get(service) - if not inv_svc: - raise ValueError(f"unknown service: {service}") - risk = (oikos_policy.classify_action("tracked-config-edit", service) - if inv_svc.get("config_repo") - else oikos_policy.classify_action("service-restart", service)) or "config_mutation" - url = inv_svc.get("url") or inv_svc.get("endpoint") - return { - "service": service, - "risk_class": risk, - "approval": oikos_policy.approval_for(risk), - "config_repo": inv_svc.get("config_repo"), - "risk_notes": inv_svc.get("risk_notes"), - "verification": f"curl -sf {url}" if url else f"tail_log({service!r})", - } - - -@mcp.tool() -def get_relations(entity: str) -> list[dict]: - """Walk the ontology graph both directions for a host or service name: - what it impacts, what affects it, and its full transitive blast radius. - """ - return oikos_relations.relations_for_name(entity) - - -@mcp.tool() -def get_state_snapshot() -> dict: - """The Week-3 scheduler's last Observe-pass snapshot (service health, - host disk usage, drift-finding count, generated_at timestamp). This is - what makes cache-first reads work from ANY client, not just the one the - scheduler runs on: the CLI's local-file cache only helps on that host; - agents elsewhere should call this tool instead of assuming a local - oikos/state.json exists.""" - from oikos import scheduler as oikos_scheduler - state = oikos_scheduler.read_state() - if state is None: - raise ValueError("no scheduler snapshot yet — has oikos-scheduler.timer run?") - return state - - -@mcp.tool() -def get_change_history(entity: str, limit: int = 20) -> list[dict]: - """Ledger entries for `entity` (e.g. "service:jellyfin", "host:strong"), - newest first.""" - return oikos_ledger.history(entity, limit=limit) - - -@mcp.tool() -def list_my_secrets(caller_pubkey: str) -> list[str]: - """Return the names of secrets the caller (identified by age pubkey) can decrypt. - - Metadata only — the server never returns plaintext. The client decrypts - locally with its own /etc/age/key.txt. - """ - if not caller_pubkey: - return [] - secrets_dir = CONTEXT_DIR / "secrets" - if not secrets_dir.exists(): - return [] - out = [] - for path in secrets_dir.glob("*.yaml"): - try: - data = yaml.safe_load(path.read_text()) or {} - except yaml.YAMLError: - continue - recipients = ( - data.get("sops", {}) - .get("age", []) - ) - for r in recipients: - if r.get("recipient") == caller_pubkey: - out.append(path.stem) - break - return sorted(out) - - -# --------------------------------------------------------------------------- -# Management tools (read-only) -# --------------------------------------------------------------------------- - -@mcp.tool() -def get_service_status(service: str) -> dict: - """systemctl is-active + is-enabled for the named service on its backend host.""" - host = _service_to_host(service) - inv_svc = inventory()["services"][service] - unit = inv_svc.get("systemd_unit", service) - active = _run_via_hubris(host, ["systemctl", "is-active", unit]) - enabled = _run_via_hubris(host, ["systemctl", "is-enabled", unit]) - return { - "service": service, - "host": host, - "unit": unit, - "active": active.stdout.strip(), - "enabled": enabled.stdout.strip(), - } - - -@mcp.tool() -def tail_log(service: str, lines: int = 200) -> str: - """Last N journalctl lines for the named service on its backend host.""" - host = _service_to_host(service) - inv_svc = inventory()["services"][service] - unit = inv_svc.get("systemd_unit", service) - proc = _run_via_hubris(host, ["journalctl", "-u", unit, - "-n", str(lines), "--no-pager"]) - return proc.stdout - - -@mcp.tool() -def list_lxcs() -> str: - """Run `pct list` on hubris.""" - return _run_via_hubris("hubris", ["pct", "list"]).stdout - - -@mcp.tool() -def get_lxc_state(lxc: str) -> dict: - """`pct status` and a quick `pct config` snapshot for the named LXC.""" - inv = inventory().get("hosts", {}) - if lxc not in inv: - raise ValueError(f"unknown lxc: {lxc}") - pve_id = inv[lxc].get("pve_id") - if pve_id is None: - raise ValueError(f"{lxc} has no pve_id (is it actually an LXC?)") - status = _run_via_hubris("hubris", ["pct", "status", str(pve_id)]) - cfg = _run_via_hubris("hubris", ["pct", "config", str(pve_id)]) - return {"lxc": lxc, "pve_id": pve_id, - "status": status.stdout.strip(), - "config": cfg.stdout} - - -@mcp.tool() -def ping_service(service: str) -> dict: - """HTTP check against the service's URL (from inventory).""" - inv_svc = inventory().get("services", {}).get(service) - if not inv_svc: - raise ValueError(f"unknown service: {service}") - url = inv_svc.get("url") or inv_svc.get("endpoint") or inv_svc.get("backend_url") - if not url: - return {"service": service, "ok": False, "reason": "no URL in inventory"} - proc = subprocess.run( - ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code} %{time_total}", - "--max-time", "5", url], - capture_output=True, text=True, - ) - code, t = (proc.stdout.strip().split() + ["", ""])[:2] - return {"service": service, "url": url, "http_code": code, "time_s": t, - "ok": code.startswith("2") or code.startswith("3")} - - -if __name__ == "__main__": - mcp.run(transport="streamable-http") diff --git a/oikos/console/__init__.py b/oikos/console/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/oikos/console/app.py b/oikos/console/app.py deleted file mode 100644 index 3615da3..0000000 --- a/oikos/console/app.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/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 deleted file mode 100644 index f3e8283..0000000 --- a/oikos/console/deploy/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# 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](../../../knowledge/wiki/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. - -## Status (2026-07-06) - -- **Console deploy on apps (105): done.** Live at `/opt/oikos-console`, - both systemd units enabled and active, verified locally - (`127.0.0.1:8091` → `200`) and end-to-end - (`https://oikos.hubris.network/` → `302`, the Authentik gate firing). -- **Gitea webhook: registered but NOT currently working.** Webhook id - **14** (`http://192.168.8.205:9831/deploy`, `push` events, `main` - branch filter, active) exists, and its secret is synced correctly - between Gitea and `/etc/oikos-console-deploy/secret` on apps (rotated - once to fix a drift from an earlier partial-PATCH update) — but - deliveries still 403 with a signature mismatch for a cause not yet - found. **Until this is fixed, `git push` to `main` will NOT - auto-redeploy the console** — run `deploy.sh` manually on apps after - any change (see "One-time setup" below; it's idempotent, safe to - re-run). Debugging this further needs either a git-committed (not - ad-hoc SSH-edited) debug build of `webhook.py`, or checking Gitea's - actual signing behavior against a captured raw request — both stalled - on safety-classifier blocks around production code edits and - credential handling this session, so left for a future pass. -- **Caddy route: done.** Pushed to `dtoro/caddy-conf` (commit `c195142`), - Authentik-gated matching `paperless.hubris.network`'s pattern, reload - confirmed clean (an unrelated route stayed healthy through the reload). - **Found and fixed a real bug while wiring this up:** `oikos-console.service` - originally bound `127.0.0.1` only — since Caddy runs on a *different* - host (LXC 121), that would have made the console completely - unreachable once deployed. Now binds `0.0.0.0`, matching `homelab-mcp`'s - convention (trust boundary is LAN/mesh + the Authentik gate, not the - bind address). -- **DNS entry: done.** `oikos.hubris.network` A record added via - Technitium's REST API (login → createToken → zones/records/add, all - in one in-memory call; the session/API token was never printed or - written to disk, and wasn't persisted anywhere after the call - completed). Verified: `dig @192.168.8.2 +short oikos.hubris.network` - → `192.168.8.175`. - -## 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 a NEW secret by default — - # see "Status" above before running this -systemctl enable --now oikos-console.service oikos-console-deploy.service -``` - -## Caddy route — done (2026-07-06), in `dtoro/caddy-conf`, not this repo - -Live in `dtoro/caddy-conf` as of commit `c195142`, Authentik-gated -(confirmed syntax against the live Caddyfile: `import authentik`, no -parens in the import itself — the snippet is *defined* as `(authentik)` -but *imported* as `authentik`), same pattern as `paperless.hubris.network`: - -```caddyfile -oikos.hubris.network { - import authentik - reverse_proxy 192.168.8.205:8091 -} -``` - -Still needed: 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 — see the DNS section below. - -## 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 deleted file mode 100755 index 13381ee..0000000 --- a/oikos/console/deploy/deploy.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/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 - -# signals/ and approvals/ are untracked-when-empty (git doesn't version -# empty directories), so a fresh clone won't have them. The systemd unit's -# ReadWritePaths need these to exist before the process starts (confirmed -# the hard way on first deploy, 2026-07-06 — a missing dir here is a -# 226/NAMESPACE crash-loop, not a graceful degradation). -mkdir -p /opt/homelab-context/signals /opt/homelab-context/approvals - -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 deleted file mode 100644 index 3bd20d7..0000000 --- a/oikos/console/deploy/oikos-console.service +++ /dev/null @@ -1,32 +0,0 @@ -[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 0.0.0.0 --port 8091 -Restart=on-failure -RestartSec=5 -# Caddy runs on a DIFFERENT host (LXC 121) and reaches this over the LAN -# at 192.168.8.205:8091 (dtoro/caddy-conf's oikos.hubris.network block) — -# binding loopback-only would make this unreachable from Caddy entirely. -# Same bind convention as homelab-mcp (0.0.0.0, trust boundary enforced -# by LAN/mesh + the Authentik forward-auth gate in front, not by bind -# address). No public exposure: only reachable via LAN/mesh. -ProtectSystem=strict -ProtectHome=true -PrivateTmp=true -NoNewPrivileges=true -ReadOnlyPaths=/opt/homelab-context /opt/oikos-console -# "-" prefix marks each path optional — signals/ and approvals/ are -# untracked-when-empty (git doesn't version empty dirs), so a fresh -# /opt/homelab-context clone won't have them yet. Without "-", systemd -# refuses to start at all (226/NAMESPACE) until something else creates -# them first — confirmed the hard way on first deploy (2026-07-06). -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 deleted file mode 100755 index 4fce25b..0000000 --- a/oikos/console/deploy/webhook/install.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/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 deleted file mode 100644 index 783443e..0000000 --- a/oikos/console/deploy/webhook/oikos-console-deploy.service +++ /dev/null @@ -1,13 +0,0 @@ -[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 deleted file mode 100644 index 679f1ce..0000000 --- a/oikos/console/deploy/webhook/webhook.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/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 deleted file mode 100644 index 6d5c580..0000000 --- a/oikos/console/static/style.css +++ /dev/null @@ -1,124 +0,0 @@ -: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 deleted file mode 100644 index 37782e3..0000000 --- a/oikos/console/templates/approvals.html +++ /dev/null @@ -1,32 +0,0 @@ -{% 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 deleted file mode 100644 index a9ace24..0000000 --- a/oikos/console/templates/base.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - {% block title %}Oikos{% endblock %} - - - - -
- {% block content %}{% endblock %} -
- - diff --git a/oikos/console/templates/drift.html b/oikos/console/templates/drift.html deleted file mode 100644 index c3c5a42..0000000 --- a/oikos/console/templates/drift.html +++ /dev/null @@ -1,22 +0,0 @@ -{% 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 deleted file mode 100644 index 01c290c..0000000 --- a/oikos/console/templates/graph.html +++ /dev/null @@ -1,11 +0,0 @@ -{% 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 deleted file mode 100644 index f9fd11d..0000000 --- a/oikos/console/templates/landing.html +++ /dev/null @@ -1,37 +0,0 @@ -{% 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 deleted file mode 100644 index 5c635a2..0000000 --- a/oikos/console/templates/node_detail.html +++ /dev/null @@ -1,35 +0,0 @@ -{% 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 deleted file mode 100644 index 5ad466d..0000000 --- a/oikos/console/templates/service_detail.html +++ /dev/null @@ -1,44 +0,0 @@ -{% 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 deleted file mode 100644 index a3dbf67..0000000 --- a/oikos/console/templates/services.html +++ /dev/null @@ -1,25 +0,0 @@ -{% 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/ontology.yaml b/oikos/ontology.yaml deleted file mode 100644 index d7d8056..0000000 --- a/oikos/ontology.yaml +++ /dev/null @@ -1,137 +0,0 @@ -# Oikos ontology — the systems model of the homelab. -# -# This file defines the closed vocabulary Oikos reasons with: entity -# types (grouped into eight domains), typed relationships (with inverses), -# and the node lifecycle. inventory.yaml holds the *instances*; this file -# defines what those instances and their fields MEAN, so agents, the -# decision classifier, and the topology generator interpret them -# identically. See OIKOS.md for the operating model. -# -# Rule of completeness: if something can break, be changed, or hold data, -# it has an entity type here and edges to the things it touches. - -domains: - physical: - description: Hardware and environment. - entity_types: [site, machine, ups, sensor, peripheral] - compute: - description: Things that execute workloads. - entity_types: [proxmox-host, lxc, vm, workstation, external-host, device] - network: - description: How things reach each other. - entity_types: [lan, mesh, dns-zone, dns-record, ingress-route, certificate, firewall-rule] - storage: - description: Where data lives and how it survives. - entity_types: [storage-pool, volume, mount, backup-target, dataset] - software: - description: What runs and how it is configured and shipped. - entity_types: [service, application, config-repo, package-set, deploy-pipeline] - identity_access: - description: Who and what may do which things. - entity_types: [person, identity-provider, account, secret, key, access-grant] - operations: - description: The OS's own working objects. - entity_types: [agent, runbook, plan, change, incident, signal, approval, report] - external: - description: Dependencies outside the lab's control. - entity_types: [domain-registration, cloud-service, isp-link, vendor-dependency] - -# Relationships. `source:` says which inventory/repo data expresses the edge -# today (thin = not yet structured, derive from docs until backfilled). -relationships: - hosts: - inverse: runs-on - example: hubris hosts lxc:apps - source: hosts..host + pve_id - provides: - inverse: provided-by - example: lxc:apps provides service:homelab_mcp - source: hosts..runs + services..backend - mounts: - inverse: mounted-by - example: lxc:jellyfin mounts /mnt/media_local from strong - source: hosts..mounts (extend with from:) - stores-on: - inverse: stores-for - example: lxc:jellyfin rootfs stores-on storage-pool:ludo-lvm - source: hosts..storage (new field) - routes-to: - inverse: routed-via - example: ingress-route:media.hubris.network routes-to service:jellyfin - source: services..url/public_host + dtoro/caddy-conf - resolves-to: - inverse: resolved-from - example: dns-record:media.hubris.network resolves-to caddy lan_ip - source: Technitium split-horizon zone (LXC 107) + dns-sync job - secured-by: - inverse: secures - example: ingress-route:paperless secured-by identity-provider:authentik - source: caddy-conf forward-auth blocks + service auth notes - authenticates-via: - inverse: authenticates - example: service:jellyfin authenticates-via authentik (native OIDC) - source: services..auth (new field, from risk_notes/docs) - connects-via: - inverse: connects - example: workstation:mac-mini connects-via mesh:netbird - source: hosts..mesh - can-decrypt: - inverse: readable-by - example: lxc:apps can-decrypt secret:gitea-pat - source: .sops.yaml path rules + hosts..age_pubkey - configured-by: - inverse: configures - example: lxc:caddy configured-by config-repo:dtoro/caddy-conf - source: services..config_repo (new field) - deploys-to: - inverse: deployed-from - example: deploy-pipeline:webhook-10 deploys-to /opt/homelab-mcp on lxc:apps - source: infrastructure/auto-deploy.md table - monitors: - inverse: monitored-by - example: agent:scheduler monitors service:* (Week 3) - source: oikos/scheduler config - depends-on: - inverse: dependency-of - example: service:paperless depends-on service:authentik - source: hosts/services depends_on (new field) - backs-up-to: - inverse: backup-of - example: dataset:nextcloud-data backs-up-to backup-target:proton-drive - source: infrastructure backups docs → structured field (thin) - documents: - inverse: documented-by - example: containers/101-jellyfin.md documents lxc:jellyfin - source: generated see_also / services..doc_page - powered-by: - inverse: powers - example: machine:hubris powered-by ups (future, thin record) - source: physical domain (thin) - registered-with: - inverse: registrar-of - example: domain-registration:hubris.network registered-with registrar - source: external domain (thin) - -# Node lifecycle. Stored as `state:` on each inventory host entry -# (absent = active, for backward compatibility). Transitions are runbooks -# (Week 2); drift detectors (Week 3) verify declared state matches reality. -lifecycle: - states: [planned, provisioning, active, migrating, deprecated, destroyed] - default: active - transitions: - planned->provisioning: - requires: [inventory-entry, ip-reserved, storage-pool-chosen, doc-page-stub] - provisioning->active: - requires: [age-key-enrolled-if-needed, mesh-joined-if-needed, - ingress-live-if-public, health-check-answering, - doc-page-complete, ledger-entry] - active->migrating: - requires: [preflight, backup-verified] - migrating->active: - requires: [post-verify, caddy-backends-checked, mounts-checked, docs-updated] - active->deprecated: - requires: [replacement-live-or-role-retired] - complete_when: no inbound depends-on / routes-to edges remain - deprecated->destroyed: - requires: [backups-verified, secrets-revoked-and-rekeyed, - ingress-and-dns-removed, archaeology-entry, ledger-entry] diff --git a/oikos/policy.yaml b/oikos/policy.yaml deleted file mode 100644 index 46c3544..0000000 --- a/oikos/policy.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# Oikos risk & approval policy — machine-readable safety model. -# -# Every operation an agent can perform maps to exactly one risk class. -# The decision classifier (oikos/decide.py, Week 3) and the homelab CLI -# consult this file before executing; agents consult it before proposing. -# See OIKOS.md for the operating model. -# -# Autonomy default (operator decision 2026-07-05): unattended agents may -# perform read_only and reversible_low actions; config_mutation and -# destructive always require operator approval. - -risk_classes: - read_only: - description: Observes state; cannot change anything. - approval: none - ledger: false - reversible_low: - description: >- - Changes runtime state in a way a single follow-up command undoes - (restart, cache clear, sync pull). No config or data changes. - approval: none - ledger: true # every mutation leaves a ledger entry - config_mutation: - description: >- - Changes tracked configuration or deployed software: repo edit + push, - deploy pipeline trigger, Caddy/Gitea/app config, package upgrades. - Reversible via git, but affects other consumers. - approval: operator # Matrix ✅/❌ reaction (Week 3 approval engine) - ledger: true - destructive: - description: >- - Destroys or irreversibly alters data/entities: container destroy, - disk format, DB wipe, secret rotation, client revocation. - approval: operator_confirmed # approval + typed confirmation phrase - ledger: true - -# Lifecycle gates (see ontology.yaml lifecycle): -# provisioning: config_mutation downgraded to reversible_low (no dependents yet) -# deprecated: adding new inbound edges (depends-on/routes-to) is refused -# destroyed: any action targeting the entity raises a drift signal -lifecycle_overrides: - provisioning: - config_mutation: reversible_low - deprecated: - refuse: [new-inbound-edges] - destroyed: - refuse: [all] - -# homelab CLI subcommands → risk class -commands: - whoami: read_only - list: read_only - status: read_only - logs: read_only - open: read_only - ssh-keyscan: read_only - apt-audit: read_only - mcp: read_only # MCP tools are individually classified below - secret: read_only # decrypt-to-stdout; never write secrets to files/docs - ssh: read_only # interactive shell itself; actions inside it carry - # their own class — agents must not use raw ssh to - # bypass policy (HERMES.md convention) - sync: reversible_low - refresh-creds: reversible_low - ssh-config: reversible_low # rewrites ~/.ssh/config, regenerable - apt-upgrade: config_mutation - render-vps-configs: config_mutation - client-add: config_mutation - client-remove: destructive # revokes key + re-keys all secrets - -# MCP tools → risk class (all currently read-only by design) -mcp_tools: - get_host: read_only - list_services: read_only - find_service: read_only - get_topology: read_only - search_docs: read_only - get_page: read_only - get_changelog: read_only - whoami: read_only - get_service_status: read_only - tail_log: read_only - list_lxcs: read_only - get_lxc_state: read_only - ping_service: read_only - list_my_secrets: read_only - -# Common operational actions (not yet CLI subcommands) → risk class. -# Used by agents to classify ad-hoc work until Week 2/3 wraps them in -# `homelab service` / runbooks. -actions: - service-restart: reversible_low - cache-clear: reversible_low - docker-compose-restart: reversible_low - tracked-config-edit: config_mutation # commit+push to config repo, never local edit - deploy-webhook-trigger: config_mutation - lxc-create: config_mutation # new entity, state: provisioning - lxc-migrate: config_mutation - dns-record-change: config_mutation - ingress-route-change: config_mutation - secret-rotate: destructive - lxc-destroy: destructive - disk-format: destructive - db-wipe: destructive - storage-pool-change: destructive - -# Per-service overrides (schema ready; populate as needs emerge). -# Example: -# jellyfin: -# service-restart: reversible_low # default anyway -# caddy: -# service-restart: config_mutation # wide blast radius: all ingress -service_overrides: - caddy: - service-restart: config_mutation # everything *.hubris.network rides on it - dns: - service-restart: config_mutation # LAN-wide resolver diff --git a/plans/index.md b/plans/index.md index 09798ba..bed7eff 100644 --- a/plans/index.md +++ b/plans/index.md @@ -8,7 +8,10 @@ went sideways, open an investigation. | Date | Title | Status | | ---- | ----- | ------ | -| 2026-06-24 | [TRMNL plugins LXC (128) + middleware deploy pipeline](2026-06-24-trmnl-plugins-lxc.md) | In Progress | +| 2026-06-24 | [TRMNL plugins LXC (128) + middleware deploy pipeline](2026-06-24-trmnl-plugins-lxc.md) | Planned | +| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned | +| 2026-07-06 | [Adopt wiki-hq doc architecture](2026-07-06-adopt-wiki-hq-doc-architecture.md) | In Progress | +| 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) | In Progress (Phase 1-6 implemented, pending cutover) | ## Done @@ -26,4 +29,4 @@ See [`done/`](done/) for executed plans: - File name: `YYYY-MM-DD-.md`. Use the *target* date if known, otherwise the planning date. - Status: `Planned` → `In Progress` → `Done` (move to `done/` on completion). - When done: add a changelog entry on every affected node page, then move the file to `done/`. -- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact. \ No newline at end of file +- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.