feat: add support for public DNS target IP and private DNS allowance in Cloudflare setup

This commit is contained in:
S
2026-03-02 23:27:04 -06:00
parent 14a5773a2d
commit 63f5bf6ea7
7 changed files with 242 additions and 29 deletions

View File

@@ -25,7 +25,7 @@ require_vars UNRAID_IP UNRAID_SSH_USER UNRAID_GITEA_IP UNRAID_CADDY_IP \
REPO_NAMES
if [[ "$TLS_MODE" == "cloudflare" ]]; then
require_vars CLOUDFLARE_API_TOKEN
require_vars CLOUDFLARE_API_TOKEN PUBLIC_DNS_TARGET_IP
elif [[ "$TLS_MODE" == "existing" ]]; then
require_vars SSL_CERT_PATH SSL_KEY_PATH
else
@@ -44,6 +44,13 @@ UNRAID_DOCKER_NETWORK_NAME="br0"
CADDY_COMPOSE_DIR="${UNRAID_COMPOSE_DIR}/caddy"
PHASE8_GITEA_ROUTE_BEGIN="# BEGIN_PHASE8_GITEA_ROUTE"
PHASE8_GITEA_ROUTE_END="# END_PHASE8_GITEA_ROUTE"
PUBLIC_DNS_TARGET_IP="${PUBLIC_DNS_TARGET_IP:-}"
PHASE8_ALLOW_PRIVATE_DNS_TARGET="${PHASE8_ALLOW_PRIVATE_DNS_TARGET:-false}"
if ! validate_bool "${PHASE8_ALLOW_PRIVATE_DNS_TARGET}"; then
log_error "Invalid PHASE8_ALLOW_PRIVATE_DNS_TARGET='${PHASE8_ALLOW_PRIVATE_DNS_TARGET}' (must be true or false)"
exit 1
fi
wait_for_https_public() {
local host="$1" max_secs="${2:-30}"
@@ -78,6 +85,149 @@ wait_for_https_via_resolve() {
return 1
}
check_unraid_gitea_backend() {
local raw code
raw=$(ssh_exec UNRAID "curl -sS -o /dev/null -w '%{http_code}' 'http://${UNRAID_GITEA_IP}:3000/api/v1/version' || true" 2>/dev/null || true)
code=$(printf '%s' "$raw" | tr -cd '0-9')
if [[ -z "$code" ]]; then
code="000"
elif [[ ${#code} -gt 3 ]]; then
code="${code:$((${#code} - 3))}"
fi
if [[ "$code" == "200" ]]; then
log_success "Unraid -> Gitea backend API reachable (HTTP 200)"
return 0
fi
log_error "Unraid -> Gitea backend API check failed (HTTP ${code}) at http://${UNRAID_GITEA_IP}:3000/api/v1/version"
return 1
}
is_private_ipv4() {
local ip="$1"
[[ "$ip" =~ ^10\. ]] || \
[[ "$ip" =~ ^192\.168\. ]] || \
[[ "$ip" =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. ]]
}
cloudflare_api_call() {
local method="$1" path="$2" data="${3:-}"
local -a args=(
curl -sS
-X "$method"
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}"
-H "Content-Type: application/json"
"https://api.cloudflare.com/client/v4${path}"
)
if [[ -n "$data" ]]; then
args+=(-d "$data")
fi
"${args[@]}"
}
ensure_cloudflare_dns_for_gitea() {
local host="$1" target_ip="$2" zone_id zone_name
local allow_private="${PHASE8_ALLOW_PRIVATE_DNS_TARGET}"
if [[ -z "$target_ip" ]]; then
log_error "PUBLIC_DNS_TARGET_IP is not set"
log_error "Set PUBLIC_DNS_TARGET_IP to your public ingress IP for ${host}"
log_error "For LAN-only/split-DNS use, also set PHASE8_ALLOW_PRIVATE_DNS_TARGET=true"
return 1
fi
if ! validate_ip "$target_ip"; then
log_error "Invalid PUBLIC_DNS_TARGET_IP='${target_ip}'"
log_error "Set PUBLIC_DNS_TARGET_IP in .env to the IP that should answer ${host}"
return 1
fi
zone_name="${host#*.}"
if [[ "$zone_name" == "$host" ]]; then
log_error "GITEA_DOMAIN='${host}' is not a valid FQDN for Cloudflare zone detection"
return 1
fi
if is_private_ipv4 "$target_ip"; then
if [[ "$allow_private" != "true" ]]; then
log_error "Refusing private DNS target ${target_ip} for Cloudflare public DNS"
log_error "Set PUBLIC_DNS_TARGET_IP to public ingress IP, or set PHASE8_ALLOW_PRIVATE_DNS_TARGET=true for LAN-only split-DNS"
return 1
fi
log_warn "Using private DNS target ${target_ip} because PHASE8_ALLOW_PRIVATE_DNS_TARGET=true"
fi
local zone_resp zone_err
zone_resp=$(cloudflare_api_call GET "/zones?name=${zone_name}&status=active")
if [[ "$(jq -r '.success // false' <<< "$zone_resp")" != "true" ]]; then
zone_err=$(jq -r '(.errors // []) | map(.message // tostring) | join("; ")' <<< "$zone_resp")
log_error "Cloudflare zone lookup failed for ${zone_name}: ${zone_err:-unknown error}"
return 1
fi
zone_id=$(jq -r '.result[0].id // empty' <<< "$zone_resp")
if [[ -z "$zone_id" ]]; then
log_error "Cloudflare zone not found or not accessible for ${zone_name}"
return 1
fi
local record_resp record_err record_count record_id old_ip
record_resp=$(cloudflare_api_call GET "/zones/${zone_id}/dns_records?type=A&name=${host}")
if [[ "$(jq -r '.success // false' <<< "$record_resp")" != "true" ]]; then
record_err=$(jq -r '(.errors // []) | map(.message // tostring) | join("; ")' <<< "$record_resp")
log_error "Cloudflare DNS query failed for ${host}: ${record_err:-unknown error}"
return 1
fi
record_count=$(jq -r '.result | length' <<< "$record_resp")
if [[ "$record_count" -eq 0 ]]; then
local create_payload create_resp create_err
create_payload=$(jq -n \
--arg type "A" \
--arg name "$host" \
--arg content "$target_ip" \
--argjson ttl 120 \
--argjson proxied false \
'{type:$type, name:$name, content:$content, ttl:$ttl, proxied:$proxied}')
create_resp=$(cloudflare_api_call POST "/zones/${zone_id}/dns_records" "$create_payload")
if [[ "$(jq -r '.success // false' <<< "$create_resp")" != "true" ]]; then
create_err=$(jq -r '(.errors // []) | map(.message // tostring) | join("; ")' <<< "$create_resp")
log_error "Failed to create Cloudflare A record ${host} -> ${target_ip}: ${create_err:-unknown error}"
return 1
fi
log_success "Created Cloudflare A record: ${host} -> ${target_ip}"
return 0
fi
record_id=$(jq -r '.result[0].id // empty' <<< "$record_resp")
old_ip=$(jq -r '.result[0].content // empty' <<< "$record_resp")
if [[ -n "$old_ip" && "$old_ip" == "$target_ip" ]]; then
log_info "Cloudflare A record already correct: ${host} -> ${target_ip}"
return 0
fi
local update_payload update_resp update_err
update_payload=$(jq -n \
--arg type "A" \
--arg name "$host" \
--arg content "$target_ip" \
--argjson ttl 120 \
--argjson proxied false \
'{type:$type, name:$name, content:$content, ttl:$ttl, proxied:$proxied}')
update_resp=$(cloudflare_api_call PUT "/zones/${zone_id}/dns_records/${record_id}" "$update_payload")
if [[ "$(jq -r '.success // false' <<< "$update_resp")" != "true" ]]; then
update_err=$(jq -r '(.errors // []) | map(.message // tostring) | join("; ")' <<< "$update_resp")
log_error "Failed to update Cloudflare A record ${host}: ${update_err:-unknown error}"
return 1
fi
log_info "Updated Cloudflare A record: ${host}"
log_info " old: ${old_ip:-<empty>}"
log_info " new: ${target_ip}"
return 0
}
caddyfile_has_domain_block() {
local file="$1" domain="$2"
awk -v domain="$domain" '
@@ -322,23 +472,39 @@ log_step 4 "Starting Caddy container..."
CONTAINER_STATUS=$(ssh_exec UNRAID "docker ps --filter name=caddy --format '{{.Status}}'" 2>/dev/null || true)
if [[ "$CONTAINER_STATUS" == *"Up"* ]]; then
log_info "Caddy container already running"
if [[ "$CADDYFILE_UPDATED" -eq 1 ]]; then
log_info "Caddyfile changed — restarting caddy to apply updated config"
ssh_exec UNRAID "docker restart caddy >/dev/null"
log_success "Caddy container restarted with new config"
log_info "Reloading Caddy config from /etc/caddy/Caddyfile"
if ssh_exec UNRAID "docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile" >/dev/null 2>&1; then
log_success "Caddy config reloaded"
else
log_info "Caddyfile unchanged — restart not required"
log_warn "Caddy reload failed; restarting caddy container"
ssh_exec UNRAID "docker restart caddy >/dev/null"
log_success "Caddy container restarted"
fi
else
ssh_exec UNRAID "cd '${CADDY_COMPOSE_DIR}' && docker compose up -d 2>/dev/null || docker-compose up -d"
log_success "Caddy container started"
if ssh_exec UNRAID "docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile" >/dev/null 2>&1; then
log_success "Caddy container started and config loaded"
else
log_success "Caddy container started"
fi
fi
# ---------------------------------------------------------------------------
# Step 5: Wait for HTTPS to work
# Step 5: Ensure DNS points Gitea domain to target ingress IP
# ---------------------------------------------------------------------------
log_step 5 "Ensuring DNS for ${GITEA_DOMAIN}..."
if [[ "$TLS_MODE" == "cloudflare" ]]; then
ensure_cloudflare_dns_for_gitea "${GITEA_DOMAIN}" "${PUBLIC_DNS_TARGET_IP}"
else
log_info "TLS_MODE=${TLS_MODE}; skipping Cloudflare DNS automation"
fi
# ---------------------------------------------------------------------------
# Step 6: Wait for HTTPS to work
# Caddy auto-obtains certs — poll until HTTPS responds.
# ---------------------------------------------------------------------------
log_step 5 "Waiting for HTTPS (Caddy auto-provisions cert)..."
log_step 6 "Waiting for HTTPS (Caddy auto-provisions cert)..."
check_unraid_gitea_backend
if wait_for_https_public "${GITEA_DOMAIN}" 30; then
log_success "HTTPS verified through current domain routing — https://${GITEA_DOMAIN} works"
else
@@ -348,7 +514,7 @@ else
fi
# ---------------------------------------------------------------------------
# Step 6: Mark GitHub repos as offsite backup only
# Step 7: Mark GitHub repos as offsite backup only
# Updates description + homepage to indicate Gitea is primary.
# Disables wiki and Pages to avoid unnecessary resource usage.
# Does NOT archive — archived repos reject pushes, which would break
@@ -356,7 +522,7 @@ fi
# Persists original mutable settings to a local state file for teardown.
# GitHub Actions already disabled in Phase 6 Step D.
# ---------------------------------------------------------------------------
log_step 6 "Marking GitHub repos as offsite backup..."
log_step 7 "Marking GitHub repos as offsite backup..."
init_phase8_state_store
GITHUB_REPO_UPDATE_FAILURES=0
@@ -390,10 +556,11 @@ for repo in "${REPOS[@]}"; do
--arg homepage "https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}" \
'{description: $description, homepage: $homepage, has_wiki: false, has_projects: false}')
if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$UPDATE_PAYLOAD" >/dev/null 2>&1; then
if PATCH_OUT=$(github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$UPDATE_PAYLOAD" 2>&1); then
log_success "Marked GitHub repo as mirror: ${repo}"
else
log_error "Failed to update GitHub repo: ${repo}"
log_error "GitHub API: $(printf '%s' "$PATCH_OUT" | tail -n 1)"
GITHUB_REPO_UPDATE_FAILURES=$((GITHUB_REPO_UPDATE_FAILURES + 1))
fi