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:
266
bootstrap.sh
Executable file
266
bootstrap.sh
Executable file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
# bootstrap.sh — enroll a new client into the homelab context system.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \
|
||||
# | sudo bash
|
||||
# curl ... | sudo bash -s -- --with-mcp # also wire Claude's .mcp.json
|
||||
# curl ... | sudo bash -s -- --dry-run # show what would happen
|
||||
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance
|
||||
#
|
||||
# Prerequisites the script verifies:
|
||||
# - running as root
|
||||
# - OS is Linux or macOS
|
||||
# - git, age, sops are installed
|
||||
# - at least one mesh (netbird OR tailscale) is connected
|
||||
# - this host has an `hosts/<hostname>.yaml` entry in the repo (or refuses)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# -------- defaults --------
|
||||
REPO_HTTPS="${HOMELAB_REPO_URL:-https://git.hubris.network/dtoro/Homelab-Docs.git}"
|
||||
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
|
||||
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-http://apps.netbird.selfhosted:9820/issue}"
|
||||
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-http://apps.ts:9820/issue}"
|
||||
MCP_URL="${HOMELAB_MCP_URL:-http://apps.netbird.selfhosted:9810/sse}"
|
||||
|
||||
WITH_MCP=0
|
||||
DRY_RUN=0
|
||||
NO_SECRETS=0
|
||||
|
||||
# -------- flag parsing --------
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--with-mcp) WITH_MCP=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--no-secrets) NO_SECRETS=1; shift ;;
|
||||
--help|-h)
|
||||
sed -n '2,11p' "$0" | sed 's/^# *//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf '+ %s\n' "$*"
|
||||
else
|
||||
eval "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
# -------- preflight --------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "bootstrap.sh must run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OS="$(uname -s)"
|
||||
case "$OS" in
|
||||
Linux|Darwin) ;;
|
||||
*) echo "unsupported OS: $OS" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Resolve hostname; on macOS prefer LocalHostName if set.
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
HNAME="$(scutil --get LocalHostName 2>/dev/null || hostname -s)"
|
||||
HNAME_ALT="$(hostname -s)"
|
||||
if [ "$HNAME" != "$HNAME_ALT" ]; then
|
||||
echo "note: scutil LocalHostName=$HNAME differs from hostname=$HNAME_ALT"
|
||||
echo " using LocalHostName for inventory lookup."
|
||||
fi
|
||||
else
|
||||
HNAME="$(hostname -s)"
|
||||
fi
|
||||
echo "[bootstrap] hostname: $HNAME"
|
||||
|
||||
# Check dependencies.
|
||||
missing=()
|
||||
for cmd in git; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
|
||||
if [ "$NO_SECRETS" -eq 0 ]; then
|
||||
for cmd in age sops; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
|
||||
fi
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "missing required tools: ${missing[*]}" >&2
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
echo " brew install ${missing[*]}"
|
||||
else
|
||||
echo " apt install -y ${missing[*]} (or platform equivalent)"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mesh check — accept either Netbird OR Tailscale.
|
||||
MESH_CONNECTED=""
|
||||
if command -v netbird >/dev/null && netbird status 2>/dev/null | grep -q "Management: Connected"; then
|
||||
MESH_CONNECTED="netbird"
|
||||
elif command -v tailscale >/dev/null && tailscale status >/dev/null 2>&1; then
|
||||
MESH_CONNECTED="tailscale"
|
||||
fi
|
||||
if [ -z "$MESH_CONNECTED" ] && [ "$NO_SECRETS" -eq 0 ]; then
|
||||
echo "neither netbird nor tailscale is connected; cannot bootstrap." >&2
|
||||
echo "either bring up the mesh first, or pass --no-secrets to skip issuance." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[bootstrap] mesh: ${MESH_CONNECTED:-none (skipped, --no-secrets)}"
|
||||
|
||||
# -------- clone --------
|
||||
if [ -d "$CLONE_DIR/.git" ]; then
|
||||
existing_remote="$(git -C "$CLONE_DIR" remote get-url origin 2>/dev/null || true)"
|
||||
if [ -n "$existing_remote" ] && [ "$existing_remote" != "$REPO_HTTPS" ]; then
|
||||
echo "$CLONE_DIR already exists with a different remote ($existing_remote);" >&2
|
||||
echo "refusing to overwrite. Move it aside or set HOMELAB_REPO_URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[bootstrap] clone exists; pulling"
|
||||
run "git -C '$CLONE_DIR' pull --ff-only --quiet"
|
||||
else
|
||||
echo "[bootstrap] cloning to $CLONE_DIR"
|
||||
run "git clone --quiet '$REPO_HTTPS' '$CLONE_DIR'"
|
||||
fi
|
||||
|
||||
# -------- identity check --------
|
||||
HOST_YAML="$CLONE_DIR/hosts/$HNAME.yaml"
|
||||
if [ ! -f "$HOST_YAML" ]; then
|
||||
cat >&2 <<EOF
|
||||
[bootstrap] no hosts/$HNAME.yaml in the repo.
|
||||
This client has not been enrolled yet. From any existing client, run:
|
||||
|
||||
homelab client add $HNAME
|
||||
|
||||
then re-run this bootstrap. (If the hostname here is wrong, fix it first:
|
||||
'sudo hostnamectl set-hostname <name>' on Linux, or System Preferences →
|
||||
Sharing on macOS.)
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------- secrets issuance --------
|
||||
if [ "$NO_SECRETS" -eq 0 ]; then
|
||||
if [ "$MESH_CONNECTED" = "netbird" ]; then
|
||||
URL="$ISSUANCE_URL_NETBIRD"
|
||||
else
|
||||
URL="$ISSUANCE_URL_TAILSCALE"
|
||||
fi
|
||||
KEY_FILE=/etc/age/key.txt
|
||||
run "mkdir -p /etc/age && chmod 0700 /etc/age"
|
||||
|
||||
if [ -f "$KEY_FILE" ]; then
|
||||
echo "[bootstrap] age key already exists at $KEY_FILE — verifying with issuance"
|
||||
fi
|
||||
echo "[bootstrap] requesting age key from $URL"
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
# The issuance endpoint identifies us by source mesh IP. No body needed.
|
||||
HTTP_CODE=$(curl -sS -o /tmp/homelab-age-key -w '%{http_code}' \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d "{\"hostname\":\"$HNAME\"}" \
|
||||
"$URL") || HTTP_CODE=000
|
||||
case "$HTTP_CODE" in
|
||||
200)
|
||||
mv /tmp/homelab-age-key "$KEY_FILE"
|
||||
chmod 0600 "$KEY_FILE"
|
||||
echo "[bootstrap] age key installed at $KEY_FILE"
|
||||
# Capture the PR snippet (if returned in a sidecar header) so the
|
||||
# operator knows the pubkey to add to inventory.yaml. The server
|
||||
# includes it in the JSON response when generating a new key.
|
||||
if grep -q '"pubkey"' "$KEY_FILE" 2>/dev/null; then
|
||||
# Shouldn't happen — server should return raw key, not JSON.
|
||||
echo "[bootstrap] unexpected: key file contains JSON, please inspect" >&2
|
||||
fi
|
||||
;;
|
||||
403)
|
||||
echo "[bootstrap] issuance returned 403 — caller not recognized" >&2
|
||||
echo "is this peer in the Netbird/Tailscale console? is the hostname" >&2
|
||||
echo "above ('$HNAME') matching the inventory entry?" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "[bootstrap] issuance failed (HTTP $HTTP_CODE)" >&2
|
||||
cat /tmp/homelab-age-key >&2 || true
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- install sync timer / launchd plist --------
|
||||
echo "[bootstrap] installing sync mechanism for $OS"
|
||||
run "bash '$CLONE_DIR/scripts/sync/install.sh'"
|
||||
|
||||
# -------- install homelab CLI --------
|
||||
echo "[bootstrap] installing homelab CLI to /usr/local/bin/homelab"
|
||||
run "install -m 0755 '$CLONE_DIR/bin/homelab' /usr/local/bin/homelab"
|
||||
|
||||
# -------- AGENTS.md symlink --------
|
||||
case "$OS" in
|
||||
Linux)
|
||||
AGENTS_LINK=/root/AGENTS.md
|
||||
;;
|
||||
Darwin)
|
||||
AGENTS_LINK=/etc/AGENTS.md
|
||||
;;
|
||||
esac
|
||||
run "ln -sfn '$CLONE_DIR/AGENTS.md' '$AGENTS_LINK'"
|
||||
echo "[bootstrap] linked AGENTS.md → $AGENTS_LINK"
|
||||
|
||||
# -------- MCP wiring --------
|
||||
if [ "$WITH_MCP" -eq 1 ]; then
|
||||
# Pick the right user's home — when invoked via sudo, SUDO_USER is set.
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
|
||||
USER_HOME=$(eval echo "~$SUDO_USER")
|
||||
else
|
||||
USER_HOME="$HOME"
|
||||
fi
|
||||
MCP_CONFIG="$USER_HOME/.claude/.mcp.json"
|
||||
run "mkdir -p '$USER_HOME/.claude'"
|
||||
|
||||
# Merge endpoint into existing config (or create new). Use python for the merge
|
||||
# because shell JSON juggling is error-prone.
|
||||
PY_MERGE=$(cat <<PYEOF
|
||||
import json, os, sys
|
||||
path = "$MCP_CONFIG"
|
||||
url = "$MCP_URL"
|
||||
cfg = {}
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
try:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
cfg = {}
|
||||
cfg.setdefault("mcpServers", {})
|
||||
cfg["mcpServers"]["homelab"] = {"type": "sse", "url": url}
|
||||
with open(path, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
print("[bootstrap] merged MCP server 'homelab' into", path)
|
||||
PYEOF
|
||||
)
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "+ would merge homelab MCP server into $MCP_CONFIG"
|
||||
else
|
||||
python3 -c "$PY_MERGE"
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
|
||||
chown "$SUDO_USER" "$MCP_CONFIG"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- done --------
|
||||
cat <<EOF
|
||||
|
||||
[bootstrap] done.
|
||||
|
||||
Identity: $CLONE_DIR/hosts/$HNAME.yaml
|
||||
Sync: 5-minute interval ($([ "$OS" = "Darwin" ] && echo launchd || echo systemd))
|
||||
Manual pull: homelab sync (or 'systemctl start homelab-context-sync' / 'launchctl kickstart')
|
||||
CLI: /usr/local/bin/homelab (try 'homelab whoami')
|
||||
AGENTS.md: $AGENTS_LINK
|
||||
EOF
|
||||
|
||||
if [ "$NO_SECRETS" -eq 0 ]; then
|
||||
echo "Secrets: sops -d $CLONE_DIR/secrets/<name>.yaml (key at $KEY_FILE)"
|
||||
fi
|
||||
if [ "$WITH_MCP" -eq 1 ]; then
|
||||
echo "MCP: merged into $MCP_CONFIG"
|
||||
fi
|
||||
Reference in New Issue
Block a user