feat: rewrite phase8_cutover.sh from Nginx to Caddy reverse proxy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user