From 40bd75ed894ede6769be8d6c8b02cd8b1ce0d7d2 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 14:23:31 +0300 Subject: [PATCH 1/3] =?UTF-8?q?v4.1.3:=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=8B=20=D1=81=20timeout=20(=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20meter=20=D1=80=D0=B5=D0=B7=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2),=20=D1=84=D0=B0=D0=B9=D0=BB=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20nft=20-c/-f,=20=D1=81=D0=B5=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D1=80=D1=8B=20=D0=B1=D0=B5=D0=B7=20SIGPIPE,=20install.sh?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20scripts/=20=D0=B8=D0=B7=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=B9=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protect: все per-IP лимитеры — наборы flags dynamic,timeout + «превысил → drop» (fail-open при переполнении); цепочки synflood_drop/ssh_flood со счётчиками - protect: кандидат ruleset → nft -c → nft -f → атомарный mv в na_filter.nft; отвергнутый — na_filter.nft.rejected, при отказе nft -f сейфти снимается - diagnose: | grep -q под pipefail переписаны; датчик заполненности лимитеров (dynset_fill_max_pct/_set, dynset_no_timeout); reboot_needed по boot_id/btime - install.sh: модули рядом — только при запуске из файла; gpg-статус без пайпа - common: LC_NUMERIC=C (LC_ALL → LANG); optimize пишет boot_id в маркер - tests: nft-behavior (настоящий nft в netns), install-unit, lint-pipefail, apply-smoke/diagnose-unit — регрессии; CI-job nft-behavior Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 +++++ CHANGELOG.md | 113 +++++++++++++++++++++++ README.md | 16 +++- install.sh | 36 +++++--- scripts/diagnose.sh | 118 +++++++++++++++++++++--- scripts/lib/common.sh | 13 ++- scripts/optimize.sh | 13 +-- scripts/protect.sh | 189 +++++++++++++++++++++++++++++---------- scripts/rollback.sh | 6 +- tests/apply-smoke.sh | 68 +++++++++++++- tests/diagnose-unit.sh | 93 ++++++++++++++++++- tests/install-unit.sh | 87 ++++++++++++++++++ tests/lint-pipefail.sh | 45 ++++++++++ tests/nft-behavior.sh | 118 ++++++++++++++++++++++++ 14 files changed, 846 insertions(+), 92 deletions(-) create mode 100755 tests/install-unit.sh create mode 100755 tests/lint-pipefail.sh create mode 100755 tests/nft-behavior.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 146c356..538411a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,15 @@ 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 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 +88,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..0576bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,118 @@ # 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` с timeout (60s для +секундных лимитов, 10m для минутных), который `update` продлевает, пока адрес активен. +Правило построено «превысил → drop, иначе accept»: если набор всё же переполнит спуф-флуд с +десятков тысяч адресов, лимит для новых адресов отключается (их по-прежнему держат syncookies +и `ct count`), а не режет их. Сверх-лимитные SYN уходят в цепочку `synflood_drop` +(лог под rate-limit + счётчик), SSH сверх лимита — в `ssh_flood` с той же логикой ban-once, +что и раньше; последний `drop` в ней срабатывает, даже если сет suspect/autoban полон. + +`na-diagnose` показывает заполненность наборов-лимитеров и есть ли у них timeout: на нодах, +где правила ещё от 4.1.2, это ▲, а при заполнении от 80% — ✘ с пожарной мерой +(`systemctl reload na-firewall` очищает наборы, но вместе с ними и баны). В `--json` — +`dynset_fill_max_pct`, `dynset_fill_max_set`, `dynset_no_timeout`. Лечится ре-раном +`protect` на 4.1.3. + +### 🟠 Файл автозагрузки правил перезаписывался до проверки + +`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` убирает и эти файлы. + +### 🟠 Сенсоры `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`. Теперь модули рядом +берутся только когда `install.sh` запущен из файла, иначе всегда скачиваются во временный +каталог, который убирается на выходе. Заодно `--help` в `curl | bash`-режиме больше не читает +файл с именем `bash` из текущей папки. + +### 🟡 Мелкое + +- **`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_ALL` + переносится в `LANG`, чтобы кодировка вывода не менялась. + +### 🧪 Тесты + +- `tests/nft-behavior.sh` (новый CI-job `nft-behavior`): **настоящий** nft в двух netns. Наборы + объявлены с timeout; переполненный набор не режет новых клиентов; записи истекают после + простоя; лимит действует, а счётчик `synflood_drop` растёт. +- `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/` его не исполняет. +- `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` не меняется, новых ручек для оператора нет. + ## 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..154d5ae 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,16 @@ # (стабильный 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 запущен из файла. При curl|bash +# (`bash -s`) BASH_SOURCE пуст, а $0 = "bash" — до v4.1.3 тут был фоллбэк на $0, то есть +# SCRIPT_DIR = ТЕКУЩАЯ папка: лежит в ней каталог scripts/ (старый клон тулкита, чужой +# /tmp/scripts) — и root исполнял его, минуя скачивание по NA_REF И проверку подписей +# NA_REQUIRE_SIG=1. Теперь в curl|bash-режиме модули всегда скачиваются. +NA_SELF="${BASH_SOURCE[0]:-}" +SCRIPTS="" +if [[ -n "$NA_SELF" && -f "$NA_SELF" ]]; then + SCRIPTS="$(cd "$(dirname "$NA_SELF")" && pwd)/scripts" +fi # NA_REF — ветка/тег для curl|bash-режима (по умолчанию main). Для прода пиньте тег. NA_REF="${NA_REF:-main}" # NA_REF уходит в URL модулей — запрещаем path-traversal/инъекцию (увод на чужой репо). @@ -37,8 +43,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-ноды. @@ -80,16 +86,22 @@ verify_sig() { # verify_sig <файл> # печатается из пакета подписи ДАЖЕ для 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). + local st + st="$(gpg --status-fd=1 --verify "$file.asc" "$file" 2>/dev/null || true)" + 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 curl -fsSL "${CURL_PROTO[@]}" "$REPO_URL/scripts/$f" -o "$SCRIPTS/$f" || { echo "[x] Не скачал $f"; exit 1; } diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index 6ac66a4..ec6fae3 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,50 @@ 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)" + 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/ +# анти-скан). До v4.1.3 они создавались без timeout: записи не истекали, и на полном +# наборе (65535) новые клиенты на порт отсекались (аудит флота: 22 764 адреса за 32 дня). +# stdout: «имяэлементовsizetimeout(1|0)» на набор; ct count-наборы (cc*/occ*) +# пропускаем — их ядро чистит само. rc=1 — таблицы нет. +dynset_fill() { + local name size to n + nft list table inet na_filter >/dev/null 2>&1 || return 1 + while IFS=$'\t' read -r name size to; do + [[ -n "$name" && "$size" =~ ^[0-9]+$ ]] || continue + n="$(nft_set_count inet na_filter "$name")"; [[ "$n" =~ ^[0-9]+$ ]] || n=0 + printf '%s\t%s\t%s\t%s\n' "$name" "$n" "$size" "$to" + done < <(nft -t list table inet na_filter 2>/dev/null | awk ' + /^[[:space:]]*(set|meter) [A-Za-z0-9_]+ \{/ { name=$2; size=0; dyn=0; to=0; inset=1; next } + inset && /^[[:space:]]*size [0-9]+/ { size=$2 } + inset && /^[[:space:]]*flags / && /dynamic/ { dyn=1 } + inset && /^[[:space:]]*timeout / { to=1 } + inset && /^[[:space:]]*}/ { + if (dyn && size>0 && name !~ /^o?cc[46]/) printf "%s\t%s\t%s\n", name, size, to + inset=0 + }') } # ─── JSON-режим (для флот-мониторинга: Zabbix/Prometheus/SSH-поллинг) ───────── @@ -198,7 +247,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}" @@ -246,7 +296,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 +312,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 +401,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 +443,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 +531,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 +623,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 +637,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 +646,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 +793,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)" @@ -819,6 +887,27 @@ if nft list table inet na_filter >/dev/null 2>&1; then fi fi fi + # Наборы per-IP лимитеров. До v4.1.3 — без timeout: адреса копятся до ребута, и на + # полном наборе новые клиенты на порт отсекаются (для ssh4 — новый IP админа в бан). + DS_MAX=-1; DS_MAXN=""; DS_NT=0; DS_N=0 + 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)"; } + [[ "$_dt" == "0" ]] && DS_NT=$((DS_NT+1)) + done < <(dynset_fill 2>/dev/null) + if [[ "$DS_N" -gt 0 ]]; then + if [[ "$DS_NT" -gt 0 && "$DS_MAX" -ge 80 ]]; then + bad "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2) заполнены до ${DS_MAX}%: $DS_MAXN — на 100% новые клиенты на порт отсекаются. Ре-ран protect (4.1.3+); пожарно: systemctl reload na-firewall (сбросит и баны)" + elif [[ "$DS_NT" -gt 0 ]]; then + wrn "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2): адреса копятся до ребута, сейчас максимум ${DS_MAX}% — $DS_MAXN. На 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 # 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 @@ -926,7 +1015,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 нода)" @@ -1091,7 +1180,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/lib/common.sh b/scripts/lib/common.sh index da01e0d..e2883a1 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -6,7 +6,18 @@ # Версия тулкита — ЕДИНСТВЕННЫЙ источник. Пишется в installed-маркеры и отдаётся # в na-diagnose/na-report --json, чтобы флот-мониторинг видел version-drift по нодам. # shellcheck disable=SC2034 -NA_VERSION="4.1.2" +NA_VERSION="4.1.3" + +# Числа — только с десятичной ТОЧКОЙ. На ноде с 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 (аудит флота). LC_ALL +# перебивает любой LC_*, поэтому переносим его в LANG — кодировка и кириллица в выводе +# остаются как были (а с ними и разбор «д/Д» в подтверждениях), — и фиксируем LC_NUMERIC. +if [[ -n "${LC_ALL:-}" && "$LC_ALL" != C && "$LC_ALL" != POSIX ]]; then + export LANG="$LC_ALL" +fi +unset LC_ALL +export LC_NUMERIC=C # shellcheck disable=SC2034 RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' 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/protect.sh b/scripts/protect.sh index 3bec78f..bfb6256 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -587,19 +587,48 @@ 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. Ручки — для тестов на настоящем 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; } +_is_duration "$NA_RATE_TO_SEC" || { err "NA_RATE_TO_SEC='$NA_RATE_TO_SEC' — ожидается длительность (напр. 60s)"; exit 1; } +_is_duration "$NA_RATE_TO_MIN" || { err "NA_RATE_TO_MIN='$NA_RATE_TO_MIN' — ожидается длительность (напр. 10m)"; exit 1; } +RATE_SETS="" +rate_set() { # rate_set <имя> + RATE_SETS+=" + set $1 { type $2; size ${NA_RATE_SET_SIZE}; flags dynamic,timeout; timeout $3; }" +} + # ─── Сборка 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 "$NA_RATE_TO_SEC" + rate_set "syn6_${p}" ipv6_addr "$NA_RATE_TO_SEC" done UDP_RULES="" @@ -612,9 +641,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 "$NA_RATE_TO_SEC" + rate_set "udp6_${p}" ipv6_addr "$NA_RATE_TO_SEC" done # Порт в bulk-списке, но не в UDP_PORTS — правило для него не сгенерится вообще: молчать нельзя. for p in ${UDP_BULK_PORTS//,/ }; do @@ -715,9 +746,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 "$NA_RATE_TO_SEC" + rate_set na6 ipv6_addr "$NA_RATE_TO_SEC" fi # portscan → autoban (включается флагом). При ENABLE_BANONCE=1 — двухступенчато: @@ -740,16 +773,23 @@ 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 "$NA_RATE_TO_MIN"; rate_set ps6 ipv6_addr "$NA_RATE_TO_MIN" + if [[ "$ENABLE_BANONCE" == "1" ]]; then + rate_set psc4 ipv4_addr "$NA_RATE_TO_MIN"; rate_set psc6 ipv6_addr "$NA_RATE_TO_MIN" fi fi @@ -819,23 +859,34 @@ 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_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 accept" +rate_set ssh4 ipv4_addr "$NA_RATE_TO_MIN" +rate_set ssh6 ipv6_addr "$NA_RATE_TO_MIN" 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 +906,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 "$NA_RATE_TO_SEC"; rate_set osyn6 ipv6_addr "$NA_RATE_TO_SEC" + rate_set oudp4 ipv4_addr "$NA_RATE_TO_SEC"; rate_set oudp6 ipv6_addr "$NA_RATE_TO_SEC" fi +# per-IP лимит ICMP echo — тоже набор с timeout (см. «Наборы per-IP лимитеров») +rate_set icmp4 ipv4_addr "$NA_RATE_TO_SEC" +rate_set icmp6 ipv6_addr "$NA_RATE_TO_SEC" + # ─── Генерация 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 || true ok "nftables na_filter применён" # новый руллсет применён → прошлое срабатывание сейфти больше не актуально rm -f "$STATE_DIR/safety-fired.last" 2>/dev/null || true 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/tests/apply-smoke.sh b/tests/apply-smoke.sh index 5089d28..b527aff 100755 --- a/tests/apply-smoke.sh +++ b/tests/apply-smoke.sh @@ -107,7 +107,21 @@ 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 10m; }' "$NFTF" \ + || { echo "[x] strict: набор ssh4 без timeout 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 +142,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 +259,53 @@ 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; } +# ── Файл автозагрузки меняется только после 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 +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..35beb34 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,6 +229,7 @@ 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 ;; @@ -266,7 +274,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 +306,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 +371,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 +514,71 @@ 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) ==" +# legacy (≤4.1.2): syn4_443 без timeout, 85 из 100; cc4_443 — ct count, ядро чистит само +cat > "$NFTD/terse" <<'TERSE' +table inet na_filter { + set whitelist_v4 { + type ipv4_addr + flags interval + } + set syn4_443 { + type ipv4_addr + size 100 + flags dynamic + } + set cc4_443 { + type ipv4_addr + size 65535 + flags dynamic + } +} +TERSE +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"; } +printf 'table inet na_filter {\n\tset syn4_443 {\n\t\ttype ipv4_addr\n\t\tsize 100\n\t\tflags dynamic\n\t\telements = { %s }\n\t}\n}\n' "$(mk_elems 85)" > "$NFTD/set-syn4_443" +printf 'table inet na_filter {\n\tset cc4_443 {\n\t\ttype ipv4_addr\n\t\tsize 65535\n\t\tflags dynamic\n\t\telements = { %s }\n\t}\n}\n' "$(mk_elems 99)" > "$NFTD/set-cc4_443" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out9.json" 2>/dev/null || true +check "json: dynset_fill_max_pct=85" 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 (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" +# v4.1.3: тот же набор с timeout, 10 из 100 → ✔ +printf 'table inet na_filter {\n\tset syn4_443 {\n\t\ttype ipv4_addr\n\t\tsize 100\n\t\tflags dynamic,timeout\n\t\ttimeout 1m\n\t\telements = { %s }\n\t}\n}\n' "$(mk_elems 10)" > "$NFTD/set-syn4_443" +awk '{ if ($0 ~ /flags dynamic$/) { print "\t\tflags dynamic,timeout"; print "\t\ttimeout 1m" } else print }' "$NFTD/terse" > "$NFTD/terse.new" && mv "$NFTD/terse.new" "$NFTD/terse" +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" +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)" +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..8bf4021 --- /dev/null +++ b/tests/install-unit.sh @@ -0,0 +1,87 @@ +#!/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 +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..38e360d --- /dev/null +++ b/tests/nft-behavior.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# nft-behavior.sh — поведение per-IP лимитеров на НАСТОЯЩЕМ nft/ядре (два netns + veth). +# +# Юнит-тесты сверяют текст ruleset; здесь проверяется то, что текст не доказывает: +# 1. наборы-лимитеры создаются с timeout (записи истекают, а не копятся до ребута); +# 2. ПОЛНЫЙ набор не режет новых клиентов (fail-open): до v4.1.3 meter без timeout на +# 65535 записях отсекал каждый новый адрес — `… accept` для него не срабатывал, и +# пакет падал в `ct state new drop` (аудит флота: 22 764 адреса за 32 дня аптайма); +# 3. после простоя записи истекают, и лимит снова действует; +# 4. сам лимит работает: сверх burst новые SYN с одного адреса дропаются. +# Размер набора и timeout ужаты ручками NA_RATE_SET_SIZE/NA_RATE_TO_SEC, чтобы проверить +# переполнение и истечение за секунды. +# +# Нужны: 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 + +# ── 1. Генерация 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; TO=3s; 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 NA_RATE_SET_SIZE=$SIZE NA_RATE_TO_SEC=$TO \ + 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" + +# ── 2. Стенд: srv (ruleset) ←veth→ cli (10 адресов-источников) ───────────────── +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 +for i in $(seq 11 20); do ip -n "$CLI" addr add "10.77.0.$i/24" dev na-t1; 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" nft -f "$T/ruleset.nft" + +ip netns exec "$SRV" python3 - <<'PY' & +import socket, threading +s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("10.77.0.1", 8443)); s.listen(512) +while True: + c, _ = s.accept(); c.close() +PY +LPID=$! +sleep 1 + +connects() { # connects → печатает число удачных TCP-соединений + ip netns exec "$CLI" python3 - "$1" "$2" <<'PY' +import socket, sys +src, n, ok = sys.argv[1], int(sys.argv[2]), 0 +for _ in range(n): + try: + socket.create_connection(("10.77.0.1", 8443), timeout=0.7, source_address=(src, 0)).close(); ok += 1 + except OSError: + pass +print(ok) +PY +} +elems() { ip netns exec "$SRV" nft list set inet na_filter "$1" | sed -n '/elements = {/,/}/p' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | wc -l | tr -d ' '; } + +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; } +cond() { if eval "$2"; then PASS=$((PASS+1)); echo " ok $1"; else FAIL=$((FAIL+1)); echo " FAIL $1 ($2)"; fi; } + +echo "== наборы-лимитеры объявлены с timeout ==" +for st in syn4_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 + PASS=$((PASS+1)); echo " ok $st: flags dynamic,timeout + timeout" + else + FAIL=$((FAIL+1)); echo " FAIL $st: нет timeout в объявлении набора ($hdr)" + fi +done + +echo "== полный набор НЕ режет новых клиентов (fail-open) ==" +okc=0 +for i in $(seq 11 18); do okc=$(( okc + $(connects "10.77.0.$i" 1) )); done +check "8 новых адресов при наборе на $SIZE — все соединились" 8 "$okc" +check "в наборе ровно $SIZE записей (потолок)" "$SIZE" "$(elems syn4_8443)" + +echo "== записи истекают после простоя ($TO) ==" +sleep 5 +check "набор syn4_8443 пуст после простоя" 0 "$(elems syn4_8443)" + +echo "== лимит действует: сверх burst $BURST при $RATE/с новые SYN дропаются ==" +okr="$(connects 10.77.0.19 10)" +cond "из 10 быстрых соединений прошли не все (лимит сработал), получено $okr" '[[ "$okr" -lt 10 ]]' +cond "burst отработал: прошло не меньше $BURST, получено $okr" '[[ "$okr" -ge '"$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)" +cond "счётчик synflood_drop вырос (${drops:-0})" '[[ "${drops:-0}" -gt 0 ]]' + +echo +echo " прогон: $PASS ok, $FAIL fail" +[[ "$FAIL" -eq 0 ]] || { echo "NFT-BEHAVIOR: FAIL"; exit 1; } +echo "NFT-BEHAVIOR: OK (лимитеры с timeout, полный набор не режет клиентов, лимит работает)" From 59f6135c1e148931a9e74e895e5f2e461bcbe6ea Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 14:57:57 +0300 Subject: [PATCH 2/3] =?UTF-8?q?v4.1.3:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=E2=80=94=20SSH=20=D0=BD=D0=B5=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D0=BC=20ssh4,=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B1=D0=B5=D0=B7=20=D0=BB=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=82=D1=80=D0=B5=D0=B2=D0=BE=D0=B3,=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=B8=D0=B5=20=D0=BA=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protect: SSH на полном ssh4 — accept известным + общий потолок 30/мин, без fail-open; timeout наборов ≥ 2× восстановления корзины, 0 отвергается; сейфти прошлого прогона не снимается при отказе nft -f; skip-режим чистит .rejected/кандидатов; nft -t в fw-status - diagnose: dynset_fill только по именам лимитеров, meter nft 1.0.6 из правил, таймаут на подсчёт, ✘ по наборам без timeout; nft -t в проверках таблицы; пустой installed_at - install.sh: соседний scripts/ только доверенный (владелец root/SUDO_UID, без g/o-записи), NA_REQUIRE_SIG=1 и для локальных модулей; gpg: rc=0 и отказ на REVKEYSIG/EXPKEYSIG/… - common: явный LC_ALL=C/C.UTF-8 уважается; иначе LC_NUMERIC=C и LC_MESSAGES=C - tests: nft-behavior — контроль старой схемы, переход поверх meter, v6, SSH ban-once и потолок; install-unit — недоверенный scripts/, подписи локальных модулей; apply-smoke — потолок SSH, timeout от rate, сейфти прошлого прогона Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 68 ++++++++++++------- install.sh | 67 +++++++++++++++---- scripts/diagnose.sh | 80 +++++++++++++++-------- scripts/lib/common.sh | 24 ++++--- scripts/protect.sh | 81 +++++++++++++++-------- tests/apply-smoke.sh | 42 +++++++++++- tests/diagnose-unit.sh | 78 +++++++++++++++++++--- tests/install-unit.sh | 32 +++++++++ tests/nft-behavior.sh | 138 +++++++++++++++++++++++++++------------ 10 files changed, 456 insertions(+), 156 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 538411a..b930087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,8 @@ jobs: # пайплайн под 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 и проверки подписей. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0576bd3..842b852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,19 +23,31 @@ SIGPIPE, а `install.sh` в режиме `curl | bash` мог исполнить сутки на порт. До отказа — от полутора до трёх месяцев без перезагрузки, и наступил бы он молча: старые клиенты продолжают работать, отваливаются только новые. -Теперь каждый лимитер — объявленный набор `flags dynamic,timeout` с timeout (60s для -секундных лимитов, 10m для минутных), который `update` продлевает, пока адрес активен. -Правило построено «превысил → drop, иначе accept»: если набор всё же переполнит спуф-флуд с -десятков тысяч адресов, лимит для новых адресов отключается (их по-прежнему держат syncookies -и `ct count`), а не режет их. Сверх-лимитные SYN уходят в цепочку `synflood_drop` -(лог под rate-limit + счётчик), SSH сверх лимита — в `ssh_flood` с той же логикой ban-once, -что и раньше; последний `drop` в ней срабатывает, даже если сет suspect/autoban полон. +Теперь каждый лимитер — объявленный набор `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` очищает наборы, но вместе с ними и баны). В `--json` — +где правила ещё от 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. +`protect` на 4.1.3. Проверки «таблица na_filter есть» и `na-fw-status` теперь читают таблицу +без содержимого наборов (`nft -t`): лимитеры держат десятки тысяч записей. ### 🟠 Файл автозагрузки правил перезаписывался до проверки @@ -49,7 +61,8 @@ SIGPIPE, а `install.sh` в режиме `curl | bash` мог исполнить Теперь кандидат пишется рядом (`.na_filter.nft.XXXXXX`) и становится файлом автозагрузки только после `nft -c` **и** успешного `nft -f`; прежний файл уходит в бэкап прогона. Отвергнутый ruleset сохраняется как `na_filter.nft.rejected`, а при отказе `nft -f` -сейфти-таймер снимается. `rollback protect` убирает и эти файлы. +сейфти-таймер этого прогона снимается (взведённый прошлым неподтверждённым прогоном — +остаётся). `rollback protect` и переключение на `FW_MODE=skip` убирают и эти файлы. ### 🟠 Сенсоры `na-diagnose` гасли от SIGPIPE @@ -73,10 +86,15 @@ SIGPIPE, а `install.sh` в режиме `curl | bash` мог исполнить `SCRIPT_DIR` вычислялся из `${BASH_SOURCE[0]:-$0}`. При `curl … | sudo bash -s` `BASH_SOURCE` пуст, `$0` = `bash`, и каталогом скрипта становилась **текущая папка**. Если в ней был каталог `scripts/` — старый клон тулкита или подложенный `/tmp/scripts`, — root исполнял его, -минуя и скачивание по `NA_REF`, и проверку подписей `NA_REQUIRE_SIG=1`. Теперь модули рядом -берутся только когда `install.sh` запущен из файла, иначе всегда скачиваются во временный -каталог, который убирается на выходе. Заодно `--help` в `curl | bash`-режиме больше не читает -файл с именем `bash` из текущей папки. +минуя и скачивание по `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 печатает и для таких). ### 🟡 Мелкое @@ -86,21 +104,26 @@ SIGPIPE, а `install.sh` в режиме `curl | bash` мог исполнить есть `boot_id`, и флаг истинен, только пока загрузка та же; для старых маркеров сравнивается время загрузки с `installed_at`. В текстовом отчёте появилась строка ▲ об ожидании ребута. - **Числа — с точкой при любой локали.** На ноде с `LANG=de_DE.UTF-8` `load1` в `--json` - становился `0`, а `na-report --json` печатал `0,99`. Теперь `LC_NUMERIC=C`, а `LC_ALL` - переносится в `LANG`, чтобы кодировка вывода не менялась. + становился `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. Наборы - объявлены с timeout; переполненный набор не режет новых клиентов; записи истекают после - простоя; лимит действует, а счётчик `synflood_drop` растёт. +- `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/` его не исполняет. +- `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, @@ -111,7 +134,8 @@ nft 1.1.3 (Debian 13), на ядрах XanMod 6.18 и стоковом 6.12. Ре-ран `protect` на 4.1.3 пересоздаёт `na_filter` с новыми наборами; накопленные адреса лимитеров, а вместе с ними autoban и suspect, при этом обнуляются (как и при любом -ре-ране). Конфиг `protect.conf` не меняется, новых ручек для оператора нет. +ре-ране). Конфиг `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/install.sh b/install.sh index 154d5ae..dafdbff 100755 --- a/install.sh +++ b/install.sh @@ -23,15 +23,35 @@ # (стабильный JSON для мониторинга/панели — без повторного curl|bash). Снимается rollback'ом. set -euo pipefail -# Модули рядом с собой берём ТОЛЬКО когда install.sh запущен из файла. При curl|bash -# (`bash -s`) BASH_SOURCE пуст, а $0 = "bash" — до v4.1.3 тут был фоллбэк на $0, то есть -# SCRIPT_DIR = ТЕКУЩАЯ папка: лежит в ней каталог scripts/ (старый клон тулкита, чужой -# /tmp/scripts) — и root исполнял его, минуя скачивание по NA_REF И проверку подписей -# NA_REQUIRE_SIG=1. Теперь в curl|bash-режиме модули всегда скачиваются. +# Модули рядом с собой берём ТОЛЬКО когда 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 - SCRIPTS="$(cd "$(dirname "$NA_SELF")" && pwd)/scripts" + _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}" @@ -74,13 +94,19 @@ 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-код терялся в пайпе @@ -88,9 +114,13 @@ verify_sig() { # verify_sig <файл> # VALIDSIG эмитится ТОЛЬКО для криптографически валидной подписи и несёт полный fpr. # Вывод сначала целиком в переменную: gpg пишет статус построчно, и `| grep -q` # под pipefail закрывал пайп на VALIDSIG — следующая строка статуса ловила SIGPIPE, - # и валидная подпись отвергалась (141 вместо 0). - local st - st="$(gpg --status-fd=1 --verify "$file.asc" "$file" 2>/dev/null || true)" + # и валидная подпись отвергалась (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 @@ -103,7 +133,7 @@ if [[ -z "$SCRIPTS" || ! -d "$SCRIPTS" ]]; then 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" \ @@ -113,6 +143,15 @@ if [[ -z "$SCRIPTS" || ! -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 ec6fae3..4da7e27 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -209,33 +209,50 @@ reboot_pending() { 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/ -# анти-скан). До v4.1.3 они создавались без timeout: записи не истекали, и на полном -# наборе (65535) новые клиенты на порт отсекались (аудит флота: 22 764 адреса за 32 дня). -# stdout: «имяэлементовsizetimeout(1|0)» на набор; ct count-наборы (cc*/occ*) -# пропускаем — их ядро чистит само. rc=1 — таблицы нет. +# 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 name size to n - nft list table inet na_filter >/dev/null 2>&1 || return 1 - while IFS=$'\t' read -r name size to; do - [[ -n "$name" && "$size" =~ ^[0-9]+$ ]] || continue - n="$(nft_set_count inet na_filter "$name")"; [[ "$n" =~ ^[0-9]+$ ]] || n=0 + 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 < <(nft -t list table inet na_filter 2>/dev/null | awk ' - /^[[:space:]]*(set|meter) [A-Za-z0-9_]+ \{/ { name=$2; size=0; dyn=0; to=0; inset=1; next } - inset && /^[[:space:]]*size [0-9]+/ { size=$2 } - inset && /^[[:space:]]*flags / && /dynamic/ { dyn=1 } - inset && /^[[:space:]]*timeout / { to=1 } - inset && /^[[:space:]]*}/ { - if (dyn && size>0 && name !~ /^o?cc[46]/) printf "%s\t%s\t%s\n", name, size, to - inset=0 - }') + 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-поллинг) ───────── @@ -272,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:-}" @@ -803,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 @@ -889,25 +906,30 @@ if nft list table inet na_filter >/dev/null 2>&1; then fi # Наборы per-IP лимитеров. До v4.1.3 — без timeout: адреса копятся до ребута, и на # полном наборе новые клиенты на порт отсекаются (для ssh4 — новый IP админа в бан). - DS_MAX=-1; DS_MAXN=""; DS_NT=0; DS_N=0 + # максимум считаем ОТДЕЛЬНО по наборам без 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)"; } - [[ "$_dt" == "0" ]] && DS_NT=$((DS_NT+1)) + 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_MAX" -ge 80 ]]; then - bad "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2) заполнены до ${DS_MAX}%: $DS_MAXN — на 100% новые клиенты на порт отсекаются. Ре-ран protect (4.1.3+); пожарно: systemctl reload na-firewall (сбросит и баны)" + 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_MAX}% — $DS_MAXN. На 100% новые клиенты на порт отсекаются: ре-ран protect (4.1.3+)" + 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 + 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 @@ -983,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." @@ -1069,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" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index e2883a1..45b2838 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -8,16 +8,20 @@ # shellcheck disable=SC2034 NA_VERSION="4.1.3" -# Числа — только с десятичной ТОЧКОЙ. На ноде с 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 (аудит флота). LC_ALL -# перебивает любой LC_*, поэтому переносим его в LANG — кодировка и кириллица в выводе -# остаются как были (а с ними и разбор «д/Д» в подтверждениях), — и фиксируем LC_NUMERIC. -if [[ -n "${LC_ALL:-}" && "$LC_ALL" != C && "$LC_ALL" != POSIX ]]; then - export LANG="$LC_ALL" -fi -unset LC_ALL -export LC_NUMERIC=C +# Числа — только с десятичной ТОЧКОЙ, сообщения утилит — на 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/protect.sh b/scripts/protect.sh index bfb6256..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 @@ -600,19 +601,29 @@ NP_NFT="${NP_EFF//,/, }" # продлевает её на каждом пакете), а правило построено «превысил → drop, иначе accept». # Переполнение (спуф-флуд с десятков тысяч адресов) тогда выключает лимит для новых # адресов, а не отрезает их: syncookies и ct count продолжают работать. -# Timeout с запасом больше времени восстановления корзины (burst/rate): иначе адрес после -# паузы получал бы свежий burst. Секундные лимиты восстанавливаются за секунды — 60s; -# минутные (SSH, анти-скан) — за минуты, им 10m. Ручки — для тестов на настоящем nft. +# 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; } -_is_duration "$NA_RATE_TO_SEC" || { err "NA_RATE_TO_SEC='$NA_RATE_TO_SEC' — ожидается длительность (напр. 60s)"; exit 1; } -_is_duration "$NA_RATE_TO_MIN" || { err "NA_RATE_TO_MIN='$NA_RATE_TO_MIN' — ожидается длительность (напр. 10m)"; 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 <имя> +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 $3; }" + set $1 { type $2; size ${NA_RATE_SET_SIZE}; flags dynamic,timeout; timeout ${to}s; }" } # ─── Сборка per-port правил ────────────────────────────────────────────────── @@ -627,8 +638,8 @@ for p in ${TCP_PORTS//,/ }; do 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 "$NA_RATE_TO_SEC" - rate_set "syn6_${p}" ipv6_addr "$NA_RATE_TO_SEC" + 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="" @@ -644,8 +655,8 @@ for p in ${UDP_PORTS//,/ }; do 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 "$NA_RATE_TO_SEC" - rate_set "udp6_${p}" ipv6_addr "$NA_RATE_TO_SEC" + 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 @@ -749,8 +760,8 @@ else 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 "$NA_RATE_TO_SEC" - rate_set na6 ipv6_addr "$NA_RATE_TO_SEC" + rate_set na4 ipv4_addr 30 60 second + rate_set na6 ipv6_addr 30 60 second fi # portscan → autoban (включается флагом). При ENABLE_BANONCE=1 — двухступенчато: @@ -787,9 +798,11 @@ if [[ "$ENABLE_PORTSCAN_BAN" == "1" && "$FW_MODE" != "open" ]]; then fi # наборы анти-скана — вне блока выше: tests/protect-unit.sh исполняет его отдельно if [[ -n "$PORTSCAN" ]]; then - rate_set ps4 ipv4_addr "$NA_RATE_TO_MIN"; rate_set ps6 ipv6_addr "$NA_RATE_TO_MIN" + 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 "$NA_RATE_TO_MIN"; rate_set psc6 ipv6_addr "$NA_RATE_TO_MIN" + rate_set psc4 ipv4_addr "$PORTSCAN_RATE" "$PORTSCAN_BURST" minute + rate_set psc6 ipv6_addr "$PORTSCAN_RATE" "$PORTSCAN_BURST" minute fi fi @@ -863,12 +876,20 @@ fi # иначе прямой бан), в пределах лимита → 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 accept" -rate_set ssh4 ipv4_addr "$NA_RATE_TO_MIN" -rate_set ssh6 ipv6_addr "$NA_RATE_TO_MIN" + 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_FLOOD_CHAIN=" # SSH сверх лимита (ban-once): 1-й раз suspect+drop, повтор в окне ${SUSPECT_TIME} → бан ${SSH_BAN_TIME} chain ssh_flood { @@ -911,13 +932,13 @@ if [[ "$FW_MODE" == "open" ]]; then 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 "$NA_RATE_TO_SEC"; rate_set osyn6 ipv6_addr "$NA_RATE_TO_SEC" - rate_set oudp4 ipv4_addr "$NA_RATE_TO_SEC"; rate_set oudp6 ipv6_addr "$NA_RATE_TO_SEC" + 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 "$NA_RATE_TO_SEC" -rate_set icmp6 ipv6_addr "$NA_RATE_TO_SEC" +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). Кандидат пишется рядом @@ -1135,12 +1156,17 @@ if [[ "$DRY_RUN" == "1" ]]; then fi # ─── Применяем (с сейфти-таймером) ─────────────────────────────────────────── +# Был ли сейфти взведён ДО нас (прошлый неинтерактивный прогон без подтверждения): +# arm_safety его перевзводит, и при отказе nft -f его надо вернуть, а не снять. +SAFETY_PRE=0 +{ 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_PRE=1 arm_safety if ! nft -f "$NFT_NEW"; then # Ядро отвергло транзакцию целиком — живая таблица осталась прежней. Взведённый сейфти # через SAFETY_DELAY снёс бы её И выключил автозагрузку: нода, которой ре-ран не - # изменил ничего, осталась бы без защиты. - disarm_safety + # изменил ничего, осталась бы без защиты. Чужой (прошлого прогона) сейфти — оставляем. + [[ "$SAFETY_PRE" == "1" ]] || disarm_safety mv -f "$NFT_NEW" "$NFT_REJECTED" err "nft -f не применил ruleset (ядро отвергло транзакцию) — живые правила и файл автозагрузки прежние. Отвергнутый ruleset: $NFT_REJECTED" exit 1 @@ -1663,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/tests/apply-smoke.sh b/tests/apply-smoke.sh index b527aff..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) деградируют мягко, не висят. @@ -111,8 +115,16 @@ grep -qE '(@osyn|meter occ|@oudp)' "$NFTF" && { echo "[x] strict: generic open- # 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 10m; }' "$NFTF" \ - || { echo "[x] strict: набор ssh4 без timeout 10m"; 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 @@ -259,6 +271,22 @@ 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: провал проверки печатал «ничего не # применено», а битый файл уже стоял на месте рабочего и поднимался первым же ребутом. @@ -293,6 +321,16 @@ for mode in check apply; do || { 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 diff --git a/tests/diagnose-unit.sh b/tests/diagnose-unit.sh index 35beb34..6ade048 100755 --- a/tests/diagnose-unit.sh +++ b/tests/diagnose-unit.sh @@ -234,6 +234,10 @@ case "$*" in "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 "*) # ${*##* } раскрывается по КАЖДОМУ параметру, а не по склейке — берём имя набора # через промежуточную переменную @@ -523,13 +527,26 @@ env TERM=dumb NA_TEST_LR_DUP=1 "$WBASH" "$DIAG" > "$T/out8.txt" 2>/dev/null || t grep_ok "дубликат в большом выводе logrotate -d найден" "logrotate: дубликат путей" "$T/out8.txt" echo "== 9. наборы-лимитеры: без timeout копят адреса, на 100% режут клиентов (v4.1.3) ==" -# legacy (≤4.1.2): syn4_443 без timeout, 85 из 100; cc4_443 — ct count, ядро чистит само +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 @@ -542,24 +559,64 @@ table inet na_filter { } } TERSE -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"; } -printf 'table inet na_filter {\n\tset syn4_443 {\n\t\ttype ipv4_addr\n\t\tsize 100\n\t\tflags dynamic\n\t\telements = { %s }\n\t}\n}\n' "$(mk_elems 85)" > "$NFTD/set-syn4_443" -printf 'table inet na_filter {\n\tset cc4_443 {\n\t\ttype ipv4_addr\n\t\tsize 65535\n\t\tflags dynamic\n\t\telements = { %s }\n\t}\n}\n' "$(mk_elems 99)" > "$NFTD/set-cc4_443" +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" 85 "$(jget "$T/out9.json" dynset_fill_max_pct)" +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 (cc4 не в счёт)" 1 "$(jget "$T/out9.json" dynset_no_timeout)" +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" -# v4.1.3: тот же набор с timeout, 10 из 100 → ✔ -printf 'table inet na_filter {\n\tset syn4_443 {\n\t\ttype ipv4_addr\n\t\tsize 100\n\t\tflags dynamic,timeout\n\t\ttimeout 1m\n\t\telements = { %s }\n\t}\n}\n' "$(mk_elems 10)" > "$NFTD/set-syn4_443" -awk '{ if ($0 ~ /flags dynamic$/) { print "\t\tflags dynamic,timeout"; print "\t\ttimeout 1m" } else print }' "$NFTD/terse" > "$NFTD/terse.new" && mv "$NFTD/terse.new" "$NFTD/terse" + +# 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)" @@ -577,6 +634,9 @@ check "другой boot_id → ребут был → false" false "$(jget "$T/o 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 diff --git a/tests/install-unit.sh b/tests/install-unit.sh index 8bf4021..98cb085 100755 --- a/tests/install-unit.sh +++ b/tests/install-unit.sh @@ -81,6 +81,38 @@ if grep -q DECOY-HELP "$T/out3.log"; then fail_ "справка напечата 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; } diff --git a/tests/nft-behavior.sh b/tests/nft-behavior.sh index 38e360d..b516bef 100755 --- a/tests/nft-behavior.sh +++ b/tests/nft-behavior.sh @@ -3,13 +3,16 @@ # nft-behavior.sh — поведение per-IP лимитеров на НАСТОЯЩЕМ nft/ядре (два netns + veth). # # Юнит-тесты сверяют текст ruleset; здесь проверяется то, что текст не доказывает: -# 1. наборы-лимитеры создаются с timeout (записи истекают, а не копятся до ребута); -# 2. ПОЛНЫЙ набор не режет новых клиентов (fail-open): до v4.1.3 meter без timeout на -# 65535 записях отсекал каждый новый адрес — `… accept` для него не срабатывал, и -# пакет падал в `ct state new drop` (аудит флота: 22 764 адреса за 32 дня аптайма); -# 3. после простоя записи истекают, и лимит снова действует; -# 4. сам лимит работает: сверх burst новые SYN с одного адреса дропаются. -# Размер набора и timeout ужаты ручками NA_RATE_SET_SIZE/NA_RATE_TO_SEC, чтобы проверить +# 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 @@ -30,7 +33,7 @@ cleanup() { } trap cleanup EXIT -# ── 1. Генерация ruleset тем же protect.sh (DRY_RUN: генерация + nft -c на этом ядре) ── +# ── Генерация ruleset тем же protect.sh (DRY_RUN: генерация + nft -c на этом ядре) ── cp -R "$REPO_ROOT/scripts" "$T/scripts" cat >> "$T/scripts/lib/common.sh" <<'STUB' apt_install(){ :; } @@ -38,81 +41,130 @@ default_iface(){ echo na-wan0; } detect_ssh_port(){ echo 22; } ssh_client_ip(){ :; } STUB -SIZE=4; TO=3s; RATE=2; BURST=3 +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 NA_RATE_SET_SIZE=$SIZE NA_RATE_TO_SEC=$TO \ + 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 -# ── 2. Стенд: srv (ruleset) ←veth→ cli (10 адресов-источников) ───────────────── +# ── Стенд: 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" nft -f "$T/ruleset.nft" ip netns exec "$SRV" python3 - <<'PY' & import socket, threading -s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -s.bind(("10.77.0.1", 8443)); s.listen(512) -while True: - c, _ = s.accept(); c.close() +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 - "$1" "$2" <<'PY' +connects() { # connects → число удачных TCP-соединений + ip netns exec "$CLI" python3 - "$@" <<'PY' import socket, sys -src, n, ok = sys.argv[1], int(sys.argv[2]), 0 +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: - socket.create_connection(("10.77.0.1", 8443), timeout=0.7, source_address=(src, 0)).close(); ok += 1 + s.bind((src, 0)); s.connect((dst, port)); ok += 1 except OSError: pass + finally: + s.close() print(ok) PY } -elems() { ip netns exec "$SRV" nft list set inet na_filter "$1" | sed -n '/elements = {/,/}/p' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | wc -l | tr -d ' '; } +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; } -cond() { if eval "$2"; then PASS=$((PASS+1)); echo " ok $1"; else FAIL=$((FAIL+1)); echo " FAIL $1 ($2)"; 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 "== наборы-лимитеры объявлены с timeout ==" -for st in syn4_8443 udp4_8443 ssh4 icmp4 ps4; do +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 - PASS=$((PASS+1)); echo " ok $st: flags dynamic,timeout + timeout" - else - FAIL=$((FAIL+1)); echo " FAIL $st: нет timeout в объявлении набора ($hdr)" - fi + if [[ "$hdr" == *"dynamic,timeout"* && "$hdr" == *" timeout "* ]]; then ok_ "$st: flags dynamic,timeout + timeout" + else fail_ "$st: нет timeout в объявлении набора ($hdr)"; fi done -echo "== полный набор НЕ режет новых клиентов (fail-open) ==" -okc=0 -for i in $(seq 11 18); do okc=$(( okc + $(connects "10.77.0.$i" 1) )); done -check "8 новых адресов при наборе на $SIZE — все соединились" 8 "$okc" -check "в наборе ровно $SIZE записей (потолок)" "$SIZE" "$(elems syn4_8443)" +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 "== записи истекают после простоя ($TO) ==" +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 -check "набор syn4_8443 пуст после простоя" 0 "$(elems syn4_8443)" -echo "== лимит действует: сверх burst $BURST при $RATE/с новые SYN дропаются ==" -okr="$(connects 10.77.0.19 10)" -cond "из 10 быстрых соединений прошли не все (лимит сработал), получено $okr" '[[ "$okr" -lt 10 ]]' -cond "burst отработал: прошло не меньше $BURST, получено $okr" '[[ "$okr" -ge '"$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)" -cond "счётчик synflood_drop вырос (${drops:-0})" '[[ "${drops:-0}" -gt 0 ]]' +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 (лимитеры с timeout, полный набор не режет клиентов, лимит работает)" +echo "NFT-BEHAVIOR: OK (старая схема режет новых, новая — нет; лимит, истечение, SSH ban-once и потолок работают)" From e91f6af153e8249a48e460da9fa0828ee4d7736f Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 16:40:22 +0300 Subject: [PATCH 3/3] release: sign 6 modules with minisign (v4.1.3) Co-Authored-By: Claude Opus 5.5 --- scripts/diagnose.sh.minisig | 8 ++++---- scripts/lib/common.sh.minisig | 8 ++++---- scripts/na-report.sh.minisig | 6 +++--- scripts/optimize.sh.minisig | 8 ++++---- scripts/protect.sh.minisig | 8 ++++---- scripts/rollback.sh.minisig | 8 ++++---- 6 files changed, 23 insertions(+), 23 deletions(-) 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.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.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.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.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==