From cfce35bee064788a7ecd2806ad131b77b1e7d3ff Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Jul 2026 00:29:06 +0200 Subject: [PATCH] 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 --- bootstrap.sh | 911 ++++++++++++++++----------------------------------- 1 file changed, 287 insertions(+), 624 deletions(-) diff --git a/bootstrap.sh b/bootstrap.sh index 991e8cf..908db45 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -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: ://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 <&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 <' 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= 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 + + + + + Labelnetwork.hubris.oikos-context-poller + ProgramArguments + $POLLER_SCRIPT + StartInterval300 + RunAtLoad + StandardOutPath/var/log/oikos-context-poller.log + StandardErrorPath/var/log/oikos-context-poller.log + + +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 ' 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 < '$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 </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 ` 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 </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/.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 \ No newline at end of file