feat: rewrite bootstrap.sh for thin client model + Oikos API enrollment

Thin client model (rev 2): no git clone, no sync timer.

Changes:
- Fetches only CLIENTS.md, AGENTS.md, OIKOS.md, tools/ from raw Gitea URL
- Enrolls via POST /api/v1/clients/enroll (replaces dead Python
  secrets-issuance service)
- Receives age keypair + Infisical identity from Oikos API
- Context poller replaces 5-minute git pull (launchd/systemd timer hits
  GET /api/v1/clients/{slug}/context?since=)
- Removed --no-secrets, --no-mesh flags (degraded modes TBD)
- Removed dead bin/homelab symlink
- Removed Gitea credential configuration (no git clone = no git auth)
- Kept --with-mcp and --with-hermes flags for optional tooling
- auto-setup scripts run from fetched tools/ directory
This commit is contained in:
2026-07-08 00:29:06 +02:00
parent a786107cc7
commit cfce35bee0

View File

@@ -1,32 +1,32 @@
#!/usr/bin/env bash
# bootstrap.sh — enroll a new client into the homelab context system.
# bootstrap.sh — enroll a new thin client into the homelab via Oikos API.
#
# Thin client model (rev 2): no git clone, no sync timer. Fetches only the
# agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md) and tooling
# (caveman, hermes-soul) from the raw Gitea URL. Enrolls via the Oikos API
# to receive an age keypair and Infisical machine identity. A lightweight
# context poller replaces the old 5-minute git pull.
#
# 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 -- --with-hermes # also install Goose + Hermes wrapper
# curl ... | sudo bash -s -- --with-mcp # wire Claude's .mcp.json
# curl ... | sudo bash -s -- --with-hermes # install Goose + Hermes
# curl ... | sudo bash -s -- --dry-run # show what would happen
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance entirely
# curl ... | sudo bash -s -- --no-mesh # get secrets over LAN only, skip
# # installing/connecting Netbird
# # (host must be on 192.168.8.0/24
# # or otherwise reach secrets.hubris.network)
#
# Prerequisites the script verifies:
# Prerequisites:
# - 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 `inventory.yaml` entry in the repo (or refuses)
# - age, python3, curl, jq installed
# - at least one mesh (netbird OR tailscale) connected
# - entity must already exist in DB (planned or provisioning state)
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:-https://secrets.hubris.network/issue}"
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
# ── defaults ─────────────────────────────────────────────────────────
REPO_RAW_URL="${HOMELAB_RAW_URL:-https://git.hubris.network/dtoro/Homelab-Docs/raw/main}"
OIKOS_API_URL="${HOMELAB_OIKOS_URL:-https://oikos.hubris.network/api/v1}"
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}"
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}"
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
@@ -34,651 +34,314 @@ HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
WITH_MCP=0
WITH_HERMES=0
DRY_RUN=0
NO_SECRETS=0
NO_MESH=0
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
# -------- flag parsing --------
# ── helpers ──────────────────────────────────────────────────────────
log() { echo "[oikos] $*"; }
warn() { echo "[oikos] WARN: $*" >&2; }
die() { echo "[oikos] FATAL: $*" >&2; exit 1; }
dry() { if [ "$DRY_RUN" -eq 1 ]; then log "[DRY-RUN] $*"; return 0; else "$@"; fi }
resolve_hostname() {
if [ "$(uname -s)" = "Darwin" ]; then
HNAME=$(scutil --get LocalHostName 2>/dev/null) || HNAME=$(hostname -s)
else
HNAME=$(hostname -s)
fi
if [ -z "$HNAME" ]; then
die "cannot resolve hostname"
fi
}
detect_mesh_ip() {
MESH_IP=""
# Try netbird first
if command -v netbird >/dev/null 2>&1; then
MESH_IP=$(netbird status 2>/dev/null | grep -oE '100\.[0-9]+\.[0-9]+\.[0-9]+' | head -1) || true
fi
# Fall back to tailscale
if [ -z "$MESH_IP" ] && command -v tailscale >/dev/null 2>&1; then
MESH_IP=$(tailscale ip -4 2>/dev/null) || true
fi
# Fall back to LAN
if [ -z "$MESH_IP" ]; then
MESH_IP=$(ifconfig 2>/dev/null | grep -Eo 'inet 192\.168\.8\.[0-9]+' | awk '{print $2}' | head -1) || true
fi
if [ -z "$MESH_IP" ]; then
die "cannot detect mesh IP (netbird/tailscale/LAN)"
fi
}
# ── parse args ───────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--with-mcp) WITH_MCP=1; shift ;;
--with-hermes) WITH_HERMES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--no-secrets) NO_SECRETS=1; shift ;;
--no-mesh) NO_MESH=1; shift ;;
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
--gitea-user) GITEA_USER="$2"; shift 2 ;;
--help|-h)
sed -n '2,11p' "$0" | sed 's/^# *//'
exit 0
;;
*)
echo "unknown flag: $1" >&2; exit 2 ;;
--with-mcp) WITH_MCP=1 ;;
--with-hermes) WITH_HERMES=1 ;;
--dry-run) DRY_RUN=1 ;;
--gitea-token) GITEA_TOKEN="$2"; shift ;;
--gitea-user) GITEA_USER="$2"; shift ;;
*) die "unknown flag: $1" ;;
esac
shift
done
# If a gitea token is provided, write it to the standard credential store
# BEFORE the clone happens. The HTTPS REPO_HTTPS will then pick it up via
# git's credential helper.
configure_gitea_creds() {
if [ -z "$GITEA_TOKEN" ]; then return 0; fi
local creds_dir=/etc/homelab-context
local creds_file=$creds_dir/git-credentials
mkdir -p "$creds_dir"
chmod 700 "$creds_dir"
# Format the credential URL: <scheme>://user:token@host (scheme must match
# the actual REPO_HTTPS — git's credential helper does exact prefix match).
local proto host
proto=$(echo "$REPO_HTTPS" | sed -E 's|^(https?)://.*|\1|')
host=$(echo "$REPO_HTTPS" | sed -E 's|^https?://([^/]+).*|\1|')
printf '%s://%s:%s@%s\n' "$proto" "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds_file"
chmod 600 "$creds_file"
# Point git at this store system-wide (/etc/gitconfig) so the systemd
# sync timer's git — which runs as root without HOME set — finds it.
# --global writes to /root/.gitconfig which the timer doesn't read.
git config --system credential.helper "store --file=$creds_file"
echo "[bootstrap] wrote gitea credentials to $creds_file"
}
# ── preflight ────────────────────────────────────────────────────────
[ "$(id -u)" -eq 0 ] || die "must run as root"
run() {
if [ "$DRY_RUN" -eq 1 ]; then
printf '+ %s\n' "$*"
else
eval "$*"
fi
}
# Run a command as the enrolling human user when one exists (i.e. this
# script was invoked via `sudo bash bootstrap.sh` from a real login), and
# directly otherwise. Minimal Linux images (bare Proxmox/Debian installs
# reached via `ssh root@host`) often don't even have a `sudo` binary
# installed — calling `sudo -u root ...` on those unconditionally fails
# with "sudo: command not found" even though we're already root and don't
# need to switch users at all.
run_as() {
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
sudo -u "$SUDO_USER" -- "$@"
else
"$@"
fi
}
# -------- preflight --------
if [ "$(id -u)" -ne 0 ]; then
echo "bootstrap.sh must run as root (use sudo)." >&2
exit 1
fi
OS="$(uname -s)"
OS=$(uname -s)
case "$OS" in
Linux|Darwin) ;;
*) echo "unsupported OS: $OS" >&2; exit 1 ;;
Darwin) ;;
Linux) ;;
*) die "unsupported OS: $OS" ;;
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"
resolve_hostname
log "hostname: $HNAME"
# Check dependencies.
missing=()
for cmd in git python3; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
# The homelab CLI needs PyYAML.
if ! python3 -c "import yaml" >/dev/null 2>&1; then
missing+=("python3-yaml")
fi
if [ "$NO_SECRETS" -eq 0 ]; then
for cmd in age sops; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
fi
# sops isn't a real Debian/Fedora package (there is no apt/dnf "sops"), so it
# always needs the direct-binary-download path, on both distros. Only Darwin
# (brew) can install it via a package manager.
install_sops_binary() {
local sops_version=v3.9.4
local arch
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "[bootstrap] unsupported arch for sops binary download: $arch" >&2; return 1 ;;
esac
curl -fsSL "https://github.com/getsops/sops/releases/download/${sops_version}/sops-${sops_version}.linux.${arch}" \
-o /usr/local/bin/sops && chmod +x /usr/local/bin/sops
}
if [ "${#missing[@]}" -gt 0 ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would install missing tools: ${missing[*]}"
else
echo "[bootstrap] installing missing tools: ${missing[*]}"
if [ "$OS" = "Darwin" ]; then
brew_list=()
for m in "${missing[@]}"; do
case "$m" in
python3-yaml) python3 -m pip install --break-system-packages pyyaml >/dev/null 2>&1 \
|| python3 -m pip install pyyaml ;;
*) brew_list+=("$m") ;;
esac
done
[ "${#brew_list[@]}" -gt 0 ] && brew install "${brew_list[@]}"
elif command -v dnf >/dev/null 2>&1; then
dnf_list=()
for m in "${missing[@]}"; do
case "$m" in
python3-yaml) dnf_list+=("python3-pyyaml") ;;
sops) install_sops_binary ;;
*) dnf_list+=("$m") ;;
esac
done
[ "${#dnf_list[@]}" -gt 0 ] && dnf install -y "${dnf_list[@]}"
elif command -v apt-get >/dev/null 2>&1; then
apt_list=()
for m in "${missing[@]}"; do
case "$m" in
sops) install_sops_binary ;;
*) apt_list+=("$m") ;;
esac
done
if [ "${#apt_list[@]}" -gt 0 ]; then
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${apt_list[@]}"
fi
else
echo "[bootstrap] no supported package manager for: ${missing[*]}" >&2
echo "[bootstrap] install with your package manager + re-run" >&2
exit 1
fi
# Re-verify (especially python yaml — the rename is the most common gotcha).
for cmd in git python3; do
command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; }
done
python3 -c "import yaml" 2>/dev/null \
|| { echo "[bootstrap] post-install python3-yaml/pyyaml still missing" >&2; exit 1; }
if [ "$NO_SECRETS" -eq 0 ]; then
for cmd in age sops; do
command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; }
done
fi
fi
fi
# -------- ensure netbird is installed + connected (workstation/VM hosts) --------
# Skipped on --no-secrets (LXCs that route via the LAN already), --no-mesh
# (explicit opt-out — secrets issuance still works if the mesh check below
# falls back to LAN reachability), and --dry-run. Installs netbird if
# missing, then drives `netbird up` against the homelab management server.
# The operator clicks the printed device-code URL once — this blocks
# indefinitely if nobody approves it, so don't skip --no-mesh on a host
# nobody's watching interactively.
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] && [ "$NO_MESH" -eq 0 ]; then
if ! command -v netbird >/dev/null 2>&1 && ! command -v tailscale >/dev/null 2>&1; then
echo "[bootstrap] no mesh CLI found; installing netbird..."
if [ "$OS" = "Darwin" ]; then
brew install --cask netbird || { echo "[bootstrap] brew install netbird failed" >&2; exit 1; }
elif command -v dnf >/dev/null 2>&1; then
cat > /etc/yum.repos.d/netbird.repo <<'NBREPO'
[netbird]
name=NetBird stable
baseurl=https://pkgs.netbird.io/yum/$basearch
enabled=1
gpgcheck=0
NBREPO
dnf install -y netbird netbird-ui || { echo "[bootstrap] dnf install netbird failed" >&2; exit 1; }
elif command -v apt-get >/dev/null 2>&1; then
install -d -m 0755 /usr/share/keyrings
curl -fsSL https://pkgs.netbird.io/debian/public.key \
| gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main" \
> /etc/apt/sources.list.d/netbird.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y netbird \
|| { echo "[bootstrap] apt install netbird failed" >&2; exit 1; }
else
echo "[bootstrap] can't auto-install netbird on this OS; install manually + re-run" >&2
exit 1
fi
fi
# Bring netbird up if not already connected.
if command -v netbird >/dev/null && ! netbird status 2>/dev/null | grep -q "Management: Connected"; then
cat <<MSG
[bootstrap] netbird is not connected to https://netbird.hubris.network.
[bootstrap] running 'netbird up' — a device-code URL will print below.
[bootstrap] OPEN THAT URL in a browser and approve the device when prompted.
[bootstrap] You may need to log in to https://auth.hubris.network first.
MSG
# --ssh-jwt-cache-ttl=86400 keeps the SSO valid for 24h of subsequent ssh
# ops into mesh peers; saves repeated browser clicks during this bootstrap.
netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 \
|| { echo "[bootstrap] 'netbird up' failed (see error above)" >&2; exit 1; }
# `netbird up` returns once the device-code SSO completes; give the
# mgmt connection ~30s to settle before continuing.
for _ in $(seq 1 10); do
netbird status 2>/dev/null | grep -q "Management: Connected" && break
sleep 3
done
if ! netbird status 2>/dev/null | grep -q "Management: Connected"; then
echo "[bootstrap] netbird daemon not reporting Management: Connected after 30s" >&2
echo "[bootstrap] try: 'netbird status -d' and 'sudo journalctl -u netbird -n 30'" >&2
exit 1
fi
echo "[bootstrap] netbird connected."
fi
fi
# Mesh check — accept Netbird, Tailscale, or LAN reachability of the issuance
# endpoint. LAN is fine for LXCs that don't run a mesh CLI but sit in the
# trusted subnet (192.168.8.0/24) included in MESH_SUBNETS.
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"
elif curl -sf -o /dev/null --max-time 3 "${ISSUANCE_URL_NETBIRD%/issue}/health" 2>/dev/null; then
MESH_CONNECTED="lan"
fi
if [ -z "$MESH_CONNECTED" ] && [ "$NO_SECRETS" -eq 0 ]; then
echo "no reachable issuance endpoint (no netbird/tailscale connected and" >&2
echo "$ISSUANCE_URL_NETBIRD did not respond to /health)." >&2
echo "either bring up the mesh first, fix DNS for *.hubris.network, or pass --no-secrets." >&2
exit 1
fi
echo "[bootstrap] mesh: ${MESH_CONNECTED:-none (skipped, --no-secrets)}"
# -------- gitea creds (if provided) --------
configure_gitea_creds
# -------- 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 --------
INVENTORY="$CLONE_DIR/inventory.yaml"
if [ ! -f "$INVENTORY" ] || ! grep -q "^ $HNAME:" "$INVENTORY" 2>/dev/null; then
cat >&2 <<EOF
[bootstrap] no entry for '$HNAME' in inventory.yaml.
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
# ── install dependencies ─────────────────────────────────────────────
for cmd in curl jq age python3; do
if ! command -v "$cmd" >/dev/null 2>&1; then
case "$OS" in
Darwin) dry brew install "$cmd" 2>/dev/null || die "install $cmd manually" ;;
Linux)
if command -v apt-get >/dev/null 2>&1; then
dry apt-get update -qq && dry apt-get install -y -qq "$cmd"
elif command -v dnf >/dev/null 2>&1; then
dry dnf install -y "$cmd"
else
die "install $cmd manually"
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
done
# ── mesh connectivity ────────────────────────────────────────────────
log "checking mesh connectivity..."
if command -v netbird >/dev/null 2>&1; then
dry netbird up --management-url https://netbird.hubris.network || true
fi
detect_mesh_ip
log "mesh IP: $MESH_IP"
# ── create homelab directory ─────────────────────────────────────────
dry mkdir -p "$CLONE_DIR"/{.agents/shared,bin}
# ── fetch agent orientation files (thin client — no git clone) ──────
log "fetching agent files..."
for f in CLIENTS.md AGENTS.md .agents/OIKOS.md .agents/shared/caveman.md .agents/shared/writing-style.md; do
url="$REPO_RAW_URL/$f"
dest="$CLONE_DIR/$f"
if curl -fsSL --connect-timeout 10 "$url" -o "$dest.tmp" 2>/dev/null; then
mv "$dest.tmp" "$dest"
log " + $f"
else
warn " - $f (not found at $url)"
fi
done
# Ensure expected path for caveman
[ -f "$CLONE_DIR/.agents/shared/caveman.md" ] || \
cp "$CLONE_DIR/caveman.md" "$CLONE_DIR/.agents/shared/caveman.md" 2>/dev/null || true
# ── fetch tools ──────────────────────────────────────────────────────
log "fetching tools..."
for tool in setup-caveman.sh setup-hermes-soul.sh caveman.js caveman_wrapper.sh post-pull.sh; do
url="$REPO_RAW_URL/tools/${tool}"
dest="$CLONE_DIR/tools/${tool}"
dry mkdir -p "$(dirname "$dest")"
if curl -fsSL --connect-timeout 10 "$url" -o "$dest.tmp" 2>/dev/null; then
mv "$dest.tmp" "$dest"
chmod +x "$dest" 2>/dev/null || true
log " + tools/$tool"
else
warn " - tools/$tool (not found)"
fi
done
# ── fetch caveman templates ──────────────────────────────────────────
for tmpl in git_log.txt git_status.txt test_results.txt; do
url="$REPO_RAW_URL/tools/caveman/templates/${tmpl}"
dest="$CLONE_DIR/tools/caveman/templates/${tmpl}"
dry mkdir -p "$(dirname "$dest")"
if curl -fsSL --connect-timeout 10 "$url" -o "$dest.tmp" 2>/dev/null; then
mv "$dest.tmp" "$dest"
fi
done
# ── enroll via Oikos API (replaces archived Python secrets-issuance) ─
log "enrolling via Oikos API..."
ENROLL_RESP=$(curl -s --connect-timeout 10 -X POST "$OIKOS_API_URL/clients/enroll" \
-H "Content-Type: application/json" \
-d "{\"slug\":\"ws:$HNAME\",\"hostname\":\"$HNAME\",\"mesh_ip\":\"$MESH_IP\"}")
if echo "$ENROLL_RESP" | jq -e '.age_private_key' >/dev/null 2>&1; then
AGE_PRIVKEY=$(echo "$ENROLL_RESP" | jq -r '.age_private_key')
AGE_PUBKEY=$(echo "$ENROLL_RESP" | jq -r '.age_public_key')
# Write age keypair
dry mkdir -p /etc/age
dry bash -c "echo '$AGE_PRIVKEY' > /etc/age/key.txt"
dry chmod 600 /etc/age/key.txt
log " + age keypair written to /etc/age/key.txt"
log " pubkey: $AGE_PUBKEY"
# Write Infisical identity if provided
INF_CLIENT=$(echo "$ENROLL_RESP" | jq -r '.infisical_client_id // empty')
INF_SECRET=$(echo "$ENROLL_RESP" | jq -r '.infisical_client_secret // empty')
if [ -n "$INF_CLIENT" ] && [ -n "$INF_SECRET" ]; then
dry mkdir -p /etc/infisical
dry bash -c "echo '{\"clientId\":\"$INF_CLIENT\",\"clientSecret\":\"$INF_SECRET\"}' > /etc/infisical/identity"
dry chmod 600 /etc/infisical/identity
log " + Infisical identity written to /etc/infisical/identity"
fi
else
err_msg=$(echo "$ENROLL_RESP" | jq -r '.detail // .title // "unknown error"')
die "enrollment failed: $err_msg"
fi
# -------- install sync timer / launchd plist --------
echo "[bootstrap] installing sync mechanism for $OS"
run "bash '$CLONE_DIR/scripts/sync/install.sh'"
# ── install context poller (replaces git sync timer) ─────────────────
log "installing context poller..."
# -------- install homelab CLI --------
# Symlink rather than copy so the 5-min sync auto-updates the CLI.
echo "[bootstrap] linking homelab CLI to /usr/local/bin/homelab"
run "ln -sfn '$CLONE_DIR/bin/homelab' /usr/local/bin/homelab"
POLLER_SCRIPT="$CLONE_DIR/tools/context-poller.sh"
cat > "$POLLER_SCRIPT" << 'POLLER_EOF'
#!/usr/bin/env bash
# context-poller.sh — lightweight deltas instead of git pull.
# Polls GET /api/v1/clients/{slug}/context?since=<timestamp> every 5 min.
set -euo pipefail
OIKOS_URL="${OIKOS_API_URL:-https://oikos.hubris.network/api/v1}"
HNAME=$(scutil --get LocalHostName 2>/dev/null || hostname -s)
STATE_FILE="${HOMELAB_CONTEXT_DIR:-/opt/homelab}/.context_since"
SINCE=""
[ -f "$STATE_FILE" ] && SINCE=$(cat "$STATE_FILE")
RESP=$(curl -s --connect-timeout 10 "$OIKOS_URL/clients/ws:${HNAME}/context?since=${SINCE}" 2>/dev/null || true)
if [ -n "$RESP" ]; then
NEW_SINCE=$(echo "$RESP" | jq -r '.since // empty')
if [ -n "$NEW_SINCE" ]; then
echo "$NEW_SINCE" > "$STATE_FILE"
CHANGED=$(echo "$RESP" | jq -r '.agent_files_changed // [] | .[]' 2>/dev/null || true)
if [ -n "$CHANGED" ]; then
echo "[oikos] context updated at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
fi
fi
POLLER_EOF
chmod +x "$POLLER_SCRIPT"
# -------- AGENTS.md symlink --------
case "$OS" in
Linux)
AGENTS_LINK=/root/AGENTS.md
;;
Darwin)
AGENTS_LINK=/etc/AGENTS.md
PLIST="/Library/LaunchDaemons/network.hubris.oikos-context-poller.plist"
cat > "$PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>network.hubris.oikos-context-poller</string>
<key>ProgramArguments</key>
<array><string>$POLLER_SCRIPT</string></array>
<key>StartInterval</key><integer>300</integer>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/var/log/oikos-context-poller.log</string>
<key>StandardErrorPath</key><string>/var/log/oikos-context-poller.log</string>
</dict>
</plist>
EOF
dry launchctl bootstrap system "$PLIST" 2>/dev/null || true
dry launchctl kickstart system/network.hubris.oikos-context-poller 2>/dev/null || true
log " + macOS context poller installed (every 5 min)"
;;
Linux)
SERVICE="/etc/systemd/system/oikos-context-poller.service"
TIMER="/etc/systemd/system/oikos-context-poller.timer"
cat > "$SERVICE" << EOF
[Unit]
Description=Oikos context poller
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$POLLER_SCRIPT
User=root
EOF
cat > "$TIMER" << EOF
[Unit]
Description=Oikos context poller (every 5 min)
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
EOF
dry systemctl daemon-reload
dry systemctl enable --now oikos-context-poller.timer
log " + Linux context poller installed (every 5 min)"
;;
esac
run "ln -sfn '$CLONE_DIR/AGENTS.md' '$AGENTS_LINK'"
echo "[bootstrap] linked AGENTS.md → $AGENTS_LINK"
# -------- auto-upgrade to write-scoped Gitea PAT --------
# After enrollment, if this client is already a recipient on
# secrets/gitea-pat.yaml (i.e. the operator has run
# `homelab client add --finalize-pubkey` from another client), swap the
# read-only bootstrap PAT for the write-scoped one. Best-effort: fails
# silently if the client isn't yet a recipient — the operator just re-runs
# bootstrap or `homelab refresh-creds` later.
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] \
&& command -v sops >/dev/null 2>&1 \
&& [ -f "$CLONE_DIR/secrets/gitea-pat.yaml" ]; then
if /usr/local/bin/homelab refresh-creds >/tmp/homelab-refresh-creds.log 2>&1; then
echo "[bootstrap] refresh-creds: write-scoped Gitea PAT installed"
else
echo "[bootstrap] refresh-creds: skipped (this client isn't yet a recipient"
echo " on secrets/gitea-pat.yaml — run 'homelab client add"
echo " $HNAME --finalize-pubkey <age...>' from an existing client,"
echo " then re-run bootstrap or 'homelab refresh-creds')"
fi
fi
# ── symlink AGENTS.md for discovery ──────────────────────────────────
case "$OS" in
Darwin) dry ln -sfn "$CLONE_DIR/AGENTS.md" /etc/AGENTS.md 2>/dev/null || true ;;
Linux) dry ln -sfn "$CLONE_DIR/AGENTS.md" /root/AGENTS.md 2>/dev/null || true ;;
esac
# -------- MCP wiring --------
# ── run auto-setup scripts ───────────────────────────────────────────
for setup in "$CLONE_DIR"/tools/*.setup.sh; do
[ -f "$setup" ] || continue
log "running setup: $(basename "$setup")"
dry bash "$setup"
done
# ── --with-mcp: wire Claude's .mcp.json ──────────────────────────────
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")
log "wiring MCP config..."
MCP_JSON="$HOME/.claude/.mcp.json"
if [ -f "$MCP_JSON" ]; then
dry bash -c "jq --arg url '$MCP_URL' '.mcpServers.homelab = {\"type\":\"sse\",\"url\":\$url}' '$MCP_JSON' > '$MCP_JSON.tmp' && mv '$MCP_JSON.tmp' '$MCP_JSON'"
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
dry mkdir -p "$(dirname "$MCP_JSON")"
dry bash -c "echo '{\"mcpServers\":{\"homelab\":{\"type\":\"sse\",\"url\":\"$MCP_URL\"}}}' > '$MCP_JSON'"
fi
log " + MCP wired to $MCP_URL"
fi
# -------- Hermes (Goose + Nous Hermes) wiring --------
# Installs the Goose CLI binary system-wide, symlinks the `hermes` wrapper
# and HERMES.md persona, and drops a Goose config that pins the OpenRouter
# provider, the Nous Hermes model, and the homelab MCP extension.
# See operations/hermes-agent.md.
# ── --with-hermes: install Goose + Hermes wrapper ────────────────────
if [ "$WITH_HERMES" -eq 1 ]; then
# Resolve the operator's home (SUDO_USER under `sudo bash`).
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
H_USER="$SUDO_USER"
H_HOME=$(eval echo "~$SUDO_USER")
else
H_USER="root"
H_HOME="$HOME"
fi
# 1. Install Goose binary at /usr/local/bin/goose (idempotent).
if ! command -v goose >/dev/null 2>&1; then
echo "[bootstrap] installing Goose CLI"
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would run upstream goose installer and symlink to /usr/local/bin/goose"
else
# Upstream installer drops the binary at ~/.local/bin/goose for the
# invoking user. We run it as $H_USER (via run_as) then symlink
# system-wide.
run_as env CONFIGURE=false \
bash -c 'curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash'
if [ -x "$H_HOME/.local/bin/goose" ]; then
ln -sfn "$H_HOME/.local/bin/goose" /usr/local/bin/goose
else
echo "[bootstrap] WARNING: goose binary not found at $H_HOME/.local/bin/goose after install" >&2
fi
fi
else
echo "[bootstrap] goose already installed: $(command -v goose)"
fi
# 2. Symlink hermes wrapper.
echo "[bootstrap] linking hermes CLI to /usr/local/bin/hermes"
run "ln -sfn '$CLONE_DIR/bin/hermes' /usr/local/bin/hermes"
# 3. Symlink HERMES.md persona. The hermes wrapper does not need it — the
# Goose config below references the canonical clone path — but operators
# frequently `cat /root/HERMES.md` to inspect the persona, mirroring the
# AGENTS.md convention above.
case "$OS" in
Linux) HERMES_LINK=/root/HERMES.md ;;
Darwin) HERMES_LINK=/etc/HERMES.md ;;
esac
run "ln -sfn '$CLONE_DIR/.agents/HERMES.md' '$HERMES_LINK'"
echo "[bootstrap] linked HERMES.md → $HERMES_LINK"
# 4. Drop the Goose config. Idempotent YAML merge — preserves any keys the
# operator added by hand, overwrites only the keys we manage.
GOOSE_DIR="$H_HOME/.config/goose"
GOOSE_CONFIG="$GOOSE_DIR/config.yaml"
GOOSEHINTS="$GOOSE_DIR/.goosehints"
run "mkdir -p '$GOOSE_DIR'"
PY_GOOSE_MERGE=$(cat <<PYEOF
import os, sys
try:
import yaml
except ImportError:
print("PyYAML required", file=sys.stderr); sys.exit(2)
path = "$GOOSE_CONFIG"
mcp_uri = "$HERMES_MCP_URI"
model = "$HERMES_MODEL"
cfg = {}
if os.path.exists(path):
with open(path) as f:
try:
cfg = yaml.safe_load(f) or {}
except Exception:
cfg = {}
cfg["GOOSE_PROVIDER"] = "openrouter"
cfg["GOOSE_MODEL"] = model
cfg.setdefault("GOOSE_MODE", "smart_approve")
cfg.setdefault("extensions", {})
cfg["extensions"]["developer"] = {
"bundled": True, "enabled": True, "name": "developer",
"timeout": 300, "type": "builtin",
}
cfg["extensions"]["homelab"] = {
"enabled": True, "name": "homelab",
"description": "Read-only homelab context tools (FastMCP).",
"type": "streamable_http", "uri": mcp_uri, "timeout": 60,
}
with open(path, "w") as f:
yaml.safe_dump(cfg, f, sort_keys=False)
print("[bootstrap] merged Goose config at", path)
PYEOF
)
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would merge Goose config at $GOOSE_CONFIG"
else
python3 -c "$PY_GOOSE_MERGE"
chown -R "$H_USER" "$GOOSE_DIR"
fi
# 5. Symlink HERMES.md as the global .goosehints — Goose injects it into
# the system prompt on every session start.
run "ln -sfn '$CLONE_DIR/.agents/HERMES.md' '$GOOSEHINTS'"
if [ "$DRY_RUN" -eq 0 ]; then
chown -h "$H_USER" "$GOOSEHINTS" 2>/dev/null || true
fi
log "installing Hermes agent..."
GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-${OS}-${ARCH:-amd64}"
if [ "$OS" = Darwin ]; then GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-darwin-${ARCH:-arm64}"; fi
dry curl -fsSL "$GOOSE_URL" -o /usr/local/bin/goose 2>/dev/null && chmod +x /usr/local/bin/goose || warn "goose not installed"
# Drop Hermes persona
cp "$CLONE_DIR/HERMES.md" "$CLONE_DIR/.agents/HERMES.md" 2>/dev/null || true
log " + Hermes agent installed"
fi
# -------- netbird tuning (skip per-session SSO for ssh into mesh peers) --------
# Apply the SSH JWT cache TTL so `ssh ... .netbird.selfhosted` doesn't trigger
# device-code SSO on every connection. Flag added in netbird 0.71.x
# (netbirdio/netbird#4015). It belongs on `netbird up` — putting it on the
# daemon's ExecStart crashes the daemon with "unknown flag". After this runs the
# FIRST ssh still prompts SSO once; subsequent sessions within 24h skip it.
if [ "$MESH_CONNECTED" = "netbird" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would: netbird down && netbird up --ssh-jwt-cache-ttl=86400"
elif netbird up --help 2>&1 | grep -q -- "--ssh-jwt-cache-ttl"; then
echo "[bootstrap] netbird: enabling ssh-jwt-cache-ttl=86400 (one SSO per 24h)"
# `netbird up` short-circuits with "Already connected" — need down first.
netbird down >/dev/null 2>&1 || true
if ! netbird up --ssh-jwt-cache-ttl=86400; then
echo "[bootstrap] WARNING: netbird up with --ssh-jwt-cache-ttl failed; rerun manually:"
echo "[bootstrap] netbird down && netbird up --ssh-jwt-cache-ttl=86400"
fi
else
echo "[bootstrap] netbird: --ssh-jwt-cache-ttl flag not supported (need >=0.71.x); skipping"
fi
# ── netbird SSH JWT cache ────────────────────────────────────────────
if command -v netbird >/dev/null 2>&1; then
dry netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 2>/dev/null || true
fi
# -------- ssh ControlMaster for netbird peers (workstations) --------
# Drop a Host block into the enrolling user's ~/.ssh/config so that ssh to
# `*.netbird.selfhosted` multiplexes over a single connection. After one SSO
# device-code completion, subsequent ssh / scp / `pct exec` invocations
# (within ControlPersist=2h) reuse the master socket with no re-auth — the
# real workaround for netbird's flaky SSH JWT cache. Skip on LXCs (no
# outbound ssh expected from them).
HKIND="$(python3 -c "import yaml; print(yaml.safe_load(open('$HOST_YAML')).get('kind',''))" 2>/dev/null || true)"
if [ "$MESH_CONNECTED" = "netbird" ] && [ "$HKIND" != "lxc" ]; then
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
SSH_USER_HOME=$(eval echo "~$SUDO_USER")
SSH_OWNER="$SUDO_USER"
else
SSH_USER_HOME="$HOME"
SSH_OWNER=""
fi
SSH_CFG="$SSH_USER_HOME/.ssh/config"
SSH_CM_DIR="$SSH_USER_HOME/.ssh/cm"
SENTINEL="# homelab-bootstrap: ssh ControlMaster for netbird mesh"
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would write Host *.netbird.selfhosted ControlMaster block into $SSH_CFG"
elif [ -f "$SSH_CFG" ] && grep -qF "$SENTINEL" "$SSH_CFG"; then
echo "[bootstrap] ssh ControlMaster block already present in $SSH_CFG (skip)"
else
mkdir -p "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR"
chmod 700 "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR"
cat >> "$SSH_CFG" <<'SSHEOF'
# homelab-bootstrap: ssh ControlMaster for netbird mesh
# One SSO covers many back-to-back ssh/scp/pct ops within ControlPersist.
Host *.netbird.selfhosted
ControlMaster auto
ControlPath ~/.ssh/cm/%C
ControlPersist 2h
SSHEOF
chmod 600 "$SSH_CFG"
if [ -n "$SSH_OWNER" ]; then
chown -R "$SSH_OWNER":"$SSH_OWNER" "$SSH_USER_HOME/.ssh"
fi
echo "[bootstrap] ssh: installed ControlMaster block into $SSH_CFG"
fi
fi
# -------- mcp CLI install (workstations) --------
# `homelab mcp <tool>` shells out to the `mcp` python CLI. Install it via
# pipx for the enrolling user. Skip on LXCs / VMs.
if [ "$HKIND" != "lxc" ] && [ "$HKIND" != "vm" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would install 'mcp[cli]' via pipx for the enrolling user"
elif command -v mcp >/dev/null 2>&1; then
echo "[bootstrap] mcp CLI already on PATH (skip)"
else
# Make sure pipx is available; OS-specific install.
if ! command -v pipx >/dev/null 2>&1; then
if [ "$OS" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
run_as brew install pipx 2>&1 | tail -2 || true
elif command -v dnf >/dev/null 2>&1; then
dnf install -y pipx 2>&1 | tail -2 || true
elif command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y pipx 2>&1 | tail -2 || true
fi
fi
if command -v pipx >/dev/null 2>&1; then
INVOKING_USER="${SUDO_USER:-root}"
run_as bash -lc "pipx install 'mcp[cli]'" 2>&1 | tail -3 || true
run_as bash -lc "pipx ensurepath" >/dev/null 2>&1 || true
echo "[bootstrap] mcp CLI installed for $INVOKING_USER via pipx"
else
echo "[bootstrap] WARNING: pipx unavailable; install manually: pipx install 'mcp[cli]'" >&2
fi
fi
fi
# -------- done --------
# ── done ─────────────────────────────────────────────────────────────
cat <<EOF
[bootstrap] done.
Oikos thin client bootstrap complete.
Identity: $CLONE_DIR/inventory.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
Hostname: $HNAME
Mesh IP: $MESH_IP
Age pubkey: $(cat /etc/age/key.txt 2>/dev/null | head -1 || echo "pending")
Context: $CLONE_DIR
Poller: every 5 min via launchd/systemd
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
Next steps:
1. On an enrolled client, finalize the public key:
POST /api/v1/clients/ws:$HNAME/activate
2. Add age pubkey to .sops.yaml recipients
3. Verify with MCP: whoami($HNAME)
EOF