diff --git a/mcp/deploy/deploy.sh b/mcp/deploy/deploy.sh deleted file mode 100755 index 1cea2a8..0000000 --- a/mcp/deploy/deploy.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Install/update homelab-mcp on LXC 105 (apps). Idempotent. -# Triggered by the gitea webhook or run by hand. -set -euo pipefail - -REPO_DIR=${REPO_DIR:-/opt/homelab-mcp} - -cd "$REPO_DIR" -echo "[deploy] git pull" -git pull --ff-only - -echo "[deploy] ensure python venv + deps" -if [ ! -d /opt/homelab-mcp/.venv ]; then - python3 -m venv /opt/homelab-mcp/.venv -fi -/opt/homelab-mcp/.venv/bin/pip install --quiet --upgrade pip -/opt/homelab-mcp/.venv/bin/pip install --quiet "mcp[cli]" pyyaml - -echo "[deploy] install systemd units" -install -m 644 mcp/deploy/homelab-mcp.service \ - /etc/systemd/system/homelab-mcp.service -install -m 644 mcp/deploy/webhook/homelab-mcp-deploy.service \ - /etc/systemd/system/homelab-mcp-deploy.service - -echo "[deploy] context clone for the MCP server" -# The MCP server reads from a local clone of Homelab-Docs at /opt/homelab-context -# (the same path every client uses). Bootstrap should already have created this; -# if not, fail loudly. -if [ ! -d /opt/homelab-context/.git ]; then - echo " /opt/homelab-context is not a git clone — run bootstrap.sh first." >&2 - exit 1 -fi - -systemctl daemon-reload -if systemctl is-active --quiet homelab-mcp.service; then - systemctl restart homelab-mcp.service -fi -if systemctl is-active --quiet homelab-mcp-deploy.service; then - systemctl restart homelab-mcp-deploy.service -fi - -echo "[deploy] done" -echo "First-time enable:" -echo " systemctl enable --now homelab-mcp.service homelab-mcp-deploy.service" -echo "Webhook first-time setup (generates secret):" -echo " $REPO_DIR/mcp/deploy/webhook/install.sh" diff --git a/mcp/deploy/homelab-mcp.service b/mcp/deploy/homelab-mcp.service deleted file mode 100644 index 6bb80ec..0000000 --- a/mcp/deploy/homelab-mcp.service +++ /dev/null @@ -1,29 +0,0 @@ -[Unit] -Description=Homelab MCP server (read-only context + management) -After=network-online.target homelab-context-sync.service -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=/opt/homelab-context -Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context -Environment=HOMELAB_MCP_SSH_KEY=/etc/homelab-mcp/mcp-reader.key -# SSH user is `root` on hubris — the restricted shell at command="..." in -# authorized_keys (see mcp/mcp-reader-shell) provides the access boundary, -# not a separate user account. -Environment=HOMELAB_MCP_SSH_USER=root -Environment=HOMELAB_MCP_HUBRIS_HOST=192.168.8.77 -Environment=HOMELAB_MCP_SSH_KNOWN_HOSTS=/etc/homelab-mcp/known_hosts -ExecStart=/opt/homelab-mcp/.venv/bin/python /opt/homelab-mcp/mcp/server.py -Restart=on-failure -RestartSec=5 -# Stay confined. -ProtectSystem=strict -ProtectHome=true -PrivateTmp=true -NoNewPrivileges=true -ReadOnlyPaths=/opt/homelab-context /opt/homelab-mcp -ReadWritePaths=/var/log/homelab-mcp - -[Install] -WantedBy=multi-user.target diff --git a/mcp/deploy/webhook/homelab-mcp-deploy.service b/mcp/deploy/webhook/homelab-mcp-deploy.service deleted file mode 100644 index a995a0a..0000000 --- a/mcp/deploy/webhook/homelab-mcp-deploy.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Gitea deploy webhook for dtoro/Homelab-Docs → homelab-mcp -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/bin/python3 /opt/homelab-mcp/mcp/deploy/webhook/webhook.py -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/mcp/deploy/webhook/install.sh b/mcp/deploy/webhook/install.sh deleted file mode 100755 index 8d61858..0000000 --- a/mcp/deploy/webhook/install.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -# First-time setup for the homelab-mcp deploy webhook. Generates a secret, -# installs the systemd unit, and starts it. Re-run is safe (won't regenerate -# the secret if one exists). -set -euo pipefail - -SECRET_DIR=/etc/homelab-mcp-deploy -SECRET=$SECRET_DIR/secret -UNIT=homelab-mcp-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 <:9811/deploy - HTTP Method: POST - Content-Type: application/json - Secret: $(cat $SECRET) - Trigger: Push events -EOF diff --git a/mcp/deploy/webhook/webhook.py b/mcp/deploy/webhook/webhook.py deleted file mode 100755 index a22f128..0000000 --- a/mcp/deploy/webhook/webhook.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -"""Deploy webhook for dtoro/Homelab-Docs on LXC 105 (apps). - -Listens on 0.0.0.0:9811/deploy. Validates Gitea HMAC, runs deploy.sh. -Port 9811: claudio-bot-deploy=9797, backup-library-deploy=9798, - claudio-monitor=9799, homelab-mcp=9811, secrets-issuance=9821. -""" -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 = 9811 -SECRET_PATH = "/etc/homelab-mcp-deploy/secret" -DEPLOY_CMD = ["/opt/homelab-mcp/mcp/deploy/deploy.sh"] -TARGET_REF = "refs/heads/main" - -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") -log = logging.getLogger("homelab-mcp-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/mcp/mcp-reader-shell b/mcp/mcp-reader-shell deleted file mode 100755 index 3c9d91e..0000000 --- a/mcp/mcp-reader-shell +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash -# mcp-reader-shell — restricted SSH command for the homelab-mcp service. -# -# Authorized in /root/.ssh/authorized_keys on hubris via: -# command="/usr/local/bin/mcp-reader-shell",restrict ssh-ed25519 AAAA... mcp-reader@homelab-mcp -# -# `restrict` disables PTY/agent/forwarding/X11. This wrapper then validates -# $SSH_ORIGINAL_COMMAND against a strict read-only allowlist before running -# it. Anything outside the allowlist (interactive shell, file writes, pct -# start/stop/destroy, etc.) is refused. -# -# Distributed via the homelab-context sync — symlink: -# /usr/local/bin/mcp-reader-shell -> /opt/homelab-context/mcp/mcp-reader-shell -# so updates land on the next 5-min pull without a manual re-install. -# -# Argument shapes allowed (Bash glob, after rejecting shell metacharacters): -# systemctl is-active -# systemctl is-enabled -# journalctl -u [-n N] [--no-pager] -# pct list -# pct status -# pct config -# pct exec -- systemctl is-active -# pct exec -- systemctl is-enabled -# pct exec -- journalctl -u [-n N] [--no-pager] -# -# Logs each call to syslog via `logger`. Deny entries are warnings. - -set -euo pipefail -set -f # disable glob expansion when we exec the command - -CMD="${SSH_ORIGINAL_COMMAND:-}" - -deny() { - logger -t mcp-reader -p auth.warning "DENY from=${SSH_CLIENT:-?}: ${CMD:-}" - echo "mcp-reader: command not allowed" >&2 - exit 1 -} - -if [ -z "$CMD" ]; then - deny -fi - -# Reject any shell metacharacter that would let an attacker chain or escape -# from the patterns below. -if [[ "$CMD" =~ [\;\&\|\>\<\`\$\\\(\)\{\}\*\?\~\!] ]]; then - deny -fi - -case "$CMD" in - "systemctl is-active "*|"systemctl is-enabled "*) ;; - "journalctl -u "*) ;; - "pct list") ;; - "pct status "*|"pct config "*) ;; - "pct exec "*" -- systemctl is-active "*) ;; - "pct exec "*" -- systemctl is-enabled "*) ;; - "pct exec "*" -- journalctl -u "*) ;; - *) deny ;; -esac - -logger -t mcp-reader -p auth.info "ALLOW from=${SSH_CLIENT:-?}: $CMD" -exec $CMD diff --git a/oikos/report.py b/oikos/report.py deleted file mode 100644 index 5d7e1a9..0000000 --- a/oikos/report.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -"""oikos/report.py — daily morning brief + weekly operator report. - -Both are generated text, not sent directly to Matrix — same integration -contract as oikos/approve.py: there's no dedicated Matrix bot in this -homelab, so Hermes (already posting alerts as @dtoro:avispero, see -knowledge/wiki/infrastructure/monitoring.md) is the one that actually delivers this text. -The daily brief is meant to run once a day (e.g. chained after an early -oikos-scheduler.service run, or its own systemd timer); the weekly report -is a deeper markdown review. - -Both are grounded only in what's actually implemented: signal state, -pending approvals, and change-ledger history. There's no Prometheus yet -(Week-3 backlog item pending LXC provisioning), so there are no trend -lines here — only point-in-time counts. - -CLI: - python3 oikos/report.py daily - python3 oikos/report.py weekly -""" - -from __future__ import annotations - -import sys -from datetime import datetime, timedelta, timezone -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(REPO)) -from oikos import approve as oikos_approve # noqa: E402 -from oikos import ledger as oikos_ledger # noqa: E402 -from oikos import scheduler as oikos_scheduler # noqa: E402 -from oikos import signal as oikos_signal # noqa: E402 - - -def _recent_ledger_entries(since: datetime) -> list[dict]: - since_s = since.isoformat(timespec="seconds") - out = [] - if not oikos_ledger.LEDGER_DIR.exists(): - return out - for path in sorted(oikos_ledger.LEDGER_DIR.glob("*.jsonl")): - for line in path.read_text().splitlines(): - if not line.strip(): - continue - import json - try: - e = json.loads(line) - except json.JSONDecodeError: - continue - if e.get("ts", "") >= since_s: - out.append(e) - out.sort(key=lambda e: e.get("ts", "")) - return out - - -def daily_brief() -> str: - now = datetime.now(timezone.utc) - since = now - timedelta(hours=24) - - state = oikos_scheduler.read_state() - if state is None: - health_line = "no scheduler snapshot yet — has oikos-scheduler.timer run?" - else: - svcs = state.get("services", {}) - checked = [s for s in svcs.values() if s.get("checked")] - healthy = sum(1 for s in checked if s.get("ok")) - health_line = f"{healthy}/{len(checked)} services healthy (as of {state['generated_at']})" - - open_signals = oikos_signal.list_signals() - open_signals = [s for s in open_signals if s.get("state") in ("raised", "acknowledged", "acting")] - by_sev = {"critical": 0, "warning": 0, "info": 0} - for s in open_signals: - by_sev[s.get("severity", "info")] = by_sev.get(s.get("severity", "info"), 0) + 1 - - pending = oikos_approve.list_approvals(state="pending") - changes = _recent_ledger_entries(since) - - lines = [ - f"Oikos daily brief — {now.strftime('%Y-%m-%d')}", - f"health: {health_line}", - f"signals: {by_sev['critical']} critical, {by_sev['warning']} warning, " - f"{by_sev['info']} info ({len(open_signals)} open)", - f"pending approvals: {len(pending)}", - f"changes in last 24h: {len(changes)}", - ] - if by_sev["critical"]: - lines.append("") - lines.append("Critical signals:") - for s in open_signals: - if s.get("severity") == "critical": - lines.append(f" - {s['id']} {s['entity']}: {s['evidence']}") - if pending: - lines.append("") - lines.append("Pending approvals:") - for a in pending: - lines.append(f" - {a['id']} {a['entity']} {a['action']} ({a['risk']})") - return "\n".join(lines) - - -def weekly_report() -> str: - now = datetime.now(timezone.utc) - since = now - timedelta(days=7) - changes = _recent_ledger_entries(since) - decisions = [c for c in changes if c.get("action", "").startswith("decide:")] - auto_act = sum(1 for d in decisions if d.get("result") == "auto-act") - escalate = sum(1 for d in decisions if d.get("result") == "escalate") - mutations = [c for c in changes if not c.get("action", "").startswith("decide:")] - - all_signals = oikos_signal.list_signals() - recent_signals = [s for s in all_signals if s.get("ts", "") >= since.isoformat(timespec="seconds")] - by_kind: dict[str, int] = {} - for s in recent_signals: - by_kind[s.get("kind", "?")] = by_kind.get(s.get("kind", "?"), 0) + 1 - - lines = [ - f"# Oikos weekly report — {since.strftime('%Y-%m-%d')} to {now.strftime('%Y-%m-%d')}", - "", - "## Changes", - f"- {len(mutations)} mutation(s) recorded in the change ledger", - f"- {len(decisions)} classifier decision(s): {auto_act} auto-act, {escalate} escalate", - "", - "## Signals raised this week", - ] - if by_kind: - for kind, count in sorted(by_kind.items(), key=lambda kv: -kv[1]): - lines.append(f"- {kind}: {count}") - else: - lines.append("- none") - lines += [ - "", - "## Open at time of report", - f"- {len([s for s in all_signals if s.get('state') in ('raised', 'acknowledged', 'acting')])} signal(s)", - f"- {len(oikos_approve.list_approvals(state='pending'))} pending approval(s)", - "", - "## Not yet available", - "- Trend lines (disk-full prediction, temp creep) — pending Prometheus provisioning", - "- Stale-doc detection — not yet implemented (60/90-day backlog)", - ] - return "\n".join(lines) - - -def main() -> int: - import argparse - p = argparse.ArgumentParser(description="oikos daily brief / weekly report") - p.add_argument("kind", choices=["daily", "weekly"]) - args = p.parse_args() - print(daily_brief() if args.kind == "daily" else weekly_report()) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/oikos/systemd/oikos-scheduler.service b/oikos/systemd/oikos-scheduler.service deleted file mode 100644 index 157bc85..0000000 --- a/oikos/systemd/oikos-scheduler.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Oikos observe pass: health probes, drift detectors, signal engine -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -WorkingDirectory=/opt/homelab-context -ExecStart=/usr/bin/env bash /opt/homelab-context/oikos/systemd/run-scheduler.sh -TimeoutStartSec=120 -Nice=10 - -[Install] -WantedBy=multi-user.target diff --git a/oikos/systemd/oikos-scheduler.timer b/oikos/systemd/oikos-scheduler.timer deleted file mode 100644 index 0819ebd..0000000 --- a/oikos/systemd/oikos-scheduler.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Periodic Oikos observe pass (health, drift, signals) - -[Timer] -OnBootSec=5min -OnUnitActiveSec=10min -AccuracySec=1min -Unit=oikos-scheduler.service - -[Install] -WantedBy=timers.target diff --git a/oikos/systemd/run-scheduler.sh b/oikos/systemd/run-scheduler.sh deleted file mode 100755 index 8a247bd..0000000 --- a/oikos/systemd/run-scheduler.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# Wrapper for oikos-scheduler.service: run one Observe pass, then commit + -# push signals/*.jsonl if the run raised/transitioned anything. state.json -# is intentionally NOT committed (see .gitignore) — it's regenerated every -# run with no audit value in the diff; signals/ is the tracked ledger. -set -euo pipefail -cd /opt/homelab-context - -python3 oikos/scheduler.py run - -# `git status --porcelain` (not `git diff`) so a brand-new signals/*.jsonl -# file (untracked) is caught too, not just modifications to tracked ones. -if [ -d signals ] && [ -n "$(git status --porcelain -- signals/)" ]; then - git add signals/ - if ! git diff --cached --quiet; then - git commit -m "signals: oikos-scheduler observe pass $(date -u +%Y-%m-%dT%H:%M:%SZ)" - git push - fi -fi