@@ -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