From 9379b95a4191d35b10591796f1158847716f25e3 Mon Sep 17 00:00:00 2001 From: S Date: Thu, 26 Feb 2026 15:29:14 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Phase=208=20=E2=80=94=20Cutover?= =?UTF-8?q?=20(HTTPS=20+=20Archive=20GitHub)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- phase8_cutover.sh | 309 +++++++++++++++++++++++++++++++++++++++++++ phase8_post_check.sh | 87 ++++++++++++ phase8_teardown.sh | 107 +++++++++++++++ 3 files changed, 503 insertions(+) create mode 100755 phase8_cutover.sh create mode 100755 phase8_post_check.sh create mode 100755 phase8_teardown.sh diff --git a/phase8_cutover.sh b/phase8_cutover.sh new file mode 100755 index 0000000..9457a45 --- /dev/null +++ b/phase8_cutover.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase8_cutover.sh — HTTPS via Nginx + Archive GitHub repos +# 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 archived with a "[MOVED]" description. +# ============================================================================= + +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 + Archive GitHub)" + +REPOS=("$REPO_1_NAME" "$REPO_2_NAME" "$REPO_3_NAME") + +# 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: 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" + + # 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" + + # Replace the redirect block content with a 301 redirect to HTTPS + # The block between markers gets replaced with just the redirect + 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: Archive GitHub repos +# Marks repos as archived with a "[MOVED]" description pointing to Gitea. +# Preserves the original description by appending it after "— was: ". +# --------------------------------------------------------------------------- +log_step 11 "Archiving GitHub repos..." + +for repo in "${REPOS[@]}"; do + # Check if already archived + IS_ARCHIVED=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.archived' || echo "false") + if [[ "$IS_ARCHIVED" == "true" ]]; then + log_info "GitHub repo ${repo} already archived — skipping" + continue + fi + + # Get original description to preserve it + ORIGINAL_DESC=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.description // ""' || echo "") + + # Build new description with moved notice + NEW_DESC="[MOVED] Now at https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}" + if [[ -n "$ORIGINAL_DESC" ]]; then + NEW_DESC="${NEW_DESC} — was: ${ORIGINAL_DESC}" + fi + + # Archive the repo with the new description + ARCHIVE_PAYLOAD=$(jq -n \ + --arg description "$NEW_DESC" \ + '{archived: true, description: $description}') + + if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$ARCHIVE_PAYLOAD" >/dev/null 2>&1; then + log_success "Archived GitHub repo: ${repo}" + else + log_error "Failed to archive GitHub repo: ${repo}" + fi +done + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +printf '\n' +log_success "Phase 8 complete — Gitea is live at https://${GITEA_DOMAIN}" +log_info "GitHub repos have been archived. Gitea is now the primary git host." diff --git a/phase8_post_check.sh b/phase8_post_check.sh new file mode 100755 index 0000000..1b558cc --- /dev/null +++ b/phase8_post_check.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase8_post_check.sh — Verify Phase 8 (Cutover) succeeded +# Checks: +# 1. HTTPS works with valid cert +# 2. HTTP redirects to HTTPS +# 3. All repos accessible via HTTPS +# 4. GitHub repos are archived +# Exits 0 only if ALL checks pass. +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" + +load_env +require_vars GITEA_DOMAIN GITEA_ADMIN_TOKEN GITEA_ORG_NAME \ + GITHUB_USERNAME GITHUB_TOKEN \ + REPO_1_NAME REPO_2_NAME REPO_3_NAME + +log_info "=== Phase 8 Post-Check ===" + +REPOS=("$REPO_1_NAME" "$REPO_2_NAME" "$REPO_3_NAME") +PASS=0 +FAIL=0 + +run_check() { + local description="$1"; shift + if "$@" 2>/dev/null; then + log_success "$description" + PASS=$((PASS + 1)) + else + log_error "FAIL: $description" + FAIL=$((FAIL + 1)) + fi +} + +# Check 1: HTTPS works +run_check "HTTPS returns 200 at https://${GITEA_DOMAIN}" \ + curl -sf -o /dev/null "https://${GITEA_DOMAIN}/api/v1/version" + +# Check 2: HTTP redirects to HTTPS (returns 301) +check_redirect() { + local http_code + http_code=$(curl -sI -o /dev/null -w "%{http_code}" "http://${GITEA_DOMAIN}/") + [[ "$http_code" == "301" ]] +} +run_check "HTTP → HTTPS redirect (301)" check_redirect + +# Check 3: SSL certificate is valid (not self-signed) +check_ssl_cert() { + # Verify openssl can connect and the cert is issued by a recognized CA + local issuer + issuer=$(echo | openssl s_client -connect "${GITEA_DOMAIN}:443" -servername "${GITEA_DOMAIN}" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "") + # Check that the issuer is not empty (meaning cert is valid) + [[ -n "$issuer" ]] +} +run_check "SSL certificate is valid" check_ssl_cert + +# Check 4: All repos accessible via HTTPS +for repo in "${REPOS[@]}"; do + run_check "Repo ${repo} accessible at https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}" \ + curl -sf -o /dev/null -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "https://${GITEA_DOMAIN}/api/v1/repos/${GITEA_ORG_NAME}/${repo}" +done + +# Check 5: GitHub repos are archived +for repo in "${REPOS[@]}"; do + check_archived() { + local is_archived + is_archived=$(github_api GET "/repos/${GITHUB_USERNAME}/$1" | jq -r '.archived') + [[ "$is_archived" == "true" ]] + } + run_check "GitHub repo ${repo} is archived" check_archived "$repo" +done + +# Summary +printf '\n' +log_info "Results: ${PASS} passed, ${FAIL} failed" + +if [[ $FAIL -gt 0 ]]; then + log_error "Phase 8 post-check FAILED" + exit 1 +else + log_success "Phase 8 post-check PASSED — Gitea is live with HTTPS" + exit 0 +fi diff --git a/phase8_teardown.sh b/phase8_teardown.sh new file mode 100755 index 0000000..74baf51 --- /dev/null +++ b/phase8_teardown.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# phase8_teardown.sh — Reverse the cutover: remove HTTPS, un-archive GitHub +# Steps: +# 1. Remove Nginx gitea.conf + reload +# 2. Remove cert renewal cron +# 3. Optionally remove SSL certificates +# 4. Un-archive GitHub repos + restore original descriptions +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" + +load_env +require_vars UNRAID_IP UNRAID_SSH_USER \ + GITEA_DOMAIN NGINX_CONTAINER_NAME NGINX_CONF_PATH \ + SSL_MODE GITHUB_USERNAME GITHUB_TOKEN \ + REPO_1_NAME REPO_2_NAME REPO_3_NAME + +log_warn "=== Phase 8 Teardown: Cutover ===" + +REPOS=("$REPO_1_NAME" "$REPO_2_NAME" "$REPO_3_NAME") + +# --------------------------------------------------------------------------- +# Step 1: Remove Nginx config and reload +# --------------------------------------------------------------------------- +if ssh_exec UNRAID "test -f '${NGINX_CONF_PATH}/gitea.conf'" 2>/dev/null; then + printf 'Remove Nginx config for %s? [y/N] ' "$GITEA_DOMAIN" + read -r confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + ssh_exec UNRAID "rm -f '${NGINX_CONF_PATH}/gitea.conf'" + ssh_exec UNRAID "docker exec ${NGINX_CONTAINER_NAME} nginx -s reload" || true + log_success "Nginx config removed and reloaded" + else + log_info "Nginx config preserved" + fi +else + log_info "Nginx config already removed" +fi + +# --------------------------------------------------------------------------- +# Step 2: Remove cert renewal cron +# --------------------------------------------------------------------------- +if ssh_exec UNRAID "crontab -l 2>/dev/null | grep -q certbot" 2>/dev/null; then + ssh_exec UNRAID "crontab -l 2>/dev/null | grep -v certbot | crontab -" + log_success "Certbot renewal cron removed" +else + log_info "No certbot cron found" +fi + +# --------------------------------------------------------------------------- +# Step 3: Optionally remove SSL certificates (letsencrypt only) +# --------------------------------------------------------------------------- +if [[ "$SSL_MODE" == "letsencrypt" ]]; then + if ssh_exec UNRAID "test -d '/etc/letsencrypt/live/${GITEA_DOMAIN}'" 2>/dev/null; then + printf 'Remove SSL certificates for %s? [y/N] ' "$GITEA_DOMAIN" + read -r confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + ssh_exec UNRAID "rm -rf '/etc/letsencrypt/live/${GITEA_DOMAIN}' '/etc/letsencrypt/archive/${GITEA_DOMAIN}' '/etc/letsencrypt/renewal/${GITEA_DOMAIN}.conf'" + log_success "SSL certificates removed" + else + log_info "SSL certificates preserved" + fi + fi +fi + +# --------------------------------------------------------------------------- +# Step 4: Un-archive GitHub repos + restore original descriptions +# The archive description format is: "[MOVED] ... — was: ORIGINAL_DESC" +# We parse the original description from after "— was: " to restore it. +# --------------------------------------------------------------------------- +printf 'Un-archive GitHub repos and restore descriptions? [y/N] ' +read -r confirm +if [[ "$confirm" =~ ^[Yy]$ ]]; then + for repo in "${REPOS[@]}"; do + IS_ARCHIVED=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.archived' || echo "false") + if [[ "$IS_ARCHIVED" != "true" ]]; then + log_info "GitHub repo ${repo} not archived — skipping" + continue + fi + + # Extract original description from the archived description + CURRENT_DESC=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.description // ""') + ORIGINAL_DESC="" + if [[ "$CURRENT_DESC" == *" — was: "* ]]; then + # Extract everything after "— was: " + ORIGINAL_DESC="${CURRENT_DESC##* — was: }" + fi + + # Un-archive and restore description + RESTORE_PAYLOAD=$(jq -n \ + --arg description "$ORIGINAL_DESC" \ + '{archived: false, description: $description}') + + if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$RESTORE_PAYLOAD" >/dev/null 2>&1; then + log_success "Un-archived GitHub repo: ${repo}" + else + log_error "Failed to un-archive GitHub repo: ${repo}" + fi + done +else + log_info "GitHub repos left as-is" +fi + +log_success "Phase 8 teardown complete"