#!/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}
Цели проверки
| Цель | IP | Статус |
${TARGETS_HTML}
Записи пула в Cloudflare
| IP | Тип | В DNS | Должен быть |
${POOL_HTML}
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; 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