diff --git a/.env.example b/.env.example index 79ca7c0..0b3b754 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,8 @@ MIGRATE_ISSUES=false # Migrate GitHub issues to Gitea MIGRATE_LABELS=true # Migrate GitHub labels MIGRATE_MILESTONES=false # Migrate GitHub milestones MIGRATE_WIKI=false # Migrate GitHub wiki +MIGRATION_POLL_INTERVAL_SEC=3 # Poll interval while waiting for async migration completion +MIGRATION_POLL_TIMEOUT_SEC=600 # Max wait per repo migration before timeout (increase for large repos) # ----------------------------------------------------------------------------- @@ -121,4 +123,4 @@ REQUIRED_APPROVALS=1 # Number of approvals required if above is tru SEMGREP_VERSION=latest # Semgrep OSS version to pin TRIVY_VERSION=latest # Trivy version to pin GITLEAKS_VERSION=latest # Gitleaks version to pin -SECURITY_FAIL_ON_ERROR=true # Block PR merge if security scan fails (true/false) \ No newline at end of file +SECURITY_FAIL_ON_ERROR=true # Block PR merge if security scan fails (true/false) diff --git a/phase4_migrate_repos.sh b/phase4_migrate_repos.sh index 62780b2..83b369e 100755 --- a/phase4_migrate_repos.sh +++ b/phase4_migrate_repos.sh @@ -22,6 +22,23 @@ require_vars GITEA_ADMIN_TOKEN GITEA_BACKUP_ADMIN_TOKEN \ MIGRATE_ISSUES MIGRATE_LABELS MIGRATE_MILESTONES MIGRATE_WIKI \ GITEA_BACKUP_MIRROR_INTERVAL +# Migration polling knobs (optional in .env) +MIGRATION_POLL_INTERVAL_SEC="${MIGRATION_POLL_INTERVAL_SEC:-3}" +MIGRATION_POLL_TIMEOUT_SEC="${MIGRATION_POLL_TIMEOUT_SEC:-600}" + +if ! [[ "$MIGRATION_POLL_INTERVAL_SEC" =~ ^[1-9][0-9]*$ ]]; then + log_error "MIGRATION_POLL_INTERVAL_SEC must be a positive integer (seconds)" + exit 1 +fi +if ! [[ "$MIGRATION_POLL_TIMEOUT_SEC" =~ ^[1-9][0-9]*$ ]]; then + log_error "MIGRATION_POLL_TIMEOUT_SEC must be a positive integer (seconds)" + exit 1 +fi +if (( MIGRATION_POLL_TIMEOUT_SEC < MIGRATION_POLL_INTERVAL_SEC )); then + log_error "MIGRATION_POLL_TIMEOUT_SEC (${MIGRATION_POLL_TIMEOUT_SEC}) must be >= MIGRATION_POLL_INTERVAL_SEC (${MIGRATION_POLL_INTERVAL_SEC})" + exit 1 +fi + phase_header 4 "Migrate Repos + Fedora Mirrors" # --------------------------------------------------------------------------- @@ -84,20 +101,20 @@ for repo in "${REPOS[@]}"; do # Wait for migration to complete — poll until repo has content # The migration API returns immediately but cloning happens async. # We check for commits to confirm the repo has actual content. - log_info "Waiting for migration to complete..." + log_info "Waiting for migration to complete (timeout: ${MIGRATION_POLL_TIMEOUT_SEC}s, interval: ${MIGRATION_POLL_INTERVAL_SEC}s)..." local_elapsed=0 - while [[ $local_elapsed -lt 120 ]]; do + while [[ $local_elapsed -lt $MIGRATION_POLL_TIMEOUT_SEC ]]; do COMMITS=$(gitea_api GET "/repos/${GITEA_ORG_NAME}/${repo}/commits?limit=1" 2>/dev/null || echo "[]") COMMIT_COUNT=$(printf '%s' "$COMMITS" | jq 'length' 2>/dev/null || echo 0) if [[ "$COMMIT_COUNT" -gt 0 ]]; then log_success "Repo ${repo} migrated with commits" break fi - sleep 3 - local_elapsed=$((local_elapsed + 3)) + sleep "$MIGRATION_POLL_INTERVAL_SEC" + local_elapsed=$((local_elapsed + MIGRATION_POLL_INTERVAL_SEC)) done - if [[ $local_elapsed -ge 120 ]]; then + if [[ $local_elapsed -ge $MIGRATION_POLL_TIMEOUT_SEC ]]; then log_error "Timeout waiting for ${repo} migration to complete" FAILED=$((FAILED + 1)) continue diff --git a/phase8_cutover.sh b/phase8_cutover.sh index 27aefb7..a83b5ad 100755 --- a/phase8_cutover.sh +++ b/phase8_cutover.sh @@ -2,7 +2,7 @@ set -euo pipefail # ============================================================================= -# phase8_cutover.sh — HTTPS via Nginx + Archive GitHub repos +# phase8_cutover.sh — HTTPS via Nginx + Mark GitHub repos as mirrors # Depends on: All prior phases complete, Nginx running on Unraid # This is the "go live" script — after this, Gitea is the primary git host. # @@ -14,7 +14,7 @@ set -euo pipefail # "letsencrypt" → auto-provision via Certbot container # "existing" → user provides cert paths (SSL_CERT_PATH, SSL_KEY_PATH) # -# After HTTPS is live, GitHub repos are archived with a "[MOVED]" description. +# After HTTPS is live, GitHub repos are marked as offsite mirrors. # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -27,9 +27,11 @@ require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_GITEA_PORT \ SSL_MODE GITHUB_USERNAME GITHUB_TOKEN \ REPO_1_NAME REPO_2_NAME REPO_3_NAME -phase_header 8 "Cutover (HTTPS + Archive GitHub)" +phase_header 8 "Cutover (HTTPS + Mark GitHub Mirrors)" REPOS=("$REPO_1_NAME" "$REPO_2_NAME" "$REPO_3_NAME") +PHASE8_STATE_DIR="$(_project_root)/.manifests" +PHASE8_STATE_FILE="${PHASE8_STATE_DIR}/phase8_github_repo_state.json" # Validate SSL_MODE if [[ "$SSL_MODE" != "letsencrypt" ]] && [[ "$SSL_MODE" != "existing" ]]; then @@ -43,6 +45,93 @@ elif [[ "$SSL_MODE" == "existing" ]]; then require_vars SSL_CERT_PATH SSL_KEY_PATH fi +# --------------------------------------------------------------------------- +# Helper: persist original GitHub repo settings for teardown symmetry +# --------------------------------------------------------------------------- +init_phase8_state_store() { + mkdir -p "$PHASE8_STATE_DIR" + if [[ ! -f "$PHASE8_STATE_FILE" ]]; then + printf '{}\n' > "$PHASE8_STATE_FILE" + fi +} + +fetch_github_pages_state() { + local repo="$1" + local tmpfile http_code + local pages_enabled=false + local pages_cname="" + local pages_source_branch="" + local pages_source_path="/" + + tmpfile=$(mktemp) + http_code=$(curl -s \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/json" \ + -o "$tmpfile" \ + -w "%{http_code}" \ + "https://api.github.com/repos/${GITHUB_USERNAME}/${repo}/pages" || echo "000") + + if [[ "$http_code" == "200" ]]; then + pages_enabled=true + pages_cname=$(jq -r '.cname // ""' "$tmpfile") + pages_source_branch=$(jq -r '.source.branch // ""' "$tmpfile") + pages_source_path=$(jq -r '.source.path // "/"' "$tmpfile") + fi + rm -f "$tmpfile" + + jq -n \ + --argjson pages_enabled "$pages_enabled" \ + --arg pages_cname "$pages_cname" \ + --arg pages_source_branch "$pages_source_branch" \ + --arg pages_source_path "$pages_source_path" \ + '{ + pages_enabled: $pages_enabled, + pages_cname: $pages_cname, + pages_source_branch: $pages_source_branch, + pages_source_path: $pages_source_path + }' +} + +snapshot_repo_state() { + local repo="$1" + local repo_data="$2" + + init_phase8_state_store + + if jq -e --arg repo "$repo" 'has($repo)' "$PHASE8_STATE_FILE" >/dev/null 2>&1; then + log_info "State snapshot already exists for ${repo} — preserving original values" + return 0 + fi + + local current_desc current_homepage current_has_wiki current_has_projects + local pages_state tmpfile + + current_desc=$(printf '%s' "$repo_data" | jq -r '.description // ""') + current_homepage=$(printf '%s' "$repo_data" | jq -r '.homepage // ""') + current_has_wiki=$(printf '%s' "$repo_data" | jq -r '.has_wiki // false') + current_has_projects=$(printf '%s' "$repo_data" | jq -r '.has_projects // false') + pages_state=$(fetch_github_pages_state "$repo") + + tmpfile=$(mktemp) + jq \ + --arg repo "$repo" \ + --arg description "$current_desc" \ + --arg homepage "$current_homepage" \ + --argjson has_wiki "$current_has_wiki" \ + --argjson has_projects "$current_has_projects" \ + --argjson pages "$pages_state" \ + '.[$repo] = { + description: $description, + homepage: $homepage, + has_wiki: $has_wiki, + has_projects: $has_projects + } + $pages' \ + "$PHASE8_STATE_FILE" > "$tmpfile" + mv "$tmpfile" "$PHASE8_STATE_FILE" + + log_info "Saved pre-cutover GitHub settings for ${repo}" +} + # --------------------------------------------------------------------------- # Helper: render Nginx config in HTTP-only or HTTPS mode # Uses the template + sed to strip/modify marker blocks. @@ -273,11 +362,12 @@ fi # Disables wiki and Pages to avoid unnecessary resource usage. # Does NOT archive — archived repos reject pushes, which would break # the push mirrors configured in Phase 6. -# Preserves original description by appending after "— was: ". +# Persists original mutable settings to a local state file for teardown. # GitHub Actions already disabled in Phase 6 Step D. # --------------------------------------------------------------------------- log_step 11 "Marking GitHub repos as offsite backup..." +init_phase8_state_store for repo in "${REPOS[@]}"; do # Fetch repo metadata (single API call) REPO_DATA=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null || echo "{}") @@ -285,10 +375,17 @@ for repo in "${REPOS[@]}"; do # Skip if already marked if [[ "$CURRENT_DESC" == "[MIRROR]"* ]]; then + if ! jq -e --arg repo "$repo" 'has($repo)' "$PHASE8_STATE_FILE" >/dev/null 2>&1; then + log_warn "GitHub repo ${repo} already marked as mirror but no local state snapshot exists" + log_warn " → Teardown may not fully restore pre-cutover settings for this repo" + fi log_info "GitHub repo ${repo} already marked as mirror — skipping" continue fi + # Snapshot current mutable state so teardown can restore exactly. + snapshot_repo_state "$repo" "$REPO_DATA" + # Build new description preserving original NEW_DESC="[MIRROR] Offsite backup — primary at https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}" if [[ -n "$CURRENT_DESC" ]]; then diff --git a/phase8_teardown.sh b/phase8_teardown.sh index 15b97e0..1f23a1d 100755 --- a/phase8_teardown.sh +++ b/phase8_teardown.sh @@ -7,7 +7,7 @@ set -euo pipefail # 1. Remove Nginx gitea.conf + reload # 2. Remove cert renewal cron # 3. Optionally remove SSL certificates -# 4. Restore GitHub repo descriptions, re-enable wiki/projects +# 4. Restore GitHub repo settings from the saved Phase 8 state snapshot # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -22,6 +22,15 @@ require_vars UNRAID_IP UNRAID_SSH_USER \ log_warn "=== Phase 8 Teardown: Cutover ===" REPOS=("$REPO_1_NAME" "$REPO_2_NAME" "$REPO_3_NAME") +PHASE8_STATE_FILE="$(_project_root)/.manifests/phase8_github_repo_state.json" + +github_pages_http_code() { + local repo="$1" + curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/json" \ + "https://api.github.com/repos/${GITHUB_USERNAME}/${repo}/pages" 2>/dev/null || echo "000" +} # --------------------------------------------------------------------------- # Step 1: Remove Nginx config and reload @@ -67,37 +76,135 @@ if [[ "$SSL_MODE" == "letsencrypt" ]]; then fi # --------------------------------------------------------------------------- -# Step 4: Restore GitHub repos — description, wiki, projects -# The mirror description format is: "[MIRROR] ... — was: ORIGINAL_DESC" -# We parse the original description from after "— was: " to restore it. +# Step 4: Restore GitHub repos +# Primary path: restore from state snapshot written by phase8_cutover.sh. +# Fallback path: if snapshot is missing, restore description from "— was: ..." +# and use legacy defaults for homepage/wiki/projects. # --------------------------------------------------------------------------- -printf 'Restore GitHub repo descriptions and re-enable wiki/projects? [y/N] ' +printf 'Restore GitHub repo settings (description/homepage/wiki/projects/pages)? [y/N] ' read -r confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then + STATE_AVAILABLE=false + if [[ -f "$PHASE8_STATE_FILE" ]]; then + STATE_AVAILABLE=true + log_info "Using saved Phase 8 state from ${PHASE8_STATE_FILE}" + else + log_warn "No Phase 8 state file found — using fallback restore behavior" + fi + + RESTORE_ERRORS=0 + for repo in "${REPOS[@]}"; do CURRENT_DESC=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.description // ""') - if [[ "$CURRENT_DESC" != "[MIRROR]"* ]]; then - log_info "GitHub repo ${repo} not marked as mirror — skipping" + + RESTORE_DESC="" + RESTORE_HOMEPAGE="" + RESTORE_HAS_WIKI=true + RESTORE_HAS_PROJECTS=true + RESTORE_PAGES_ENABLED=false + RESTORE_PAGES_CNAME="" + RESTORE_PAGES_BRANCH="" + RESTORE_PAGES_PATH="/" + + if [[ "$STATE_AVAILABLE" == "true" ]] && jq -e --arg repo "$repo" 'has($repo)' "$PHASE8_STATE_FILE" >/dev/null 2>&1; then + REPO_STATE=$(jq -c --arg repo "$repo" '.[$repo]' "$PHASE8_STATE_FILE") + RESTORE_DESC=$(printf '%s' "$REPO_STATE" | jq -r '.description // ""') + RESTORE_HOMEPAGE=$(printf '%s' "$REPO_STATE" | jq -r '.homepage // ""') + RESTORE_HAS_WIKI=$(printf '%s' "$REPO_STATE" | jq -r '.has_wiki // true') + RESTORE_HAS_PROJECTS=$(printf '%s' "$REPO_STATE" | jq -r '.has_projects // true') + RESTORE_PAGES_ENABLED=$(printf '%s' "$REPO_STATE" | jq -r '.pages_enabled // false') + RESTORE_PAGES_CNAME=$(printf '%s' "$REPO_STATE" | jq -r '.pages_cname // ""') + RESTORE_PAGES_BRANCH=$(printf '%s' "$REPO_STATE" | jq -r '.pages_source_branch // ""') + RESTORE_PAGES_PATH=$(printf '%s' "$REPO_STATE" | jq -r '.pages_source_path // "/"') + else + if [[ "$CURRENT_DESC" != "[MIRROR]"* ]]; then + log_info "GitHub repo ${repo} not marked as mirror and no snapshot found — skipping" + continue + fi + if [[ "$CURRENT_DESC" == *" — was: "* ]]; then + RESTORE_DESC="${CURRENT_DESC##* — was: }" + fi + fi + + # Restore description/homepage/wiki/projects + RESTORE_PAYLOAD=$(jq -n \ + --arg description "$RESTORE_DESC" \ + --arg homepage "$RESTORE_HOMEPAGE" \ + --argjson has_wiki "$RESTORE_HAS_WIKI" \ + --argjson has_projects "$RESTORE_HAS_PROJECTS" \ + '{ + description: $description, + homepage: $homepage, + has_wiki: $has_wiki, + has_projects: $has_projects + }') + + if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$RESTORE_PAYLOAD" >/dev/null 2>&1; then + log_success "Restored GitHub repo settings: ${repo}" + else + log_error "Failed to restore GitHub repo: ${repo}" + RESTORE_ERRORS=$((RESTORE_ERRORS + 1)) continue fi - # Extract original description from the mirror description - ORIGINAL_DESC="" - if [[ "$CURRENT_DESC" == *" — was: "* ]]; then - ORIGINAL_DESC="${CURRENT_DESC##* — was: }" - fi + # Restore GitHub Pages state + CURRENT_PAGES_CODE=$(github_pages_http_code "$repo") - # Restore description, homepage, and re-enable wiki/projects - RESTORE_PAYLOAD=$(jq -n \ - --arg description "$ORIGINAL_DESC" \ - '{description: $description, homepage: "", has_wiki: true, has_projects: true}') + if [[ "$RESTORE_PAGES_ENABLED" == "true" ]]; then + if [[ -z "$RESTORE_PAGES_BRANCH" ]]; then + log_warn "Cannot restore Pages for ${repo}: missing source branch in saved state" + RESTORE_ERRORS=$((RESTORE_ERRORS + 1)) + continue + fi - if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$RESTORE_PAYLOAD" >/dev/null 2>&1; then - log_success "Restored GitHub repo: ${repo}" + PAGES_PAYLOAD=$(jq -n \ + --arg branch "$RESTORE_PAGES_BRANCH" \ + --arg path "$RESTORE_PAGES_PATH" \ + --arg cname "$RESTORE_PAGES_CNAME" \ + '{ + source: { + branch: $branch, + path: $path + } + } + (if $cname != "" then {cname: $cname} else {} end)') + + if [[ "$CURRENT_PAGES_CODE" == "200" ]]; then + if github_api PUT "/repos/${GITHUB_USERNAME}/${repo}/pages" "$PAGES_PAYLOAD" >/dev/null 2>&1; then + log_success "Restored GitHub Pages config for ${repo}" + else + log_warn "Failed to update GitHub Pages config for ${repo}" + RESTORE_ERRORS=$((RESTORE_ERRORS + 1)) + fi + else + if github_api POST "/repos/${GITHUB_USERNAME}/${repo}/pages" "$PAGES_PAYLOAD" >/dev/null 2>&1; then + log_success "Recreated GitHub Pages for ${repo}" + else + log_warn "Failed to recreate GitHub Pages for ${repo}" + RESTORE_ERRORS=$((RESTORE_ERRORS + 1)) + fi + fi else - log_error "Failed to restore GitHub repo: ${repo}" + if [[ "$CURRENT_PAGES_CODE" == "200" ]]; then + if github_api DELETE "/repos/${GITHUB_USERNAME}/${repo}/pages" >/dev/null 2>&1; then + log_info "Disabled GitHub Pages for ${repo} (matches pre-cutover state)" + else + log_warn "Failed to disable GitHub Pages for ${repo}" + RESTORE_ERRORS=$((RESTORE_ERRORS + 1)) + fi + else + log_info "GitHub Pages already disabled for ${repo}" + fi fi done + + if [[ "$STATE_AVAILABLE" == "true" ]]; then + if [[ "$RESTORE_ERRORS" -eq 0 ]]; then + rm -f "$PHASE8_STATE_FILE" + log_info "Removed saved Phase 8 state file after successful restore" + else + log_warn "Keeping Phase 8 state file due to restore errors: ${PHASE8_STATE_FILE}" + fi + fi else log_info "GitHub repos left as-is" fi diff --git a/preflight.sh b/preflight.sh index eeb067c..b340d85 100755 --- a/preflight.sh +++ b/preflight.sh @@ -116,7 +116,7 @@ REQUIRED_VARS=( GITHUB_USERNAME GITHUB_TOKEN REPO_1_NAME REPO_2_NAME REPO_3_NAME GITHUB_MIRROR_TOKEN - NGINX_CONTAINER_NAME NGINX_CONF_PATH SSL_EMAIL + NGINX_CONTAINER_NAME NGINX_CONF_PATH SSL_MODE ) check_required_vars() { @@ -127,6 +127,33 @@ check_required_vars() { missing=1 fi done + + # SSL vars are conditional on SSL_MODE: + # - letsencrypt => SSL_EMAIL is required + # - existing => SSL_CERT_PATH + SSL_KEY_PATH are required + case "${SSL_MODE:-}" in + letsencrypt) + if [[ -z "${SSL_EMAIL:-}" ]]; then + log_error " → Missing required var: SSL_EMAIL (required when SSL_MODE=letsencrypt)" + missing=1 + fi + ;; + existing) + if [[ -z "${SSL_CERT_PATH:-}" ]]; then + log_error " → Missing required var: SSL_CERT_PATH (required when SSL_MODE=existing)" + missing=1 + fi + if [[ -z "${SSL_KEY_PATH:-}" ]]; then + log_error " → Missing required var: SSL_KEY_PATH (required when SSL_MODE=existing)" + missing=1 + fi + ;; + *) + log_error " → Invalid SSL_MODE='${SSL_MODE:-}' (must be 'letsencrypt' or 'existing')" + missing=1 + ;; + esac + return $missing } check 6 "All required .env vars are set" check_required_vars @@ -348,6 +375,30 @@ check_fedora_versions() { } check 22 "Fedora tool minimum versions (docker>=20, compose>=2, jq>=1.6)" check_fedora_versions +# --------------------------------------------------------------------------- +# Check 23: Unraid can SSH to Fedora (required for backup transfer) +# --------------------------------------------------------------------------- +check_unraid_to_fedora_ssh() { + ssh_exec UNRAID "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o BatchMode=yes -p '${FEDORA_SSH_PORT:-22}' '${FEDORA_SSH_USER}@${FEDORA_IP}' true" &>/dev/null +} +check 23 "Unraid can SSH to Fedora (host-to-host backup path)" check_unraid_to_fedora_ssh +if ! check_unraid_to_fedora_ssh 2>/dev/null; then + log_error " → Unraid cannot SSH to Fedora with key auth (needed by backup/backup_primary.sh)." + log_error " → Configure SSH keys so Unraid can run: ssh ${FEDORA_SSH_USER}@${FEDORA_IP}" +fi + +# --------------------------------------------------------------------------- +# Check 24: Fedora can SSH to Unraid (required for restore transfer) +# --------------------------------------------------------------------------- +check_fedora_to_unraid_ssh() { + ssh_exec FEDORA "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o BatchMode=yes -p '${UNRAID_SSH_PORT:-22}' '${UNRAID_SSH_USER}@${UNRAID_IP}' true" &>/dev/null +} +check 24 "Fedora can SSH to Unraid (host-to-host restore path)" check_fedora_to_unraid_ssh +if ! check_fedora_to_unraid_ssh 2>/dev/null; then + log_error " → Fedora cannot SSH to Unraid with key auth (needed by backup/restore_to_primary.sh)." + log_error " → Configure SSH keys so Fedora can run: ssh ${UNRAID_SSH_USER}@${UNRAID_IP}" +fi + # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- diff --git a/run_all.sh b/run_all.sh index 162c7a9..5db6fdc 100755 --- a/run_all.sh +++ b/run_all.sh @@ -26,16 +26,22 @@ require_local_os "Darwin" "run_all.sh must run from macOS (the control plane)" # --------------------------------------------------------------------------- SKIP_SETUP=false START_FROM=0 +START_FROM_SET=false for arg in "$@"; do case "$arg" in --skip-setup) SKIP_SETUP=true ;; --start-from=*) START_FROM="${arg#*=}" + START_FROM_SET=true if ! [[ "$START_FROM" =~ ^[0-9]+$ ]]; then log_error "--start-from must be a number (1-9)" exit 1 fi + if [[ "$START_FROM" -lt 1 ]] || [[ "$START_FROM" -gt 9 ]]; then + log_error "--start-from must be between 1 and 9" + exit 1 + fi ;; --help|-h) cat </dev/null || log_warn "Failed to remove Xcode CLI Tools (may need sudo)" } +cleanup_ssh_key() { + local host_key="$1" path="$2" + if [[ "$DRY_RUN" == "true" ]]; then + log_info "[dry-run] Would remove SSH key pair on ${host_key}: ${path}" + return 0 + fi + log_info "Removing SSH key pair on ${host_key}: ${path}" + # No single quotes around path — tilde must expand on the remote shell + ssh_exec "$host_key" "rm -f ${path} ${path}.pub" 2>/dev/null || log_warn "Failed to remove SSH key on ${host_key}" +} + +cleanup_authorized_key() { + local host_key="$1" marker="$2" + if [[ "$DRY_RUN" == "true" ]]; then + log_info "[dry-run] Would remove authorized_key entry '${marker}' on ${host_key}" + return 0 + fi + log_info "Removing authorized_key entry '${marker}' on ${host_key}" + ssh_exec "$host_key" "sed -i '/# ${marker}/d' ~/.ssh/authorized_keys" 2>/dev/null || log_warn "Failed to remove authorized_key '${marker}' on ${host_key}" +} + # --------------------------------------------------------------------------- # Map host names to SSH host keys for remote operations # --------------------------------------------------------------------------- @@ -269,6 +290,30 @@ for host in "${HOSTS[@]}"; do FAILED=$((FAILED + 1)) fi ;; + ssh_key) + if [[ -z "$ssh_key" ]]; then + log_warn "Cannot clean up ssh_key '$target' — no SSH key for host '$host'" + FAILED=$((FAILED + 1)) + continue + fi + if cleanup_ssh_key "$ssh_key" "$target"; then + CLEANED=$((CLEANED + 1)) + else + FAILED=$((FAILED + 1)) + fi + ;; + authorized_key) + if [[ -z "$ssh_key" ]]; then + log_warn "Cannot clean up authorized_key '$target' — no SSH key for host '$host'" + FAILED=$((FAILED + 1)) + continue + fi + if cleanup_authorized_key "$ssh_key" "$target"; then + CLEANED=$((CLEANED + 1)) + else + FAILED=$((FAILED + 1)) + fi + ;; *) log_warn "Unknown action type '${action_type}' for target '${target}' — skipping" FAILED=$((FAILED + 1)) diff --git a/setup/cross_host_ssh.sh b/setup/cross_host_ssh.sh new file mode 100755 index 0000000..abed1e5 --- /dev/null +++ b/setup/cross_host_ssh.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# setup/cross_host_ssh.sh — Establish SSH trust between Unraid and Fedora +# +# The backup/restore scripts do direct SCP between these hosts. This script +# generates ed25519 key pairs on each remote (if missing) and distributes +# public keys to the other host's authorized_keys. +# +# Runs from MacBook — orchestrates via SSH to both remotes. +# Idempotent: skips key generation if keys exist, skips authorized_keys +# entries if already present. +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/../lib/common.sh" + +load_env +require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_SSH_PORT \ + FEDORA_IP FEDORA_SSH_USER FEDORA_SSH_PORT + +log_info "=== Cross-Host SSH Trust Setup ===" + +# --------------------------------------------------------------------------- +# Verify MacBook can reach both hosts first +# --------------------------------------------------------------------------- +log_info "Verifying MacBook can reach both hosts..." +if ! ssh_check UNRAID; then + log_error "Cannot SSH to Unraid — run setup/unraid.sh first" + exit 1 +fi +if ! ssh_check FEDORA; then + log_error "Cannot SSH to Fedora — run setup/fedora.sh first" + exit 1 +fi +log_success "MacBook can reach both hosts" + +# --------------------------------------------------------------------------- +# Manifest — track key generation and authorized_keys entries for rollback. +# We record to both hosts' manifests since each gets a key pair AND an +# authorized_keys entry (from the other host). +# --------------------------------------------------------------------------- +manifest_init "unraid" +manifest_init "fedora" + +# Comment markers appended to authorized_keys entries so cleanup can +# identify and remove exactly the lines this script added. +UNRAID_KEY_MARKER="gitea-migration-from-unraid" +FEDORA_KEY_MARKER="gitea-migration-from-fedora" + +# --------------------------------------------------------------------------- +# Step 1: Generate SSH key pair on Unraid (if not present) +# Uses ed25519 — modern, fast, small keys. No passphrase (automation key). +# --------------------------------------------------------------------------- +log_info "Checking SSH key on Unraid..." +if ssh_exec UNRAID "test -f ~/.ssh/id_ed25519" 2>/dev/null; then + log_success "Unraid already has an ed25519 key — skipping generation" +else + log_info "Generating ed25519 key pair on Unraid..." + ssh_exec UNRAID "mkdir -p ~/.ssh && chmod 700 ~/.ssh && ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 -C '${UNRAID_SSH_USER}@unraid-gitea-migration'" + # shellcheck disable=SC2088 # tilde is intentionally literal — expanded by remote shell + manifest_record "unraid" "ssh_key" "~/.ssh/id_ed25519" + log_success "Unraid SSH key generated" +fi + +# --------------------------------------------------------------------------- +# Step 2: Generate SSH key pair on Fedora (if not present) +# --------------------------------------------------------------------------- +log_info "Checking SSH key on Fedora..." +if ssh_exec FEDORA "test -f ~/.ssh/id_ed25519" 2>/dev/null; then + log_success "Fedora already has an ed25519 key — skipping generation" +else + log_info "Generating ed25519 key pair on Fedora..." + ssh_exec FEDORA "mkdir -p ~/.ssh && chmod 700 ~/.ssh && ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 -C '${FEDORA_SSH_USER}@fedora-gitea-migration'" + # shellcheck disable=SC2088 # tilde is intentionally literal — expanded by remote shell + manifest_record "fedora" "ssh_key" "~/.ssh/id_ed25519" + log_success "Fedora SSH key generated" +fi + +# --------------------------------------------------------------------------- +# Step 3: Distribute Unraid's public key to Fedora's authorized_keys +# The key line gets a comment marker so cleanup.sh can identify and remove it. +# --------------------------------------------------------------------------- +log_info "Distributing Unraid's public key to Fedora..." +UNRAID_PUBKEY=$(ssh_exec UNRAID "cat ~/.ssh/id_ed25519.pub") + +if [[ -z "$UNRAID_PUBKEY" ]]; then + log_error "Failed to read Unraid's public key" + exit 1 +fi + +if ssh_exec FEDORA "grep -qF '${UNRAID_KEY_MARKER}' ~/.ssh/authorized_keys 2>/dev/null"; then + log_success "Unraid's key already in Fedora's authorized_keys — skipping" +else + ssh_exec FEDORA "mkdir -p ~/.ssh && chmod 700 ~/.ssh && printf '%s %s\n' '${UNRAID_PUBKEY}' '# ${UNRAID_KEY_MARKER}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" + manifest_record "fedora" "authorized_key" "${UNRAID_KEY_MARKER}" + log_success "Unraid's public key added to Fedora's authorized_keys" +fi + +# --------------------------------------------------------------------------- +# Step 4: Distribute Fedora's public key to Unraid's authorized_keys +# --------------------------------------------------------------------------- +log_info "Distributing Fedora's public key to Unraid..." +FEDORA_PUBKEY=$(ssh_exec FEDORA "cat ~/.ssh/id_ed25519.pub") + +if [[ -z "$FEDORA_PUBKEY" ]]; then + log_error "Failed to read Fedora's public key" + exit 1 +fi + +if ssh_exec UNRAID "grep -qF '${FEDORA_KEY_MARKER}' ~/.ssh/authorized_keys 2>/dev/null"; then + log_success "Fedora's key already in Unraid's authorized_keys — skipping" +else + ssh_exec UNRAID "mkdir -p ~/.ssh && chmod 700 ~/.ssh && printf '%s %s\n' '${FEDORA_PUBKEY}' '# ${FEDORA_KEY_MARKER}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" + manifest_record "unraid" "authorized_key" "${FEDORA_KEY_MARKER}" + log_success "Fedora's public key added to Unraid's authorized_keys" +fi + +# --------------------------------------------------------------------------- +# Step 5: Verify cross-host SSH connectivity +# Uses the same test commands as preflight checks 23-24 to ensure the +# exact path the backup/restore scripts will use actually works. +# --------------------------------------------------------------------------- +log_info "Verifying Unraid → Fedora SSH..." +if ssh_exec UNRAID "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o BatchMode=yes -p '${FEDORA_SSH_PORT:-22}' '${FEDORA_SSH_USER}@${FEDORA_IP}' true" &>/dev/null; then + log_success "Unraid → Fedora SSH works" +else + log_error "Unraid → Fedora SSH failed after key distribution" + log_error "Check that ${FEDORA_SSH_USER}@${FEDORA_IP}:${FEDORA_SSH_PORT:-22} accepts key auth" + exit 1 +fi + +log_info "Verifying Fedora → Unraid SSH..." +if ssh_exec FEDORA "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o BatchMode=yes -p '${UNRAID_SSH_PORT:-22}' '${UNRAID_SSH_USER}@${UNRAID_IP}' true" &>/dev/null; then + log_success "Fedora → Unraid SSH works" +else + log_error "Fedora → Unraid SSH failed after key distribution" + log_error "Check that ${UNRAID_SSH_USER}@${UNRAID_IP}:${UNRAID_SSH_PORT:-22} accepts key auth" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +log_success "Cross-host SSH trust established" +log_info " Unraid → Fedora: backup/backup_primary.sh can SCP dumps" +log_info " Fedora → Unraid: backup/restore_to_primary.sh can SCP archives"