#!/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" } caddy_block_extract_for_host() { local infile="$1" host="$2" outfile="$3" awk -v host="$host" ' function trim(s) { sub(/^[[:space:]]+/, "", s) sub(/[[:space:]]+$/, "", s) return s } function brace_delta(s, tmp, opens, closes) { tmp = s opens = gsub(/\{/, "{", tmp) closes = gsub(/\}/, "}", tmp) return opens - closes } function has_host(labels, i, n, parts, token) { labels = trim(labels) gsub(/[[:space:]]+/, "", labels) n = split(labels, parts, ",") for (i = 1; i <= n; i++) { token = parts[i] if (token == host) { return 1 } } return 0 } BEGIN { depth = 0 in_target = 0 target_depth = 0 found = 0 } { line = $0 if (!in_target) { if (depth == 0) { pos = index(line, "{") if (pos > 0) { labels = substr(line, 1, pos - 1) if (trim(labels) != "" && labels !~ /^[[:space:]]*\(/ && has_host(labels)) { in_target = 1 target_depth = brace_delta(line) found = 1 print line next } } } depth += brace_delta(line) } else { target_depth += brace_delta(line) print line if (target_depth <= 0) { in_target = 0 } } } END { if (!found) { exit 1 } } ' "$infile" > "$outfile" } caddy_block_remove_for_host() { local infile="$1" host="$2" outfile="$3" awk -v host="$host" ' function trim(s) { sub(/^[[:space:]]+/, "", s) sub(/[[:space:]]+$/, "", s) return s } function brace_delta(s, tmp, opens, closes) { tmp = s opens = gsub(/\{/, "{", tmp) closes = gsub(/\}/, "}", tmp) return opens - closes } function has_host(labels, i, n, parts, token) { labels = trim(labels) gsub(/[[:space:]]+/, "", labels) n = split(labels, parts, ",") for (i = 1; i <= n; i++) { token = parts[i] if (token == host) { return 1 } } return 0 } BEGIN { depth = 0 in_target = 0 target_depth = 0 removed = 0 } { line = $0 if (in_target) { target_depth += brace_delta(line) if (target_depth <= 0) { in_target = 0 } next } if (depth == 0) { pos = index(line, "{") if (pos > 0) { labels = substr(line, 1, pos - 1) if (trim(labels) != "" && labels !~ /^[[:space:]]*\(/ && has_host(labels)) { in_target = 1 target_depth = brace_delta(line) removed = 1 next } } } print line depth += brace_delta(line) } END { if (!removed) { exit 1 } } ' "$infile" > "$outfile" } upsert_site_block_by_host() { local infile="$1" entry="$2" outfile="$3" local host upstream streaming body_limit skip_verify IFS='|' read -r host upstream streaming body_limit skip_verify <<< "$entry" local tmp_new_block tmp_old_block tmp_without_old tmp_combined tmp_new_block=$(mktemp) tmp_old_block=$(mktemp) tmp_without_old=$(mktemp) tmp_combined=$(mktemp) : > "$tmp_new_block" emit_site_block_standalone "$tmp_new_block" "$host" "$upstream" "$streaming" "$body_limit" "$skip_verify" if caddy_block_extract_for_host "$infile" "$host" "$tmp_old_block"; then log_info "Domain '${host}' already exists; replacing existing site block" log_info "Previous block for '${host}':" sed 's/^/ | /' "$tmp_old_block" >&2 caddy_block_remove_for_host "$infile" "$host" "$tmp_without_old" cat "$tmp_without_old" "$tmp_new_block" > "$tmp_combined" else log_info "Domain '${host}' not present; adding new site block" cat "$infile" "$tmp_new_block" > "$tmp_combined" fi mv "$tmp_combined" "$outfile" rm -f "$tmp_new_block" "$tmp_old_block" "$tmp_without_old" } 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 } 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_WORK=$(mktemp) TMP_NEXT=$(mktemp) cp /dev/null "$TMP_NEXT" ssh_exec UNRAID "cat '${CADDY_DATA_PATH}/Caddyfile'" > "$TMP_WORK" for entry in "${CANARY_HOST_MAP[@]}"; do upsert_site_block_by_host "$TMP_WORK" "$entry" "$TMP_NEXT" mv "$TMP_NEXT" "$TMP_WORK" TMP_NEXT=$(mktemp) done cp "$TMP_WORK" "$TMP_CADDYFILE" rm -f "$TMP_WORK" "$TMP_NEXT" log_info "Canary mode: existing routes preserved; canary domains upserted" 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" probe_http_code_ok() { local code="$1" role="$2" if [[ "$role" == "gitea_api" ]]; then [[ "$code" == "200" ]] return fi [[ "$code" =~ ^(2|3)[0-9][0-9]$ || "$code" == "401" || "$code" == "403" ]] } probe_host_via_caddy() { local host="$1" upstream="$2" role="$3" local path="/" if [[ "$role" == "gitea_api" ]]; then path="/api/v1/version" fi local tmp_body http_code tmp_body=$(mktemp) http_code=$(curl -sk --resolve "${host}:443:${UNRAID_CADDY_IP}" \ -o "$tmp_body" -w "%{http_code}" "https://${host}${path}" 2>/dev/null || echo "000") if probe_http_code_ok "$http_code" "$role"; then log_success "Probe passed: ${host} (HTTP ${http_code})" rm -f "$tmp_body" return 0 fi log_error "Probe failed: ${host} (HTTP ${http_code})" if [[ "$http_code" == "502" || "$http_code" == "503" || "$http_code" == "504" || "$http_code" == "000" ]]; then local upstream_probe_raw upstream_code upstream_probe_raw=$(ssh_exec UNRAID "curl -sk -o /dev/null -w '%{http_code}' '${upstream}' || true" 2>/dev/null || true) upstream_code=$(printf '%s' "$upstream_probe_raw" | tr -cd '0-9') if [[ -z "$upstream_code" ]]; then upstream_code="000" elif [[ ${#upstream_code} -gt 3 ]]; then upstream_code="${upstream_code:$((${#upstream_code} - 3))}" fi log_warn "Upstream check from Unraid: ${upstream} -> HTTP ${upstream_code}" fi rm -f "$tmp_body" return 1 } if [[ "$MODE" == "canary" ]]; then if confirm_action "Run canary HTTPS probe for tower.sintheus.com via Caddy IP now? [y/N] "; then if ! probe_host_via_caddy "tower.sintheus.com" "https://192.168.1.82:443" "generic"; then 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 upstream _ <<< "$entry" role="generic" if [[ "$host" == "$GITEA_DOMAIN" ]]; then role="gitea_api" fi if ! probe_host_via_caddy "$host" "$upstream" "$role"; then 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