feat: add support for public DNS target IP and private DNS allowance in Cloudflare setup
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user