Compare commits

..

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

8 changed files with 121 additions and 680 deletions

View file

@ -15,28 +15,18 @@ jobs:
DOMAINS_FILE: domains.txt DOMAINS_FILE: domains.txt
# Временные конфиги (куда пишутся generate-configs.sh) # Временные конфиги (куда пишутся generate-configs.sh)
NFTSET_CONF: /tmp/90-nftset.conf IPSET_CONF: /tmp/91-ipset-bbrkn.conf
RESOLVE_CONF: /tmp/92-resolve-bbrkn.conf RESOLVE_CONF: /tmp/92-resolve-bbrkn.conf
# Резолвер, которому pihole делегирует bbrkn-домены (host dnsmasq :5350). # DNS-сервер для резолвинга
# Кавычки обязательны — '#' иначе может трактоваться как комментарий. DNS_SERVER: 10.100.1.2
DNS_SERVER: "127.0.0.1#5350"
# 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 TARGET_DIR: /opt/appdata/pihole/etc/dnsmasq.d
# Имя контейнера Pi-hole # Имя контейнера Pi-hole
DOCKER_CONTAINER: pihole 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" 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 между запусками) # Кэш API-ответов (self-hosted runner сохраняет workspace между запусками)
@ -58,6 +48,6 @@ jobs:
with: with:
name: dnsmasq-configs name: dnsmasq-configs
path: | path: |
${{ env.NFTSET_CONF }} ${{ env.IPSET_CONF }}
${{ env.RESOLVE_CONF }} ${{ env.RESOLVE_CONF }}
${{ env.DEBUG_LOG }} ${{ env.DEBUG_LOG }}

View file

