diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 146c356..b930087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,17 @@ jobs: # whitelist относительно protect.conf (#38); PSI три состояния (#37); серты в # /opt/*/certs и снятые с renew (#39); глубина журнала (#35); RPS-юнит vs факт (#30). run: bash tests/diagnose-unit.sh + - name: lint — никаких `cmd | grep -q` под pipefail (SIGPIPE → ложное «нет») + # grep -q закрывает пайп на первом совпадении; дописывающая команда ловит SIGPIPE, + # пайплайн под pipefail отдаёт 141. В v4.1.2 так через раз гас сенсор лога + # анти-скана, никогда не срабатывал cubic-warn и не виден был дубль logrotate. + run: bash tests/lint-pipefail.sh + - name: Install minisign (install unit — подписи локальных модулей) + run: sudo apt-get install -y -qq minisign || echo "minisign недоступен — секция подписей в install-unit пропустится" + - name: install unit (curl|bash НЕ исполняет scripts/ из текущей папки) + # До v4.1.3 SCRIPT_DIR брался из $0 = "bash" → текущая папка: подложенный scripts/ + # исполнялся root'ом мимо скачивания по NA_REF и проверки подписей. + run: bash tests/install-unit.sh - name: na-report читает журнал всех загрузок (-b all), не только текущей (#41) # `-k` подразумевает `-b`: после ребута отчёт «за 24ч» молча становился «с момента # загрузки» (×31 занижение на ноде флота). Оба вызова journalctl -k — с -b all. @@ -79,6 +90,20 @@ jobs: bash scripts/na-report.sh --json | grep -q '"journal_span_h":' || { echo "na-report --json без journal_span_h"; exit 1; } echo "ok: -b all в обоих вызовах, journal_span_h в json" + # ── Поведение лимитеров на НАСТОЯЩЕМ nft (netns + veth, root) ──────────────── + # Текст ruleset проверяют юнит-тесты; здесь — что полный набор не режет новых клиентов, + # записи истекают, а сам лимит работает (v4.1.3: meter без timeout копил адреса до + # ребута и на 65535 записях отсекал каждого нового клиента). + nft-behavior: + runs-on: ubuntu-latest + steps: + # SHA-пин: плавающий тег v4 может быть перенесён на другой коммит + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install nftables + run: sudo apt-get update -qq && sudo apt-get install -y -qq nftables iproute2 python3 + - name: real nft in netns (timeout у наборов, fail-open на полном наборе, rate-limit) + run: sudo bash tests/nft-behavior.sh + # ── Smoke-тесты на матрице дистрибутивов ──────────────────────────────────── # Ловит ровно тот класс регрессий, из-за которых появился этот PR: установка # XanMod на разных Ubuntu/Debian и генерация nftables-ruleset. diff --git a/CHANGELOG.md b/CHANGELOG.md index fda6fed..842b852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,142 @@ # Changelog +## v4.1.3 — 2026-10-03 + +Хотфикс по аудиту живого флота (около 30 нод на 4.0–4.1.2). Главное — тихий отказ, который +наступает просто со временем: per-IP лимитеры копили адреса до ребута и на полном наборе +начинали отсекать новых клиентов. Плюс три дефекта, из-за которых тулкит говорил неправду о +себе: файл автозагрузки правил перезаписывался до проверки, сенсоры `na-diagnose` гасли от +SIGPIPE, а `install.sh` в режиме `curl | bash` мог исполнить чужой `scripts/` из текущей папки. + +### 🔴 Лимитеры копили адреса до ребута и на полном наборе резали новых клиентов + +Каждый per-IP лимит (SYN-rate на сервисных портах, UDP-rate, SSH, ICMP, анти-скан, мягкий +лимит node-порта, лимиты режима `open`) был голым `meter … { ip saddr limit rate … } accept`. +Такой набор создаётся как `size 65535; flags dynamic` — **без timeout**, а записи с `limit` +ядро не чистит никогда (в отличие от `ct count`, у которого есть сборщик мусора). В набор +попадал каждый адрес, хоть раз пришедший на порт, и оставался там до ребута или перезагрузки +таблицы. Когда набор полон, ядро не может завести запись новому адресу: правило `… accept` +для него не срабатывает, пакет падает в `ct state new drop`. Для `ssh4` то же самое +означает, что первый же SSH-вход с нового адреса уходит в suspect, а повтор — в бан. + +Замер флота: до 22 764 адресов в одном наборе за 32 дня аптайма, рост 700–1300 адресов в +сутки на порт. До отказа — от полутора до трёх месяцев без перезагрузки, и наступил бы он +молча: старые клиенты продолжают работать, отваливаются только новые. + +Теперь каждый лимитер — объявленный набор `flags dynamic,timeout`, который `update` +продлевает, пока адрес активен. Timeout — не меньше 60s для секундных лимитов и 10m для +минутных и не меньше двух времён восстановления корзины (burst/rate): при медленном rate он +растёт сам, иначе адрес после паузы получал бы свежий burst. Правило построено «превысил → +drop, иначе accept»: если набор сервисного порта всё же переполнит спуф-флуд с десятков +тысяч адресов, лимит для новых адресов отключается (их по-прежнему держат syncookies и +`ct count`), а не режет их. Сверх-лимитные SYN уходят в цепочку `synflood_drop` (лог под +rate-limit + счётчик; лог-лимит теперь общий на все порты, а не 5/с на каждый — в +`na-report` событий synflood станет меньше), SSH сверх лимита — в `ssh_flood` с той же +логикой ban-once, что и раньше; последний `drop` в ней срабатывает, даже если сет +suspect/autoban полон. + +SSH на полном наборе **не** открывается: держать `ssh4` полным дёшево (≈110 SYN/с со +спуфнутых адресов при timeout 10m, для v6 хватает своей /64), и fail-open выключил бы защиту +от перебора. Адрес, которому не нашлось записи, проходит под общий потолок 30 новых SSH в +минуту без бана; админ из `WHITELIST` проходит выше всех лимитов, как и раньше. + +`na-diagnose` показывает заполненность наборов-лимитеров и есть ли у них timeout: на нодах, +где правила ещё от 4.1.2, это ▲, а при заполнении такого набора от 80% — ✘ с пожарной мерой +(`systemctl reload na-firewall` очищает наборы, но вместе с ними и баны). Смотрятся только +лимитеры (autoban/suspect nft тоже помечает dynamic, но timeout у них на каждой записи), а +старые meter на nft 1.0.6 — анонимные наборы — находятся по правилам. В `--json` — +`dynset_fill_max_pct`, `dynset_fill_max_set`, `dynset_no_timeout`. Лечится ре-раном +`protect` на 4.1.3. Проверки «таблица na_filter есть» и `na-fw-status` теперь читают таблицу +без содержимого наборов (`nft -t`): лимитеры держат десятки тысяч записей. + +### 🟠 Файл автозагрузки правил перезаписывался до проверки + +`protect` генерировал ruleset прямо в `/etc/node-accelerator/na_filter.nft` — в тот самый +файл, который грузит `na-firewall.service`, — и только потом звал `nft -c`. Провал проверки +печатал «ничего не применено», но битый файл уже лежал на месте рабочего, и первый же ребут +поднимал ноду без файрвола. Отказ ядра на `nft -f` был ещё хуже: скрипт падал по `set -e` с +ВЗВЕДЁННЫМ сейфти-таймером, и тот через `SAFETY_DELAY` удалял прежнюю, ничем не повреждённую +таблицу и выключал автозагрузку. + +Теперь кандидат пишется рядом (`.na_filter.nft.XXXXXX`) и становится файлом автозагрузки +только после `nft -c` **и** успешного `nft -f`; прежний файл уходит в бэкап прогона. +Отвергнутый ruleset сохраняется как `na_filter.nft.rejected`, а при отказе `nft -f` +сейфти-таймер этого прогона снимается (взведённый прошлым неподтверждённым прогоном — +остаётся). `rollback protect` и переключение на `FW_MODE=skip` убирают и эти файлы. + +### 🟠 Сенсоры `na-diagnose` гасли от SIGPIPE + +`diagnose` живёт под `set -o pipefail`, и конструкция `cmd | grep -q` там ломается: `grep -q` +закрывает пайп на первом совпадении, следующая запись `cmd` ловит SIGPIPE, и пайплайн +отдаёт 141. Условие становится ложным ровно тогда, когда совпадение есть. На флоте это давало: + +- `portscan_log_lines_boot` через раз становился `-1`: справка `journalctl` (5 КБ) уходит в + пайп двумя записями. Между соседними прогонами на одной ноде число и `-1` чередовались; +- предупреждение «часть сокетов на cubic при bbr» в `--retrans` не срабатывало никогда (вывод + `ss -tin` — мегабайты); +- дубликат путей в `logrotate -d` не виден именно тогда, когда он есть; +- в `install.sh` проверка GPG-подписи могла отвергнуть валидную подпись (статус gpg пишется + построчно). + +Все такие места переписаны (вывод в переменную, `grep -c`, `awk … END{exit !f}`). Новый линтер +`tests/lint-pipefail.sh` в CI запрещает `| grep -q` в скриптах под `pipefail`. + +### 🟠 `install.sh` в режиме `curl | bash` мог исполнить чужой `scripts/` + +`SCRIPT_DIR` вычислялся из `${BASH_SOURCE[0]:-$0}`. При `curl … | sudo bash -s` `BASH_SOURCE` +пуст, `$0` = `bash`, и каталогом скрипта становилась **текущая папка**. Если в ней был +каталог `scripts/` — старый клон тулкита или подложенный `/tmp/scripts`, — root исполнял его, +минуя и скачивание по `NA_REF`, и проверку подписей `NA_REQUIRE_SIG=1`. Теперь в +`curl | bash`-режиме модули всегда скачиваются во временный каталог, который убирается на +выходе. При запуске из файла соседний `scripts/` берётся, только если он доверенный: владелец +— root или тот, кто зовёт `sudo`, запись для группы и остальных закрыта, все модули на месте +(иначе — скачивание; так закрыт и вариант `sudo bash /tmp/install.sh` рядом с чужим +`/tmp/scripts`). С `NA_REQUIRE_SIG=1` подписи проверяются и у локальных модулей. Заодно +`--help` в `curl | bash`-режиме больше не читает файл с именем `bash` из текущей папки, а +проверка GPG-подписи требует нулевой код возврата gpg и отвергает подпись отозванным или +просроченным ключом (`VALIDSIG` gpg печатает и для таких). + +### 🟡 Мелкое + +- **`reboot_needed` вычисляется, а не читается из вечного маркера.** `optimize` пишет + `reboot_needed=1`, и раньше его никто не снимал: после ребута `--json` продолжал ждать + перезагрузки (на флоте — на каждой второй ноде, уже работающей на XanMod). Теперь в маркере + есть `boot_id`, и флаг истинен, только пока загрузка та же; для старых маркеров сравнивается + время загрузки с `installed_at`. В текстовом отчёте появилась строка ▲ об ожидании ребута. +- **Числа — с точкой при любой локали.** На ноде с `LANG=de_DE.UTF-8` `load1` в `--json` + становился `0`, а `na-report --json` печатал `0,99`. Теперь `LC_NUMERIC=C` и + `LC_MESSAGES=C` (вывод утилит разбирается, а не переводится); чужой `LC_ALL` переносится в + `LANG`, чтобы кодировка вывода не менялась, а явный `LC_ALL=C`/`C.UTF-8` остаётся как есть. + +### 🧪 Тесты + +- `tests/nft-behavior.sh` (новый CI-job `nft-behavior`): **настоящий** nft в двух netns. + Контроль: старая схема `meter` без timeout на полном наборе режет новых клиентов. Ruleset + 4.1.3 встаёт поверх живой таблицы со старыми meter. Наборы объявлены с timeout; лимит + действует, счётчик `synflood_drop` растёт; записи истекают; полный набор не режет новых — + v4 и v6; перебор SSH приводит к бану (ban-once), а полный `ssh4` — к общему потолку, а не + к открытому порту. +- `tests/apply-smoke.sh`: наборы-лимитеры с timeout и схема «превысил → drop»; файл + автозагрузки не подменяется при отказе `nft -c` и `nft -f`; при отказе `nft -f` сейфти снят; + прежний ruleset попадает в бэкап. +- `tests/diagnose-unit.sh`: справка `journalctl` и `logrotate -d` больше буфера пайпа + (регрессия SIGPIPE); заполненность наборов-лимитеров; `reboot_needed` по `boot_id` и `btime`. + Попутно исправлена песочница теста: `/proc/sys/...` уводился в несуществующий путь. +- `tests/install-unit.sh`: `curl | bash` из папки с подложенным `scripts/` его не исполняет; + `scripts/` с записью для группы не берётся; `NA_REQUIRE_SIG=1` проверяет и локальные модули. +- `tests/lint-pipefail.sh`: нет `| grep -q` под `pipefail`. + +Все новые проверки падают на v4.1.2 и проходят на v4.1.3. Сгенерированные ruleset (strict, +полный набор модулей, без ban-once, open) проверены `nft -c` на nft 1.0.6 (Debian 12) и +nft 1.1.3 (Debian 13), на ядрах XanMod 6.18 и стоковом 6.12. + +### Миграция + +Ре-ран `protect` на 4.1.3 пересоздаёт `na_filter` с новыми наборами; накопленные адреса +лимитеров, а вместе с ними autoban и suspect, при этом обнуляются (как и при любом +ре-ране). Конфиг `protect.conf` не меняется, новых ручек для оператора нет (`NA_RATE_SET_SIZE`, +`NA_RATE_TO_SEC`, `NA_RATE_TO_MIN` — для тестов на настоящем nft; 0 у timeout отвергается). + ## v4.1.2 — 2026-09-06 Два дефекта ре-рана, из-за которых состояние ноды расходилось с тем, что печатал установщик diff --git a/README.md b/README.md index 0264488..1444f62 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ - **flag-drop** — XMAS, NULL, SYN+FIN, SYN+RST, FIN+RST и прочие скан-пакеты. - **anti-spoofing** — bogon/RFC1918/CGNAT источники на WAN (v4 **и** v6 bogon). - **SYN-flood / UDP-flood** — **per-IP** rate-limit (масштабируется по числу клиентов, а не глобальный потолок). + Состояние лимитов — в наборах с `timeout` (запись живёт, пока адрес активен), правило «превысил → drop»: + переполненный спуф-флудом набор отключает лимит для новых адресов, а не отрезает их. - **connect-flood SSH** — >6 новых/мин с IP → бан (с ban-once). - **per-IP connlimit** (`ct count`) — кап одновременных коннектов с одного адреса. - **node-agent порт** — **автодетект с ноды** (`NODE_PORT=auto`, дефолт: env контейнера @@ -86,7 +88,7 @@ - **Персист конфига** — ре-ран без ENV не сбрасывает поднятые под ноду ручки. ### 🩺 Диагностика (`scripts/diagnose.sh`) -Read-only отчёт: ядро/BBR, sysctl, лимиты, conntrack, NIC/RPS, swap/THP/governor, firewall, CrowdSec, порты, RTT — с итогом ✔/▲/✘ и рекомендациями. Плюс сенсоры **стека ноды**: контейнер `remnanode` (статус/рестарты/`SPAWN_ERROR` за час — ловит коллизию node-address в панели), **рассинхрон порта node-агента с файрволом** (агент слушает `:3000`, а strict-правила держат `:2222` → «нода недоступна» для панели; в отчёте — с командой пожарного фикса, в `--json` — `node_port_detected`/`node_port_fw`), **сроки TLS-сертификатов** (LE/acme.sh/`/opt/*/certs`; серты, снятые с renew в acme.sh, не считаются; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`), **whitelist в трёх местах** (живой сет vs `na_filter.nft` vs `protect.conf`: адрес, который переживёт ребут, но не ре-ран `protect`, называется по имени), **датчик `CONN_LIMIT` по тому же срезу, что и правило** (только входящие на сервисные порты, без loopback/whitelist — раньше на ноде с внутренним nginx он горел всегда), **PSI** (различает «не собран», «выключен в сборке ядра» и «работает»), **ретеншен journald в часах**, **замороженные дефолты в сохранённом конфиге**, **свежесть fleet-sync/blocklist**, IPv6 default-route, UDP `RcvbufErrors` (QUIC/Hysteria2). После установки доступна как команда **`na-diagnose`** (`--json` для мониторинга/панели — теперь с `na_version`, `hostname`, `uptime_s`, `load1`, `mem_used_pct`, WAN rx/tx-байтами; `--retrans [--window N]` — разбор причин TCP-retransmits). +Read-only отчёт: ядро/BBR, sysctl, лимиты, conntrack, NIC/RPS, swap/THP/governor, firewall, CrowdSec, порты, RTT — с итогом ✔/▲/✘ и рекомендациями. Плюс сенсоры **стека ноды**: контейнер `remnanode` (статус/рестарты/`SPAWN_ERROR` за час — ловит коллизию node-address в панели), **рассинхрон порта node-агента с файрволом** (агент слушает `:3000`, а strict-правила держат `:2222` → «нода недоступна» для панели; в отчёте — с командой пожарного фикса, в `--json` — `node_port_detected`/`node_port_fw`), **сроки TLS-сертификатов** (LE/acme.sh/`/opt/*/certs`; серты, снятые с renew в acme.sh, не считаются; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`), **whitelist в трёх местах** (живой сет vs `na_filter.nft` vs `protect.conf`: адрес, который переживёт ребут, но не ре-ран `protect`, называется по имени), **датчик `CONN_LIMIT` по тому же срезу, что и правило** (только входящие на сервисные порты, без loopback/whitelist — раньше на ноде с внутренним nginx он горел всегда), **PSI** (различает «не собран», «выключен в сборке ядра» и «работает»), **ретеншен journald в часах**, **замороженные дефолты в сохранённом конфиге**, **свежесть fleet-sync/blocklist**, IPv6 default-route, UDP `RcvbufErrors` (QUIC/Hysteria2), **заполненность наборов-лимитеров** (и есть ли у них timeout), **ожидание перезагрузки по факту загрузки** (`reboot_needed` сверяется с `boot_id`, а не верит вечному маркеру). После установки доступна как команда **`na-diagnose`** (`--json` для мониторинга/панели — теперь с `na_version`, `hostname`, `uptime_s`, `load1`, `mem_used_pct`, WAN rx/tx-байтами; `--retrans [--window N]` — разбор причин TCP-retransmits). ### 🔥 Форензика атак (`scripts/na-report.sh`) Read-only: кто/откуда/чем/когда — из журнала ядра, nft-сетов и CrowdSec. **`na-report`** (человекочитаемо) или **`na-report --json`**: `drops_by_reason`, `timeline`, `top_ips` с вердиктом, `top_asn` (ASN/гео — best-effort через Team Cymru whois). Флаги: `--hours N`, `--top N`, `--ip `. Журнал читается по всем загрузкам (`-b all`), а не с момента ребута; в шапке — фактическая глубина журнала (`journal_span_h`), чтобы «событий мало» не путать с «журнал вытеснен». @@ -121,11 +123,11 @@ curl -fsSL https://raw.githubusercontent.com/jestivald/node-accelerator/main/ins # прод-режим: пиньте тег через NA_REF — компрометация ветки main тогда не утечёт # сразу на весь флот (скрипты тянутся из того же тега): -export NA_REF=v4.1.2 +export NA_REF=v4.1.3 curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all # максимум: + проверка minisign-подписей модулей (подписи лежат в дереве с v3.6): -export NA_REF=v4.1.2 NA_REQUIRE_SIG=1 \ +export NA_REF=v4.1.3 NA_REQUIRE_SIG=1 \ NA_MINISIGN_PUBKEY="RWQrJghT9nkdBC3ntiEXF29zrS8o429WhObHKq6I7CKoftVDhQBrBscu" curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all ``` @@ -289,10 +291,16 @@ sudo bash install.sh rollback optimize # снять тюнинг (XanMod ос показывает крупные файлы в /var/log, json-логи контейнеров и то, активен ли часовой таймер ротации (в `--json`: `disk_pct`, `inode_pct`, `log_max_bytes`, `docker_log_max_bytes`, `logrotate_timer`). - **autoban с `size`-капом** — спуф-флудом чистого SYN нельзя раздуть set в памяти ядра. +- **Лимитеры с `timeout`, а не вечные `meter`** — до v4.1.3 каждый адрес, хоть раз пришедший на порт, + оставался в наборе до ребута; на 65 535 записях новые клиенты молча отсекались. Теперь записи истекают, + а `na-diagnose` показывает заполненность (`dynset_fill_max_pct`, `dynset_no_timeout` в `--json`). +- **Файл автозагрузки меняется последним** — ruleset сначала проходит `nft -c` и `nft -f`, и только потом + становится `na_filter.nft`; отвергнутый лежит рядом как `na_filter.nft.rejected`. - **CGNAT-дружелюбность** — portscan-бан срабатывает по порогу скорости скана (а не по одному SYN, который за CGNAT банил весь оператор); ICMP-лимит per-IP, а не глобальный; `CONN_LIMIT` с большим запасом. Датчик `макс конн/IP vs CONN_LIMIT` в 🩺 диагностике показывает, душит ли лимит на самом деле. - **conntrack-ёмкость от RAM** — мелкая VPS под флудом не упирается в OOM ядра. - **Ключ XanMod по полному отпечатку** + keyserver-фоллбэк при CF-403 (Hetzner/GCP) + поддержка Ubuntu 22.04 (jammy→bookworm). -- **CI** — `shellcheck` + smoke-матрица (Debian/Ubuntu): генерация nftables и резолв XanMod проверяются на каждый PR. +- **CI** — `shellcheck` + smoke-матрица (Debian/Ubuntu): генерация nftables и резолв XanMod проверяются на каждый PR; + поведение лимитеров — на настоящем nft в netns (`tests/nft-behavior.sh`), запрет `| grep -q` под `pipefail` — линтером. --- diff --git a/install.sh b/install.sh index f9aba25..dafdbff 100755 --- a/install.sh +++ b/install.sh @@ -13,7 +13,7 @@ # curl-bash: # curl -fsSL https://raw.githubusercontent.com/jestivald/node-accelerator/main/install.sh | sudo bash -s all # # прод: пиньте тег (компрометация ветки main тогда не утечёт сразу на весь флот): -# export NA_REF=v4.1.2; curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all +# export NA_REF=v4.1.3; curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all # # + подписи модулей: NA_REQUIRE_SIG=1 NA_MINISIGN_PUBKEY=<ключ из README> # # ENV: NA_REF=<ветка|тег> NA_REPO_URL=<база raw-URL, для форка/зеркала> @@ -23,10 +23,36 @@ # (стабильный JSON для мониторинга/панели — без повторного curl|bash). Снимается rollback'ом. set -euo pipefail -# ${BASH_SOURCE[0]:-$0}: при запуске через curl|bash (bash -s) BASH_SOURCE пуст, и под -# set -u голый ${BASH_SOURCE[0]} даёт «unbound variable». Фоллбэк на $0 убирает шум. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -SCRIPTS="$SCRIPT_DIR/scripts" +# Модули рядом с собой берём ТОЛЬКО когда install.sh запущен из файла И каталог scripts/ +# рядом доверенный. При curl|bash (`bash -s`) BASH_SOURCE пуст, а $0 = "bash" — до v4.1.3 +# тут был фоллбэк на $0, то есть каталог скрипта = ТЕКУЩАЯ папка: лежит в ней scripts/ +# (старый клон тулкита, подложенный /tmp/scripts) — и root исполнял его, минуя скачивание +# по NA_REF И проверку подписей NA_REQUIRE_SIG=1. Из файла — та же ловушка в общей папке +# (`curl -o /tmp/install.sh && sudo bash /tmp/install.sh` рядом с чужим /tmp/scripts), +# поэтому соседние модули доверенные, только если их владелец — root или тот, кто зовёт +# (SUDO_UID), запись группе/остальным закрыта и все модули на месте. Иначе — скачиваем. +NA_SELF="${BASH_SOURCE[0]:-}" +NA_MODULES="lib/common.sh optimize.sh protect.sh diagnose.sh na-report.sh rollback.sh" +SCRIPTS="" +_na_trusted() { # _na_trusted <путь> — владелец root/вызывающий, без записи для группы/остальных + local l mode uid + l="$(ls -ldn "$1" 2>/dev/null)" || return 1 + mode="${l%% *}"; uid="$(awk '{print $3}' <<<"$l")" + [[ "${mode:5:1}" != w && "${mode:8:1}" != w ]] || return 1 + [[ "$uid" == 0 || "$uid" == "$(id -u)" || ( -n "${SUDO_UID:-}" && "$uid" == "$SUDO_UID" ) ]] +} +if [[ -n "$NA_SELF" && -f "$NA_SELF" ]]; then + _cand="$(cd "$(dirname "$NA_SELF")" && pwd)/scripts" + _ok=1 + { _na_trusted "$_cand" && _na_trusted "$_cand/lib"; } || _ok=0 + for _m in $NA_MODULES; do { [[ -f "$_cand/$_m" ]] && _na_trusted "$_cand/$_m"; } || _ok=0; done + if [[ "$_ok" == 1 ]]; then + SCRIPTS="$_cand" + elif [[ -d "$_cand" ]]; then + echo "[!] $_cand не доверенный (чужой владелец, запись для группы/остальных или не все модули) — модули скачаю по NA_REF" + fi + unset _cand _ok _m +fi # NA_REF — ветка/тег для curl|bash-режима (по умолчанию main). Для прода пиньте тег. NA_REF="${NA_REF:-main}" # NA_REF уходит в URL модулей — запрещаем path-traversal/инъекцию (увод на чужой репо). @@ -37,8 +63,8 @@ REPO_URL="${NA_REPO_URL:-https://raw.githubusercontent.com/jestivald/node-accele # с ошибкой вместо вывода помощи, поэтому есть встроенный фоллбэк. Обрабатываем ДО # скачивания модулей и require_root: за справкой не должно требоваться ни root, ни сеть. usage() { - if [[ -f "$0" ]]; then - sed -n '2,24p' "$0" + if [[ -n "$NA_SELF" && -f "$NA_SELF" ]]; then + sed -n '2,24p' "$NA_SELF" else cat <<'U' node-accelerator — ⚡ оптимизатор + 🩺 диагностика + 🛡 защита Remnawave/VPN-ноды. @@ -68,30 +94,46 @@ NA_SIG_FINGERPRINT="${NA_SIG_FINGERPRINT:-}" # Скачиваем строго по https и НЕ разрешаем редиректу увести на cleartext http: # по умолчанию curl такой даунгрейд-редирект молча выполняет. CURL_PROTO=(--proto '=https' --proto-redir '=https') -verify_sig() { # verify_sig <файл> - local file="$1" url="$2" +verify_sig() { # verify_sig <файл> [url-без-расширения] — без url: подпись лежит рядом с файлом + local file="$1" url="${2:-}" if [[ -n "$NA_MINISIGN_PUBKEY" ]] && command -v minisign >/dev/null 2>&1; then - curl -fsSL "${CURL_PROTO[@]}" "$url.minisig" -o "$file.minisig" 2>/dev/null || { echo "[x] нет .minisig для $(basename "$file")"; return 1; } + if [[ -n "$url" ]]; then + curl -fsSL "${CURL_PROTO[@]}" "$url.minisig" -o "$file.minisig" 2>/dev/null || { echo "[x] нет .minisig для $(basename "$file")"; return 1; } + fi + [[ -f "$file.minisig" ]] || { echo "[x] нет .minisig для $(basename "$file")"; return 1; } minisign -V -P "$NA_MINISIGN_PUBKEY" -m "$file" >/dev/null 2>&1 elif [[ -n "$NA_SIG_FINGERPRINT" ]] && command -v gpg >/dev/null 2>&1; then - curl -fsSL "${CURL_PROTO[@]}" "$url.asc" -o "$file.asc" 2>/dev/null || { echo "[x] нет .asc для $(basename "$file")"; return 1; } + if [[ -n "$url" ]]; then + curl -fsSL "${CURL_PROTO[@]}" "$url.asc" -o "$file.asc" 2>/dev/null || { echo "[x] нет .asc для $(basename "$file")"; return 1; } + fi + [[ -f "$file.asc" ]] || { echo "[x] нет .asc для $(basename "$file")"; return 1; } # Судим по МАШИННОМУ статусу (--status-fd), а НЕ по человекочитаемому выводу: # `gpg --verify | grep ` матчил бы 'using RSA key ' — эта строка # печатается из пакета подписи ДАЖЕ для BAD-подписи, а exit-код терялся в пайпе # (валидная .asc настоящего ключа рядом с подменённым файлом проходила бы как ок). # VALIDSIG эмитится ТОЛЬКО для криптографически валидной подписи и несёт полный fpr. - gpg --status-fd=1 --verify "$file.asc" "$file" 2>/dev/null \ - | grep -Eq "^\[GNUPG:\] VALIDSIG ${NA_SIG_FINGERPRINT// /}( |\$)" + # Вывод сначала целиком в переменную: gpg пишет статус построчно, и `| grep -q` + # под pipefail закрывал пайп на VALIDSIG — следующая строка статуса ловила SIGPIPE, + # и валидная подпись отвергалась (141 вместо 0). Код возврата gpg при этом обязан + # быть 0, а VALIDSIG сам по себе не достаточен: gpg печатает его и для подписи + # отозванным/просроченным ключом — такие статусы отвергаем явно. + local st rc=0 + st="$(gpg --status-fd=1 --verify "$file.asc" "$file" 2>/dev/null)" || rc=$? + [[ "$rc" -eq 0 ]] || return 1 + grep -Eq '^\[GNUPG:\] (REVKEYSIG|EXPKEYSIG|EXPSIG|BADSIG|ERRSIG)( |$)' <<<"$st" && return 1 + grep -Eq "^\[GNUPG:\] VALIDSIG ${NA_SIG_FINGERPRINT// /}( |\$)" <<<"$st" else echo "[x] NA_REQUIRE_SIG=1, но нет minisign+NA_MINISIGN_PUBKEY или gpg+NA_SIG_FINGERPRINT"; return 1 fi } -# curl|bash — подтянуть модули -if [[ ! -d "$SCRIPTS" ]]; then - SCRIPTS="$(mktemp -d)/scripts"; mkdir -p "$SCRIPTS/lib" +# curl|bash — подтянуть модули (во временный каталог, который убираем на выходе) +if [[ -z "$SCRIPTS" || ! -d "$SCRIPTS" ]]; then + NA_TMPD="$(mktemp -d)" + trap 'rm -rf "$NA_TMPD"' EXIT + SCRIPTS="$NA_TMPD/scripts"; mkdir -p "$SCRIPTS/lib" echo "[*] Скачиваю модули из $REPO_URL ..." - for f in lib/common.sh optimize.sh protect.sh diagnose.sh na-report.sh rollback.sh; do + for f in $NA_MODULES; do curl -fsSL "${CURL_PROTO[@]}" "$REPO_URL/scripts/$f" -o "$SCRIPTS/$f" || { echo "[x] Не скачал $f"; exit 1; } if [[ "$NA_REQUIRE_SIG" == "1" ]]; then verify_sig "$SCRIPTS/$f" "$REPO_URL/scripts/$f" \ @@ -101,6 +143,15 @@ if [[ ! -d "$SCRIPTS" ]]; then done fi +# Локальные модули под NA_REQUIRE_SIG=1 проверяются так же, как скачанные (подписи в дереве). +if [[ -z "${NA_TMPD:-}" && "$NA_REQUIRE_SIG" == "1" ]]; then + for f in $NA_MODULES; do + verify_sig "$SCRIPTS/$f" \ + && echo "[+] подпись $f валидна (локальный модуль)" \ + || { echo "[x] подпись локального $f НЕ прошла — отказ (NA_REQUIRE_SIG=1)"; exit 1; } + done +fi + # shellcheck source=scripts/lib/common.sh . "$SCRIPTS/lib/common.sh" require_root diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index 6ac66a4..4da7e27 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -159,7 +159,13 @@ portscan_log_lines() { command -v journalctl >/dev/null 2>&1 || return 1 # `-g` (grep по журналу) есть не во всех сборках systemd — спрашиваем help, а не # пробный запуск: пустой результат пробы неотличим от «опция не поддержана». - _to 5 journalctl --help 2>/dev/null | grep -q -- '--grep' || return 1 + # Не `journalctl --help | grep -q`: под pipefail grep -q закрывает пайп на первом + # совпадении, а справка (5 КБ) уходит в него двумя записями — вторая ловит SIGPIPE, + # пайплайн отдаёт 141, и сенсор через раз молча становится «-1» (аудит флота: на + # 8 нодах из 8 проверенных, -1 и число чередовались между соседними прогонами). + local help + help="$(_to 5 journalctl --help 2>/dev/null)" + [[ "$help" == *--grep* ]] || return 1 n="$(_to 15 journalctl -b -k -g '\[na portscan\]' --no-pager -q 2>/dev/null | wc -l | tr -d ' ')"; rc=$? # pipefail: timeout, убивший journalctl, отдаёт 124 — счёт неполный, врать не будем [[ "$rc" -eq 124 ]] && return 1 @@ -186,7 +192,67 @@ NA_CERT_GLOBS='/etc/letsencrypt/live/*/fullchain.pem /root/.acme.sh/*/fullchain. # tls_listening — на :443/:8443 кто-то слушает? Если сертов при этом не нашли, сенсор # не «чист», а слеп — это warn, а не info (issue #39). tls_listening() { - ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE '[:.](443|8443)$' + # awk дочитывает вход до конца (не grep -q: SIGPIPE под pipefail, см. portscan_log_lines) + ss -tlnH 2>/dev/null | awk '$4 ~ /[:.](443|8443)$/{f=1} END{exit !f}' +} + +# reboot_pending — optimize поставил ядро/параметры загрузки, а перезагрузки с тех пор не +# было. Маркер `reboot_needed=1` пишет optimize, и никто его не снимает: после ребута JSON +# продолжал отдавать true (аудит флота: 9 нод уже на XanMod, а мониторинг ждал ребута). +# Сверяем с ТЕКУЩЕЙ загрузкой: boot_id из маркера (v4.1.3+) или, для старых маркеров, +# время загрузки (btime) против installed_at. Не разобрать — как раньше, верим маркеру. +reboot_pending() { + local f="$STATE_DIR/optimize.installed" bid cur inst inst_s bt + [[ -f "$f" ]] || return 1 + grep -q '^reboot_needed=1' "$f" 2>/dev/null || return 1 + bid="$(awk -F= '/^boot_id=/{print $2; exit}' "$f" 2>/dev/null)" + cur="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null)" + if [[ -n "$bid" && -n "$cur" ]]; then [[ "$bid" == "$cur" ]]; return; fi + inst="$(awk -F= '/^installed_at=/{print $2; exit}' "$f" 2>/dev/null)" + [[ -n "$inst" ]] || return 0 # `date -d ""` = сегодняшняя полночь — не дата установки + inst_s="$(date -d "$inst" +%s 2>/dev/null)" || return 0 + bt="$(awk '/^btime /{print $2; exit}' /proc/stat 2>/dev/null)" + [[ "$inst_s" =~ ^[0-9]+$ && "$bt" =~ ^[0-9]+$ ]] || return 0 + (( bt < inst_s )) +} + +# dynset_fill — заполненность наборов-лимитеров na_filter (SYN/UDP/SSH/ICMP/анти-скан/ +# node-port/open). До v4.1.3 они создавались `meter` без timeout: записи не истекали, и на +# полном наборе (65535) новые клиенты на порт отсекались (аудит флота: 22 764 адреса за +# 32 дня). Берём ТОЛЬКО лимитеры по имени: nft помечает dynamic и сеты autoban/suspect +# (в них пишет правило), а timeout у тех — на каждой записи, не в объявлении. Старые +# meter на nft 1.0.6 — анонимные: в `-t list table` они есть только внутри правил +# (`meter NAME size N {`), содержимое отдаёт `nft list meter`. +# stdout: «имяэлементовsizetimeout(1|0)»; набор, который не уложился в +# таймаут (флуд — десятки тысяч записей), пропускается. rc=1 — таблицы нет. +NA_LIMITER_RE='^((syn|udp|osyn|oudp)[46](_[0-9]+)?|ssh[46]|icmp[46]|na[46]|psc?[46])$' +_dyn_count() { # _dyn_count set|meter <имя> → число записей; rc≠0 — не измерено + local out + out="$(_to 5 nft list "$1" inet na_filter "$2" 2>/dev/null)" || return 1 + printf '%s\n' "$out" | sed -n '/elements = {/,/^[[:space:]]*}/p' \ + | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]+)?|[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){2,7}(/[0-9]+)?' \ + | sort -u | wc -l | tr -d ' ' +} +dynset_fill() { + local terse kind name size to n + terse="$(_to 10 nft -t list table inet na_filter 2>/dev/null)" || return 1 + [[ -n "$terse" ]] || return 1 + while IFS=$'\t' read -r kind name size to; do + [[ "$name" =~ $NA_LIMITER_RE && "$size" =~ ^[0-9]+$ ]] && (( size > 0 )) || continue + n="$(_dyn_count "$kind" "$name")" && [[ "$n" =~ ^[0-9]+$ ]] || continue + printf '%s\t%s\t%s\t%s\n' "$name" "$n" "$size" "$to" + done < <(awk ' + /^[[:space:]]*set [A-Za-z0-9_]+ \{/ { name=$2; size=0; to=0; inset=1; next } + inset && /^[[:space:]]*size [0-9]+/ { size=$2 } + inset && /^[[:space:]]*timeout / { to=1 } + inset && /^[[:space:]]*}/ { print "set\t" name "\t" size "\t" to; seen[name]=1; inset=0; next } + { while (match($0, /meter [A-Za-z0-9_]+ size [0-9]+/)) { + split(substr($0, RSTART, RLENGTH), m, " ") + if (!(m[2] in meters)) meters[m[2]] = m[4] + $0 = substr($0, RSTART + RLENGTH) + } } + END { for (k in meters) if (!(k in seen)) print "meter\t" k "\t" meters[k] "\t0" } + ' <<<"$terse") } # ─── JSON-режим (для флот-мониторинга: Zabbix/Prometheus/SSH-поллинг) ───────── @@ -198,7 +264,8 @@ emit_json() { local nav host up load1 mempct wi wanrx wantx ip6def udperr local rnst rnrc rnse fsa bla certd certf nowsec cf cend cends cd npd npfw local mcpi jsp psl psist wdc csd tcpp wl_live wl_conf tmpv - kern="$(uname -r)"; uname -r | grep -qi xanmod && xanmod=true || xanmod=false + local dsf dsfn dsnt _dn _dc _ds _dt + kern="$(uname -r)"; [[ "${kern,,}" == *xanmod* ]] && xanmod=true || xanmod=false virt="$(detect_virt)" cc="$(val net.ipv4.tcp_congestion_control)"; qd="$(val net.core.default_qdisc)" ctmax="$(val net.netfilter.nf_conntrack_max)"; ctmax="${ctmax:-0}" @@ -222,7 +289,7 @@ emit_json() { eval "$(awk '/^Tcp:/{ if(!h){for(i=2;i<=NF;i++)nm[i]=$i;h=1;next} for(i=2;i<=NF;i++){if(nm[i]=="OutSegs")print "out="$i;if(nm[i]=="RetransSegs")print "rtx="$i} }' /proc/net/snmp 2>/dev/null)" out="${out:-0}"; rtx="${rtx:-0}"; [[ "$out" -gt 0 ]] && rtxpct=$(( rtx * 100 / out )) fi - nft list table inet na_filter >/dev/null 2>&1 && fw=true || fw=false + nft -t list table inet na_filter >/dev/null 2>&1 && fw=true || fw=false # режим файрвола из маркера protect (strict|open|skip; пусто = protect не гонялся # или старая версия без fw_mode) — панель отличает осознанный skip/open от «защиты нет» fwm="$(awk -F= '/^fw_mode=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)"; fwm="${fwm:-}" @@ -246,7 +313,7 @@ emit_json() { { systemctl is-active --quiet na-fw-safety.timer 2>/dev/null \ || { [[ -f "$STATE_DIR/na-fw-safety.pid" ]] && kill -0 "$(cat "$STATE_DIR/na-fw-safety.pid" 2>/dev/null)" 2>/dev/null; }; } \ && safety=true || safety=false - rebootn=false; grep -q '^reboot_needed=1' "$STATE_DIR/optimize.installed" 2>/dev/null && rebootn=true + rebootn=false; reboot_pending && rebootn=true # ── na-panel extras: identity, нагрузка, WAN-счётчики, стек ноды, свежесть, серты ── nowsec="$(date +%s)" @@ -262,7 +329,7 @@ emit_json() { wantx="$(cat "/sys/class/net/$wi/statistics/tx_bytes" 2>/dev/null || echo 0)" fi [[ "$wanrx" =~ ^[0-9]+$ ]] || wanrx=0; [[ "$wantx" =~ ^[0-9]+$ ]] || wantx=0 - ip -6 route show default 2>/dev/null | grep -q . && ip6def=true || ip6def=false + [[ -n "$(ip -6 route show default 2>/dev/null)" ]] && ip6def=true || ip6def=false udperr="$(awk '/^Udp:/{if(!h){for(i=2;i<=NF;i++)n[i]=$i;h=1;next} for(i=2;i<=NF;i++) if(n[i]=="RcvbufErrors") print $i}' /proc/net/snmp 2>/dev/null)" udperr="${udperr:-0}"; [[ "$udperr" =~ ^[0-9]+$ ]] || udperr=0 # remnanode (Remnawave node-контейнер) — статус/рестарты/SPAWN_ERROR за час. Read-only. @@ -351,6 +418,16 @@ emit_json() { lrc="$(cut -f1 "$STATE_DIR/logrotate.ceded" 2>/dev/null | paste -sd' ' - || true)"; lrc="${lrc:-}" lrcn="$(awk -F'\t' '$3=="none"' "$STATE_DIR/logrotate.ceded" 2>/dev/null | grep -c .)"; [[ "$lrcn" =~ ^[0-9]+$ ]] || lrcn=0 lro="$(json_escape "$lro")"; lrc="$(json_escape "$lrc")" + # v4.1.3: заполненность наборов-лимитеров (−1 = таблицы нет) и сколько из них без + # timeout (наследие ≤4.1.2: такие копят адреса до ребута и на 100% режут новых) + dsf=-1; dsfn=""; dsnt=0 + while IFS=$'\t' read -r _dn _dc _ds _dt; do + [[ -n "$_dn" ]] || continue + [[ "$dsf" -lt 0 ]] && dsf=0 + (( _dc * 100 / _ds > dsf )) && { dsf=$(( _dc * 100 / _ds )); dsfn="$_dn"; } + [[ "$_dt" == "0" ]] && dsnt=$((dsnt+1)) + done < <(dynset_fill 2>/dev/null) + dsfn="$(json_escape "$dsfn")" # Каждое строковое значение — через json_escape. Раньше каждое поле полагалось на то, # что источник «и так чистый», и одного сырого перевода строки из docker хватило, @@ -383,8 +460,10 @@ emit_json() { "$mcpi" "$jsp" "$psl" "$psist" printf '"whitelist_drift_conf":%s,"conf_stale_defaults":%s,"cert_min_file":"%s",' \ "$wdc" "$csd" "$certf" - printf '"logrotate_owned_masks":"%s","logrotate_ceded_masks":"%s","logrotate_ceded_nocap":%s}\n' \ + printf '"logrotate_owned_masks":"%s","logrotate_ceded_masks":"%s","logrotate_ceded_nocap":%s,' \ "$lro" "$lrc" "$lrcn" + printf '"dynset_fill_max_pct":%s,"dynset_fill_max_set":"%s","dynset_no_timeout":%s}\n' \ + "$dsf" "$dsfn" "$dsnt" } if [[ "${1:-}" == "--json" ]]; then emit_json; exit 0; fi @@ -469,7 +548,9 @@ I if command -v ss >/dev/null 2>&1; then local ccdist; ccdist="$(ss -tin state established 2>/dev/null | grep -oE ' (bbr|cubic|reno|htcp|vegas|dctcp) ' | sort | uniq -c | sort -rn | awk '{printf "%s:%s ",$2,$1}')" [[ -n "$ccdist" ]] && info "на сокетах: $ccdist" || info "на сокетах: (нет ESTAB или старый ss)" - if ss -tin state established 2>/dev/null | grep -qE ' cubic ' && [[ "$(val net.ipv4.tcp_congestion_control)" == "bbr" ]]; then + # счёт, а не grep -q: на тысячах сокетов grep -q закрывал пайп раньше ss → SIGPIPE → + # под pipefail условие было ложным ВСЕГДА, и предупреждение не срабатывало никогда + if [[ "$(ss -tin state established 2>/dev/null | grep -cE ' cubic ')" -gt 0 ]] && [[ "$(val net.ipv4.tcp_congestion_control)" == "bbr" ]]; then warn "часть сокетов на cubic при cc=bbr — это коннекты ДО смены CC (или приложение задаёт своё)" fi fi @@ -559,8 +640,9 @@ unset _cf _k _old _new _ver _why # ─── Ядро / BBR ────────────────────────────────────────────────────────────── title "Ядро и congestion control" -if uname -r | grep -qi xanmod; then - pass "XanMod-ядро активно ($(uname -r)) → BBRv3 доступен" +KREL="$(uname -r)" +if [[ "${KREL,,}" == *xanmod* ]]; then + pass "XanMod-ядро активно ($KREL) → BBRv3 доступен" else if can_install_kernel; then wrn "Ядро не XanMod — BBRv3 нет. Поставь оптимизатор (XanMod), будет +скорость." @@ -572,7 +654,7 @@ else fi CC="$(val net.ipv4.tcp_congestion_control)" AVAIL="$(val net.ipv4.tcp_available_congestion_control)" -[[ "$CC" == "bbr" ]] && pass "congestion_control = bbr$(uname -r | grep -qi xanmod && echo ' (BBRv3)')" \ +[[ "$CC" == "bbr" ]] && pass "congestion_control = bbr$([[ "${KREL,,}" == *xanmod* ]] && echo ' (BBRv3)')" \ || wrn "congestion_control = ${CC:-?} (ожидалось bbr). Доступно: ${AVAIL:-?}" QD="$(val net.core.default_qdisc)" [[ "$QD" == "fq" || "$QD" == "fq_codel" || "$QD" == "cake" ]] && pass "default_qdisc = $QD" \ @@ -581,6 +663,9 @@ if [[ "$(arch)" == "x86_64" ]]; then LVL="$(cpu_psabi_level)" info "CPU psABI: поддерживает до x86-64-v${LVL} (выбор сборки XanMod)" fi +if reboot_pending; then + wrn "optimize поставил ядро/параметры загрузки, а перезагрузки с тех пор не было — reboot (XanMod/BBRv3, psi=1 включатся после него)" +fi # Реальность поверх sysctl: сколько живых TCP-сокетов реально на BBR + доля ретрансмитов. BBRN="$(ss -tin 2>/dev/null | grep -c bbr || true)" [[ "${BBRN:-0}" -gt 0 ]] && info "Живых TCP-сокетов на BBR сейчас: $BBRN" @@ -725,7 +810,7 @@ fi # ─── Память / прочее ───────────────────────────────────────────────────────── title "Память, swap, THP, governor" -if swapon --show 2>/dev/null | grep -q .; then pass "swap: $(swapon --show=NAME,SIZE --noheadings 2>/dev/null | tr '\n' ' ')"; else wrn "swap отсутствует"; fi +if [[ -n "$(swapon --show 2>/dev/null)" ]]; then pass "swap: $(swapon --show=NAME,SIZE --noheadings 2>/dev/null | tr '\n' ' ')"; else wrn "swap отсутствует"; fi THP="$(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null | grep -oE '\[.*\]' | tr -d '[]')" [[ "$THP" == "never" ]] && pass "THP = never" || wrn "THP = ${THP:-?} (для сетевых нагрузок лучше never)" GOV="$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null)" @@ -735,7 +820,7 @@ systemctl is-active --quiet irqbalance 2>/dev/null && pass "irqbalance акти # ─── Firewall / защита ─────────────────────────────────────────────────────── title "Firewall и защита" FW_MODE_INST="$(awk -F= '/^fw_mode=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" -if nft list table inet na_filter >/dev/null 2>&1; then +if nft -t list table inet na_filter >/dev/null 2>&1; then if [[ "$FW_MODE_INST" == "open" ]]; then pass "nftables na_filter активна (FW_MODE=open: лимиты/баны есть, не перечисленные порты открыты)" else @@ -819,6 +904,32 @@ if nft list table inet na_filter >/dev/null 2>&1; then fi fi fi + # Наборы per-IP лимитеров. До v4.1.3 — без timeout: адреса копятся до ребута, и на + # полном наборе новые клиенты на порт отсекаются (для ssh4 — новый IP админа в бан). + # максимум считаем ОТДЕЛЬНО по наборам без timeout: ✘ — про них (режут клиентов), + # заполненный набор с timeout — другой случай (флуд, лимит отключается) + DS_MAX=-1; DS_MAXN=""; DS_NT=0; DS_N=0; DS_NTMAX=-1; DS_NTMAXN="" + while IFS=$'\t' read -r _dn _dc _ds _dt; do + [[ -n "$_dn" ]] || continue + DS_N=$((DS_N+1)) + (( _dc * 100 / _ds > DS_MAX )) && { DS_MAX=$(( _dc * 100 / _ds )); DS_MAXN="$_dn ($_dc из $_ds)"; } + if [[ "$_dt" == "0" ]]; then + DS_NT=$((DS_NT+1)) + (( _dc * 100 / _ds > DS_NTMAX )) && { DS_NTMAX=$(( _dc * 100 / _ds )); DS_NTMAXN="$_dn ($_dc из $_ds)"; } + fi + done < <(dynset_fill 2>/dev/null) + if [[ "$DS_N" -gt 0 ]]; then + if [[ "$DS_NT" -gt 0 && "$DS_NTMAX" -ge 80 ]]; then + bad "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2) заполнены до ${DS_NTMAX}%: $DS_NTMAXN — на 100% новые клиенты на порт отсекаются. Ре-ран protect (4.1.3+); пожарно: systemctl reload na-firewall (сбросит и баны)" + elif [[ "$DS_NT" -gt 0 ]]; then + wrn "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2): адреса копятся до ребута, сейчас максимум ${DS_NTMAX}% — $DS_NTMAXN. На 100% новые клиенты на порт отсекаются: ре-ран protect (4.1.3+)" + elif [[ "$DS_MAX" -ge 80 ]]; then + wrn "набор-лимитер заполнен на ${DS_MAX}%: $DS_MAXN — флуд с множества адресов; на 100% лимит перестаёт действовать для новых адресов (клиентов не режет)" + else + pass "наборы-лимитеры с timeout, максимум заполнения ${DS_MAX}% ($DS_MAXN)" + fi + fi + unset _dn _dc _ds _dt DS_NTMAX DS_NTMAXN # v3.0 компоненты if nft list set inet na_filter suspect_v4 >/dev/null 2>&1; then SUSP="$(nft_set_count inet na_filter suspect_v4)"; [[ "$SUSP" =~ ^[0-9]+$ ]] || SUSP=0 @@ -894,7 +1005,7 @@ if [[ -f "$STATE_DIR/safety-fired.last" ]]; then fi fi # Таблица есть, а автозагрузки нет → после ребута нода останется без правил. -if nft list table inet na_filter >/dev/null 2>&1 \ +if nft -t list table inet na_filter >/dev/null 2>&1 \ && [[ -f /etc/systemd/system/na-firewall.service ]] \ && ! systemctl is-enabled --quiet na-firewall.service 2>/dev/null; then wrn "na_filter активна, но na-firewall.service ВЫКЛЮЧЕН — после ребута правила не поднимутся. Лечится повторным прогоном protect." @@ -926,7 +1037,7 @@ EXTIP="$(curl -fsS --max-time 4 https://api.ipify.org 2>/dev/null || true)" # IPv6 default-route: на нодах где v6 включён осознанно (напр. CDN-origin) его пропажа # после смены сети/провайдера тихо ломает v6-клиентов. Показываем факт, без warn # (v4-only ноды — легитимный кейс). -if ip -6 route show default 2>/dev/null | grep -q .; then +if [[ -n "$(ip -6 route show default 2>/dev/null)" ]]; then info "IPv6 default-route: есть ($(ip -6 route show default 2>/dev/null | awk '{print $3; exit}'))" else info "IPv6 default-route: нет (v4-only нода)" @@ -980,7 +1091,7 @@ if [[ -n "$NP_DET" && -f "$STATE_DIR/protect.installed" ]]; then NP_FW="$(awk -F= '/^node_port=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" NP_FWM="$(awk -F= '/^fw_mode=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" NP_TCPP="$(awk -F= '/^tcp_ports=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" - if [[ "${NP_FWM:-strict}" == "strict" ]] && nft list table inet na_filter >/dev/null 2>&1; then + if [[ "${NP_FWM:-strict}" == "strict" ]] && nft -t list table inet na_filter >/dev/null 2>&1; then NP_MISS="" for _np in ${NP_DET//,/ }; do [[ ",${NP_FW:-},${NP_TCPP:-}," == *",$_np,"* ]] || NP_MISS+="${NP_MISS:+,}$_np" @@ -1091,7 +1202,10 @@ elif systemctl is-active --quiet na-logrotate.timer 2>/dev/null; then else info "часовой таймер ротации не активен — работает только суточный logrotate.timer (maxsize проверяется раз в сутки)" fi -if command -v logrotate >/dev/null 2>&1 && logrotate -d /etc/logrotate.conf 2>&1 | grep -qi 'duplicate log entry'; then +# grep -c, а не -q: вывод `logrotate -d` большой, grep -q закрывал пайп на первом совпадении → +# SIGPIPE → под pipefail условие ложно ровно тогда, когда дубликат ЕСТЬ +if command -v logrotate >/dev/null 2>&1 \ + && [[ "$(logrotate -d /etc/logrotate.conf 2>&1 | grep -ci 'duplicate log entry')" -gt 0 ]]; then wrn "logrotate: дубликат путей — часть станс пропускается целиком (logrotate -d /etc/logrotate.conf)" fi # Ретеншен журнала ВО ВРЕМЕНИ. Датчик по объёму (выше) спокоен, когда логи капнуты, но diff --git a/scripts/diagnose.sh.minisig b/scripts/diagnose.sh.minisig index 37b3b0a..6f1faec 100644 --- a/scripts/diagnose.sh.minisig +++ b/scripts/diagnose.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.2 -RUQrJghT9nkdBENoxhnU9rwsJzsLv3J7D9SkM1oe+0YmmPdKLZWtjkaSKCLIQJyFN6bIsc+I6+50gMTg6NHare9Bwo6SMiIB9AY= -trusted comment: timestamp:1788647488 file:diagnose.sh hashed -vD5w6tc+/j+lAHtgd4marGeKQL+6LIMeYjVB/BA2N+oQm+9s24iSz9md+jdy8U1O6xnh4xMdS9Tyj6QLfCj0Bg== +untrusted comment: node-accelerator v4.1.3 +RUQrJghT9nkdBFCEAwWrllS47lCVJwSJJthj3R6eNOithI/ixDYFLmryb/D0CNkK8GuO/neNVvzagE1zAKUASAfW73YDhYYWlg0= +trusted comment: timestamp:1791034798 file:diagnose.sh hashed +KIYk5fxLY8KqDhy9y8EDHN+61H7t2AeNtlYMxo8+7XOMh+No3+35nl6wUEBZTLjuTnPx+Wt8IcYeiUDrF5+iBw== diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index da01e0d..45b2838 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -6,7 +6,22 @@ # Версия тулкита — ЕДИНСТВЕННЫЙ источник. Пишется в installed-маркеры и отдаётся # в na-diagnose/na-report --json, чтобы флот-мониторинг видел version-drift по нодам. # shellcheck disable=SC2034 -NA_VERSION="4.1.2" +NA_VERSION="4.1.3" + +# Числа — только с десятичной ТОЧКОЙ, сообщения утилит — на C. На ноде с LANG/LC_ALL= +# de_DE.UTF-8 mawk читал «0.35» из /proc/loadavg как 0 (в JSON уезжал "load1":0), printf +# печатал «0,99» прямо в na-report --json, а пороги --retrans сравнивали «4,99» как 4 +# (аудит флота); локализованный вывод утилит (`free`: «Speicher:» вместо «Mem:») ломал +# разбор. Явный LC_ALL=C/POSIX/C.UTF-8 уважаем — он и так даёт ровно это. Любой другой +# LC_ALL перебил бы LC_* — переносим его в LANG: кодировка и кириллица в выводе остаются +# как были (а с ними и разбор «д/Д» в подтверждениях), фиксируем числа и сообщения. +case "${LC_ALL:-}" in + C|POSIX|C.UTF-8|C.utf8) ;; + *) + [[ -n "${LC_ALL:-}" ]] && export LANG="$LC_ALL" + unset LC_ALL + export LC_NUMERIC=C LC_MESSAGES=C ;; +esac # shellcheck disable=SC2034 RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' diff --git a/scripts/lib/common.sh.minisig b/scripts/lib/common.sh.minisig index bdf99c3..7c7fb86 100644 --- a/scripts/lib/common.sh.minisig +++ b/scripts/lib/common.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.2 -RUQrJghT9nkdBL9iAGv1f9+ZiVf16X9beZhz659G4TXQS+g5bw0e8RQHeluighIIV7kWOSE1PkubOQ4CbWBrsX8vNQZfVr/78A4= -trusted comment: timestamp:1788647488 file:common.sh hashed -zcWQlx5JcCMnl4bxHUPCFV60li61IHyh95+obuk9BUH4CsixVLy4HejZ7qcufzajDQUMLLppiIGJBLMW53LyDA== +untrusted comment: node-accelerator v4.1.3 +RUQrJghT9nkdBD+R1Pg09mULuaNOM/Lrvrs/zMFJlb0Cne3RdtH8Wra5nVKhxmzJYGWHy9PKm8BFydR+lXICiMY7Go4GCERsjw8= +trusted comment: timestamp:1791034798 file:common.sh hashed +RAnw0/8GdpQPCuOiA9rVIKjbW4S3I2PQjUFwyI6TOqJvYK6kUoBjlBeS3uQY1fTggJiDRVmv8tRDFBPZNRfpAA== diff --git a/scripts/na-report.sh.minisig b/scripts/na-report.sh.minisig index ea0f71c..46b1ffa 100644 --- a/scripts/na-report.sh.minisig +++ b/scripts/na-report.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.2 +untrusted comment: node-accelerator v4.1.3 RUQrJghT9nkdBOrdxdxfyqJUywTv/RLU7ZreVDZAtLeliMCTEsY5M0vWhsR1gF5MEbykzE8/QRKRqFN3Odck90tt1E7RC8oxow8= -trusted comment: timestamp:1788647488 file:na-report.sh hashed -Q4FyevSLXC7c7wgxf7XHYHYBS2S/z6fZaL/xNM6vMBLToDX/UtMaCNABbpWWYzGqMvj+oe/EGdja+GRlQYOICQ== +trusted comment: timestamp:1791034798 file:na-report.sh hashed +sgw1l67Vrp2DhKiGbZSd+D9Vhgt+Wie37wqQKgibZyaEPgsSd3oR6i9Q75MQtcpSE2Zu+vfPGHQ4o9oVAOUKDA== diff --git a/scripts/optimize.sh b/scripts/optimize.sh index fdc8793..eee2613 100755 --- a/scripts/optimize.sh +++ b/scripts/optimize.sh @@ -270,7 +270,7 @@ if [[ "$ENABLE_XANMOD" == "1" ]]; then else warn "Архитектура $(arch) — XanMod только под x86_64. Пропускаю ядро." fi - elif uname -r | grep -q xanmod; then + elif [[ "$(uname -r)" == *xanmod* ]]; then ok "XanMod уже стоит ($(uname -r)) — обновляю только репозиторий (чтобы шли апдейты ядра)" setup_xanmod_repo || warn "репозиторий XanMod не обновлён (само ядро не тронуто)" else @@ -480,7 +480,7 @@ echo "tcp_bbr" > /etc/modules-load.d/na-bbr.conf echo "nf_conntrack" > /etc/modules-load.d/na-conntrack.conf sysctl --system >/dev/null 2>&1 || true -if sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null | grep -qx bbr; then +if [[ "$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)" == "bbr" ]]; then ok "BBR активен (под XanMod это BBRv3)" else warn "BBR пока не активен — модуль/ядро подхватятся после reboot" @@ -728,7 +728,7 @@ swap_size_mb() { # "2G" / "512M" / "2048" → мегабайты (для dd-ф make_swapfile() { local size="${REMNAWAVE_SWAP_SIZE:-2G}" mb mb="$(swap_size_mb "$size")" - if swapon --show=NAME --noheadings 2>/dev/null | grep -qx '/swapfile'; then + if [[ $'\n'"$(swapon --show=NAME --noheadings 2>/dev/null)"$'\n' == *$'\n'/swapfile$'\n'* ]]; then info "/swapfile уже активен — пропускаю"; return 0 fi if ! fallocate -l "$size" /swapfile 2>/dev/null; then @@ -748,7 +748,7 @@ make_swapfile() { mkdir -p "$STATE_DIR" && date -Is > "$STATE_DIR/swapfile.created" ok "Создан /swapfile $size" } -if swapon --show 2>/dev/null | grep -q .; then +if [[ -n "$(swapon --show 2>/dev/null)" ]]; then info "Swap уже есть — пропускаю" elif [[ "$TIER" -le 2 && "$SETUP_NO_ZRAM" != "1" ]] && modprobe zram 2>/dev/null; then cat > /usr/local/sbin/na-zram-setup <<'ZR' @@ -756,7 +756,7 @@ elif [[ "$TIER" -le 2 && "$SETUP_NO_ZRAM" != "1" ]] && modprobe zram 2>/dev/null # zram-swap ~50% RAM (lz4). Идемпотентно: если наш zram-swap уже активен — выходим. set -e modprobe zram 2>/dev/null || exit 0 -swapon --show=NAME --noheadings 2>/dev/null | grep -q '/dev/zram' && exit 0 +case "$(swapon --show=NAME --noheadings 2>/dev/null)" in *"/dev/zram"*) exit 0;; esac SIZE="$(awk '/^MemTotal:/{printf "%d", $2*1024/2}' /proc/meminfo 2>/dev/null)" [ -n "$SIZE" ] || exit 0 DEV="$(zramctl --find --size "$SIZE" --algorithm lz4 2>/dev/null || zramctl --find --size "$SIZE" 2>/dev/null || true)" @@ -780,7 +780,7 @@ WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now na-zram.service >/dev/null 2>&1 || true - if swapon --show 2>/dev/null | grep -q zram; then + if [[ "$(swapon --show 2>/dev/null)" == *zram* ]]; then ok "zram-swap включён ($(swapon --show=NAME,SIZE --noheadings 2>/dev/null | grep zram | tr '\n' ' '))" else warn "zram не поднялся — fallback на /swapfile" @@ -1065,6 +1065,7 @@ backup=$BACKUP nic=${NIC:-none} xanmod=$([[ -f "$STATE_DIR/xanmod.pkg" ]] && cat "$STATE_DIR/xanmod.pkg" || echo none) reboot_needed=$REBOOT_NEEDED +boot_id=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true) psi=$PSI_MARK EOF diff --git a/scripts/optimize.sh.minisig b/scripts/optimize.sh.minisig index 9ea93ee..37cdece 100644 --- a/scripts/optimize.sh.minisig +++ b/scripts/optimize.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.2 -RUQrJghT9nkdBGrIA8t3qUQSL2vbCsTwMaj68kto3cF+M9kaELTIV7p3rC3tHaM2hK3IKdIAp1mrk4z0A3GyasaldaUxstpXzAo= -trusted comment: timestamp:1788647488 file:optimize.sh hashed -Bgtevmgdu7iPsHegOuE+DOg3nmauiXmQwG19F8Dt+/GE09Oa6RLJO1xHq7/MS6zSoeBXKvPfHjdfHwoixf0UCw== +untrusted comment: node-accelerator v4.1.3 +RUQrJghT9nkdBGAN1171KyR+ijYqhef+3TOEd0BTjvoql1JPPdzxTollSTVm6tiCHhgTfBGlf1JFIU0CLieEjhkQ/upd1HYKdw0= +trusted comment: timestamp:1791034798 file:optimize.sh hashed +OMM6gXhIgZxlzXCREfQaOAXOr8U1gnQBlyg8Z98DOZeSS+fRYCT8VcsGCnjSW/0ZlgL1XVWXVMrp4ftgD5C2AA== diff --git a/scripts/protect.sh b/scripts/protect.sh index 3bec78f..dac6c36 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -518,13 +518,14 @@ if [[ "$FW_MODE" == "skip" ]]; then print_fw_howto # Переключение strict→skip: старая na_filter сама не исчезнет — порты остались бы # заблокированы «непонятно чем». Интерактивно предлагаем снять, иначе громкий hint. - if [[ "$DRY_RUN" != "1" ]] && { nft list table inet na_filter >/dev/null 2>&1 || [[ -f /etc/systemd/system/na-firewall.service ]]; }; then + if [[ "$DRY_RUN" != "1" ]] && { nft -t list table inet na_filter >/dev/null 2>&1 || [[ -f /etc/systemd/system/na-firewall.service ]]; }; then warn "Найден ранее установленный файрвол na_filter — FW_MODE=skip сам его НЕ удаляет." if [[ -t 0 && -z "${REMNAWAVE_NONINTERACTIVE:-}" ]] && confirm "Удалить na_filter сейчас (порты разблокируются)?"; then nft delete table inet na_filter 2>/dev/null || true systemctl disable --now na-firewall.service >/dev/null 2>&1 || true systemctl disable --now na-fleet-sync.timer na-blocklist.timer >/dev/null 2>&1 || true - rm -f /etc/systemd/system/na-firewall.service "$CONF_DIR/na_filter.nft" + rm -f /etc/systemd/system/na-firewall.service "$CONF_DIR/na_filter.nft" \ + "$CONF_DIR/na_filter.nft.rejected" "$CONF_DIR"/.na_filter.nft.* systemctl daemon-reload 2>/dev/null || true ok "na_filter удалена, порты разблокированы (полный откат модуля: bash install.sh rollback protect)" else @@ -587,19 +588,58 @@ fi [[ -n "$NP_DETECTED" ]] && NODE_PORT_LAST="$NP_DETECTED" NP_NFT="${NP_EFF//,/, }" +# ─── Наборы per-IP лимитеров ───────────────────────────────────────────────── +# До v4.1.3 каждый per-IP лимит был голым `meter … { ip saddr limit rate … } accept`: +# набор `size 65535; flags dynamic` БЕЗ timeout. Записи с `limit` ядро не чистит никогда +# (в отличие от `ct count`, у которого есть GC), поэтому набор копил КАЖДЫЙ адрес, +# хоть раз пришедший на порт, до ребута или перезагрузки таблицы. Аудит флота: 22 764 +# адреса в syn4_2087 за 32 дня аптайма, рост 700–1300 в сутки на порт. На полном наборе +# ядро не может завести запись новому адресу, правило `… accept` для него не срабатывает, +# и пакет падает в `ct state new drop` — новые клиенты молча отрезаны (а для ssh4 новый +# адрес админа уходит в suspect/бан). +# Теперь: набор объявлен явно, с timeout (запись живёт, пока адрес активен: `update` +# продлевает её на каждом пакете), а правило построено «превысил → drop, иначе accept». +# Переполнение (спуф-флуд с десятков тысяч адресов) тогда выключает лимит для новых +# адресов, а не отрезает их: syncookies и ct count продолжают работать. +# Timeout — не меньше пола и не меньше ДВУХ времён восстановления корзины (burst/rate): +# иначе адрес после паузы получал бы свежий burst. Пол: секундным лимитам 60s, минутным +# (SSH, анти-скан) 10m; при медленном rate (PORTSCAN_RATE=1, burst 30 → 30 мин) timeout +# растёт вместе с ним. Ручки NA_RATE_* — для тестов на настоящем nft (маленький набор, +# быстрое истечение), оператору их трогать незачем. +NA_RATE_SET_SIZE="${NA_RATE_SET_SIZE:-65535}" +NA_RATE_TO_SEC="${NA_RATE_TO_SEC:-60s}" +NA_RATE_TO_MIN="${NA_RATE_TO_MIN:-10m}" +_is_uint "$NA_RATE_SET_SIZE" && (( NA_RATE_SET_SIZE > 0 )) || { err "NA_RATE_SET_SIZE='$NA_RATE_SET_SIZE' — ожидается целое > 0"; exit 1; } +for _k in NA_RATE_TO_SEC NA_RATE_TO_MIN; do + # 0 = запись без срока, то есть ровно та вечная корзина, от которой и уходим + _is_duration "${!_k}" && (( $(systime_to_s "${!_k}") > 0 )) || { err "$_k='${!_k}' — ожидается длительность > 0 (напр. 60s, 10m)"; exit 1; } +done +unset _k +RATE_SETS="" +rate_set() { # rate_set <имя> + local unit_s=1 floor refill to + [[ "$5" == minute ]] && unit_s=60 + if [[ "$5" == minute ]]; then floor="$(systime_to_s "$NA_RATE_TO_MIN")"; else floor="$(systime_to_s "$NA_RATE_TO_SEC")"; fi + refill=$(( ($4 * unit_s + $3 - 1) / ($3 > 0 ? $3 : 1) )) + to=$(( refill * 2 > floor ? refill * 2 : floor )) + RATE_SETS+=" + set $1 { type $2; size ${NA_RATE_SET_SIZE}; flags dynamic,timeout; timeout ${to}s; }" +} + # ─── Сборка per-port правил ────────────────────────────────────────────────── TCP_RULES="" for p in ${TCP_PORTS//,/ }; do [[ -z "$p" ]] && continue TCP_RULES+=" - # порт ${p}: per-IP лимит одновременных коннектов (анти-exhaustion) + # порт ${p}: per-IP лимит одновременных коннектов (анти-exhaustion; записи ct count ядро чистит само) tcp dport ${p} ct state new meter cc4_${p} { ip saddr ct count over ${CONN_LIMIT} } drop tcp dport ${p} ct state new meter cc6_${p} { ip6 saddr ct count over ${CONN_LIMIT} } drop # порт ${p}: per-IP SYN-rate (масштабируется по числу клиентов, не глобальный потолок) - tcp dport ${p} ct state new meter syn4_${p} { ip saddr limit rate ${SYN_RATE}/second burst ${SYN_BURST} packets } accept - tcp dport ${p} ct state new meter syn6_${p} { ip6 saddr limit rate ${SYN_RATE}/second burst ${SYN_BURST} packets } accept - tcp dport ${p} ct state new limit rate 5/second log prefix \"[na synflood] \" level info - tcp dport ${p} ct state new drop" + tcp dport ${p} ct state new update @syn4_${p} { ip saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop + tcp dport ${p} ct state new update @syn6_${p} { ip6 saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop + tcp dport ${p} ct state new accept" + rate_set "syn4_${p}" ipv4_addr "$SYN_RATE" "$SYN_BURST" second + rate_set "syn6_${p}" ipv6_addr "$SYN_RATE" "$SYN_BURST" second done UDP_RULES="" @@ -612,9 +652,11 @@ for p in ${UDP_PORTS//,/ }; do fi UDP_RULES+=" # порт ${p}/udp: per-IP rate (QUIC/Hysteria2/TUIC) — ${_ukind} - udp dport ${p} meter udp4_${p} { ip saddr limit rate ${_urate}/second burst ${_uburst} packets } accept - udp dport ${p} meter udp6_${p} { ip6 saddr limit rate ${_urate}/second burst ${_uburst} packets } accept - udp dport ${p} drop" + udp dport ${p} update @udp4_${p} { ip saddr limit rate over ${_urate}/second burst ${_uburst} packets } counter drop + udp dport ${p} update @udp6_${p} { ip6 saddr limit rate over ${_urate}/second burst ${_uburst} packets } counter drop + udp dport ${p} accept" + rate_set "udp4_${p}" ipv4_addr "$_urate" "$_uburst" second + rate_set "udp6_${p}" ipv6_addr "$_urate" "$_uburst" second done # Порт в bulk-списке, но не в UDP_PORTS — правило для него не сгенерится вообще: молчать нельзя. for p in ${UDP_BULK_PORTS//,/ }; do @@ -715,9 +757,11 @@ elif [[ "$NODE_PORT_WHITELIST_ONLY" == "1" ]]; then fi else NODE_RULES=" # node-agent: whitelist (выше) + мягкий per-IP лимит для неизвестных - tcp dport { ${NP_NFT} } ct state new meter na4 { ip saddr limit rate 30/second burst 60 packets } accept - tcp dport { ${NP_NFT} } ct state new meter na6 { ip6 saddr limit rate 30/second burst 60 packets } accept - tcp dport { ${NP_NFT} } ct state new drop" + tcp dport { ${NP_NFT} } ct state new update @na4 { ip saddr limit rate over 30/second burst 60 packets } counter drop + tcp dport { ${NP_NFT} } ct state new update @na6 { ip6 saddr limit rate over 30/second burst 60 packets } counter drop + tcp dport { ${NP_NFT} } ct state new accept" + rate_set na4 ipv4_addr 30 60 second + rate_set na6 ipv6_addr 30 60 second fi # portscan → autoban (включается флагом). При ENABLE_BANONCE=1 — двухступенчато: @@ -740,16 +784,25 @@ if [[ "$ENABLE_PORTSCAN_BAN" == "1" && "$FW_MODE" != "open" ]]; then PORTSCAN=" # ANTI-SCAN (ban-once): 1-й быстрый скан → suspect, 2-й в окне ${SUSPECT_TIME} → бан. $_ps_log4 # уже suspect и снова бьёт быстрее порога → confirmed-бан - meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new ip saddr @suspect_v4 meter psc4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v4 { ip saddr timeout ${PORTSCAN_BAN_TIME} } drop - meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new ip6 saddr @suspect_v6 meter psc6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v6 { ip6 saddr timeout ${PORTSCAN_BAN_TIME} } drop + meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new ip saddr @suspect_v4 update @psc4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v4 { ip saddr timeout ${PORTSCAN_BAN_TIME} } drop + meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new ip6 saddr @suspect_v6 update @psc6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v6 { ip6 saddr timeout ${PORTSCAN_BAN_TIME} } drop # ещё не suspect и бьёт быстрее порога → пометить suspect (без бана; скан дропнет catch-all) - meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new meter ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } - meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new meter ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} }" + meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } + meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} }" else PORTSCAN=" # ANTI-SCAN: бьёт по закрытым портам быстрее ${PORTSCAN_RATE}/min → бан ${PORTSCAN_BAN_TIME}. $_ps_log4 - meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new meter ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v4 { ip saddr timeout ${PORTSCAN_BAN_TIME} } drop - meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new meter ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v6 { ip6 saddr timeout ${PORTSCAN_BAN_TIME} } drop" + meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v4 { ip saddr timeout ${PORTSCAN_BAN_TIME} } drop + meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v6 { ip6 saddr timeout ${PORTSCAN_BAN_TIME} } drop" + fi +fi +# наборы анти-скана — вне блока выше: tests/protect-unit.sh исполняет его отдельно +if [[ -n "$PORTSCAN" ]]; then + rate_set ps4 ipv4_addr "$PORTSCAN_RATE" "$PORTSCAN_BURST" minute + rate_set ps6 ipv6_addr "$PORTSCAN_RATE" "$PORTSCAN_BURST" minute + if [[ "$ENABLE_BANONCE" == "1" ]]; then + rate_set psc4 ipv4_addr "$PORTSCAN_RATE" "$PORTSCAN_BURST" minute + rate_set psc6 ipv6_addr "$PORTSCAN_RATE" "$PORTSCAN_BURST" minute fi fi @@ -819,23 +872,42 @@ if [[ "$FLEET_ON" == "1" ]]; then ip6 saddr @na_fleet_v6 accept" fi -# SSH connect-flood: с ban-once (suspect→confirmed) или прямой бан. +# SSH connect-flood: превысил лимит → цепочка ssh_flood (ban-once: suspect→confirmed, +# иначе прямой бан), в пределах лимита → accept. Последний drop цепочки — страховка: +# если сет suspect/autoban полон и запись не легла, пакет сверх лимита всё равно не +# проходит (иначе он вернулся бы из цепочки и упал в accept ниже). +# В отличие от сервисных портов, SSH на ПОЛНОМ наборе не открываем: держать ssh4 полным +# дёшево (≈110 SYN/с со спуфнутых адресов на 10m timeout, для v6 хватает своей /64), и +# fail-open выключил бы защиту от перебора. Адрес, которому не нашлось записи, идёт под +# общий потолок без бана (админ из whitelist проходит выше, остальным хватит 30/мин). +SSH_RULES=" # SSH connect-flood: >${SSH_RATE}/мин новых с одного IP → цепочка ssh_flood + tcp dport { ${SSH_NFT} } ct state new update @ssh4 { ip saddr limit rate over ${SSH_RATE}/minute burst ${SSH_BURST} packets } jump ssh_flood + tcp dport { ${SSH_NFT} } ct state new update @ssh6 { ip6 saddr limit rate over ${SSH_RATE}/minute burst ${SSH_BURST} packets } jump ssh_flood + tcp dport { ${SSH_NFT} } ct state new ip saddr @ssh4 accept + tcp dport { ${SSH_NFT} } ct state new ip6 saddr @ssh6 accept + # набор переполнен (флуд со множества адресов) — общий потолок, без бана + tcp dport { ${SSH_NFT} } ct state new limit rate 30/minute burst 30 packets accept + tcp dport { ${SSH_NFT} } ct state new counter drop" +rate_set ssh4 ipv4_addr "$SSH_RATE" "$SSH_BURST" minute +rate_set ssh6 ipv6_addr "$SSH_RATE" "$SSH_BURST" minute if [[ "$ENABLE_BANONCE" == "1" ]]; then - SSH_RULES=" # SSH connect-flood (ban-once): перебор → 1-й раз suspect+drop, 2-й в окне → бан ${SSH_BAN_TIME} - tcp dport { ${SSH_NFT} } ct state new meter ssh4 { ip saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport { ${SSH_NFT} } ct state new meter ssh6 { ip6 saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport { ${SSH_NFT} } ct state new limit rate 5/second log prefix \"[na ssh-flood] \" level warn - tcp dport { ${SSH_NFT} } ct state new ip saddr @suspect_v4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop - tcp dport { ${SSH_NFT} } ct state new ip6 saddr @suspect_v6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop - tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv4 add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } drop - tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv6 add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} } drop" + SSH_FLOOD_CHAIN=" # SSH сверх лимита (ban-once): 1-й раз suspect+drop, повтор в окне ${SUSPECT_TIME} → бан ${SSH_BAN_TIME} + chain ssh_flood { + limit rate 5/second log prefix \"[na ssh-flood] \" level warn + ip saddr @suspect_v4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop + ip6 saddr @suspect_v6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop + meta nfproto ipv4 add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } drop + meta nfproto ipv6 add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} } drop + counter drop + }" else - SSH_RULES=" # SSH connect-flood: >${SSH_RATE}/мин новых с одного IP → бан ${SSH_BAN_TIME} - tcp dport { ${SSH_NFT} } ct state new meter ssh4 { ip saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport { ${SSH_NFT} } ct state new meter ssh6 { ip6 saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport { ${SSH_NFT} } ct state new limit rate 5/second log prefix \"[na ssh-flood] \" level warn - tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop - tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop" + SSH_FLOOD_CHAIN=" # SSH сверх лимита: бан ${SSH_BAN_TIME} + chain ssh_flood { + limit rate 5/second log prefix \"[na ssh-flood] \" level warn + meta nfproto ipv4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop + meta nfproto ipv6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop + counter drop + }" fi WL4_LINE=""; [[ -n "$WL4" ]] && WL4_LINE="elements = { $WL4 }" @@ -855,23 +927,37 @@ if [[ "$FW_MODE" == "open" ]]; then # но per-IP лимиты им — те же, что перечисленным портам (drop сверх лимита ≠ бан) meta l4proto tcp ct state new meter occ4 { ip saddr ct count over ${CONN_LIMIT} } drop meta l4proto tcp ct state new meter occ6 { ip6 saddr ct count over ${CONN_LIMIT} } drop - meta l4proto tcp ct state new meter osyn4 { ip saddr limit rate ${SYN_RATE}/second burst ${SYN_BURST} packets } accept - meta l4proto tcp ct state new meter osyn6 { ip6 saddr limit rate ${SYN_RATE}/second burst ${SYN_BURST} packets } accept - meta l4proto tcp ct state new limit rate 5/second log prefix \"[na synflood] \" level info - meta l4proto tcp ct state new drop - meta l4proto udp meter oudp4 { ip saddr limit rate ${UDP_RATE}/second burst ${UDP_BURST} packets } accept - meta l4proto udp meter oudp6 { ip6 saddr limit rate ${UDP_RATE}/second burst ${UDP_BURST} packets } accept - meta l4proto udp counter drop + meta l4proto tcp ct state new update @osyn4 { ip saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop + meta l4proto tcp ct state new update @osyn6 { ip6 saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop + meta l4proto udp update @oudp4 { ip saddr limit rate over ${UDP_RATE}/second burst ${UDP_BURST} packets } counter drop + meta l4proto udp update @oudp6 { ip6 saddr limit rate over ${UDP_RATE}/second burst ${UDP_BURST} packets } counter drop counter accept" + rate_set osyn4 ipv4_addr "$SYN_RATE" "$SYN_BURST" second; rate_set osyn6 ipv6_addr "$SYN_RATE" "$SYN_BURST" second + rate_set oudp4 ipv4_addr "$UDP_RATE" "$UDP_BURST" second; rate_set oudp6 ipv6_addr "$UDP_RATE" "$UDP_BURST" second fi +# per-IP лимит ICMP echo — тоже набор с timeout (см. «Наборы per-IP лимитеров») +rate_set icmp4 ipv4_addr "$ICMP_RATE" "$ICMP_BURST" second +rate_set icmp6 ipv6_addr "$ICMP_RATE" "$ICMP_BURST" second + # ─── Генерация nft-файла ───────────────────────────────────────────────────── +# NFT_FILE — файл автозагрузки (его грузит na-firewall.service). Кандидат пишется рядом +# (та же ФС → атомарный mv) и становится файлом автозагрузки только после `nft -c` И +# успешного `nft -f`. До v4.1.3 генерация шла прямо в файл автозагрузки: провал проверки +# печатал «ничего не применено», а битый файл уже лежал на месте рабочего — и первый же +# ребут поднимал ноду без файрвола. В DRY_RUN кандидат и есть результат (/tmp). NFT_FILE="$CONF_DIR/na_filter.nft" -[[ "$DRY_RUN" == "1" ]] && NFT_FILE="$(mktemp /tmp/na_filter.XXXXXX.nft)" mkdir -p "$CONF_DIR" +if [[ "$DRY_RUN" == "1" ]]; then + NFT_FILE="$(mktemp /tmp/na_filter.XXXXXX.nft)"; NFT_NEW="$NFT_FILE" +else + NFT_NEW="$(mktemp "$CONF_DIR/.na_filter.nft.XXXXXX")" + trap 'rm -f "$NFT_NEW" 2>/dev/null || true' EXIT +fi +NFT_REJECTED="$CONF_DIR/na_filter.nft.rejected" title "Генерация nftables → $NFT_FILE" -cat > "$NFT_FILE" < "$NFT_NEW" </dev/null \ + || { [[ -f "$STATE_DIR/na-fw-safety.pid" ]] && kill -0 "$(cat "$STATE_DIR/na-fw-safety.pid" 2>/dev/null)" 2>/dev/null; }; } && SAFETY_PRE=1 arm_safety -nft -f "$NFT_FILE" +if ! nft -f "$NFT_NEW"; then + # Ядро отвергло транзакцию целиком — живая таблица осталась прежней. Взведённый сейфти + # через SAFETY_DELAY снёс бы её И выключил автозагрузку: нода, которой ре-ран не + # изменил ничего, осталась бы без защиты. Чужой (прошлого прогона) сейфти — оставляем. + [[ "$SAFETY_PRE" == "1" ]] || disarm_safety + mv -f "$NFT_NEW" "$NFT_REJECTED" + err "nft -f не применил ruleset (ядро отвергло транзакцию) — живые правила и файл автозагрузки прежние. Отвергнутый ruleset: $NFT_REJECTED" + exit 1 +fi +backup_file "$NFT_FILE" "$BACKUP" +mv -f "$NFT_NEW" "$NFT_FILE" +rm -f "$NFT_REJECTED" 2>/dev/null || true ok "nftables na_filter применён" # новый руллсет применён → прошлое срабатывание сейфти больше не актуально rm -f "$STATE_DIR/safety-fired.last" 2>/dev/null || true @@ -1570,7 +1689,8 @@ write_fw_status() { declare -f nft_set_count nft_set_elems cat <<'STAT' echo "── nft table inet na_filter ──" -nft list table inet na_filter 2>/dev/null | grep -E 'policy|counter|elements' | head -40 +# -t: без содержимого наборов — лимитеры держат десятки тысяч записей +nft -t list table inet na_filter 2>/dev/null | grep -E 'policy|counter' | head -40 echo echo "── autoban (живые баны) ──" echo "v4: $(nft_set_count inet na_filter autoban_v4) v6: $(nft_set_count inet na_filter autoban_v6)" diff --git a/scripts/protect.sh.minisig b/scripts/protect.sh.minisig index e53b9f8..2209fc2 100644 --- a/scripts/protect.sh.minisig +++ b/scripts/protect.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.2 -RUQrJghT9nkdBJqIVt61VwZTKJhdE0N9X+OAtg5EGgMFJOI+zHuMzLKXLkvnGEc2SC+dinSfa29iiA2zRt3rrVQ9BPFg6iEREg4= -trusted comment: timestamp:1788647488 file:protect.sh hashed -9w+0564+2LwgBKvoPS/n+a7UP4qccb7K/MVtieSjv1TFDw6vjLJbVUuSRRyVDLw8RVUcsvvkeQQLmUyi/pPbAA== +untrusted comment: node-accelerator v4.1.3 +RUQrJghT9nkdBLcVd2ABKIp+LSZq+1DLMsMZ9hWIVAQ1f/J1D4GQmjkoRg3d1kJ5TMBWescNU8wVJsbY07vX47+HNdivQA1xrAs= +trusted comment: timestamp:1791034798 file:protect.sh hashed +HULNG7vPwzxTWefIEsV1dlDzRXI+r7ZrNkyMcDOUX3AAfSdyGvOPR4E+I58SdcueCIRv6xh4lyDDAVqq3UHRDg== diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 0f8ff67..d5df0d1 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -84,7 +84,7 @@ rollback_optimize() { # XanMod-ядро: удаляем ТОЛЬКО если сейчас работаем не на нём (иначе оставим как есть) if [[ -f "$STATE_DIR/xanmod.pkg" ]]; then local pkg; pkg="$(cat "$STATE_DIR/xanmod.pkg")" - if [[ "${NA_REMOVE_XANMOD:-0}" == "1" ]] && ! uname -r | grep -qi xanmod; then + if [[ "${NA_REMOVE_XANMOD:-0}" == "1" ]] && [[ "$(uname -r)" != *xanmod* ]]; then info "Удаляю XanMod-пакет $pkg ..." DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq "$pkg" >/dev/null 2>&1 || warn "не удалил $pkg" update-grub >/dev/null 2>&1 || true @@ -118,7 +118,9 @@ rollback_protect() { # удаляем ТОЛЬКО свои таблицы — CrowdSec/Docker не трогаем nft delete table inet na_filter 2>/dev/null || true nft delete table inet na_ctguard 2>/dev/null || true - rm -f "$CONF_DIR/na_filter.nft" + # + отвергнутый ruleset и недописанные кандидаты (protect v4.1.3+ генерирует рядом и + # переименовывает в na_filter.nft только после успешного nft -f) + rm -f "$CONF_DIR/na_filter.nft" "$CONF_DIR/na_filter.nft.rejected" "$CONF_DIR"/.na_filter.nft.* rm -f /usr/local/sbin/na-fw-status /usr/local/sbin/na-fw-top-talkers \ /usr/local/sbin/na-fleet-sync /usr/local/sbin/na-blocklist-update /usr/local/sbin/na-ctguard \ /usr/local/sbin/na-fw-safety-revert diff --git a/scripts/rollback.sh.minisig b/scripts/rollback.sh.minisig index 6657608..8856155 100644 --- a/scripts/rollback.sh.minisig +++ b/scripts/rollback.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.2 -RUQrJghT9nkdBMsr5fhI1fYTi7I7wBpqvnimavufi/5cvOoomiqN/XsfcRtJWW4VVRfH6Kzhw5S/0psSDogosrTIfeISuCnI0AY= -trusted comment: timestamp:1788647488 file:rollback.sh hashed -IE75JmhRbtIM0cM4OsXG8F1LiDLTblJ8S+7/onPT727coriGziz0R7w45E66LdAbvlnO9Hotky+RbjHd4zPOBA== +untrusted comment: node-accelerator v4.1.3 +RUQrJghT9nkdBKMchSSpLL4nYdXOr18pZsDjniYKvZl4X4zQuxiH5QpwZQXWKVBsppYgz0opEDd73O2k8jGu6ecgsXIdzoVylwA= +trusted comment: timestamp:1791034798 file:rollback.sh hashed +YSbSVRZkpRpj/bBjkGA0qH2hKdsKBM8KcB+z274xNZKnWDS4Ex2gNTHKAHBg1hE+MbLaM6Cz2wqxFWOteV8mCg== diff --git a/tests/apply-smoke.sh b/tests/apply-smoke.sh index 5089d28..2b94c38 100755 --- a/tests/apply-smoke.sh +++ b/tests/apply-smoke.sh @@ -33,6 +33,10 @@ export NA_TEST_SYSTEMCTL_LOG="$T/systemctl.log" cat > "$T/bin/systemctl" <<'SYSTEMCTL' #!/bin/sh printf '%s\n' "$*" >> "$NA_TEST_SYSTEMCTL_LOG" +# сейфти прошлого прогона «взведён» только по просьбе теста (иначе — нет) +case "$*" in + "is-active --quiet na-fw-safety.timer") [ "${NA_TEST_SAFETY_ACTIVE:-0}" = 1 ] && exit 0; exit 3 ;; +esac exit 0 SYSTEMCTL # curl падает → сетевые fetch (crowdsec/blocklist/fleet) деградируют мягко, не висят. @@ -107,7 +111,29 @@ grep -q '^node_port=2222,3000$' "$T/state/protect.installed" || { echo "[x] stri grep -q '198.51.100.7' "$NFTF" && { echo "[x] strict: пир НЕ должен попадать в сет при явном NODE_PORT_WHITELIST_ONLY=1"; fail=1; } grep -qF 'node-port сейчас держат коннект: 198.51.100.7' "$LOG" || { echo "[x] strict: нет warn со списком established-пиров node-порта"; fail=1; } grep -q '^fw_mode=strict$' "$T/state/protect.installed" || { echo "[x] strict: fw_mode=strict не в маркере"; fail=1; } -grep -qE 'meter (osyn|occ|oudp)' "$NFTF" && { echo "[x] strict: generic open-лимитеры не должны ставиться (ruleset должен быть идентичен прежнему)"; fail=1; } +grep -qE '(@osyn|meter occ|@oudp)' "$NFTF" && { echo "[x] strict: generic open-лимитеры не должны ставиться (ruleset должен быть идентичен прежнему)"; fail=1; } +# v4.1.3: per-IP лимитеры — объявленные наборы С timeout, правило «превысил → drop» +grep -q 'set syn4_443 { type ipv4_addr; size 65535; flags dynamic,timeout; timeout 60s; }' "$NFTF" \ + || { echo "[x] strict: набор syn4_443 без timeout (записи копятся до ребута → полный набор режет новых клиентов)"; fail=1; } +grep -q 'set ssh4 { type ipv4_addr; size 65535; flags dynamic,timeout; timeout 600s; }' "$NFTF" \ + || { echo "[x] strict: набор ssh4 без timeout 600s (пол 10m)"; fail=1; } +# SSH на полном ssh4 НЕ открывается: известный адрес — accept, прочим — общий потолок +grep -q 'tcp dport { 22 } ct state new ip saddr @ssh4 accept' "$NFTF" \ + || { echo "[x] strict: нет accept для адресов из ssh4"; fail=1; } +grep -q 'tcp dport { 22 } ct state new limit rate 30/minute burst 30 packets accept' "$NFTF" \ + || { echo "[x] strict: нет общего потолка SSH на полном наборе"; fail=1; } +# медленный rate → timeout растёт вместе с временем восстановления корзины +grep -q 'set ps4 { type ipv4_addr; size 65535; flags dynamic,timeout; timeout 600s; }' "$NFTF" \ + || { echo "[x] strict: набор ps4 — ожидался timeout 600s (пол 10m)"; fail=1; } +grep -q 'update @syn4_443 { ip saddr limit rate over 200/second burst 400 packets } jump synflood_drop' "$NFTF" \ + || { echo "[x] strict: SYN-лимит не по схеме «превысил → drop»"; fail=1; } +if grep -qE 'meter [A-Za-z0-9_]+ \{ ip6? saddr limit' "$NFTF"; then + echo "[x] strict: остался meter с limit без timeout:"; grep -nE 'meter [A-Za-z0-9_]+ \{ ip6? saddr limit' "$NFTF"; fail=1 +fi +for st in ps4 psc4 icmp4 udp4_443; do + grep -q "set $st { type ipv4_addr; size 65535; flags dynamic,timeout;" "$NFTF" || { echo "[x] strict: нет набора $st с timeout"; fail=1; } +done +grep -q 'chain ssh_flood {' "$NFTF" || { echo "[x] strict: нет цепочки ssh_flood"; fail=1; } # Сброс окружения между прогонами режимов reset_t() { @@ -128,9 +154,10 @@ if [ "$rc" -ne 0 ]; then echo "[x] FW_MODE=open: apply упал (exit $rc)"; tai grep -qiE 'unbound variable|bad substitution' "$LOG2" && { echo "[x] open: unbound-переменная:"; grep -iE 'unbound variable|bad substitution' "$LOG2"; fail=1; } grep -q 'hook input priority filter; policy accept;' "$NFTF" || { echo "[x] open: нет policy accept на input"; fail=1; } grep -q 'counter accept' "$NFTF" || { echo "[x] open: нет catch-all accept"; fail=1; } -grep -q 'meter osyn4' "$NFTF" || { echo "[x] open: нет generic per-IP SYN-rate для неперечисленных портов"; fail=1; } +grep -q 'update @osyn4 { ip saddr limit rate over' "$NFTF" || { echo "[x] open: нет generic per-IP SYN-rate для неперечисленных портов"; fail=1; } grep -q 'meter occ4' "$NFTF" || { echo "[x] open: нет generic per-IP conn-limit для неперечисленных портов"; fail=1; } -grep -q 'meter oudp4' "$NFTF" || { echo "[x] open: нет generic per-IP UDP-rate для неперечисленных портов"; fail=1; } +grep -q 'update @oudp4 { ip saddr limit rate over' "$NFTF" || { echo "[x] open: нет generic per-IP UDP-rate для неперечисленных портов"; fail=1; } +grep -q 'set osyn4 { type ipv4_addr; size 65535; flags dynamic,timeout; timeout 60s; }' "$NFTF" || { echo "[x] open: набор osyn4 без timeout"; fail=1; } grep -q 'ANTI-SCAN' "$NFTF" && { echo "[x] open: анти-скан автобан не должен ставиться"; fail=1; } grep -qE 'tcp dport \{ 2222|na_nodeport_wl' "$NFTF" && { echo "[x] open: node-port правила/сеты не должны ставиться"; fail=1; } grep -q 'ssh-flood' "$NFTF" || { echo "[x] open: SSH-защита должна оставаться"; fail=1; } @@ -244,6 +271,79 @@ grep -q '^ssh_port=22,56777$' "$T/state/protect.installed" \ grep -q 'SSH_PORT:=22}' "$T/conf/protect.conf" \ || { echo "[x] ssh-port: в protect.conf должен персиститься intent (22), а не транзитный порт сессии"; fail=1; } +# ── timeout набора растёт с медленным rate; NA_RATE_TO_*=0 (вечные записи) отвергается ── +reset_t +set +e +PORTSCAN_RATE=1 PORTSCAN_BURST=30 ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 \ + bash "$T/scripts/protect.sh" >"$T/apply-slow.log" 2>&1 +rc=$? +NA_RATE_TO_MIN=0 ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 \ + bash "$T/scripts/protect.sh" >"$T/apply-to0.log" 2>&1 +rc0=$? +set -e +[ "$rc" -eq 0 ] || { echo "[x] slow-rate: прогон упал"; fail=1; } +# 30 пакетов при 1/мин = 30 мин восстановления → timeout 2×30 мин = 3600s (выше пола 10m) +grep -q 'set ps4 { type ipv4_addr; size 65535; flags dynamic,timeout; timeout 3600s; }' "$NFTF" \ + || { echo "[x] slow-rate: timeout ps4 не вырос до 2× восстановления корзины"; fail=1; } +[ "$rc0" -ne 0 ] && grep -q 'NA_RATE_TO_MIN' "$T/apply-to0.log" || { echo "[x] NA_RATE_TO_MIN=0 должен отвергаться"; fail=1; } + +# ── Файл автозагрузки меняется только после nft -c И успешного nft -f (v4.1.3) ── +# До v4.1.3 ruleset генерился прямо в na_filter.nft: провал проверки печатал «ничего не +# применено», а битый файл уже стоял на месте рабочего и поднимался первым же ребутом. +nft_fail_on() { # nft_fail_on — стаб nft, падающий на nft -c или на nft -f + if [ "$1" = check ]; then + printf '#!/bin/sh\ncase "$*" in *-c*) exit 1;; esac\nexit 0\n' > "$T/bin/nft" + else + printf '#!/bin/sh\ncase "$*" in *-c*) exit 0;; "-f "*) exit 1;; esac\nexit 0\n' > "$T/bin/nft" + fi + chmod +x "$T/bin/nft" +} +for mode in check apply; do + reset_t + echo '# OLD-WORKING-RULESET' > "$NFTF" + : > "$NA_TEST_SYSTEMCTL_LOG" + nft_fail_on "$mode" + LOG8="$T/apply-nftfail-$mode.log" + set +e + ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$LOG8" 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || { echo "[x] nft-$mode-fail: protect должен завершиться ошибкой"; fail=1; } + grep -qx '# OLD-WORKING-RULESET' "$NFTF" || { echo "[x] nft-$mode-fail: файл автозагрузки подменён отвергнутым ruleset"; fail=1; } + [ -s "$T/conf/na_filter.nft.rejected" ] || { echo "[x] nft-$mode-fail: отвергнутый ruleset не сохранён рядом (.rejected)"; fail=1; } + ls "$T/conf"/.na_filter.nft.* >/dev/null 2>&1 && { echo "[x] nft-$mode-fail: остался временный кандидат"; fail=1; } + [ -e "$T/state/protect.installed" ] && { echo "[x] nft-$mode-fail: маркер не должен писаться при отказе"; fail=1; } + if [ "$mode" = apply ]; then + # nft -f отвергнут → живая таблица прежняя → сейфти обязан быть снят, иначе он + # через SAFETY_DELAY снёс бы рабочую таблицу и автозагрузку + # arm_safety сам гасит прошлый таймер перед взводом → ждём ДВА stop: взвод + снятие + [ "$(grep -c 'stop na-fw-safety.timer' "$NA_TEST_SYSTEMCTL_LOG")" -ge 2 ] \ + || { echo "[x] nft-apply-fail: сейфти не снят после отказа nft -f"; fail=1; } + fi +done +# сейфти прошлого неинтерактивного прогона ещё взведён → при отказе nft -f его НЕ снимаем +reset_t +echo '# OLD-WORKING-RULESET' > "$NFTF" +: > "$NA_TEST_SYSTEMCTL_LOG" +nft_fail_on apply +set +e +NA_TEST_SAFETY_ACTIVE=1 ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-nftfail-pre.log" 2>&1 +set -e +[ "$(grep -c 'stop na-fw-safety.timer' "$NA_TEST_SYSTEMCTL_LOG")" -eq 1 ] \ + || { echo "[x] nft-apply-fail: сейфти прошлого прогона снят (а должен остаться взведённым)"; fail=1; } +printf '#!/bin/sh\nexit 0\n' > "$T/bin/nft"; chmod +x "$T/bin/nft" +# успешный прогон: кандидат переименован в файл автозагрузки, прошлый — в бэкапе +reset_t +echo '# OLD-WORKING-RULESET' > "$NFTF" +set +e +ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-ok.log" 2>&1 +rc=$? +set -e +[ "$rc" -eq 0 ] || { echo "[x] nft-ok: прогон упал (exit $rc)"; tail -15 "$T/apply-ok.log"; fail=1; } +grep -q 'table inet na_filter {' "$NFTF" || { echo "[x] nft-ok: новый ruleset не стал файлом автозагрузки"; fail=1; } +grep -qx '# OLD-WORKING-RULESET' "$T/backup/na_filter.nft" 2>/dev/null || { echo "[x] nft-ok: прошлый ruleset не попал в бэкап"; fail=1; } +ls "$T/conf"/.na_filter.nft.* >/dev/null 2>&1 && { echo "[x] nft-ok: остался временный кандидат"; fail=1; } + if [ "$fail" -ne 0 ]; then echo "=== ХВОСТ ЛОГА (strict) ==="; tail -25 "$LOG" echo "APPLY-SMOKE: FAIL"; exit 1 diff --git a/tests/diagnose-unit.sh b/tests/diagnose-unit.sh index adad745..6ade048 100755 --- a/tests/diagnose-unit.sh +++ b/tests/diagnose-unit.sh @@ -48,7 +48,7 @@ mkdir -p "$BIN" "$NFTD" "$SSD" \ "$T/sys/class/net/eth0/queues/rx-0" "$T/sys/class/net/eth0/statistics" \ "$T/sys/kernel/mm/transparent_hugepage" \ "$T/opt/selfsteal/certs/node.example.test" \ - "$T/proc/net" "$T/proc/sys/net/netfilter" \ + "$T/proc/net" "$T/proc/sys/net/netfilter" "$T/proc/sys/kernel/random" \ "$T/root/.acme.sh/retired.example.test" KREL="6.18.47-x64v3-xanmod1" @@ -61,12 +61,17 @@ export JSTART # без этого тест читал бы живой /proc/pressure раннера и результат зависел бы от того, # с каким ядром собран CI. cp -R "$REPO_ROOT/scripts" "$T/scripts" +# /proc/sys/… уводим через метку: иначе правило для /sys/ срабатывало ВНУТРИ уже +# подставленного $T/proc/sys/… и ломало путь (до v4.1.3 так молча не читались +# nf_conntrack_count и boot_id песочницы). sandbox_paths() { sed -i.bak \ -e "s#/var/lib/node-accelerator#$T/var/lib/node-accelerator#g" \ -e "s#/etc/node-accelerator#$T/etc/node-accelerator#g" \ + -e "s#/proc/sys/#@@PROCSYS@@#g" \ -e "s#/proc/#$T/proc/#g" \ -e "s#/sys/#$T/sys/#g" \ + -e "s#@@PROCSYS@@#$T/proc/sys/#g" \ -e "s#/boot/config-#$T/boot/config-#g" \ -e "s#/var/log#$T/var/log#g" \ -e "s#/var/lib/docker#$T/var/lib/docker#g" \ @@ -89,7 +94,9 @@ DIAG="$T/scripts/diagnose.sh" printf 'PRETTY_NAME="Debian GNU/Linux 13 (trixie)"\nID=debian\n' > "$T/etc/os-release" # ── Фикстуры /proc, /sys, /boot ─────────────────────────────────────────────── -printf 'cpu 100 0 50 900 0 0 0 0 0 0\n' > "$T/proc/stat" +# btime = 2026-09-02 00:00 UTC — загрузка ПОЗЖЕ installed_at старого маркера optimize +printf 'cpu 100 0 50 900 0 0 0 0 0 0\nbtime 1788307200\n' > "$T/proc/stat" +printf 'boot-current\n' > "$T/proc/sys/kernel/random/boot_id" printf '99999.00 88888.00\n' > "$T/proc/uptime" printf '0.05 0.10 0.15 1/200 1234\n' > "$T/proc/loadavg" printf 'MemTotal: 2048000 kB\nMemAvailable: 1024000 kB\n' > "$T/proc/meminfo" @@ -222,10 +229,15 @@ DOC cat > "$BIN/nft" <<'NFTS' #!/bin/sh case "$*" in + "-t list table inet na_filter") [ -f "$NFTD/terse" ] && cat "$NFTD/terse"; exit 0 ;; "list table inet na_filter") exit 0 ;; "list table inet na_ctguard") exit 1 ;; "list table ip crowdsec") exit 1 ;; "list chain inet na_filter input") cat "$NFTD/chain-input"; exit 0 ;; + "list meter inet "*) + all="$*"; s=${all##* } + if [ -f "$NFTD/meter-$s" ]; then cat "$NFTD/meter-$s"; exit 0; fi + echo "Error: No such file or directory" >&2; exit 1 ;; "list set inet "*) # ${*##* } раскрывается по КАЖДОМУ параметру, а не по склейке — берём имя набора # через промежуточную переменную @@ -266,7 +278,14 @@ CS cat > "$BIN/journalctl" <<'JC' #!/bin/sh case "$*" in - *--help*) echo " -g --grep=PATTERN Show entries with MESSAGE matching PATTERN"; exit 0 ;; + *--help*) + echo " -g --grep=PATTERN Show entries with MESSAGE matching PATTERN" + # NA_TEST_BIG_HELP: справка больше буфера пайпа — `| grep -q` закрыл бы пайп на + # первой строке, и следующая запись словила бы SIGPIPE (141 под pipefail) + if [ "${NA_TEST_BIG_HELP:-0}" = 1 ]; then + i=0; while [ "$i" -lt 6000 ]; do echo " --filler-option-$i long help text line to overflow the pipe buffer"; i=$((i+1)); done + fi + exit 0 ;; *short-unix*) printf '%s.000000 node kernel: Linux version\n' "$JSTART"; exit 0 ;; *"na portscan"*) printf 'a\nb\nc\n'; exit 0 ;; esac @@ -291,6 +310,12 @@ SC # `-d` по пустому conf в песочнице → тишина, дубликатов нет. cat > "$BIN/logrotate" <<'LR' #!/bin/sh +# NA_TEST_LR_DUP: дубликат в НАЧАЛЕ большого вывода `logrotate -d` — ровно тот случай, +# когда grep -q закрывал пайп раньше, чем logrotate дописывал (SIGPIPE → «дублей нет») +if [ "$1" = "-d" ] && [ "${NA_TEST_LR_DUP:-0}" = 1 ]; then + echo "error: na-node-logs:1 duplicate log entry for /var/log/nginx/access.log" + i=0; while [ "$i" -lt 6000 ]; do echo "considering log /var/log/filler-$i.log"; i=$((i+1)); done +fi exit 0 LR @@ -350,6 +375,7 @@ if [ "$1" = "-d" ]; then case "$2" in *GOOD*) echo $(( now + 60*86400 )) ;; *RETIRED*) echo $(( now + 3*86400 )) ;; + 2026-09-01T00:00:00+00:00) echo 1788220800 ;; # installed_at старого маркера optimize *) exit 1 ;; esac exit 0 @@ -492,6 +518,127 @@ env "$SSHENV" TERM=dumb "$WBASH" "$DIAG" > "$T/out7c.txt" 2>/dev/null || true grep_ok "текст: станса на месте → ✔ с перечнем масок" "часовой таймер активен, станса на месте (/var/log/remnanode/*.log)" "$T/out7c.txt" rm -f "$LRSTATE/logrotate.owned" +echo "== 8. SIGPIPE под pipefail: большой вывод не гасит сенсоры (v4.1.3) ==" +for i in 1 2 3; do + env TERM=dumb NA_TEST_BIG_HELP=1 "$WBASH" "$DIAG" --json > "$T/out8.json" 2>/dev/null || true + check "portscan_log_lines_boot=3 при справке journalctl > буфера пайпа (прогон $i)" 3 "$(jget "$T/out8.json" portscan_log_lines_boot)" +done +env TERM=dumb NA_TEST_LR_DUP=1 "$WBASH" "$DIAG" > "$T/out8.txt" 2>/dev/null || true +grep_ok "дубликат в большом выводе logrotate -d найден" "logrotate: дубликат путей" "$T/out8.txt" + +echo "== 9. наборы-лимитеры: без timeout копят адреса, на 100% режут клиентов (v4.1.3) ==" +mk_elems() { local n="$1" i out=""; for ((i=1; i<=n; i++)); do out+="${out:+, }198.18.$((i/250)).$((i%250+1))"; done; printf '%s' "$out"; } +mk_set() { # mk_set <имя> <число элементов> + local to_line=""; [[ -n "$4" ]] && to_line="\t\ttimeout $4\n" + printf "table inet na_filter {\n\t%s %s {\n\t\ttype ipv4_addr\n\t\tsize %s\n\t\tflags %s\n${to_line}\t\telements = { %s }\n\t}\n}\n" \ + "$6" "$1" "$2" "$3" "$(mk_elems "$5")" > "$NFTD/$6-$1" +} +# nft 1.1.3, правила ≤4.1.2: syn4_443 — бывший meter, виден как набор БЕЗ timeout (85 из 100); +# autoban_v4 — dynamic,timeout БЕЗ строки timeout (срок на каждой записи) и почти полон: +# это НЕ лимитер, его считать нельзя; cc4_443 — ct count, ядро чистит само. +cat > "$NFTD/terse" <<'TERSE' +table inet na_filter { + set whitelist_v4 { + type ipv4_addr + flags interval + } + set autoban_v4 { + type ipv4_addr + size 100 + flags dynamic,timeout + } + set syn4_443 { + type ipv4_addr + size 100 + flags dynamic + } + set cc4_443 { + type ipv4_addr + size 65535 + flags dynamic + } +} +TERSE +mk_set syn4_443 100 dynamic "" 85 set +mk_set autoban_v4 100 dynamic,timeout "" 95 set +mk_set cc4_443 65535 dynamic "" 99 set +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out9.json" 2>/dev/null || true +check "json: dynset_fill_max_pct=85 (autoban не лимитер)" 85 "$(jget "$T/out9.json" dynset_fill_max_pct)" +check "json: dynset_fill_max_set=syn4_443" syn4_443 "$(jget "$T/out9.json" dynset_fill_max_set)" +check "json: dynset_no_timeout=1 (autoban и cc4 не в счёт)" 1 "$(jget "$T/out9.json" dynset_no_timeout)" +env TERM=dumb "$WBASH" "$DIAG" > "$T/out9.txt" 2>/dev/null || true +grep_ok "текст: ✘ legacy-набор почти полон" "наборы-лимитеры без timeout (1 шт., правила ≤4.1.2) заполнены до 85%" "$T/out9.txt" + +# nft 1.0.6, правила ≤4.1.2: meter анонимный — в -t есть только внутри правила +cat > "$NFTD/terse" <<'TERSE' +table inet na_filter { + set autoban_v4 { + type ipv4_addr + size 65536 + flags dynamic,timeout + } + chain input { + type filter hook input priority filter; policy drop; + tcp dport 443 ct state new meter cc4_443 size 65535 { ip saddr ct count over 2048 } drop + tcp dport 443 ct state new meter syn4_443 size 100 { ip saddr limit rate 200/second burst 400 packets } accept + tcp dport 443 ct state new drop + } +} +TERSE +rm -f "$NFTD/set-syn4_443" +mk_set syn4_443 100 dynamic "" 90 meter +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out9n.json" 2>/dev/null || true +check "nft 1.0.6: meter из правила виден, 90%" 90 "$(jget "$T/out9n.json" dynset_fill_max_pct)" +check "nft 1.0.6: meter считается без timeout" 1 "$(jget "$T/out9n.json" dynset_no_timeout)" +rm -f "$NFTD/meter-syn4_443" + +# v4.1.3: лимитер с timeout (10 из 100) → ✔, autoban на 95% тревоги не поднимает +cat > "$NFTD/terse" <<'TERSE' +table inet na_filter { + set autoban_v4 { + type ipv4_addr + size 100 + flags dynamic,timeout + } + set syn4_443 { + type ipv4_addr + size 100 + flags dynamic,timeout + timeout 60s + } +} +TERSE +mk_set syn4_443 100 dynamic,timeout 60s 10 set +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out9b.json" 2>/dev/null || true +check "json: с timeout dynset_no_timeout=0" 0 "$(jget "$T/out9b.json" dynset_no_timeout)" +check "json: заполнение 10%" 10 "$(jget "$T/out9b.json" dynset_fill_max_pct)" +env TERM=dumb "$WBASH" "$DIAG" > "$T/out9b.txt" 2>/dev/null || true +grep_ok "текст: ✔ лимитеры с timeout" "наборы-лимитеры с timeout, максимум заполнения 10%" "$T/out9b.txt" +rm -f "$NFTD/terse" "$NFTD/set-syn4_443" "$NFTD/set-cc4_443" +mk_set autoban_v4 65536 dynamic,timeout "" 0 set; rm -f "$NFTD/set-autoban_v4" +set_fixture autoban_v4 '203.0.113.10 timeout 1d expires 1h11m50s608ms, 203.0.113.11 timeout 1d expires 2h' +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out9c.json" 2>/dev/null || true +check "json: наборов нет → dynset_fill_max_pct=-1" -1 "$(jget "$T/out9c.json" dynset_fill_max_pct)" + +echo "== 10. reboot_needed — по факту загрузки, а не вечный маркер (v4.1.3) ==" +OPTM="$T/var/lib/node-accelerator/optimize.installed" +printf 'installed_at=2026-10-03T10:00:00+00:00\nreboot_needed=1\nboot_id=boot-current\n' > "$OPTM" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out10.json" 2>/dev/null || true +check "тот же boot_id → ребута не было → true" true "$(jget "$T/out10.json" reboot_needed)" +env TERM=dumb "$WBASH" "$DIAG" > "$T/out10.txt" 2>/dev/null || true +grep_ok "текст: ▲ ждёт перезагрузки" "а перезагрузки с тех пор не было" "$T/out10.txt" +printf 'installed_at=2026-10-03T10:00:00+00:00\nreboot_needed=1\nboot_id=boot-before\n' > "$OPTM" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out10b.json" 2>/dev/null || true +check "другой boot_id → ребут был → false" false "$(jget "$T/out10b.json" reboot_needed)" +# маркер ≤4.1.2 без boot_id: установка 01.09, загрузка (btime) 02.09 → ребут был +printf 'installed_at=2026-09-01T00:00:00+00:00\nreboot_needed=1\n' > "$OPTM" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out10c.json" 2>/dev/null || true +check "старый маркер, btime > installed_at → false" false "$(jget "$T/out10c.json" reboot_needed)" +printf 'reboot_needed=1\n' > "$OPTM" # без boot_id и installed_at — верим маркеру, как раньше +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out10d.json" 2>/dev/null || true +check "маркер без дат → как раньше, true" true "$(jget "$T/out10d.json" reboot_needed)" +rm -f "$OPTM" + echo echo " прогон: $PASS ok, $FAIL fail" if [[ "$FAIL" -ne 0 ]]; then echo "DIAGNOSE-UNIT: FAIL"; exit 1; fi diff --git a/tests/install-unit.sh b/tests/install-unit.sh new file mode 100755 index 0000000..98cb085 --- /dev/null +++ b/tests/install-unit.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# +# install-unit.sh — install.sh в режиме curl|bash НЕ берёт scripts/ из текущей папки. +# +# До v4.1.3 SCRIPT_DIR вычислялся из `${BASH_SOURCE[0]:-$0}`: при `curl … | bash -s` +# BASH_SOURCE пуст, $0 = "bash", dirname → «.», то есть ТЕКУЩАЯ папка. Лежал в ней каталог +# scripts/ (старый клон тулкита, подложенный /tmp/scripts) — root исполнял его, минуя и +# скачивание по NA_REF, и проверку подписей NA_REQUIRE_SIG=1. +# +# Сеть не нужна: curl подменён стабом, который «скачивает» модули из рабочего дерева и +# пишет журнал запросов. Root не нужен: после скачивания install.sh упирается в +# require_root — к этому моменту выбор источника модулей уже сделан. +# Запуск: bash tests/install-unit.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/decoy/scripts/lib" + +pick_bash() { + local c + for c in bash /opt/homebrew/bin/bash /usr/local/bin/bash; do + command -v "$c" >/dev/null 2>&1 || continue + if "$c" -c 'set -u; a=(); : "${a[@]}"; [[ -v HOME ]]' 2>/dev/null; then command -v "$c"; return 0; fi + done + return 1 +} +WBASH="$(pick_bash)" || { echo "[x] не нашёл bash ≥ 4.2"; exit 1; } + +# Приманка: «модули» в текущей папке, которые громко сообщают, что их исполнили. +for m in lib/common.sh optimize.sh protect.sh diagnose.sh na-report.sh rollback.sh; do + printf '#!/usr/bin/env bash\necho DECOY-EXECUTED\nrequire_root(){ :; }\ndetect_os(){ :; }\n' > "$T/decoy/scripts/$m" +done + +# curl-стаб: `curl … -o <файл>` → копия scripts/<путь> из рабочего дерева. +export NA_TEST_CURL_LOG="$T/curl.log" NA_TEST_REPO="$REPO_ROOT" +cat > "$T/bin/curl" <<'CURL' +#!/usr/bin/env bash +out=""; url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2; continue ;; + http*://*) url="$1" ;; + esac + shift +done +printf '%s\n' "$url" >> "$NA_TEST_CURL_LOG" +rel="${url#*/scripts/}" +[ -f "$NA_TEST_REPO/scripts/$rel" ] || exit 22 +cp "$NA_TEST_REPO/scripts/$rel" "$out" +CURL +chmod +x "$T/bin/curl" + +PASS=0; FAIL=0 +ok_() { PASS=$((PASS+1)); echo " ok $1"; } +fail_() { FAIL=$((FAIL+1)); echo " FAIL $1"; } + +echo "== 1. curl|bash из папки с чужим scripts/ ==" +: > "$NA_TEST_CURL_LOG" +# Зовём именно как `bash` (относительное имя, $0 = "bash") — ровно так выглядит +# `curl … | sudo bash -s`; по абсолютному пути dirname($0) уводил бы мимо текущей папки. +BDIR="$(dirname "$WBASH")" +( cd "$T/decoy" && PATH="$T/bin:$BDIR:$PATH" bash -s diagnose < "$REPO_ROOT/install.sh" ) > "$T/out1.log" 2>&1 || true +if grep -q DECOY-EXECUTED "$T/out1.log"; then fail_ "исполнены модули из ТЕКУЩЕЙ папки (scripts/ приманки)" +else ok_ "модули из текущей папки не исполнялись"; fi +if grep -q '/scripts/lib/common.sh$' "$NA_TEST_CURL_LOG"; then ok_ "модули скачаны по NA_REF (curl вызван)" +else fail_ "модули не скачивались (curl не вызывался)"; fi + +echo "== 2. запуск из файла берёт модули рядом с install.sh ==" +: > "$NA_TEST_CURL_LOG" +( cd "$T/decoy" && PATH="$T/bin:$PATH" "$WBASH" "$REPO_ROOT/install.sh" diagnose ) > "$T/out2.log" 2>&1 || true +if [ -s "$NA_TEST_CURL_LOG" ]; then fail_ "при запуске из файла install.sh полез в сеть" +else ok_ "из файла: модули рядом, без скачивания"; fi +grep -q DECOY-EXECUTED "$T/out2.log" && fail_ "из файла: исполнена приманка из текущей папки" || ok_ "из файла: приманка не тронута" + +echo "== 3. --help в curl|bash-режиме не читает файл по имени «bash» ==" +printf '#!/bin/sh\necho DECOY-HELP\n' > "$T/decoy/bash" +( cd "$T/decoy" && PATH="$BDIR:$PATH" bash -s -- --help < "$REPO_ROOT/install.sh" ) > "$T/out3.log" 2>&1 || true +if grep -q DECOY-HELP "$T/out3.log"; then fail_ "справка напечатана из файла ./bash" +elif grep -q 'install.sh rollback' "$T/out3.log"; then ok_ "встроенная справка" +else fail_ "справки нет: $(head -c 200 "$T/out3.log")"; fi + +echo "== 4. из файла, но scripts/ рядом НЕ доверенный (запись для группы) → скачивание ==" +mkdir -p "$T/shared" +cp "$REPO_ROOT/install.sh" "$T/shared/install.sh" +cp -R "$T/decoy/scripts" "$T/shared/scripts" +chmod g+w "$T/shared/scripts" +: > "$NA_TEST_CURL_LOG" +( cd "$T" && PATH="$T/bin:$BDIR:$PATH" bash "$T/shared/install.sh" diagnose ) > "$T/out4.log" 2>&1 || true +grep -q DECOY-EXECUTED "$T/out4.log" && fail_ "исполнен scripts/ с записью для группы" || ok_ "scripts/ с записью для группы не исполнялся" +grep -q '/scripts/lib/common.sh$' "$NA_TEST_CURL_LOG" && ok_ "вместо него модули скачаны" || fail_ "модули не скачивались" +grep -q 'не доверенный' "$T/out4.log" && ok_ "причина названа" || fail_ "нет сообщения о недоверенном scripts/" + +echo "== 5. NA_REQUIRE_SIG=1 проверяет и ЛОКАЛЬНЫЕ модули ==" +if command -v minisign >/dev/null 2>&1; then + mkdir -p "$T/signed" "$T/key" + cp "$REPO_ROOT/install.sh" "$T/signed/install.sh" + cp -R "$REPO_ROOT/scripts" "$T/signed/scripts" + rm -f "$T/signed/scripts"/*.minisig "$T/signed/scripts/lib"/*.minisig + minisign -G -W -p "$T/key/k.pub" -s "$T/key/k.key" >/dev/null 2>&1 + for m in lib/common.sh optimize.sh protect.sh diagnose.sh na-report.sh rollback.sh; do + minisign -S -s "$T/key/k.key" -m "$T/signed/scripts/$m" >/dev/null 2>&1 + done + PUB="$(sed -n 2p "$T/key/k.pub")" + : > "$NA_TEST_CURL_LOG" + ( cd "$T" && NA_REQUIRE_SIG=1 NA_MINISIGN_PUBKEY="$PUB" PATH="$T/bin:$BDIR:$PATH" bash "$T/signed/install.sh" diagnose ) > "$T/out5.log" 2>&1 || true + grep -q 'подпись lib/common.sh валидна (локальный модуль)' "$T/out5.log" && ok_ "подписанные локальные модули приняты" || fail_ "подписанные локальные модули не приняты: $(tail -3 "$T/out5.log")" + echo '# tampered' >> "$T/signed/scripts/protect.sh" + ( cd "$T" && NA_REQUIRE_SIG=1 NA_MINISIGN_PUBKEY="$PUB" PATH="$T/bin:$BDIR:$PATH" bash "$T/signed/install.sh" diagnose ) > "$T/out5b.log" 2>&1 || true + grep -q 'подпись локального protect.sh НЕ прошла' "$T/out5b.log" && ok_ "подменённый локальный модуль отвергнут" || fail_ "подменённый локальный модуль НЕ отвергнут" +else + echo " skip (нет minisign)" +fi + +echo +echo " прогон: $PASS ok, $FAIL fail" +[[ "$FAIL" -eq 0 ]] || { echo "INSTALL-UNIT: FAIL"; exit 1; } +echo "INSTALL-UNIT: OK (curl|bash всегда качает модули; из файла — берёт свои)" diff --git a/tests/lint-pipefail.sh b/tests/lint-pipefail.sh new file mode 100755 index 0000000..76a6a98 --- /dev/null +++ b/tests/lint-pipefail.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# lint-pipefail.sh — запрещает `cmd | grep -q` в скриптах, живущих под `set -o pipefail`. +# +# grep -q выходит на ПЕРВОМ совпадении и закрывает пайп. Если команда слева пишет ещё — +# она ловит SIGPIPE, пайплайн под pipefail отдаёт 141, и условие становится ложным ровно +# тогда, когда совпадение ЕСТЬ. Так в v4.1.2 через раз пропадал сенсор лога анти-скана +# (справка journalctl 5 КБ уходит двумя записями), никогда не срабатывало предупреждение +# «сокеты на cubic» (ss -tin — мегабайты) и не виден был дубликат в `logrotate -d`. +# +# Разрешено: источник — одиночный `printf '%s[\n]' "$var"` (одна маленькая запись, писать +# после выхода grep нечего) и строки с пометкой `# pipefail-ok` (осознанное исключение). +# Правильные замены: вывод в переменную + `[[ $v == *pat* ]]` / `grep -q … <<<"$v"`, +# `grep -c` (дочитывает вход), `awk '…{f=1} END{exit !f}'`. +# +# Не требует root/сети. Запуск: bash tests/lint-pipefail.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +bad=0 +for f in install.sh scripts/*.sh scripts/lib/*.sh; do + # common.sh своего set не имеет, но исполняется внутри скриптов с pipefail + if [[ "$f" != scripts/lib/common.sh ]] && ! grep -qE '^set -[a-z]*o pipefail' "$f"; then + continue + fi + while IFS= read -r hit; do + line="${hit#*:}" # grep -n по одному файлу: «N:строка» + code="${line%%#*}" # хвостовой комментарий не в счёт + [[ "$line" == *'# pipefail-ok'* ]] && continue + [[ "$code" =~ \|[[:space:]]*grep[[:space:]]+-[A-Za-z]*q ]] || continue + if [[ "$code" =~ printf\ \'%s(\\n)?\'\ \"\$[A-Za-z_][A-Za-z_0-9]*\"[[:space:]]*\|[[:space:]]*grep ]]; then + continue + fi + echo " [x] $f:${hit%%:*}: $(sed -E 's/^[[:space:]]+//' <<<"$line")" + bad=$((bad+1)) + done < <(grep -nE '\|[[:space:]]*grep[[:space:]]+-[A-Za-z]*q' "$f" || true) +done + +if [[ "$bad" -gt 0 ]]; then + echo "LINT-PIPEFAIL: FAIL — $bad пайп(ов) в grep -q под pipefail (SIGPIPE → ложное «нет»)" + exit 1 +fi +echo "LINT-PIPEFAIL: OK (под pipefail нет пайпов в grep -q)" diff --git a/tests/nft-behavior.sh b/tests/nft-behavior.sh new file mode 100755 index 0000000..b516bef --- /dev/null +++ b/tests/nft-behavior.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# +# nft-behavior.sh — поведение per-IP лимитеров на НАСТОЯЩЕМ nft/ядре (два netns + veth). +# +# Юнит-тесты сверяют текст ruleset; здесь проверяется то, что текст не доказывает: +# 0. контроль: старая схема (meter без timeout, «в лимите → accept, иначе drop») на +# полном наборе РЕЖЕТ новых клиентов — ровно отказ, найденный на флоте; +# 1. ruleset 4.1.3 встаёт поверх живой таблицы со старыми meter (переход 4.1.2 → 4.1.3); +# 2. наборы-лимитеры объявлены с timeout; +# 3. лимит работает: сверх burst новые SYN с одного адреса дропаются, счётчик растёт; +# 4. после простоя записи истекают; +# 5. ПОЛНЫЙ набор не режет новых клиентов (fail-open) — v4 и v6; +# 6. SSH: превышение → suspect → бан (ban-once), а на полном наборе — общий потолок, +# а не открытая дверь для перебора. +# Размер набора и пол timeout ужаты ручками NA_RATE_SET_SIZE/NA_RATE_TO_*, чтобы проверить +# переполнение и истечение за секунды. +# +# Нужны: Linux, root, nft, ip, python3. CI: job nft-behavior. Запуск: sudo bash tests/nft-behavior.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[[ "$(uname -s)" == Linux ]] || { echo "SKIP: нужен Linux (netns + nftables)"; exit 0; } +[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "[x] нужен root (sudo bash $0)"; exit 1; } +for c in nft ip python3; do command -v "$c" >/dev/null || { echo "[x] нет $c"; exit 1; }; done + +T="$(mktemp -d)" +SRV=na-t-srv; CLI=na-t-cli +cleanup() { + [[ -n "${LPID:-}" ]] && kill "$LPID" 2>/dev/null || true + ip netns del "$SRV" 2>/dev/null || true + ip netns del "$CLI" 2>/dev/null || true + rm -rf "$T" +} +trap cleanup EXIT + +# ── Генерация ruleset тем же protect.sh (DRY_RUN: генерация + nft -c на этом ядре) ── +cp -R "$REPO_ROOT/scripts" "$T/scripts" +cat >> "$T/scripts/lib/common.sh" <<'STUB' +apt_install(){ :; } +default_iface(){ echo na-wan0; } +detect_ssh_port(){ echo 22; } +ssh_client_ip(){ :; } +STUB +SIZE=4; RATE=2; BURST=3 +OUT="$(TERM=dumb NA_NO_LOCK=1 DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 ENABLE_CROWDSEC=0 \ + TCP_PORTS=8443 UDP_PORTS=8443 NODE_PORT=2222 NODE_PORT_WHITELIST_ONLY=0 \ + SYN_RATE=$RATE SYN_BURST=$BURST SSH_RATE=6 SSH_BURST=2 ENABLE_BANONCE=1 \ + NA_RATE_SET_SIZE=$SIZE NA_RATE_TO_SEC=1s NA_RATE_TO_MIN=1s \ + bash "$T/scripts/protect.sh" 2>&1)" || { echo "$OUT" | tail -20; echo "[x] генерация/nft -c не прошли"; exit 1; } +F="$(sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' <<<"$OUT" | sed -nE 's/.*Генерация nftables → (\/tmp\/na_filter\.[A-Za-z0-9]+\.nft).*/\1/p' | tail -1)" +[[ -n "$F" && -s "$F" ]] || { echo "[x] не нашёл сгенерированный ruleset"; echo "$OUT" | tail -20; exit 1; } +cp "$F" "$T/ruleset.nft"; rm -f "$F" +# timeout набора = max(пол, 2× восстановление корзины): 3/2 с → 2 с × 2 = 4 с +SET_TO=4 + +# ── Стенд: srv ←veth→ cli (10 адресов v4 и 8 адресов v6) ───────────────────────── +ip netns add "$SRV"; ip netns add "$CLI" +ip link add na-t0 type veth peer name na-t1 +ip link set na-t0 netns "$SRV"; ip link set na-t1 netns "$CLI" +ip -n "$SRV" addr add 10.77.0.1/24 dev na-t0 +ip -n "$SRV" -6 addr add fd77::1/64 dev na-t0 nodad +for i in $(seq 11 20); do ip -n "$CLI" addr add "10.77.0.$i/24" dev na-t1; done +for i in $(seq 11 18); do ip -n "$CLI" -6 addr add "fd77::$i/64" dev na-t1 nodad; done +for ns in "$SRV" "$CLI"; do ip -n "$ns" link set lo up; done +ip -n "$SRV" link set na-t0 up; ip -n "$CLI" link set na-t1 up + +ip netns exec "$SRV" python3 - <<'PY' & +import socket, threading +def serve(fam, addr, port): + s = socket.socket(fam, socket.SOCK_STREAM); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((addr, port)); s.listen(512) + while True: + c, _ = s.accept(); c.close() +for fam, addr, port in ((socket.AF_INET, "10.77.0.1", 8443), (socket.AF_INET6, "fd77::1", 8443), (socket.AF_INET, "10.77.0.1", 22)): + threading.Thread(target=serve, args=(fam, addr, port), daemon=True).start() +threading.Event().wait() +PY +LPID=$! +sleep 1 + +connects() { # connects → число удачных TCP-соединений + ip netns exec "$CLI" python3 - "$@" <<'PY' +import socket, sys +src, dst, port, n, ok = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), 0 +fam = socket.AF_INET6 if ":" in dst else socket.AF_INET +for _ in range(n): + s = socket.socket(fam, socket.SOCK_STREAM); s.settimeout(0.7) + try: + s.bind((src, 0)); s.connect((dst, port)); ok += 1 + except OSError: + pass + finally: + s.close() +print(ok) +PY +} +elems() { # elems <имя> → число адресов в наборе + ip netns exec "$SRV" nft list "$1" inet na_filter "$2" 2>/dev/null | sed -n '/elements = {/,/^[[:space:]]*}/p' \ + | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}|fd77::[0-9a-f]+' | sort -u | wc -l | tr -d ' ' +} +in_set() { ip netns exec "$SRV" nft get element inet na_filter "$1" "{ $2 }" >/dev/null 2>&1; } + +PASS=0; FAIL=0 +check() { if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1"; else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi; } +ok_() { PASS=$((PASS+1)); echo " ok $1"; } +fail_() { FAIL=$((FAIL+1)); echo " FAIL $1"; } + +echo "== 0. контроль: meter без timeout на полном наборе режет новых (схема ≤4.1.2) ==" +ip netns exec "$SRV" nft -f - <<'LEGACY' +table inet na_filter { + chain input { + type filter hook input priority filter; policy accept; + tcp dport 8443 ct state new meter syn4_8443 size 4 { ip saddr limit rate 200/second burst 400 packets } accept + tcp dport 8443 ct state new drop + } +} +LEGACY +okc=0 +for i in $(seq 11 16); do okc=$(( okc + $(connects "10.77.0.$i" 10.77.0.1 8443 1) )); done +check "из 6 новых адресов прошли только 4 (размер набора) — отказ воспроизведён" 4 "$okc" + +echo "== 1. ruleset 4.1.3 встаёт поверх живой таблицы со старыми meter ==" +if ip netns exec "$SRV" nft -f "$T/ruleset.nft"; then ok_ "nft -f поверх таблицы с meter прошёл"; else fail_ "nft -f поверх таблицы с meter НЕ прошёл"; exit 1; fi + +echo "== 2. наборы-лимитеры объявлены с timeout ==" +for st in syn4_8443 syn6_8443 udp4_8443 ssh4 icmp4 ps4; do + hdr="$(ip netns exec "$SRV" nft list set inet na_filter "$st" 2>/dev/null | grep -E 'flags|timeout' | tr -s '\t ' ' ')" + if [[ "$hdr" == *"dynamic,timeout"* && "$hdr" == *" timeout "* ]]; then ok_ "$st: flags dynamic,timeout + timeout" + else fail_ "$st: нет timeout в объявлении набора ($hdr)"; fi +done + +echo "== 3. лимит действует: сверх burst $BURST при $RATE/с новые SYN дропаются ==" +okr="$(connects 10.77.0.19 10.77.0.1 8443 10)" +[[ "$okr" -lt 10 ]] && ok_ "из 10 быстрых соединений прошли не все (лимит сработал), прошло $okr" || fail_ "лимит не сработал: прошло $okr из 10" +[[ "$okr" -ge "$BURST" ]] && ok_ "burst отработал: прошло не меньше $BURST ($okr)" || fail_ "burst не отработал: прошло $okr < $BURST" +drops="$(ip netns exec "$SRV" nft list chain inet na_filter synflood_drop | grep -oE 'counter packets [0-9]+' | grep -oE '[0-9]+' | head -1)" +[[ "${drops:-0}" -gt 0 ]] && ok_ "счётчик synflood_drop вырос (${drops})" || fail_ "счётчик synflood_drop не вырос" + +echo "== 4. записи истекают после простоя (timeout ${SET_TO}s) ==" +for _ in $(seq 1 20); do [[ "$(elems set syn4_8443)" == 0 ]] && break; sleep 1; done +check "набор syn4_8443 пуст после простоя" 0 "$(elems set syn4_8443)" +# листинг прячет истёкшие записи сразу, а счётчик набора ядро уменьшает только на сборке +# мусора (отложенно) — даём ей пройти, иначе тест ниже увидел бы «полный» набор раньше +sleep 5 + +echo "== 5. полный набор НЕ режет новых клиентов (fail-open), v4 и v6 ==" +okc=0 +for i in $(seq 11 18); do okc=$(( okc + $(connects "10.77.0.$i" 10.77.0.1 8443 1) )); done +check "v4: 8 новых адресов при наборе на $SIZE — все соединились" 8 "$okc" +check "v4: в наборе ровно $SIZE записей (потолок)" "$SIZE" "$(elems set syn4_8443)" +okc=0 +for i in $(seq 11 18); do okc=$(( okc + $(connects "fd77::$i" fd77::1 8443 1) )); done +check "v6: 8 новых адресов при наборе на $SIZE — все соединились" 8 "$okc" + +echo "== 6. SSH: ban-once и полный набор ==" +# .20: burst 2 → 3-я попытка сверх лимита → suspect, 4-я → autoban +connects 10.77.0.20 10.77.0.1 22 4 >/dev/null +in_set autoban_v4 10.77.0.20 && ok_ "перебор SSH: адрес в autoban (ban-once suspect→бан)" || fail_ "перебор SSH не привёл к бану" +# ssh4 полон (.20 + три новых), новые адреса — под общий потолок 30/мин, а не открытая дверь +for i in 11 12 13; do connects "10.77.0.$i" 10.77.0.1 22 1 >/dev/null; done +check "ssh4 заполнен до $SIZE" "$SIZE" "$(elems set ssh4)" +okc=0 +for i in 14 15 16 17; do okc=$(( okc + $(connects "10.77.0.$i" 10.77.0.1 22 1) )); done +check "полный ssh4: новые адреса проходят по общему потолку" 4 "$okc" +in_set autoban_v4 10.77.0.14 && fail_ "полный ssh4: невиновный адрес забанен" || ok_ "полный ssh4: без бана невиновных" + +echo +echo " прогон: $PASS ok, $FAIL fail" +[[ "$FAIL" -eq 0 ]] || { echo "NFT-BEHAVIOR: FAIL"; exit 1; } +echo "NFT-BEHAVIOR: OK (старая схема режет новых, новая — нет; лимит, истечение, SSH ban-once и потолок работают)"