From b52d3187d99d10bdf716e4f7e9ea9a22a2f1dd85 Mon Sep 17 00:00:00 2001 From: S Date: Mon, 2 Mar 2026 22:22:07 -0600 Subject: [PATCH] feat: enhance canary mode in Nginx to Caddy migration script to preserve existing routes --- TODO.md | 6 +++ phase7_5_nginx_to_caddy.sh | 89 +++++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index b0d8284..ed20191 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,9 @@ work so we do not lose reasoning between sessions. - `--strict-backend-https` fails if any upstream is `http://`. 5. Canary-first rollout: - first migration target is `tower.sintheus.com`. +6. Canary mode is additive: + - preserves existing Caddy routes + - updates only a managed canary block for `tower.sintheus.com`. ## Host map and backend TLS status @@ -109,3 +112,6 @@ Phase 7.5 is done only when all are true: 3. Script does not change Cloudflare DNS records automatically. - DNS updates are intentional/manual to keep blast radius controlled. 4. Do not set public Cloudflare proxied records to private `192.168.x.x` addresses. +5. Canary updates are enclosed between markers: + - `# BEGIN_PHASE7_5_CANARY` + - `# END_PHASE7_5_CANARY` diff --git a/phase7_5_nginx_to_caddy.sh b/phase7_5_nginx_to_caddy.sh index 154fafb..ca4af21 100755 --- a/phase7_5_nginx_to_caddy.sh +++ b/phase7_5_nginx_to_caddy.sh @@ -83,7 +83,7 @@ else exit 1 fi -phase_header "8.5" "Nginx to Caddy Migration (Multi-domain)" +phase_header "7.5" "Nginx to Caddy Migration (Multi-domain)" # host|upstream|streaming(true/false)|body_limit|insecure_skip_verify(true/false) FULL_HOST_MAP=( @@ -104,10 +104,12 @@ CANARY_HOST_MAP=( GITEA_ENTRY="${GITEA_DOMAIN}|http://${UNRAID_GITEA_IP}:3000|false||false" CADDY_COMPOSE_DIR="${UNRAID_COMPOSE_DIR}/caddy" +CANARY_BEGIN_MARKER="# BEGIN_PHASE7_5_CANARY" +CANARY_END_MARKER="# END_PHASE7_5_CANARY" SELECTED_HOST_MAP=() if [[ "$MODE" == "canary" ]]; then - SELECTED_HOST_MAP=( "${CANARY_HOST_MAP[@]}" "$GITEA_ENTRY" ) + SELECTED_HOST_MAP=( "${CANARY_HOST_MAP[@]}" ) else SELECTED_HOST_MAP=( "${FULL_HOST_MAP[@]}" "$GITEA_ENTRY" ) fi @@ -170,6 +172,46 @@ emit_site_block() { } >> "$outfile" } +emit_site_block_standalone() { + local outfile="$1" host="$2" upstream="$3" streaming="$4" body_limit="$5" skip_verify="$6" + + { + echo "${host} {" + if [[ "$TLS_MODE" == "existing" ]]; then + echo " tls ${SSL_CERT_PATH} ${SSL_KEY_PATH}" + fi + echo " encode zstd gzip" + echo " header {" + echo " Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\"" + echo " X-Content-Type-Options \"nosniff\"" + echo " X-Frame-Options \"SAMEORIGIN\"" + echo " Referrer-Policy \"strict-origin-when-cross-origin\"" + echo " -Server" + echo " }" + echo + if [[ -n "$body_limit" ]]; then + echo " request_body {" + echo " max_size ${body_limit}" + echo " }" + echo + fi + echo " reverse_proxy ${upstream} {" + echo " header_up Host {host}" + echo " header_up X-Real-IP {remote_host}" + if [[ "$streaming" == "true" ]]; then + echo " flush_interval -1" + fi + if [[ "$skip_verify" == "true" && "$upstream" == https://* ]]; then + echo " transport http {" + echo " tls_insecure_skip_verify" + echo " }" + fi + echo " }" + echo "}" + echo + } >> "$outfile" +} + build_caddyfile() { local outfile="$1" local entry host upstream streaming body_limit skip_verify @@ -218,6 +260,29 @@ build_caddyfile() { done } +build_canary_fragment() { + local outfile="$1" + local entry host upstream streaming body_limit skip_verify + : > "$outfile" + { + echo + echo "${CANARY_BEGIN_MARKER}" + echo "# Managed by phase7_5_nginx_to_caddy.sh (canary additive mode)" + echo "# Existing routes above are preserved." + echo + } >> "$outfile" + + for entry in "${CANARY_HOST_MAP[@]}"; do + IFS='|' read -r host upstream streaming body_limit skip_verify <<< "$entry" + emit_site_block_standalone "$outfile" "$host" "$upstream" "$streaming" "$body_limit" "$skip_verify" + done + + { + echo "${CANARY_END_MARKER}" + echo + } >> "$outfile" +} + if ! validate_backend_tls_policy; then exit 1 fi @@ -266,14 +331,28 @@ log_success "Caddy compose deployed to ${CADDY_COMPOSE_DIR}" log_step 3 "Generating and deploying multi-domain Caddyfile..." TMP_CADDYFILE=$(mktemp) -build_caddyfile "$TMP_CADDYFILE" - +HAS_EXISTING_CADDYFILE=false if ssh_exec UNRAID "test -f '${CADDY_DATA_PATH}/Caddyfile'" 2>/dev/null; then + HAS_EXISTING_CADDYFILE=true BACKUP_PATH="${CADDY_DATA_PATH}/Caddyfile.pre_phase7_5.$(date +%Y%m%d%H%M%S)" ssh_exec UNRAID "cp '${CADDY_DATA_PATH}/Caddyfile' '${BACKUP_PATH}'" log_info "Backed up previous Caddyfile to ${BACKUP_PATH}" fi +if [[ "$MODE" == "canary" && "$HAS_EXISTING_CADDYFILE" == "true" ]]; then + TMP_EXISTING=$(mktemp) + TMP_CLEAN=$(mktemp) + TMP_FRAGMENT=$(mktemp) + ssh_exec UNRAID "cat '${CADDY_DATA_PATH}/Caddyfile'" > "$TMP_EXISTING" + sed "/^${CANARY_BEGIN_MARKER}\$/,/^${CANARY_END_MARKER}\$/d" "$TMP_EXISTING" > "$TMP_CLEAN" + build_canary_fragment "$TMP_FRAGMENT" + cat "$TMP_CLEAN" "$TMP_FRAGMENT" > "$TMP_CADDYFILE" + rm -f "$TMP_EXISTING" "$TMP_CLEAN" "$TMP_FRAGMENT" + log_info "Canary mode: preserved existing routes and updated canary block only" +else + build_caddyfile "$TMP_CADDYFILE" +fi + scp_to UNRAID "$TMP_CADDYFILE" "${CADDY_DATA_PATH}/Caddyfile" rm -f "$TMP_CADDYFILE" log_success "Caddyfile deployed" @@ -320,7 +399,7 @@ log_info "Next (no DNS change required): verify via curl --resolve and browser c log_info "LAN-only routing option: split-DNS/hosts override to ${UNRAID_CADDY_IP}" log_info "Public routing option: point public DNS to WAN ingress (not 192.168.x.x) and forward 443 to Caddy" if [[ "$MODE" == "canary" ]]; then - log_info "Canary host is tower.sintheus.com (plus Gitea host entry present in config)" + log_info "Canary host is tower.sintheus.com; existing routes were preserved" else log_info "Full host map is now active in Caddy" fi