#!/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