Migrate bbrkn from legacy ipset to nftables sets

The gateway (Archie) routes bbrkn domains via nft sets bbrkn_v4/bbrkn_v6
(inet filter), not the legacy ipset. bbrkn was still emitting dead
`ipset=/domain/bbrkn` directives (no such ipset exists) plus a 92-resolve
pointing at 8.8.8.8, both overridden by hand-maintained files on the host.
This makes bbrkn the generator of record for the real scheme.

generate-configs.sh:
- emit `nftset=/domain/$NFTSET_SPEC` (default 4#inet#filter#bbrkn_v4,
  6#inet#filter#bbrkn_v6) into 90-nftset.conf instead of ipset= into
  91-ipset-bbrkn.conf
- DNS_SERVER default 8.8.8.8 -> 127.0.0.1#5350 (host dnsmasq pihole
  delegates bbrkn domains to for VPN resolution + nftset capture)

deploy-to-gateway.sh: two targets, two instances
- 90-nftset.conf -> host /etc/dnsmasq.d (:5350), 92-resolve -> pihole
- full restart of both (dnsmasq SIGHUP does NOT re-read nftset=/server=)
- flush nft sets bbrkn_v4/bbrkn_v6 instead of `ipset flush bbrkn`
- add end-to-end nftset-capture health check via :5350
- rollback restores both files and restarts both instances

Makefile/workflow: rename IPSET_CONF->NFTSET_CONF, add NFTSET_TARGET_DIR
and HOST_DNSMASQ_SVC, DNS_SERVER=127.0.0.1#5350 (escaped `\#` in Make,
quoted in YAML), note runner is ephemeral (cold gekata crawl).

Docs: README + new CLAUDE.md describe the two-dnsmasq / nft-set model;
exit-node DPI failover (10.77.1.2/10.77.2.2) documented as external
(wg-ha.service), not owned by bbrkn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
goodvin 2026-07-24 23:55:16 +03:00
parent ef30817cf8
commit d81a4ab3b2
6 changed files with 271 additions and 111 deletions

View file

