cleanup: reflect Go rewrite reality — remove legacy Python artifacts

Removed:
- bin/hermes (9.3MB compiled binary accidentally committed to git)
- oikos/console/ (Flask web console — replaced by Go REST API + SSE)
- mcp/server.py (Python MCP server — replaced by internal/mcp/)
- oikos/policy.yaml, oikos/ontology.yaml (duplicates of seeds/)

Kept:
- oikos/*.py kernel files (12 files) — still imported by bin/homelab
  for operational CLI commands (ssh, pct, logs, restart, status,
  open, secret, client, sync, mcp). Will be removed when bin/homelab
  is ported to Go.
- mcp/build_host_files.py — generates hosts/*.yaml from inventory,
  still operational. Will be ported to Go.
- bin/homelab — active Python CLI, still operational.

Updated:
- .gitignore: added bin/hermes, cleaned up legacy comments
- plans/index.md: listed all 4 active plans with accurate statuses
This commit is contained in:
2026-07-07 17:43:10 +02:00
parent dcd35b6315
commit c8b27b2c51
23 changed files with 13 additions and 1573 deletions

12
.gitignore vendored
View File

@@ -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.

Binary file not shown.

View File

@@ -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 <id> --`
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/<name>.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")

View File

@@ -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)

View File

@@ -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()`).

View File

@@ -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."

View File

@@ -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

View File

@@ -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 <<EOF
[install] webhook listening on :9831/deploy.
Configure Gitea (dtoro/Homelab-Docs → Settings → Webhooks → Add Webhook → Gitea):
Target URL: http://<lxc-105-mesh-ip>:9831/deploy
HTTP Method: POST
Content-Type: application/json
Secret: $(cat $SECRET)
Trigger: Push events
EOF

View File

@@ -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

View File

@@ -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())

View File

@@ -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; }

View File

@@ -1,32 +0,0 @@
{% extends "base.html" %}
{% block title %}Oikos — approvals{% endblock %}
{% block content %}
<h1>Pending approvals</h1>
<p class="muted">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
<span class="mono">homelab</span> CLI with the resulting grant.</p>
{% if approvals %}
{% for a in approvals %}
<div class="card">
<p><span class="pill">{{ a.id }}</span> <span class="pill">{{ a.risk }}</span></p>
<p><b>{{ a.entity }}</b> — {{ a.action }}</p>
<p class="muted">{{ a.evidence }}</p>
{% if a.verification %}<p class="muted mono">verify: {{ a.verification }}</p>{% endif %}
<form method="post" action="/approvals/{{ a.id }}/reply">
<input type="hidden" name="decision" value="approve">
{% if a.requires_phrase %}
<input type="text" name="phrase" placeholder="type: {{ a.confirmation_phrase }}" size="30">
{% endif %}
<button type="submit">approve</button>
</form>
<form class="inline" method="post" action="/approvals/{{ a.id }}/reply">
<input type="hidden" name="decision" value="deny">
<button class="deny" type="submit">deny</button>
</form>
</div>
{% endfor %}
{% else %}
<div class="empty">No pending approvals.</div>
{% endif %}
{% endblock %}

View File

@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{% block title %}Oikos{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<a href="/" class="brand">Oikos</a>
<a href="/">Signals{% if nav.open_signals %} <span class="badge">{{ nav.open_signals }}</span>{% endif %}</a>
<a href="/services">Services</a>
<a href="/graph">Graph</a>
<a href="/drift">Drift</a>
<a href="/approvals">Approvals{% if nav.pending_approvals %} <span class="badge badge-warn">{{ nav.pending_approvals }}</span>{% endif %}</a>
<a href="/reports/daily">Daily brief</a>
<a href="/reports/weekly">Weekly report</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block title %}Oikos — drift{% endblock %}
{% block content %}
<h1>Drift findings</h1>
<p class="muted">Live run of oikos/drift.py detectors — not raised as Signals from here (that's the scheduler's job).</p>
{% if findings %}
<table>
<tr><th></th><th>Kind</th><th>Entity</th><th>Evidence</th></tr>
{% for f in findings %}
<tr>
<td><span class="dot dot-{{ f.severity }}"></span></td>
<td>{{ f.kind }}</td>
<td class="mono">{{ f.entity }}</td>
<td>{{ f.evidence }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">No drift found.</div>
{% endif %}
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Oikos — graph{% endblock %}
{% block content %}
<h1>Topology</h1>
<p class="muted">Same Mermaid source as infrastructure/topology.md, rendered live from inventory.yaml.</p>
<div class="mermaid">
{{ mermaid_src }}
</div>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>mermaid.initialize({ startOnLoad: true, theme: "dark" });</script>
{% endblock %}

View File

@@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block title %}Oikos — signals{% endblock %}
{% block content %}
<h1>What needs attention?</h1>
{% if health_summary %}
<p class="muted">{{ health_summary.healthy }}/{{ health_summary.total }} services healthy
&middot; as of {{ health_summary.as_of }}</p>
{% else %}
<p class="muted">no scheduler snapshot yet — has oikos-scheduler.timer run?</p>
{% endif %}
{% if signals %}
<table>
<tr><th></th><th>Kind</th><th>Entity</th><th>Evidence</th><th>Raised</th><th></th></tr>
{% for s in signals %}
<tr>
<td><span class="dot dot-{{ s.severity }}"></span></td>
<td>{{ s.kind }}</td>
<td class="mono">{{ s.entity }}</td>
<td>{{ s.evidence }}</td>
<td class="muted">{{ s.ts }}</td>
<td>
<form class="inline" method="post" action="/signals/{{ s.id }}/ack">
<button class="secondary" type="submit">ack</button>
</form>
<form class="inline" method="post" action="/signals/{{ s.id }}/resolve">
<button class="secondary" type="submit">resolve</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">No open signals. All quiet.</div>
{% endif %}
{% endblock %}

View File

@@ -1,35 +0,0 @@
{% extends "base.html" %}
{% block title %}Oikos — {{ name }}{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
<div class="card">
<p><span class="pill">host:{{ name }}</span> <span class="pill">{{ entry.kind }}</span>
<span class="pill">{{ entry.get('state', 'active') }}</span></p>
{% if entry.host %}<p>runs on: <a class="link" href="/nodes/{{ entry.host }}">{{ entry.host }}</a></p>{% endif %}
{% if entry.role %}<p>role: {{ entry.role }}</p>{% endif %}
{% if entry.lan_ip %}<p>address: <span class="mono">{{ entry.lan_ip }}</span></p>{% endif %}
{% if entry.mounts %}<p>mounts: {% for m in entry.mounts %}<span class="pill">{{ m }}</span> {% endfor %}</p>{% endif %}
</div>
<h2>Blast radius</h2>
<div class="card">
<p>impacts: {% for e in relations.impacts %}<a class="pill link" href="/{{ 'services' if e.startswith('service:') else 'nodes' }}/{{ e.split(':',1)[1] }}">{{ e }}</a> {% else %}<span class="muted">none</span>{% endfor %}</p>
<p>affected by: {% for e in relations.affected_by %}<span class="pill">{{ e }}</span> {% else %}<span class="muted">none</span>{% endfor %}</p>
{% if relations.blast_radius %}
<p>full blast radius: {% for e in relations.blast_radius %}<span class="pill">{{ e }}</span> {% endfor %}</p>
{% endif %}
</div>
<h2>Recent changes</h2>
{% if history %}
<table>
<tr><th>Time</th><th>Action</th><th>Risk</th><th>Result</th></tr>
{% for h in history %}
<tr><td class="muted">{{ h.ts }}</td><td>{{ h.action }}</td><td>{{ h.risk }}</td><td>{{ h.result }}</td></tr>
{% endfor %}
</table>
{% else %}
<p class="muted">(none yet)</p>
{% endif %}
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends "base.html" %}
{% block title %}Oikos — {{ name }}{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
<div class="card">
<p><span class="pill">service:{{ name }}</span>
{% if health %}
<span class="pill">{{ 'ok' if health.ok else 'unhealthy' }} as of {{ health.as_of }}</span>
{% endif %}
</p>
{% if entry.backend %}<p>backend: <a class="link" href="/nodes/{{ entry.backend }}">{{ entry.backend }}</a></p>{% endif %}
{% if entry.url or entry.endpoint %}<p>url: <a class="link" href="{{ entry.url or entry.endpoint }}">{{ entry.url or entry.endpoint }}</a></p>{% endif %}
{% if entry.doc_page %}<p>doc: <span class="mono">{{ entry.doc_page }}</span></p>{% endif %}
{% if entry.config_repo %}<p>config repo: <span class="mono">{{ entry.config_repo }}</span></p>{% endif %}
{% if entry.risk_notes %}<p class="muted">{{ entry.risk_notes }}</p>{% endif %}
</div>
<h2>Blast radius</h2>
<div class="card">
<p>impacts: {% for e in relations.impacts %}<span class="pill">{{ e }}</span> {% else %}<span class="muted">none</span>{% endfor %}</p>
<p>affected by: {% for e in relations.affected_by %}<span class="pill">{{ e }}</span> {% else %}<span class="muted">none</span>{% endfor %}</p>
</div>
<h2>Safe actions</h2>
<table>
<tr><th>Action</th><th>Risk</th><th>Approval</th></tr>
{% for a in actions %}
<tr><td>{{ a.action }}</td><td>{{ a.risk }}</td><td>{{ a.approval }}</td></tr>
{% endfor %}
</table>
<h2>Recent changes</h2>
{% if history %}
<table>
<tr><th>Time</th><th>Action</th><th>Risk</th><th>Result</th></tr>
{% for h in history %}
<tr><td class="muted">{{ h.ts }}</td><td>{{ h.action }}</td><td>{{ h.risk }}</td><td>{{ h.result }}</td></tr>
{% endfor %}
</table>
{% else %}
<p class="muted">(none yet)</p>
{% endif %}
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Oikos — services{% endblock %}
{% block content %}
<h1>Services</h1>
{% if as_of %}<p class="muted">cached health as of {{ as_of }}</p>{% endif %}
<table>
<tr><th></th><th>Service</th><th>Backend</th><th>URL</th><th>Risk notes</th></tr>
{% for s in services %}
<tr>
<td>
{% if s.checked %}
<span class="dot {{ 'dot-ok' if s.ok else 'dot-crit' }}"></span>
{% else %}
<span class="dot dot-unknown" title="no health check configured"></span>
{% endif %}
</td>
<td><a class="link" href="/services/{{ s.name }}">{{ s.name }}</a></td>
<td class="mono">{{ s.backend }}</td>
<td class="muted">{{ s.url or "—" }}</td>
<td class="muted">{{ s.risk_notes or "" }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -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.<lxc>.host + pve_id
provides:
inverse: provided-by
example: lxc:apps provides service:homelab_mcp
source: hosts.<name>.runs + services.<svc>.backend
mounts:
inverse: mounted-by
example: lxc:jellyfin mounts /mnt/media_local from strong
source: hosts.<name>.mounts (extend with from:)
stores-on:
inverse: stores-for
example: lxc:jellyfin rootfs stores-on storage-pool:ludo-lvm
source: hosts.<name>.storage (new field)
routes-to:
inverse: routed-via
example: ingress-route:media.hubris.network routes-to service:jellyfin
source: services.<svc>.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.<svc>.auth (new field, from risk_notes/docs)
connects-via:
inverse: connects
example: workstation:mac-mini connects-via mesh:netbird
source: hosts.<name>.mesh
can-decrypt:
inverse: readable-by
example: lxc:apps can-decrypt secret:gitea-pat
source: .sops.yaml path rules + hosts.<name>.age_pubkey
configured-by:
inverse: configures
example: lxc:caddy configured-by config-repo:dtoro/caddy-conf
source: services.<svc>.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.<svc>.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]

View File

@@ -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

View File

@@ -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-<slug>.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.
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.