Files
ayurishchevandClaude Opus 5 fa389f119b Шаг 5: динамическое изучение MAC/ARP для бэкендов
MAC каждого члена пула больше не статическая константа в topology.env,
а резолвится демоном hcd через обычный ARP ядра — в реальном
окружении MAC бэкенда заранее не известен (сервер ещё не подключён,
NIC может замениться), топология не описывается статически, в отличие
от стенда.

hcif-порты (единственные адреса узла в ядре) уже были настоящими
L3-интерфейсами в тех же сегментах, что и бэкенды — единственное, что
мешало обычному ARP, это permanent-записи ip neigh в entrypoint.sh.
Убрав их и добавив hc/neigh.go (читает ip -json neigh show, точечно
заливает бандл в таблицу 21 на том же тикере, что и health-пробы),
получили резолвер без нового OpenFlow-контроллера.

Таблица 21 стала единственным источником MAC для обоих путей —
маршрутизируемого и коммутируемого (шаг 3): таблица 12 больше не
дублирует MAC инлайново, ct(commit) ведёт сразу в таблицу 21.

Исправлен попутно найденный баг: после apply.sh (replace-flows)
таблица 21 не восстанавливалась, поскольку syncNeighbors сравнивал
MAC с памятью демона, а не с датапасом. Добавлен force-режим по
аналогии с Agent.apply(), плюс регрессионная проверка в verify.sh.

Проверено на живом стенде: MAC всех четырёх членов резолвлен и
совпадает с реальными интерфейсами; смена MAC "железа" обнаружена и
применена без вмешательства за счёт штатного старения ARP ядра.
Регрессия: 94 из 94 проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:33:48 +03:00

