Compare commits

..

No commits in common. "main" and "refactor" have entirely different histories.

8 changed files with 113 additions and 573 deletions

View file

@ -15,28 +15,18 @@ jobs:
DOMAINS_FILE: domains.txt
# Временные конфиги (куда пишутся generate-configs.sh)
NFTSET_CONF: /tmp/90-nftset.conf
IPSET_CONF: /tmp/91-ipset-bbrkn.conf
RESOLVE_CONF: /tmp/92-resolve-bbrkn.conf
# Резолвер, которому pihole делегирует bbrkn-домены (host dnsmasq :5350).
# Кавычки обязательны — '#' иначе может трактоваться как комментарий.
DNS_SERVER: "127.0.0.1#5350"
# DNS-сервер для резолвинга
DNS_SERVER: 10.100.1.2
# Gekata
CHROME_SERVER: http://10.77.1.2:3000
# nftset-таргет: host-инстанс dnsmasq (:5350) грузит /etc/dnsmasq.d
NFTSET_TARGET_DIR: /etc/dnsmasq.d
# resolve-таргет: pihole (docker) делегирует bbrkn-домены на :5350
# Директория конфигов на шлюзе
TARGET_DIR: /opt/appdata/pihole/etc/dnsmasq.d
# Имя контейнера Pi-hole
DOCKER_CONTAINER: pihole
# systemd-сервис host-инстанса dnsmasq
HOST_DNSMASQ_SVC: dnsmasq
IGNORE_PARTS: "doubleclick yandex mail.ru bigsv.ru mts.ru rambler.ru yadro.ru aif.ru vkvideo.ru tns-counter.ru st.vk.com vk.com"
# Кэш API-ответов (self-hosted runner сохраняет workspace между запусками)
@ -58,6 +48,6 @@ jobs:
with:
name: dnsmasq-configs
path: |
${{ env.NFTSET_CONF }}
${{ env.IPSET_CONF }}
${{ env.RESOLVE_CONF }}
${{ env.DEBUG_LOG }}

View file

