#!/usr/bin/env bash set -euo pipefail # ============================================================================= # phase7_5_nginx_to_caddy.sh — One-time Nginx -> Caddy migration cutover helper # # Goals: # - Serve both sintheus.com and privacyindesign.com hostnames from one Caddy # - Keep public ingress HTTPS-only # - Support canary-first rollout (default: tower.sintheus.com only) # - Preserve current mixed backend schemes (http/https) unless strict mode is enabled # # Usage examples: # ./phase7_5_nginx_to_caddy.sh # ./phase7_5_nginx_to_caddy.sh --mode=full # ./phase7_5_nginx_to_caddy.sh --mode=full --strict-backend-https # ./phase7_5_nginx_to_caddy.sh --mode=canary --yes # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" AUTO_YES=false MODE="canary" # canary|full STRICT_BACKEND_HTTPS=false # Reuse Unraid's existing Docker network. UNRAID_DOCKER_NETWORK_NAME="br0" usage() { cat < ${upstream}" ) fi done if [[ "${#non_tls_entries[@]}" -eq 0 ]]; then log_success "All selected backends are HTTPS" return 0 fi if [[ "$STRICT_BACKEND_HTTPS" == "true" ]]; then log_error "Strict backend HTTPS is enabled, but these entries are not HTTPS:" printf '%s\n' "${non_tls_entries[@]}" | sed 's/^/ - /' >&2 return 1 fi log_warn "Using mixed backend schemes (allowed):" printf '%s\n' "${non_tls_entries[@]}" | sed 's/^/ - /' >&2 } emit_site_block() { 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 " import common_security" echo if [[ -n "$body_limit" ]]; then echo " request_body {" echo " max_size ${body_limit}" echo " }" echo fi echo " reverse_proxy ${upstream} {" if [[ "$streaming" == "true" ]]; then echo " import proxy_streaming" else echo " import proxy_headers" fi if [[ "$skip_verify" == "true" && "$upstream" == https://* ]]; then echo " transport http {" echo " tls_insecure_skip_verify" echo " }" fi echo " }" echo "}" echo } >> "$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 : > "$outfile" { echo "# Generated by phase7_5_nginx_to_caddy.sh" echo "# Mode: ${MODE}" echo echo "{" if [[ "$TLS_MODE" == "cloudflare" ]]; then echo " acme_dns cloudflare {env.CF_API_TOKEN}" fi echo " servers {" echo " trusted_proxies static private_ranges" echo " protocols h1 h2 h3" echo " }" echo "}" echo echo "(common_security) {" 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 "}" echo echo "(proxy_headers) {" echo " header_up Host {host}" echo " header_up X-Real-IP {remote_host}" echo "}" echo echo "(proxy_streaming) {" echo " import proxy_headers" echo " flush_interval -1" echo "}" echo } >> "$outfile" for entry in "${SELECTED_HOST_MAP[@]}"; do IFS='|' read -r host upstream streaming body_limit skip_verify <<< "$entry" emit_site_block "$outfile" "$host" "$upstream" "$streaming" "$body_limit" "$skip_verify" 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 log_step 1 "Creating Caddy data directories on Unraid..." ssh_exec UNRAID "mkdir -p '${CADDY_DATA_PATH}/data' '${CADDY_DATA_PATH}/config'" log_success "Caddy data directories ensured" log_step 2 "Deploying Caddy docker-compose on Unraid..." if ! ssh_exec UNRAID "docker network inspect '${UNRAID_DOCKER_NETWORK_NAME}'" &>/dev/null; then log_error "Required Docker network '${UNRAID_DOCKER_NETWORK_NAME}' not found on Unraid" exit 1 fi ssh_exec UNRAID "mkdir -p '${CADDY_COMPOSE_DIR}'" TMP_COMPOSE=$(mktemp) CADDY_CONTAINER_IP="${UNRAID_CADDY_IP}" GITEA_NETWORK_NAME="${UNRAID_DOCKER_NETWORK_NAME}" export CADDY_CONTAINER_IP CADDY_DATA_PATH GITEA_NETWORK_NAME if [[ "$TLS_MODE" == "cloudflare" ]]; then CADDY_ENV_VARS=" - CF_API_TOKEN=${CLOUDFLARE_API_TOKEN}" CADDY_EXTRA_VOLUMES="" else CADDY_ENV_VARS="" 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 render_template "${SCRIPT_DIR}/templates/docker-compose-caddy.yml.tpl" "$TMP_COMPOSE" \ "\${CADDY_DATA_PATH} \${CADDY_CONTAINER_IP} \${CADDY_ENV_VARS} \${CADDY_EXTRA_VOLUMES} \${GITEA_NETWORK_NAME}" if [[ -z "$CADDY_ENV_VARS" ]]; then sed -i.bak '/^[[:space:]]*environment:$/d' "$TMP_COMPOSE" rm -f "${TMP_COMPOSE}.bak" fi if [[ -z "$CADDY_EXTRA_VOLUMES" ]]; then sed -i.bak -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$TMP_COMPOSE" rm -f "${TMP_COMPOSE}.bak" fi scp_to UNRAID "$TMP_COMPOSE" "${CADDY_COMPOSE_DIR}/docker-compose.yml" rm -f "$TMP_COMPOSE" log_success "Caddy compose deployed to ${CADDY_COMPOSE_DIR}" log_step 3 "Generating and deploying multi-domain Caddyfile..." TMP_CADDYFILE=$(mktemp) 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" log_step 4 "Starting/reloading Caddy container..." ssh_exec UNRAID "cd '${CADDY_COMPOSE_DIR}' && docker compose up -d 2>/dev/null || docker-compose up -d" if ! ssh_exec UNRAID "docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile" &>/dev/null; then log_warn "Hot reload failed; restarting caddy container" ssh_exec UNRAID "docker restart caddy" >/dev/null fi log_success "Caddy container is running with new config" if [[ "$MODE" == "canary" ]]; then if confirm_action "Run canary HTTPS probe for tower.sintheus.com via Caddy IP now? [y/N] "; then if curl -skf --resolve "tower.sintheus.com:443:${UNRAID_CADDY_IP}" \ "https://tower.sintheus.com/" >/dev/null; then log_success "Canary probe passed: tower.sintheus.com via ${UNRAID_CADDY_IP}" else log_error "Canary probe failed for tower.sintheus.com via ${UNRAID_CADDY_IP}" exit 1 fi fi else log_step 5 "Probing all configured hosts via Caddy IP..." PROBE_FAILS=0 for entry in "${SELECTED_HOST_MAP[@]}"; do IFS='|' read -r host _ <<< "$entry" if curl -skf --resolve "${host}:443:${UNRAID_CADDY_IP}" "https://${host}/" >/dev/null; then log_success "Probe passed: ${host}" else log_error "Probe failed: ${host}" PROBE_FAILS=$((PROBE_FAILS + 1)) fi done if [[ "$PROBE_FAILS" -gt 0 ]]; then log_error "One or more probes failed (${PROBE_FAILS})" exit 1 fi fi printf '\n' log_success "Phase 7.5 complete (${MODE} mode)" log_info "Next (no DNS change required): verify via curl --resolve and browser checks" 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; existing routes were preserved" else log_info "Full host map is now active in Caddy" fi