From 2671e90d201762a02d9ea08a9a0dc4b2f9d15d66 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 23 Mar 2026 17:31:26 +0700 Subject: [PATCH] Init Commit --- .dockerignore | 6 + .env.example | 33 +++ .gitea/workflows/docker.yml | 53 ++++ Dockerfile | 42 ++++ README.md | 272 +++++++++++++++++++++ docker-compose.yml | 13 + entrypoint.sh | 465 ++++++++++++++++++++++++++++++++++++ status-handler.sh | 93 ++++++++ 8 files changed, 977 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 status-handler.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38753ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitea +*.md +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f195dc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..fb8bba0 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -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" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3c981d5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1aff0f --- /dev/null +++ b/README.md @@ -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 +``` + +В логах используются **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: `//cloudflare-balancer:`. + +> В 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` отдельно. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..886e59c --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..4fe0e55 --- /dev/null +++ b/entrypoint.sh @@ -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 '
  • %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Должен быть
    +
    +
    +
    +

    События

    +
      ${events_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' +} + +# --- старт --- +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 diff --git a/status-handler.sh b/status-handler.sh new file mode 100644 index 0000000..fb3a377 --- /dev/null +++ b/status-handler.sh @@ -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' '

    status pending

    ' +fi