@ -1,54 +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). Then it runs `warm-nftset.sh` (non-fatal) to refill the just-flushed sets. Backups go to `$BACKUP_DIR` (`/var/backups/bbrkn`, newest `BACKUP_KEEP`=10) — never into a dnsmasq conf dir, which would load them as live config. **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
- **dnsmasq only adds IPs to an nft set on a fresh upstream reply**, never on a cache hit — hence `cache-size=0` on the `:5350` instance and the hourly warmer (`/etc/cron.d/bbrkn-warm` → `/usr/local/sbin/bbrkn-warm`, a manually installed copy of `scripts/warm-nftset.sh`). The `:5350` main process can also wedge (`nftset … cache initialization failed: Protocol error` on every add until restart); the warmer detects that via `journalctl _PID=` and restarts it.
- `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 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 DOMAINS_FILE ?= domains.txt
# Резолвер, которому pihole делегирует bbrkn-домены (host dnsmasq :5350). DNS_SERVER ?= 10.100.1.2
# ВНИМАНИЕ: '#' в Makefile — начало комментария, экранируем как '\#'. CACHE_DIR ?= .cache/api
DNS_SERVER ?= 127.0.0.1\#5350 CACHE_TTL_DAYS ?= 15
NFTSET_TARGET_DIR ?= /etc/dnsmasq.d
CACHE_DIR ?= .cache/api
CACHE_TTL_DAYS ?= 15
# Экспортируем переменные, чтобы они были доступны внутри shell-скриптов # Экспортируем переменные, чтобы они были доступны внутри 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 all: generate deploy
@echo "✅ Конфиги успешно сгенерированы и задеплоены" @echo "✅ Конфиги успешно сгенерированы и задеплоены"
clean: clean:
@echo "🧹 Очистка временных файлов" @echo "🧹 Очистка временных файлов"
@rm -f $(NFTSET_CONF) $(RESOLVE_CONF) @rm -f $(IPSET_CONF) $(RESOLVE_CONF)
cache-clean: cache-clean:
@echo "🗑 Очистка кэша API ($(CACHE_DIR))" @echo "🗑 Очистка кэша API ($(CACHE_DIR))"
@ -55,10 +52,3 @@ deploy:
@echo "🚀 Деплой конфигов в систему" @echo "🚀 Деплой конфигов в систему"
@chmod +x scripts/deploy-to-gateway.sh @chmod +x scripts/deploy-to-gateway.sh
@./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

154
README.md
View file

@ -1,6 +1,6 @@
# bbrkn — DNS Bypass Config Generator # bbrkn — DNS Bypass Config Generator
Автоматизирует генерацию конфигураций **Pi-hole / dnsmasq** для маршрутизации трафика заблокированных доменов через VPN-туннель (WireGuard) с помощью **nftables-сетов**. Автоматизирует генерацию конфигураций **Pi-hole / dnsmasq** для маршрутизации трафика заблокированных доменов через VPN-туннель (WireGuard).
## Как это работает ## Как это работает
@ -8,29 +8,25 @@
flowchart TD flowchart TD
A[domains.txt] --> B[generate-configs.sh] A[domains.txt] --> B[generate-configs.sh]
B -->|кэш .cache/api| B B -->|кэш .cache/api| B
B -->|MISS / EXPIRED| C[gekata: Chromium headless API] B -->|MISS / EXPIRED| C[Chromium headless API]
C --> B C --> B
B --> D[90-nftset.conf] B --> D[91-ipset-bbrkn.conf]
B --> E[92-resolve-bbrkn.conf] B --> E[92-resolve-bbrkn.conf]
D --> F[deploy-to-gateway.sh] D --> F[deploy-to-gateway.sh]
E --> F E --> F
F -->|backup + copy + systemctl restart| G[host dnsmasq :5350<br/>/etc/dnsmasq.d/] F -->|backup + copy| G[Pi-hole dnsmasq.d/]
F -->|backup + copy + docker restart| H[Pi-hole :53] F -->|docker restart| H[Pi-hole container]
F -->|health check + nftset check| H F -->|DNS health check| H
F -.->|rollback если упал| G H -->|rollback если упал| G
subgraph "Трафик клиента" subgraph "Трафик клиента"
K[Клиент] -->|DNS запрос| H K[Клиент] -->|DNS запрос| H
H -->|"server= → 127.0.0.1#5350"| G H -->|домен в ipset → mark| I[iptables]
G -->|"резолв через VPN + nftset="| N["nft set bbrkn_v4 / bbrkn_v6"] I --> J[WireGuard VPN]
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[Интернет] J --> L[Интернет]
end 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 # example.com — 4 subdomains
nftset=/example.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6 ipset=/example.com/bbrkn
nftset=/static.example.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6 ipset=/static.example.com/bbrkn
nftset=/api.example.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6 ipset=/api.example.com/bbrkn
... ...
# some-service.com — 0 subdomains # some-service.com — 0 subdomains
nftset=/some-service.com/4#inet#filter#bbrkn_v4,6#inet#filter#bbrkn_v6 ipset=/some-service.com/bbrkn
```
`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
...
``` ```
--- ---
@ -82,8 +67,7 @@ bbrkn/
├── Makefile # управление сборкой и деплоем ├── Makefile # управление сборкой и деплоем
├── scripts/ ├── scripts/
│ ├── generate-configs.sh # генерация dnsmasq-конфигов │ ├── generate-configs.sh # генерация dnsmasq-конфигов
│ ├── deploy-to-gateway.sh # деплой в Pi-hole + rollback │ └── deploy-to-gateway.sh # деплой в Pi-hole + rollback
│ └── warm-nftset.sh # прогрев nft-сетов по cron (на шлюзе)
└── .forgejo/workflows/deploy.yaml # CI/CD на self-hosted runner └── .forgejo/workflows/deploy.yaml # CI/CD на self-hosted runner
``` ```
@ -98,7 +82,6 @@ make deploy # только задеплоить в Pi-hole
make clean # удалить сгенерированные конфиги из /tmp make clean # удалить сгенерированные конфиги из /tmp
make cache-clean # очистить кэш API-ответов (.cache/api/) make cache-clean # очистить кэш API-ответов (.cache/api/)
make check # проверить синтаксис domains.txt make check # проверить синтаксис domains.txt
make warm # прогреть nft-сеты (запускать на шлюзе)
``` ```
--- ---
@ -110,11 +93,10 @@ make warm # прогреть nft-сеты (запускать на шл
| Переменная | По умолчанию | Описание | | Переменная | По умолчанию | Описание |
|---|---|---| |---|---|---|
| `DOMAINS_FILE` | `domains.txt` | Входной файл со списком доменов | | `DOMAINS_FILE` | `domains.txt` | Входной файл со списком доменов |
| `NFTSET_CONF` | `/tmp/90-nftset.conf` | Выходной файл директив `nftset=` | | `IPSET_CONF` | `/tmp/91-ipset-bbrkn.conf` | Выходной файл правил ipset |
| `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= |
| `RESOLVE_CONF` | `/tmp/92-resolve-bbrkn.conf` | Выходной файл директив `server=` | | `CHROME_SERVER` | `http://127.0.0.1:3000` | Адрес Chromium headless API |
| `CHROME_SERVER` | `http://127.0.0.1:3000` | Адрес gekata (Chromium headless API) | | `DNS_SERVER` | `8.8.8.8` | DNS-сервер для резолвинга через VPN |
| `DNS_SERVER` | `127.0.0.1#5350` | Резолвер, которому pihole делегирует домены (host dnsmasq :5350) |
| `IGNORE_PARTS` | _(пусто)_ | Подстроки через пробел — домены с совпадением игнорируются | | `IGNORE_PARTS` | _(пусто)_ | Подстроки через пробел — домены с совпадением игнорируются |
| `CACHE_DIR` | `.cache/api` | Директория кэша API-ответов | | `CACHE_DIR` | `.cache/api` | Директория кэша API-ответов |
| `CACHE_TTL_DAYS` | `15` | Время жизни кэша в днях; `0` — отключить кэш | | `CACHE_TTL_DAYS` | `15` | Время жизни кэша в днях; `0` — отключить кэш |
@ -126,19 +108,12 @@ make warm # прогреть nft-сеты (запускать на шл
| Переменная | По умолчанию | Описание | | Переменная | По умолчанию | Описание |
|---|---|---| |---|---|---|
| `NFTSET_CONF` | `/tmp/90-nftset.conf` | Сгенерированный файл `nftset=` | | `TARGET_DIR` | `/opt/appdata/pihole/etc/dnsmasq.d` | Директория dnsmasq в Pi-hole |
| `NFTSET_TARGET_DIR` | `/etc/dnsmasq.d` | Директория host-инстанса dnsmasq (:5350) | | `IPSET_CONF` | `/tmp/91-ipset-bbrkn.conf` | Сгенерированный файл ipset |
| `RESOLVE_CONF` | `/tmp/92-resolve-bbrkn.conf` | Сгенерированный файл `server=` | | `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 | | `DOCKER_CONTAINER` | `pihole` | Имя Docker-контейнера Pi-hole |
| `HOST_DNSMASQ_SVC` | `dnsmasq` | systemd-сервис host-инстанса dnsmasq | | `DNS_LISTEN_ADDR` | `127.0.0.1` | Адрес, на котором Pi-hole слушает DNS |
| `BACKUP_DIR` | `/var/backups/bbrkn` | Куда класть бэкапы конфигов перед деплоем (НЕ в `dnsmasq.d` — оттуда грузится всё) | | `DNS_CHECK_DOMAIN` | `google.com` | Домен для DNS health check после рестарта |
| `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-инстанса для проверки захвата |
--- ---
@ -150,7 +125,7 @@ make warm # прогреть nft-сеты (запускать на шл
- Если файл **старше** TTL → перезапрашивается и кэш обновляется. - Если файл **старше** TTL → перезапрашивается и кэш обновляется.
- Если кэш **повреждён** (невалидный JSON) → перезапрашивается автоматически. - Если кэш **повреждён** (невалидный JSON) → перезапрашивается автоматически.
> **Внимание:** forgejo-runner на шлюзе **эфемерный** — workspace (и `.cache/api`) между запусками не сохраняется. Поэтому первый CI-прогон после сброса кэша выполняет **холодный обход всех доменов через gekata** (~30-70 с на домен, последовательно) и работает долго. Для быстрых повторных прогонов держите тёплый `.cache/api` в постоянной директории (`CACHE_DIR`). На self-hosted runner workspace сохраняется между запусками, поэтому повторные CI-запуски значительно быстрее.
```bash ```bash
# Сбросить кэш и запросить всё заново # Сбросить кэш и запросить всё заново
@ -164,75 +139,15 @@ CACHE_TTL_DAYS=0 make generate
## Деплой и автооткат ## Деплой и автооткат
`deploy-to-gateway.sh` деплоит в **два инстанса dnsmasq** и выполняет шаги: `deploy-to-gateway.sh` выполняет следующие шаги:
1. Проверяет наличие обоих сгенерированных конфигов. 1. Проверяет наличие сгенерированных конфигов.
2. Создаёт timestamped-бэкапы `90-nftset.conf` (host) и `92-resolve-bbrkn.conf` (pihole). 2. Создаёт timestamped-бэкапы текущих конфигов в `TARGET_DIR`.
3. Копирует `90-nftset.conf` → `NFTSET_TARGET_DIR`, `92-resolve-bbrkn.conf` → `TARGET_DIR`. 3. Копирует новые конфиги в `TARGET_DIR`.
4. Перезапускает **оба** инстанса: `systemctl restart $HOST_DNSMASQ_SVC` + `docker restart $DOCKER_CONTAINER`. Полный рестарт обязателен — dnsmasq по **SIGHUP не перечитывает** директивы `nftset=`/`server=`. 4. Перезапускает контейнер Pi-hole.
5. Ждёт: host-инстанс слушает `:5350` (до 15 с) и pihole в состоянии `running` (до 30 с). 5. Ждёт запуска (до 30 секунд, polling через `docker inspect`).
6. Сбрасывает nft-сеты `bbrkn_v4`/`bbrkn_v6` (устаревшие IP иначе висят до timeout 24 ч). 6. Выполняет DNS health check через `dig` / `nslookup`.
7. Health check: базовый DNS через pihole `:53` + end-to-end проверка nftset-захвата (резолв bbrkn-домена через `:5350` → сет наполнился). 7. При ошибке на шагах 5 или 6 — **автоматически откатывается**: восстанавливает бэкапы и перезапускает контейнер со старой конфигурацией.
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 с.
--- ---
@ -311,11 +226,10 @@ some-domain.com → api_failed_added_without_subdomains
| Инструмент | Где используется | | Инструмент | Где используется |
|---|---| |---|---|
| `bash` 4.2+ | оба скрипта | | `bash` 4.2+ | оба скрипта |
| `curl` | запросы к gekata API | | `curl` | запросы к Chromium API |
| `jq` | парсинг JSON-ответов | | `jq` | парсинг JSON-ответов |
| `docker` | перезапуск Pi-hole | | `docker` | перезапуск Pi-hole |
| `nft` | flush nft-сетов + проверка захвата после деплоя | | `ipset` | сброс маршрутов после деплоя |
| `systemctl` | рестарт host-инстанса dnsmasq |
| `dig` или `nslookup` | DNS health check (опционально) | | `dig` или `nslookup` | DNS health check (опционально) |
--- ---

