#!/bin/bash set -euo pipefail CF_API="https://api.cloudflare.com/client/v4" R=$'\e[31m' G=$'\e[32m' Y=$'\e[33m' B=$'\e[34m' M=$'\e[35m' K=$'\e[1m' X=$'\e[0m' log() { printf '%s\n' "$*"; } ok() { printf '%b\n' "${G}$*${X}"; } warn() { printf '%b\n' "${Y}$*${X}"; } err() { printf '%b\n' "${R}$*${X}" >&2; } hdr() { printf '%b\n' "${B}${K}━━ $* ━━${X}"; } CF_TOKEN="${CLOUDFLARE_API_TOKEN:-${CLOUDFLARE_API_KEY:-}}" [[ -n "$CF_TOKEN" ]] || { err "Нужен CLOUDFLARE_API_TOKEN (или CLOUDFLARE_API_KEY)."; exit 1; } [[ -n "${POOL_DOMAIN:-}" ]] || { err "Нужен POOL_DOMAIN."; exit 1; } [[ -n "${CHECK_TARGETS:-}" ]] || { err "Нужен CHECK_TARGETS."; exit 1; } CHECK_MODE="${CHECK_MODE:-ping}" CHECK_INTERVAL_SEC="${CHECK_INTERVAL_SEC:-30}" PING_COUNT="${PING_COUNT:-2}" PING_TIMEOUT_SEC="${PING_TIMEOUT_SEC:-2}" CURL_MAX_TIME_SEC="${CURL_MAX_TIME_SEC:-5}" CURL_CONNECT_TIMEOUT_SEC="${CURL_CONNECT_TIMEOUT_SEC:-3}" CURL_URL_TEMPLATE="${CURL_URL_TEMPLATE:-http://%s/}" CURL_OK_MIN="${CURL_OK_MIN:-200}" CURL_OK_MAX="${CURL_OK_MAX:-399}" DEFAULT_TTL="${DEFAULT_TTL:-300}" DEFAULT_PROXIED="${DEFAULT_PROXIED:-false}" ENABLE_STATUS_HTTP="${ENABLE_STATUS_HTTP:-0}" STATUS_HTTP_PORT="${STATUS_HTTP_PORT:-8080}" STATUS_HTTP_ALLOW_IPS="${STATUS_HTTP_ALLOW_IPS:-}" ZONE_ID="${CLOUDFLARE_ZONE_ID:-}" STATUS_FILE="/var/www/cfb/status.html" mkdir -p /var/www/cfb 2>/dev/null || true is_url() { [[ "$1" =~ ^https?:// ]]; } is_ipv4() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; } is_ipv6() { [[ "$1" == *:* ]] && ! is_ipv4 "$1"; } extract_host_from_url() { local u="$1" u="${u#http://}" u="${u#https://}" u="${u#*@}" if [[ "$u" == \[*\]* ]]; then u="${u#\[}" printf '%s' "${u%%\]*}" return fi u="${u%%/*}" printf '%s' "${u%%:*}" } dig_first() { local host="$1" type="$2" line while IFS= read -r line; do [[ -n "$line" ]] || continue [[ "$line" =~ ^\; ]] && continue printf '%s' "$line" return 0 done < <(dig +short +time=2 +tries=1 "$host" "$type" 2>/dev/null) return 1 } resolve_to_ip() { local t="$1" ans if is_url "$t"; then local h h=$(extract_host_from_url "$t") ans=$(dig_first "$h" A) || true [[ -n "$ans" ]] || ans=$(dig_first "$h" AAAA) || true printf '%s' "$ans" elif is_ipv4 "$t" || is_ipv6 "$t"; then printf '%s' "$t" else ans=$(dig_first "$t" A) || true [[ -n "$ans" ]] || ans=$(dig_first "$t" AAAA) || true printf '%s' "$ans" fi } record_type_for_ip() { if is_ipv6 "$1"; then echo "AAAA"; else echo "A"; fi } zone_name_from_pool() { if [[ -n "${CLOUDFLARE_ZONE_NAME:-}" ]]; then printf '%s' "$CLOUDFLARE_ZONE_NAME" return fi local d="$POOL_DOMAIN" local IFS='.' local -a parts read -ra parts <<<"$d" local n=${#parts[@]} if (( n >= 2 )); then printf '%s.%s' "${parts[n - 2]}" "${parts[n - 1]}" else printf '%s' "$d" fi } cf_curl() { local method="$1" path="$2" local body="${3:-}" local url="${CF_API}${path}" local -a args=(-sS -X "$method" "$url" -H "Authorization: Bearer ${CF_TOKEN}" -H "Content-Type: application/json") [[ -n "$body" ]] && args+=(-d "$body") curl "${args[@]}" } cf_curl_get() { local path="$1" curl -sS -G "${CF_API}${path}" -H "Authorization: Bearer ${CF_TOKEN}" } resolve_zone_id() { [[ -n "$ZONE_ID" ]] && return 0 local zn zn=$(zone_name_from_pool) hdr "Поиск зоны Cloudflare: ${zn}" local resp resp=$(cf_curl_get "/zones?name=${zn}") if ! jq -e '.success == true' <<<"$resp" >/dev/null; then err "Ошибка API зон: $(jq -c '.errors' <<<"$resp")" return 1 fi ZONE_ID=$(jq -r '.result[0].id // empty' <<<"$resp") [[ -n "$ZONE_ID" ]] || { err "Зона «${zn}» не найдена. Задайте CLOUDFLARE_ZONE_ID или CLOUDFLARE_ZONE_NAME."; return 1; } ok "ZONE_ID=${ZONE_ID}" } list_pool_records_json() { local page=1 accum='[]' resp part total_pages while true; do resp=$(curl -sS -G "${CF_API}/zones/${ZONE_ID}/dns_records" \ -H "Authorization: Bearer ${CF_TOKEN}" \ --data-urlencode "name=${POOL_DOMAIN}" \ --data "page=${page}" \ --data "per_page=100") if ! jq -e '.success == true' <<<"$resp" >/dev/null; then err "Список DNS: $(jq -c '.errors' <<<"$resp")" echo '[]' return 1 fi part=$(jq '.result' <<<"$resp") accum=$(jq -s '.[0] as $a | .[1] as $b | $a + $b' <(echo "$accum") <(echo "$part")) total_pages=$(jq -r '.result_info.total_pages // 1' <<<"$resp") (( page >= total_pages )) && break ((page++)) || true done jq '[.[] | select(.type == "A" or .type == "AAAA")]' <<<"$accum" } dns_delete() { local id="$1" local resp resp=$(cf_curl DELETE "/zones/${ZONE_ID}/dns_records/${id}") if jq -e '.success == true' <<<"$resp" >/dev/null; then ok "DELETE запись ${id}" return 0 fi err "DELETE ${id}: $(jq -c '.errors' <<<"$resp")" return 1 } dns_create() { local rtype="$1" content="$2" local proxied_json=false [[ "${DEFAULT_PROXIED,,}" == "true" || "$DEFAULT_PROXIED" == "1" ]] && proxied_json=true local body body=$(jq -n \ --arg type "$rtype" \ --arg name "$POOL_DOMAIN" \ --arg content "$content" \ --argjson ttl "${DEFAULT_TTL}" \ --argjson proxied "$proxied_json" \ '{type:$type,name:$name,content:$content,ttl:$ttl,proxied:$proxied}') local resp resp=$(cf_curl POST "/zones/${ZONE_ID}/dns_records" "$body") if jq -e '.success == true' <<<"$resp" >/dev/null; then ok "POST ${rtype} ${content}" return 0 fi err "POST DNS: $(jq -c '.errors' <<<"$resp")" return 1 } check_ping() { local ip="$1" [[ -n "$ip" ]] || return 1 ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SEC" -q "$ip" >/dev/null 2>&1 } check_curl() { local target="$1" local url if is_url "$target"; then url="$target" else # shellcheck disable=SC2059 url=$(printf "$CURL_URL_TEMPLATE" "$target") fi local code code=$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout "$CURL_CONNECT_TIMEOUT_SEC" \ --max-time "$CURL_MAX_TIME_SEC" -k "$url" 2>/dev/null || echo "000") [[ "$code" =~ ^[0-9]+$ ]] || return 1 (( code >= CURL_OK_MIN && code <= CURL_OK_MAX )) } check_target() { local t="$1" ip="$2" case "$CHECK_MODE" in ping) check_ping "$ip" ;; curl) check_curl "$t" ;; *) err "Неизвестный CHECK_MODE=${CHECK_MODE}"; return 1 ;; esac } LAST_EVENTS=() add_event() { LAST_EVENTS+=("$1") while (( ${#LAST_EVENTS[@]} > 30 )); do LAST_EVENTS=("${LAST_EVENTS[@]:1}") done } render_status_html() { local ts events_html ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") IFS=$'\n' events_html=$(printf '
  • %s
  • ' "${LAST_EVENTS[@]}" 2>/dev/null || true) unset IFS [[ -n "$events_html" ]] || events_html="
  • " cat >"$STATUS_FILE.tmp" < Cloudflare balancer — статус

    Пул DNS и проверки

    POOL_DOMAIN: ${POOL_DOMAIN} · обновлено UTC ${ts} · режим: ${CHECK_MODE}

    Цели проверки

    ${TARGETS_HTML}
    ЦельIPСтатус

    Записи пула в Cloudflare

    ${POOL_HTML}
    IPТипВ DNSДолжен быть

    События

    HTMLEOF mv -f "$STATUS_FILE.tmp" "$STATUS_FILE" } socat_pid="" cleanup() { [[ -n "$socat_pid" ]] && kill "$socat_pid" 2>/dev/null || true } trap cleanup EXIT start_status_http() { [[ "$ENABLE_STATUS_HTTP" == "1" ]] || return 0 export STATUS_FILE export STATUS_HTTP_ALLOW_IPS local bind="127.0.0.1" [[ -n "${STATUS_HTTP_ALLOW_IPS// }" ]] && bind="0.0.0.0" hdr "HTTP статус на ${bind}:${STATUS_HTTP_PORT}" socat TCP4-LISTEN:"${STATUS_HTTP_PORT}",bind="${bind}",fork,reuseaddr EXEC:/opt/cfb/status-handler.sh & socat_pid=$! } pool_record_count() { local records_json="$1" ip="$2" rtype="$3" jq -r --arg c "$ip" --arg t "$rtype" '[.[] | select(.content == $c and .type == $t)] | length' <<<"$records_json" } delete_pool_records_for() { local records_json="$1" ip="$2" rtype="$3" local del_id while IFS= read -r del_id; do [[ -z "$del_id" ]] && continue dns_delete "$del_id" || true done < <(jq -r --arg c "$ip" --arg t "$rtype" '.[] | select(.content == $c and .type == $t) | .id' <<<"$records_json") } main_cycle() { date -u +%s > /tmp/cfb.liveness 2>/dev/null || true local -A ip_total ip_ok local -a order=() TARGET_ROWS=() POOL_ROWS=() local IFS=',' hdr "Цикл проверки — $(date -u +"%H:%M:%S UTC")" for raw in $CHECK_TARGETS; do local t="${raw#"${raw%%[![:space:]]*}"}" t="${t%"${t##*[![:space:]]}"}" [[ -n "$t" ]] || continue local ip ip=$(resolve_to_ip "$t") if [[ -z "$ip" ]]; then warn "«${t}» — не удалось резолвить IP" TARGET_ROWS+=("$(html_escape "$t")—FAIL") continue fi if [[ -z "${ip_total[$ip]+x}" ]]; then order+=("$ip") ip_total[$ip]=0 ip_ok[$ip]=0 fi ((ip_total[$ip]++)) || true local t0=$SECONDS ok=0 if check_target "$t" "$ip"; then ok=1 ((ip_ok[$ip]++)) || true ok "OK ${t} → ${ip} (${CHECK_MODE}, ~$((SECONDS - t0))s)" else err "FAIL ${t} → ${ip} (${CHECK_MODE})" fi local badge [[ "$ok" == 1 ]] && badge='OK' || badge='FAIL' TARGET_ROWS+=("$(html_escape "$t")$(html_escape "$ip")${badge}") done unset IFS local -A want for ip in "${order[@]}"; do (( ip_ok["$ip"] == ip_total["$ip"] )) && want["$ip"]=1 done local records_json records_json=$(list_pool_records_json) || records_json='[]' for ip in "${order[@]}"; do local should=0 [[ -n "${want[$ip]+x}" ]] && should=1 local rtype rtype=$(record_type_for_ip "$ip") local cnt cnt=$(pool_record_count "$records_json" "$ip" "$rtype") local in_d=0 (( cnt > 0 )) && in_d=1 local want_badge pool_badge [[ "$should" == 1 ]] && want_badge='да' || want_badge='нет' [[ "$in_d" == 1 ]] && pool_badge='да' || pool_badge='нет' POOL_ROWS+=("$(html_escape "$ip")${rtype}${pool_badge}${want_badge}") if [[ "$should" == 0 && "$in_d" == 1 ]]; then hdr "Удаляем из пула: ${ip} (${rtype})" delete_pool_records_for "$records_json" "$ip" "$rtype" add_event "$(date -u +%H:%M:%SZ) удалён ${ip} (${rtype})" elif [[ "$should" == 1 && "$in_d" == 0 ]]; then hdr "Восстанавливаем в пуле: ${ip} (${rtype})" if dns_create "$rtype" "$ip"; then add_event "$(date -u +%H:%M:%SZ) добавлен ${ip} (${rtype})" fi elif [[ "$should" == 1 && "$cnt" -gt 1 ]]; then warn "Дубликаты записей ${ip} (${rtype}): ${cnt} шт. — оставляем одну" local first=1 del_id while IFS= read -r del_id; do [[ -z "$del_id" ]] && continue [[ "$first" == 1 ]] && { first=0; continue; } dns_delete "$del_id" || true done < <(jq -r --arg c "$ip" --arg t "$rtype" '.[] | select(.content == $c and .type == $t) | .id' <<<"$records_json") fi done local line id typ content known ip while IFS= read -r line; do [[ -z "$line" ]] && continue content=$(jq -r '.content' <<<"$line") typ=$(jq -r '.type' <<<"$line") known=0 for ip in "${order[@]}"; do [[ "$content" == "$ip" ]] && known=1 && break done if [[ "$known" == 0 ]]; then warn "В DNS есть ${content} (${typ}), не в CHECK_TARGETS — не изменяем" POOL_ROWS+=("$(html_escape "$content")${typ}давне списка") fi done < <(jq -c '.[]' <<<"$records_json") TARGETS_HTML=$(printf '%s\n' "${TARGET_ROWS[@]:-}") POOL_HTML=$(printf '%s\n' "${POOL_ROWS[@]:-}") [[ -n "$TARGETS_HTML" ]] || TARGETS_HTML="—" [[ -n "$POOL_HTML" ]] || POOL_HTML="—" render_status_html } html_escape() { printf '%s' "${1:-}" | sed 's/&/\&/g; s//\>/g; s/"/\"/g' } # --- старт --- hdr "Cloudflare DNS pool balancer" log "POOL_DOMAIN=${POOL_DOMAIN} CHECK_MODE=${CHECK_MODE} интервал=${CHECK_INTERVAL_SEC}s" resolve_zone_id || exit 1 start_status_http date -u +%s > /tmp/cfb.liveness 2>/dev/null || true while true; do main_cycle || warn "Цикл завершился с ошибкой, следующий через ${CHECK_INTERVAL_SEC}s" sleep "$CHECK_INTERVAL_SEC" done