361 lines
23 KiB
Bash
Executable File
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.
#!/bin/bash
# Проверки стенда, выполнимые с хоста. Клиентские шаги (ping/curl из
# 192.168.5.0/24) описаны в README и выполняются вручную; если поднят
# macvlan-shim (host-prereq.sh --shim), скрипт прогоняет и их.
set -uo pipefail
cd "$(dirname "$0")/.."
DC="docker compose"
VIP=192.168.5.21
LB_IP=192.168.5.20
SHIM=hpnn-shim
pass=0
fail=0
ok() { echo -e " \033[32mOK\033[0m $*"; pass=$((pass + 1)); }
bad() { echo -e " \033[31mПРОВАЛ\033[0m $*"; fail=$((fail + 1)); }
skip() { echo -e " \033[33mПРОПУСК\033[0m $*"; }
head_() { echo; echo "== $*"; }
head_ "1. Контейнеры"
for c in hpnn-lb hpnn-be1 hpnn-be2 hpnn-be3 hpnn-be4 hpnn-cli2 hpnn-cli3; do
state=$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo "нет")
[ "$state" = "running" ] && ok "$c: $state" || bad "$c: $state"
done
head_ "2. Мост br-lb и порты"
ports=$($DC exec -T lb-router ovs-vsctl list-ports br-lb 2>/dev/null | tr '\n' ' ')
for p in pub0 p2 p3; do
echo "$ports" | grep -qw "$p" && ok "порт $p в мосту" || bad "порт $p отсутствует (есть: $ports)"
done
# Шаг 3: каждая ВМ сегмента — отдельный порт моста. Без этого балансировщик не
# увидит ответ бэкенда клиенту-соседу и приватный листенер работать не сможет.
for p in be1 be3 cli2 be2 be4 cli3; do
echo "$ports" | grep -qw "$p" && ok "ВМ $p подключена портом OVS" \
|| bad "порт $p отсутствует — выполните make attach"
done
dp=$($DC exec -T lb-router ovs-appctl dpif/show 2>/dev/null | head -1)
[ -n "$dp" ] && ok "датапас: $dp" || bad "датапас не поднят"
head_ "3. Пайплайн"
for t in 0 1 5 6 10 11 12 15 16 20 21 24 25; do
n=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb "table=$t" 2>/dev/null | grep -c 'table=')
[ "${n:-0}" -gt 0 ] && ok "таблица $t: $n правил" || bad "таблица $t пуста"
done
for p in hcif-p2 hcif-p3; do
echo "$ports" | grep -qw "$p" && ok "порт-источник проб $p в мосту" || bad "порт $p отсутствует"
done
head_ "4. Маршруты бэкендов (профиль A: default мимо LB)"
for be in be1:10.20.0.1:10.30.0.0/24 be2:10.30.0.1:10.20.0.0/24 \
be3:10.20.0.1:10.30.0.0/24 be4:10.30.0.1:10.20.0.0/24; do
name=${be%%:*}; rest=${be#*:}; gw=${rest%%:*}; peer=${rest#*:}
r=$($DC exec -T "$name" ip route 2>/dev/null)
echo "$r" | grep -q "192.168.5.0/24 via $gw" && ok "$name: клиентский префикс via $gw" || bad "$name: нет маршрута на 192.168.5.0/24"
echo "$r" | grep -q "$peer via $gw" && ok "$name: соседний сегмент via $gw" || bad "$name: нет маршрута на $peer"
echo "$r" | grep -q "^default via .*\.254" && ok "$name: default мимо LB" || bad "$name: default не на шлюзе Docker"
done
head_ "5. Маршрутизация между приватными сегментами"
for pair in be1:10.30.0.2:be2 be3:10.30.0.3:be4 be2:10.20.0.2:be1 be4:10.20.0.3:be3; do
from=${pair%%:*}; rest=${pair#*:}; dst=${rest%%:*}; to=${rest#*:}
out=$($DC exec -T "$from" curl -s --max-time 5 "http://$dst:8080/" 2>/dev/null)
echo "$out" | grep -q "backend=$to" && ok "$from -> $to через OVS" \
|| bad "$from -> $to недоступен (ответ: ${out:-пусто})"
done
head_ "6. Балансировка (требует macvlan-shim на хосте)"
if ip link show "$SHIM" >/dev/null 2>&1; then
ping -c1 -W2 "$LB_IP" >/dev/null 2>&1 && ok "ping $LB_IP (ARP+ICMP-респондер OVS)" || bad "ping $LB_IP не проходит"
ping -c1 -W2 "$VIP" >/dev/null 2>&1 && ok "ping $VIP (VIP)" || bad "ping $VIP не проходит"
declare -A hits=()
client=""
for _ in $(seq 20); do
line=$(curl -s --max-time 5 "http://$VIP/" 2>/dev/null) || continue
b=$(echo "$line" | sed -n 's/.*backend=\([a-z0-9]*\).*/\1/p')
c=$(echo "$line" | sed -n 's/.*client=\([0-9.]*\):.*/\1/p')
[ -n "$b" ] && hits[$b]=$(( ${hits[$b]:-0} + 1 ))
[ -n "$c" ] && client=$c
done
total=0; for k in "${!hits[@]}"; do total=$((total + hits[$k])); done
echo " распределение: $(for k in "${!hits[@]}"; do printf '%s=%s ' "$k" "${hits[$k]}"; done)(всего $total)"
# На 20 запросах и четырёх членах пропуск одного бэкенда возможен, но
# маловероятен; порог в три различных имени отделяет статистику от дефекта.
[ "${#hits[@]}" -ge 3 ] && ok "трафик распределён между ${#hits[@]} бэкендами из 4" \
|| bad "задействовано лишь ${#hits[@]} бэкендов"
if [ -n "$client" ]; then
case "$client" in
192.168.5.*) ok "бэкенд видит реальный IP клиента: $client (DNAT без SNAT)" ;;
*) bad "бэкенд видит адрес $client вместо клиентского" ;;
esac
else
bad "не удалось получить адрес клиента из ответа"
fi
else
skip "shim не поднят — выполните: sudo scripts/host-prereq.sh --shim"
fi
head_ "7. Профиль A: собственный egress бэкенда не маршрутизируется балансировщиком"
# С шага 3 br-lb коммутирует сегмент, поэтому egress физически через него
# проходит — но только как через коммутатор. Признак: кадр адресован MAC
# docker-шлюза, а не MAC маршрутизатора сегмента (02:42:0a:14:00:01).
dump=$(mktemp)
timeout 12 $DC exec -T lb-router timeout 8 tcpdump -eni be1 -c 2 'host 1.1.1.1' >"$dump" 2>&1 &
tcpdump_pid=$!
sleep 2
code=$($DC exec -T be1 curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://1.1.1.1/ 2>/dev/null)
wait $tcpdump_pid 2>/dev/null
[ "${code:-000}" != "000" ] && ok "be1 достучался наружу (HTTP $code) через шлюз Docker" \
|| bad "be1 не имеет выхода наружу (HTTP ${code:-000})"
if grep -q '^[0-9][0-9]:' "$dump"; then
if grep -q '> 02:42:0a:14:00:01' "$dump"; then
bad "egress адресован маршрутизатору сегмента — default ушёл на балансировщик"
else
ok "egress адресован шлюзу Docker, а не маршрутизатору сегмента"
fi
else
bad "трафик egress не наблюдается на порту ВМ"
fi
rm -f "$dump"
head_ "8. Health-check: состояние пула"
hc() { $DC exec -T lb-router curl -fsS --max-time 5 "http://127.0.0.1:9111$1" 2>/dev/null; }
state() { hc /status | sed -n "s/.*\"name\":\"$1\"[^}]*\"state\":\"\([a-z]*\)\".*/\1/p"; }
digest() { hc /status | sed -n 's/.*"slot_table_digest":"\([^"]*\)".*/\1/p'; }
# Раскладка из датапаса: пары «слот -> член». reg1=0 OVS печатает без 0x.
snapshot() {
$DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 2>/dev/null \
| sed -n 's/.*reg1=\(0x[0-9a-f]*\|[0-9]\+\).*set_field:\(0x[0-9a-f]*\)->reg2.*/\1 \2/p' | sort
}
slots_of() { snapshot | grep -c " $1\$"; }
# wait_state <член> <состояние> <секунд>
wait_state() {
for _ in $(seq "$3"); do
[ "$(hc /status | sed -n "s/.*\"name\":\"$1\"[^}]*\"state\":\"\([a-z]*\)\".*/\1/p")" = "$2" ] && return 0
sleep 1
done
return 1
}
for m in be1 be2 be3 be4; do
[ "$(state $m)" = "up" ] && ok "член $m: up" || bad "член $m: $(state $m)"
done
probes=$(hc /status | sed -n 's/.*"probes":\([0-9]*\).*/\1/p' | head -1)
[ "${probes:-0}" -gt 0 ] && ok "пробы идут (у be1 их $probes)" || bad "проб не было"
total_slots=$(snapshot | wc -l)
[ "$total_slots" -eq 1024 ] && ok "таблица слотов заполнена: $total_slots слотов" \
|| bad "слотов $total_slots вместо 1024"
# Идеальная доля при четырёх равновесных членах — 256 слотов; Maglev
# гарантирует отклонение в пределах ±1, порог взят с большим запасом.
uneven=0; dist=""
for id in 0x1 0x2 0x3 0x4; do
n=$(slots_of $id); dist="$dist $id=$n"
[ "$n" -lt 230 ] || [ "$n" -gt 280 ] && uneven=1
done
[ "$uneven" -eq 0 ] && ok "слоты поделены поровну:$dist" || bad "неравномерная раскладка:$dist"
head_ "9. Источник health-проб — уникальный адрес узла, не VIP"
# Слушаем порт самой ВМ: с шага 3 проба идёт коммутацией сегмента, а не через
# аплинк p2, поэтому на аплинке её больше не видно.
src=$($DC exec -T lb-router timeout 6 tcpdump -ni be1 -c 1 'tcp port 8080 and tcp[tcpflags] & tcp-syn != 0' 2>/dev/null \
| sed -n 's/.*IP \([0-9.]*\)\.[0-9]* > .*/\1/p' | head -1)
[ "$src" = "10.20.0.253" ] && ok "проба к be1 уходит с $src" || bad "проба уходит с ${src:-неизвестно}, ожидался 10.20.0.253"
head_ "10. Минимальное возмущение раскладки при выводе члена"
before=$(mktemp); after=$(mktemp)
snapshot > "$before"
digest_full=$(digest)
$DC exec -T lb-router lbctl drain be1 >/dev/null 2>&1
sleep 1
snapshot > "$after"
# Слоты выбывшего члена обязаны переехать — это его 25 % таблицы. Смысл
# проверки в другом: сколько слотов сменило владельца у ОСТАВШИХСЯ членов.
# Классический Maglev гарантирует малое, а не нулевое возмущение, поэтому
# проверяется порог. Измеренное на этом стенде значение — 8 слотов (0,8 %).
own=$(join "$before" "$after" | awk '$2=="0x1"' | wc -l)
foreign=$(join "$before" "$after" | awk '$2!="0x1" && $2!=$3' | wc -l)
if [ "$foreign" -le 20 ]; then
ok "возмущение минимально: переехали $own слотов выбывшего be1 и лишь $foreign чужих (порог 20)"
else
bad "у оставшихся членов переехало $foreign слотов — возмущение выше ожидаемого"
fi
$DC exec -T lb-router lbctl enable be1 >/dev/null 2>&1
sleep 1
[ "$(digest)" = "$digest_full" ] && ok "после возврата члена дайджест прежний: $digest_full" \
|| bad "дайджест не восстановился: $(digest) вместо $digest_full"
rm -f "$before" "$after"
head_ "11. Отказ и восстановление члена пула"
$DC pause be1 >/dev/null 2>&1
if wait_state be1 down 20; then
ok "be1 переведён в down по данным проб"
[ "$(slots_of 0x1)" -eq 0 ] && ok "у be1 не осталось слотов" || bad "у be1 ещё $(slots_of 0x1) слотов"
[ "$(snapshot | wc -l)" -eq 1024 ] && ok "его слоты разошлись по трём оставшимся членам" \
|| bad "в таблице $(snapshot | wc -l) слотов вместо 1024"
nodead=1
for _ in $(seq 8); do
line=$(curl -s --max-time 5 "http://$VIP/" 2>/dev/null)
echo "$line" | grep -qE 'backend=(be2|be3|be4)' || nodead=0
done
[ "$nodead" -eq 1 ] && ok "весь трафик на VIP обслуживают живые члены, ошибок нет" \
|| bad "часть запросов не обслужена или ушла на мёртвый член"
else
bad "be1 не перешёл в down за 20 с"
fi
$DC unpause be1 >/dev/null 2>&1
wait_state be1 up 20 && ok "be1 вернулся в up после восстановления" || bad "be1 не вернулся в up"
head_ "12. Fail-close при полном отказе пула"
$DC pause be1 be2 be3 be4 >/dev/null 2>&1
if wait_state be1 down 20 && wait_state be2 down 20 \
&& wait_state be3 down 20 && wait_state be4 down 20; then
[ "$(snapshot | wc -l)" -eq 0 ] && ok "таблица слотов пуста" || bad "в таблице остались слоты"
d0=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 2>/dev/null \
| sed -n 's/.*n_packets=\([0-9]*\).*priority=0.*/\1/p')
curl -s --max-time 4 "http://$VIP/" >/dev/null 2>&1 && bad "VIP ответил при пустом пуле" || ok "клиент получает таймаут"
d1=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 2>/dev/null \
| sed -n 's/.*n_packets=\([0-9]*\).*priority=0.*/\1/p')
[ "${d1:-0}" -gt "${d0:-0}" ] && ok "счётчик правила fail-close вырос: ${d0:-0} -> ${d1:-0}" \
|| bad "счётчик fail-close не изменился"
else
bad "члены не перешли в down"
fi
$DC unpause be1 be2 be3 be4 >/dev/null 2>&1
wait_state be1 up 20 && wait_state be2 up 20 && wait_state be3 up 20 && wait_state be4 up 20 \
&& ok "пул восстановлен" || bad "пул не восстановился"
head_ "13. Установленная сессия переживает смену состава пула"
if ip link show "$SHIM" >/dev/null 2>&1; then
slow=$(mktemp)
(curl -sN --max-time 22 "http://$VIP/slow?seconds=14" > "$slow" 2>&1 &)
sleep 4
served=$(sed -n 's/backend=\([a-z0-9]*\).*/\1/p' "$slow" | head -1)
if [ -n "$served" ]; then
$DC exec -T lb-router lbctl drain "$served" >/dev/null 2>&1
sleep 12
$DC exec -T lb-router lbctl enable "$served" >/dev/null 2>&1
ticks=$(grep -c '^backend=' "$slow")
[ "$ticks" -eq 14 ] && ok "сессия к $served не порвалась при его выводе из пула ($ticks/14 тиков)" \
|| bad "сессия оборвалась: $ticks из 14 тиков"
else
bad "не удалось начать длинную сессию"
fi
rm -f "$slow"
else
skip "shim не поднят"
fi
head_ "14. Идемпотентность: перезаливка пайплайна не теряет раскладку"
d_before=$(digest)
$DC exec -T lb-router /opt/lb/apply.sh >/dev/null 2>&1
sleep 1
[ "$(digest)" = "$d_before" ] && [ "$(snapshot | wc -l)" -eq 1024 ] \
&& ok "после apply.sh раскладка та же: $d_before" \
|| bad "раскладка изменилась: $(digest), слотов $(snapshot | wc -l)"
head_ "15. Листенер в приватном сегменте (шаг 3)"
# Клиент cli2 живёт в одном сегменте с be1 и be3 — тот самый случай, ради
# которого сегмент переведён на порты OVS. В его ОС не настроено ничего, кроме
# адреса: VIP находится в его собственной подсети.
P2_VIP=10.20.0.100
routes=$($DC exec -T cli2 ip route 2>/dev/null | grep -c 'via')
[ "${routes:-1}" -eq 0 ] && ok "в ОС клиента нет ни одного маршрута — только адрес" \
|| bad "у клиента появились маршруты: их быть не должно"
$DC exec -T cli2 ping -c1 -W2 "$P2_VIP" >/dev/null 2>&1 \
&& ok "ping $P2_VIP из сегмента (ARP- и ICMP-респондеры OVS)" \
|| bad "VIP приватного листенера недоступен"
declare -A phits=()
pclient=""; pserved=""; pown=0
for _ in $(seq 24); do
line=$($DC exec -T cli2 curl -s --max-time 5 "http://$P2_VIP/" 2>/dev/null) || continue
b=$(echo "$line" | sed -n 's/.*backend=\([a-z0-9]*\).*/\1/p')
c=$(echo "$line" | sed -n 's/.*client=\([0-9.]*\):.*/\1/p')
sv=$(echo "$line" | sed -n 's/.*served=\([0-9.]*:[0-9]*\).*/\1/p')
[ -n "$b" ] && phits[$b]=$(( ${phits[$b]:-0} + 1 ))
[ -n "$c" ] && pclient=$c
case "$b" in be1|be3) pown=$((pown + 1)); pserved=$sv ;; esac
done
echo " распределение: $(for k in "${!phits[@]}"; do printf '%s=%s ' "$k" "${phits[$k]}"; done)"
[ "${#phits[@]}" -ge 3 ] && ok "приватный листенер балансирует между ${#phits[@]} членами из 4" \
|| bad "задействовано лишь ${#phits[@]} членов"
# Ключевая проверка шага: бэкенд видит исходный адрес клиента.
[ "$pclient" = "10.20.0.10" ] && ok "бэкенд видит исходный IP клиента: $pclient (SNAT не выполняется)" \
|| bad "бэкенд видит $pclient вместо 10.20.0.10"
# Тот самый случай: член пула в одном сегменте с клиентом. Ответ уходит по L2
# напрямую, но проходит через br-lb, где таблица 24 снимает трансляцию.
[ "$pown" -gt 0 ] && ok "члены из сегмента клиента обслужили $pown запросов" \
|| bad "ни один запрос не попал на be1/be3 — коммутируемый путь не проверен"
case "$pserved" in
10.20.0.[23]:8080) ok "трансляция порта выполнена: листенер 80 -> член $pserved" ;;
"") bad "не удалось определить адрес обслуживания" ;;
*) bad "неожиданный адрес обслуживания: $pserved" ;;
esac
# Путь остался коммутируемым: TTL ответа от VIP не уменьшен, значит клиент и
# бэкенд по-прежнему L2-соседи и балансировщик не стал для них L3-хопом.
# Захватываем с запасом: пул из 4 членов, capture -c должен гарантированно
# застать хотя бы один ответ от соседа по сегменту (be1/be3), а не только от
# кросс-сегментных (be2/be4) — иначе проверка становится статистически
# хрупкой на малой выборке.
ttls=$(mktemp)
timeout 20 $DC exec -T lb-router timeout 15 tcpdump -ni cli2 -c 16 -v \
"tcp and src host $P2_VIP and tcp[tcpflags] & tcp-syn != 0" >"$ttls" 2>/dev/null &
tpid=$!
sleep 1
for _ in $(seq 32); do $DC exec -T cli2 curl -s --max-time 3 -o /dev/null "http://$P2_VIP/" 2>/dev/null; done
wait $tpid 2>/dev/null
grep -q 'ttl 64' "$ttls" && ok "ответ от VIP приходит с TTL 64 — путь коммутируемый, не маршрутный" \
|| bad "TTL ответа уменьшен: путь стал маршрутизируемым"
rm -f "$ttls"
head_ "16. Внутрисегментный трафик не блокирован"
out=$($DC exec -T cli2 curl -s --max-time 5 http://10.20.0.2:8080/ 2>/dev/null)
echo "$out" | grep -q 'backend=be1' && ok "клиент обращается к бэкенду напрямую, минуя VIP" \
|| bad "прямое обращение клиента к бэкенду не работает"
out=$($DC exec -T be1 curl -s --max-time 5 http://10.20.0.3:8080/ 2>/dev/null)
echo "$out" | grep -q 'backend=be3' && ok "be1 -> be3 внутри сегмента" \
|| bad "связность между бэкендами сегмента нарушена"
$DC exec -T be1 ping -c1 -W2 10.20.0.254 >/dev/null 2>&1 \
&& ok "бэкенд достаёт шлюз Docker через аплинк сегмента" \
|| bad "шлюз сегмента недоступен"
$DC exec -T cli3 curl -s --max-time 5 http://10.30.0.3:8080/ 2>/dev/null | grep -q 'backend=be4' \
&& ok "cli3 -> be4 во втором сегменте" || bad "связность второго сегмента нарушена"
head_ "17. Динамическое изучение MAC/ARP (шаг 5)"
# MAC членов пула больше не статичен: hcd резолвит его через обычный ARP на
# hcif-портах (ядро) и сам заливает таблицу 21. Проверяем, что резолвленный
# MAC совпадает с реальным MAC интерфейса ВМ, а не с константой topology.env.
neigh=$($DC exec -T lb-router lbctl neigh 2>/dev/null)
for be in be1:10.20.0.2 be2:10.30.0.2 be3:10.20.0.3 be4:10.30.0.3; do
name=${be%%:*}; ip=${be#*:}
real_mac=$($DC exec -T "$name" ip -o link show eth0 2>/dev/null | sed -n 's/.*link\/ether \([0-9a-f:]*\).*/\1/p')
resolved=$(echo "$neigh" | awk -v n="$name" '$1==n{print $4}')
if [ -n "$real_mac" ] && [ "$resolved" = "$real_mac" ]; then
ok "$name: hcd резолвил реальный MAC $real_mac (не из topology.env)"
else
bad "$name: резолвлен $resolved, реальный MAC интерфейса $real_mac"
fi
done
# Таблица 21 — единственное место с MAC следующего перехода; правило члена
# должно быть приоритета 100 (заливка hcd), а не 90 (пассивный learn клиента).
# Проверка заодно закрывает регрессию force-реапплая: apply.sh уже вызывался
# в блоке 14 и стирал весь пайплайн вместе с таблицей 21 — если бы /reapply
# не восстанавливал её принудительно, этих правил здесь бы уже не было.
t21=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=21 2>/dev/null)
for ip in 10.20.0.2 10.30.0.2 10.20.0.3 10.30.0.3; do
echo "$t21" | grep -q "priority=100,ip,nw_dst=$ip " \
&& ok "таблица 21: приоритет-100 правило для $ip пережило apply.sh" \
|| bad "таблица 21: нет приоритет-100 правила для $ip"
done
echo
echo "Итог: успешно $pass, провалено $fail"
[ "$fail" -eq 0 ]