View file

@ -23,6 +23,7 @@ claude.ai
dell.com dell.com
download.freebsd.org download.freebsd.org
duckduckgo.com duckduckgo.com
echo.msk.ru
fast.com fast.com
flibusta.is flibusta.is
forum.xda-developers.com forum.xda-developers.com
@ -62,6 +63,15 @@ seasonvar.ru
sketchup.com sketchup.com
snap.licdn.com snap.licdn.com
sonic-net.github.io sonic-net.github.io
spoti.fi
spotifycharts.com
spotifycodes.com
spotify.com
spotify.design
spotify-everywhere.com
spotifyforbrands.com
spotifyjobs.com
spotify.link
static.cdninstagram.com static.cdninstagram.com
static.rutracker.cc static.rutracker.cc
store.akamai.steamstatic.com store.akamai.steamstatic.com
@ -166,113 +176,3 @@ files.oaiusercontent.com
notebooklm.google notebooklm.google
notebooklm.google.com notebooklm.google.com
maps-cdn.zepp.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
vibix.org
videoseed.tv
vidfast.pro
vidlink.pro
vidora.su
vidsrc.cc
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
printables.com
files.printables.com
api.bambulab.com
bambulab.com
e.bambulab.com
public-cdn.bblmw.com
makerworld.com
makerworld.bblmw.com
bblmw.com
blog.3mdeb.com
3mdeb.com
withpersona.com

