diff --git a/phase8_cutover.sh b/phase8_cutover.sh index 6f7d307..46042f0 100755 --- a/phase8_cutover.sh +++ b/phase8_cutover.sh @@ -2,17 +2,13 @@ set -euo pipefail # ============================================================================= -# phase8_cutover.sh — HTTPS via Nginx + Mark GitHub repos as mirrors -# Depends on: All prior phases complete, Nginx running on Unraid +# phase8_cutover.sh — HTTPS via Caddy + Mark GitHub repos as mirrors +# Depends on: All prior phases complete, macvlan network created 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) +# Caddy handles TLS automatically. TLS_MODE from .env controls how: +# "cloudflare" → DNS-01 via Cloudflare API (wildcard cert) +# "existing" → user provides cert/key paths # # After HTTPS is live, GitHub repos are marked as offsite mirrors. # ============================================================================= @@ -21,29 +17,33 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" load_env -require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_GITEA_PORT \ +require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_GITEA_IP UNRAID_CADDY_IP \ GITEA_INTERNAL_URL GITEA_DOMAIN GITEA_ADMIN_TOKEN \ - GITEA_ORG_NAME NGINX_CONTAINER_NAME NGINX_CONF_PATH \ - SSL_MODE GITHUB_USERNAME GITHUB_TOKEN \ + GITEA_ORG_NAME TLS_MODE CADDY_DOMAIN CADDY_DATA_PATH \ + GITHUB_USERNAME GITHUB_TOKEN \ REPO_NAMES -phase_header 8 "Cutover (HTTPS + Mark GitHub Mirrors)" +if [[ "$TLS_MODE" == "cloudflare" ]]; then + require_vars CLOUDFLARE_API_TOKEN +elif [[ "$TLS_MODE" == "existing" ]]; then + require_vars SSL_CERT_PATH SSL_KEY_PATH +else + log_error "Invalid TLS_MODE='${TLS_MODE}' — must be 'cloudflare' or 'existing'" + exit 1 +fi + +phase_header 8 "Cutover (HTTPS via Caddy + Mark GitHub Mirrors)" read -ra REPOS <<< "$REPO_NAMES" 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 +# Strip conditional blocks from a rendered file. +_strip_block() { + local file="$1" start="$2" end="$3" + sed -i.bak "/${start}/,/${end}/d" "$file" + rm -f "${file}.bak" +} # --------------------------------------------------------------------------- # Helper: persist original GitHub repo settings for teardown symmetry @@ -133,232 +133,96 @@ snapshot_repo_state() { } # --------------------------------------------------------------------------- -# 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. +# Step 1: Create Caddy data directories # --------------------------------------------------------------------------- -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" -} +log_step 1 "Creating Caddy data directories on Unraid..." +if ssh_exec UNRAID "test -d '${CADDY_DATA_PATH}/data'"; then + log_info "Caddy data directory already exists — skipping" +else + ssh_exec UNRAID "mkdir -p '${CADDY_DATA_PATH}/data' '${CADDY_DATA_PATH}/config'" + log_success "Caddy data directories created" +fi # --------------------------------------------------------------------------- -# 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). +# Step 2: Render + deploy Caddyfile # --------------------------------------------------------------------------- -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" +log_step 2 "Deploying Caddyfile..." +if ssh_exec UNRAID "test -f '${CADDY_DATA_PATH}/Caddyfile'" 2>/dev/null; then + log_info "Caddyfile already exists — skipping" +else + TMPFILE=$(mktemp) + GITEA_CONTAINER_IP="${UNRAID_GITEA_IP}" + export GITEA_CONTAINER_IP GITEA_DOMAIN + + # Build TLS block based on TLS_MODE + if [[ "$TLS_MODE" == "cloudflare" ]]; then + TLS_BLOCK=" tls { + dns cloudflare {env.CF_API_TOKEN} + }" else - log_info "HTTP-only config exists — skipping" + TLS_BLOCK=" tls ${SSL_CERT_PATH} ${SSL_KEY_PATH}" fi + export TLS_BLOCK + + render_template "${SCRIPT_DIR}/templates/Caddyfile.tpl" "$TMPFILE" \ + "\${GITEA_DOMAIN} \${TLS_BLOCK} \${GITEA_CONTAINER_IP}" + scp_to UNRAID "$TMPFILE" "${CADDY_DATA_PATH}/Caddyfile" + rm -f "$TMPFILE" + log_success "Caddyfile deployed" +fi + +# --------------------------------------------------------------------------- +# Step 3: Render + deploy Caddy docker-compose +# --------------------------------------------------------------------------- +log_step 3 "Deploying Caddy docker-compose..." +if ssh_exec UNRAID "test -f '${CADDY_DATA_PATH}/docker-compose.yml'" 2>/dev/null; then + log_info "Caddy docker-compose.yml already exists — skipping" 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 + TMPFILE=$(mktemp) + CADDY_CONTAINER_IP="${UNRAID_CADDY_IP}" + export CADDY_CONTAINER_IP CADDY_DATA_PATH -# --------------------------------------------------------------------------- -# 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" + if [[ "$TLS_MODE" == "cloudflare" ]]; then + CADDY_ENV_VARS=" - CF_API_TOKEN=${CLOUDFLARE_API_TOKEN}" + CADDY_EXTRA_VOLUMES="" 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" + CADDY_ENV_VARS="" + # Mount cert/key files into the container + CADDY_EXTRA_VOLUMES=" - ${SSL_CERT_PATH}:${SSL_CERT_PATH}:ro + - ${SSL_KEY_PATH}:${SSL_KEY_PATH}:ro" fi + export CADDY_ENV_VARS CADDY_EXTRA_VOLUMES - 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" + render_template "${SCRIPT_DIR}/templates/docker-compose-caddy.yml.tpl" "$TMPFILE" \ + "\${CADDY_DATA_PATH} \${CADDY_CONTAINER_IP} \${CADDY_ENV_VARS} \${CADDY_EXTRA_VOLUMES}" + scp_to UNRAID "$TMPFILE" "${CADDY_DATA_PATH}/docker-compose.yml" + rm -f "$TMPFILE" + log_success "Caddy docker-compose.yml deployed" fi # --------------------------------------------------------------------------- -# Step 6: Deploy HTTPS Nginx config -# Overwrites the HTTP-only config with the full HTTPS version. +# Step 4: Start Caddy container # --------------------------------------------------------------------------- -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" +log_step 4 "Starting Caddy container..." +CONTAINER_STATUS=$(ssh_exec UNRAID "docker ps --filter name=caddy --format '{{.Status}}'" 2>/dev/null || true) +if [[ "$CONTAINER_STATUS" == *"Up"* ]]; then + log_info "Caddy container already running — skipping" 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" + ssh_exec UNRAID "cd '${CADDY_DATA_PATH}' && docker compose up -d 2>/dev/null || docker-compose up -d" + log_success "Caddy container started" 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. +# Step 5: Wait for HTTPS to work +# Caddy auto-obtains certs — poll until HTTPS responds. # --------------------------------------------------------------------------- -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 +log_step 5 "Waiting for HTTPS (Caddy auto-provisions cert)..." +wait_for_http "https://${GITEA_DOMAIN}/api/v1/version" 120 + +log_success "HTTPS verified — https://${GITEA_DOMAIN} works" # --------------------------------------------------------------------------- -# Step 11: Mark GitHub repos as offsite backup only +# Step 6: 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 @@ -366,7 +230,7 @@ fi # 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..." +log_step 6 "Marking GitHub repos as offsite backup..." init_phase8_state_store GITHUB_REPO_UPDATE_FAILURES=0