feat: add cross-host SSH trust, state-aware teardown, and configurable migration polling

- Add setup/cross_host_ssh.sh to establish ed25519 SSH trust between
  Unraid and Fedora (required by backup/restore scripts for direct SCP)
- Add ssh_key and authorized_key cleanup handlers to setup/cleanup.sh
- Rewrite phase8 cutover to mark GitHub repos as mirrors instead of
  archiving them (archived repos reject push mirror writes), with a
  JSON state snapshot of pre-cutover settings (description, homepage,
  wiki, projects, Pages) for exact restoration on teardown
- Rewrite phase8 teardown to restore from state snapshot with fallback
  to legacy "— was:" description parsing
- Make migration polling configurable via MIGRATION_POLL_INTERVAL_SEC
  and MIGRATION_POLL_TIMEOUT_SEC in .env (was hardcoded 120s/3s)
- Fix preflight SSL validation: check SSL_MODE instead of always
  requiring SSL_EMAIL, add conditional checks per SSL_MODE
- Add preflight checks 23-24: cross-host SSH connectivity
- Add --start-from range validation and cross_host_ssh.sh to run_all.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
S
2026-02-28 20:50:41 -05:00
parent dc08375ad0
commit 316d318b5e
8 changed files with 509 additions and 31 deletions

View File

@@ -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)
SECURITY_FAIL_ON_ERROR=true # Block PR merge if security scan fails (true/false)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:-<empty>}' (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
# ---------------------------------------------------------------------------

View File

@@ -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 <<EOF
@@ -121,8 +127,13 @@ if [[ "$SKIP_SETUP" == "false" ]] && [[ "$START_FROM" -eq 0 ]]; then
run_step "Setup MacBook" "setup/macbook.sh"
run_step "Setup Unraid" "setup/unraid.sh"
run_step "Setup Fedora" "setup/fedora.sh"
run_step "Cross-host SSH trust" "setup/cross_host_ssh.sh"
else
log_info "Skipping setup (--skip-setup or --start-from=${START_FROM})"
if [[ "$START_FROM_SET" == "true" ]]; then
log_info "Skipping setup (--skip-setup or --start-from=${START_FROM})"
else
log_info "Skipping setup (--skip-setup)"
fi
fi
# ---------------------------------------------------------------------------

View File

@@ -166,6 +166,27 @@ cleanup_xcode_cli() {
sudo rm -rf /Library/Developer/CommandLineTools 2>/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))

148
setup/cross_host_ssh.sh Executable file
View File

@@ -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"