634 lines
26 KiB
Bash
Executable File
634 lines
26 KiB
Bash
Executable File
#!/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 -- --with-hermes # also install Goose + Hermes wrapper
|
|
# 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:-https://secrets.hubris.network/issue}"
|
|
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
|
|
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network}"
|
|
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network}"
|
|
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
|
|
|
|
WITH_MCP=0
|
|
WITH_HERMES=0
|
|
DRY_RUN=0
|
|
NO_SECRETS=0
|
|
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
|
|
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
|
|
|
|
# -------- flag parsing --------
|
|
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 ;;
|
|
--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 ;;
|
|
esac
|
|
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"
|
|
}
|
|
|
|
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 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
|
|
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") ;;
|
|
*) dnf_list+=("$m") ;;
|
|
esac
|
|
done
|
|
dnf install -y "${dnf_list[@]}"
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
DEBIAN_FRONTEND=noninteractive apt-get update
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
|
|
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) 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.
|
|
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -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 --------
|
|
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 --------
|
|
# 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"
|
|
|
|
# -------- 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"
|
|
|
|
# -------- 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
|
|
|
|
# -------- 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
|
|
|
|
# -------- 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.
|
|
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 then symlink system-wide.
|
|
sudo -u "$H_USER" \
|
|
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/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/HERMES.md' '$GOOSEHINTS'"
|
|
if [ "$DRY_RUN" -eq 0 ]; then
|
|
chown -h "$H_USER" "$GOOSEHINTS" 2>/dev/null || true
|
|
fi
|
|
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
|
|
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
|
|
sudo -u "${SUDO_USER:-$USER}" 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:-$USER}"
|
|
sudo -u "$INVOKING_USER" -- bash -lc "pipx install 'mcp[cli]'" 2>&1 | tail -3 || true
|
|
sudo -u "$INVOKING_USER" -- 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 --------
|
|
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
|