Add warm-nftset.sh — periodic nftset warmer
All checks were successful
Deploy DNS Configuration / deploy (push) Successful in 14m41s

Elements in bbrkn_v4/bbrkn_v6 carry a 1d timeout and are only refreshed
when a query reaches the host dnsmasq on :5350, where the nftset=
directives are applied. Idle domains — or ones answered from the pihole
FTL cache — age out of the sets and their traffic silently falls back to
the plain WAN route instead of the tunnel.

warm-nftset.sh re-resolves every domain from the deployed 90-nftset.conf
(base + related subdomains) directly against 127.0.0.1:5350, bypassing
the FTL cache. Sourcing the domain list from the deployed config means
there is no second list to keep in sync.

Ping is off by default: packets originating on the gateway itself go
through OUTPUT, never prerouting_mangle, so they leave unmarked and
almost all time out — a misleading signal, not a real failure.

Run on the gateway via `make warm` or hourly cron. Measured: 1059
domains in ~1m45s at PARALLEL=8, v4 526->2203, v6 305->1440 elements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goodvin 2026-08-05 20:58:33 +03:00
parent 38d5bdf0ce
commit 660a31c35e
3 changed files with 187 additions and 2 deletions

128
scripts/warm-nftset.sh Executable file
View file

@ -0,0 +1,128 @@
#!/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