feat: add Nginx to Caddy migration toolkit with scripts and usage guide

This commit is contained in:
S
2026-03-02 21:11:00 -05:00
parent 98c3d021ef
commit ca4f4924b6
7 changed files with 1038 additions and 0 deletions

View File

@@ -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 <<USAGE
Usage: $(basename "$0") --input=nginx-full.conf [options]
Convert basic Nginx server/location/proxy_pass blocks to a Caddyfile.
Options:
--input=PATH Input config (usually nginx -T output)
--output=PATH Output Caddyfile (default: setup/nginx-to-caddy/output/Caddyfile.generated)
--warnings=PATH Warning report path (default: setup/nginx-to-caddy/output/conversion-warnings.txt)
--tls-mode=MODE TLS strategy: auto|cloudflare|existing (default: auto)
--strict Exit non-zero if any unsupported directives are found
--yes, -y Skip confirmation prompt
--help, -h Show help
Coverage:
- server_name
- listen (80/443 detection)
- ssl_certificate / ssl_certificate_key
- return 301/302 redirects
- location + proxy_pass (prefix and exact paths)
Not automatically converted:
- regex locations (~, ~*)
- fastcgi/uwsgi/scgi/grpc
- try_files/rewrite/map/if/auth_request/lua
USAGE
}
for arg in "$@"; do
case "$arg" in
--input=*) INPUT_FILE="${arg#*=}" ;;
--output=*) OUTPUT_FILE="${arg#*=}" ;;
--warnings=*) WARNINGS_FILE="${arg#*=}" ;;
--tls-mode=*) TLS_MODE="${arg#*=}" ;;
--strict) STRICT=true ;;
--yes|-y) AUTO_YES=true ;;
--help|-h) usage; exit 0 ;;
*) log_error "Unknown argument: $arg"; usage; exit 1 ;;
esac
done
require_cmd awk sed grep sort mktemp
if [[ -z "$INPUT_FILE" ]]; then
log_error "--input is required"
usage
exit 1
fi
if [[ ! -f "$INPUT_FILE" ]]; then
log_error "Input file not found: $INPUT_FILE"
exit 1
fi
case "$TLS_MODE" in
auto|cloudflare|existing) ;;
*) log_error "Invalid --tls-mode '$TLS_MODE' (use: auto|cloudflare|existing)"; exit 1 ;;
esac
mkdir -p "$(dirname "$OUTPUT_FILE")" "$(dirname "$WARNINGS_FILE")"
: > "$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