#!/usr/bin/env bash set -euo pipefail # ============================================================================= # 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. # # Two-stage Nginx deploy: # Stage 1: HTTP-only reverse proxy (needed for ACME challenge if letsencrypt) # Stage 2: Full HTTPS with SSL cert + HTTP→HTTPS redirect # # SSL_MODE from .env controls certificate handling: # "letsencrypt" → auto-provision via Certbot container # "existing" → user provides cert paths (SSL_CERT_PATH, SSL_KEY_PATH) # # After HTTPS is live, GitHub repos are marked as offsite mirrors. # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" load_env require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_GITEA_PORT \ GITEA_INTERNAL_URL GITEA_DOMAIN GITEA_ADMIN_TOKEN \ GITEA_ORG_NAME NGINX_CONTAINER_NAME NGINX_CONF_PATH \ SSL_MODE GITHUB_USERNAME GITHUB_TOKEN \ REPO_1_NAME REPO_2_NAME REPO_3_NAME 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 log_error "Invalid SSL_MODE='${SSL_MODE}' — must be 'letsencrypt' or 'existing'" exit 1 fi if [[ "$SSL_MODE" == "letsencrypt" ]]; then require_vars SSL_EMAIL 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. # Writes result to a local tmpfile and returns its path via stdout. # --------------------------------------------------------------------------- render_nginx_http_only() { local tmpfile tmpfile=$(mktemp) # Render the template with envsubst first local rendered rendered=$(mktemp) export GITEA_DOMAIN UNRAID_IP UNRAID_GITEA_PORT # Set dummy cert paths (not used in HTTP-only mode) export SSL_CERT_FULLPATH="/dev/null" export SSL_KEY_FULLPATH="/dev/null" render_template "${SCRIPT_DIR}/templates/nginx-gitea.conf.tpl" "$rendered" \ "\${GITEA_DOMAIN} \${UNRAID_IP} \${UNRAID_GITEA_PORT} \${SSL_CERT_FULLPATH} \${SSL_KEY_FULLPATH}" # Strip the HTTPS server block (everything between markers inclusive) sed '/# SSL_HTTPS_BLOCK_START/,/# SSL_HTTPS_BLOCK_END/d' "$rendered" > "$tmpfile" rm -f "$rendered" echo "$tmpfile" } render_nginx_https() { local cert_path="$1" key_path="$2" local tmpfile tmpfile=$(mktemp) local rendered rendered=$(mktemp) export GITEA_DOMAIN UNRAID_IP UNRAID_GITEA_PORT export SSL_CERT_FULLPATH="$cert_path" export SSL_KEY_FULLPATH="$key_path" render_template "${SCRIPT_DIR}/templates/nginx-gitea.conf.tpl" "$rendered" \ "\${GITEA_DOMAIN} \${UNRAID_IP} \${UNRAID_GITEA_PORT} \${SSL_CERT_FULLPATH} \${SSL_KEY_FULLPATH}" # Replace the redirect block content with a 301 redirect to HTTPS # The block between markers gets replaced with just the redirect # shellcheck disable=SC2016 sed '/# SSL_REDIRECT_BLOCK_START/,/# SSL_REDIRECT_BLOCK_END/{ /# SSL_REDIRECT_BLOCK_START/!{/# SSL_REDIRECT_BLOCK_END/!d;} /# SSL_REDIRECT_BLOCK_START/a\ return 301 https://\$host\$request_uri; }' "$rendered" > "$tmpfile" rm -f "$rendered" echo "$tmpfile" } # --------------------------------------------------------------------------- # Step 1: Deploy HTTP-only Nginx config # This enables the reverse proxy and the ACME challenge location needed # by Certbot for domain validation (letsencrypt mode). # --------------------------------------------------------------------------- log_step 1 "Deploying HTTP-only Nginx config..." if ssh_exec UNRAID "test -f '${NGINX_CONF_PATH}/gitea.conf'" 2>/dev/null; then log_info "gitea.conf already exists — checking if SSL is already configured" # If HTTPS block is present in the existing config, we're already past HTTP-only stage if ssh_exec UNRAID "grep -q 'listen 443 ssl' '${NGINX_CONF_PATH}/gitea.conf'" 2>/dev/null; then log_info "HTTPS already configured — skipping HTTP-only stage" else log_info "HTTP-only config exists — skipping" fi else HTTP_CONF=$(render_nginx_http_only) scp_to UNRAID "$HTTP_CONF" "${NGINX_CONF_PATH}/gitea.conf" rm -f "$HTTP_CONF" log_success "HTTP-only config deployed" fi # --------------------------------------------------------------------------- # Step 2: Test Nginx config # Always test before reloading — never push a broken config to a live server. # If test fails, remove the config and exit. # --------------------------------------------------------------------------- log_step 2 "Testing Nginx config..." if ! ssh_exec UNRAID "docker exec ${NGINX_CONTAINER_NAME} nginx -t" 2>/dev/null; then log_error "Nginx config test failed — removing gitea.conf and aborting" ssh_exec UNRAID "rm -f '${NGINX_CONF_PATH}/gitea.conf'" || true exit 1 fi log_success "Nginx config test passed" # --------------------------------------------------------------------------- # Step 3: Reload Nginx (HTTP) # --------------------------------------------------------------------------- log_step 3 "Reloading Nginx..." ssh_exec UNRAID "docker exec ${NGINX_CONTAINER_NAME} nginx -s reload" log_success "Nginx reloaded" # --------------------------------------------------------------------------- # Step 4: Verify HTTP proxy works # --------------------------------------------------------------------------- log_step 4 "Verifying HTTP proxy..." if curl -sf -o /dev/null "http://${GITEA_DOMAIN}/api/v1/version" 2>/dev/null; then log_success "HTTP proxy verified — http://${GITEA_DOMAIN} works" else log_error "HTTP proxy failed — http://${GITEA_DOMAIN} not responding" exit 1 fi # --------------------------------------------------------------------------- # Step 5: Obtain or verify SSL certificate # For letsencrypt: run Certbot in a Docker container using webroot validation. # For existing: verify cert files exist on Unraid. # --------------------------------------------------------------------------- log_step 5 "Obtaining/verifying SSL certificate..." if [[ "$SSL_MODE" == "letsencrypt" ]]; then # Check if cert already exists if ssh_exec UNRAID "test -f '/etc/letsencrypt/live/${GITEA_DOMAIN}/fullchain.pem'" 2>/dev/null; then log_info "Let's Encrypt cert already exists for ${GITEA_DOMAIN} — skipping" else # Verify Nginx container has required volume mounts for Certbot # /etc/letsencrypt must be mounted for cert storage # /var/www/html must be mounted for ACME challenge serving MOUNTS=$(ssh_exec UNRAID "docker inspect ${NGINX_CONTAINER_NAME} --format '{{json .Mounts}}'" 2>/dev/null) if ! echo "$MOUNTS" | grep -q "letsencrypt"; then log_error "Nginx container '${NGINX_CONTAINER_NAME}' does not mount /etc/letsencrypt" log_error "Add the volume mount to your Nginx container config and restart it" exit 1 fi log_info "Running Certbot for ${GITEA_DOMAIN}..." ssh_exec UNRAID "docker run --rm \ -v /etc/letsencrypt:/etc/letsencrypt \ -v /var/www/html:/var/www/html \ certbot/certbot certonly \ --webroot -w /var/www/html \ -d '${GITEA_DOMAIN}' \ --email '${SSL_EMAIL}' \ --agree-tos \ --non-interactive" log_success "SSL certificate obtained from Let's Encrypt" fi SSL_CERT_FULLPATH="/etc/letsencrypt/live/${GITEA_DOMAIN}/fullchain.pem" SSL_KEY_FULLPATH="/etc/letsencrypt/live/${GITEA_DOMAIN}/privkey.pem" elif [[ "$SSL_MODE" == "existing" ]]; then # Verify cert files exist on Unraid if ! ssh_exec UNRAID "test -f '${SSL_CERT_PATH}'" 2>/dev/null; then log_error "SSL cert not found at ${SSL_CERT_PATH} on Unraid" exit 1 fi if ! ssh_exec UNRAID "test -f '${SSL_KEY_PATH}'" 2>/dev/null; then log_error "SSL key not found at ${SSL_KEY_PATH} on Unraid" exit 1 fi log_success "Existing SSL cert verified" SSL_CERT_FULLPATH="$SSL_CERT_PATH" SSL_KEY_FULLPATH="$SSL_KEY_PATH" fi # --------------------------------------------------------------------------- # Step 6: Deploy HTTPS Nginx config # Overwrites the HTTP-only config with the full HTTPS version. # --------------------------------------------------------------------------- log_step 6 "Deploying HTTPS Nginx config..." HTTPS_CONF=$(render_nginx_https "$SSL_CERT_FULLPATH" "$SSL_KEY_FULLPATH") scp_to UNRAID "$HTTPS_CONF" "${NGINX_CONF_PATH}/gitea.conf" rm -f "$HTTPS_CONF" log_success "HTTPS config deployed" # --------------------------------------------------------------------------- # Step 7: Test Nginx config (HTTPS) # If the HTTPS config fails, revert to HTTP-only as a safety measure. # --------------------------------------------------------------------------- log_step 7 "Testing HTTPS Nginx config..." if ! ssh_exec UNRAID "docker exec ${NGINX_CONTAINER_NAME} nginx -t" 2>/dev/null; then log_error "HTTPS Nginx config test failed — reverting to HTTP-only" HTTP_CONF=$(render_nginx_http_only) scp_to UNRAID "$HTTP_CONF" "${NGINX_CONF_PATH}/gitea.conf" rm -f "$HTTP_CONF" ssh_exec UNRAID "docker exec ${NGINX_CONTAINER_NAME} nginx -s reload" || true exit 1 fi log_success "HTTPS Nginx config test passed" # --------------------------------------------------------------------------- # Step 8: Reload Nginx (HTTPS) # --------------------------------------------------------------------------- log_step 8 "Reloading Nginx with HTTPS..." ssh_exec UNRAID "docker exec ${NGINX_CONTAINER_NAME} nginx -s reload" log_success "Nginx reloaded with HTTPS" # --------------------------------------------------------------------------- # Step 9: Verify HTTPS works # --------------------------------------------------------------------------- log_step 9 "Verifying HTTPS..." sleep 2 # Brief pause for Nginx to apply the new config if curl -sf -o /dev/null "https://${GITEA_DOMAIN}/api/v1/version" 2>/dev/null; then log_success "HTTPS verified — https://${GITEA_DOMAIN} works" else log_error "HTTPS verification failed — https://${GITEA_DOMAIN} not responding" exit 1 fi # Verify HTTP redirects to HTTPS HTTP_STATUS=$(curl -sI -o /dev/null -w "%{http_code}" "http://${GITEA_DOMAIN}/" 2>/dev/null || true) if [[ "$HTTP_STATUS" == "301" ]]; then log_success "HTTP → HTTPS redirect working (301)" else log_warn "HTTP redirect returned ${HTTP_STATUS} instead of 301" fi # --------------------------------------------------------------------------- # Step 10: Set up cert auto-renewal cron (letsencrypt only) # Runs daily at 3 AM — Certbot only renews when cert is within 30 days of expiry. # After renewal, Nginx is reloaded to pick up the new cert. # --------------------------------------------------------------------------- log_step 10 "Setting up cert auto-renewal..." if [[ "$SSL_MODE" == "letsencrypt" ]]; then # Check if cron already exists if ssh_exec UNRAID "crontab -l 2>/dev/null | grep -q certbot" 2>/dev/null; then log_info "Certbot renewal cron already installed — skipping" else # Append certbot cron to existing crontab ssh_exec UNRAID "(crontab -l 2>/dev/null; echo '0 3 * * * docker run --rm -v /etc/letsencrypt:/etc/letsencrypt -v /var/www/html:/var/www/html certbot/certbot renew --quiet && docker exec ${NGINX_CONTAINER_NAME} nginx -s reload') | crontab -" log_success "Certbot renewal cron installed (daily at 3 AM)" fi else log_info "SSL_MODE=existing — skipping auto-renewal (user manages certs)" fi # --------------------------------------------------------------------------- # Step 11: Mark GitHub repos as offsite backup only # Updates description + homepage to indicate Gitea is primary. # 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. # 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 GITHUB_REPO_UPDATE_FAILURES=0 for repo in "${REPOS[@]}"; do # Fetch repo metadata (single API call) REPO_DATA=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null || echo "{}") CURRENT_DESC=$(printf '%s' "$REPO_DATA" | jq -r '.description // ""') # 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 NEW_DESC="${NEW_DESC} — was: ${CURRENT_DESC}" fi # Update description + homepage, disable wiki and projects UPDATE_PAYLOAD=$(jq -n \ --arg description "$NEW_DESC" \ --arg homepage "https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}" \ '{description: $description, homepage: $homepage, has_wiki: false, has_projects: false}') if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$UPDATE_PAYLOAD" >/dev/null 2>&1; then log_success "Marked GitHub repo as mirror: ${repo}" else log_error "Failed to update GitHub repo: ${repo}" GITHUB_REPO_UPDATE_FAILURES=$((GITHUB_REPO_UPDATE_FAILURES + 1)) fi # Disable GitHub Pages if enabled (Pages can incur bandwidth costs) github_api DELETE "/repos/${GITHUB_USERNAME}/${repo}/pages" >/dev/null 2>&1 || true done # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- printf '\n' if [[ "$GITHUB_REPO_UPDATE_FAILURES" -gt 0 ]]; then log_error "Phase 8 failed: ${GITHUB_REPO_UPDATE_FAILURES} GitHub repo update(s) failed" exit 1 fi log_success "Phase 8 complete — Gitea is live at https://${GITEA_DOMAIN}" log_info "GitHub repos marked as offsite backup. Push mirrors remain active."