View file

@ -4,69 +4,46 @@ set -euo pipefail
# ============================== # ==============================
# Конфигурация через переменные окружения # Конфигурация через переменные окружения
# ============================== # ==============================
# Сгенерированные конфиги (из generate-configs.sh) TARGET_DIR="${TARGET_DIR:-/opt/appdata/pihole/etc/dnsmasq.d}"
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}" 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}}"
# Бэкапы — ВНЕ conf-dir: dnsmasq (и pihole) грузит из dnsmasq.d все файлы,
# кроме исключённых расширений, так что *.backup.* там стал бы живым конфигом.
BACKUP_DIR="${BACKUP_DIR:-/var/backups/bbrkn}"
BACKUP_KEEP="${BACKUP_KEEP:-10}" # сколько последних копий хранить на каждый файл
DOCKER_CONTAINER="${DOCKER_CONTAINER:-pihole}" DOCKER_CONTAINER="${DOCKER_CONTAINER:-pihole}"
# systemd-сервис host-инстанса dnsmasq (:5350)
HOST_DNSMASQ_SVC="${HOST_DNSMASQ_SVC:-dnsmasq}"
# nft-сеты для сброса после деплоя (иначе висят до timeout 24h). # Адрес, на котором слушает Pi-hole DNS (для health check)
# Формат: "family table set" — перечисляются через ';' в NFT_SETS. DNS_LISTEN_ADDR="${DNS_LISTEN_ADDR:-127.0.0.1}"
NFT_SETS="${NFT_SETS:-inet filter bbrkn_v4;inet filter bbrkn_v6}" # Домен для проверки DNS после рестарта
# --- Health check ---
DNS_LISTEN_ADDR="${DNS_LISTEN_ADDR:-127.0.0.1}" # pihole :53 (клиентский)
DNS_CHECK_DOMAIN="${DNS_CHECK_DOMAIN:-google.com}" 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) # Имена назначения (без basename — используем параметрическую подстановку)
nftset_name="${NFTSET_CONF##*/}" ipset_name="${IPSET_CONF##*/}"
resolve_name="${RESOLVE_CONF##*/}" resolve_name="${RESOLVE_CONF##*/}"
nftset_dest="$NFTSET_TARGET_DIR/$nftset_name" ipset_dest="$TARGET_DIR/$ipset_name"
resolve_dest="$RESOLVE_TARGET_DIR/$resolve_name" resolve_dest="$TARGET_DIR/$resolve_name"
# Пути к бэкапам (для rollback) # Пути к бэкапам (заполняются ниже, нужны для rollback)
NFTSET_BACKUP="" IPSET_BACKUP=""
RESOLVE_BACKUP="" RESOLVE_BACKUP=""
echo "Deploying nftset config → $nftset_dest (host dnsmasq :$RESOLVER_PORT)" echo "Deploying configuration files to $TARGET_DIR"
echo "Deploying resolve config → $resolve_dest ($DOCKER_CONTAINER)"
# ============================== # ==============================
# Проверка входных файлов # Проверка входных файлов
# ============================== # ==============================
if [ ! -f "$NFTSET_CONF" ] || [ ! -f "$RESOLVE_CONF" ]; then if [ ! -f "$IPSET_CONF" ] || [ ! -f "$RESOLVE_CONF" ]; then
echo "Error: one or both generated config files not found!" echo "Error: one or both config files not found!"
echo " NFTSET_CONF=$NFTSET_CONF RESOLVE_CONF=$RESOLVE_CONF"
exit 1 exit 1
fi fi
# ============================== # ==============================
# Функция отката: восстанавливает бэкапы и перезапускает оба инстанса # Функция отката: восстанавливает бэкапы и перезапускает контейнер
# ============================== # ==============================
rollback() { rollback() {
echo "[ROLLBACK] Restoring previous configuration..." echo "[ROLLBACK] Restoring previous configuration..."
local restored=false local restored=false
if [ -n "$NFTSET_BACKUP" ] && [ -f "$NFTSET_BACKUP" ]; then if [ -n "$IPSET_BACKUP" ] && [ -f "$IPSET_BACKUP" ]; then
cp "$NFTSET_BACKUP" "$nftset_dest" cp "$IPSET_BACKUP" "$ipset_dest"
echo "[ROLLBACK] Restored $nftset_dest" echo "[ROLLBACK] Restored $ipset_dest"
restored=true restored=true
fi fi
if [ -n "$RESOLVE_BACKUP" ] && [ -f "$RESOLVE_BACKUP" ]; then if [ -n "$RESOLVE_BACKUP" ] && [ -f "$RESOLVE_BACKUP" ]; then
@ -75,8 +52,7 @@ rollback() {
restored=true restored=true
fi fi
if $restored; then if $restored; then
echo "[ROLLBACK] Restarting host dnsmasq ($HOST_DNSMASQ_SVC) and $DOCKER_CONTAINER..." echo "[ROLLBACK] Restarting $DOCKER_CONTAINER with previous config..."
systemctl restart "$HOST_DNSMASQ_SVC" 2>/dev/null || true
docker restart "$DOCKER_CONTAINER" 2>/dev/null || true docker restart "$DOCKER_CONTAINER" 2>/dev/null || true
else else
echo "[ROLLBACK] No backups found, cannot restore" echo "[ROLLBACK] No backups found, cannot restore"
@ -87,68 +63,31 @@ rollback() {
# ============================== # ==============================
# Бэкап существующих файлов # Бэкап существующих файлов
# ============================== # ==============================
# Оставляет BACKUP_KEEP самых свежих копий <name>.backup.<ts>
# (ts вида YYYYmmdd-HHMMSS сортируется лексикографически).
prune_backups() { # <name>
local name="$1" old
find "$BACKUP_DIR" -maxdepth 1 -type f -name "${name}.backup.*" -printf '%f\n' \
| sort -r | tail -n +"$((BACKUP_KEEP + 1))" \
| while read -r old; do
rm -f -- "$BACKUP_DIR/$old"
echo " pruned old backup $old"
done
}
mkdir -p "$BACKUP_DIR"
ts=$(date +%Y%m%d-%H%M%S) ts=$(date +%Y%m%d-%H%M%S)
if [ -f "$nftset_dest" ]; then if [ -f "$ipset_dest" ]; then
NFTSET_BACKUP="$BACKUP_DIR/${nftset_name}.backup.${ts}" IPSET_BACKUP="${ipset_dest}.backup.${ts}"
cp "$nftset_dest" "$NFTSET_BACKUP" cp "$ipset_dest" "$IPSET_BACKUP"
echo "Backed up $nftset_dest → $NFTSET_BACKUP" echo "Backed up $ipset_dest → $IPSET_BACKUP"
prune_backups "$nftset_name"
fi fi
if [ -f "$resolve_dest" ]; then if [ -f "$resolve_dest" ]; then
RESOLVE_BACKUP="$BACKUP_DIR/${resolve_name}.backup.${ts}" RESOLVE_BACKUP="${resolve_dest}.backup.${ts}"
cp "$resolve_dest" "$RESOLVE_BACKUP" cp "$resolve_dest" "$RESOLVE_BACKUP"
echo "Backed up $resolve_dest → $RESOLVE_BACKUP" echo "Backed up $resolve_dest → $RESOLVE_BACKUP"
prune_backups "$resolve_name"
fi fi
# ============================== # ==============================
# Копирование новых файлов # Копирование новых файлов
# ============================== # ==============================
cp "$NFTSET_CONF" "$nftset_dest" cp "$IPSET_CONF" "$TARGET_DIR/"
cp "$RESOLVE_CONF" "$resolve_dest" 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..." echo "Restarting $DOCKER_CONTAINER container..."
docker restart "$DOCKER_CONTAINER" docker restart "$DOCKER_CONTAINER"
# --- Ждём host dnsmasq (:5350) --- # Ждём запуска — polling через docker inspect вместо sleep + docker ps
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 ---
echo "Waiting for $DOCKER_CONTAINER to be ready (up to 30s)..." echo "Waiting for $DOCKER_CONTAINER to be ready (up to 30s)..."
ready=false ready=false
for i in $(seq 1 30); do for i in $(seq 1 30); do
@ -159,96 +98,52 @@ for i in $(seq 1 30); do
fi fi
sleep 1 sleep 1
done done
if ! $ready; then 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 rollback
exit 1 exit 1
fi fi
echo "$DOCKER_CONTAINER is running" echo "$DOCKER_CONTAINER is running"
# ============================== # ==============================
# Сброс nft-сетов (чистим устаревшие записи удалённых доменов; timeout 24h иначе) # DNS health check
# ============================== # ==============================
echo "Flushing nft sets..." dns_health_check() {
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"
if command -v dig >/dev/null 2>&1; then 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 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 fi
} }
if ! command -v dig >/dev/null 2>&1 && ! command -v nslookup >/dev/null 2>&1; then echo "Running DNS health check ($DNS_CHECK_DOMAIN via $DNS_LISTEN_ADDR, up to 15 attempts)..."
echo "Warning: neither 'dig' nor 'nslookup' found, skipping DNS health checks" dns_ok=false
else for i in $(seq 1 15); do
echo "DNS health check: $DNS_CHECK_DOMAIN via $DNS_LISTEN_ADDR:53 (up to 15 attempts)..." if dns_health_check; then
dns_ok=false dns_ok=true
for i in $(seq 1 15); do echo "DNS health check passed (attempt $i)"
if [ -n "$(dns_query "$DNS_LISTEN_ADDR" 53 "$DNS_CHECK_DOMAIN")" ]; then break
dns_ok=true
echo " passed (attempt $i)"
break
fi
echo " attempt $i failed, retry in 1s..."
sleep 1
done
if ! $dns_ok; then
echo "Error: DNS health check failed — pihole not resolving on $DNS_LISTEN_ADDR:53"
rollback
exit 1
fi fi
echo " attempt $i failed, retrying in 1s..."
# ==============================
# Health check 2: end-to-end nftset-захват.
# Резолвим bbrkn-домен через :5350 и убеждаемся, что nft-сет наполнился.
# ==============================
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 sleep 1
# shellcheck disable=SC2086 done
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 if ! $dns_ok; then
echo " passed — set '$NFT_CHECK_SET' populated ($set_count element line(s))" echo "Error: DNS health check failed after 15 attempts — Pi-hole not responding on $DNS_LISTEN_ADDR"
else rollback
echo "Error: nftset '$NFT_CHECK_SET' still empty after resolving $NFTSET_CHECK_DOMAIN" exit 1
echo " host dnsmasq likely not loading $nftset_dest"
rollback
exit 1
fi
fi fi
# ============================== # ==============================
# Прогрев nft-сетов: после flush сеты пусты, а у клиентов в кэше (Edge, # Очистка IPSET
# Windows) остаются старые IP — без прогрева их трафик идёт мимо туннеля до
# первого свежего резолва. Ошибка прогрева деплой не откатывает: конфиги уже
# проверены, а cron-прогрев повторит попытку.
# ============================== # ==============================
WARM_SCRIPT="${WARM_SCRIPT:-$(dirname "$0")/warm-nftset.sh}" echo "Flushing ipset bbrkn..."
if [ -f "$WARM_SCRIPT" ]; then ipset flush bbrkn
echo "Warming nft sets ($WARM_SCRIPT)..."
if ! NFTSET_CONF="$nftset_dest" HOST_DNSMASQ_SVC="$HOST_DNSMASQ_SVC" \
LOCK_WAIT="${WARM_LOCK_WAIT:-300}" bash "$WARM_SCRIPT"; then
echo " WARN: warm failed — sets will refill on client queries / hourly cron"
fi
else
echo " WARN: $WARM_SCRIPT not found, skipping warm"
fi
echo "Deployment completed successfully" echo "Deployment completed successfully"
DOMAIN_COUNT=$(grep -c '^nftset=' "$nftset_dest" || echo "0") DOMAIN_COUNT=$(grep -c '^ipset=' "$ipset_dest" || echo "0")
echo "Applied nftset configuration for $DOMAIN_COUNT domain directives" echo "Applied configuration for $DOMAIN_COUNT domains"

View file

@ -6,17 +6,11 @@ set -euo pipefail
# ============================== # ==============================
INPUT_FILE="${DOMAINS_FILE:-domains.txt}" 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}" 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=" API_URL="${CHROME_SERVER:-http://127.0.0.1:3000}/domains?domain="
# Куда pihole делегирует резолвинг bbrkn-доменов: инстанс dnsmasq :5350, который DNS_SERVER="${DNS_SERVER:-8.8.8.8}"
# резолвит через VPN и наполняет nft-сеты. Формат dnsmasq server=: ip[#port].
DNS_SERVER="${DNS_SERVER:-127.0.0.1#5350}"
# Игнорируемые части доменов (разделитель — пробел) # Игнорируемые части доменов (разделитель — пробел)
IGNORE_PARTS="${IGNORE_PARTS:-}" 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 if [ "$DEBUG" != "0" ]; then : > "$DEBUG_LOG"; dbg "Debugging enabled"; fi
log "Starting generate-configs.sh" 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 if [ ! -f "$INPUT_FILE" ]; then
err "Input file not found: $INPUT_FILE" err "Input file not found: $INPUT_FILE"
@ -319,9 +313,9 @@ if ! $DRY_RUN; then
{ {
for d in "${ORDERED_DOMS[@]}"; do for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}" [[ -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 done
} > "$NFTSET_CONF" } > "$IPSET_CONF"
{ {
for d in "${ORDERED_DOMS[@]}"; do for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}" [[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}"

View file

@ -1,188 +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 после последнего успешного деплоя.
#
# Самовосстановление: dnsmasq добавляет IP в сет только при свежем ответе
# upstream и иногда "клинит" — основной процесс начинает отвечать на каждую
# попытку записи "nftset ... netlink: Error: cache initialization failed:
# Protocol error" и больше сеты не наполняет до рестарта. Если за прогон в
# журнале основного процесса появилась такая ошибка — рестартуем 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}"
# Состояние и лог. Общий каталог для cron и деплоя — lock у них один.
STATE_DIR="${STATE_DIR:-/var/lib/bbrkn}"
LOG="${LOG:-${STATE_DIR}/warm.log}"
LOCK="${LOCK:-${STATE_DIR}/warm.lock}"
LOG_MAX_BYTES="${LOG_MAX_BYTES:-4194304}" # ротация лога, 4 МиБ
LOCK_WAIT="${LOCK_WAIT:-0}" # секунд ждать чужой прогон (0 — сразу выйти)
# Параметры опроса
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}"
# systemd-сервис host-инстанса dnsmasq (:5350) и самовосстановление
HOST_DNSMASQ_SVC="${HOST_DNSMASQ_SVC:-dnsmasq}"
SELF_HEAL="${SELF_HEAL:-1}"
# Под root (cron.d, CI) sudo не нужен
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo -n"
mkdir -p "$STATE_DIR"
# Один инстанс: медленный прогон не должен наложиться на следующий по cron.
exec 9>"$LOCK"
if ! flock -w "$LOCK_WAIT" 9; then
echo "$(date -Is) SKIP предыдущий запуск ещё выполняется" >>"$LOG"
exit 0
fi
log() { echo "$(date -Is) $*" >>"$LOG"; }
# Количество элементов в сете. У каждого элемента есть timeout, поэтому
# "expires" встречается ровно один раз на элемент. Ошибка здесь не фатальна —
# это только метрика прогресса.
set_count() {
$SUDO "$NFT" list set inet filter "$1" 2>/dev/null | grep -c 'expires' || true
}
main_pid() {
systemctl show -p MainPID --value "$HOST_DNSMASQ_SVC" 2>/dev/null
}
# Ошибки записи в nft-сет у основного процесса dnsmasq с момента <since>.
# Ошибки дочерних (TCP) процессов не считаем — они разовые; заклинивание
# видно именно по основному.
nftset_errors() { # <pid> <since>
$SUDO journalctl -q --no-pager _PID="$1" --since "$2" 2>/dev/null \
| grep -c 'nftset.*Protocol error' || true
}
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
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 и ERRORS (ошибки записи в
# сет у основного процесса dnsmasq за время прогона).
warm_all() {
local since pid
since=$(date '+%Y-%m-%d %H:%M:%S')
pid=$(main_pid)
RESULTS="$(printf '%s\n' "${HOSTS[@]}" \
| xargs -r -P "$PARALLEL" -I{} bash -c 'warm_one "$@"' _ {})"
sleep 1 # журнал пишется асинхронно
ERRORS=0
if [ -n "$pid" ] && [ "$pid" != "0" ]; then
ERRORS=$(nftset_errors "$pid" "$since")
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_all
HEALED=0
if [ "$ERRORS" -gt 0 ] && [ "$SELF_HEAL" = "1" ]; then
log "WEDGE $HOST_DNSMASQ_SVC pid=$(main_pid) nftset_errors=$ERRORS — рестарт и повторный прогрев"
if $SUDO systemctl restart "$HOST_DNSMASQ_SVC"; then
sleep 2
HEALED=1
warm_all
else
log "FATAL не удалось перезапустить $HOST_DNSMASQ_SVC"
fi
fi
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 nftset_errors=$ERRORS healed=$HEALED 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}"
[ "$HEALED" = "1" ] && echo "⚠ dnsmasq заклинило на записи в nft-сет — перезапущен ($HOST_DNSMASQ_SVC)"
# Ротация лога
if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG")" -gt "$LOG_MAX_BYTES" ]; then
mv -f "$LOG" "${LOG}.1"
fi
# Не ноль, если сеты так и не наполняются — деплой покажет предупреждение.
if [ "$ERRORS" -gt 0 ]; then
log "FAIL nftset_errors=$ERRORS после прогрева"
echo "Ошибка: dnsmasq не пишет в nft-сеты (nftset_errors=$ERRORS), см. journalctl -u $HOST_DNSMASQ_SVC" >&2
exit 1
fi
exit 0