@ -1,53 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Generates **Pi-hole / dnsmasq** configs that route blocked domains through a WireGuard VPN via **nftables sets**. Input is `domains.txt`; output is two dnsmasq files deployed to **two different dnsmasq instances** on the Archie gateway (see Architecture). README.md (Russian) is the authoritative reference — read it for the classification table, env-var descriptions, and report format.
> **Routing model (Archie gateway):** client → pihole (docker, `:53`) delegates bbrkn domains via `92-resolve-bbrkn.conf` (`server=/domain/127.0.0.1#5350`) to a **host dnsmasq on `:5350`**, which resolves via VPN and applies `90-nftset.conf` (`nftset=…#bbrkn_v4,…#bbrkn_v6`) to populate nft sets `inet filter bbrkn_v4`/`bbrkn_v6`. An nft mangle chain marks packets to those set IPs (`0x1`/`0x2`) and policy-routing sends them out a WireGuard exit. Dual-exit DPI-bypass failover (`10.77.1.2`/`10.77.2.2`) is handled by `wg-ha.service`, **external to bbrkn** — bbrkn only maintains domain→set membership.
## Commands
```bash
make all # generate + deploy (full cycle)
make generate # generate configs into /tmp only
make deploy # deploy existing configs to Pi-hole
make check # validate domains.txt syntax (regex per line)
make clean # remove generated /tmp configs
make cache-clean # wipe .cache/api/
./scripts/generate-configs.sh --dry-run # generate, print report, write nothing
DEBUG=1 make generate # verbose log to $DEBUG_LOG
STRICT_MODE=1 make generate # exit 1 if any domain's API query failed
CACHE_TTL_DAYS=0 make generate # bypass API cache for one run
```
No test suite. Verification = `--dry-run` + reading the DEBUG REPORT (see README) and diffing generated `/tmp` configs.
## Architecture
Two bash scripts, driven by env vars (defaults in each script's header, overridden by Makefile and CI):
**`scripts/generate-configs.sh`** — classifies each domain by querying a Chromium headless API (`$CHROME_SERVER/domains?domain=`), builds the ordered domain list, writes both config files in one batch. Key logic:
- **Classification** (drives whether subdomains are added): API JSON with `relatedDomains`*site* (base + related subdomains); `ERR_NAME_NOT_RESOLVED`/`Timeout`*dead*, skipped entirely; any other API error → *service* (base only, no subdomains); API fully unreachable → *fallback* (base only, recorded under `API FAILURES`). **Invariant: every domain in `domains.txt` reaches the output unless it's dead.**
- **State is held in bash associative arrays** (`DOM_ROLE`, `EXPANDED`, `SOURCES`, `SITE_RELATED`, …). The main loop deliberately avoids subshells so counters survive — do not wrap counter-mutating code in `$(...)` or pipes.
- **Caching**: successful API responses cached to `$CACHE_DIR/<domain>.json`, reused while newer than `CACHE_TTL_DAYS`; corrupt or expired entries refetch. `find -mtime +N` decides freshness.
- **Ordering pass** emits each base domain, a `# domain — N subdomains` comment, then its not-yet-written related domains; a related domain is attributed to whichever base comes first alphabetically. A final uniqueness check `exit 5`s (config NOT written) if any domain would appear twice — treat that as a real bug in the ordering logic, not a data issue.
- Output lines: `nftset=/<domain>/$NFTSET_SPEC` (NFTSET_CONF → `90-nftset.conf`; default `NFTSET_SPEC=4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6`) and `server=/<domain>/$DNS_SERVER` (RESOLVE_CONF → `92-resolve-bbrkn.conf`; `DNS_SERVER` default `127.0.0.1#5350`, the host resolver pihole delegates to).
**`scripts/deploy-to-gateway.sh`** — deploys to **two targets**: `90-nftset.conf` → host `$NFTSET_TARGET_DIR` (`/etc/dnsmasq.d`, the `:5350` instance) and `92-resolve-bbrkn.conf` → pihole `$RESOLVE_TARGET_DIR` (`/opt/appdata/pihole/etc/dnsmasq.d`). Flow: timestamped-backup both → copy → **`systemctl restart $HOST_DNSMASQ_SVC` + `docker restart $DOCKER_CONTAINER`** (a full restart is required — dnsmasq **SIGHUP does not re-read `nftset=`/`server=` directives**, only hosts files) → wait for `:5350` listening + pihole `running``nft flush set` on `bbrkn_v4`/`bbrkn_v6` → DNS health check via pihole `:53` → end-to-end nftset-capture check (resolve a bbrkn domain via `:5350`, assert the set populated). **On any failure it calls `rollback()`**, restoring both backups and restarting both instances. Edits must preserve that rollback path — a broken deploy can break DNS for every client on the gateway.
## Deployment / CI
`.forgejo/workflows/deploy.yaml` runs on push to `main` (and `workflow_dispatch`) on a **self-hosted `forgejo-runner` on the gateway itself** — no SSH, deploy is local. Note: the runner is **ephemeral** (no persisted `.cache/api` was found on the host despite the older "persists workspace" claim), so a fresh CI run performs a **cold gekata crawl of every domain** (~30-70s each, serialized) — the first run after a cache reset is slow and heavy on gekata. CI runs `make clean && make all` and uploads the generated configs + debug log as artifacts even on failure. CI-only values (`NFTSET_TARGET_DIR`, `TARGET_DIR`, `CHROME_SERVER`, `DOCKER_CONTAINER`, `HOST_DNSMASQ_SVC`, `DNS_SERVER`, `IGNORE_PARTS`) live in the workflow `env:` block, not the scripts.
## Gotchas
- `domains.txt` is the primary data file; commits are typically domain additions. Run `make check` after editing it.
- `IGNORE_PARTS` is space-separated substrings; any domain (base or related) containing one is dropped.
- **`#` gotcha**: `DNS_SERVER=127.0.0.1#5350``#` starts a comment in Makefiles (escape as `127.0.0.1\#5350`) and can start one in YAML (quote it). The scripts themselves take the plain value.
- The nft sets (`bbrkn_v4`/`bbrkn_v6`, `inet filter`) and mangle/routing rules live on the gateway (`/etc/nftables.d/`, Ansible-managed) — bbrkn only fills the sets via dnsmasq, it does not define them.
- Requires `bash` 4.2+ (associative arrays, `${var,,}`), `curl`, `jq`, `docker`, `nft`, `systemctl`; `dig`/`nslookup` optional (health checks skipped if absent).

View file

@ -3,29 +3,26 @@
# ===============================
# --- Переменные окружения (с дефолтами для локального запуска) ---
NFTSET_CONF ?= /tmp/90-nftset.conf
IPSET_CONF ?= /tmp/91-ipset-bbrkn.conf
RESOLVE_CONF ?= /tmp/92-resolve-bbrkn.conf
CHROME_SERVER ?= http://10.77.1.2:3000
CHROME_SERVER ?= http://10.100.1.2:3000
DOMAINS_FILE ?= domains.txt
# Резолвер, которому pihole делегирует bbrkn-домены (host dnsmasq :5350).
# ВНИМАНИЕ: '#' в Makefile — начало комментария, экранируем как '\#'.
DNS_SERVER ?= 127.0.0.1\#5350
NFTSET_TARGET_DIR ?= /etc/dnsmasq.d
DNS_SERVER ?= 10.100.1.2
CACHE_DIR ?= .cache/api
CACHE_TTL_DAYS ?= 15
# Экспортируем переменные, чтобы они были доступны внутри shell-скриптов
export NFTSET_CONF RESOLVE_CONF CHROME_SERVER DOMAINS_FILE DNS_SERVER NFTSET_TARGET_DIR CACHE_DIR CACHE_TTL_DAYS
export IPSET_CONF RESOLVE_CONF CHROME_SERVER DOMAINS_FILE DNS_SERVER CACHE_DIR CACHE_TTL_DAYS
# --- Основные цели ---
.PHONY: all clean cache-clean check generate deploy warm
.PHONY: all clean cache-clean check generate deploy
all: generate deploy
@echo "✅ Конфиги успешно сгенерированы и задеплоены"
clean:
@echo "🧹 Очистка временных файлов"
@rm -f $(NFTSET_CONF) $(RESOLVE_CONF)
@rm -f $(IPSET_CONF) $(RESOLVE_CONF)
cache-clean:
@echo "🗑 Очистка кэша API ($(CACHE_DIR))"
@ -55,10 +52,3 @@ deploy:
@echo "🚀 Деплой конфигов в систему"
@chmod +x scripts/deploy-to-gateway.sh
@./scripts/deploy-to-gateway.sh
# Прогрев nft-сетов. Запускать на шлюзе — читает задеплоенный конфиг и
# ходит в host-инстанс dnsmasq на 127.0.0.1:5350.
warm:
@echo "🔥 Прогрев nft-сетов"
@chmod +x scripts/warm-nftset.sh
@./scripts/warm-nftset.sh

141
README.md
View file

@ -1,6 +1,6 @@
# bbrkn — DNS Bypass Config Generator
Автоматизирует генерацию конфигураций **Pi-hole / dnsmasq** для маршрутизации трафика заблокированных доменов через VPN-туннель (WireGuard) с помощью **nftables-сетов**.
Автоматизирует генерацию конфигураций **Pi-hole / dnsmasq** для маршрутизации трафика заблокированных доменов через VPN-туннель (WireGuard).
## Как это работает
@ -8,29 +8,25 @@
flowchart TD
A[domains.txt] --> B[generate-configs.sh]
B -->|кэш .cache/api| B
B -->|MISS / EXPIRED| C[gekata: Chromium headless API]
B -->|MISS / EXPIRED| C[Chromium headless API]
C --> B
B --> D[90-nftset.conf]
B --> D[91-ipset-bbrkn.conf]
B --> E[92-resolve-bbrkn.conf]
D --> F[deploy-to-gateway.sh]
E --> F
F -->|backup + copy + systemctl restart| G[host dnsmasq :5350<br/>/etc/dnsmasq.d/]
F -->|backup + copy + docker restart| H[Pi-hole :53]
F -->|health check + nftset check| H
F -.->|rollback если упал| G
F -->|backup + copy| G[Pi-hole dnsmasq.d/]
F -->|docker restart| H[Pi-hole container]
F -->|DNS health check| H
H -->|rollback если упал| G
subgraph "Трафик клиента"
K[Клиент] -->|DNS запрос| H
H -->|"server= → 127.0.0.1#5350"| G
G -->|"резолв через VPN + nftset="| N["nft set bbrkn_v4 / bbrkn_v6"]
N -->|ip daddr @bbrkn → mark| I[nft mangle]
I -->|policy routing| J[WireGuard exit<br/>10.77.1.2 / 10.77.2.2]
H -->|домен в ipset → mark| I[iptables]
I --> J[WireGuard VPN]
J --> L[Интернет]
end
```
> **Два инстанса dnsmasq.** Pi-hole (:53) отдаёт клиентов и делегирует bbrkn-домены host-инстансу на :5350 (`92-resolve`). Инстанс :5350 резолвит их через VPN и по директиве `nftset=` (`90-nftset.conf`) кладёт полученные IP в nft-сеты `bbrkn_v4`/`bbrkn_v6`. nft-mangle маркирует пакеты к этим IP, policy-routing уводит их в WireGuard. **Выбор из двух exit-нод (DPI-обход РКН) и failover — вне bbrkn**, ими управляет `wg-ha.service` на шлюзе.
---
## Классификация доменов
@ -50,26 +46,15 @@ flowchart TD
Домены группируются по базовому домену с комментарием:
`90-nftset.conf` (наполняет nft-сеты):
```
# example.com — 4 subdomains
nftset=/example.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6
nftset=/static.example.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6
nftset=/api.example.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6
ipset=/example.com/bbrkn
ipset=/static.example.com/bbrkn
ipset=/api.example.com/bbrkn
...
# some-service.com — 0 subdomains
nftset=/some-service.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6
```
`92-resolve-bbrkn.conf` (делегирует те же домены host-инстансу :5350):
```
# example.com — 4 subdomains
server=/example.com/127.0.0.1#5350
server=/static.example.com/127.0.0.1#5350
...
ipset=/some-service.com/bbrkn
```
---
@ -82,8 +67,7 @@ bbrkn/
├── Makefile # управление сборкой и деплоем
├── scripts/
│ ├── generate-configs.sh # генерация dnsmasq-конфигов
│ ├── deploy-to-gateway.sh # деплой в Pi-hole + rollback
│ └── warm-nftset.sh # прогрев nft-сетов по cron (на шлюзе)
│ └── deploy-to-gateway.sh # деплой в Pi-hole + rollback
└── .forgejo/workflows/deploy.yaml # CI/CD на self-hosted runner
```
@ -98,7 +82,6 @@ make deploy # только задеплоить в Pi-hole
make clean # удалить сгенерированные конфиги из /tmp
make cache-clean # очистить кэш API-ответов (.cache/api/)
make check # проверить синтаксис domains.txt
make warm # прогреть nft-сеты (запускать на шлюзе)
```
---
@ -110,11 +93,10 @@ make warm # прогреть nft-сеты (запускать на шл
| Переменная | По умолчанию | Описание |
|---|---|---|
| `DOMAINS_FILE` | `domains.txt` | Входной файл со списком доменов |
| `NFTSET_CONF` | `/tmp/90-nftset.conf` | Выходной файл директив `nftset=` |
| `NFTSET_SPEC` | `4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6` | Цель nftset: `<4\|6>#family#table#set` |
| `RESOLVE_CONF` | `/tmp/92-resolve-bbrkn.conf` | Выходной файл директив `server=` |
| `CHROME_SERVER` | `http://127.0.0.1:3000` | Адрес gekata (Chromium headless API) |
| `DNS_SERVER` | `127.0.0.1#5350` | Резолвер, которому pihole делегирует домены (host dnsmasq :5350) |
| `IPSET_CONF` | `/tmp/91-ipset-bbrkn.conf` | Выходной файл правил ipset |
| `RESOLVE_CONF` | `/tmp/92-resolve-bbrkn.conf` | Выходной файл правил server= |
| `CHROME_SERVER` | `http://127.0.0.1:3000` | Адрес Chromium headless API |
| `DNS_SERVER` | `8.8.8.8` | DNS-сервер для резолвинга через VPN |
| `IGNORE_PARTS` | _(пусто)_ | Подстроки через пробел — домены с совпадением игнорируются |
| `CACHE_DIR` | `.cache/api` | Директория кэша API-ответов |
| `CACHE_TTL_DAYS` | `15` | Время жизни кэша в днях; `0` — отключить кэш |
@ -126,17 +108,12 @@ make warm # прогреть nft-сеты (запускать на шл
| Переменная | По умолчанию | Описание |
|---|---|---|
| `NFTSET_CONF` | `/tmp/90-nftset.conf` | Сгенерированный файл `nftset=` |
| `NFTSET_TARGET_DIR` | `/etc/dnsmasq.d` | Директория host-инстанса dnsmasq (:5350) |
| `RESOLVE_CONF` | `/tmp/92-resolve-bbrkn.conf` | Сгенерированный файл `server=` |
| `TARGET_DIR` | `/opt/appdata/pihole/etc/dnsmasq.d` | Директория dnsmasq в Pi-hole (для `92-resolve`) |
| `TARGET_DIR` | `/opt/appdata/pihole/etc/dnsmasq.d` | Директория dnsmasq в Pi-hole |
| `IPSET_CONF` | `/tmp/91-ipset-bbrkn.conf` | Сгенерированный файл ipset |
| `RESOLVE_CONF` | `/tmp/92-resolve-bbrkn.conf` | Сгенерированный файл server= |
| `DOCKER_CONTAINER` | `pihole` | Имя Docker-контейнера Pi-hole |
| `HOST_DNSMASQ_SVC` | `dnsmasq` | systemd-сервис host-инстанса dnsmasq |
| `NFT_SETS` | `inet filter bbrkn_v4;inet filter bbrkn_v6` | Сеты для flush после деплоя (через `;`) |
| `DNS_LISTEN_ADDR` | `127.0.0.1` | Адрес, на котором Pi-hole слушает DNS (:53) |
| `DNS_CHECK_DOMAIN` | `google.com` | Домен базового DNS health check |
| `NFTSET_CHECK_DOMAIN` | `chatgpt.com` | bbrkn-домен для end-to-end проверки nftset-захвата |
| `RESOLVER_ADDR` / `RESOLVER_PORT` | `127.0.0.1` / `5350` | Адрес host-инстанса для проверки захвата |
| `DNS_LISTEN_ADDR` | `127.0.0.1` | Адрес, на котором Pi-hole слушает DNS |
| `DNS_CHECK_DOMAIN` | `google.com` | Домен для DNS health check после рестарта |
---
@ -148,7 +125,7 @@ make warm # прогреть nft-сеты (запускать на шл
- Если файл **старше** TTL → перезапрашивается и кэш обновляется.
- Если кэш **повреждён** (невалидный JSON) → перезапрашивается автоматически.
> **Внимание:** forgejo-runner на шлюзе **эфемерный** — workspace (и `.cache/api`) между запусками не сохраняется. Поэтому первый CI-прогон после сброса кэша выполняет **холодный обход всех доменов через gekata** (~30-70 с на домен, последовательно) и работает долго. Для быстрых повторных прогонов держите тёплый `.cache/api` в постоянной директории (`CACHE_DIR`).
На self-hosted runner workspace сохраняется между запусками, поэтому повторные CI-запуски значительно быстрее.
```bash
# Сбросить кэш и запросить всё заново
@ -162,64 +139,15 @@ CACHE_TTL_DAYS=0 make generate
## Деплой и автооткат
`deploy-to-gateway.sh` деплоит в **два инстанса dnsmasq** и выполняет шаги:
`deploy-to-gateway.sh` выполняет следующие шаги:
1. Проверяет наличие обоих сгенерированных конфигов.
2. Создаёт timestamped-бэкапы `90-nftset.conf` (host) и `92-resolve-bbrkn.conf` (pihole).
3. Копирует `90-nftset.conf``NFTSET_TARGET_DIR`, `92-resolve-bbrkn.conf``TARGET_DIR`.
4. Перезапускает **оба** инстанса: `systemctl restart $HOST_DNSMASQ_SVC` + `docker restart $DOCKER_CONTAINER`. Полный рестарт обязателен — dnsmasq по **SIGHUP не перечитывает** директивы `nftset=`/`server=`.
5. Ждёт: host-инстанс слушает `:5350` (до 15 с) и pihole в состоянии `running` (до 30 с).
6. Сбрасывает nft-сеты `bbrkn_v4`/`bbrkn_v6` (устаревшие IP иначе висят до timeout 24 ч).
7. Health check: базовый DNS через pihole `:53` + end-to-end проверка nftset-захвата (резолв bbrkn-домена через `:5350` → сет наполнился).
8. При ошибке на шагах 57 — **автоматически откатывается**: восстанавливает оба бэкапа и перезапускает оба инстанса со старой конфигурацией.
---
## Прогрев nft-сетов (`warm-nftset.sh`)
Элементы сетов `bbrkn_v4`/`bbrkn_v6` живут по `timeout` (сейчас `1d`) и обновляются **только** когда запрос доходит до host-инстанса `:5350` — именно там применяются директивы `nftset=`. Если домен долго никто не открывал (или ответ отдал кэш pihole FTL), элемент истекает, и трафик к этому домену уходит мимо туннеля напрямую в WAN.
`scripts/warm-nftset.sh` закрывает эту дыру: заново резолвит **все** домены из задеплоенного `90-nftset.conf` (база + связанные субдомены) **напрямую** через `dig @127.0.0.1 -p 5350`, минуя кэш FTL, — каждый запрос гарантированно доходит до тегирующего инстанса.
> **Запускать только на шлюзе.** Инстанс `:5350` слушает `listen-address=127.0.0.1` и с других хостов LAN недоступен. Список доменов берётся из задеплоенного конфига, так что скрипт всегда работает с актуальным после последнего деплоя списком.
```bash
# разово, вручную
make warm
./scripts/warm-nftset.sh
# ежечасно по cron (пользователь на шлюзе, sudo без пароля для nft)
crontab -e
17 * * * * /path/to/bbrkn/scripts/warm-nftset.sh >/dev/null 2>&1
```
### Переменные окружения
| Переменная | По умолчанию | Описание |
|---|---|---|
| `NFTSET_CONF` | `/etc/dnsmasq.d/90-nftset.conf` | Задеплоенный конфиг — источник списка доменов |
| `RESOLVER` / `RESOLVER_PORT` | `127.0.0.1` / `5350` | Host-инстанс dnsmasq, применяющий `nftset=` |
| `PARALLEL` | `8` | Одновременных запросов к `:5350` |
| `DIG_TIMEOUT` / `DIG_TRIES` | `3` / `2` | Таймаут и число попыток на домен |
| `WARM_PING` | `0` | `1` — дополнительно пинговать первый A-адрес (только для отладки) |
| `STATE_DIR` | `~/.local/state/bbrkn` | Лог `warm.log` и lock-файл |
| `LOG_MAX_BYTES` | `4194304` | Порог ротации лога в `warm.log.1` |
Параллельные запуски блокируются через `flock` — медленный прогон не наложится на следующий по cron.
### Лог
```
2026-08-05T20:46:57+03:00 START domains=1059 conf=/etc/dnsmasq.d/90-nftset.conf v4=526 v6=305
2026-08-05T20:48:44+03:00 DONE ok=1036 noping=0 dnsfail=23 v4=526->2203 v6=305->1440
2026-08-05T20:48:44+03:00 dnsfail: azureedge.net cdn-telegram.org kinozal.tv ...
```
`v4=X->Y` — число элементов в сете до и после прогона (считается через `sudo -n nft list set`; без прав sudo вместо числа будет `?`, на сам прогрев это не влияет). Элементов больше, чем доменов — у CDN на один домен приходится несколько адресов. Домены в `dnsfail` не резолвятся вообще (мёртвые записи, NXDOMAIN) — кандидаты на удаление из `domains.txt`.
> `WARM_PING=1` на шлюзе почти всегда даёт `noping`: пакеты, рождённые на самом шлюзе, идут через `OUTPUT` и не проходят цепочку `prerouting_mangle`, то есть уходят в WAN без метки. Это диагностический режим, не показатель проблемы.
Полный прогон 1059 доменов при `PARALLEL=8` занимает ~1 мин 45 с.
1. Проверяет наличие сгенерированных конфигов.
2. Создаёт timestamped-бэкапы текущих конфигов в `TARGET_DIR`.
3. Копирует новые конфиги в `TARGET_DIR`.
4. Перезапускает контейнер Pi-hole.
5. Ждёт запуска (до 30 секунд, polling через `docker inspect`).
6. Выполняет DNS health check через `dig` / `nslookup`.
7. При ошибке на шагах 5 или 6 — **автоматически откатывается**: восстанавливает бэкапы и перезапускает контейнер со старой конфигурацией.
---
@ -298,11 +226,10 @@ some-domain.com → api_failed_added_without_subdomains
| Инструмент | Где используется |
|---|---|
| `bash` 4.2+ | оба скрипта |
| `curl` | запросы к gekata API |
| `curl` | запросы к Chromium API |
| `jq` | парсинг JSON-ответов |
| `docker` | перезапуск Pi-hole |
| `nft` | flush nft-сетов + проверка захвата после деплоя |
| `systemctl` | рестарт host-инстанса dnsmasq |
| `ipset` | сброс маршрутов после деплоя |
| `dig` или `nslookup` | DNS health check (опционально) |
---

View file

@ -176,115 +176,3 @@ files.oaiusercontent.com
notebooklm.google
notebooklm.google.com
maps-cdn.zepp.com
tunnelbroker.net
ingage.tech
ex.ingage.tech
asternos.dev
asternos.com
synthetic.new
www.nokia3310lteproject.com
lowendbox.com
ghcr.io
github.com
www.iplocate.io
image.tmdb.org
tmdb.org
netlify.com
netlifydns.com
netlifyusercontent.com
www.ubuntu-touch.io
ubports.com
4pda.to
www.patreon.com
registry-1.docker.io
docker.io
downloads.claude.ai
claude.com
30bf3790.obrut.show
amedia.online
anime1.best
animego.me
animevost.org
api.anilibria.app
api.anilibria.tv
api.apbugall.org
api.bhcesh.me
api.cdnlibs.org
api.filmix.tv
api.g1.plvideo.ru
api.lumex.space
api.luxembd.ws
api.moonanime.art
api.rgshows.me
api.rstprgapipt.com
api.srvkp.com
api.vokino.org
apivb.com
base.ashdi.vip
cdnmovies-stream.online
chaturbate.com
coldcdn.xyz
ee.bongacams.com
embed.su
eneyida.tv
filmixapp.cyou
getstv.com
go.zet-flix.online
hdrezka.me
iframe.video
in.tizam.info
iptv.online
kinobase.org
kinogo.luxury
kinogo.media
kinoukr.com
kinovibe.vip
kodikapi.com
m.hqporner.com
megaoblako.com
moviesapi.club
myfanserial.net
plapi.cdnvideohub.com
player.autoembed.cc
player.smashystream.com
player.videasy.net
portal.lumex.host
redheadsound.studio
rt.pornhub.com
rt.pornhubpremium.com
ru.spankbang.com
ru.xhamster.com
rus.runetki5.com
vibix.org
videoseed.tv
vidfast.pro
vidlink.pro
vidora.su
vidsrc.cc
www.ebalovo.pro
www.eporner.com
www.porntrex.com
www.xnxx.com
www.xvideos.com
www.xvideos.red
gemini.google.com
nakarte.me
a-api.anthropic.com
youtube.com
youtu.be
youtube-nocookie.com
ytimg.com
ggpht.com
googlevideo.com
gvt1.com
gvt2.com
gvt3.com
googleusercontent.com
gstatic.com
jnn-pa.googleapis.com
play.googleapis.com
youtubei.googleapis.com
googleapis.com
api.bambulab.com
bambulab.com
e.bambulab.com

View file

@ -4,64 +4,46 @@ set -euo pipefail
# ==============================
# Конфигурация через переменные окружения
# ==============================
# Сгенерированные конфиги (из generate-configs.sh)
NFTSET_CONF="${NFTSET_CONF:-/tmp/90-nftset.conf}"
TARGET_DIR="${TARGET_DIR:-/opt/appdata/pihole/etc/dnsmasq.d}"
IPSET_CONF="${IPSET_CONF:-/tmp/91-ipset-bbrkn.conf}"
RESOLVE_CONF="${RESOLVE_CONF:-/tmp/92-resolve-bbrkn.conf}"
# Два таргета — два инстанса dnsmasq:
# NFTSET_CONF → host dnsmasq (:5350), грузит /etc/dnsmasq.d, наполняет nft-сеты
# RESOLVE_CONF → pihole (docker), делегирует bbrkn-домены инстансу :5350
NFTSET_TARGET_DIR="${NFTSET_TARGET_DIR:-/etc/dnsmasq.d}"
RESOLVE_TARGET_DIR="${RESOLVE_TARGET_DIR:-${TARGET_DIR:-/opt/appdata/pihole/etc/dnsmasq.d}}"
DOCKER_CONTAINER="${DOCKER_CONTAINER:-pihole}"
# systemd-сервис host-инстанса dnsmasq (:5350)
HOST_DNSMASQ_SVC="${HOST_DNSMASQ_SVC:-dnsmasq}"
# nft-сеты для сброса после деплоя (иначе висят до timeout 24h).
# Формат: "family table set" — перечисляются через ';' в NFT_SETS.
NFT_SETS="${NFT_SETS:-inet filter bbrkn_v4;inet filter bbrkn_v6}"
# --- Health check ---
DNS_LISTEN_ADDR="${DNS_LISTEN_ADDR:-127.0.0.1}" # pihole :53 (клиентский)
# Адрес, на котором слушает Pi-hole DNS (для health check)
DNS_LISTEN_ADDR="${DNS_LISTEN_ADDR:-127.0.0.1}"
# Домен для проверки DNS после рестарта
DNS_CHECK_DOMAIN="${DNS_CHECK_DOMAIN:-google.com}"
# Домен, который ЕСТЬ в bbrkn-сете — для end-to-end проверки nftset-захвата
NFTSET_CHECK_DOMAIN="${NFTSET_CHECK_DOMAIN:-chatgpt.com}"
RESOLVER_ADDR="${RESOLVER_ADDR:-127.0.0.1}" # host dnsmasq (:5350)
RESOLVER_PORT="${RESOLVER_PORT:-5350}"
NFT_CHECK_SET="${NFT_CHECK_SET:-inet filter bbrkn_v4}"
# Имена/пути назначения (параметрическая подстановка, без basename)
nftset_name="${NFTSET_CONF##*/}"
# Имена назначения (без basename — используем параметрическую подстановку)
ipset_name="${IPSET_CONF##*/}"
resolve_name="${RESOLVE_CONF##*/}"
nftset_dest="$NFTSET_TARGET_DIR/$nftset_name"
resolve_dest="$RESOLVE_TARGET_DIR/$resolve_name"
ipset_dest="$TARGET_DIR/$ipset_name"
resolve_dest="$TARGET_DIR/$resolve_name"
# Пути к бэкапам (для rollback)
NFTSET_BACKUP=""
# Пути к бэкапам (заполняются ниже, нужны для rollback)
IPSET_BACKUP=""
RESOLVE_BACKUP=""
echo "Deploying nftset config → $nftset_dest (host dnsmasq :$RESOLVER_PORT)"
echo "Deploying resolve config → $resolve_dest ($DOCKER_CONTAINER)"
echo "Deploying configuration files to $TARGET_DIR"
# ==============================
# Проверка входных файлов
# ==============================
if [ ! -f "$NFTSET_CONF" ] || [ ! -f "$RESOLVE_CONF" ]; then
echo "Error: one or both generated config files not found!"
echo " NFTSET_CONF=$NFTSET_CONF RESOLVE_CONF=$RESOLVE_CONF"
if [ ! -f "$IPSET_CONF" ] || [ ! -f "$RESOLVE_CONF" ]; then
echo "Error: one or both config files not found!"
exit 1
fi
# ==============================
# Функция отката: восстанавливает бэкапы и перезапускает оба инстанса
# Функция отката: восстанавливает бэкапы и перезапускает контейнер
# ==============================
rollback() {
echo "[ROLLBACK] Restoring previous configuration..."
local restored=false
if [ -n "$NFTSET_BACKUP" ] && [ -f "$NFTSET_BACKUP" ]; then
cp "$NFTSET_BACKUP" "$nftset_dest"
echo "[ROLLBACK] Restored $nftset_dest"
if [ -n "$IPSET_BACKUP" ] && [ -f "$IPSET_BACKUP" ]; then
cp "$IPSET_BACKUP" "$ipset_dest"
echo "[ROLLBACK] Restored $ipset_dest"
restored=true
fi
if [ -n "$RESOLVE_BACKUP" ] && [ -f "$RESOLVE_BACKUP" ]; then
@ -70,8 +52,7 @@ rollback() {
restored=true
fi
if $restored; then
echo "[ROLLBACK] Restarting host dnsmasq ($HOST_DNSMASQ_SVC) and $DOCKER_CONTAINER..."
systemctl restart "$HOST_DNSMASQ_SVC" 2>/dev/null || true
echo "[ROLLBACK] Restarting $DOCKER_CONTAINER with previous config..."
docker restart "$DOCKER_CONTAINER" 2>/dev/null || true
else
echo "[ROLLBACK] No backups found, cannot restore"
@ -83,10 +64,10 @@ rollback() {
# Бэкап существующих файлов
# ==============================
ts=$(date +%Y%m%d-%H%M%S)
if [ -f "$nftset_dest" ]; then
NFTSET_BACKUP="${nftset_dest}.backup.${ts}"
cp "$nftset_dest" "$NFTSET_BACKUP"
echo "Backed up $nftset_dest$NFTSET_BACKUP"
if [ -f "$ipset_dest" ]; then
IPSET_BACKUP="${ipset_dest}.backup.${ts}"
cp "$ipset_dest" "$IPSET_BACKUP"
echo "Backed up $ipset_dest$IPSET_BACKUP"
fi
if [ -f "$resolve_dest" ]; then
RESOLVE_BACKUP="${resolve_dest}.backup.${ts}"
@ -97,38 +78,16 @@ fi
# ==============================
# Копирование новых файлов
# ==============================
cp "$NFTSET_CONF" "$nftset_dest"
cp "$RESOLVE_CONF" "$resolve_dest"
cp "$IPSET_CONF" "$TARGET_DIR/"
cp "$RESOLVE_CONF" "$TARGET_DIR/"
# ==============================
# Перезапуск обоих инстансов.
# ВАЖНО: dnsmasq по SIGHUP НЕ перечитывает директивы конфига (nftset=/server=),
# только hosts-файлы — новые домены подхватываются лишь полным рестартом.
# Перезапуск контейнера
# ==============================
echo "Restarting host dnsmasq ($HOST_DNSMASQ_SVC)..."
systemctl restart "$HOST_DNSMASQ_SVC"
echo "Restarting $DOCKER_CONTAINER container..."
docker restart "$DOCKER_CONTAINER"
# --- Ждём host dnsmasq (:5350) ---
echo "Waiting for host dnsmasq on :$RESOLVER_PORT (up to 15s)..."
host_ready=false
for i in $(seq 1 15); do
if ss -uln 2>/dev/null | grep -q ":$RESOLVER_PORT "; then
host_ready=true
break
fi
sleep 1
done
if ! $host_ready; then
echo "Error: host dnsmasq not listening on :$RESOLVER_PORT after 15s"
rollback
exit 1
fi
echo "host dnsmasq is listening on :$RESOLVER_PORT"
# --- Ждём pihole ---
# Ждём запуска — polling через docker inspect вместо sleep + docker ps
echo "Waiting for $DOCKER_CONTAINER to be ready (up to 30s)..."
ready=false
for i in $(seq 1 30); do
@ -139,79 +98,52 @@ for i in $(seq 1 30); do
fi
sleep 1
done
if ! $ready; then
echo "Error: $DOCKER_CONTAINER failed to start after 30s (status: ${status:-unknown})"
echo "Error: $DOCKER_CONTAINER failed to start after 30s (status: $status)"
rollback
exit 1
fi
echo "$DOCKER_CONTAINER is running"
# ==============================
# Сброс nft-сетов (чистим устаревшие записи удалённых доменов; timeout 24h иначе)
# DNS health check
# ==============================
echo "Flushing nft sets..."
IFS=';' read -ra _sets <<< "$NFT_SETS"
for s in "${_sets[@]}"; do
[ -z "$s" ] && continue
# shellcheck disable=SC2086
if nft flush set $s 2>/dev/null; then
echo " flushed set $s"
else
echo " WARN: could not flush set $s"
fi
done
# ==============================
# Health check 1: базовый DNS через pihole :53
# ==============================
dns_query() { # <addr> <port> <domain>
local addr="$1" port="$2" dom="$3"
dns_health_check() {
if command -v dig >/dev/null 2>&1; then
dig +short +time=3 +tries=2 -p "$port" "@$addr" "$dom" 2>/dev/null
dig +short +time=3 +tries=2 "@$DNS_LISTEN_ADDR" "$DNS_CHECK_DOMAIN" >/dev/null 2>&1
elif command -v nslookup >/dev/null 2>&1; then
nslookup -port="$port" "$dom" "$addr" 2>/dev/null | awk '/^Address: /{print $2}'
nslookup "$DNS_CHECK_DOMAIN" "$DNS_LISTEN_ADDR" >/dev/null 2>&1
else
echo "Warning: neither 'dig' nor 'nslookup' found, skipping DNS health check"
return 0
fi
}
if ! command -v dig >/dev/null 2>&1 && ! command -v nslookup >/dev/null 2>&1; then
echo "Warning: neither 'dig' nor 'nslookup' found, skipping DNS health checks"
else
echo "DNS health check: $DNS_CHECK_DOMAIN via $DNS_LISTEN_ADDR:53 (up to 15 attempts)..."
echo "Running DNS health check ($DNS_CHECK_DOMAIN via $DNS_LISTEN_ADDR, up to 15 attempts)..."
dns_ok=false
for i in $(seq 1 15); do
if [ -n "$(dns_query "$DNS_LISTEN_ADDR" 53 "$DNS_CHECK_DOMAIN")" ]; then
if dns_health_check; then
dns_ok=true
echo " passed (attempt $i)"
echo "DNS health check passed (attempt $i)"
break
fi
echo " attempt $i failed, retry in 1s..."
echo " attempt $i failed, retrying in 1s..."
sleep 1
done
if ! $dns_ok; then
echo "Error: DNS health check failed — pihole not resolving on $DNS_LISTEN_ADDR:53"
echo "Error: DNS health check failed after 15 attempts — Pi-hole not responding on $DNS_LISTEN_ADDR"
rollback
exit 1
fi
# ==============================
# Health check 2: end-to-end nftset-захват.
# Резолвим bbrkn-домен через :5350 и убеждаемся, что nft-сет наполнился.
# Очистка IPSET
# ==============================
echo "nftset capture check: $NFTSET_CHECK_DOMAIN via $RESOLVER_ADDR:$RESOLVER_PORT..."
dns_query "$RESOLVER_ADDR" "$RESOLVER_PORT" "$NFTSET_CHECK_DOMAIN" >/dev/null
sleep 1
# shellcheck disable=SC2086
set_count=$(nft list set $NFT_CHECK_SET 2>/dev/null | grep -cE '([0-9]{1,3}\.){3}[0-9]{1,3}' || echo 0)
if [ "${set_count:-0}" -gt 0 ]; then
echo " passed — set '$NFT_CHECK_SET' populated ($set_count element line(s))"
else
echo "Error: nftset '$NFT_CHECK_SET' still empty after resolving $NFTSET_CHECK_DOMAIN"
echo " host dnsmasq likely not loading $nftset_dest"
rollback
exit 1
fi
fi
echo "Flushing ipset bbrkn..."
ipset flush bbrkn
echo "Deployment completed successfully"
DOMAIN_COUNT=$(grep -c '^nftset=' "$nftset_dest" || echo "0")
echo "Applied nftset configuration for $DOMAIN_COUNT domain directives"
DOMAIN_COUNT=$(grep -c '^ipset=' "$ipset_dest" || echo "0")
echo "Applied configuration for $DOMAIN_COUNT domains"

View file

@ -6,17 +6,11 @@ set -euo pipefail
# ==============================
INPUT_FILE="${DOMAINS_FILE:-domains.txt}"
NFTSET_CONF="${NFTSET_CONF:-/tmp/90-nftset.conf}"
IPSET_CONF="${IPSET_CONF:-/tmp/91-ipset-bbrkn.conf}"
RESOLVE_CONF="${RESOLVE_CONF:-/tmp/92-resolve-bbrkn.conf}"
# nftset-цель для dnsmasq: <4|6>#<family>#<table>#<set>[,...]
# 4# → A-записи в bbrkn_v4; 6# → AAAA в bbrkn_v6 (таблица: inet filter)
NFTSET_SPEC="${NFTSET_SPEC:-4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6}"
API_URL="${CHROME_SERVER:-http://127.0.0.1:3000}/domains?domain="
# Куда pihole делегирует резолвинг bbrkn-доменов: инстанс dnsmasq :5350, который
# резолвит через VPN и наполняет nft-сеты. Формат dnsmasq server=: ip[#port].
DNS_SERVER="${DNS_SERVER:-127.0.0.1#5350}"
DNS_SERVER="${DNS_SERVER:-8.8.8.8}"
# Игнорируемые части доменов (разделитель — пробел)
IGNORE_PARTS="${IGNORE_PARTS:-}"
@ -49,7 +43,7 @@ if ! command -v jq >/dev/null 2>&1; then err "jq is required"; exit 2; fi
if [ "$DEBUG" != "0" ]; then : > "$DEBUG_LOG"; dbg "Debugging enabled"; fi
log "Starting generate-configs.sh"
dbg "ENV: INPUT_FILE=$INPUT_FILE NFTSET_CONF=$NFTSET_CONF NFTSET_SPEC=$NFTSET_SPEC RESOLVE_CONF=$RESOLVE_CONF API_URL=$API_URL DNS_SERVER=$DNS_SERVER IGNORE_PARTS='$IGNORE_PARTS' DRY_RUN=$DRY_RUN CACHE_DIR=$CACHE_DIR CACHE_TTL_DAYS=$CACHE_TTL_DAYS STRICT_MODE=$STRICT_MODE"
dbg "ENV: INPUT_FILE=$INPUT_FILE IPSET_CONF=$IPSET_CONF RESOLVE_CONF=$RESOLVE_CONF API_URL=$API_URL DNS_SERVER=$DNS_SERVER IGNORE_PARTS='$IGNORE_PARTS' DRY_RUN=$DRY_RUN CACHE_DIR=$CACHE_DIR CACHE_TTL_DAYS=$CACHE_TTL_DAYS STRICT_MODE=$STRICT_MODE"
if [ ! -f "$INPUT_FILE" ]; then
err "Input file not found: $INPUT_FILE"
@ -319,9 +313,9 @@ if ! $DRY_RUN; then
{
for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}"
printf 'nftset=/%s/%s\n' "$d" "$NFTSET_SPEC"
printf 'ipset=/%s/bbrkn\n' "$d"
done
} > "$NFTSET_CONF"
} > "$IPSET_CONF"
{
for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}"

View file

@ -1,128 +0,0 @@
#!/bin/bash
set -uo pipefail
# ==============================
# Прогрев nft-сетов (запускать НА ШЛЮЗЕ)
# ==============================
# Заново резолвит все домены, которые dnsmasq помечает директивами nftset=,
# обращаясь напрямую к host-инстансу 127.0.0.1#5350. Прямой запрос обходит
# кэш pihole FTL: запрос, попавший в кэш FTL, до :5350 не доходит и элементы
# в inet filter bbrkn_v4/bbrkn_v6 не обновляет — они молча истекают по timeout
# и домен уходит мимо туннеля.
#
# Источник списка доменов — сам задеплоенный конфиг, поэтому отдельный список
# синхронизировать не нужно: скрипт всегда работает с тем, что реально
# загружен в dnsmasq после последнего успешного деплоя.
# --- Конфигурация через переменные окружения ---
# Задеплоенный конфиг с директивами nftset= (источник списка доменов)
NFTSET_CONF="${NFTSET_CONF:-/etc/dnsmasq.d/90-nftset.conf}"
# Host-инстанс dnsmasq, который применяет nftset=
RESOLVER="${RESOLVER:-127.0.0.1}"
RESOLVER_PORT="${RESOLVER_PORT:-5350}"
# Состояние и лог
STATE_DIR="${STATE_DIR:-${HOME}/.local/state/bbrkn}"
LOG="${LOG:-${STATE_DIR}/warm.log}"
LOCK="${LOCK:-${STATE_DIR}/warm.lock}"
LOG_MAX_BYTES="${LOG_MAX_BYTES:-4194304}" # ротация лога, 4 МиБ
# Параметры опроса
PARALLEL="${PARALLEL:-8}" # одновременных запросов к :5350
DIG_TIMEOUT="${DIG_TIMEOUT:-3}" # секунд на попытку
DIG_TRIES="${DIG_TRIES:-2}"
# Пинг выключен по умолчанию: пакеты, рождённые на самом шлюзе, идут через
# OUTPUT и не проходят цепочку prerouting_mangle — уходят в обычный WAN без
# метки и почти все отваливаются по таймауту. Включать только для отладки.
WARM_PING="${WARM_PING:-0}"
PING_TIMEOUT="${PING_TIMEOUT:-2}"
NFT="${NFT:-/usr/sbin/nft}"
mkdir -p "$STATE_DIR"
# Один инстанс: медленный прогон не должен наложиться на следующий по cron.
exec 9>"$LOCK"
if ! flock -n 9; then
echo "$(date -Is) SKIP предыдущий запуск ещё выполняется" >>"$LOG"
exit 0
fi
log() { echo "$(date -Is) $*" >>"$LOG"; }
# Количество элементов в сете. У каждого элемента есть timeout, поэтому
# "expires" встречается ровно один раз на элемент. Ошибка здесь не фатальна —
# это только метрика прогресса.
set_count() {
sudo -n "$NFT" list set inet filter "$1" 2>/dev/null | grep -c 'expires' || echo "?"
}
if [ ! -r "$NFTSET_CONF" ]; then
log "FATAL не читается $NFTSET_CONF"
echo "Ошибка: не читается $NFTSET_CONF (скрипт запускается на шлюзе)" >&2
exit 1
fi
mapfile -t HOSTS < <(sed -n 's|^nftset=/\([^/]*\)/.*|\1|p' "$NFTSET_CONF" | sort -u)
if [ "${#HOSTS[@]}" -eq 0 ]; then
log "FATAL в $NFTSET_CONF не найдено ни одной директивы nftset="
echo "Ошибка: в $NFTSET_CONF не найдено ни одной директивы nftset=" >&2
exit 1
fi
BEFORE_V4=$(set_count bbrkn_v4)
BEFORE_V6=$(set_count bbrkn_v6)
log "START domains=${#HOSTS[@]} conf=$NFTSET_CONF v4=$BEFORE_V4 v6=$BEFORE_V6"
warm_one() {
local host="$1" a aaaa
a=$(dig +short +timeout="$DIG_TIMEOUT" +tries="$DIG_TRIES" \
@"$RESOLVER" -p "$RESOLVER_PORT" A "$host" 2>/dev/null \
| grep -E '^[0-9]+\.' | head -1)
aaaa=$(dig +short +timeout="$DIG_TIMEOUT" +tries="$DIG_TRIES" \
@"$RESOLVER" -p "$RESOLVER_PORT" AAAA "$host" 2>/dev/null \
| grep -E '^[0-9a-fA-F]*:' | head -1)
if [ -z "$a" ] && [ -z "$aaaa" ]; then
echo "dnsfail $host"
return
fi
# Пинг только подтверждает проходимость маршрута; в сет IP попал уже на
# шаге резолва выше.
if [ "$WARM_PING" = "1" ] && [ -n "$a" ]; then
if ping -n -q -c1 -W "$PING_TIMEOUT" "$a" >/dev/null 2>&1; then
echo "ok $host $a"
else
echo "noping $host $a"
fi
else
echo "ok $host ${a:-$aaaa}"
fi
}
export -f warm_one
export RESOLVER RESOLVER_PORT DIG_TIMEOUT DIG_TRIES WARM_PING PING_TIMEOUT
RESULTS="$(printf '%s\n' "${HOSTS[@]}" \
| xargs -r -P "$PARALLEL" -I{} bash -c 'warm_one "$@"' _ {})"
OK=$(grep -c '^ok ' <<<"$RESULTS" || true)
NOPING=$(grep -c '^noping ' <<<"$RESULTS" || true)
DNSFAIL=$(grep -c '^dnsfail ' <<<"$RESULTS" || true)
AFTER_V4=$(set_count bbrkn_v4)
AFTER_V6=$(set_count bbrkn_v6)
log "DONE ok=$OK noping=$NOPING dnsfail=$DNSFAIL v4=${BEFORE_V4}->${AFTER_V4} v6=${BEFORE_V6}->${AFTER_V6}"
if [ "$DNSFAIL" -gt 0 ]; then
log "dnsfail: $(grep '^dnsfail ' <<<"$RESULTS" | cut -d' ' -f2 | tr '\n' ' ')"
fi
echo "✔ Прогрето доменов: $OK, не резолвится: $DNSFAIL; bbrkn_v4 ${BEFORE_V4}->${AFTER_V4}, bbrkn_v6 ${BEFORE_V6}->${AFTER_V6}"
# Ротация лога
if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG")" -gt "$LOG_MAX_BYTES" ]; then
mv -f "$LOG" "${LOG}.1"
fi
exit 0