#!/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."