Files
Denozordec 2671e90d20
Docker / build (push) Successful in 31s
Init Commit
2026-03-23 17:31:26 +07:00

466 lines
15 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 '<li>%s</li>' "${LAST_EVENTS[@]}" 2>/dev/null || true)
unset IFS
[[ -n "$events_html" ]] || events_html="<li>—</li>"
cat >"$STATUS_FILE.tmp" <<HTMLEOF
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="5">
<title>Cloudflare balancer — статус</title>
<style>
:root { --bg:#0f1419; --card:#1a2332; --ok:#3fb950; --bad:#f85149; --txt:#e6edf3; --muted:#8b949e; --acc:#58a6ff; }
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--txt); margin: 0; padding: 1.5rem; line-height: 1.5; }
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.5rem; color: var(--acc); }
.meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 1.25rem; }
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
section { background: var(--card); border-radius: 10px; padding: 1rem 1.1rem; border: 1px solid #30363d; }
h2 { font-size: 0.95rem; margin: 0 0 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #30363d; }
th { color: var(--muted); font-weight: 500; }
.badge { display: inline-block; padding: 0.15rem 0.45rem; border-radius: 6px; font-size: 0.75rem; font-weight: 600; }
.up { background: rgba(63,185,80,0.2); color: var(--ok); }
.down { background: rgba(248,81,73,0.2); color: var(--bad); }
.inpool { color: var(--ok); }
.outpool { color: var(--bad); }
ul.events { margin: 0; padding-left: 1.1rem; font-size: 0.85rem; color: var(--muted); }
</style>
</head>
<body>
<h1>Пул DNS и проверки</h1>
<div class="meta">POOL_DOMAIN: <strong>${POOL_DOMAIN}</strong> · обновлено UTC ${ts} · режим: ${CHECK_MODE}</div>
<div class="grid">
<section>
<h2>Цели проверки</h2>
<table>
<thead><tr><th>Цель</th><th>IP</th><th>Статус</th></tr></thead>
<tbody>
${TARGETS_HTML}
</tbody>
</table>
</section>
<section>
<h2>Записи пула в Cloudflare</h2>
<table>
<thead><tr><th>IP</th><th>Тип</th><th>В DNS</th><th>Должен быть</th></tr></thead>
<tbody>
${POOL_HTML}
</tbody>
</table>
</section>
</div>
<section style="margin-top:1rem;">
<h2>События</h2>
<ul class="events">${events_html}</ul>
</section>
</body>
</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+=("<tr><td>$(html_escape "$t")</td><td>—</td><td><span class=\"badge down\">FAIL</span></td></tr>")
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='<span class="badge up">OK</span>' || badge='<span class="badge down">FAIL</span>'
TARGET_ROWS+=("<tr><td>$(html_escape "$t")</td><td>$(html_escape "$ip")</td><td>${badge}</td></tr>")
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='<span class="badge up">да</span>' || want_badge='<span class="badge down">нет</span>'
[[ "$in_d" == 1 ]] && pool_badge='<span class="inpool">да</span>' || pool_badge='<span class="outpool">нет</span>'
POOL_ROWS+=("<tr><td>$(html_escape "$ip")</td><td>${rtype}</td><td>${pool_badge}</td><td>${want_badge}</td></tr>")
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+=("<tr><td>$(html_escape "$content")</td><td>${typ}</td><td><span class=\"inpool\">да</span></td><td><span class=\"outpool\">вне списка</span></td></tr>")
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="<tr><td colspan=\"3\">—</td></tr>"
[[ -n "$POOL_HTML" ]] || POOL_HTML="<tr><td colspan=\"4\">—</td></tr>"
render_status_html
}
html_escape() {
printf '%s' "${1:-}" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/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