#!/usr/bin/env bash # scripts/onboard/steps/00-access.sh — establish remote access channel # # Stages: # 1. ensure_ssh_key — copy SATURN public key to first_contact (idempotent) # 2. ensure_tailscale — install Tailscale and join network (interactive auth URL) # 3. verify — confirm SSH over Tailscale, assert arch=aarch64 # # Does NOT configure NOPASSWD, /opt/homelab, or any host mutation beyond Tailscale. # Reads: first_contact (e.g. pi@pimirror2.local) and tailscale.hostname from node.yaml. # pi user on Raspberry Pi OS has passwordless sudo by default — required for `tailscale up`. set -euo pipefail STEP_NAME="00-access" : "${REPO_ROOT:?REPO_ROOT is not set — run via onboard.sh}" : "${NODE_YAML:?NODE_YAML is not set — run via onboard.sh}" : "${DRY_RUN:=false}" # Source common.sh when run standalone (orchestrator sources it before calling steps) if ! declare -f log >/dev/null 2>&1; then # shellcheck source=../lib/common.sh source "${REPO_ROOT}/scripts/onboard/lib/common.sh" fi # ── parse node.yaml ─────────────────────────────────────────────────────────── FIRST_CONTACT=$(yaml_get "$NODE_YAML" "first_contact") TS_HOSTNAME=$(yaml_get "$NODE_YAML" "tailscale.hostname") [[ -z "$FIRST_CONTACT" ]] && die "first_contact not set in $NODE_YAML" [[ -z "$TS_HOSTNAME" ]] && die "tailscale.hostname not set in $NODE_YAML" FC_USER="${FIRST_CONTACT%%@*}" # remote.sh binds to ONBOARD_SSH_USER / ONBOARD_SSH_HOST — set from orchestrator # (post-Tailscale target); fall back to first_contact coordinates for standalone use. export ONBOARD_SSH_USER="${ONBOARD_SSH_USER:-${FC_USER}}" export ONBOARD_SSH_HOST="${ONBOARD_SSH_HOST:-${TS_HOSTNAME}}" # shellcheck source=../lib/remote.sh source "${REPO_ROOT}/scripts/onboard/lib/remote.sh" # ── SSH option arrays ───────────────────────────────────────────────────────── # Interactive — no BatchMode; used for ssh-copy-id (may need password) _FC_SSH_NOKEY=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10) # Post-key — BatchMode; used once the key is installed _FC_SSH=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes) # ═══════════════════════════════════════════════════════════════════════════════ # Stage 1 — ensure_ssh_key # ═══════════════════════════════════════════════════════════════════════════════ step "[$STEP_NAME] 1/3 ensure_ssh_key → ${FIRST_CONTACT}" if [[ "$DRY_RUN" == "true" ]]; then dryrun "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ${FIRST_CONTACT} true" dryrun "# if key not present:" dryrun "ssh-copy-id -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -i ~/.ssh/id_*.pub ${FIRST_CONTACT}" else if ssh "${_FC_SSH[@]}" "$FIRST_CONTACT" true 2>/dev/null; then log "SSH key already accepted by ${FIRST_CONTACT} — skip" else # Find the first available public key pubkeys=( "$HOME"/.ssh/id_*.pub ) [[ -f "${pubkeys[0]}" ]] || die "No public key found at ~/.ssh/id_*.pub on SATURN" log "Installing public key ${pubkeys[0]} on ${FIRST_CONTACT}" log "(password prompt for ${FIRST_CONTACT} expected)" ssh-copy-id \ -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=10 \ -i "${pubkeys[0]}" \ "$FIRST_CONTACT" log "Verifying key-based access..." ssh "${_FC_SSH[@]}" "$FIRST_CONTACT" true \ || die "Key-based SSH still fails after ssh-copy-id — check ~/.ssh/authorized_keys on ${FIRST_CONTACT}" log "Key installed and verified" fi fi # ═══════════════════════════════════════════════════════════════════════════════ # Stage 2 — ensure_tailscale # ═══════════════════════════════════════════════════════════════════════════════ step "[$STEP_NAME] 2/3 ensure_tailscale on ${FIRST_CONTACT} → hostname=${TS_HOSTNAME}" _ts_state() { # Returns BackendState string or "unknown". Uses python3 (available on RPi OS). ssh "${_FC_SSH[@]}" "$FIRST_CONTACT" \ 'tailscale status --json 2>/dev/null | python3 -c \ "import sys,json; print(json.load(sys.stdin).get(\"BackendState\",\"unknown\"))" \ 2>/dev/null || echo "unknown"' } if [[ "$DRY_RUN" == "true" ]]; then dryrun "ssh ${FIRST_CONTACT} 'command -v tailscale' # check if installed" dryrun "# if missing: ssh ${FIRST_CONTACT} 'curl -fsSL https://tailscale.com/install.sh | sh'" dryrun "ssh ${FIRST_CONTACT} 'tailscale status --json' # check BackendState" dryrun "# if not Running: ssh ${FIRST_CONTACT} 'sudo tailscale up --hostname=${TS_HOSTNAME}'" else # 2a — install if missing if ! ssh "${_FC_SSH[@]}" "$FIRST_CONTACT" 'command -v tailscale' >/dev/null 2>&1; then log "Tailscale not found — installing on ${FIRST_CONTACT}..." ssh "${_FC_SSH[@]}" "$FIRST_CONTACT" 'curl -fsSL https://tailscale.com/install.sh | sh' log "Tailscale installed" else log "Tailscale already installed on ${FIRST_CONTACT}" fi # 2b — join if not active ts_state=$(_ts_state) if [[ "$ts_state" == "Running" ]]; then log "Tailscale already active (BackendState=Running) — skip" else warn "Tailscale BackendState=${ts_state} — running tailscale up" echo "" echo -e "${_C_BOLD}┌─────────────────────────────────────────────────────────────┐" echo -e "│ ACTION REQUIRED: open the URL below in your browser to │" echo -e "│ authorize ${TS_HOSTNAME} in your Tailscale account. │" echo -e "└─────────────────────────────────────────────────────────────┘${_C_RESET}" echo "" # pi user has passwordless sudo on Raspberry Pi OS; tailscale up blocks # until the user authenticates via the URL it prints to stdout. ssh "${_FC_SSH[@]}" "$FIRST_CONTACT" "sudo tailscale up --hostname=${TS_HOSTNAME}" echo "" ts_state2=$(_ts_state) [[ "$ts_state2" == "Running" ]] \ || die "Tailscale still not active after tailscale up (BackendState=${ts_state2})" log "Tailscale joined successfully (BackendState=Running)" fi fi # ═══════════════════════════════════════════════════════════════════════════════ # Stage 3 — verify over Tailscale # ═══════════════════════════════════════════════════════════════════════════════ step "[$STEP_NAME] 3/3 verify SSH over Tailscale → ${ONBOARD_SSH_USER}@${TS_HOSTNAME}" if [[ "$DRY_RUN" == "true" ]]; then dryrun "ssh -o BatchMode=yes ${ONBOARD_SSH_USER}@${TS_HOSTNAME} 'echo ok && uname -m'" dryrun "# expected output: ok / aarch64" else out=$(ssh "${_FC_SSH[@]}" "${ONBOARD_SSH_USER}@${TS_HOSTNAME}" 'echo ok && uname -m' 2>&1) \ || die "Verification SSH to ${TS_HOSTNAME} failed:\n ${out}" echo "$out" | grep -q '^ok' || die "Verification output missing 'ok' line: ${out}" arch=$(echo "$out" | grep -v '^ok' | head -1 | tr -d '[:space:]') [[ "$arch" == "aarch64" ]] || warn "Unexpected arch '${arch}' — expected aarch64" log "Verify OK: ${ONBOARD_SSH_USER}@${TS_HOSTNAME} reachable, arch=${arch}" fi log "[$STEP_NAME] done — SSH key installed, Tailscale active, Tailscale SSH verified"