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:
57
secrets-issuance/deploy/deploy.sh
Executable file
57
secrets-issuance/deploy/deploy.sh
Executable 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"
|
||||
30
secrets-issuance/deploy/webhook/install.sh
Executable file
30
secrets-issuance/deploy/webhook/install.sh
Executable 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
|
||||
@@ -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
|
||||
91
secrets-issuance/deploy/webhook/webhook.py
Executable file
91
secrets-issuance/deploy/webhook/webhook.py
Executable 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
266
secrets-issuance/server.py
Executable 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())
|
||||
23
secrets-issuance/server.service
Normal file
23
secrets-issuance/server.service
Normal 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
|
||||
Reference in New Issue
Block a user