Phase 1: cross-client homelab context + MCP scaffolding

Add the foundation for distributing homelab context to every client
(LXCs, VMs, workstations including republic-laptop, mac-mini, ludo-mini)
with a single source of truth, structured query layer (MCP), and per-client
age-key issuance for secrets:

- inventory.yaml — canonical topology (hosts, services, mesh addresses)
- hosts/*.yaml — per-host identity files generated from inventory by
  mcp/build_host_files.py; do not edit by hand
- AGENTS.md — orientation doc symlinked to /root/AGENTS.md on every client
- bootstrap.sh — one-shot enroll (Linux + macOS), clones repo, fetches age
  key from issuance, installs sync timer/launchd job, drops the homelab CLI
- bin/homelab — single-binary Python CLI: whoami, list, ssh, pct, logs,
  restart, open, status, secret, sync, mcp, client add/remove, nuke
- mcp/server.py — FastMCP server: context tools + read-only management
  tools (no mutations exposed); shell-outs use mcp-reader restricted ssh key
- mcp/deploy/ — claudio-monitor-style gitea webhook deploy scaffold for the
  MCP service on LXC 105 (ports 9810 mcp, 9811 webhook)
- secrets-issuance/ — per-client age key auto-provisioning over the mesh;
  source-IP gated against inventory, with denylist for revoked clients
  (ports 9820 issue, 9821 webhook)
- secrets/, .sops.yaml — SOPS recipient scaffolding; the operator fills in
  age public keys after Phase 3a generates them
- scripts/sync/ — systemd timer (Linux) + launchd plist (macOS) pulling
  /opt/homelab-context every 5 min

Mesh: both Netbird (preferred, 100.122.0.0/16) and Tailscale accepted
during the in-flight migration; no client is gated on completing the move.

Plan reference: /root/.claude/plans/lets-make-a-plan-fluttering-trinket.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 15:47:48 +02:00
parent 8f598a0e7a
commit 3c25f936d3
44 changed files with 3180 additions and 0 deletions

46
mcp/deploy/deploy.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/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"

View File

@@ -0,0 +1,24 @@
[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
Environment=HOMELAB_MCP_SSH_USER=mcp-reader
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

View File

@@ -0,0 +1,13 @@
[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

31
mcp/deploy/webhook/install.sh Executable file
View File

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

95
mcp/deploy/webhook/webhook.py Executable file
View File

@@ -0,0 +1,95 @@
#!/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())