Init Commit
Docker / build (push) Successful in 31s

This commit is contained in:
Denozordec
2026-03-23 17:31:26 +07:00
commit 2671e90d20
8 changed files with 977 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.gitea
*.md
.env
.env.*
!.env.example
+33
View File
@@ -0,0 +1,33 @@
# Скопируйте в .env и заполните. Файл .env не коммитьте.
# Обязательные
POOL_DOMAIN=app.example.com
CHECK_TARGETS=10.0.0.1,10.0.0.2,backend.example.org
CLOUDFLARE_API_TOKEN=
# Опционально: если не задан — зона ищется по последним двум меткам POOL_DOMAIN
# CLOUDFLARE_ZONE_ID=
# CLOUDFLARE_ZONE_NAME=example.com
CHECK_INTERVAL_SEC=30
CHECK_MODE=ping
# ping
PING_COUNT=2
PING_TIMEOUT_SEC=2
# curl (CHECK_MODE=curl)
# CURL_URL_TEMPLATE='http://%s/health'
# CURL_MAX_TIME_SEC=5
# CURL_CONNECT_TIMEOUT_SEC=3
# CURL_OK_MIN=200
# CURL_OK_MAX=399
DEFAULT_TTL=300
DEFAULT_PROXIED=false
# Веб-страница статуса (не путать с проверкой бэкендов curl)
ENABLE_STATUS_HTTP=0
STATUS_HTTP_PORT=8080
# Через запятую: IP или IPv4 CIDR. Пусто = слушать только 127.0.0.1 внутри контейнера
# STATUS_HTTP_ALLOW_IPS=10.0.0.0/8,172.17.0.1
+53
View File
@@ -0,0 +1,53 @@
name: Docker
on:
push:
branches:
- main
- master
tags:
- 'v*'
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build image
run: docker build -t cloudflare-balancer:ci .
# Опционально: публикация в Container Registry Gitea.
# Секреты репозитория:
# REGISTRY_URL — хост Gitea (https://gitea.example.com допустимо)
# REGISTRY_USER — имя пользователя Gitea (логин)
# PACKAGE_TOKEN — токен для push в пакеты / registry (основной; пароль в docker login)
# REGISTRY_PASSWORD — запасной вариант, если PACKAGE_TOKEN не задан
- name: Log in to registry
if: ${{ secrets.REGISTRY_USER != '' && secrets.REGISTRY_URL != '' && (secrets.PACKAGE_TOKEN != '' || secrets.REGISTRY_PASSWORD != '') }}
run: |
REG="${{ secrets.REGISTRY_URL }}"
REG="${REG#https://}"
REG="${REG#http://}"
REG="${REG%/}"
PASS="${{ secrets.PACKAGE_TOKEN }}"
if [ -z "$PASS" ]; then PASS="${{ secrets.REGISTRY_PASSWORD }}"; fi
echo "$PASS" | docker login "$REG" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Tag and push
if: ${{ secrets.REGISTRY_USER != '' && secrets.REGISTRY_URL != '' && (secrets.PACKAGE_TOKEN != '' || secrets.REGISTRY_PASSWORD != '') }}
run: |
REG="${{ secrets.REGISTRY_URL }}"
REG="${REG#https://}"
REG="${REG#http://}"
REG="${REG%/}"
IMG="cloudflare-balancer"
OWNER_LOWER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
TAG="${GITHUB_SHA:0:12}"
case "${GITHUB_REF}" in refs/tags/*) TAG="${GITHUB_REF#refs/tags/}" ;; esac
docker tag cloudflare-balancer:ci "${REG}/${OWNER_LOWER}/${IMG}:${TAG}"
docker tag cloudflare-balancer:ci "${REG}/${OWNER_LOWER}/${IMG}:latest"
docker push "${REG}/${OWNER_LOWER}/${IMG}:${TAG}"
docker push "${REG}/${OWNER_LOWER}/${IMG}:latest"
+42
View File
@@ -0,0 +1,42 @@
FROM alpine:3.20
RUN apk add --no-cache \
bash \
curl \
jq \
bind-tools \
iputils \
socat \
libcap \
&& setcap cap_net_raw+ep /usr/bin/ping 2>/dev/null || true
RUN mkdir -p /var/www/cfb \
&& chown -R nobody:nogroup /var/www/cfb
COPY --chmod=755 entrypoint.sh status-handler.sh /opt/cfb/
RUN chown -R nobody:nogroup /opt/cfb
USER nobody:nogroup
WORKDIR /opt/cfb
ENV CHECK_INTERVAL_SEC=30 \
CHECK_MODE=ping \
PING_COUNT=2 \
PING_TIMEOUT_SEC=2 \
CURL_MAX_TIME_SEC=5 \
CURL_CONNECT_TIMEOUT_SEC=3 \
CURL_URL_TEMPLATE='http://%s/' \
CURL_OK_MIN=200 \
CURL_OK_MAX=399 \
DEFAULT_TTL=300 \
DEFAULT_PROXIED=false \
ENABLE_STATUS_HTTP=0 \
STATUS_HTTP_PORT=8080 \
STATUS_HTTP_ALLOW_IPS=""
EXPOSE 8080
HEALTHCHECK --interval=45s --timeout=5s --start-period=20s --retries=3 \
CMD test -f /tmp/cfb.liveness && test "$(($(date +%s) - $(stat -c %Y /tmp/cfb.liveness)))" -lt 180 || exit 1
ENTRYPOINT ["/bin/bash", "/opt/cfb/entrypoint.sh"]
+272
View File
@@ -0,0 +1,272 @@
# Cloudflare DNS pool balancer
Лёгкий контейнер на **Alpine**: периодически проверяет доступность бэкендов (**ping** или **HTTP**), синхронизирует записи **A / AAAA** для одного DNS-имени (**пул**) в Cloudflare через **API Token**, пишет цветные логи и опционально отдаёт **HTML-страницу статуса** с ограничением по IP.
---
## Оглавление
- [Как это работает](#как-это-работает)
- [Требования](#требования)
- [Сборка образа (Linux / bash)](#сборка-образа-linux--bash)
- [Быстрый старт](#быстрый-старт)
- [Переменные окружения](#переменные-окружения)
- [Cloudflare: API Token](#cloudflare-api-token)
- [Примеры запуска](#примеры-запуска)
- [Docker Compose](#docker-compose)
- [Веб-интерфейс статуса и whitelist](#веб-интерфейс-статуса-и-whitelist)
- [Логи](#логи)
- [Ограничения](#ограничения)
- [Устранение неполадок](#устранение-неполадок)
- [Сборка в CI (Gitea Actions)](#сборка-в-ci-gitea-actions)
- [Безопасность](#безопасность)
- [Windows и WSL2](#windows-и-wsl2)
---
## Как это работает
1. Для каждой цели из `CHECK_TARGETS` определяется IP (прямой адрес, резолв имени или хоста из URL).
2. Выполняется проверка: **ICMP ping** к IP или **curl** по URL (см. `CHECK_MODE`).
3. IP считается **желательным в пуле**, только если **все** цели, которые резолвятся в этот IP, прошли проверку.
4. Читаются текущие записи **A/AAAA** с именем `POOL_DOMAIN` в Cloudflare.
5. Лишние записи удаляются, недостающие создаются (TTL и proxied из переменных окружения).
Два разных смысла **curl**:
- **Проверка бэкендов** (`CHECK_MODE=curl`) — запросы к URL из целей / шаблону `CURL_URL_TEMPLATE`.
- **Порт `STATUS_HTTP_PORT`** — только встроенная страница статуса внутри контейнера, не health-check.
---
## Требования
- **Docker Engine** на Linux (или эквивалент с Linux-контейнерами).
- У контейнера должен быть доступ в интернет (Cloudflare API, ping/curl к бэкендам).
- Для **ping** из пользователя `nobody` в образе выставлен `cap_net_raw` на `/usr/bin/ping`. Если ping не работает, запускайте с **`--cap-add=NET_RAW`** (в `docker-compose.yml` это уже указано).
---
## Сборка образа (Linux / bash)
Из корня репозитория:
```bash
cd /path/to/cloudflare_balancer
docker build -t cloudflare-balancer:latest .
```
Проверка локально (без реальных секретов контейнер сразу завершится с ошибкой — это нормально):
```bash
docker build -t cloudflare-balancer:latest . && echo "сборка OK"
```
---
## Быстрый старт
Минимально нужны: `POOL_DOMAIN`, `CHECK_TARGETS`, `CLOUDFLARE_API_TOKEN`.
Рекомендуется передавать секреты через **`--env-file`**:
```bash
cp .env.example .env
# отредактируйте .env
docker run --rm \
--cap-add=NET_RAW \
--env-file .env \
cloudflare-balancer:latest
```
---
## Переменные окружения
| Переменная | Обязательно | По умолчанию | Описание |
|------------|-------------|--------------|----------|
| `POOL_DOMAIN` | да | — | DNS-имя пула (все **A/AAAA** с этим именем управляются скриптом). |
| `CHECK_TARGETS` | да | — | Список целей через **запятую**: IP, hostname или `http(s)://...`. |
| `CLOUDFLARE_API_TOKEN` | да* | — | Bearer-токен Cloudflare. |
| `CLOUDFLARE_API_KEY` | нет | — | Алиас для токена (если не задан `CLOUDFLARE_API_TOKEN`). |
| `CLOUDFLARE_ZONE_ID` | нет | — | ID зоны; если пусто — поиск по имени зоны. |
| `CLOUDFLARE_ZONE_NAME` | нет | — | Имя зоны (apex), например `example.com`; если пусто — эвристика: **две последние метки** `POOL_DOMAIN`. |
| `CHECK_INTERVAL_SEC` | нет | `30` | Пауза между циклами, сек. |
| `CHECK_MODE` | нет | `ping` | `ping` или `curl`. |
| `PING_COUNT` | нет | `2` | Число ICMP-запросов. |
| `PING_TIMEOUT_SEC` | нет | `2` | Таймаут ping (см. `iputils-ping`). |
| `CURL_URL_TEMPLATE` | нет | `http://%s/` | Для не-URL целей: один плейсхолдер `%s` заменяется на цель. |
| `CURL_MAX_TIME_SEC` | нет | `5` | `--max-time` для curl. |
| `CURL_CONNECT_TIMEOUT_SEC` | нет | `3` | `--connect-timeout`. |
| `CURL_OK_MIN` | нет | `200` | Минимальный допустимый HTTP-код. |
| `CURL_OK_MAX` | нет | `399` | Максимальный допустимый HTTP-код. |
| `DEFAULT_TTL` | нет | `300` | TTL при создании записи (для **прокси**-записей Cloudflare сам использует авто-TTL). |
| `DEFAULT_PROXIED` | нет | `false` | `true` / `false` — оранжевое облако. |
| `ENABLE_STATUS_HTTP` | нет | `0` | `1` — включить HTTP-страницу статуса. |
| `STATUS_HTTP_PORT` | нет | `8080` | Порт **внутри контейнера** для страницы статуса. |
| `STATUS_HTTP_ALLOW_IPS` | нет | *(пусто)* | Whitelist: IP и IPv4 **CIDR** через запятую. Пусто = слушать только **127.0.0.1**. |
\* Обязателен токен или алиас.
> **Важно:** эвристика зоны по двум последним меткам не подходит для всех публичных суффиксов (например некоторые зоны второго уровня). В сомнениях задайте `CLOUDFLARE_ZONE_ID` или `CLOUDFLARE_ZONE_NAME`.
---
## Cloudflare: API Token
1. Cloudflare Dashboard → **My Profile****API Tokens****Create Token**.
2. Шаблон **Edit zone DNS** или кастомный минимум:
- **Zone** — **DNS****Edit**
- **Zone** — **Zone****Read**
- Ограничить **конкретной зоной** (рекомендуется).
3. Скопируйте токен в `.env` как `CLOUDFLARE_API_TOKEN`.
Официальная документация API: [Cloudflare API](https://developers.cloudflare.com/api/).
---
## Примеры запуска
### Только ping, без UI
```bash
docker run --rm --cap-add=NET_RAW \
-e POOL_DOMAIN=app.example.com \
-e CHECK_TARGETS='10.0.0.1,10.0.0.2' \
-e CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \
-e CHECK_INTERVAL_SEC=20 \
cloudflare-balancer:latest
```
### Режим curl и шаблон URL
```bash
docker run --rm --cap-add=NET_RAW \
-e POOL_DOMAIN=app.example.com \
-e CHECK_TARGETS='backend1.internal,backend2.internal' \
-e CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \
-e CHECK_MODE=curl \
-e CURL_URL_TEMPLATE='http://%s:8080/health' \
cloudflare-balancer:latest
```
### Полный URL как цель
```bash
docker run --rm --cap-add=NET_RAW \
-e POOL_DOMAIN=app.example.com \
-e CHECK_TARGETS='https://node1.example.com/health' \
-e CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \
-e CHECK_MODE=curl \
cloudflare-balancer:latest
```
### Веб-статус + проброс порта + whitelist
При доступе **с хоста Docker** клиентский IP в контейнере часто совпадает с адресом **шлюза bridge** (часто `172.17.0.1` или подсеть `172.17.0.0/16`). Добавьте её или конкретный IP в whitelist.
```bash
docker run --rm --cap-add=NET_RAW \
-p 8080:8080 \
-e POOL_DOMAIN=app.example.com \
-e CHECK_TARGETS='10.0.0.1' \
-e CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \
-e ENABLE_STATUS_HTTP=1 \
-e STATUS_HTTP_PORT=8080 \
-e STATUS_HTTP_ALLOW_IPS='127.0.0.1,172.17.0.0/16,10.0.0.0/8' \
cloudflare-balancer:latest
```
Откройте в браузере: `http://127.0.0.1:8080/` (если whitelist это разрешает).
---
## Docker Compose
```bash
cp .env.example .env
# заполните CLOUDFLARE_API_TOKEN и остальное
docker compose build
docker compose up -d
docker compose logs -f
```
Файл [docker-compose.yml](docker-compose.yml) подключает `.env` и добавляет **`NET_RAW`** для ping.
---
## Веб-интерфейс статуса и whitelist
- **`STATUS_HTTP_PORT`** — порт HTTP-сервера **статуса**, не имеет отношения к `CHECK_MODE=curl`.
- Если **`STATUS_HTTP_ALLOW_IPS` пустой** — `socat` слушает **127.0.0.1** (доступ с хоста через `-p` без NAT к loopback контейнера обычно **не** попадёт на 127.0.0.1 внутри контейнера). Для просмотра со стороны хоста задайте whitelist и `0.0.0.0`-bind (это делается автоматически при непустом whitelist).
- В whitelist поддерживаются **точные IPv4** и **IPv4 CIDR**; для IPv6 — в основном точное совпадение (CIDR для v6 в обработчике минимальный).
---
## Логи
```bash
docker logs -f <container_id>
```
В логах используются **ANSI-цвета** (удобно в терминале). Блоки: заголовок цикла, строки **OK/FAIL** по целям, действия **DELETE/POST** в API.
---
## Ограничения
- Одна строка в `CHECK_TARGETS` с запятой внутри URL может сломать разбор — избегайте запятых в целях или используйте только один хост на цель.
- Несколько имён, резолвящихся в **один IP**, считаются одним бэкендом: IP остаётся в пуле только если **все** такие проверки успешны.
- Записи **A/AAAA** для `POOL_DOMAIN`, которые **не соответствуют** IP из текущего резолва целей, **не удаляются** (в лог и HTML выводится предупреждение «вне списка»).
---
## Устранение неполадок
| Симптом | Что проверить |
|--------|----------------|
| Ошибка авторизации Cloudflare | Токен, срок, зона в области действия токена. |
| «Зона не найдена» | Задайте `CLOUDFLARE_ZONE_ID` или корректный `CLOUDFLARE_ZONE_NAME`. |
| Все ping FAIL | `NET_RAW` / `cap_add`; маршрутизация сети контейнера до бэкендов; ICMP может быть запрещён файрволом. |
| curl всегда FAIL | URL, TLS, коды ответа (`CURL_OK_MIN` / `CURL_OK_MAX`), таймауты. |
| UI отдаёт 403 | Whitelist: добавьте IP шлюза Docker или вашу подсеть. |
| После старта пул «пустой» | При всех FAIL записи удаляются — проверьте доступность целей и корректность IP. |
---
## Сборка в CI (Gitea Actions)
1. На сервере Gitea включите **Actions** и подключите **runner** ([документация](https://docs.gitea.com/usage/actions/overview)).
2. Workflow: [.gitea/workflows/docker.yml](.gitea/workflows/docker.yml).
3. По умолчанию выполняется **`docker build`**.
4. Опциональная публикация в **Container Registry** Gitea: задайте секреты репозитория:
- `REGISTRY_URL` — хост (можно с `https://`, скрипт обрежет протокол для `docker login`).
- `REGISTRY_USER` — логин пользователя Gitea.
- **`PACKAGE_TOKEN`** — токен Gitea с правом публиковать пакеты (используется как **пароль** в `docker login`; рекомендуемый способ).
- `REGISTRY_PASSWORD` — опционально, только если не задан `PACKAGE_TOKEN` (устаревший/альтернативный секрет).
Имя образа в registry: `<REGISTRY>/<owner_lowercase>/cloudflare-balancer:<tag>`.
> В Gitea создайте **Personal Access Token** (или токен с нужным scope) с доступом к **пакетам** / записи в Container Registry и сохраните его в секрете **`PACKAGE_TOKEN`**.
---
## Безопасность
- Не коммитьте `.env` и реальные токены.
- Минимизируйте права API Token одной зоной.
- Не публикуйте порт статуса в интернет без **whitelist** и без понимания сетевой модели Docker.
---
## Windows и WSL2
Основные команды из этой инструкции рассчитаны на **bash под Linux**. На Windows удобнее запускать Docker **внутри WSL2** и выполнять те же `docker build` / `docker run` из Ubuntu (или другого дистрибутива в WSL).
---
## Лицензия
Проект в репозитории пользователя — при необходимости добавьте файл `LICENSE` отдельно.
+13
View File
@@ -0,0 +1,13 @@
services:
balancer:
build: .
image: cloudflare-balancer:latest
restart: unless-stopped
env_file:
- .env
# Для ping из непривилегированного контейнера при отсутствии cap на бинарник:
cap_add:
- NET_RAW
# Опционально: веб-статус на хосте (см. STATUS_HTTP_ALLOW_IPS)
# ports:
# - "8080:8080"
+465
View File
@@ -0,0 +1,465 @@
#!/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
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# Вызывается из socat EXEC; в окружении есть SOCAT_PEERADDR.
set -uo pipefail
STATUS_FILE="${STATUS_FILE:-/var/www/cfb/status.html}"
ALLOW="${STATUS_HTTP_ALLOW_IPS:-}"
ip_to_int() {
local a b c d IFS=.
read -r a b c d <<<"$1"
[[ -n "$d" ]] || return 1
echo $(((a << 24) | (b << 16) | (c << 8) | d))
}
ipv4_in_cidr() {
local ip="$1" cidr="$2" base masklen ipn basen mask
[[ "$cidr" =~ ^([0-9]{1,3}(\.[0-9]{1,3}){3})/([0-9]{1,2})$ ]] || return 1
base="${BASH_REMATCH[1]}"
masklen="${BASH_REMATCH[3]}"
(( masklen >= 0 && masklen <= 32 )) || return 1
(( masklen == 0 )) && return 0
ipn=$(ip_to_int "$ip") || return 1
basen=$(ip_to_int "$base") || return 1
(( masklen == 32 )) && { [[ "$ip" == "$base" ]]; return; }
mask=$(( (0xFFFFFFFF << (32 - masklen)) & 0xFFFFFFFF ))
(( (ipn & mask) == (basen & mask) ))
}
extract_peer_ip() {
local raw="${SOCAT_PEERADDR:-}"
[[ -n "$raw" ]] || { echo ""; return 1; }
if [[ "$raw" == \[*\]* ]]; then
local inner="${raw#\[}"
echo "${inner%%\]*}"
return 0
fi
if [[ "$raw" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}:[0-9]+$ ]]; then
echo "${raw%:*}"
return 0
fi
echo "$raw"
}
allowed_ip() {
local peer="$1" entry trimmed
[[ -n "$peer" ]] || return 1
IFS=',' read -ra _entries <<<"$ALLOW"
for entry in "${_entries[@]}"; do
trimmed="${entry#"${entry%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
[[ -n "$trimmed" ]] || continue
if [[ "$trimmed" == */* ]]; then
if [[ "$peer" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
ipv4_in_cidr "$peer" "$trimmed" && return 0
fi
continue
fi
[[ "$peer" == "$trimmed" ]] && return 0
done
return 1
}
# Считать минимальный HTTP-запрос
while IFS= read -r line; do
line="${line%$'\r'}"
[[ -z "$line" ]] && break
done
peer_ip=$(extract_peer_ip)
hdr_ok=$'HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n'
hdr_denied=$'HTTP/1.0 403 Forbidden\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\nForbidden\r\n'
if [[ -z "${ALLOW// }" ]]; then
# только loopback — на всякий случай
if [[ "$peer_ip" != "127.0.0.1" && "$peer_ip" != "::1" ]]; then
printf '%b' "$hdr_denied"
exit 0
fi
else
if ! allowed_ip "$peer_ip"; then
printf '%b' "$hdr_denied"
exit 0
fi
fi
printf '%b' "$hdr_ok"
if [[ -r "$STATUS_FILE" ]]; then
cat "$STATUS_FILE"
else
printf '%s\n' '<!DOCTYPE html><html><body><p>status pending</p></body></html>'
fi