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

View File

@@ -0,0 +1,57 @@
#!/bin/bash
# Install/update secrets-issuance on LXC 105 (apps). Idempotent.
set -euo pipefail
REPO_DIR=${REPO_DIR:-/opt/secrets-issuance}
cd "$REPO_DIR"
echo "[deploy] git pull"
git pull --ff-only
echo "[deploy] ensure python venv + deps"
if [ ! -d /opt/secrets-issuance/.venv ]; then
python3 -m venv /opt/secrets-issuance/.venv
fi
/opt/secrets-issuance/.venv/bin/pip install --quiet --upgrade pip
/opt/secrets-issuance/.venv/bin/pip install --quiet pyyaml
echo "[deploy] verify dependencies (age, shred)"
command -v age-keygen >/dev/null || { echo "age-keygen not installed: apt install -y age" >&2; exit 1; }
command -v shred >/dev/null || { echo "shred not installed: apt install -y coreutils" >&2; exit 1; }
echo "[deploy] state dir"
install -d -m 700 /var/lib/secrets-issuance
install -d -m 700 /var/lib/secrets-issuance/keys
echo "[deploy] admin token dir"
install -d -m 700 /etc/secrets-issuance
if [ ! -s /etc/secrets-issuance/admin-token ]; then
head -c 32 /dev/urandom | base64 > /etc/secrets-issuance/admin-token
chmod 600 /etc/secrets-issuance/admin-token
echo "[deploy] generated admin token at /etc/secrets-issuance/admin-token"
fi
echo "[deploy] install systemd units"
install -m 644 secrets-issuance/server.service \
/etc/systemd/system/secrets-issuance.service
install -m 644 secrets-issuance/deploy/webhook/secrets-issuance-deploy.service \
/etc/systemd/system/secrets-issuance-deploy.service
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 secrets-issuance.service; then
systemctl restart secrets-issuance.service
fi
if systemctl is-active --quiet secrets-issuance-deploy.service; then
systemctl restart secrets-issuance-deploy.service
fi
echo "[deploy] done"
echo "First-time enable:"
echo " systemctl enable --now secrets-issuance.service secrets-issuance-deploy.service"
echo "Webhook first-time setup:"
echo " $REPO_DIR/secrets-issuance/deploy/webhook/install.sh"

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# First-time setup for the secrets-issuance deploy webhook. Generates a
# secret, installs the systemd unit, and starts it.
set -euo pipefail
SECRET_DIR=/etc/secrets-issuance-deploy
SECRET=$SECRET_DIR/secret
UNIT=secrets-issuance-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 :9821/deploy.
Configure Gitea (dtoro/Homelab-Docs → Settings → Webhooks → Add Webhook → Gitea):
Target URL: http://<lxc-105-mesh-ip>:9821/deploy
HTTP Method: POST
Content-Type: application/json
Secret: $(cat $SECRET)
Trigger: Push events
EOF

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Gitea deploy webhook for dtoro/Homelab-Docs → secrets-issuance
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/secrets-issuance/secrets-issuance/deploy/webhook/webhook.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Deploy webhook for dtoro/Homelab-Docs → secrets-issuance on LXC 105.
Listens on 0.0.0.0:9821/deploy. Validates Gitea HMAC, runs deploy.sh.
"""
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 = 9821
SECRET_PATH = "/etc/secrets-issuance-deploy/secret"
DEPLOY_CMD = ["/opt/secrets-issuance/secrets-issuance/deploy/deploy.sh"]
TARGET_REF = "refs/heads/main"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("secrets-issuance-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 = self.headers.get("X-Gitea-Signature", "")
secret = load_secret()
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
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:
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())

266
secrets-issuance/server.py Executable file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""
Secrets-issuance HTTP service.
Lives on LXC 105 alongside the MCP server. Mesh-bound (nftables restricts to
Netbird + Tailscale subnets). Identifies callers by source mesh IP and returns
the per-client age private key.
State:
/var/lib/secrets-issuance/keys/<hostname>.key (private, 0600 root)
/var/lib/secrets-issuance/keys/<hostname>.pub (public)
/var/lib/secrets-issuance/denylist.txt (one hostname per line)
Inventory lookup:
/opt/homelab-context/inventory.yaml — the issuance server is itself a
homelab-context client, so it sees inventory updates within 5 min.
Endpoints:
POST /issue body: {"hostname": "..."} -> raw age private key
POST /revoke body: {"hostname": "..."} -> shred key + add to denylist
(called by 'homelab client remove')
GET /health
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import secrets as sysrandom
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import yaml
BIND_HOST = "0.0.0.0"
BIND_PORT = 9820
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
STATE_DIR = Path(os.environ.get("SECRETS_ISSUANCE_STATE", "/var/lib/secrets-issuance"))
KEYS_DIR = STATE_DIR / "keys"
DENYLIST = STATE_DIR / "denylist.txt"
ADMIN_TOKEN_PATH = Path(os.environ.get("SECRETS_ISSUANCE_ADMIN_TOKEN",
"/etc/secrets-issuance/admin-token"))
MESH_SUBNETS = [
ipaddress.ip_network(s.strip())
for s in os.environ.get("MESH_SUBNETS", "100.64.0.0/10").split(",")
if s.strip()
]
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("secrets-issuance")
def load_inventory() -> dict:
return yaml.safe_load((CONTEXT_DIR / "inventory.yaml").read_text())
def known_hosts() -> dict[str, dict]:
return load_inventory().get("hosts", {})
def host_by_mesh_ip(ip: str) -> str | None:
"""Resolve a mesh source IP to a hostname per inventory."""
for name, entry in known_hosts().items():
mesh = entry.get("mesh", {})
for proto in ("netbird", "tailscale"):
p = mesh.get(proto, {})
if not isinstance(p, dict):
continue
if p.get("ip") == ip:
return name
return None
def in_mesh(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
return any(addr in subnet for subnet in MESH_SUBNETS)
def is_denied(hostname: str) -> bool:
if not DENYLIST.exists():
return False
return hostname in {line.strip() for line in DENYLIST.read_text().splitlines() if line.strip()}
def deny(hostname: str) -> None:
DENYLIST.parent.mkdir(parents=True, exist_ok=True)
existing = set()
if DENYLIST.exists():
existing = {line.strip() for line in DENYLIST.read_text().splitlines() if line.strip()}
existing.add(hostname)
DENYLIST.write_text("\n".join(sorted(existing)) + "\n")
def generate_key(hostname: str) -> tuple[Path, str]:
"""Generate an age keypair for hostname; return (priv_path, pubkey)."""
KEYS_DIR.mkdir(parents=True, exist_ok=True)
priv = KEYS_DIR / f"{hostname}.key"
pub_path = KEYS_DIR / f"{hostname}.pub"
proc = subprocess.run(["age-keygen"], capture_output=True, text=True, check=True)
priv.write_text(proc.stdout)
priv.chmod(0o600)
# Extract the public key. age-keygen writes "# public key: ageXXX" line.
pubkey = ""
for line in proc.stdout.splitlines():
if line.startswith("# public key:"):
pubkey = line.split(":", 1)[1].strip()
if not pubkey:
priv.unlink(missing_ok=True)
raise RuntimeError("age-keygen did not emit a public key")
pub_path.write_text(pubkey + "\n")
log.info("generated new age key for %s (pubkey: %s)", hostname, pubkey)
return priv, pubkey
def existing_pubkey(hostname: str) -> str | None:
pub_path = KEYS_DIR / f"{hostname}.pub"
if pub_path.exists():
return pub_path.read_text().strip()
return None
def read_admin_token() -> str | None:
if ADMIN_TOKEN_PATH.exists():
return ADMIN_TOKEN_PATH.read_text().strip()
return None
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info("%s - %s", self.address_string(), fmt % args)
def _reply(self, status: int, body: bytes | str = b"",
content_type: str = "text/plain") -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
if isinstance(body, str):
body = body.encode()
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _read_json(self) -> dict | None:
length = int(self.headers.get("Content-Length", "0"))
if not length:
return {}
body = self.rfile.read(length)
try:
return json.loads(body)
except json.JSONDecodeError:
return None
def do_GET(self) -> None:
if self.path == "/health":
self._reply(200, "ok")
return
self._reply(404, "not found")
def do_POST(self) -> None:
client_ip = self.client_address[0]
if not in_mesh(client_ip):
log.warning("rejecting non-mesh source %s", client_ip)
self._reply(403, "non-mesh source")
return
if self.path == "/issue":
self._handle_issue(client_ip)
elif self.path == "/revoke":
self._handle_revoke()
else:
self._reply(404, "not found")
def _handle_issue(self, client_ip: str) -> None:
payload = self._read_json()
if payload is None:
self._reply(400, "bad json")
return
hostname = (payload.get("hostname") or "").strip()
if not hostname:
self._reply(400, "hostname required")
return
# Cross-check: does inventory list this hostname, and does its mesh IP
# match the caller (if known)?
hosts = known_hosts()
if hostname not in hosts:
log.warning("unknown hostname in /issue: %s (from %s)", hostname, client_ip)
self._reply(403, f"unknown hostname: {hostname} (run 'homelab client add {hostname}' first)")
return
if is_denied(hostname):
log.warning("denied hostname %s tried to issue (from %s)", hostname, client_ip)
self._reply(403, "hostname on denylist (was removed); operator must clear before re-enrolling")
return
inv_ip = host_by_mesh_ip(client_ip)
if inv_ip is not None and inv_ip != hostname:
log.warning("source %s maps to inventory host %s but body claims %s",
client_ip, inv_ip, hostname)
self._reply(403, "source IP / hostname mismatch")
return
if inv_ip is None:
# Inventory has no IP for this hostname yet (first-bootstrap state).
# Accept the call but log it loudly so the operator backfills.
log.info("issuing for %s (source %s) — inventory has no mesh IP yet",
hostname, client_ip)
priv = KEYS_DIR / f"{hostname}.key"
existing_pub = existing_pubkey(hostname)
if priv.exists() and existing_pub:
# Re-issue: client lost its key but is still authorized. Return
# the existing key.
log.info("returning existing key for %s", hostname)
self._reply(200, priv.read_text())
return
# Fresh provisioning.
priv, pubkey = generate_key(hostname)
# Tell the operator (via response body comment) which pubkey to commit.
body = priv.read_text()
body += f"\n# operator: add this pubkey to inventory.yaml under hosts.{hostname}.age_pubkey:\n"
body += f"# {pubkey}\n"
self._reply(200, body)
def _handle_revoke(self) -> None:
token = self.headers.get("X-Admin-Token", "")
expected = read_admin_token()
if not expected or token != expected:
self._reply(403, "admin token required")
return
payload = self._read_json()
if payload is None:
self._reply(400, "bad json")
return
hostname = (payload.get("hostname") or "").strip()
if not hostname:
self._reply(400, "hostname required")
return
priv = KEYS_DIR / f"{hostname}.key"
pub = KEYS_DIR / f"{hostname}.pub"
for p in (priv, pub):
if p.exists():
# shred-then-unlink
subprocess.run(["shred", "-u", str(p)], check=False)
if p.exists():
p.unlink(missing_ok=True)
deny(hostname)
log.warning("revoked %s (shredded keys, added to denylist)", hostname)
self._reply(200, f"revoked {hostname}\n")
def main() -> int:
KEYS_DIR.mkdir(parents=True, exist_ok=True)
server = HTTPServer((BIND_HOST, BIND_PORT), Handler)
log.info("listening on %s:%d (context=%s, state=%s)",
BIND_HOST, BIND_PORT, CONTEXT_DIR, STATE_DIR)
server.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Homelab secrets-issuance (per-client age key provisioning)
After=network-online.target homelab-context-sync.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/secrets-issuance
Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context
Environment=SECRETS_ISSUANCE_STATE=/var/lib/secrets-issuance
Environment=MESH_SUBNETS=100.122.0.0/16,100.64.0.0/10
ExecStart=/opt/secrets-issuance/.venv/bin/python /opt/secrets-issuance/secrets-issuance/server.py
Restart=on-failure
RestartSec=5
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadOnlyPaths=/opt/homelab-context /opt/secrets-issuance
ReadWritePaths=/var/lib/secrets-issuance
[Install]
WantedBy=multi-user.target