@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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` отдельно.
|
||||||
@@ -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
@@ -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/&/\&/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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user