466 lines
15 KiB
Bash
466 lines
15 KiB
Bash
#!/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/&/\&/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
|