@ -4,46 +4,64 @@ set -euo pipefail
# ==============================
# Конфигурация через переменные окружения
# ==============================
TARGET_DIR="${TARGET_DIR:-/opt/appdata/pihole/etc/dnsmasq.d}"
IPSET_CONF="${IPSET_CONF:-/tmp/91-ipset-bbrkn.conf}"
# Сгенерированные конфиги (из generate-configs.sh)
NFTSET_CONF="${NFTSET_CONF:-/tmp/90-nftset.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}"
# Адрес, на котором слушает Pi-hole DNS (для health check)
DNS_LISTEN_ADDR="${DNS_LISTEN_ADDR:-127.0.0.1}"
# Домен для проверки DNS после рестарта
# 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 (клиентский)
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 — используем параметрическую подстановку)
ipset_name="${IPSET_CONF##*/}"
# Имена/пути назначения (параметрическая подстановка, без basename)
nftset_name="${NFTSET_CONF##*/}"
resolve_name="${RESOLVE_CONF##*/}"
ipset_dest="$TARGET_DIR/$ipset_name"
resolve_dest="$TARGET_DIR/$resolve_name"
nftset_dest="$NFTSET_TARGET_DIR/$nftset_name"
resolve_dest="$RESOLVE_TARGET_DIR/$resolve_name"
# Пути к бэкапам (заполняются ниже, нужны для rollback)
IPSET_BACKUP=""
# Пути к бэкапам (для rollback)
NFTSET_BACKUP=""
RESOLVE_BACKUP=""
echo "Deploying configuration files to $TARGET_DIR"
echo "Deploying nftset config → $nftset_dest (host dnsmasq :$RESOLVER_PORT)"
echo "Deploying resolve config → $resolve_dest ($DOCKER_CONTAINER)"
# ==============================
# Проверка входных файлов
# ==============================
if [ ! -f "$IPSET_CONF" ] || [ ! -f "$RESOLVE_CONF" ]; then
echo "Error: one or both config files not found!"
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"
exit 1
fi
# ==============================
# Функция отката: восстанавливает бэкапы и перезапускает контейнер
# Функция отката: восстанавливает бэкапы и перезапускает оба инстанса
# ==============================
rollback() {
echo "[ROLLBACK] Restoring previous configuration..."
local restored=false
if [ -n "$IPSET_BACKUP" ] && [ -f "$IPSET_BACKUP" ]; then
cp "$IPSET_BACKUP" "$ipset_dest"
echo "[ROLLBACK] Restored $ipset_dest"
if [ -n "$NFTSET_BACKUP" ] && [ -f "$NFTSET_BACKUP" ]; then
cp "$NFTSET_BACKUP" "$nftset_dest"
echo "[ROLLBACK] Restored $nftset_dest"
restored=true
fi
if [ -n "$RESOLVE_BACKUP" ] && [ -f "$RESOLVE_BACKUP" ]; then
@ -52,7 +70,8 @@ rollback() {
restored=true
fi
if $restored; then
echo "[ROLLBACK] Restarting $DOCKER_CONTAINER with previous config..."
echo "[ROLLBACK] Restarting host dnsmasq ($HOST_DNSMASQ_SVC) and $DOCKER_CONTAINER..."
systemctl restart "$HOST_DNSMASQ_SVC" 2>/dev/null || true
docker restart "$DOCKER_CONTAINER" 2>/dev/null || true
else
echo "[ROLLBACK] No backups found, cannot restore"
@ -64,10 +83,10 @@ rollback() {
# Бэкап существующих файлов
# ==============================
ts=$(date +%Y%m%d-%H%M%S)
if [ -f "$ipset_dest" ]; then
IPSET_BACKUP="${ipset_dest}.backup.${ts}"
cp "$ipset_dest" "$IPSET_BACKUP"
echo "Backed up $ipset_dest$IPSET_BACKUP"
if [ -f "$nftset_dest" ]; then
NFTSET_BACKUP="${nftset_dest}.backup.${ts}"
cp "$nftset_dest" "$NFTSET_BACKUP"
echo "Backed up $nftset_dest$NFTSET_BACKUP"
fi
if [ -f "$resolve_dest" ]; then
RESOLVE_BACKUP="${resolve_dest}.backup.${ts}"
@ -78,16 +97,38 @@ fi
# ==============================
# Копирование новых файлов
# ==============================
cp "$IPSET_CONF" "$TARGET_DIR/"
cp "$RESOLVE_CONF" "$TARGET_DIR/"
cp "$NFTSET_CONF" "$nftset_dest"
cp "$RESOLVE_CONF" "$resolve_dest"
# ==============================
# Перезапуск контейнера
# Перезапуск обоих инстансов.
# ВАЖНО: 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"
# Ждём запуска — polling через docker inspect вместо sleep + docker ps
# --- Ждём 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 ---
echo "Waiting for $DOCKER_CONTAINER to be ready (up to 30s)..."
ready=false
for i in $(seq 1 30); do
@ -98,52 +139,79 @@ 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)"
echo "Error: $DOCKER_CONTAINER failed to start after 30s (status: ${status:-unknown})"
rollback
exit 1
fi
echo "$DOCKER_CONTAINER is running"
# ==============================
# DNS health check
# Сброс nft-сетов (чистим устаревшие записи удалённых доменов; timeout 24h иначе)
# ==============================
dns_health_check() {
if command -v dig >/dev/null 2>&1; then
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 "$DNS_CHECK_DOMAIN" "$DNS_LISTEN_ADDR" >/dev/null 2>&1
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 "Warning: neither 'dig' nor 'nslookup' found, skipping DNS health check"
return 0
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
dig +short +time=3 +tries=2 -p "$port" "@$addr" "$dom" 2>/dev/null
elif command -v nslookup >/dev/null 2>&1; then
nslookup -port="$port" "$dom" "$addr" 2>/dev/null | awk '/^Address: /{print $2}'
fi
}
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 dns_health_check; then
dns_ok=true
echo "DNS health check passed (attempt $i)"
break
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)..."
dns_ok=false
for i in $(seq 1 15); do
if [ -n "$(dns_query "$DNS_LISTEN_ADDR" 53 "$DNS_CHECK_DOMAIN")" ]; then
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
echo " attempt $i failed, retrying in 1s..."
sleep 1
done
if ! $dns_ok; then
echo "Error: DNS health check failed after 15 attempts — Pi-hole not responding on $DNS_LISTEN_ADDR"
rollback
exit 1
# ==============================
# 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
# 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
# ==============================
# Очистка IPSET
# ==============================
echo "Flushing ipset bbrkn..."
ipset flush bbrkn
echo "Deployment completed successfully"
DOMAIN_COUNT=$(grep -c '^ipset=' "$ipset_dest" || echo "0")
echo "Applied configuration for $DOMAIN_COUNT domains"
DOMAIN_COUNT=$(grep -c '^nftset=' "$nftset_dest" || echo "0")
echo "Applied nftset configuration for $DOMAIN_COUNT domain directives"

View file

@ -6,11 +6,17 @@ set -euo pipefail
# ==============================
INPUT_FILE="${DOMAINS_FILE:-domains.txt}"
IPSET_CONF="${IPSET_CONF:-/tmp/91-ipset-bbrkn.conf}"
NFTSET_CONF="${NFTSET_CONF:-/tmp/90-nftset.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="
DNS_SERVER="${DNS_SERVER:-8.8.8.8}"
# Куда pihole делегирует резолвинг bbrkn-доменов: инстанс dnsmasq :5350, который
# резолвит через VPN и наполняет nft-сеты. Формат dnsmasq server=: ip[#port].
DNS_SERVER="${DNS_SERVER:-127.0.0.1#5350}"
# Игнорируемые части доменов (разделитель — пробел)
IGNORE_PARTS="${IGNORE_PARTS:-}"
@ -43,7 +49,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 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"
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"
if [ ! -f "$INPUT_FILE" ]; then
err "Input file not found: $INPUT_FILE"
@ -313,9 +319,9 @@ if ! $DRY_RUN; then
{
for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}"
printf 'ipset=/%s/bbrkn\n' "$d"
printf 'nftset=/%s/%s\n' "$d" "$NFTSET_SPEC"
done
} > "$IPSET_CONF"
} > "$NFTSET_CONF"
{
for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}"