warm: self-heal wedged dnsmasq, run after deploy, shared state dir
All checks were successful
Deploy DNS Configuration / deploy (push) Successful in 1h15m4s

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
This commit is contained in:
goodvin 2026-10-13 21:26:49 +03:00
parent a5ee057c59
commit 8598a1a48f
4 changed files with 107 additions and 18 deletions

View file

@ -13,6 +13,13 @@ set -uo pipefail
# Источник списка доменов — сам задеплоенный конфиг, поэтому отдельный список
# синхронизировать не нужно: скрипт всегда работает с тем, что реально
# загружен в dnsmasq после последнего успешного деплоя.
#
# Самовосстановление: dnsmasq добавляет IP в сет только при свежем ответе
# upstream и иногда "клинит" — основной процесс начинает отвечать на каждую
# попытку записи "nftset ... netlink: Error: cache initialization failed:
# Protocol error" и больше сеты не наполняет до рестарта. Если за прогон в
# журнале основного процесса появилась такая ошибка — рестартуем dnsmasq и
# прогреваем ещё раз.
# --- Конфигурация через переменные окружения ---
# Задеплоенный конфиг с директивами nftset= (источник списка доменов)
@ -21,11 +28,12 @@ NFTSET_CONF="${NFTSET_CONF:-/etc/dnsmasq.d/90-nftset.conf}"
RESOLVER="${RESOLVER:-127.0.0.1}"
RESOLVER_PORT="${RESOLVER_PORT:-5350}"
# Состояние и лог
STATE_DIR="${STATE_DIR:-${HOME}/.local/state/bbrkn}"
# Состояние и лог. Общий каталог для 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
@ -40,11 +48,19 @@ 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 -n 9; then
if ! flock -w "$LOCK_WAIT" 9; then
echo "$(date -Is) SKIP предыдущий запуск ещё выполняется" >>"$LOG"
exit 0
fi
@ -55,7 +71,19 @@ log() { echo "$(date -Is) $*" >>"$LOG"; }
# "expires" встречается ровно один раз на элемент. Ошибка здесь не фатальна —
# это только метрика прогресса.
set_count() {
sudo -n "$NFT" list set inet filter "$1" 2>/dev/null | grep -c 'expires' || echo "?"
$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
@ -71,10 +99,6 @@ if [ "${#HOSTS[@]}" -eq 0 ]; then
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" \
@ -104,8 +128,37 @@ warm_one() {
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 "$@"' _ {})"
# Один прогон по всем доменам. Заполняет 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)
@ -113,16 +166,23 @@ 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}"
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