bbrkn/README.md
goodvin 8598a1a48f
All checks were successful
Deploy DNS Configuration / deploy (push) Successful in 1h15m4s
warm: self-heal wedged dnsmasq, run after deploy, shared state dir
dnsmasq adds IPs to an nft set only on a fresh upstream reply; a cache
hit never refreshes the set. On Archie the :5350 instance also wedged:
TCP child processes first, then the main process, logged
'nftset ... netlink: Error: cache initialization failed: Protocol error'
on every add and stopped filling bbrkn_v4/v6 until restarted.

warm-nftset.sh now counts those errors for the main PID during the run
(journalctl _PID=) and, if any, restarts the service and warms again;
exit 1 if it still fails. The state dir defaults to /var/lib/bbrkn so the
root cron job and the deploy share one lock (LOCK_WAIT lets deploy wait).

deploy-to-gateway.sh runs the warmer right after flushing the sets,
since clients keep old IPs cached and would bypass the tunnel until the
next fresh resolve. A warm failure only warns; it does not roll back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DR7zpSsdX28T4Kn6v4jPSS
2026-10-13 21:26:49 +03:00

325 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# bbrkn — DNS Bypass Config Generator
Автоматизирует генерацию конфигураций **Pi-hole / dnsmasq** для маршрутизации трафика заблокированных доменов через VPN-туннель (WireGuard) с помощью **nftables-сетов**.
## Как это работает
```mermaid
flowchart TD
A[domains.txt] --> B[generate-configs.sh]
B -->|кэш .cache/api| B
B -->|MISS / EXPIRED| C[gekata: Chromium headless API]
C --> B
B --> D[90-nftset.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
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]
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` на шлюзе.
---
## Классификация доменов
Для каждого домена из `domains.txt` скрипт обращается к Chromium headless API и классифицирует результат:
| Результат API | Действие |
|---|---|
| JSON с `relatedDomains` | **Сайт** — добавляется домен + все связанные субдомены |
| JSON с `error: ERR_NAME_NOT_RESOLVED` или `Timeout` | **Мёртвый домен** — пропускается, в конфиг не попадает |
| JSON с любой другой ошибкой (`ERR_CERT_*`, `ERR_CONNECTION_REFUSED` и т.д.) | **Сервис** — добавляется только сам домен без субдоменов (не HTTP-сервис: SMTP, VPN, игровой сервер и т.п.) |
| API недоступен (не 200 / не JSON после всех попыток) | **Fallback** — домен всё равно добавляется без субдоменов; фиксируется в разделе отчёта `API FAILURES` |
> Любой домен из `domains.txt` гарантированно попадает в финальный конфиг, даже если API недоступен.
### Формат выходных файлов
Домены группируются по базовому домену с комментарием:
`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
...
# 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
...
```
---
## Структура проекта
```
bbrkn/
├── domains.txt # список доменов для обхода
├── Makefile # управление сборкой и деплоем
├── scripts/
│ ├── generate-configs.sh # генерация dnsmasq-конфигов
│ ├── deploy-to-gateway.sh # деплой в Pi-hole + rollback
│ └── warm-nftset.sh # прогрев nft-сетов по cron (на шлюзе)
└── .forgejo/workflows/deploy.yaml # CI/CD на self-hosted runner
```
---
## Makefile
```bash
make all # generate + deploy (полный цикл)
make generate # только сгенерировать конфиги
make deploy # только задеплоить в Pi-hole
make clean # удалить сгенерированные конфиги из /tmp
make cache-clean # очистить кэш API-ответов (.cache/api/)
make check # проверить синтаксис domains.txt
make warm # прогреть nft-сеты (запускать на шлюзе)
```
---
## Переменные окружения
### generate-configs.sh
| Переменная | По умолчанию | Описание |
|---|---|---|
| `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) |
| `IGNORE_PARTS` | _(пусто)_ | Подстроки через пробел — домены с совпадением игнорируются |
| `CACHE_DIR` | `.cache/api` | Директория кэша API-ответов |
| `CACHE_TTL_DAYS` | `15` | Время жизни кэша в днях; `0` — отключить кэш |
| `STRICT_MODE` | `0` | `1` — завершать с кодом 1 при любых ошибках API |
| `DEBUG` | `0` | `1` — включить подробный лог |
| `DEBUG_LOG` | `/tmp/generate-configs.debug.log` | Путь к файлу отладочного лога |
### deploy-to-gateway.sh
| Переменная | По умолчанию | Описание |
|---|---|---|
| `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`) |
| `DOCKER_CONTAINER` | `pihole` | Имя Docker-контейнера Pi-hole |
| `HOST_DNSMASQ_SVC` | `dnsmasq` | systemd-сервис host-инстанса dnsmasq |
| `BACKUP_DIR` | `/var/backups/bbrkn` | Куда класть бэкапы конфигов перед деплоем (НЕ в `dnsmasq.d` — оттуда грузится всё) |
| `BACKUP_KEEP` | `10` | Сколько последних бэкапов хранить на каждый файл |
| `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-инстанса для проверки захвата |
---
## Кэш API-ответов
При каждом запуске ответы Chromium API сохраняются в `$CACHE_DIR/<domain>.json`. При повторном запуске:
- Если файл кэша **свежее** `CACHE_TTL_DAYS` дней → ответ берётся из кэша, Chromium не запускается.
- Если файл **старше** TTL → перезапрашивается и кэш обновляется.
- Если кэш **повреждён** (невалидный JSON) → перезапрашивается автоматически.
> **Внимание:** forgejo-runner на шлюзе **эфемерный** — workspace (и `.cache/api`) между запусками не сохраняется. Поэтому первый CI-прогон после сброса кэша выполняет **холодный обход всех доменов через gekata** (~30-70 с на домен, последовательно) и работает долго. Для быстрых повторных прогонов держите тёплый `.cache/api` в постоянной директории (`CACHE_DIR`).
```bash
# Сбросить кэш и запросить всё заново
make cache-clean && make generate
# Отключить кэш для одного запуска
CACHE_TTL_DAYS=0 make generate
```
---
## Деплой и автооткат
`deploy-to-gateway.sh` деплоит в **два инстанса dnsmasq** и выполняет шаги:
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. При ошибке на шагах 5–7 — **автоматически откатывается**: восстанавливает оба бэкапа и перезапускает оба инстанса со старой конфигурацией.
9. После успешного деплоя запускает `warm-nftset.sh` — сеты только что очищены, а клиенты держат старые IP в кэше. Ошибка прогрева деплой не откатывает (только `WARN`).
---
## Прогрев 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` должен быть выключен кэш** (`cache-size=0` в `/etc/dnsmasq.conf` на шлюзе). dnsmasq добавляет IP в сет **только при свежем ответе upstream** — ответ из собственного кэша сет не обновляет. Кэширует pihole перед ним.
**Самовосстановление.** dnsmasq иногда «клинит» на записи в сеты: основной процесс на каждую попытку пишет в журнал `nftset inet filter bbrkn_v4 netlink: Error: cache initialization failed: Protocol error` и больше ничего в сеты не добавляет до рестарта (наблюдалось под нагрузкой; начинается с дочерних TCP-процессов). Скрипт после прогона считает такие ошибки у основного процесса (`journalctl _PID=…`); если они есть — `systemctl restart $HOST_DNSMASQ_SVC` и повторный прогрев. Если не помогло — `exit 1` и `FAIL` в логе.
> **Запускать только на шлюзе.** Инстанс `:5350` слушает `listen-address=127.0.0.1` и с других хостов LAN недоступен. Список доменов берётся из задеплоенного конфига, так что скрипт всегда работает с актуальным после последнего деплоя списком.
```bash
# разово, вручную
make warm
./scripts/warm-nftset.sh
# ежечасно по cron — так стоит на Archie:
install -m 0755 scripts/warm-nftset.sh /usr/local/sbin/bbrkn-warm
cat /etc/cron.d/bbrkn-warm
17 * * * * root /usr/local/sbin/bbrkn-warm >/dev/null 2>&1
```
Копия в `/usr/local/sbin` **не обновляется деплоем** — после правок скрипта переустановить вручную. Деплой (CI) запускает скрипт из репо, `STATE_DIR` у них общий, поэтому lock тоже общий.
### Переменные окружения
| Переменная | По умолчанию | Описание |
|---|---|---|
| `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` | `/var/lib/bbrkn` | Лог `warm.log` и lock-файл (общие для cron и деплоя) |
| `LOG_MAX_BYTES` | `4194304` | Порог ротации лога в `warm.log.1` |
| `LOCK_WAIT` | `0` | Секунд ждать идущий прогон (`0` — сразу выйти с `SKIP`; деплой ждёт `WARM_LOCK_WAIT=300`) |
| `HOST_DNSMASQ_SVC` | `dnsmasq` | systemd-сервис `:5350` — для детектора заклинивания и рестарта |
| `SELF_HEAL` | `1` | `0` — только сообщать о заклинивании, не перезапускать dnsmasq |
Параллельные запуски блокируются через `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 nftset_errors=0 healed=0 v4=526->2203 v6=305->1440
2026-08-05T20:48:44+03:00 dnsfail: azureedge.net cdn-telegram.org kinozal.tv ...
```
`v4=X->Y` — число элементов в сете до и после прогона (считается через `nft list set`; не под root — через `sudo -n`, без прав будет `0`, на сам прогрев это не влияет). `nftset_errors` — ошибки записи в сет у основного процесса dnsmasq, `healed=1` — был рестарт. Элементов больше, чем доменов — у CDN на один домен приходится несколько адресов. Домены в `dnsfail` не резолвятся вообще (мёртвые записи, NXDOMAIN) — кандидаты на удаление из `domains.txt`.
> `WARM_PING=1` на шлюзе почти всегда даёт `noping`: пакеты, рождённые на самом шлюзе, идут через `OUTPUT` и не проходят цепочку `prerouting_mangle`, то есть уходят в WAN без метки. Это диагностический режим, не показатель проблемы.
Полный прогон 1059 доменов при `PARALLEL=8` занимает ~1 мин 45 с.
---
## Запуск вручную
```bash
# Полный цикл
make all
# Только генерация (без деплоя)
make generate
# Dry-run — проверить что будет сгенерировано, ничего не записывать
./scripts/generate-configs.sh --dry-run
# Отладочный режим
DEBUG=1 make generate
# Строгий режим — завершить с ошибкой если часть доменов не опросилась
STRICT_MODE=1 make generate
```
---
## CI/CD
Workflow запускается при push в `main`, а также **вручную из UI** (`workflow_dispatch`).
```
push → main
└─ make clean
└─ make all
├─ generate-configs.sh (генерация с кэшем)
└─ deploy-to-gateway.sh (деплой + rollback)
└─ upload artifacts (конфиги + debug-лог, даже при падении)
```
Self-hosted runner работает непосредственно на шлюзе, поэтому деплой происходит локально без SSH.
---
## Пример отчёта
```
===== DEBUG REPORT =====
Input file: domains.txt
Raw input lines: 170
Processed lines: 170
Normalized OK: 165
Normalized skipped: 5
Ignored (IGNORE_PARTS): 12
Cache hits: 148 (TTL: 15d, dir: .cache/api)
Cache expired/refetched: 3
API success (sites): 11
API error/failed: 3
Related domains added: 87
Final unique domains: 256
---- VALID BASE SITES ----
example.com
another-site.org
...
---- API FAILURES (added without subdomains) ----
some-domain.com → api_failed_added_without_subdomains
...
===== END DEBUG REPORT =====
```
---
## Зависимости
| Инструмент | Где используется |
|---|---|
| `bash` 4.2+ | оба скрипта |
| `curl` | запросы к gekata API |
| `jq` | парсинг JSON-ответов |
| `docker` | перезапуск Pi-hole |
| `nft` | flush nft-сетов + проверка захвата после деплоя |
| `systemctl` | рестарт host-инстанса dnsmasq |
| `dig` или `nslookup` | DNS health check (опционально) |
---
## Лицензия
MIT