diff --git a/setup/nginx-to-caddy/CUTOVER_CHECKLIST.md b/setup/nginx-to-caddy/CUTOVER_CHECKLIST.md new file mode 100644 index 0000000..422ca38 --- /dev/null +++ b/setup/nginx-to-caddy/CUTOVER_CHECKLIST.md @@ -0,0 +1,26 @@ +# Cutover Checklist + +## Pre-cutover +- [ ] `nginx -T` snapshot captured (`output/nginx-full.conf`) +- [ ] Generated Caddyfile reviewed +- [ ] `conversion-warnings.txt` reviewed and resolved for canary site +- [ ] `validate_caddy.sh` passes +- [ ] DNS TTL lowered for canary domain + +## Canary +- [ ] One subdomain switched to Caddy +- [ ] HTTPS cert valid +- [ ] UI and API calls work +- [ ] Websocket/live components work +- [ ] Access/error logs clean + +## Full rollout +- [ ] Remaining subdomains switched in batches +- [ ] Monitor 24h +- [ ] Keep Nginx rollback config archived + +## Rollback trigger +- [ ] TLS failures +- [ ] High 4xx/5xx +- [ ] Broken websockets/realtime components +- [ ] Unexpected redirects or auth loops diff --git a/setup/nginx-to-caddy/README.md b/setup/nginx-to-caddy/README.md new file mode 100644 index 0000000..3dd6282 --- /dev/null +++ b/setup/nginx-to-caddy/README.md @@ -0,0 +1,44 @@ +# Nginx to Caddy Toolkit + +Purpose: inventory an existing Nginx reverse-proxy setup and generate a first-pass Caddyfile for simple host/path proxying. + +This module is intentionally conservative: +- it auto-converts common/basic patterns +- it flags complex directives for manual review +- it never edits live Nginx config + +## Scripts + +- `extract_nginx_inventory.sh` + - SSH into a host and collect `nginx -T`, `/etc/nginx` tarball, and a quick inventory summary. +- `nginx_to_caddy.sh` + - Convert basic Nginx server blocks into a generated Caddyfile. +- `validate_caddy.sh` + - Run `caddy fmt`, `caddy adapt`, and `caddy validate` on the generated Caddyfile. + +## Quick Start + +```bash +cd setup/nginx-to-caddy + +./extract_nginx_inventory.sh --host= --user= --port=22 --yes +./nginx_to_caddy.sh --input=./output/nginx-full.conf --output=./output/Caddyfile.generated --tls-mode=cloudflare --yes +./validate_caddy.sh --config=./output/Caddyfile.generated --docker +``` + +## Conversion Scope + +Automatically handled: +- `server_name` +- `listen` (80/443 hints) +- `ssl_certificate`, `ssl_certificate_key` +- `return 301/302 ...` +- `location` + `proxy_pass` (simple prefix or exact path) + +Manual follow-up required for: +- regex locations (`~`, `~*`) +- `rewrite`, `try_files`, `if`, `map` +- FastCGI/uWSGI/SCGI/gRPC backends +- auth subrequests, Lua, or advanced caching directives + +See `USAGE_GUIDE.md` for a safe migration workflow. diff --git a/setup/nginx-to-caddy/USAGE_GUIDE.md b/setup/nginx-to-caddy/USAGE_GUIDE.md new file mode 100644 index 0000000..b1ad316 --- /dev/null +++ b/setup/nginx-to-caddy/USAGE_GUIDE.md @@ -0,0 +1,95 @@ +# Usage Guide: Nginx to Caddy Migration + +This guide helps you migrate safely without big-bang risk. + +## 1) Collect Nginx inventory + +Run from your admin machine: + +```bash +cd setup/nginx-to-caddy +./extract_nginx_inventory.sh --host= --user= --port=22 --yes +``` + +You will get: +- `output/nginx-full.conf` +- `output/etc-nginx.tar.gz` +- `output/inventory-summary.txt` + +## 2) Generate initial Caddyfile + +For Cloudflare DNS-01 flow: + +```bash +./nginx_to_caddy.sh \ + --input=./output/nginx-full.conf \ + --output=./output/Caddyfile.generated \ + --warnings=./output/conversion-warnings.txt \ + --tls-mode=cloudflare \ + --yes +``` + +Review warnings: + +```bash +cat ./output/conversion-warnings.txt +``` + +If warnings include rewrite/regex/auth directives, expect manual edits. + +## 3) Validate generated config + +If `caddy` is not installed locally, use Docker: + +```bash +./validate_caddy.sh --config=./output/Caddyfile.generated --docker +``` + +If local `caddy` is installed: + +```bash +./validate_caddy.sh --config=./output/Caddyfile.generated +``` + +## 4) Canary migration (recommended) + +Migrate one low-risk subdomain first: +1. Copy only one site block from generated Caddyfile to your live Caddy config. +2. Point only that DNS record to Caddy target. +3. Verify: + - TLS cert valid + - page loads + - API/websocket calls work +4. Keep Nginx serving all other subdomains. + +## 5) Full migration after canary success + +When the canary is stable: +1. Add remaining site blocks. +2. Move DNS entries in batches. +3. Keep Nginx config snapshots for rollback. +4. Decommission Nginx only after monitoring period. + +## 6) Rollback plan + +If a site fails after cutover: +1. Repoint affected DNS entry back to Nginx endpoint. +2. Restore previous Nginx server block. +3. Investigate conversion warnings for that block. + +## 7) Domain/TLS note for your current setup + +You confirmed the domain is `privacyindesign.com`. + +If you use `TLS_MODE=cloudflare` with Caddy, ensure: +- Caddy site labels use `*.privacyindesign.com` or specific subdomains under it. +- Cloudflare token has DNS edit on the same zone. +- DNS records point to the Caddy ingress path you intend (direct or via edge proxy). + +## 8) Suggested next step for Phase 8 + +Given your current repo config: +- keep Phase 8 Caddy focused on `source.privacyindesign.com` +- migrate broader Nginx estate separately with this toolkit + +That keeps Gitea cutover small and lowers blast radius. diff --git a/setup/nginx-to-caddy/extract_nginx_inventory.sh b/setup/nginx-to-caddy/extract_nginx_inventory.sh new file mode 100755 index 0000000..f6589ec --- /dev/null +++ b/setup/nginx-to-caddy/extract_nginx_inventory.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./lib.sh +source "$SCRIPT_DIR/lib.sh" + +REMOTE_HOST="" +REMOTE_USER="" +REMOTE_PORT="22" +OUT_DIR="$SCRIPT_DIR/output" +AUTO_YES=false +USE_SUDO=true + +usage() { + cat </dev/null + +log_info "Capturing nginx version and build info..." +ssh "${ssh_opts[@]}" "$ssh_target" "${sudo_prefix}nginx -V 2>&1" > "${OUT_DIR}/nginx-version.txt" + +log_info "Capturing full rendered nginx config (nginx -T)..." +ssh "${ssh_opts[@]}" "$ssh_target" "${sudo_prefix}nginx -T 2>&1" > "${OUT_DIR}/nginx-full.conf" + +log_info "Capturing /etc/nginx snapshot..." +ssh "${ssh_opts[@]}" "$ssh_target" "${sudo_prefix}tar -C / -czf - etc/nginx" > "${OUT_DIR}/etc-nginx.tar.gz" + +log_info "Building inventory summary..." +{ + echo "Inventory generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo + echo "Server names:" + awk ' + /^[[:space:]]*server_name[[:space:]]+/ { + line=$0 + sub(/^[[:space:]]*server_name[[:space:]]+/, "", line) + sub(/[[:space:]]*;[[:space:]]*$/, "", line) + gsub(/[[:space:]]+/, " ", line) + print " - " line + } + ' "${OUT_DIR}/nginx-full.conf" | sort -u + echo + echo "Proxy targets:" + awk ' + /^[[:space:]]*proxy_pass[[:space:]]+/ { + line=$0 + sub(/^[[:space:]]*proxy_pass[[:space:]]+/, "", line) + sub(/[[:space:]]*;[[:space:]]*$/, "", line) + print " - " line + } + ' "${OUT_DIR}/nginx-full.conf" | sort -u +} > "${OUT_DIR}/inventory-summary.txt" + +log_success "Nginx inventory collected in: ${OUT_DIR}" +log_info "Next: run nginx_to_caddy.sh --input=${OUT_DIR}/nginx-full.conf" diff --git a/setup/nginx-to-caddy/lib.sh b/setup/nginx-to-caddy/lib.sh new file mode 100755 index 0000000..5dfaad3 --- /dev/null +++ b/setup/nginx-to-caddy/lib.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -t 2 ]]; then + _C_RESET='\033[0m' + _C_RED='\033[0;31m' + _C_GREEN='\033[0;32m' + _C_YELLOW='\033[0;33m' + _C_BLUE='\033[0;34m' +else + _C_RESET='' _C_RED='' _C_GREEN='' _C_YELLOW='' _C_BLUE='' +fi + +log_info() { + printf '%b[INFO]%b %s\n' "$_C_BLUE" "$_C_RESET" "$*" >&2 +} + +log_warn() { + printf '%b[WARN]%b %s\n' "$_C_YELLOW" "$_C_RESET" "$*" >&2 +} + +log_error() { + printf '%b[ERROR]%b %s\n' "$_C_RED" "$_C_RESET" "$*" >&2 +} + +log_success() { + printf '%b[OK]%b %s\n' "$_C_GREEN" "$_C_RESET" "$*" >&2 +} + +require_cmd() { + local cmd + for cmd in "$@"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + log_error "Required command not found: $cmd" + return 1 + fi + done +} + +confirm_action() { + local prompt="${1:-Continue?}" + local auto_yes="${2:-false}" + + if [[ "$auto_yes" == "true" ]]; then + return 0 + fi + + printf '%s [y/N] ' "$prompt" + read -r reply + [[ "$reply" =~ ^[Yy]$ ]] +} diff --git a/setup/nginx-to-caddy/nginx_to_caddy.sh b/setup/nginx-to-caddy/nginx_to_caddy.sh new file mode 100755 index 0000000..8a4d734 --- /dev/null +++ b/setup/nginx-to-caddy/nginx_to_caddy.sh @@ -0,0 +1,617 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./lib.sh +source "$SCRIPT_DIR/lib.sh" + +INPUT_FILE="" +OUTPUT_FILE="$SCRIPT_DIR/output/Caddyfile.generated" +WARNINGS_FILE="$SCRIPT_DIR/output/conversion-warnings.txt" +TLS_MODE="auto" +STRICT=false +AUTO_YES=false + +usage() { + cat < "$WARNINGS_FILE" + +warn() { + local msg="$1" + printf '%s\n' "$msg" >> "$WARNINGS_FILE" + log_warn "$msg" +} + +join_by_comma() { + local first=true + local out="" + local item + for item in "$@"; do + if [[ "$first" == true ]]; then + out="$item" + first=false + else + out+=", $item" + fi + done + printf '%s' "$out" +} + +normalize_redirect_target() { + local target="$1" + target="$(printf '%s' "$target" | sed \ + -e 's/\$server_name/{host}/g' \ + -e 's/\$http_host/{host}/g' \ + -e 's/\$host/{host}/g' \ + -e 's/\$request_uri/{uri}/g' \ + -e 's/\$uri/{uri}/g' \ + -e 's/\$scheme/{scheme}/g')" + printf '%s' "$target" +} + +extract_upstream_targets() { + local src="$1" + + awk ' + function clean_comments(s) { + sub(/[[:space:]]*#.*/, "", s) + return s + } + 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 + } + + BEGIN { + in_upstream = 0 + depth = 0 + upstream_name = "" + targets = "" + } + + { + raw = $0 + line = clean_comments(raw) + + if (!in_upstream) { + if (line ~ /^[[:space:]]*upstream[[:space:]]+[A-Za-z0-9_.-]+[[:space:]]*\{[[:space:]]*$/) { + tmp = line + sub(/^[[:space:]]*upstream[[:space:]]+/, "", tmp) + sub(/[[:space:]]*\{[[:space:]]*$/, "", tmp) + upstream_name = trim(tmp) + in_upstream = 1 + depth = 1 + targets = "" + next + } + } else { + if (line ~ /^[[:space:]]*server[[:space:]]+/) { + tmp = line + sub(/^[[:space:]]*server[[:space:]]+/, "", tmp) + sub(/[[:space:]]*;[[:space:]]*$/, "", tmp) + tmp = trim(tmp) + if (tmp != "") { + if (targets == "") { + targets = tmp + } else { + targets = targets "," tmp + } + } + } + + depth += brace_delta(line) + if (depth <= 0) { + if (upstream_name != "" && targets != "") { + print upstream_name "|" targets + } + in_upstream = 0 + depth = 0 + upstream_name = "" + targets = "" + } + } + } + ' "$src" +} + +UPSTREAM_NAMES=() +UPSTREAM_TARGETS=() + +load_upstream_map() { + local line name targets first_target + while IFS= read -r line; do + [[ -z "$line" ]] && continue + IFS='|' read -r name targets <<< "$line" + [[ -z "$name" || -z "$targets" ]] && continue + first_target="${targets%%,*}" + + UPSTREAM_NAMES+=("$name") + UPSTREAM_TARGETS+=("$first_target") + + if [[ "$targets" == *,* ]]; then + warn "upstream '${name}' has multiple servers (${targets}); using first target '${first_target}'" + unsupported_count=$((unsupported_count + 1)) + fi + done < <(extract_upstream_targets "$INPUT_FILE") +} + +lookup_upstream_target() { + local name="$1" + local i + for i in "${!UPSTREAM_NAMES[@]}"; do + if [[ "${UPSTREAM_NAMES[$i]}" == "$name" ]]; then + printf '%s' "${UPSTREAM_TARGETS[$i]}" + return 0 + fi + done + return 1 +} + +resolve_proxy_target() { + local proxy_target="$1" + local block_id="$2" + local scheme host port path mapped + + if [[ "$proxy_target" =~ ^(https?)://([^/:]+)(:[0-9]+)?(.*)$ ]]; then + scheme="${BASH_REMATCH[1]}" + host="${BASH_REMATCH[2]}" + port="${BASH_REMATCH[3]}" + path="${BASH_REMATCH[4]}" + + if mapped="$(lookup_upstream_target "$host")"; then + printf '%s://%s%s' "$scheme" "$mapped" "$path" + return 0 + fi + + # If host has no dot and no upstream definition, call it out. + if [[ "$host" != *.* ]]; then + warn "${block_id}: upstream '${host}' not found; leaving proxy target as '${proxy_target}'" + unsupported_count=$((unsupported_count + 1)) + fi + fi + + printf '%s' "$proxy_target" +} + +extract_server_blocks() { + local src="$1" + local out_dir="$2" + + awk -v out_dir="$out_dir" ' + function clean_comments(s) { + sub(/[[:space:]]*#.*/, "", s) + return s + } + function brace_delta(s, tmp, opens, closes) { + tmp = s + opens = gsub(/\{/, "{", tmp) + closes = gsub(/\}/, "}", tmp) + return opens - closes + } + + BEGIN { + in_server = 0 + depth = 0 + idx = 0 + file = "" + } + + { + raw = $0 + line = clean_comments(raw) + + if (!in_server) { + if (line ~ /^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/) { + in_server = 1 + depth = 0 + idx += 1 + file = sprintf("%s/server_%03d.conf", out_dir, idx) + } + } + + if (in_server) { + print raw >> file + depth += brace_delta(line) + + if (depth <= 0) { + close(file) + in_server = 0 + depth = 0 + file = "" + } + } + } + + END { + print idx + } + ' "$src" +} + +if ! confirm_action "Convert ${INPUT_FILE} to Caddyfile now?" "$AUTO_YES"; then + log_info "Cancelled" + exit 0 +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +server_count="$(extract_server_blocks "$INPUT_FILE" "$tmp_dir")" +if [[ -z "$server_count" || "$server_count" -eq 0 ]]; then + log_error "No Nginx server blocks found in $INPUT_FILE" + exit 1 +fi + +{ + echo "# ---------------------------------------------------------------------------" + echo "# Generated by setup/nginx-to-caddy/nginx_to_caddy.sh" + echo "# Source: $INPUT_FILE" + echo "# Generated at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "# ---------------------------------------------------------------------------" + echo +} > "$OUTPUT_FILE" + +converted_blocks=0 +unsupported_count=0 +load_upstream_map + +for block in "$tmp_dir"/server_*.conf; do + [[ -f "$block" ]] || continue + block_id="$(basename "$block" .conf)" + + # Collect server names + names=() + while IFS= read -r line; do + [[ -z "$line" ]] && continue + for token in $line; do + [[ "$token" == "_" ]] && continue + seen=false + for existing in "${names[@]:-}"; do + if [[ "$existing" == "$token" ]]; then + seen=true + break + fi + done + if [[ "$seen" == false ]]; then + names+=("$token") + fi + done + done < <( + awk ' + /^[[:space:]]*server_name[[:space:]]+/ { + line=$0 + sub(/^[[:space:]]*server_name[[:space:]]+/, "", line) + sub(/[[:space:]]*;[[:space:]]*$/, "", line) + gsub(/[[:space:]]+/, " ", line) + print line + } + ' "$block" + ) + + if [[ ${#names[@]} -eq 0 ]]; then + warn "${block_id}: no server_name found; skipping block" + unsupported_count=$((unsupported_count + 1)) + continue + fi + + # Detect listen hints + listen_443=false + listen_80=false + if grep -Eq '^[[:space:]]*listen[[:space:]][^;]*(443|ssl)' "$block"; then + listen_443=true + fi + if grep -Eq '^[[:space:]]*listen[[:space:]][^;]*80([^0-9]|$)' "$block"; then + listen_80=true + fi + + ssl_cert="$(awk '/^[[:space:]]*ssl_certificate[[:space:]]+/ { line=$0; sub(/^[[:space:]]*ssl_certificate[[:space:]]+/, "", line); sub(/[[:space:]]*;[[:space:]]*$/, "", line); print line; exit }' "$block")" + ssl_key="$(awk '/^[[:space:]]*ssl_certificate_key[[:space:]]+/ { line=$0; sub(/^[[:space:]]*ssl_certificate_key[[:space:]]+/, "", line); sub(/[[:space:]]*;[[:space:]]*$/, "", line); print line; exit }' "$block")" + + redirect_code="" + redirect_target="" + redirect_line="$(awk '/^[[:space:]]*return[[:space:]]+30[12][[:space:]]+/ { line=$0; sub(/^[[:space:]]*/, "", line); sub(/[[:space:]]*;[[:space:]]*$/, "", line); print line; exit }' "$block")" + if [[ -n "$redirect_line" ]]; then + if [[ "$redirect_line" =~ ^return[[:space:]]+([0-9]{3})[[:space:]]+(.+)$ ]]; then + redirect_code="${BASH_REMATCH[1]}" + redirect_target="$(normalize_redirect_target "${BASH_REMATCH[2]}")" + fi + fi + + map_lines="$(awk ' + function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s + } + function clean_comments(s) { + sub(/[[:space:]]*#.*/, "", s) + return s + } + function brace_delta(s, t, o, c) { + t=s + o=gsub(/\{/, "{", t) + c=gsub(/\}/, "}", t) + return o-c + } + + BEGIN { + in_loc=0 + depth=0 + loc_expr="" + proxy="" + insecure_tls=0 + } + + { + raw=$0 + line=clean_comments(raw) + + if (!in_loc) { + if (line ~ /^[[:space:]]*location[[:space:]]+/ && line ~ /\{[[:space:]]*$/) { + tmp=line + sub(/^[[:space:]]*location[[:space:]]+/, "", tmp) + sub(/[[:space:]]*\{[[:space:]]*$/, "", tmp) + in_loc=1 + depth=1 + loc_expr=trim(tmp) + proxy="" + insecure_tls=0 + next + } + } else { + if (line ~ /^[[:space:]]*proxy_pass[[:space:]]+/) { + tmp=line + sub(/^[[:space:]]*proxy_pass[[:space:]]+/, "", tmp) + sub(/[[:space:]]*;[[:space:]]*$/, "", tmp) + proxy=trim(tmp) + } + if (line ~ /^[[:space:]]*proxy_ssl_verify[[:space:]]+off[[:space:]]*;[[:space:]]*$/) { + insecure_tls=1 + } + + depth += brace_delta(line) + if (depth <= 0) { + if (proxy != "") { + print loc_expr "|" proxy "|" insecure_tls + } + in_loc=0 + depth=0 + loc_expr="" + proxy="" + insecure_tls=0 + } + } + } + ' "$block")" + + if grep -nE '^[[:space:]]*(if|map|rewrite|try_files|fastcgi_pass|uwsgi_pass|scgi_pass|grpc_pass|auth_request)\b' "$block" >/dev/null; then + while IFS= read -r line; do + warn "${block_id}: unsupported directive -> ${line}" + unsupported_count=$((unsupported_count + 1)) + done < <(grep -nE '^[[:space:]]*(if|map|rewrite|try_files|fastcgi_pass|uwsgi_pass|scgi_pass|grpc_pass|auth_request)\b' "$block") + fi + + site_names=() + needs_tls=true + if [[ "$listen_80" == "true" && "$listen_443" == "false" ]]; then + needs_tls=false + fi + for name in "${names[@]}"; do + if [[ "$listen_443" == "true" && "$listen_80" == "false" ]]; then + site_names+=("https://${name}") + elif [[ "$listen_80" == "true" && "$listen_443" == "false" ]]; then + site_names+=("http://${name}") + else + site_names+=("$name") + fi + done + site_label="$(join_by_comma "${site_names[@]}")" + + { + echo "# Source block: ${block_id}" + echo "${site_label} {" + + if [[ "$TLS_MODE" == "cloudflare" && "$needs_tls" == "true" ]]; then + echo " tls {" + echo " dns cloudflare {env.CF_API_TOKEN}" + echo " }" + echo + elif [[ "$TLS_MODE" == "existing" && "$needs_tls" == "true" ]]; then + if [[ -n "$ssl_cert" && -n "$ssl_key" ]]; then + echo " tls ${ssl_cert} ${ssl_key}" + echo + elif [[ "$listen_443" == "true" ]]; then + warn "${block_id}: listen 443 detected but ssl_certificate/ssl_certificate_key missing" + unsupported_count=$((unsupported_count + 1)) + fi + fi + + emitted_route=false + + if [[ -n "$redirect_code" && -n "$redirect_target" && -z "$map_lines" ]]; then + if [[ "$redirect_code" == "301" ]]; then + echo " redir ${redirect_target} permanent" + elif [[ "$redirect_code" == "302" ]]; then + echo " redir ${redirect_target} temporary" + else + echo " redir ${redirect_target} ${redirect_code}" + fi + emitted_route=true + fi + + if [[ -n "$map_lines" ]]; then + while IFS='|' read -r loc_expr proxy_target insecure_tls; do + [[ -z "$loc_expr" || -z "$proxy_target" ]] && continue + resolved_proxy_target="$(resolve_proxy_target "$proxy_target" "$block_id")" + + modifier="prefix" + path_expr="$loc_expr" + + if [[ "$loc_expr" =~ ^([=~\^]+)[[:space:]]+(.+)$ ]]; then + modifier="${BASH_REMATCH[1]}" + path_expr="${BASH_REMATCH[2]}" + fi + + case "$modifier" in + "~"|"~*"|"^~") + warn "${block_id}: location modifier '${modifier}' for '${path_expr}' requires manual conversion" + unsupported_count=$((unsupported_count + 1)) + continue + ;; + "=") + if [[ "$path_expr" == /* ]]; then + echo " handle ${path_expr} {" + if [[ "$insecure_tls" == "1" && "$resolved_proxy_target" == https://* ]]; then + echo " reverse_proxy ${resolved_proxy_target} {" + echo " transport http {" + echo " tls_insecure_skip_verify" + echo " }" + echo " }" + else + echo " reverse_proxy ${resolved_proxy_target}" + fi + echo " }" + emitted_route=true + else + warn "${block_id}: exact location '${loc_expr}' is not a path; manual conversion required" + unsupported_count=$((unsupported_count + 1)) + fi + ;; + *) + if [[ "$path_expr" == "/" ]]; then + if [[ "$insecure_tls" == "1" && "$resolved_proxy_target" == https://* ]]; then + echo " reverse_proxy ${resolved_proxy_target} {" + echo " transport http {" + echo " tls_insecure_skip_verify" + echo " }" + echo " }" + else + echo " reverse_proxy ${resolved_proxy_target}" + fi + emitted_route=true + elif [[ "$path_expr" == /* ]]; then + clean_path="${path_expr%/}" + [[ -z "$clean_path" ]] && clean_path="/" + echo " handle ${clean_path} {" + if [[ "$insecure_tls" == "1" && "$resolved_proxy_target" == https://* ]]; then + echo " reverse_proxy ${resolved_proxy_target} {" + echo " transport http {" + echo " tls_insecure_skip_verify" + echo " }" + echo " }" + else + echo " reverse_proxy ${resolved_proxy_target}" + fi + echo " }" + echo " handle_path ${clean_path}/* {" + if [[ "$insecure_tls" == "1" && "$resolved_proxy_target" == https://* ]]; then + echo " reverse_proxy ${resolved_proxy_target} {" + echo " transport http {" + echo " tls_insecure_skip_verify" + echo " }" + echo " }" + else + echo " reverse_proxy ${resolved_proxy_target}" + fi + echo " }" + emitted_route=true + else + warn "${block_id}: location '${loc_expr}' is not a simple path; manual conversion required" + unsupported_count=$((unsupported_count + 1)) + fi + ;; + esac + done <<< "$map_lines" + fi + + if [[ "$emitted_route" == false ]]; then + echo " respond \"No automatic route generated for this server block\" 404" + warn "${block_id}: no proxy_pass/redirect converted; added placeholder response" + unsupported_count=$((unsupported_count + 1)) + fi + + echo "}" + echo + } >> "$OUTPUT_FILE" + + converted_blocks=$((converted_blocks + 1)) +done + +if [[ ! -s "$WARNINGS_FILE" ]]; then + echo "No warnings." > "$WARNINGS_FILE" +fi + +log_success "Generated Caddyfile: $OUTPUT_FILE" +log_info "Converted server blocks: $converted_blocks / $server_count" +log_info "Warnings report: $WARNINGS_FILE" + +if [[ "$STRICT" == "true" && "$unsupported_count" -gt 0 ]]; then + log_error "Strict mode enabled: ${unsupported_count} warning(s) found" + exit 1 +fi diff --git a/setup/nginx-to-caddy/validate_caddy.sh b/setup/nginx-to-caddy/validate_caddy.sh new file mode 100755 index 0000000..778e562 --- /dev/null +++ b/setup/nginx-to-caddy/validate_caddy.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./lib.sh +source "$SCRIPT_DIR/lib.sh" + +CONFIG_FILE="$SCRIPT_DIR/output/Caddyfile.generated" +FORMAT_FILE=true +USE_DOCKER=false +DO_ADAPT=true +DO_VALIDATE=true +CADDY_IMAGE="caddy:2" + +usage() { + cat </dev/null + fi + + if [[ "$DO_VALIDATE" == "true" ]]; then + log_info "Validating Caddyfile (Docker)..." + docker run --rm \ + -v "$CONFIG_FILE:/etc/caddy/Caddyfile:ro" \ + "$CADDY_IMAGE" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + fi +else + require_cmd caddy + + if [[ "$FORMAT_FILE" == "true" ]]; then + log_info "Formatting Caddyfile..." + caddy fmt --overwrite "$CONFIG_FILE" + fi + + if [[ "$DO_ADAPT" == "true" ]]; then + log_info "Adapting Caddyfile..." + caddy adapt --config "$CONFIG_FILE" --adapter caddyfile >/dev/null + fi + + if [[ "$DO_VALIDATE" == "true" ]]; then + log_info "Validating Caddyfile..." + caddy validate --config "$CONFIG_FILE" --adapter caddyfile + fi +fi + +log_success "Validation checks complete"