diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f96da20..6b45554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,32 @@ jobs: # решает вердикт самого logrotate, а не сравнение строк (#25), и глобы # NA_LOG_PATHS не раскрываются шеллом в явные пути. run: bash tests/logrotate-unit.sh + - name: common unit (счёт nft-сетов, json_escape, tty_tput, дрейф дефолтов conf) + # nft_set_count считает адреса, а не строки с 'timeout' (#32); json_escape не даёт + # переводу строки сломать --json (#27); tput молчит не в терминале (#28); conf + # с замороженным старым дефолтом → warn / NA_ADOPT_NEW_DEFAULTS=1 / маркер explicit (#34). + run: bash tests/common-unit.sh + - name: rps unit (ИСПОЛНЯЕТ сгенерированный na-rps-setup против стабов ip/nproc/sysfs) + # Юнит рапортовал success при неприменённом RPS: default route на буте появляется + # позже network-online.target, скрипт молча выходил нулём (#30). Теперь NIC из + # ExecStart, ожидание маршрута, фолбэк на физические, честный отказ; плюс #28 — + # в перенаправленном stdout нет escape-кодов курсора. + run: bash tests/rps-unit.sh + - name: psi unit (секция PSI optimize.sh + откат против стаба update-grub) + # ENABLE_PSI=1 дописывает psi=1 в GRUB_CMDLINE_LINUX_DEFAULT идемпотентно, =0 не + # трогает файл, rollback снимает ТОЛЬКО свой psi=1 (маркер) (#37). + run: bash tests/psi-unit.sh + - name: protect unit (na-fw-status / add_wl / сборка PORTSCAN / бюджет журнала против стабов) + # Счётчики na-fw-status считают адреса (#32); дубли и широкие CIDR в WHITELIST + # замечаются, /32 нормализуется, дедуп доезжает до nft/CrowdSec-yaml (#38); + # PORTSCAN_LOG_RATE=0 снимает лог, не бан; бюджет журнала считается (#35). + run: bash tests/protect-unit.sh + - name: diagnose unit (ИСПОЛНЯЕТ обе ветки diagnose против стабов docker/nft/ss/cscli/journalctl) + # --json валиден на хосте без контейнера node-агента (#27); счётчики autoban/suspect + # считают адреса (#32); датчик CONN_LIMIT без loopback/whitelist (#31); дрейф + # whitelist относительно protect.conf (#38); PSI три состояния (#37); серты в + # /opt/*/certs и снятые с renew (#39); глубина журнала (#35); RPS-юнит vs факт (#30). + run: bash tests/diagnose-unit.sh # ── Smoke-тесты на матрице дистрибутивов ──────────────────────────────────── # Ловит ровно тот класс регрессий, из-за которых появился этот PR: установка @@ -155,6 +181,42 @@ jobs: DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 FLEET_SYNC=1 \ REMNAWAVE_NODES_URL=https://panel.example.com/fleet/nodes.json \ bash scripts/protect.sh + - name: protect.sh DRY_RUN (PORTSCAN_LOG_RATE=0 — лог анти-скана не ставится, бан остаётся) + run: | + DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 PORTSCAN_LOG_RATE=0 bash scripts/protect.sh + F="$(ls -t /tmp/na_filter.*.nft | head -1)" + grep -q '\[na portscan\]' "$F" && { echo "лог анти-скана не должен ставиться при PORTSCAN_LOG_RATE=0"; exit 1; } + grep -q 'add @autoban_v4' "$F" || { echo "бан за скан пропал вместе с логом"; exit 1; } + echo "ok: лог снят, бан на месте" + - name: protect.sh DRY_RUN (дефолтный рейт лога анти-скана → nft -c) + отказ на кривом PORTSCAN_LOG_RATE + run: | + DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh + F="$(ls -t /tmp/na_filter.*.nft | head -1)" + grep -q 'limit rate 60/minute burst 30 packets log prefix "\[na portscan\] "' "$F" || { echo "лог анти-скана не на 60/minute burst 30"; exit 1; } + if PORTSCAN_LOG_RATE=abc DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh 2>/dev/null; then + echo "валидация НЕ сработала на кривом PORTSCAN_LOG_RATE"; exit 1 + else echo "ok: кривой PORTSCAN_LOG_RATE отвергнут"; fi + - name: protect.sh DRY_RUN (дубль и широкий CIDR в WHITELIST → warn, в ruleset один адрес) + run: | + OUT="$(DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 \ + WHITELIST='203.0.113.7,203.0.113.7,203.0.113.0/24' bash scripts/protect.sh)" + echo "$OUT" | grep -q 'указан дважды' || { echo "дубль в WHITELIST не замечен"; exit 1; } + echo "$OUT" | grep -q 'ПОЛНЫЙ обход защиты' || { echo "широкий CIDR не замечен"; exit 1; } + F="$(ls -t /tmp/na_filter.*.nft | head -1)" + [ "$(grep -o '203\.0\.113\.7' "$F" | wc -l)" -eq 1 ] || { echo "дубль уехал в ruleset"; exit 1; } + echo "ok: дедуп + warn о широком CIDR" + - name: "protect.sh DRY_RUN — замороженный дефолт в protect.conf (#34): warn, adopt, explicit" + run: | + mkdir -p /etc/node-accelerator + printf '# node-accelerator — сохранённый конфиг ноды @ 2026-07-11\n: "${CROWDSEC_STRICT:=0}"\n' > /etc/node-accelerator/protect.conf + OUT="$(DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh)" + echo "$OUT" | grep -q 'CROWDSEC_STRICT=0 записан старой версией' || { echo "нет warn о замороженном дефолте"; exit 1; } + OUT="$(DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 NA_ADOPT_NEW_DEFAULTS=1 bash scripts/protect.sh)" + echo "$OUT" | grep -q 'CROWDSEC_STRICT=0 → 1 — принят дефолт' || { echo "NA_ADOPT_NEW_DEFAULTS=1 не принял дефолт"; exit 1; } + OUT="$(DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 CROWDSEC_STRICT=0 bash scripts/protect.sh)" + echo "$OUT" | grep -q 'записан старой версией' && { echo "warn при явном ENV — лишний"; exit 1; } + rm -f /etc/node-accelerator/protect.conf + echo "ok: дрейф дефолтов conf — warn / adopt / explicit" - name: CrowdSec-репозиторий резолвится (CROWDSEC_PROBE, без установки) run: CROWDSEC_PROBE=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh - name: install.sh persist → CLI na-diagnose + na-report создаются и отдают JSON diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d301b1..8b71ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,139 @@ # Changelog +## v4.1 — 2026-09-02 + +Релиз по итогам аудита боевого флота (11 нод + панель на v4.0.1, Debian 12/13, XanMod 6.18): +десять issue ([#27](https://github.com/jestivald/node-accelerator/issues/27)–[#39](https://github.com/jestivald/node-accelerator/issues/39)), +почти все — про то, что тулкит **врал о своём состоянии**: сенсоры считали не то, что обещали, +юнит рапортовал успех при неприменённом тюнинге, а сохранённый конфиг молча держал дефолты +прошлой версии. Один дефолт сменился осознанно (рейт лога анти-скана), одна ручка появилась +для нового opt-in (PSI). Всё остальное — аддитивно. + +### 🔴 Сохранённый конфиг пиннил дефолты старой версии (#34) + +`/etc/node-accelerator/*.conf` подхватывается идиомой `: "${KEY:=value}"`, чтобы ре-ран без ENV не +сбрасывал ручки, поднятые под ноду. Обратная сторона: файл так же пиннил и **дефолты той версии, +при которой ноду настраивали**, и встроенный дефолт новой версии не мог выиграть у записанного +никогда. Ужесточение `CROWDSEC_STRICT` `0→1` из v4.0 на пяти из восьми хостов флота так и не +применилось — при том что оператор считал флот «переведённым на v4», а ни `protect`, ни `na-diagnose` +о расхождении не говорили. + +Теперь тулкит различает «оператор задал» и «записан тогдашний дефолт». `save_conf` помечает ключи, +пришедшие из ENV, маркером `# explicit: KEY` (и пишет `# na_version=`); ключ без маркера, пиннящий +старый дефолт из таблицы `NA_DEFAULT_CHANGES` в `lib/common.sh`, — заморозка: `load_conf` печатает +`[!] conf: CROWDSEC_STRICT=0 записан старой версией, а дефолт с v4.0 = 1 (…)`, `na-diagnose` даёт +отдельный ▲ и поле `conf_stale_defaults` в `--json`. Принять все новые дефолты разом — +`NA_ADOPT_NEW_DEFAULTS=1` при ре-ране; оставить старое осознанно — задать `KEY=old` в ENV один раз, +маркер переживёт последующие ре-раны. Таблицу пополнять при каждой смене дефолта. + +### 🔴 `--json` ломался на хосте без контейнера node-агента (#27, #33) + +`docker inspect -f` по несуществующему контейнеру на Docker 29.x печатает в stdout пустую строку и +только потом падает, а `$(… || echo absent)` дописывал `absent` ПОСЛЕ неё: значение становилось +`"\nabsent"`, ветка «контейнера нет» была недостижима (ложный ✘ «node-агент лежит» на панели и +CDN-origin), а сырой перевод строки внутри JSON-строки валил парсер на всём документе — ровно на +тех хостах, где мониторинг по `--json` и хочется собирать. Значение нормализуется (`tr -d +'[:space:]'`, пусто → absent), и **все строковые поля `emit_json` теперь идут через `json_escape`** +(`lib/common.sh`): любое будущее «сырое» значение больше не сможет сломать документ. Имя контейнера — +ручка `NA_NODE_CONTAINER` (по умолчанию `remnanode`). + +### 🔴 Счётчики банов считали строки вывода nft, а не адреса (#32, #36) + +`grep -c timeout` по `nft list set` матчил строку заголовка `flags dynamic,timeout`, которая есть +всегда: пустой набор показывался как «1», непустой завышался на 1 (с `expires` — на 2), а +`na-fw-status` и `na-diagnose` считали одну величину тремя способами и расходились между собой. +В `--json` это уходило во флот-мониторинг: алерт «autoban > 0» горел бы постоянно. Единый +`nft_set_count` в `lib/common.sh` считает адреса внутри блока `elements = { … }` (v4/v6/CIDR, +многострочный вывод); на него переведены все счётчики в `na-diagnose`, `--json` и `na-fw-status` +(в хелпер функция инжектируется при генерации — common.sh на ноде может и не быть). При ненулевом +autoban печатаются сами адреса: оператору важно «кто», а не «сколько». Той же породы — +`cscli decisions list -o raw` печатает CSV с заголовком, и он попадал в счёт активных решений. + +### 🟠 Датчик `CONN_LIMIT` считал loopback, вайтлист и исходящие (#31) + +`ss -tnH state established` отдавал ВСЕ сокеты, а правило `ct count` стоит после `iif lo accept` и +`ip saddr @whitelist_v4 accept` и только на `ct state new` к сервисным портам. На ноде с внутренним +nginx-фолбэком loopback-пар всегда тысячи, и датчик горел постоянно, подталкивая ровно к вредному +действию — поднять `CONN_LIMIT`. Теперь считается тот же срез, что видит правило: входящие +established на порты из `tcp_ports=` маркера, без `127/8`, `::1` и членов whitelist (точные адреса и +CIDR). В `--json` — `max_conn_per_ip`. + +### 🟠 `na-rps.service` рапортовал success при неприменённом RPS (#30) + +`na-rps-setup` брал интерфейс по default route в момент запуска и при его отсутствии молча выходил с +кодом 0; `network-online.target` на Debian 13 достигается раньше, чем появляется маршрут, — и +`RemainAfterExit=yes` превращал это в `active (exited)` навсегда при `rps_cpus=0`. Без RPS на +single-queue virtio весь RX-softirq висит на cpu0. Юнит теперь получает интерфейс аргументом из +детекта на момент `optimize` (с фолбэком на автодетект, если после смены ядра его переименовали), +скрипт ждёт маршрут до 20 с, затем берёт все физические интерфейсы, а если применять не к чему — +выходит ненулём под `Restart=on-failure` (с `StartLimit*`, чтобы бокс без сети не перезапускал юнит +вечно). Строка `na-rps: NIC=… mask=…` в журнале — маркер успеха на каждый интерфейс. `na-diagnose` +больше не печатает «✔ na-rps.service активен» рядом с «▲ RPS выключен». + +### 🟠 Лог анти-скана съедал журнал за сутки (#35) + +`limit rate 5/second` для `[na portscan]` — до 432 000 строк в сутки, а `SystemMaxUse=300M`, который +ставит тот же тулкит, при этом вмещал на публичной ноде **меньше суток** (замеры флота: 17–20 ч на +CDN-origin и selfsteal-ноде против месяца на ноде, куда скан не доходит). На Debian 13 minimal +journald — единственный источник истории входов, так что разбор инцидента старше вчерашнего дня +был невозможен. Дефолт снижен до `PORTSCAN_LOG_RATE=60` в минуту (`PORTSCAN_LOG_BURST=30`) — +`na-report` по-прежнему видит топ сканеров, а бан и так работает по счётчикам, не по логу (`0` — +лог не ставить вовсе). Остальные лог-правила (`[na synflood]`/`[na ssh-flood]`/`[na badflags]`) +не тронуты: они срабатывают только сверх лимитов и объёма не создают. `protect` считает суточный +бюджет (по ~500 Б на запись journald с метаданными, не по длине строки) против капа journald и +предупреждает, если он больше 30%; кап — новая ручка `optimize` `NA_JOURNAL_MAX_USE` (по умолчанию +прежние `300M`). `na-diagnose` показывает глубину журнала в часах (`journal_span_h`, ▲ при <48 ч) +и число строк `[na portscan]` за загрузку. + +### 🟠 «PSI недоступен (старое ядро)» на ядре 6.18 (#37, #29) + +XanMod (как и сток Debian) собран с `CONFIG_PSI_DEFAULT_DISABLED=y`: PSI есть, но без `psi=1` в +cmdline `/proc/pressure` не появляется. Сенсор давления молчал на 100% боксов и объяснял это неверно. +Теперь различаются три состояния (не собран / выключен в сборке / работает; поле `psi` в `--json`), +а `optimize` умеет включить: `ENABLE_PSI=1` дописывает `psi=1` в `GRUB_CMDLINE_LINUX_DEFAULT` +(opt-in — учёт PSI не бесплатен для планировщика; идемпотентно; нужен ребут, о чём финальный warn +теперь говорит отдельно от «новое ядро»). `rollback optimize` снимает `psi=1` только если его +дописали мы (метка `psi=1` в маркере) — чужой параметр загрузки не наш артефакт. + +### 🟠 Whitelist: «переживёт ребут» — но не ре-ран, дубли и широкие CIDR (#38) + +Сверка whitelist смотрела на живой сет и `na_filter.nft`, но не на `protect.conf` — а именно он +вход для ре-рана `protect`. Адрес, добавленный транзитно (авто-whitelist SSH-IP при прогоне) или +руками, диагностика объявляла здоровым, а первый ре-ран из другой сессии его молча выбрасывал. +Теперь три источника и два вердикта — «переживёт ребут» и «переживёт ре-ран», с адресами по имени +(и пометкой, если это IP текущей сессии); в `--json` — `whitelist_drift_conf`. В `add_wl` появился +дедуп с нормализацией `/32` и `/128` (дубль из CSV раньше уезжал в nft-сет, `na_filter.nft` и +CrowdSec-yaml — последний строился из сырого `WHITELIST` и теперь берёт разобранный список) и +предупреждение о префиксах шире `/29` (v6 — `/64`): whitelist в na — полный обход автобана, CrowdSec +и лимитов, а не «доверенный список». `protect.conf` хранит `WHITELIST` дословно, как задал оператор. + +### 🟠 Сенсор сертификатов был слеп к нестандартному каталогу и считал мёртвые (#39) + +Сканировались только `/etc/letsencrypt/live` и `/root/.acme.sh`; серт фасада в `/opt/*/certs/` +не находился, и `cert_min_days=-1` означало одновременно «сертов нет» и «сенсор слеп». Добавлены +`/opt/*/certs/*/fullchain.{cer,pem}`; каталоги acme.sh, снятые с renew (`*.conf.removed`), больше не +считаются; когда сертов не найдено, а на :443/:8443 кто-то слушает — ▲ вместо тихого info. +В `--json` — `cert_min_file`. + +### 🟢 Escape-коды курсора в логах (#28) + +`tput civis/cnorm` решает по `TERM`, а не по `isatty`: под `nohup … > log` в файл уезжали +`[?25h`. Всё «курсорное» — через `tty_tput` (`lib/common.sh`), прогресс-бар не в терминале не +рисуется. + +### 🧪 Тесты + +`tests/common-unit.sh` (счёт nft-сетов, `json_escape`, `tty_tput`, дрейф дефолтов conf — +warn/adopt/explicit), `tests/diagnose-unit.sh` (ИСПОЛНЯЕТ diagnose против стабов docker/nft/cscli/ +ss/journalctl: валидный `--json`, absent, счётчики, датчик CONN_LIMIT, дрейф whitelist, PSI), +`tests/rps-unit.sh` (сгенерированный `na-rps-setup` против стабов ip/nproc/sysfs: гонка с маршрутом, +фолбэки, честный отказ; отсутствие escape-кодов в перенаправленном выводе), `tests/psi-unit.sh` +(секция PSI и откат против стаба `update-grub`: идемпотентность, чужой `psi=1` не трогается), +`tests/protect-unit.sh` (`na-fw-status` со стабом nft, `add_wl` дедуп/CIDR, сборка `PORTSCAN`, +бюджет журнала, полный apply со стабами). Все на коде v4.0.1 красные. В CI добавлены DRY_RUN-проверки +дрейфа дефолтов (warn / adopt / explicit), `PORTSCAN_LOG_RATE=0` и отказа на кривом значении, +дедупа и широкого CIDR в `WHITELIST`. + ## v4.0.1 — 2026-08-31 Два бага секции ротации логов, пойманные в день релиза при раскатке v4.0 на флот diff --git a/README.md b/README.md index 07756ad..cea9d87 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,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; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`), **свежесть 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). После установки доступна как команда **`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 `. @@ -118,11 +118,11 @@ curl -fsSL https://raw.githubusercontent.com/jestivald/node-accelerator/main/ins # прод-режим: пиньте тег через NA_REF — компрометация ветки main тогда не утечёт # сразу на весь флот (скрипты тянутся из того же тега): -export NA_REF=v4.0.1 +export NA_REF=v4.1 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.0.1 NA_REQUIRE_SIG=1 \ +export NA_REF=v4.1 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 ``` @@ -139,7 +139,7 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `SSH_PORT` | авто-детект | порт(ы) SSH через запятую. Детект: активный `ssh.socket`/`sshd.socket` → `sshd -T` (учитывает `sshd_config.d/*`) → `ss` → `sshd_config`. Порт текущей SSH-сессии добавляется к правилам автоматически + warn | | `TCP_PORTS` / `UDP_PORTS` | `443,2087` | сервисные порты | | `NODE_PORT` | `auto` | порт(ы) node-agent через запятую. `auto` — детект с ноды (env контейнера `remnawave/node` → `.env` → `ss`; агент молчит → прошлый детект, иначе оба дефолта `2222,3000`). Явный порт ≠ факту → правила на оба + warn | -| `WHITELIST` | _пусто_ | IP/CIDR (v4+v6) панели/мониторинга — никогда не банятся | +| `WHITELIST` | _пусто_ | IP/CIDR (v4+v6) панели/мониторинга — никогда не банятся. Это **полный обход** защиты (accept раньше автобана/CrowdSec/лимитов), а не «доверенный список»: префиксы шире `/29` (v6 — `/64`) вызывают предупреждение; дубли и `/32` нормализуются перед записью в правила | | `SYN_RATE`/`SYN_BURST` | `200`/`400` | **per-IP** новых TCP-конн./сек на порт | | `UDP_RATE`/`UDP_BURST` | `200`/`400` | **per-IP** UDP пакетов/сек | | `UDP_BULK_PORTS` | _пусто_ | порты объёмного UDP-туннеля (Hysteria2/TUIC). Общий `UDP_RATE` — это потолок около 2 Мбит/с: он душит туннель, клиент ретранслитит и видит огромную задержку. Порт должен быть и в `UDP_PORTS` | @@ -150,6 +150,7 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `SSH_BAN_TIME`/`PORTSCAN_BAN_TIME` | `24h`/`1h` | сроки бана | | `ENABLE_PORTSCAN_BAN` | `1` | автобан за скан закрытых портов | | `PORTSCAN_RATE`/`PORTSCAN_BURST` | `15`/`30` | порог скана (SYN на закрытые порты/мин, per-IP) до бана — ниже порога просто дроп, без бана | +| `PORTSCAN_LOG_RATE`/`PORTSCAN_LOG_BURST` | `60`/`30` | сколько строк `[na portscan]` в минуту уходит в journald (до v4.1 — `5/second` ≈ 432 000 строк/сутки: на публичной ноде журнал в 300M жил меньше суток и форензика старше вчера была невозможна). Бан работает по счётчикам, не по логу; `0` — лог анти-скана не ставить. `protect` считает суточный бюджет против капа journald и предупреждает, если он больше 30% | | `ENABLE_CROWDSEC` | `1` | ставить CrowdSec + bouncer | | `CROWDSEC_STRICT` | `1` | только пиннингованный APT-репо; не поднялся → CrowdSec пропускается. `0` возвращает `curl\|bash`-фоллбэк, который атакующий может форсировать, сделав репозиторий недоступным | | `ENABLE_SYNPROXY` | `0` | nft synproxy на сервисные порты (advanced). На VPN-relay избыточен — syncookies + per-IP ct-лимиты уже дают анти-спуф; включать под подтверждённый спуф-SYN-флуд | @@ -157,6 +158,7 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `SAFETY_DELAY` | `300` | сек до авто-сброса правил, если не подтвердить SSH. Сброс снимает и таблицу, и автозагрузку (`na-firewall.service`) — иначе локаут вернулся бы ребутом | | `FLEET_SYNC_INTERVAL` | `5min` | интервал fleet-sync (персистится; `na-diagnose` считает свежесть по нему, а не по дефолту) | | `NA_NO_LOCK` | `0` | `1` — не брать flock (по умолчанию параллельный второй прогон отказывается стартовать) | +| `NA_ADOPT_NEW_DEFAULTS` | `0` | `1` — принять новые дефолты тулкита разом там, где сохранённый конфиг пиннит старый (см. [Сохранённый конфиг и смена дефолтов](#сохранённый-конфиг-и-смена-дефолтов)). Работает и для `optimize` | | `DRY_RUN` | `0` | `1` — только сгенерировать + `nft -c`, не применять | | `ENABLE_BANONCE` | `1` | двухступенчатый автобан (suspect→confirmed), анти-CGNAT-FP | | `SUSPECT_TIME` | `30m` | окно наблюдения за «подозреваемым» (ban-once) | @@ -174,7 +176,7 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `NA_CTG_PHANTOM_MIN` / `NA_CTG_LIVE_FLOOR` | `4000` / `2` | порог conntrack-холдера / порог живых сокетов. `LIVE_FLOOR` подбирают по observe-режиму: у обычных клиентов бывает 1–2 живых сокета при заметном conntrack | | `NA_CTG_BANTIME` / `NA_CTG_INTERVAL` / `NA_CTG_COARSE_MULT` | `15m` / `20s` / `3` | срок эвикта / период проверки / во сколько раз conntrack должен превышать число живых сокетов, чтобы вообще запускать разбор | -`optimize.sh`: `ENABLE_XANMOD=1`, `XANMOD_FLAVOR=lts|main|edge|rt`, `XANMOD_PKG=...`, `REMNAWAVE_SWAP_SIZE=2G`, `TCP_ECN_MODE=2` (0/1/2), `DISABLE_TFO=0`, `CT_EST_TIMEOUT=7440` (conntrack established-timeout, сек; ↑ напр. до `14400` для idle-туннелей/мостов без частого keepalive), `QDISC=fq|fq_codel|cake` (cake — против bufferbloat на слабых аплинках; сравнивай A/B), `ENABLE_MSS_CLAMP=0` (для routed/WireGuard-нод), `SETUP_NO_ZRAM=0`, `ENABLE_LOGROTATE=1` + `NA_LOG_PATHS`/`NA_LOG_MAXSIZE=200M`/`NA_LOG_ROTATE=4`/`NA_LOG_INTERVAL=hourly` (ротация файловых логов ноды, см. ниже), `NA_REMNANODE_ENV=/opt/remnanode/.env` (откуда брать порт агента), `NA_CERT_PATHS` (доп. сертификаты для сенсора срока). Буферы/conntrack/somaxconn — **tier-aware** (масштаб от RAM). +`optimize.sh`: `ENABLE_XANMOD=1`, `XANMOD_FLAVOR=lts|main|edge|rt`, `XANMOD_PKG=...`, `REMNAWAVE_SWAP_SIZE=2G`, `TCP_ECN_MODE=2` (0/1/2), `DISABLE_TFO=0`, `CT_EST_TIMEOUT=7440` (conntrack established-timeout, сек; ↑ напр. до `14400` для idle-туннелей/мостов без частого keepalive), `QDISC=fq|fq_codel|cake` (cake — против bufferbloat на слабых аплинках; сравнивай A/B), `ENABLE_MSS_CLAMP=0` (для routed/WireGuard-нод), `SETUP_NO_ZRAM=0`, `ENABLE_LOGROTATE=1` + `NA_LOG_PATHS`/`NA_LOG_MAXSIZE=200M`/`NA_LOG_ROTATE=4`/`NA_LOG_INTERVAL=hourly` (ротация файловых логов ноды, см. ниже), `ENABLE_PSI=0` (`1` — дописать `psi=1` в `GRUB_CMDLINE_LINUX_DEFAULT`: XanMod и стоковые ядра Debian собраны с `CONFIG_PSI_DEFAULT_DISABLED=y`, без этого `/proc/pressure` нет и сенсор давления в `na-diagnose` слеп; учёт PSI не бесплатен для планировщика, поэтому opt-in; ребут), `NA_JOURNAL_MAX_USE=300M` (кап journald), `NA_REMNANODE_ENV=/opt/remnanode/.env` (откуда брать порт агента), `NA_NODE_CONTAINER=remnanode` (имя контейнера node-агента для сенсора `na-diagnose`), `NA_CERT_PATHS` (доп. сертификаты для сенсора срока). Буферы/conntrack/somaxconn — **tier-aware** (масштаб от RAM). `XANMOD_PROBE=1` — проверить, что репозиторий+ключ+сборка ядра резолвятся на этой ОС, **без установки** (для CI и быстрой проверки совместимости). --- @@ -237,6 +239,24 @@ sudo ENABLE_CTGUARD=1 NA_CTG_ENFORCE=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/p > резать. Посмотри кандидатов через `na-fw-top-talkers` и занеси upstream-диапазоны в > `WHITELIST=` — whitelist стоит выше всех лимитов. +## Сохранённый конфиг и смена дефолтов + +Эффективные параметры каждого прогона сохраняются в `/etc/node-accelerator/{protect,optimize}.conf` +идиомой `: "${KEY:=value}"` — ре-ран без ENV не сбрасывает то, что оператор задал под ноду +(`WHITELIST`, `CONN_LIMIT`, `NODE_PORT`…). Обратная сторона: файл так же пиннит и **дефолты той +версии, при которой ноду настраивали**, и встроенный дефолт новой версии не может выиграть у +записанного никогда. Ужесточение по безопасности (например, `CROWDSEC_STRICT` `0→1` в v4.0) на уже +настроенных нодах молча не применялось. + +С v4.1 тулкит различает «оператор задал» и «записан тогдашний дефолт»: ключи, пришедшие из ENV, +помечаются в файле маркером `# explicit: KEY`; ключ без маркера, пиннящий старый дефолт, — заморозка, +о которой `protect`/`optimize` печатают `[!] conf: KEY=old записан старой версией, а дефолт с vX = new…`, +а `na-diagnose` — отдельный ▲ (в `--json` — `conf_stale_defaults`). Принять новые дефолты разом: +`NA_ADOPT_NEW_DEFAULTS=1` при ре-ране; оставить старое осознанно: задать `KEY=old` в ENV один раз — +маркер `explicit` переживёт последующие ре-раны. + +--- + ## Откат ```bash diff --git a/install.sh b/install.sh index 5978d05..db98c71 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.0.1; curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all +# export NA_REF=v4.1; 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, для форка/зеркала> diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index 2cf32e5..a2c73d6 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -6,6 +6,11 @@ # diagnose.sh — человекочитаемый отчёт # diagnose.sh --json — один JSON-объект для флот-мониторинга (Zabbix/Prometheus) # diagnose.sh --retrans [--window N] — глубокий разбор причин TCP-retransmits +# +# ENV-ручки: +# NA_NODE_CONTAINER=<имя> — контейнер node-агента (дефолт remnanode); на панели/ +# CDN-origin контейнер зовётся иначе или его нет вовсе +# NA_CERT_PATHS=' ' — доп. пути к fullchain для сенсора сроков TLS set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -23,6 +28,167 @@ val() { sysctl -n "$1" 2>/dev/null; } # для PRETTY_NAME, если есть. [[ -f /etc/os-release ]] && { . /etc/os-release 2>/dev/null || true; } +# Имя контейнера node-агента. На панели Remnawave и на CDN-origin контейнер зовётся +# иначе или его нет вовсе, а сенсор жёстко смотрел на `remnanode` — и на таком боксе +# рапортовал «node-агент лежит» (issue #27/#33). Ручка даёт сказать правду обоим. +NA_NODE_CONTAINER="${NA_NODE_CONTAINER:-remnanode}" + +# ─── Локальные хелперы сенсоров (нужны и --json, и человекочитаемой ветке) ──── + +# timeout — coreutils, но diagnose обязан работать и на урезанном боксе: без него +# просто зовём команду как есть. +_to() { if command -v timeout >/dev/null 2>&1; then timeout "$@"; else shift; "$@"; fi; } + +# IPv4 → 32-битное целое; rc=1, если это не IPv4 (маска отрезается). +_ip4_int() { + local o a b c d + IFS=. read -r a b c d <<<"${1%%/*}" + for o in "$a" "$b" "$c" "$d"; do + [[ "$o" =~ ^[0-9]{1,3}$ ]] && (( 10#$o <= 255 )) || return 1 + done + echo $(( (10#$a << 24) | (10#$b << 16) | (10#$c << 8) | 10#$d )) +} +# ip4_in_cidr — вхождение адреса в сеть, целочисленно. Без ipcalc/python: +# на голой ноде их может не быть, а датчик обязан работать везде. +ip4_in_cidr() { + local ip="$1" net="${2%%/*}" pfx=32 ipi neti mask + [[ "$2" == */* ]] && pfx="${2##*/}" + [[ "$pfx" =~ ^[0-9]{1,2}$ ]] && (( pfx <= 32 )) || return 1 + ipi="$(_ip4_int "$ip")" || return 1 + neti="$(_ip4_int "$net")" || return 1 + (( pfx == 0 )) && return 0 + mask=$(( (0xFFFFFFFF << (32 - pfx)) & 0xFFFFFFFF )) + (( (ipi & mask) == (neti & mask) )) +} + +# Живой whitelist (обе семьи) — нужен и датчику CONN_LIMIT (#31), и сверке дрейфа (#38). +# Читаем ОДИН раз: nft-вызов не бесплатен. Наборы na_fleet_* / na_nodeport_wl_* сюда +# НЕ подмешиваем — это отдельные списки с другой судьбой при ре-ране. +NA_WL4=""; NA_WL6=""; NA_WL_READ=0 +wl_live_load() { + [[ "$NA_WL_READ" == "1" ]] && return 0 + NA_WL_READ=1 + NA_WL4="$(nft_set_elems inet na_filter whitelist_v4 2>/dev/null)" + NA_WL6="$(nft_set_elems inet na_filter whitelist_v6 2>/dev/null)" + return 0 +} +# in_whitelist — адрес покрыт живым whitelist'ом? v4 — с учётом CIDR, v6 — точное +# совпадение (интервальных v6-вайтлистов на флоте нет, а разбор /64 стоил бы bigint). +in_whitelist() { + local ip="$1" e + wl_live_load + if [[ "$ip" == *:* ]]; then + for e in $NA_WL6; do [[ "${e%%/*}" == "$ip" ]] && return 0; done + return 1 + fi + for e in $NA_WL4; do + if [[ "$e" == */* ]]; then ip4_in_cidr "$ip" "$e" && return 0 + elif [[ "$e" == "$ip" ]]; then return 0; fi + done + return 1 +} + +# conn_per_ip_max — макс. число ВХОДЯЩИХ established с одного пира по тому +# же срезу, что режет правило `ct count`. v4.0 считал ВЕСЬ `ss -tnH state established`: +# туда попадали loopback-пары nginx↔xray (каждая учитывалась дважды), исходящие плечи и +# вайтлист — а до `ct count` ни один из них не доходит (в цепочке выше два accept и +# `iif lo accept`). Отсюда вечное «6790 из 8192» на здоровой ноде (issue #31). +# echo: число (0 = внешних пиров нет); rc=1 = мерить не по чему (портов не задано). +conn_per_ip_max() { + local ports="${1:-}" p filt="" cnt peer + for p in ${ports//,/ }; do + [[ "$p" =~ ^[0-9]+$ ]] || continue + filt+="${filt:+ or }sport = :$p" + done + [[ -n "$filt" ]] || return 1 + while read -r cnt peer; do + [[ "$cnt" =~ ^[0-9]+$ && -n "$peer" ]] || continue + [[ "$peer" == 127.* || "$peer" == "::1" ]] && continue + in_whitelist "$peer" && continue + echo "$cnt"; return 0 + done < <(ss -tnH state established "( $filt )" 2>/dev/null \ + | awk '{print $NF}' \ + | sed -E 's/:[0-9]+$//; s/^\[//; s/\]$//; s/^::ffff:([0-9.]+)$/\1/' \ + | sort | uniq -c | sort -rn) + echo 0 +} + +# psi_state — on | off-by-default | absent | unknown. XanMod (и сток Debian) собран с +# CONFIG_PSI_DEFAULT_DISABLED=y: PSI ЕСТЬ, но /proc/pressure появляется только при +# `psi=1` в cmdline. v4.0 знал два состояния и объяснял пропажу «старым ядром» на ядре +# 6.18 — сенсор давления молчал на 100% боксов, и это выглядело как норма (issue #37). +psi_state() { + [[ -r /proc/pressure/cpu ]] && { echo on; return 0; } + local kr cfg="" + kr="$(uname -r 2>/dev/null)" + if [[ -n "$kr" && -r "/boot/config-$kr" ]]; then + cfg="$(grep -E '^CONFIG_PSI(_DEFAULT_DISABLED)?=' "/boot/config-$kr" 2>/dev/null)" + elif [[ -r /proc/config.gz ]] && command -v zcat >/dev/null 2>&1; then + cfg="$(zcat /proc/config.gz 2>/dev/null | grep -E '^CONFIG_PSI(_DEFAULT_DISABLED)?=')" + fi + [[ -n "$cfg" ]] || { echo unknown; return 0; } + grep -q '^CONFIG_PSI=y' <<<"$cfg" || { echo absent; return 0; } + if grep -q '^CONFIG_PSI_DEFAULT_DISABLED=y' <<<"$cfg" && ! grep -qw 'psi=1' /proc/cmdline 2>/dev/null; then + echo off-by-default; return 0 + fi + echo unknown +} + +# journal_span_h — на сколько часов назад хватает journald (возраст самой старой записи). +# Поток `[na portscan]` на публичной ноде (5/сек ≈ 50 МБ/сутки) вытесняет из журнала при +# SystemMaxUse=300M всё остальное, и глубина падает ниже суток: разбор вчерашнего +# инцидента становится невозможен, а на Debian 13 minimal journald — единственный +# источник истории входов (issue #35). rc=1 = не измерено. +journal_span_h() { + command -v journalctl >/dev/null 2>&1 || return 1 + local first now + # -o short-unix отдаёт запись с самой старой первой строкой; head закрывает пайп + # сразу, поэтому цена не зависит от размера журнала. + first="$(_to 10 journalctl -q --no-pager -o short-unix 2>/dev/null | head -n1 | awk '{print $1}' | cut -d. -f1)" + [[ "$first" =~ ^[0-9]+$ ]] || return 1 + now="$(date +%s 2>/dev/null)"; [[ "$now" =~ ^[0-9]+$ ]] || return 1 + (( now > first )) || { echo 0; return 0; } + echo $(( (now - first) / 3600 )) +} + +# portscan_log_lines — строк `[na portscan]` за текущую загрузку (сколько журнала съел +# лог анти-скана). echo: число; rc=1 = не измерено (нет journalctl / сборка без `-g` / +# не уложились в таймаут — частичный счёт лучше не выдавать за точный). +portscan_log_lines() { + local n rc + 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 + 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 + [[ "$n" =~ ^[0-9]+$ ]] || return 1 + echo "$n" +} + +# cert_retired — серт acme.sh, снятый с renew. `acme.sh --remove` +# оставляет каталог с сертом на диске, но переименовывает .conf → +# .conf.removed: продлеваться он уже не будет, и его notAfter — гарантированная +# ложная тревога по домену, которого в проде нет (issue #39). +cert_retired() { + local d; d="$(dirname "$1")" + compgen -G "$d/*.conf" >/dev/null 2>&1 && return 1 + compgen -G "$d/*.conf.removed" >/dev/null 2>&1 && return 0 + return 1 +} + +# Пути сертов: LE, acme.sh, фасадный selfsteal-каталог (/opt/<стек>/certs/...) и +# NA_CERT_PATHS. Нестандартного /opt-каталога в v4.0 не было, и на selfsteal-ноде +# сенсор отдавал cert_min_days=-1 — молчал ровно там, где серт и надо стеречь (#39). +NA_CERT_GLOBS='/etc/letsencrypt/live/*/fullchain.pem /root/.acme.sh/*/fullchain.cer /opt/*/certs/*/fullchain.cer /opt/*/certs/*/fullchain.pem /opt/*/certs/fullchain.cer /opt/*/certs/fullchain.pem' + +# tls_listening — на :443/:8443 кто-то слушает? Если сертов при этом не нашли, сенсор +# не «чист», а слеп — это warn, а не info (issue #39). +tls_listening() { + ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE '[:.](443|8443)$' +} + # ─── JSON-режим (для флот-мониторинга: Zabbix/Prometheus/SSH-поллинг) ───────── # `diagnose.sh --json` печатает один машинно-читаемый объект и выходит. Read-only. emit_json() { @@ -30,7 +196,8 @@ emit_json() { local fw fwm ab4 ab6 susp bl4 bl6 fl4 fl6 crowd ctg syndeg safety rebootn local u1 n1 s1 i1 w1 q1 sq1 st1 u2 n2 s2 i2 w2 q2 sq2 st2 dt steal out rtx rtxpct local nav host up load1 mempct wi wanrx wantx ip6def udperr - local rnst rnrc rnse fsa bla certd nowsec cf cend cends cd npd npfw + 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 virt="$(detect_virt)" cc="$(val net.ipv4.tcp_congestion_control)"; qd="$(val net.core.default_qdisc)" @@ -59,13 +226,17 @@ emit_json() { # режим файрвола из маркера 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:-}" - ab4="$(nft list set inet na_filter autoban_v4 2>/dev/null | grep -c timeout)" - ab6="$(nft list set inet na_filter autoban_v6 2>/dev/null | grep -c timeout)" - susp="$(nft list set inet na_filter suspect_v4 2>/dev/null | grep -c timeout)" - bl4="$(nft list set inet na_filter blocklist_v4 2>/dev/null | grep -coE '[0-9.]+')" - bl6="$(nft list set inet na_filter blocklist_v6 2>/dev/null | grep -c ':')" - fl4="$(nft list set inet na_filter na_fleet_v4 2>/dev/null | grep -coE '[0-9.]+')" - fl6="$(nft list set inet na_filter na_fleet_v6 2>/dev/null | grep -c ':')" + # Считаем АДРЕСА, а не строки вывода nft: в заголовке динамического набора всегда + # есть `flags dynamic,timeout` → `grep -c timeout` давал «1» на пустом наборе и +1 + # (с 'expires' — +2) на непустом, и этот ряд уезжал прямо в Zabbix/Prometheus + # («autoban > 0» горел вечно). Общий хелпер — в lib/common.sh (issue #32/#36). + ab4="$(nft_set_count inet na_filter autoban_v4)" + ab6="$(nft_set_count inet na_filter autoban_v6)" + susp="$(nft_set_count inet na_filter suspect_v4)" + bl4="$(nft_set_count inet na_filter blocklist_v4)" + bl6="$(nft_set_count inet na_filter blocklist_v6)" + fl4="$(nft_set_count inet na_filter na_fleet_v4)" + fl6="$(nft_set_count inet na_filter na_fleet_v6)" command -v cscli >/dev/null 2>&1 && { systemctl is-active --quiet crowdsec && crowd=true || crowd=false; } || crowd=false if nft list table inet na_ctguard >/dev/null 2>&1; then local enf; enf="$(awk -F= '/^NA_CTG_ENFORCE/{print $2}' /etc/node-accelerator/ctguard.conf 2>/dev/null)" @@ -97,10 +268,16 @@ emit_json() { # remnanode (Remnawave node-контейнер) — статус/рестарты/SPAWN_ERROR за час. Read-only. rnst="no-docker"; rnrc=0; rnse=0 if command -v docker >/dev/null 2>&1; then - rnst="$(docker inspect -f '{{.State.Status}}' remnanode 2>/dev/null || echo absent)"; [[ -n "$rnst" ]] || rnst=absent - rnrc="$(docker inspect -f '{{.RestartCount}}' remnanode 2>/dev/null || echo 0)"; [[ "$rnrc" =~ ^[0-9]+$ ]] || rnrc=0 + # `docker inspect -f` по несуществующему контейнеру (29.x) печатает в stdout + # ПУСТУЮ строку и только потом падает: идиома `… || echo absent` давала + # "\nabsent" — статус не совпадал с absent, а сырой перевод строки ломал ВЕСЬ + # JSON-документ (issue #27). Не смешиваем stdout с кодом возврата. + rnst="$(docker inspect -f '{{.State.Status}}' "$NA_NODE_CONTAINER" 2>/dev/null | tr -d '[:space:]')" + [[ -n "$rnst" ]] || rnst=absent + rnrc="$(docker inspect -f '{{.RestartCount}}' "$NA_NODE_CONTAINER" 2>/dev/null | tr -d '[:space:]')" + [[ "$rnrc" =~ ^[0-9]+$ ]] || rnrc=0 if [[ "$rnst" != "absent" ]]; then - rnse="$(docker logs --since 1h remnanode 2>&1 | grep -c 'SPAWN_ERROR' || true)"; [[ "$rnse" =~ ^[0-9]+$ ]] || rnse=0 + rnse="$(docker logs --since 1h "$NA_NODE_CONTAINER" 2>&1 | grep -c 'SPAWN_ERROR' || true)"; [[ "$rnse" =~ ^[0-9]+$ ]] || rnse=0 fi fi # порт node-агента: факт (детект с ноды) vs заложенный в файрвол — рассинхрон @@ -120,17 +297,45 @@ emit_json() { [[ -f "$STATE_DIR/blocklist.last" ]] && { cf="$(cat "$STATE_DIR/blocklist.last" 2>/dev/null)"; [[ "$cf" =~ ^[0-9]+$ ]] && bla=$(( nowsec - cf )); } # ближайший к истечению серт (−1 = нет openssl / сертов не нашли). NA_CERT_PATHS — # доп.пути через пробел; используются только в [ -f ] и openssl (без eval). - certd=-1 + certd=-1; certf="" if command -v openssl >/dev/null 2>&1; then - for cf in /etc/letsencrypt/live/*/fullchain.pem /root/.acme.sh/*/fullchain.cer ${NA_CERT_PATHS:-}; do + # shellcheck disable=SC2086 # глобы и NA_CERT_PATHS обязаны раскрыться + for cf in $NA_CERT_GLOBS ${NA_CERT_PATHS:-}; do [[ -f "$cf" ]] || continue + cert_retired "$cf" && continue cend="$(openssl x509 -enddate -noout -in "$cf" 2>/dev/null | cut -d= -f2)"; [[ -n "$cend" ]] || continue cends="$(date -d "$cend" +%s 2>/dev/null)" || continue; [[ "$cends" =~ ^[0-9]+$ ]] || continue cd=$(( (cends - nowsec) / 86400 )) - { [[ "$certd" -lt 0 ]] || [[ "$cd" -lt "$certd" ]]; } && certd="$cd" + { [[ "$certd" -lt 0 ]] || [[ "$cd" -lt "$certd" ]]; } && { certd="$cd"; certf="$cf"; } done fi + # ── v4.1: метрики, которых мониторингу не хватало ────────────────────────── + # max_conn_per_ip: по тому же срезу, что и правило `ct count` (−1 = мерить не по чему) + mcpi=-1 + tcpp="$(awk -F= '/^tcp_ports=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" + if [[ -n "${tcpp:-}" ]]; then + tmpv="$(conn_per_ip_max "$tcpp")" && [[ "$tmpv" =~ ^[0-9]+$ ]] && mcpi="$tmpv" + fi + # глубина журнала и объём лога анти-скана (−1 = не измерено) + jsp=-1; tmpv="$(journal_span_h)" && [[ "$tmpv" =~ ^[0-9]+$ ]] && jsp="$tmpv" + psl=-1; tmpv="$(portscan_log_lines)" && [[ "$tmpv" =~ ^[0-9]+$ ]] && psl="$tmpv" + psist="$(psi_state)"; psist="${psist:-unknown}" + # whitelist_drift_conf: адреса живого whitelist_v4, которых НЕТ в WHITELIST= из + # protect.conf — они не переживут ре-ран protect (−1 = conf нет, сверять не с чем) + wdc=-1 + if [[ -f "$CONF_DIR/protect.conf" ]]; then + wl_live="$(nft_set_elems inet na_filter whitelist_v4 2>/dev/null | sed -E 's#/32$##' | sort -u)" + wl_conf="$(sed -nE 's/.*\{WHITELIST:=([^}]*)\}.*/\1/p' "$CONF_DIR/protect.conf" 2>/dev/null | tail -1 \ + | tr ',' '\n' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]+)?' | sed -E 's#/32$##' | sort -u)" + wdc="$(comm -23 <(printf '%s\n' "$wl_live" | grep -v '^$' | sort -u) \ + <(printf '%s\n' "$wl_conf" | grep -v '^$' | sort -u) 2>/dev/null | grep -c .)" + [[ "$wdc" =~ ^[0-9]+$ ]] || wdc=-1 + fi + # сохранённый conf пиннит дефолты той версии, при которой ноду настраивали + csd="$( { conf_stale_defaults "$CONF_DIR/protect.conf"; conf_stale_defaults "$CONF_DIR/optimize.conf"; } 2>/dev/null | grep -c .)" + [[ "$csd" =~ ^[0-9]+$ ]] || csd=0 + # Диск, inodes и лог-флуд: в человекочитаемом выводе они были, а во флот-мониторинге — # нет, поэтому самая частая авария (диск под завязку от нертотируемого лога) была не # видна снаружи вообще, пока нода не замолкала. @@ -141,6 +346,17 @@ emit_json() { [[ "$dklogmax" =~ ^[0-9]+$ ]] || dklogmax=0 lrt=0; systemctl is-active --quiet na-logrotate.timer 2>/dev/null && lrt=1 + # Каждое строковое значение — через json_escape. Раньше каждое поле полагалось на то, + # что источник «и так чистый», и одного сырого перевода строки из docker хватило, + # чтобы уронить парсер на ВСЁМ документе (issue #27). + kern="$(json_escape "$kern")"; virt="$(json_escape "$virt")" + cc="$(json_escape "${cc:-}")"; qd="$(json_escape "${qd:-}")" + fwm="$(json_escape "$fwm")"; ctg="$(json_escape "$ctg")" + nav="$(json_escape "$nav")"; host="$(json_escape "$host")" + wi="$(json_escape "$wi")"; rnst="$(json_escape "$rnst")" + npd="$(json_escape "${npd:-}")"; npfw="$(json_escape "$npfw")" + psist="$(json_escape "$psist")"; certf="$(json_escape "$certf")" + printf '{' printf '"kernel":"%s","xanmod":%s,"virt":"%s","cpu_steal_pct":%s,"tcp_retrans_pct":%s,' "$kern" "$xanmod" "$virt" "$steal" "$rtxpct" printf '"congestion_control":"%s","qdisc":"%s","conntrack_max":%s,"conntrack_count":%s,"conntrack_pct":%s,' "${cc:-}" "${qd:-}" "$ctmax" "$ctcnt" "$ctpct" @@ -153,8 +369,14 @@ emit_json() { printf '"remnanode_status":"%s","remnanode_restarts":%s,"remnanode_spawn_errors_1h":%s,' "$rnst" "$rnrc" "$rnse" printf '"node_port_detected":"%s","node_port_fw":"%s",' "$npd" "$npfw" printf '"fleet_sync_age_s":%s,"blocklist_age_s":%s,"cert_min_days":%s,' "$fsa" "$bla" "$certd" - printf '"disk_pct":%s,"inode_pct":%s,"log_max_bytes":%s,"docker_log_max_bytes":%s,"logrotate_timer":%s}\n' \ + printf '"disk_pct":%s,"inode_pct":%s,"log_max_bytes":%s,"docker_log_max_bytes":%s,"logrotate_timer":%s,' \ "$dsp" "$din" "$logmax" "$dklogmax" "$lrt" + # v4.1 (аудит флота): новые поля ТОЛЬКО в хвост — имена и порядок прежних читает + # мониторинг. −1 везде = «не измерено», а не «ноль». + printf '"max_conn_per_ip":%s,"journal_span_h":%s,"portscan_log_lines_boot":%s,"psi":"%s",' \ + "$mcpi" "$jsp" "$psl" "$psist" + printf '"whitelist_drift_conf":%s,"conf_stale_defaults":%s,"cert_min_file":"%s"}\n' \ + "$wdc" "$csd" "$certf" } if [[ "${1:-}" == "--json" ]]; then emit_json; exit 0; fi @@ -310,6 +532,22 @@ if [[ "${DT:-0}" -gt 0 ]]; then else pass "CPU steal = ${STEAL}% (CPU ноды не отбирают)" fi fi +# Сохранённый conf ПИННИТ дефолты той версии, при которой ноду настраивали: идиома +# `: "${K:=v}"` из load_conf выигрывает у встроенного дефолта всегда. Когда новая +# мажорная меняет дефолт по безопасности (CROWDSEC_STRICT 0→1 в v4.0), на уже настроенных +# нодах он молча не применяется — ни ре-раном, ни апгрейдом тулкита, и узнать об этом +# можно было только чтением конфига на каждом хосте (issue #34). +CSD_N=0 +for _cf in "$CONF_DIR/protect.conf" "$CONF_DIR/optimize.conf"; do + [[ -f "$_cf" ]] || continue + while IFS='|' read -r _k _old _new _ver _why; do + [[ -n "$_k" ]] || continue + CSD_N=$((CSD_N+1)) + wrn "$(basename "$_cf") пиннит устаревший дефолт: $_k=$_old (дефолт с v$_ver = $_new — $_why). Принять: NA_ADOPT_NEW_DEFAULTS=1 ре-ран $(basename "$_cf" .conf)" + done < <(conf_stale_defaults "$_cf") +done +[[ "$CSD_N" -eq 0 ]] && pass "conf модулей не пиннит устаревших дефолтов" +unset _cf _k _old _new _ver _why # ─── Ядро / BBR ────────────────────────────────────────────────────────────── title "Ядро и congestion control" @@ -458,7 +696,21 @@ if [[ -n "$NIC" ]]; then else pass "NIC без drop/error счётчиков" fi - systemctl is-active --quiet na-rps.service 2>/dev/null && pass "na-rps.service активен" || info "na-rps.service не запущен (ставится оптимизатором)" + # «Юнит активен» ≠ «RPS применён»: na-rps-setup определяет NIC по default route и при + # его отсутствии молча выходит нулём, а RemainAfterExit=yes фиксирует active (exited) + # навсегда. На буте это гонка с появлением маршрута, и v4.0 печатал ✔ рядом с ▲ «RPS + # выключен» — две строки в одной секции утверждали противоположное (issue #30). + if systemctl is-active --quiet na-rps.service 2>/dev/null; then + if [[ "$RPS_ON" == "1" ]]; then + pass "na-rps.service активен" + elif [[ "$CORES" -gt 1 ]] 2>/dev/null; then + wrn "na-rps.service active, но rps_cpus пуст — юнит отработал до появления default route (гонка на буте) и вышел нулём: systemctl restart na-rps.service; v4.1 optimize чинит юнит" + else + info "na-rps.service активен (1 ядро — размазывать нечего)" + fi + else + info "na-rps.service не запущен (ставится оптимизатором)" + fi else wrn "Основной интерфейс не определён" fi @@ -481,9 +733,20 @@ if nft list table inet na_filter >/dev/null 2>&1; then else pass "nftables na_filter активна (policy drop на input)" fi - AB4=$(nft list set inet na_filter autoban_v4 2>/dev/null | grep -c 'timeout\|expires') - AB6=$(nft list set inet na_filter autoban_v6 2>/dev/null | grep -c 'timeout\|expires') - info "autoban: v4=$AB4 v6=$AB6" + # Считаем АДРЕСА, а не строки вывода nft: `flags dynamic,timeout` в заголовке + # матчился всегда (пустой набор = «1»), а на непустом `timeout\|expires` давал +2 к + # элементу. Вечная фантомная единица приучала считать строку шумом — и настоящий + # первый бан (в т.ч. адреса панели) терялся на её фоне (issue #32/#36). + AB4="$(nft_set_count inet na_filter autoban_v4)"; [[ "$AB4" =~ ^[0-9]+$ ]] || AB4=0 + AB6="$(nft_set_count inet na_filter autoban_v6)"; [[ "$AB6" =~ ^[0-9]+$ ]] || AB6=0 + if [[ $((AB4+AB6)) -gt 0 ]]; then + # оператору важно не «сколько», а «кто»: первый же бан может оказаться панелью + AB_LIST="$( { nft_set_elems inet na_filter autoban_v4; nft_set_elems inet na_filter autoban_v6; } 2>/dev/null | head -10 | paste -sd' ' -)" + AB_MORE=""; [[ $((AB4+AB6)) -gt 10 ]] && AB_MORE=" …ещё $((AB4+AB6-10))" + info "autoban: v4=$AB4 v6=$AB6 — $AB_LIST$AB_MORE" + else + info "autoban: v4=0 v6=0 (нода никого не банила)" + fi WLN=$(nft list set inet na_filter whitelist_v4 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | wc -l | tr -d ' ') WL6N=$(nft list set inet na_filter whitelist_v6 2>/dev/null | grep -c ':') if [[ "$WLN" -gt 0 || "$WL6N" -gt 0 ]]; then pass "whitelist: v4=$WLN v6=$WL6N адрес(ов)" @@ -492,34 +755,69 @@ if nft list table inet na_filter >/dev/null 2>&1; then # существуют только в памяти ядра: при загрузке правила берутся из na_filter.nft, и # ре-ран protect или ребут молча их выбрасывает. Отсюда классическая авария — нода # исправна и доступна, а после перезагрузки панель до неё не достучалась. + # Сверяем ТРИ источника, потому что переживают они разное: + # live == na_filter.nft → переживёт РЕБУТ (правила грузятся из файла); + # live ⊆ WHITELIST= conf → переживёт РЕ-РАН protect (он перегенерирует .nft). + # v4.0 сверял только первую пару и объявлял здоровым адрес, который есть в файле и в + # памяти, но не в protect.conf: авто-whitelist SSH-IP в conf не пишется намеренно, то + # есть «раскурили ноду из транзитной сессии → адрес осел в .nft → диагностика + # довольна → первый ре-ран из другой сессии его молча выкинул» (issue #38). + # Наборы na_fleet_* / na_nodeport_wl_* здесь ни при чём — у них свой жизненный цикл. NFT_FILE="$CONF_DIR/na_filter.nft" + LIVE_WL="$(nft_set_elems inet na_filter whitelist_v4 2>/dev/null | sed -E 's#/32$##' | sort -u)" if [[ -r "$NFT_FILE" ]]; then - LIVE_WL="$(nft list set inet na_filter whitelist_v4 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?' | sort -u)" - FILE_WL="$(awk '/set whitelist_v4/,/}/' "$NFT_FILE" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?' | sort -u)" - DRIFT="$(comm -23 <(printf '%s\n' "$LIVE_WL") <(printf '%s\n' "$FILE_WL") 2>/dev/null | grep -c .)" - if [[ "${DRIFT:-0}" -gt 0 ]]; then - wrn "в живом whitelist_v4 на $DRIFT адрес(ов) больше, чем в $NFT_FILE — они пропадут при ре-ране/ребуте: $(comm -23 <(printf '%s\n' "$LIVE_WL") <(printf '%s\n' "$FILE_WL") 2>/dev/null | paste -sd' ' -)" + FILE_WL="$(awk '/set whitelist_v4/,/}/' "$NFT_FILE" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?' | sed -E 's#/32$##' | sort -u)" + DRIFT_L="$(comm -23 <(printf '%s\n' "$LIVE_WL" | grep -v '^$' | sort -u) <(printf '%s\n' "$FILE_WL" | grep -v '^$' | sort -u) 2>/dev/null)" + DRIFT="$(printf '%s\n' "$DRIFT_L" | grep -c .)"; [[ "$DRIFT" =~ ^[0-9]+$ ]] || DRIFT=0 + if [[ "$DRIFT" -gt 0 ]]; then + wrn "в живом whitelist_v4 на $DRIFT адрес(ов) больше, чем в $NFT_FILE — они пропадут при ребуте: $(printf '%s\n' "$DRIFT_L" | paste -sd' ' -)" else pass "whitelist в файле и в памяти совпадают (переживёт ребут)" fi fi - # датчик: насколько близко самый «жирный» источник к CONN_LIMIT (виден ли per-IP потолок) + if [[ -f "$CONF_DIR/protect.conf" ]]; then + # conf пишется идиомой `: "${WHITELIST:=…}"` — наивный `^WHITELIST=` не матчит + CONF_WL="$(sed -nE 's/.*\{WHITELIST:=([^}]*)\}.*/\1/p' "$CONF_DIR/protect.conf" 2>/dev/null | tail -1 \ + | tr ',' '\n' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?' | sed -E 's#/32$##' | sort -u)" + CDRIFT_L="$(comm -23 <(printf '%s\n' "$LIVE_WL" | grep -v '^$' | sort -u) <(printf '%s\n' "$CONF_WL" | grep -v '^$' | sort -u) 2>/dev/null)" + CDRIFT="$(printf '%s\n' "$CDRIFT_L" | grep -c .)"; [[ "$CDRIFT" =~ ^[0-9]+$ ]] || CDRIFT=0 + if [[ "$CDRIFT" -gt 0 ]]; then + SSH_IP_NOW="$(ssh_client_ip || true)" + CD_NOTE="" + [[ -n "$SSH_IP_NOW" ]] && printf '%s\n' "$CDRIFT_L" | grep -qxF "$SSH_IP_NOW" \ + && CD_NOTE=" (среди них $SSH_IP_NOW — это IP текущей сессии)" + wrn "whitelist НЕ переживёт ре-ран protect: в WHITELIST= из protect.conf нет $CDRIFT адрес(ов) — $(printf '%s\n' "$CDRIFT_L" | paste -sd' ' -)$CD_NOTE. Добавлены транзитно (SSH-IP при прогоне protect) или руками; при ре-ране из другой сессии исчезнут. Закрепить: WHITELIST=…,$(printf '%s\n' "$CDRIFT_L" | head -1) ре-ран protect" + else + pass "whitelist ⊆ WHITELIST= в protect.conf (переживёт ре-ран protect)" + fi + fi + # Датчик per-IP потолка. Считаем ТОТ ЖЕ срез, что режет правило `ct count`: только + # входящие established на порты из tcp_ports=, без loopback и вайтлиста. v4.0 брал + # весь `ss state established` — loopback-пары nginx↔xray (тысячи, и каждая считалась + # дважды), исходящие плечи и вайтлист, до которых правило не доходит вовсе, — и + # предупреждение горело вечно, подталкивая поднять CONN_LIMIT, т.е. ослабить + # защиту от exhaustion ради несуществующей проблемы (issue #31). CLIM=$(nft list chain inet na_filter input 2>/dev/null | grep -oE 'ct count over [0-9]+' | head -1 | grep -oE '[0-9]+') if [[ -n "$CLIM" ]]; then - MAXIP=$(ss -tnH state established 2>/dev/null | awk '{print $NF}' | sed -E 's/:[0-9]+$//; s/^\[//; s/\]$//' | sort | uniq -c | sort -rn | head -1 | awk '{print $1+0}') - if [[ "${MAXIP:-0}" -ge $((CLIM*80/100)) ]]; then - wrn "макс. конн. с одного IP = ${MAXIP:-0} при CONN_LIMIT=$CLIM (≥80%) — за CGNAT возможны дропы, подними CONN_LIMIT" + TCPP_INST="$(awk -F= '/^tcp_ports=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" + if [[ -z "${TCPP_INST:-}" ]]; then + info "per-IP лимит $CLIM есть, но список портов правила неизвестен (в $STATE_DIR/protect.installed нет tcp_ports=) — датчик пропущен" else - pass "макс. конн. с одного IP = ${MAXIP:-0} / CONN_LIMIT $CLIM (запас есть)" + MAXIP="$(conn_per_ip_max "$TCPP_INST")"; [[ "$MAXIP" =~ ^[0-9]+$ ]] || MAXIP=0 + if [[ "$MAXIP" -ge $((CLIM*80/100)) ]]; then + wrn "макс. входящих конн. с одного IP = $MAXIP при CONN_LIMIT=$CLIM (≥80%, порты $TCPP_INST) — за CGNAT возможны дропы, подними CONN_LIMIT" + else + pass "макс. входящих конн. с одного IP = $MAXIP / CONN_LIMIT $CLIM (порты $TCPP_INST, без loopback и вайтлиста — запас есть)" + fi fi fi # v3.0 компоненты if nft list set inet na_filter suspect_v4 >/dev/null 2>&1; then - SUSP=$(nft list set inet na_filter suspect_v4 2>/dev/null | grep -c timeout) + SUSP="$(nft_set_count inet na_filter suspect_v4)"; [[ "$SUSP" =~ ^[0-9]+$ ]] || SUSP=0 info "ban-once: suspect (наблюдение) v4=$SUSP" fi if nft list set inet na_filter blocklist_v4 >/dev/null 2>&1; then - BL4=$(nft list set inet na_filter blocklist_v4 2>/dev/null | grep -coE '[0-9.]+') + BL4="$(nft_set_count inet na_filter blocklist_v4)"; [[ "$BL4" =~ ^[0-9]+$ ]] || BL4=0 [[ "$BL4" -gt 0 ]] && pass "threat-блоклисты: v4=$BL4 записей (na-blocklist-update)" \ || wrn "blocklist_v4 пуст — фиды не подтянулись? journalctl -t na-blocklist" if [[ -f "$STATE_DIR/blocklist.last" ]]; then @@ -528,7 +826,7 @@ if nft list table inet na_filter >/dev/null 2>&1; then fi fi if nft list set inet na_filter na_fleet_v4 >/dev/null 2>&1; then - FL4=$(nft list set inet na_filter na_fleet_v4 2>/dev/null | grep -coE '[0-9.]+') + FL4="$(nft_set_count inet na_filter na_fleet_v4)"; [[ "$FL4" =~ ^[0-9]+$ ]] || FL4=0 [[ "$FL4" -gt 0 ]] && pass "fleet-sync: $FL4 нод флота в whitelist" \ || wrn "na_fleet пуст — панель/токен? journalctl -t na-fleet-sync" # свежесть: fail-safe last-known-good нем — протухший токен/сменившийся API @@ -562,7 +860,7 @@ fi # ctguard (отдельная таблица) if nft list table inet na_ctguard >/dev/null 2>&1; then ENF=$(awk -F= '/^NA_CTG_ENFORCE/{print $2}' /etc/node-accelerator/ctguard.conf 2>/dev/null) - PH4=$(nft list set inet na_ctguard phantom_v4 2>/dev/null | grep -c timeout) + PH4="$(nft_set_count inet na_ctguard phantom_v4)"; [[ "$PH4" =~ ^[0-9]+$ ]] || PH4=0 [[ "${ENF:-0}" == 1 ]] && pass "ctguard ENFORCE активен (фантомов в блоке: $PH4)" \ || info "ctguard в observe-режиме (только лог; NA_CTG_ENFORCE=1 для эвикта)" fi @@ -596,9 +894,12 @@ fi if command -v cscli >/dev/null 2>&1; then systemctl is-active --quiet crowdsec && pass "CrowdSec агент активен" || wrn "CrowdSec установлен, но не active" systemctl is-active --quiet crowdsec-firewall-bouncer && pass "firewall-bouncer активен" || wrn "bouncer не active" + # `cscli decisions list -o raw` печатает CSV С ЗАГОЛОВКОМ, и он попадал в счёт: на + # пустом списке выходило «decisions: 1» — читается как «один бан есть» (issue #32). + # Срезаем первую строку (`-o json` + jq не годится: jq на ноде не обязателен). # grep -vc печатает 0 И возвращает rc=1, когда совпадений нет → наивный `|| echo 0` # дописывал ВТОРОЙ ноль и строка выходила битой. Берём значение, потом валидируем. - DEC="$(cscli decisions list -o raw 2>/dev/null | grep -vc '^$')" + DEC="$(cscli decisions list -o raw 2>/dev/null | tail -n +2 | grep -vc '^$')" [[ "$DEC" =~ ^[0-9]+$ ]] || DEC=0 info "CrowdSec decisions (активные баны): $DEC" nft list table ip crowdsec >/dev/null 2>&1 && info "таблица bouncer'а ip crowdsec присутствует (priority -10, раньше na_filter)" @@ -638,24 +939,30 @@ fi # ─── Стек ноды (remnanode) и сертификаты ───────────────────────────────────── title "Стек ноды и сертификаты" if command -v docker >/dev/null 2>&1; then - RN_ST="$(docker inspect -f '{{.State.Status}}' remnanode 2>/dev/null || echo absent)" + # `docker inspect -f` по несуществующему контейнеру (29.x) печатает в stdout ПУСТУЮ + # строку и только потом падает: `… || echo absent` давал "\nabsent", ветка absent + # была недостижима, и на панель-боксе/CDN-origin сенсор рапортовал ложный ✘ + # «node-агент лежит» (issue #27/#33). Имя контейнера — ручка NA_NODE_CONTAINER. + RN_ST="$(docker inspect -f '{{.State.Status}}' "$NA_NODE_CONTAINER" 2>/dev/null | tr -d '[:space:]')" + [[ -n "$RN_ST" ]] || RN_ST=absent if [[ "$RN_ST" == "running" ]]; then - RN_RC="$(docker inspect -f '{{.RestartCount}}' remnanode 2>/dev/null || echo 0)" - RN_SE="$(docker logs --since 1h remnanode 2>&1 | grep -c 'SPAWN_ERROR' || true)" + RN_RC="$(docker inspect -f '{{.RestartCount}}' "$NA_NODE_CONTAINER" 2>/dev/null | tr -d '[:space:]')" + [[ "$RN_RC" =~ ^[0-9]+$ ]] || RN_RC=0 + RN_SE="$(docker logs --since 1h "$NA_NODE_CONTAINER" 2>&1 | grep -c 'SPAWN_ERROR' || true)" if [[ "${RN_SE:-0}" -gt 0 ]]; then - bad "remnanode: running, но $RN_SE SPAWN_ERROR за час — xray не стартует (сверь node-address в панели: коллизия IP?)" + bad "$NA_NODE_CONTAINER: running, но $RN_SE SPAWN_ERROR за час — xray не стартует (сверь node-address в панели: коллизия IP?)" elif [[ "${RN_RC:-0}" -gt 3 ]]; then - wrn "remnanode: running, но RestartCount=$RN_RC — контейнер флапает (docker logs remnanode)" + wrn "$NA_NODE_CONTAINER: running, но RestartCount=$RN_RC — контейнер флапает (docker logs $NA_NODE_CONTAINER)" else - pass "remnanode: running (рестартов $RN_RC, SPAWN_ERROR за час нет)" + pass "$NA_NODE_CONTAINER: running (рестартов $RN_RC, SPAWN_ERROR за час нет)" fi elif [[ "$RN_ST" == "absent" ]]; then - info "remnanode: контейнера нет (нода без Remnawave node-агента? или иное имя)" + info "$NA_NODE_CONTAINER: контейнера нет (бокс без Remnawave node-агента — панель/CDN-origin? иначе задай NA_NODE_CONTAINER=<имя>)" else - bad "remnanode: статус '$RN_ST' (не running) — node-агент лежит" + bad "$NA_NODE_CONTAINER: статус '$RN_ST' (не running) — node-агент лежит" fi else - info "docker не установлен — сенсор remnanode пропущен" + info "docker не установлен — сенсор node-контейнера пропущен" fi # порт node-агента vs файрвол: рассинхрон (агент мигрировал 2222→3000, а strict-файрвол # в правилах держит старый порт) = панель молча теряет ноду. Сверяем ФАКТ (детект с ноды: @@ -685,14 +992,24 @@ fi # сертификаты: ближайший к истечению (Let's Encrypt / acme.sh / NA_CERT_PATHS) if command -v openssl >/dev/null 2>&1; then CERT_MIN=-1; CERT_MIN_F="" - for cf in /etc/letsencrypt/live/*/fullchain.pem /root/.acme.sh/*/fullchain.cer ${NA_CERT_PATHS:-}; do + # shellcheck disable=SC2086 # глобы и NA_CERT_PATHS обязаны раскрыться + for cf in $NA_CERT_GLOBS ${NA_CERT_PATHS:-}; do [[ -f "$cf" ]] || continue + cert_retired "$cf" && continue cend="$(openssl x509 -enddate -noout -in "$cf" 2>/dev/null | cut -d= -f2)"; [[ -n "$cend" ]] || continue cends="$(date -d "$cend" +%s 2>/dev/null)" || continue; [[ "$cends" =~ ^[0-9]+$ ]] || continue cdays=$(( (cends - $(date +%s)) / 86400 )) { [[ "$CERT_MIN" -lt 0 ]] || [[ "$cdays" -lt "$CERT_MIN" ]]; } && { CERT_MIN="$cdays"; CERT_MIN_F="$cf"; } done - if [[ "$CERT_MIN" -lt 0 ]]; then info "TLS-сертификатов в стандартных путях не найдено (задай NA_CERT_PATHS, если selfsteal-серт лежит иначе)" + if [[ "$CERT_MIN" -lt 0 ]]; then + # «сертов нет» и «сенсор слеп» были неотличимы и оба тихие: на selfsteal-ноде с + # сертом в /opt/<стек>/certs это молчание означало «о протухании не предупредим + # вообще» — ровно то, ради чего сенсор и нужен (issue #39). + if tls_listening; then + wrn "на :443/:8443 кто-то слушает, а TLS-сертификатов не нашёл — сенсор слеп: задай NA_CERT_PATHS='/путь/к/fullchain.pem …'" + else + info "TLS-сертификатов в стандартных путях не найдено (задай NA_CERT_PATHS, если selfsteal-серт лежит иначе)" + fi elif [[ "$CERT_MIN" -lt 7 ]]; then bad "TLS-серт истекает через ${CERT_MIN} дн ($CERT_MIN_F) — renewal сломан?" elif [[ "$CERT_MIN" -lt 14 ]]; then wrn "TLS-серт истекает через ${CERT_MIN} дн ($CERT_MIN_F) — проверь авто-renew" else pass "ближайший TLS-серт: ${CERT_MIN} дн до истечения ($CERT_MIN_F)"; fi @@ -708,7 +1025,20 @@ if [[ -r /proc/pressure/cpu ]]; then A="$(awk '/^some/{for(i=1;i<=NF;i++) if($i ~ /^avg10=/){sub(/avg10=/,"",$i); print $i}}' "/proc/pressure/$r" 2>/dev/null)"; A="${A:-0}" if awk -v x="$A" 'BEGIN{exit !(x+0>=10)}'; then wrn "PSI $r some avg10=${A}% — заметный стол ядра"; else info "PSI $r some avg10=${A}%"; fi done -else info "PSI недоступен (старое ядро)"; fi +else + # Три состояния вместо двух: «ядро без CONFIG_PSI» ≠ «PSI собран, но выключен по + # умолчанию». XanMod (тот, что ставит сам тулкит) — второй случай, и v4.0 объяснял + # его «старым ядром» на ядре 6.18: сенсор давления молчал на 100% боксов, а строка + # выглядела как штатное «на этом железе не поддерживается» (issue #37). + case "$(psi_state)" in + off-by-default) + info "PSI выключен в сборке ядра (CONFIG_PSI_DEFAULT_DISABLED=y) — включи ENABLE_PSI=1 ре-раном optimize (допишет psi=1 в GRUB) + ребут" ;; + absent) + info "PSI не собран в этом ядре (нет CONFIG_PSI) — давление не измеряется" ;; + *) + info "PSI недоступен: /proc/pressure нет, конфиг ядра не прочитать" ;; + esac +fi # Диск + inodes (лог-флуд жрёт inodes раньше места) DSP="$(df -P / 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5);print $5}')" DIN="$(df -Pi / 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5);print $5}')" @@ -739,6 +1069,27 @@ fi if command -v logrotate >/dev/null 2>&1 && logrotate -d /etc/logrotate.conf 2>&1 | grep -qi 'duplicate log entry'; then wrn "logrotate: дубликат путей — часть станс пропускается целиком (logrotate -d /etc/logrotate.conf)" fi +# Ретеншен журнала ВО ВРЕМЕНИ. Датчик по объёму (выше) спокоен, когда логи капнуты, но +# journald при SystemMaxUse=300M и логе анти-скана на 5/сек (~432 000 строк = ~50 МБ в +# сутки) вытесняет всё остальное и живёт меньше суток: разбор вчерашнего инцидента уже +# невозможен, а на Debian 13 minimal journald — единственный источник истории входов +# (issue #35). Меряем возраст самой старой записи, а не размер. +JSPAN="$(journal_span_h)" || JSPAN="" +if [[ "$JSPAN" =~ ^[0-9]+$ ]]; then + if [[ "$JSPAN" -lt 48 ]]; then + wrn "журнал вмещает менее 48ч (самая старая запись ${JSPAN}ч назад) — форензика вчерашнего инцидента уже недоступна: снизь PORTSCAN_LOG_RATE (ре-ран protect) или подними SystemMaxUse" + else + info "глубина журнала: ${JSPAN}ч (~$((JSPAN/24)) сут)" + fi +fi +PSLINES="$(portscan_log_lines)" || PSLINES="" +if [[ "$PSLINES" =~ ^[0-9]+$ ]]; then + if [[ "$PSLINES" -gt 100000 ]]; then + wrn "строк [na portscan] за текущую загрузку: $PSLINES — лог анти-скана вытесняет журнал (бан работает по счётчикам сета, а не по строкам лога: снизь PORTSCAN_LOG_RATE)" + else + info "строк [na portscan] за текущую загрузку: $PSLINES" + fi +fi # Инциденты ядра/сервисов OOM="$(journalctl -k --since '-24h' --no-pager 2>/dev/null | grep -ciE 'out of memory|oom-killer|soft lockup|hung task')" if [[ "${OOM:-0}" -gt 0 ]]; then wrn "kern-лог за 24ч: $OOM строк OOM/lockup/hung — память/перегруз"; else pass "kern-лог чист (OOM/lockup/hung за 24ч нет)"; fi diff --git a/scripts/diagnose.sh.minisig b/scripts/diagnose.sh.minisig index c0a072d..2bc7b2d 100644 --- a/scripts/diagnose.sh.minisig +++ b/scripts/diagnose.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.0.1 -RUQrJghT9nkdBLJfnqldUYYax1SmBwgDdXRiDFhrsI/FzxS/JgJj3fpgeZhq1UgmtIIIawExiiZNCPN/sHzr5+je/5GdATHZGAU= -trusted comment: timestamp:1788195409 file:diagnose.sh hashed -p5ofh+JC9BbY/T1Ctzr1u4sfyJIXbK1tU+tjD1sc62MuFzHn8K6hAn3Qp+YXnTr3gpnfhXTIH+M6P472fVN9Ag== +untrusted comment: node-accelerator v4.1 +RUQrJghT9nkdBAhVc5hN2qvpWxRqKBW7EivyAkjnlvEChQZ9XIWepJk1Eq7I9rhUhLo9FN7CNY0aWk7tuyrlqRtg89jVpMO9cAY= +trusted comment: timestamp:1788348398 file:diagnose.sh hashed +hZZu8QDEvt0VgKsIkQ8PfxOIrdw9HxrN0WUwYeZKjOQ/Nf/mrkjENA/oXkBGS1A5CdS/2gjheaSU7fGhBlfDDw== diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 85942b1..1f4708e 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -6,7 +6,7 @@ # Версия тулкита — ЕДИНСТВЕННЫЙ источник. Пишется в installed-маркеры и отдаётся # в na-diagnose/na-report --json, чтобы флот-мониторинг видел version-drift по нодам. # shellcheck disable=SC2034 -NA_VERSION="4.0.1" +NA_VERSION="4.1" # shellcheck disable=SC2034 RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' @@ -30,6 +30,61 @@ status_line() { esac } +# ─── Терминал ──────────────────────────────────────────────────────────────── +# tput решает по TERM, а не по isatty(1): под `nohup … > log` / `curl | bash > log` +# при непустом TERM управляющие последовательности курсора уезжали в файл и ломали +# grep по логам раскатки (issue #28). Всё «курсорное» — только когда stdout терминал. +is_tty() { [[ -t 1 ]]; } +tty_tput() { if is_tty; then tput "$@" 2>/dev/null || true; fi; return 0; } + +# ─── JSON ──────────────────────────────────────────────────────────────────── +# json_escape <строка> — значение для вставки внутрь JSON-строки: экранирует \ и ", +# переводит \n \r \t в escape-последовательности, остальные ASCII-контроль выкидывает. +# Один сырой перевод строки внутри значения ломал ВЕСЬ документ `--json` (issue #27): +# каждое строковое поле полагалось на то, что источник «и так чистый». +json_escape() { + local s="$1" + s="${s//\\/\\\\}"; s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}"; s="${s//$'\r'/\\r}"; s="${s//$'\t'/\\t}" + printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037' +} + +# ─── nft-наборы ────────────────────────────────────────────────────────────── +# nft_set_count — число элементов набора. Считаем АДРЕСА внутри +# блока `elements = { … }`, а не строки вывода: в заголовке динамического набора всегда +# есть `flags dynamic,timeout` и `timeout 30m`, поэтому `grep -c timeout` давал «1» на +# пустом наборе и +1 (с 'expires' — +2) на непустом; к тому же nft переносит длинные +# списки, и «строка = адрес» не выполняется вовсе (issue #32/#36). Понимает v4, v6 и +# интервалы (CIDR). Регэксп v6 требует ≥2 двоеточий — иначе матчились бы «1d»/«608ms» +# из timeout/expires (hex-буквы). +nft_set_count() { + local n + n="$(nft list set "$1" "$2" "$3" 2>/dev/null | 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 ' ')" + echo "${n:-0}" +} +# nft_set_elems
— сами адреса (по одному на строку), тем же срезом: +# оператору важно не «сколько», а «кто». +nft_set_elems() { + nft list set "$1" "$2" "$3" 2>/dev/null | 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 +} + +# ver_ge — «a ≥ b» для версий вида X.Y[.Z] (без sort -V: его нет в BSD-coreutils). +ver_ge() { + local -a a b; local i x y + IFS=. read -r -a a <<< "$1"; IFS=. read -r -a b <<< "$2" + for i in 0 1 2; do + x="${a[$i]:-0}"; y="${b[$i]:-0}" + [[ "$x" =~ ^[0-9]+$ ]] || x=0; [[ "$y" =~ ^[0-9]+$ ]] || y=0 + (( 10#$x > 10#$y )) && return 0 + (( 10#$x < 10#$y )) && return 1 + done + return 0 +} + require_root() { if [[ ${EUID:-$(id -u)} -ne 0 ]]; then err "Запусти от root: sudo bash $0" @@ -299,25 +354,84 @@ NA_LIB_DIR=/usr/local/lib/node-accelerator # переменная ещё не задана → ENV всегда побеждает. У optimize и protect — РАЗНЫЕ # файлы, чтобы они не затирали ключи друг друга. -# load_conf — подхватить сохранённый конфиг (no-op если файла нет). +# ─── Дефолты, менявшиеся между версиями (issue #34) ────────────────────────── +# Обратная сторона идиомы `:=`: встроенный дефолт НИКОГДА не выигрывает у записанного. +# Для ручек, которые оператор задавал сам (WHITELIST, NODE_PORT, FW_MODE), так и надо; +# для ручек, которых он не касался, это заморозка — ужесточение по безопасности в новой +# версии молча не доезжает до уже настроенных нод, и никто об этом не узнаёт. +# Различаем «оператор задал» и «записан тогдашний дефолт» маркером `# explicit: KEY`, +# который save_conf пишет для ключей, пришедших из ENV. Ключ БЕЗ маркера, пиннящий +# старый дефолт, — заморозка: warn с обоими значениями, а `NA_ADOPT_NEW_DEFAULTS=1` +# принимает новые дефолты разом (persist на том же прогоне). +# Формат: KEY|старый дефолт|новый дефолт|версия смены|зачем (без символа «|»). +# Пополнять при КАЖДОЙ смене дефолта. +NA_DEFAULT_CHANGES=' +CROWDSEC_STRICT|0|1|4.0|CrowdSec только из пиннингованного APT-репо: curl-bash-фоллбэк форсируется атакующим +' + +# Ключи из NA_DEFAULT_CHANGES, заданные ENV в ЭТОМ прогоне. Снимается ОДИН раз — до +# подхвата первого conf (после него все ключи «заданы», и маркеры explicit врали бы). +conf_note_env_keys() { + [[ -z "${NA_CONF_ENV_KEYS+x}" ]] || return 0 + local k _o _n _v _w + NA_CONF_ENV_KEYS="" + while IFS='|' read -r k _o _n _v _w; do + [[ -n "$k" ]] || continue + [[ -v "$k" ]] && NA_CONF_ENV_KEYS+=" $k" + done <<< "$NA_DEFAULT_CHANGES" + return 0 +} + +# conf_stale_defaults — строки «KEY|старый|новый|версия|зачем» для ключей, которые +# файл пиннит на СТАРЫЙ дефолт без маркера explicit. Общий срез для load_conf и diagnose. +conf_stale_defaults() { + local f="$1" k old new ver why + [[ -f "$f" ]] || return 0 + while IFS='|' read -r k old new ver why; do + [[ -n "$k" ]] || continue + grep -qE "^: \"\\\$\\{${k}:=${old}\\}\"[[:space:]]*$" "$f" 2>/dev/null || continue + grep -qE "^# explicit: ${k}$" "$f" 2>/dev/null && continue + printf '%s|%s|%s|%s|%s\n' "$k" "$old" "$new" "$ver" "$why" + done <<< "$NA_DEFAULT_CHANGES" + return 0 +} + +# load_conf — подхватить сохранённый конфиг (no-op если файла нет), затем +# доложить о замороженных дефолтах (или принять новые при NA_ADOPT_NEW_DEFAULTS=1). load_conf() { - local f="$1" + local f="$1" k old new ver why cur + conf_note_env_keys [[ -n "$f" && -f "$f" && ! -L "$f" ]] || return 0 # shellcheck disable=SC1090 . "$f" + while IFS='|' read -r k old new ver why; do + [[ -n "$k" ]] || continue + [[ " $NA_CONF_ENV_KEYS " == *" $k "* ]] && continue # оператор задал сейчас + cur="${!k-}"; [[ "$cur" == "$old" ]] || continue + if [[ "${NA_ADOPT_NEW_DEFAULTS:-0}" == "1" ]]; then + printf -v "$k" '%s' "$new" + ok "conf: $k=$old → $new — принят дефолт v$ver ($why)" + else + warn "conf: $k=$old записан старой версией, а дефолт с v$ver = $new ($why). Принять: NA_ADOPT_NEW_DEFAULTS=1 (все новые дефолты) или $k=$new при ре-ране; оставить осознанно: $k=$old явно в ENV" + fi + done < <(conf_stale_defaults "$f") + return 0 } # save_conf KEY1 KEY2 … — записать эффективные значения перечисленных # ключей. Атомарно (mktemp+mv), root-only (0600). Значения у нас валидированы и # просты (порты/IP/CIDR/числа/duration); на всякий случай пропускаем ключ, если # в значении есть спецсимволы, способные сломать heredoc-присваивание. +# Маркеры `# explicit: KEY` — для отслеживаемых ключей, заданных ENV сейчас или +# помеченных так в прежнем файле (осознанный выбор оператора переживает ре-раны). save_conf() { local f="$1"; shift - local dir k v tmp + local dir k v tmp _o _n _v _w dir="$(dirname "$f")"; mkdir -p "$dir" tmp="$(mktemp "${TMPDIR:-/tmp}/na-conf.XXXXXX")" || return 1 { echo "# node-accelerator — сохранённый конфиг ноды @ $(date -Is)" + echo "# na_version=${NA_VERSION:-?}" echo "# ENV при ре-ране ПЕРЕОПРЕДЕЛЯЕТ эти значения (идиома :=)." echo "# Сбросить к встроенным дефолтам: rm $f" for k in "$@"; do @@ -328,6 +442,13 @@ save_conf() { esac printf ': "${%s:=%s}"\n' "$k" "$v" done + while IFS='|' read -r k _o _n _v _w; do + [[ -n "$k" ]] || continue + if [[ " ${NA_CONF_ENV_KEYS:-} " == *" $k "* ]] \ + || { [[ -f "$f" ]] && grep -qE "^# explicit: ${k}$" "$f" 2>/dev/null; }; then + echo "# explicit: $k" + fi + done <<< "$NA_DEFAULT_CHANGES" } > "$tmp" chmod 0600 "$tmp" mv -f "$tmp" "$f" diff --git a/scripts/lib/common.sh.minisig b/scripts/lib/common.sh.minisig index fb82d29..14c2083 100644 --- a/scripts/lib/common.sh.minisig +++ b/scripts/lib/common.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.0.1 -RUQrJghT9nkdBKwOKY4N7laoNFGudKyYKokpkqAVMO8ZL4A4qfqQhZaOGhlEmr2CoQzYBKkDBi1zCCr7T7s8AqmpXRfAmxzUVA4= -trusted comment: timestamp:1788195409 file:common.sh hashed -beUfhY5bkXI9hMQ1bSyzrwTsN0DcXEW9QoB904FiGp9eGTHqx9uGmdTCGOXuRszMuwG4ihcCtzPUPpchydRNDg== +untrusted comment: node-accelerator v4.1 +RUQrJghT9nkdBDoV6iLC2aa+udx6asRQD+/4Jh2dHmhHFi2DwCsnKjDnugZ4sje0QhSLZCqfNewWNCx54rN3wX+dzt+3a62rgwU= +trusted comment: timestamp:1788348398 file:common.sh hashed +4C7yF1qh32Hfa8+0wiIibiEOJyYYSPJrWj1o+OxojOGDPXvcBwTNUKn5Nt5eBMuS1sPCV5aHm4eRzx7FT2sICQ== diff --git a/scripts/na-report.sh b/scripts/na-report.sh index d115cc8..fc47969 100755 --- a/scripts/na-report.sh +++ b/scripts/na-report.sh @@ -283,6 +283,10 @@ B ips+=("$ip"); hitsarr+=("$h"); [[ "$h" -gt "$maxhits" ]] && maxhits="$h" done < <(top_ips_raw) enrich_asn "${ips[@]}" + # Колонка ASN остаётся «?», когда обогащать нечем: на боксе без dig И без whois + # отчёт молча выглядит иначе, чем у соседней ноды, и причина неочевидна. + command -v dig >/dev/null 2>&1 || command -v whois >/dev/null 2>&1 \ + || info "ASN не обогащён: нет dig/whois (best-effort — колонка ASN останется '?')" printf " %-18s %6s %-22s %s\n" "IP" "hits" "ASN" "вердикт" for i in "${!ips[@]}"; do ip="${ips[$i]}"; h="${hitsarr[$i]}" diff --git a/scripts/na-report.sh.minisig b/scripts/na-report.sh.minisig index 89b2113..37ab426 100644 --- a/scripts/na-report.sh.minisig +++ b/scripts/na-report.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.0.1 -RUQrJghT9nkdBNmjwr6wpd5z//F4lgTsIa6dWD/8N/aZY869CdDgKV+Sz4Y8fqWUgyQVQCj30hO7g1POm4jwkgYMS0cODVv5pQY= -trusted comment: timestamp:1788195409 file:na-report.sh hashed -MZDoiNSUoUk2WboLttANFBlRp8DTtrGN+nroi6Bgo39ZrsYD3cjX7h/OwWsV/w/CPg7VLJ0RNmxkrU7acItzAw== +untrusted comment: node-accelerator v4.1 +RUQrJghT9nkdBJsxTo1G46BJw9b/ROKYvO+GiPKwL0s0yRThLQdZF7IbLOjOYyrJDXpes7ngwsc+NYx0/eIw5ZB5ZmEaFtMxKAA= +trusted comment: timestamp:1788348398 file:na-report.sh hashed +AZfQN3jm1+flcRBGuimlHHLALVewa0S4xwowVFJb9O+lXAuDSyY/ORXkI8zCrcA5EeEOENbFdHScJ8UE+2aWAA== diff --git a/scripts/optimize.sh b/scripts/optimize.sh index 1a3557f..ff958e5 100755 --- a/scripts/optimize.sh +++ b/scripts/optimize.sh @@ -16,6 +16,8 @@ # ENABLE_LOGROTATE=1 ротация файловых логов ноды + часовой таймер (0 = не трогать) # NA_LOG_PATHS="/var/log/nginx/*.log /var/log/remnanode/*.log" что ротировать # NA_LOG_MAXSIZE=200M NA_LOG_ROTATE=4 NA_LOG_INTERVAL=hourly +# NA_JOURNAL_MAX_USE=300M потолок journald (публичную ноду 300M держат <суток) +# ENABLE_PSI=0 дописать psi=1 в cmdline → /proc/pressure (нужен reboot; 1 = вкл) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -24,8 +26,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Прячем курсор для прогресс-бара установки ядра ниже; гарантированно возвращаем его # при любом выходе, включая Ctrl-C, чтобы не оставить терминал с невидимым курсором. -trap 'tput cnorm 2>/dev/null || true' EXIT -trap 'tput cnorm 2>/dev/null || true; exit 130' INT +# tty_tput, а не голый tput: tput решает по TERM, а не по isatty(1), и под +# `nohup … > log` управляющие последовательности уезжали в лог раскатки (issue #28). +trap 'tty_tput cnorm' EXIT +trap 'tty_tput cnorm; exit 130' INT require_root detect_os @@ -72,10 +76,16 @@ ENABLE_XANMOD="${ENABLE_XANMOD:-1}" XANMOD_FLAVOR="${XANMOD_FLAVOR:-lts}" BACKUP="$(backup_dir)" REBOOT_NEEDED=0 +# Причин ребута теперь может быть две (ядро и psi=1 в cmdline) — копим текстом, +# чтобы в финале не обещать «установлено новое ядро» там, где его не ставили. +REBOOT_WHY="" info "Бэкап изменяемых файлов: $BACKUP" # Прогресс-бар установки ядра (рисуется по APT::Status-Fd в install_xanmod). +# Вне терминала не рисуем ВООБЩЕ: перерисовка живёт на \r и \033[K, а в файле это +# мусор поверх строк лога (issue #28). Прогресс шага там несёт "Ставлю $p…" + итог. draw_progress_bar() { + is_tty || return 0 local percent=$1 desc=$2 width=30 i bar="" local filled=$((percent * width / 100)) local empty=$((width - filled)) @@ -86,6 +96,12 @@ draw_progress_bar() { printf "\r[*] [%s] %3d%% (%s)\033[K" "$bar" "$percent" "$desc" } +# Снять строку прогресс-бара и вернуть курсор. Всё «курсорное» — только в терминале. +progress_end() { + if is_tty; then printf "\r\033[K"; fi + tty_tput cnorm +} + # ─── 1. Зависимости ────────────────────────────────────────────────────────── title "Зависимости" apt_install ca-certificates curl gnupg irqbalance ethtool @@ -190,7 +206,7 @@ install_xanmod() { apt-cache show "$p" >/dev/null 2>&1 || continue info "Ставлю $p (это надолго — компилит initramfs)…" err_log="$(mktemp)" - tput civis 2>/dev/null || true + tty_tput civis # APT::Status-Fd=1 → машинный прогресс в stdout; stdbuf -oL снимает буферизацию пайпа. # pkg НЕ трогаем в subshell справа от пайпа (там только отрисовка) — ставим в родителе. if DEBIAN_FRONTEND=noninteractive stdbuf -oL \ @@ -211,10 +227,10 @@ install_xanmod() { esac done then - printf "\r\033[K"; tput cnorm 2>/dev/null || true + progress_end rm -f "$err_log"; pkg="$p"; break else - printf "\r\033[K"; tput cnorm 2>/dev/null || true + progress_end warn "Сборка $p не установилась — пробую следующую. Хвост ошибки:" tail -n 3 "$err_log" >&2; rm -f "$err_log" fi @@ -225,6 +241,7 @@ install_xanmod() { update-grub >/dev/null 2>&1 || true ok "XanMod установлен: $pkg (активируется ПОСЛЕ перезагрузки)" REBOOT_NEEDED=1 + REBOOT_WHY="${REBOOT_WHY:+$REBOOT_WHY; }новое ядро XanMod ($pkg)" return 0 } @@ -263,6 +280,55 @@ else info "ENABLE_XANMOD=0 — установка ядра пропущена" fi +# ─── 2b. PSI: учёт давления CPU/памяти/IO (opt-in) ─────────────────────────── +# XanMod и стоковые ядра Debian собраны с CONFIG_PSI_DEFAULT_DISABLED=y: PSI в ядре +# ЕСТЬ, но /proc/pressure не появляется без psi=1 в командной строке. То есть тулкит +# сам ставит ядро, на котором его же сенсор давления слеп на 100% нод (issue #37). +# Ручка opt-in и по умолчанию 0: учёт PSI не бесплатен для планировщика, а параметр +# требует ребута. Идемпотентно: второй прогон psi=1 не дублирует. +title "PSI (учёт давления, psi=1 в cmdline)" +ENABLE_PSI="${ENABLE_PSI:-0}" +[[ "$ENABLE_PSI" =~ ^[01]$ ]] || { warn "ENABLE_PSI='$ENABLE_PSI' — ожидается 0|1, беру 0"; ENABLE_PSI=0; } +# Маркер «psi=1 в GRUB прописали МЫ» — только по нему rollback имеет право его снять. +# Переносим с прошлого прогона: ре-ран не должен превратить нашу запись в «чужую». +PSI_MARK=0 +if grep -qx 'psi=1' "$STATE_DIR/optimize.installed" 2>/dev/null; then PSI_MARK=1; fi + +if [[ "$ENABLE_PSI" != "1" ]]; then + # Осознанно НЕ снимаем уже стоящий psi=1: оператор мог включить его сам. + info "psi=1 не трогаю (ENABLE_PSI=1 — включить /proc/pressure, применится после reboot)" +elif [[ ! -f /etc/default/grub ]]; then + info "нет /etc/default/grub (контейнер или загрузчик не GRUB) — psi=1 прописать некуда" +else + # Конфиг ТЕКУЩЕГО ядра. Если ядро только что поставили — грузимся ещё на старом, + # и проверять нечего: лишний параметр загрузки безвреден, дописываем. + _kcfg="/boot/config-$(uname -r)" + _psi_line="$(grep -m1 -E '^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT="[^"]*"[[:space:]]*$' /etc/default/grub 2>/dev/null || true)" + if [[ "$REBOOT_NEEDED" != "1" && -r "$_kcfg" ]] && ! grep -q '^CONFIG_PSI=y' "$_kcfg" 2>/dev/null; then + info "в ядре нет CONFIG_PSI ($_kcfg) — psi=1 ничего не включит, пропускаю" + elif [[ -z "$_psi_line" ]]; then + warn "GRUB_CMDLINE_LINUX_DEFAULT не в ожидаемом виде KEY=\"…\" — psi=1 допиши вручную + update-grub" + elif [[ "$_psi_line" == *psi=1* ]]; then + ok "psi=1 уже в GRUB_CMDLINE_LINUX_DEFAULT" + [[ -r /proc/pressure/cpu ]] || { REBOOT_NEEDED=1; REBOOT_WHY="${REBOOT_WHY:+$REBOOT_WHY; }psi=1 в cmdline (сенсор давления)"; } + else + backup_file /etc/default/grub "$BACKUP" + # Дописываем ВНУТРЬ кавычек значения (за ними идёт остальной cmdline ядра). + # rc≠0 (ro-раздел/битые права) не должен ронять весь прогон — ниже проверяем факт. + sed -i -E 's/^([[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT="[^"]*)"[[:space:]]*$/\1 psi=1"/' /etc/default/grub || true + sed -i -E 's/^([[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=")[[:space:]]+/\1/' /etc/default/grub || true + if grep -qE '^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=.*psi=1' /etc/default/grub; then + PSI_MARK=1 + update-grub >/dev/null 2>&1 || warn "update-grub не отработал — psi=1 доедет только после ручного update-grub" + ok "psi=1 дописан в GRUB_CMDLINE_LINUX_DEFAULT (/proc/pressure — после reboot)" + [[ -r /proc/pressure/cpu ]] || { REBOOT_NEEDED=1; REBOOT_WHY="${REBOOT_WHY:+$REBOOT_WHY; }psi=1 в cmdline (сенсор давления)"; } + else + warn "не смог дописать psi=1 в /etc/default/grub — оставил как было" + fi + fi + unset _kcfg _psi_line +fi + # ─── 3. Sysctl ─────────────────────────────────────────────────────────────── title "Sysctl: BBR, буферы (tier-aware), conntrack, anti-spoof, syncookies" # Tier-aware буферы: масштабируем ПОТОЛКИ сокетов по RAM. На мелкой VPS 64MB-буфер на @@ -451,13 +517,53 @@ ok "nofile/nproc → 1048576 (shell-сессии подхватят после # ─── 5. RPS/RFS/XPS — раскидываем softirq по ядрам ─────────────────────────── title "RPS/RFS/XPS (масштабирование приёма пакетов по ядрам)" +# Интерфейс детектим ЗДЕСЬ (раньше это делала секция 6 «NIC tuning»): имя нужно вшить +# в ExecStart юнита RPS — на буте default route появляется ПОЗЖЕ network-online.target, +# и автодетект в этот момент пуст (issue #30). Секция 6 берёт уже готовое значение. +NIC="$(default_iface || true)" cat > /usr/local/sbin/na-rps-setup <<'RPS' #!/usr/bin/env bash # Включает Receive/Transmit Packet Steering на основном интерфейсе. # На virtio/single-queue VPS весь RX-softirq иначе висит на cpu0 — это потолок PPS. -set -e -NIC="${1:-$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')}" -[ -z "$NIC" ] && exit 0 +# +# Почему тут ожидание маршрута и два фолбэка: network-online.target на буте +# достигается РАНЬШЕ, чем в таблице появляется default route (гонка с dhcpcd, та же, +# что известна по /etc/resolv.conf). Прежняя версия в этот момент молча делала exit 0, +# и юнит с RemainAfterExit навсегда оставался active (exited) с НЕприменённым RPS — +# отказ был полностью бесшумным (issue #30). Теперь: имя NIC с момента optimize → +# ожидание маршрута → все физические интерфейсы → отказ с ненулевым кодом. +set -u +want="${1:-}" +sysdir=/sys/class/net + +nics="" +# 1) Интерфейс, определённый при установке. После смены ядра он мог переименоваться +# (eth0→ens18) — тогда имени в /sys нет, и полагаться на него нельзя. +if [ -n "$want" ] && [ -d "$sysdir/$want" ]; then + nics="$want" +else + [ -n "$want" ] && echo "na-rps: интерфейс '$want' не найден — автодетект" >&2 + # 2) Ждём default route до ~20 с: сеть на буте поднимается позже юнита. + i=0 + while [ "$i" -lt 20 ]; do + nics="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')" + if [ -n "$nics" ]; then break; fi + i=$((i + 1)) + sleep 1 + done + # 3) Маршрута нет и через 20 с (IPv6-only, бридж без default) — берём ВСЕ + # физические интерфейсы: RPS на лишнем NIC безвреден, отсутствующий — потолок PPS. + if [ -z "$nics" ]; then + for d in "$sysdir"/*/device; do + [ -e "$d" ] || continue + n="${d%/device}"; n="${n##*/}" + case "$n" in lo|veth*|docker*|br-*) continue;; esac + nics="${nics:+$nics }$n" + done + [ -n "$nics" ] && echo "na-rps: default route не появился за 20с — беру физические: $nics" >&2 + fi +fi + ncpu="$(nproc)" # Битовая маска всех CPU в формате rps_cpus (группы по 32 бита, старшая первой). mask="$(awk -v n="$ncpu" 'BEGIN{ @@ -465,38 +571,59 @@ mask="$(awk -v n="$ncpu" 'BEGIN{ v=(b>=32?4294967295:(2^b)-1); s=(s==""?sprintf("%x",v):sprintf("%x,%s",v,s)); } print (s==""?"0":s) }')" echo 32768 > /proc/sys/net/core/rps_sock_flow_entries 2>/dev/null || true -for q in /sys/class/net/"$NIC"/queues/rx-*; do - [ -e "$q/rps_cpus" ] && echo "$mask" > "$q/rps_cpus" 2>/dev/null || true - [ -e "$q/rps_flow_cnt" ] && echo 4096 > "$q/rps_flow_cnt" 2>/dev/null || true -done -for q in /sys/class/net/"$NIC"/queues/tx-*; do - [ -e "$q/xps_cpus" ] && echo "$mask" > "$q/xps_cpus" 2>/dev/null || true + +applied=0 +for NIC in $nics; do + [ -d "$sysdir/$NIC" ] || continue + for q in "$sysdir/$NIC"/queues/rx-*; do + [ -e "$q/rps_cpus" ] && echo "$mask" > "$q/rps_cpus" 2>/dev/null || true + [ -e "$q/rps_flow_cnt" ] && echo 4096 > "$q/rps_flow_cnt" 2>/dev/null || true + done + for q in "$sysdir/$NIC"/queues/tx-*; do + [ -e "$q/xps_cpus" ] && echo "$mask" > "$q/xps_cpus" 2>/dev/null || true + done + # Эта строка — маркер успеха в журнале: её отсутствие в `journalctl -u na-rps` + # означает, что RPS не применён, чем бы ни рапортовал статус юнита. + echo "na-rps: NIC=$NIC mask=$mask cpus=$ncpu" + applied=1 done -echo "na-rps: NIC=$NIC mask=$mask cpus=$ncpu" + +# Молчаливый exit 0 здесь и делал «зелёный юнит при выключенном RPS» — падаем честно, +# юнит перезапустится (Restart=on-failure) и отказ будет виден в статусе. +if [ "$applied" -ne 1 ]; then + echo "na-rps: no usable interface (default route not found, no physical NIC) — giving up" >&2 + exit 1 +fi RPS chmod +x /usr/local/sbin/na-rps-setup -cat > /etc/systemd/system/na-rps.service <<'EOF' +# Restart= для Type=oneshot systemd принимает с v244 (в матрице поддержки минимум — +# Debian 11 с 247 и Ubuntu 20.04 с 245, так что версию не гейтим). StartLimit* — чтобы +# нода без сети не перезапускала юнит вечно: после 5 неудач он остаётся failed и виден. +cat > /etc/systemd/system/na-rps.service </dev/null 2>&1 || true -ok "RPS/RFS/XPS включены ($(nproc) ядер)" +ok "RPS/RFS/XPS включены ($(nproc) ядер, NIC=${NIC:-автодетект на буте})" # ─── 6. NIC tuning ─────────────────────────────────────────────────────────── title "NIC tuning (ring buffer, offloads)" -NIC="$(default_iface || true)" if [[ -n "${NIC:-}" ]]; then cat > /etc/systemd/system/na-nic-tune.service < /etc/systemd/journald.conf.d/na-size.conf <<'J' +cat > /etc/systemd/journald.conf.d/na-size.conf </dev/null || echo n/a)" echo if [[ "$REBOOT_NEEDED" == "1" ]]; then - warn "УСТАНОВЛЕНО НОВОЕ ЯДРО XanMod — нужна ПЕРЕЗАГРУЗКА (reboot), чтобы BBRv3 заработал." - warn "После reboot проверь: uname -r (должно содержать 'xanmod')." + warn "НУЖНА ПЕРЕЗАГРУЗКА (reboot): ${REBOOT_WHY:-изменения в параметрах загрузки}" + if [[ "$REBOOT_WHY" == *XanMod* ]]; then + warn "После reboot проверь: uname -r (должно содержать 'xanmod') — тогда работает BBRv3." + fi + if [[ "$REBOOT_WHY" == *psi=1* ]]; then + warn "После reboot проверь: ls /proc/pressure (сенсор давления в na-diagnose оживёт)." + fi fi warn "Часть лимитов применится после перелогина/reboot (DefaultLimit* для systemd-сервисов)." diff --git a/scripts/optimize.sh.minisig b/scripts/optimize.sh.minisig index 53ed6d8..c6b4a25 100644 --- a/scripts/optimize.sh.minisig +++ b/scripts/optimize.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.0.1 -RUQrJghT9nkdBAWiEgKBFnAYZ9EfYbT1tC/09FL5a79BZ5+u6e7FmsylSJ/kVOIAzHWpdCHnsxO9thv+pZ+8VftRqcv7FzzCBwI= -trusted comment: timestamp:1788195409 file:optimize.sh hashed -kGEu8xrDSP4JIp61fjLkWuzvPaPk8xnHdkWHDCCuiLSZeFjiQ8qKgZKn17/jlk6Z1ICJMykSKw0IBX+T5tjZCA== +untrusted comment: node-accelerator v4.1 +RUQrJghT9nkdBPL3gKFq93BQCX78f+pqykpXsUpYxkYuPRSgp46CuXV9bcXjEXtHOblnfLqVPbW0qBmHCTLnwFcaaGD/Lb+XQwg= +trusted comment: timestamp:1788348398 file:optimize.sh hashed +Yd7kr9+TUfp61M5hc5GmxFZuQov2zrOuZiMI0D+iLFNCDx5HABvYhNZHKrf7mhDCyIgUw3CV/FgqKvxUjMW5DQ== diff --git a/scripts/protect.sh b/scripts/protect.sh index e8fa2c2..4241fab 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -25,6 +25,8 @@ # CONN_LIMIT=2048 макс. одновременных конн. с одного IP (ct count) # SSH_RATE=6 SSH_BURST=5 per-IP новых SSH/мин до бана # SSH_BAN_TIME=24h PORTSCAN_BAN_TIME=1h +# PORTSCAN_LOG_RATE=60 строк [na portscan] в МИНУТУ в журнал (0 = не +# PORTSCAN_LOG_BURST=30 логировать вовсе; на бан не влияет — он по meter'ам) # ENABLE_PORTSCAN_BAN=1 ENABLE_CROWDSEC=1 ENABLE_SYNPROXY=0 # CROWDSEC_STRICT=1 ставить CrowdSec ТОЛЬКО из пиннингованного APT-репо; # не поднялся — пропустить (0 = разрешить curl|bash) @@ -93,6 +95,16 @@ PORTSCAN_BAN_TIME="${PORTSCAN_BAN_TIME:-1h}" # Порог автобана за скан: банить IP только если он бьёт по закрытым портам БЫСТРЕЕ # порога (реальный сканер). Одиночные шальные SYN из CGNAT-пула не банят весь оператор. PORTSCAN_RATE="${PORTSCAN_RATE:-15}"; PORTSCAN_BURST="${PORTSCAN_BURST:-30}" # /minute, per-IP +# Сколько строк `[na portscan]` в МИНУТУ пишем в журнал (единицы как у PORTSCAN_RATE). +# До v4.1 тут было 5/second — до 432 000 строк в сутки. Вместе с journald-капом, который +# ставит наш же optimize (SystemMaxUse=300M), это давало на публичной ноде ГЛУБИНУ +# журнала меньше суток: скан вытеснял историю входов, а на Debian minimal journald — +# единственный её источник (ни rsyslog, ни wtmp). Замер флота: 405 822 строки за буту, +# журнал 291.8 МБ, самая старая запись — 20 часов назад (issue #35). +# 60/минуту (≈1/с) хватает, чтобы увидеть скан и построить топ сканеров в na-report. +# 0 = лог-правило анти-скана не ставится вовсе: САМ БАН от этого не меняется — он +# работает по meter'ам (add @suspect / add @autoban), а не по строкам лога. +PORTSCAN_LOG_RATE="${PORTSCAN_LOG_RATE:-60}"; PORTSCAN_LOG_BURST="${PORTSCAN_LOG_BURST:-30}" ENABLE_PORTSCAN_BAN="${ENABLE_PORTSCAN_BAN:-1}" ENABLE_CROWDSEC="${ENABLE_CROWDSEC:-1}" # CROWDSEC_STRICT=1 (дефолт с v4.0) — никакого curl|bash-фоллбэка: не поднялся @@ -228,7 +240,7 @@ _is_duration() { [[ "$1" =~ ^[0-9]+(s|m|h|d)?$ ]]; } # в .timer-юнит → валидируем, чтобы непровалидированный ENV не дописал директив. _is_systime() { [[ "$1" =~ ^[0-9]+(s|sec|m|min|h|hr|d|day)?$ ]]; } for _k in SYN_RATE SYN_BURST UDP_RATE UDP_BURST UDP_BULK_RATE UDP_BULK_BURST CONN_LIMIT ICMP_RATE ICMP_BURST \ - SSH_RATE SSH_BURST PORTSCAN_RATE PORTSCAN_BURST SAFETY_DELAY \ + SSH_RATE SSH_BURST PORTSCAN_RATE PORTSCAN_BURST PORTSCAN_LOG_RATE PORTSCAN_LOG_BURST SAFETY_DELAY \ NA_CTG_PHANTOM_MIN NA_CTG_LIVE_FLOOR NA_CTG_COARSE_MULT; do _is_uint "${!_k}" || { err "$_k='${!_k}' — ожидается целое число"; exit 1; } done @@ -301,24 +313,66 @@ if [[ "$FW_MODE" == "open" && "$ENABLE_PORTSCAN_BAN" == "1" ]]; then fi # whitelist → v4/v6 +# Whitelist в na — это НЕ «доверенный список», а ПОЛНЫЙ обход защиты: accept стоит выше +# автобана, CrowdSec-бунсера и всех per-IP лимитов, а при whitelist-only даёт ещё и +# допуск к контрол-порту node-агента. Отсюда два правила гигиены (issue #38): +# • дубликат из CSV оператора раньше уезжал как есть в nft-сет, в na_filter.nft и в +# CrowdSec-yaml (на боевых нодах один адрес был прописан дважды во всех трёх местах +# сразу) — дедупим, как это давно делает соседний add_npwl; /32 и /128 нормализуем к +# голому адресу: для nft это одно и то же значение, а как ТЕКСТ — два разных, и +# дедуп без нормализации их бы не поймал; +# • широкий CIDR принимался молча: /24 в конфиге ноды означает «256 чужих адресов +# имеют иммунитет», и такие строки живут годами — просто потому, что никто вслух +# не сказал, что это иммунитет, а не «список наших». +# protect.conf при этом хранит WHITELIST РОВНО как задал оператор (это его intent), +# поэтому дубликат там только предупреждается, а не переписывается за него. WL4=""; WL6="" -add_wl() { - local x +WL_DUP_SEEN="" +# Дубль: предупреждаем ОДИН раз на значение (CSV с тройным повтором не должен +# превращать вывод в простыню); из служебного источника (авто-IP сессии) — молча. +wl_warn_dup() { # wl_warn_dup <адрес> <источник> + [[ "$2" == "auto" ]] && return 0 + [[ ",$WL_DUP_SEEN," == *",$1,"* ]] && return 0 + WL_DUP_SEEN+="${WL_DUP_SEEN:+,}$1" + warn "WHITELIST: '$1' указан дважды — в правила пойдёт один раз; в protect.conf твой список остаётся как есть, почисти его сам" + return 0 +} +# Широкий префикс: короче /29 (v4) или /64 (v6). 2^N — сколько адресов получают обход. +wl_warn_wide() { # wl_warn_wide <разрядность> <порог> + local cidr="$1" width="$2" floor="$3" p n human="" + [[ "$cidr" == */* ]] || return 0 + p="${cidr#*/}" + [[ "$p" =~ ^[0-9]+$ ]] || return 0 + (( p < floor )) || return 0 + n=$(( width - p )) + if (( n <= 31 )); then human=" ($(( 1 << n )))"; fi + warn "WHITELIST: $cidr — это ПОЛНЫЙ обход защиты (accept раньше автобана/CrowdSec/лимитов, допуск к node-port при whitelist-only) для 2^$n адресов$human; сузь до хостов" + return 0 +} +add_wl() { # add_wl [auto] — 'auto' = служебный источник (IP текущей SSH-сессии) + local x src="${2:-}" for x in ${1//,/ }; do [[ -z "$x" ]] && continue if [[ "$x" == *:* ]]; then # строго hex+двоеточия (+опц. /prefix) — иначе значение уходит дословно в # nft-heredoc 'elements = { ... }' и может дописать произвольные правила [[ "$x" =~ ^[0-9a-fA-F:]+(/[0-9]{1,3})?$ ]] || { err "WHITELIST: '$x' не валидный IPv6/CIDR"; return 1; } + [[ "$x" == */128 ]] && x="${x%/128}" + if [[ ",${WL6//, /,}," == *",$x,"* ]]; then wl_warn_dup "$x" "$src"; continue; fi + wl_warn_wide "$x" 128 64 WL6+="${WL6:+, }$x" - elif [[ "$x" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$ ]]; then WL4+="${WL4:+, }$x" + elif [[ "$x" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$ ]]; then + [[ "$x" == */32 ]] && x="${x%/32}" + if [[ ",${WL4//, /,}," == *",$x,"* ]]; then wl_warn_dup "$x" "$src"; continue; fi + wl_warn_wide "$x" 32 29 + WL4+="${WL4:+, }$x" else err "WHITELIST: '$x' не IPv4/IPv6/CIDR"; return 1; fi done } add_wl "$WHITELIST" || exit 1 ADMIN_IP="$(ssh_client_ip || true)" if [[ -n "$ADMIN_IP" ]]; then - add_wl "$ADMIN_IP" || true + add_wl "$ADMIN_IP" auto || true info "Авто-whitelist твоего SSH-IP: $ADMIN_IP (защита от самоблокировки)" fi @@ -672,7 +726,16 @@ fi # confirmed-бан. Снимает ложные баны целых CGNAT-операторов из-за одного шального скана. PORTSCAN="" if [[ "$ENABLE_PORTSCAN_BAN" == "1" && "$FW_MODE" != "open" ]]; then - _ps_log4="meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new limit rate 5/second log prefix \"[na portscan] \" level info" + # Единственное лог-правило тулкита, которое пишет в журнал в ШТАТНОМ режиме: оно + # стоит первым в анти-скан-блоке и матчит КАЖДЫЙ новый SYN, дошедший сюда (то есть + # любой стук в закрытый порт), а не превышение лимита. Поэтому именно его рейт вынесен + # в ручку и понижен до PORTSCAN_LOG_RATE/минуту (issue #35). Остальные лог-правила + # ([na synflood], [na ssh-flood], [na badflags]) оставлены на 5/second сознательно: + # они стоят ПОСЛЕ accept'ов с лимитом и срабатывают только сверх порога / на битых + # флагах, то есть в норме молчат и объёма в журнале не создают. + _ps_log4="# лог анти-скана выключен (PORTSCAN_LOG_RATE=0) — бан считают meter'ы ниже, не лог" + [[ "$PORTSCAN_LOG_RATE" != "0" ]] && \ + _ps_log4="meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new limit rate ${PORTSCAN_LOG_RATE}/minute burst ${PORTSCAN_LOG_BURST} packets log prefix \"[na portscan] \" level info" if [[ "$ENABLE_BANONCE" == "1" ]]; then PORTSCAN=" # ANTI-SCAN (ban-once): 1-й быстрый скан → suspect, 2-й в окне ${SUSPECT_TIME} → бан. $_ps_log4 @@ -929,6 +992,60 @@ if ! nft -c -f "$NFT_FILE"; then fi ok "nft -c: синтаксис валиден" +# ─── Бюджет журнала под лог анти-скана (issue #35) ─────────────────────────── +# Тулкит одной рукой включает поток `[na portscan]`, другой (optimize) ограничивает +# journald капом — и до v4.1 нигде не говорил, что вместе это даёт ретеншен меньше +# суток. Считаем ДО применения (в DRY_RUN тоже) и говорим вслух. +# ~500 Б — размер ЗАПИСИ journald, а не длины текста: сама строка «[na portscan] IN=… +# SRC=… DPT=…» это ~120 Б, но journald хранит её с заголовком и двумя десятками полей +# метаданных (_PID/_COMM/_BOOT_ID/_MACHINE_ID/…), а Compress=yes не сжимает записи +# мельче 512 Б. Замер флота даёт верхнюю границу ~700 Б/запись (405 822 строки при +# журнале 291.8 МБ) — берём 500 как срединную оценку. +NA_JOURNAL_LINE_BYTES=500 +# Кап журнала в байтах. Порядок как у systemd: journald.conf, поверх — drop-in'ы +# journald.conf.d/*.conf (побеждает последний), закомментированные строки не в счёт. +# Не нашли — считаем 300M: столько ставит наш же optimize, и это худший реалистичный +# случай (на ноде без optimize кап по умолчанию — 10% от размера /var/log). +journald_cap_bytes() { + local f v last="" n u + for f in /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf; do + [[ -f "$f" ]] || continue + v="$(awk -F= '/^[[:space:]]*SystemMaxUse[[:space:]]*=/{gsub(/[[:space:]]/,"",$2); print $2}' "$f" 2>/dev/null | tail -1)" + [[ -n "$v" ]] && last="$v" + done + [[ -n "$last" ]] || { echo $((300*1024*1024)); return 0; } + n="$(printf '%s' "$last" | grep -oE '^[0-9]+')" || true + [[ -n "$n" ]] || { echo $((300*1024*1024)); return 0; } + u="$(printf '%s' "${last#"$n"}" | tr '[:lower:]' '[:upper:]')" + case "$u" in + K) echo $((n*1024));; + M) echo $((n*1024*1024));; + G) echo $((n*1024*1024*1024));; + T) echo $((n*1024*1024*1024*1024));; + *) echo "$n";; # без суффикса systemd читает как байты + esac +} +_hbytes() { # байты → человекочитаемо (МБ/ГБ), без bc + local b="$1" + if (( b >= 1024*1024*1024 )); then printf '%d.%02d ГБ' $((b/1024/1024/1024)) $(( (b*100/1024/1024/1024)%100 )) + elif (( b >= 1024*1024 )); then printf '%d МБ' $((b/1024/1024)) + else printf '%d КБ' $((b/1024)); fi +} +check_journal_budget() { + [[ "$ENABLE_PORTSCAN_BAN" == "1" && "$FW_MODE" != "open" ]] || return 0 + (( PORTSCAN_LOG_RATE > 0 )) || return 0 + local cap day pct + cap="$(journald_cap_bytes)" + (( cap > 0 )) || return 0 + day=$(( PORTSCAN_LOG_RATE * 1440 * NA_JOURNAL_LINE_BYTES )) + pct=$(( day * 100 / cap )) + (( pct > 30 )) || return 0 + warn "журнал: лог анти-скана при PORTSCAN_LOG_RATE=$PORTSCAN_LOG_RATE даёт ~$(_hbytes "$day")/сутки при капе journald $(_hbytes "$cap") (${pct}% в сутки) — история в журнале проживёт меньше $(( 100 / pct )) суток и вытеснит логи входов/сервисов" + warn "снизь PORTSCAN_LOG_RATE (0 = не логировать, на бан не влияет) или подними NA_JOURNAL_MAX_USE (optimize)" + return 0 +} +check_journal_budget + if [[ "$DRY_RUN" == "1" ]]; then ok "DRY-RUN: файл сгенерирован и проверен. Применение пропущено." info "Посмотреть: cat $NFT_FILE" @@ -999,8 +1116,12 @@ if [[ "$ENABLE_CROWDSEC" == "1" ]]; then # whitelist админа/панели в самом CrowdSec — чтобы IPS их не банил. # Ключи ip:/cidr: пишем ТОЛЬКО при наличии записей (пустые ключи валят парсер). mkdir -p /etc/crowdsec/parsers/s02-enrich + # Источник — УЖЕ разобранные WL4/WL6 (дедуп + нормализация /32,/128), а не сырой + # WHITELIST: иначе дубликат из CSV оператора попадал сюда третьим экземпляром, + # мимо чистки, сделанной для nft-сета и na_filter.nft (issue #38). ADMIN_IP тут + # отдельно не нужен — add_wl уже влил его в WL4/WL6. IP_ITEMS=""; CIDR_ITEMS="" - for x in ${WHITELIST//,/ } ${ADMIN_IP:-}; do + for x in ${WL4//,/ } ${WL6//,/ }; do [[ -z "$x" ]] && continue if [[ "$x" == */* ]]; then CIDR_ITEMS+=" - \"$x\""$'\n'; else IP_ITEMS+=" - \"$x\""$'\n'; fi done @@ -1426,32 +1547,50 @@ EOF fi # ─── fw-status хелпер ──────────────────────────────────────────────────────── -cat > /usr/local/sbin/na-fw-status <<'STAT' -#!/usr/bin/env bash +# Все счётчики наборов идут через nft_set_count из lib/common.sh, а тело функций +# ВШИВАЕТСЯ в хелпер через `declare -f`: na-fw-status — самостоятельный скрипт на ноде, +# и lib/common.sh рядом с ним может не лежать вовсе (curl|bash гоняет модули из +# временной папки, которой после установки уже нет). +# Почему не `grep -c` по выводу nft: в заголовке ЛЮБОГО динамического набора всегда есть +# строка `flags dynamic,timeout`, поэтому `grep -c timeout` рисовал «1» на ПУСТОМ наборе +# и +1 на непустом, а элементы nft переносит по несколько в строку — «строка = адрес» не +# выполняется в принципе. Один и тот же autoban считался в na-fw-status и в na-diagnose +# двумя разными способами, и команды расходились между собой на живой ноде (issue #32/#36). +write_fw_status() { + { + echo '#!/usr/bin/env bash' + echo '# na-fw-status — сводка защиты (ставит protect.sh, снимает rollback).' + echo '# Счётчики наборов — общий хелпер lib/common.sh, вшит сюда declare -f: скрипт' + echo '# должен работать на ноде сам по себе, без каталога с библиотекой рядом.' + 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 echo echo "── autoban (живые баны) ──" -echo "v4: $(nft list set inet na_filter autoban_v4 2>/dev/null | grep -oE '[0-9.]+ timeout' | wc -l) v6: $(nft list set inet na_filter autoban_v6 2>/dev/null | grep -c timeout)" -nft list set inet na_filter autoban_v4 2>/dev/null | grep -oE '[0-9.]+ (timeout|expires)[^,]*' | head -15 +echo "v4: $(nft_set_count inet na_filter autoban_v4) v6: $(nft_set_count inet na_filter autoban_v6)" +# Кто именно забанен и до когда. Срез строго по блоку `elements = { … }`: вне его те же +# слова timeout/expires живут в заголовке набора. +nft list set inet na_filter autoban_v4 2>/dev/null | sed -n '/elements = {/,/^[[:space:]]*}/p' \ + | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3} (timeout|expires)[^,}]*' | head -15 if nft list set inet na_filter suspect_v4 >/dev/null 2>&1; then - echo "suspect (наблюдение, ban-once) v4: $(nft list set inet na_filter suspect_v4 2>/dev/null | grep -c timeout) v6: $(nft list set inet na_filter suspect_v6 2>/dev/null | grep -c timeout)" + echo "suspect (наблюдение, ban-once) v4: $(nft_set_count inet na_filter suspect_v4) v6: $(nft_set_count inet na_filter suspect_v6)" fi echo if nft list set inet na_filter blocklist_v4 >/dev/null 2>&1; then echo "── threat-блоклисты ──" - echo "v4: $(nft list set inet na_filter blocklist_v4 2>/dev/null | grep -coE '[0-9.]+') v6: $(nft list set inet na_filter blocklist_v6 2>/dev/null | grep -c ':') (обновляет na-blocklist-update)" + echo "v4: $(nft_set_count inet na_filter blocklist_v4) v6: $(nft_set_count inet na_filter blocklist_v6) (обновляет na-blocklist-update)" echo fi if nft list set inet na_filter na_fleet_v4 >/dev/null 2>&1; then echo "── fleet-sync (ноды флота → whitelist) ──" - echo "v4: $(nft list set inet na_filter na_fleet_v4 2>/dev/null | grep -coE '[0-9.]+') v6: $(nft list set inet na_filter na_fleet_v6 2>/dev/null | grep -c ':') (последний синк: $(journalctl -t na-fleet-sync -n1 --no-pager -o cat 2>/dev/null | head -c 80))" + echo "v4: $(nft_set_count inet na_filter na_fleet_v4) v6: $(nft_set_count inet na_filter na_fleet_v6) (последний синк: $(journalctl -t na-fleet-sync -n1 --no-pager -o cat 2>/dev/null | head -c 80))" echo fi if nft list table inet na_ctguard >/dev/null 2>&1; then echo "── ctguard (phantom-eviction) ──" enf="$(awk -F= '/^NA_CTG_ENFORCE/{print $2}' /etc/node-accelerator/ctguard.conf 2>/dev/null)" - echo "режим: $([ "${enf:-0}" = 1 ] && echo ENFORCE || echo observe) фантомов в блоке v4: $(nft list set inet na_ctguard phantom_v4 2>/dev/null | grep -c timeout) v6: $(nft list set inet na_ctguard phantom_v6 2>/dev/null | grep -c timeout)" + echo "режим: $([ "${enf:-0}" = 1 ] && echo ENFORCE || echo observe) фантомов в блоке v4: $(nft_set_count inet na_ctguard phantom_v4) v6: $(nft_set_count inet na_ctguard phantom_v6)" journalctl -t na-ctguard -n3 --no-pager -o cat 2>/dev/null | sed 's/^/ /' echo fi @@ -1466,7 +1605,10 @@ if command -v cscli >/dev/null 2>&1; then cscli metrics 2>/dev/null | sed -n '1,25p' fi STAT -chmod +x /usr/local/sbin/na-fw-status + } > /usr/local/sbin/na-fw-status + chmod +x /usr/local/sbin/na-fw-status +} +write_fw_status # ─── top-talkers хелпер ────────────────────────────────────────────────────── # Если нода за реверс-прокси/балансировщиком/CDN — трафик идёт с горстки upstream-IP, @@ -1523,7 +1665,7 @@ save_conf "$CONF_DIR/protect.conf" \ FW_MODE SSH_PORT TCP_PORTS UDP_PORTS NODE_PORT WHITELIST \ SYN_RATE SYN_BURST UDP_RATE UDP_BURST UDP_BULK_PORTS UDP_BULK_RATE UDP_BULK_BURST CONN_LIMIT \ ICMP_RATE ICMP_BURST SSH_RATE SSH_BURST SSH_BAN_TIME \ - PORTSCAN_BAN_TIME PORTSCAN_RATE PORTSCAN_BURST \ + PORTSCAN_BAN_TIME PORTSCAN_RATE PORTSCAN_BURST PORTSCAN_LOG_RATE PORTSCAN_LOG_BURST \ ENABLE_PORTSCAN_BAN ENABLE_CROWDSEC CROWDSEC_STRICT ENABLE_SYNPROXY \ ENABLE_BLOCKLISTS BLOCK_TOR BLOCKLIST_REFRESH ENABLE_BANONCE SUSPECT_TIME \ FLEET_SYNC FLEET_SYNC_INTERVAL \ diff --git a/scripts/protect.sh.minisig b/scripts/protect.sh.minisig index 2db4061..aae0380 100644 --- a/scripts/protect.sh.minisig +++ b/scripts/protect.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.0.1 -RUQrJghT9nkdBJW7QbjT9v5Dk6an8bokwh6Gr4iIZoMQBo1F5KKnSg73UhWoWBCLp/iabhet9WHP/SiR00YJ9QBWdnmXTuhkDAw= -trusted comment: timestamp:1788195409 file:protect.sh hashed -LZVWgqk1U6+p2WHdRhrxog/LmwDJfoQTcGN1bZZZRV48oK44J/i7isxqsNVbvzkmPBw2ppufPDbJH+gb0FSMBw== +untrusted comment: node-accelerator v4.1 +RUQrJghT9nkdBCM9IR9iMEwQS6sDcwVTfzg4UV7E98JvNxD41mLD2QfmRf0TyPypOeHE83iUW2N3726gl0RL3SkQH1Sem0cEKwY= +trusted comment: timestamp:1788348398 file:protect.sh hashed +ggstRnICDPxx7aRN65Hx+Maj1WBpDpF019pgSYD3eX91Ub8EsCawEq2SGYNIo6IPZOSvoSGFSATjm9HdhaCzCg== diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 99ad535..56d5113 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -62,6 +62,25 @@ rollback_optimize() { sysctl --system >/dev/null 2>&1 || true systemctl restart systemd-journald 2>/dev/null || true + # psi=1 в GRUB_CMDLINE_LINUX_DEFAULT снимаем ТОЛЬКО если дописывали его МЫ: ровно + # в этом случае в маркере есть строка psi=1. Оператор мог включить учёт давления + # сам (или он приехал из образа хостера) — чужой параметр загрузки не наш артефакт. + if [[ -f /etc/default/grub ]] \ + && grep -qx 'psi=1' "$STATE_DIR/optimize.installed" 2>/dev/null \ + && grep -qE '^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=.*psi=1' /etc/default/grub; then + # Правим ТОЛЬКО строку GRUB_CMDLINE_LINUX_DEFAULT: остальной cmdline (и чужие + # psi= в других переменных) не наше дело. Хвостовые пробелы подчищаем, иначе + # ре-ран optimize увидит «не в ожидаемом виде». + sed -i -E '/^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=/{ + s/psi=1//g + s/="[[:space:]]+/="/ + s/[[:space:]]+"[[:space:]]*$/"/ + s/[[:space:]][[:space:]]+/ /g + }' /etc/default/grub 2>/dev/null || warn "не смог убрать psi=1 из /etc/default/grub — проверь вручную" + update-grub >/dev/null 2>&1 || true + info "psi=1 убран из GRUB_CMDLINE_LINUX_DEFAULT (/proc/pressure исчезнет после reboot)" + fi + # XanMod-ядро: удаляем ТОЛЬКО если сейчас работаем не на нём (иначе оставим как есть) if [[ -f "$STATE_DIR/xanmod.pkg" ]]; then local pkg; pkg="$(cat "$STATE_DIR/xanmod.pkg")" diff --git a/scripts/rollback.sh.minisig b/scripts/rollback.sh.minisig index 1c471d1..02678e3 100644 --- a/scripts/rollback.sh.minisig +++ b/scripts/rollback.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.0.1 -RUQrJghT9nkdBHtUwNrCoDinG8eRDo4TrqXAq0Hwzrh4mZGVTElcs2zYKR/xOS808sfl3QbCPUy1zVF++n61QNMXVXUvMX3J6AU= -trusted comment: timestamp:1788195409 file:rollback.sh hashed -nDy52eEyypysmejwrXtouFE11L2zaZwcS2IC6E7CWV7QAhcFyWofGnhB6AZcGScjHojnESJMG2WObTAxmH7KAw== +untrusted comment: node-accelerator v4.1 +RUQrJghT9nkdBB27AWybm39DtJ+7UApLPni5ByAeqgET7Ghc3BDFfn5Idz5Cc9n5WK99g5bU/FN1ZvxW8NaK7oLw+MMpBCXNcws= +trusted comment: timestamp:1788348398 file:rollback.sh hashed +P98lXulSmOThhYnLo/URvGT9p3ieavFZMHQiXwuKVTnD4gawt0DU9H/Kvlb80z1MK4iJX469XCv9FxhS6MccCw== diff --git a/tests/common-unit.sh b/tests/common-unit.sh new file mode 100644 index 0000000..0fdc3d6 --- /dev/null +++ b/tests/common-unit.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# common-unit.sh — юнит-тесты хелперов lib/common.sh, добавленных по аудиту флота v4.1: +# 1. nft_set_count / nft_set_elems считают АДРЕСА, а не строки вывода nft — пустой +# динамический набор = 0, непустой = число элементов, многострочный вывод и v6 +# разбираются (issue #32/#36); +# 2. json_escape: перевод строки / кавычка / бэкслеш внутри значения не ломают +# JSON-документ (issue #27); +# 3. tty_tput ничего не пишет, когда stdout — не терминал (issue #28); +# 4. персист конфига (issue #34): замороженный старый дефолт без маркера explicit +# → warn; NA_ADOPT_NEW_DEFAULTS=1 → принят; ENV-значение → маркер `# explicit:` +# и тишина на следующем прогоне; ver_ge. +# +# Не требует root/сети/nft. Запуск: bash tests/common-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/etc" + +# хелперам нужен bash ≥ 4.2 ([[ -v ]]) — на нодах и CI есть всегда, на macOS системный +# древний: берём первый подходящий. +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.4"; exit 1; } +[[ "$(basename "$WBASH")" == bash ]] || true + +# ── стаб nft: печатает наборы как настоящий nft 1.0.9/1.1.x ─────────────────── +cat > "$T/bin/nft" <<'NFT' +#!/usr/bin/env bash +# nft list set
+set="${5:-}" +case "$set" in + empty_v4) + printf 'table inet na_filter {\n\tset empty_v4 {\n\t\ttype ipv4_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\ttimeout 30m\n\t}\n}\n';; + one_v4) + printf 'table inet na_filter {\n\tset one_v4 {\n\t\ttype ipv4_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\telements = { 198.51.100.23 timeout 1d expires 1h11m50s608ms }\n\t}\n}\n';; + many_v4) + printf 'table inet na_filter {\n\tset many_v4 {\n\t\ttype ipv4_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\telements = { 1.2.3.4 timeout 1d expires 1h,\n\t\t\t 5.6.7.8 timeout 1d expires 2h, 9.9.9.9 timeout 1d expires 3h,\n\t\t\t 10.0.0.1 timeout 1d expires 4h }\n\t}\n}\n';; + empty_v6) + printf 'table inet na_filter {\n\tset empty_v6 {\n\t\ttype ipv6_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t}\n}\n';; + two_v6) + printf 'table inet na_filter {\n\tset two_v6 {\n\t\ttype ipv6_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\telements = { 2001:db8::1 timeout 1d expires 5m30s608ms,\n\t\t\t 2001:db8:abcd::5 timeout 1d expires 1h }\n\t}\n}\n';; + cidr_v4) + printf 'table inet na_filter {\n\tset cidr_v4 {\n\t\ttype ipv4_addr\n\t\tflags interval\n\t\tauto-merge\n\t\telements = { 10.0.0.0/8, 203.0.113.7, 192.0.2.0/24 }\n\t}\n}\n';; + *) echo "Error: No such file or directory" >&2; exit 1;; +esac +NFT +chmod +x "$T/bin/nft" +export PATH="$T/bin:$PATH" + +PASS=0; FAIL=0 +check() { # check "описание" <ожидание> <факт> + if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1" + else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi +} +run() { "$WBASH" -c "set -uo pipefail; . '$REPO_ROOT/scripts/lib/common.sh'; $1" 2>&1; } + +echo "== 1. nft_set_count / nft_set_elems ==" +check "пустой динамический набор v4 = 0 (не 1 из-за 'flags dynamic,timeout')" 0 "$(run 'nft_set_count inet na_filter empty_v4')" +check "один элемент v4 = 1 (не 2 из-за timeout+expires)" 1 "$(run 'nft_set_count inet na_filter one_v4')" +check "многострочный список v4 = 4" 4 "$(run 'nft_set_count inet na_filter many_v4')" +check "пустой набор v6 = 0" 0 "$(run 'nft_set_count inet na_filter empty_v6')" +check "два элемента v6 = 2 ('1d'/'608ms' из timeout не считаются адресами)" 2 "$(run 'nft_set_count inet na_filter two_v6')" +check "интервальный набор (CIDR) = 3" 3 "$(run 'nft_set_count inet na_filter cidr_v4')" +check "несуществующий набор = 0" 0 "$(run 'nft_set_count inet na_filter nope')" +check "nft_set_elems отдаёт адреса по одному в строку" "$(printf '1.2.3.4\n10.0.0.1\n5.6.7.8\n9.9.9.9')" "$(run 'nft_set_elems inet na_filter many_v4')" + +echo "== 2. json_escape ==" +check "перевод строки → \\n" 'a\nb' "$(run 'json_escape "$(printf "a\nb")"')" +check "кавычка и бэкслеш" 'x\"y\\z' "$(run 'json_escape "x\"y\\z"')" +check "таб → \\t, прочий контроль выкинут" 'p\tq' "$(run 'json_escape "$(printf "p\tq\001")"')" +check "UTF-8 проходит как есть" 'нода' "$(run 'json_escape "нода"')" +J="$(run 'printf "{\"s\":\"%s\"}\n" "$(json_escape "$(printf "\nabsent")")"')" +if command -v jq >/dev/null 2>&1; then + check "экранированное значение парсится jq" '"\nabsent"' "$(printf '%s' "$J" | jq -c '.s')" +elif command -v python3 >/dev/null 2>&1; then + check "экранированное значение парсится python" "ok" "$(printf '%s' "$J" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("ok" if d["s"]=="\nabsent" else d["s"])')" +fi + +echo "== 3. tty_tput ==" +# stdout здесь — пайп, не терминал: ничего не должно печататься и код 0 +check "tty_tput civis в пайпе молчит" "" "$(TERM=xterm run 'tty_tput civis; tty_tput cnorm' | od -c | grep -v '^[0-9]* *$' || true)" +check "tty_tput возвращает 0 не в терминале" "0" "$(TERM=xterm run 'tty_tput civis; echo $?')" + +echo "== 4. ver_ge ==" +check "4.1 ≥ 4.0" yes "$(run 'ver_ge 4.1 4.0 && echo yes || echo no')" +check "4.0.1 ≥ 4.0" yes "$(run 'ver_ge 4.0.1 4.0 && echo yes || echo no')" +check "3.9.2 ≥ 4.0 нет" no "$(run 'ver_ge 3.9.2 4.0 && echo yes || echo no')" +check "4.0 ≥ 4.0.1 нет" no "$(run 'ver_ge 4.0 4.0.1 && echo yes || echo no')" +check "10.0 ≥ 9.9" yes "$(run 'ver_ge 10.0 9.9 && echo yes || echo no')" + +echo "== 5. персист: замороженный дефолт (issue #34) ==" +CONF="$T/etc/protect.conf" +# conf, записанный v3.9: CROWDSEC_STRICT=0 — тогдашний дефолт, оператор не трогал +printf '# node-accelerator — сохранённый конфиг ноды @ 2026-07-11\n: "${CROWDSEC_STRICT:=0}"\n: "${CONN_LIMIT:=2048}"\n' > "$CONF" +OUT="$(run "load_conf '$CONF'; echo VAL=\$CROWDSEC_STRICT")" +check "старый дефолт без explicit → warn" 1 "$(printf '%s\n' "$OUT" | grep -c 'CROWDSEC_STRICT=0 записан старой версией')" +check "значение при этом НЕ меняется (статус-кво)" "VAL=0" "$(printf '%s\n' "$OUT" | grep '^VAL=')" +check "conf_stale_defaults перечисляет ключ" "CROWDSEC_STRICT|0|1|4.0" "$(run "conf_stale_defaults '$CONF'" | cut -d'|' -f1-4)" +OUT="$(run "NA_ADOPT_NEW_DEFAULTS=1 load_conf '$CONF'; echo VAL=\$CROWDSEC_STRICT")" +check "NA_ADOPT_NEW_DEFAULTS=1 → принят новый дефолт" "VAL=1" "$(printf '%s\n' "$OUT" | grep '^VAL=')" +check "…и об этом сказано ok, без warn" 0 "$(printf '%s\n' "$OUT" | grep -c 'записан старой версией')" +# оператор задал ENV сам → warn нет, save_conf пишет маркер explicit +OUT="$(run "export CROWDSEC_STRICT=0; load_conf '$CONF'; save_conf '$CONF' CROWDSEC_STRICT CONN_LIMIT; cat '$CONF'")" +check "ENV-значение: warn нет" 0 "$(printf '%s\n' "$OUT" | grep -c 'записан старой версией')" +check "save_conf пишет маркер explicit" 1 "$(printf '%s\n' "$OUT" | grep -c '^# explicit: CROWDSEC_STRICT$')" +check "save_conf пишет na_version в шапку" 1 "$(grep -c '^# na_version=' "$CONF")" +check "значение сохранено идиомой :=" 1 "$(grep -c '^: "${CROWDSEC_STRICT:=0}"$' "$CONF")" +# следующий прогон БЕЗ ENV: маркер есть → тишина, conf_stale_defaults пуст +OUT="$(run "load_conf '$CONF'; echo VAL=\$CROWDSEC_STRICT")" +check "ре-ран без ENV при маркере explicit: тишина" 0 "$(printf '%s\n' "$OUT" | grep -c 'записан старой версией')" +check "conf_stale_defaults пуст при маркере" "" "$(run "conf_stale_defaults '$CONF'")" +# маркер переживает следующий save_conf без ENV +OUT="$(run "load_conf '$CONF'; save_conf '$CONF' CROWDSEC_STRICT; cat '$CONF'")" +check "маркер explicit переживает ре-сохранение без ENV" 1 "$(printf '%s\n' "$OUT" | grep -c '^# explicit: CROWDSEC_STRICT$')" +# conf, в котором ключ уже на новом дефолте — не дрейф +printf ': "${CROWDSEC_STRICT:=1}"\n' > "$CONF" +check "новый дефолт в conf — не дрейф" "" "$(run "conf_stale_defaults '$CONF'")" +# ключа в conf нет вовсе (старый conf до появления ручки) — не дрейф +printf ': "${CONN_LIMIT:=2048}"\n' > "$CONF" +check "ключа нет в conf — не дрейф" "" "$(run "conf_stale_defaults '$CONF'")" +# conf_note_env_keys фиксирует ENV один раз — второй load_conf не «видит» ключи первого файла как ENV +printf ': "${CROWDSEC_STRICT:=0}"\n' > "$CONF" +printf ': "${CROWDSEC_STRICT:=0}"\n' > "$T/etc/second.conf" +OUT="$(run "load_conf '$CONF' >/dev/null; load_conf '$T/etc/second.conf'")" +check "второй load_conf: ключ из первого файла — не ENV (warn есть)" 1 "$(printf '%s\n' "$OUT" | grep -c 'записан старой версией')" + +echo +echo "итого: ok=$PASS fail=$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/tests/diagnose-unit.sh b/tests/diagnose-unit.sh new file mode 100755 index 0000000..559db63 --- /dev/null +++ b/tests/diagnose-unit.sh @@ -0,0 +1,465 @@ +#!/usr/bin/env bash +# +# diagnose-unit.sh — ИСПОЛНЯЕТ scripts/diagnose.sh (обе ветки: --json и человекочитаемую) +# в песочнице против стабов docker/nft/ss/cscli/journalctl/systemctl. diagnose read-only, +# но целиком в CI не гонялся никогда — поэтому весь урожай аудита боевого флота v4.0.1 +# (11 нод + панель) собран именно здесь. +# +# Что стережём (issue → проверка): +# #27 docker inspect по несуществующему контейнеру печатает "\n" ДО ошибки → статус +# должен быть absent (а не "\nabsent"), --json обязан парситься, ✘ «node-агент +# лежит» не печататься; NA_NODE_CONTAINER переключает имя контейнера; +# #30 юнит na-rps active при пустом rps_cpus — это ▲ «гонка на буте», а не ✔; +# #31 датчик CONN_LIMIT считает ТОЛЬКО входящие на порты правила, без loopback и +# вайтлиста (иначе вечная ложная тревога на любой ноде с nginx↔xray); +# #32 счётчики autoban/suspect считают АДРЕСА, а не строки вывода nft (пустой набор = +# 0, не 1), при ненулевом autoban печатаются сами адреса; CrowdSec decisions без +# строки-заголовка CSV; +# #34 conf, пиннящий устаревший дефолт (CROWDSEC_STRICT=0), виден как ▲; +# #35 глубина журнала во времени + объём лога анти-скана; +# #37 PSI: «собран, но выключен по умолчанию» ≠ «старое ядро»; +# #38 whitelist сверяется с ТРЕМЯ источниками: дрейф относительно protect.conf назван +# по адресу и опознан как IP текущей сессии; +# #39 серт в /opt/<стек>/certs/*/ находится, серт acme.sh, снятый с renew, — нет. +# +# Не требует root/сети/nft/docker. Запуск: bash tests/diagnose-unit.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT + +# diagnose и хелперам нужен bash ≥ 4.2 ([[ -v ]], пустые массивы под set -u) — на нодах и +# в CI он есть всегда, системный bash macOS древний: берём первый подходящий. +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; } + +BIN="$T/bin"; NFTD="$T/nft"; SSD="$T/ssdata"; export NFTD SSD +mkdir -p "$BIN" "$NFTD" "$SSD" \ + "$T/var/lib/node-accelerator" "$T/etc/node-accelerator" "$T/var/log" \ + "$T/var/lib/docker/containers" "$T/proc" "$T/boot" "$T/systemd" \ + "$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/root/.acme.sh/retired.example.test" + +KREL="6.18.47-x64v3-xanmod1" +JSTART=$(( $(date +%s) - 10*3600 )) # журнал вмещает 10ч → «менее 48ч» +export JSTART + +# ── Копия скриптов с системными путями, уведёнными в песочницу ──────────────── +# STATE_DIR/CONF_DIR живут в lib/common.sh, менять его нельзя — правим КОПИЮ (тот же +# приём, что в logrotate-unit.sh). Заодно уводим /proc, /sys, /boot и пути сертов: +# без этого тест читал бы живой /proc/pressure раннера и результат зависел бы от того, +# с каким ядром собран CI. +cp -R "$REPO_ROOT/scripts" "$T/scripts" +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/#$T/proc/#g" \ + -e "s#/sys/#$T/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" \ + -e "s#/etc/systemd/system/#$T/systemd/#g" \ + -e "s#/etc/logrotate.conf#$T/etc/logrotate.conf#g" \ + -e "s#/etc/os-release#$T/etc/os-release#g" \ + -e "s#/tmp/na-fw-safety.pid#$T/na-fw-safety.pid#g" \ + -e "s#/etc/letsencrypt#$T/etc/letsencrypt#g" \ + -e "s#/root/.acme.sh#$T/root/.acme.sh#g" \ + -e "s#/opt/\*/certs#$T/opt/*/certs#g" \ + "$1" + rm -f "$1.bak" +} +sandbox_paths "$T/scripts/diagnose.sh" +sandbox_paths "$T/scripts/lib/common.sh" +DIAG="$T/scripts/diagnose.sh" +: > "$T/etc/logrotate.conf" +# os-release тоже в песочницу: иначе отчёт зависел бы от ОС раннера +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" +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" +# PSI собран, но выключен по умолчанию, и psi=1 в cmdline нет (ровно XanMod, #37) +printf 'CONFIG_PSI=y\nCONFIG_PSI_DEFAULT_DISABLED=y\n' > "$T/boot/config-$KREL" +printf 'BOOT_IMAGE=/boot/vmlinuz-%s root=PARTUUID=x ro console=tty0\n' "$KREL" > "$T/proc/cmdline" +printf '100\n' > "$T/proc/sys/net/netfilter/nf_conntrack_count" +cat > "$T/proc/net/snmp" <<'SNMP' +Tcp: RtoAlgorithm RtoMin RtoMax MaxConn ActiveOpens PassiveOpens AttemptFails EstabResets CurrEstab InSegs OutSegs RetransSegs InErrs OutRsts +Tcp: 1 200 120000 -1 100 200 0 0 10 5000 10000 100 0 0 +Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors +Udp: 1000 0 0 900 0 0 +SNMP +printf '0\n' > "$T/sys/class/net/eth0/queues/rx-0/rps_cpus" # RPS НЕ применён (#30) +printf '1000\n' > "$T/sys/class/net/eth0/tx_queue_len" +printf '1234\n' > "$T/sys/class/net/eth0/statistics/rx_bytes" +printf '4321\n' > "$T/sys/class/net/eth0/statistics/tx_bytes" +printf 'always madvise [never]\n' > "$T/sys/kernel/mm/transparent_hugepage/enabled" + +# ── Маркеры и конфиги тулкита ───────────────────────────────────────────────── +cat > "$T/var/lib/node-accelerator/protect.installed" <<'MARK' +installed_at=2026-09-01T00:00:00+00:00 +na_version=4.0.1 +fw_mode=strict +ssh_port=22 +tcp_ports=443,8445 +udp_ports=443 +node_port=2222 +crowdsec=1 +MARK +# WHITELIST в conf НЕ содержит 198.51.100.7 — он осел в .nft транзитно (SSH-IP), и +# ре-ран protect из другой сессии его выкинет (#38) +cat > "$T/etc/node-accelerator/protect.conf" <<'CONF' +: "${FW_MODE:=strict}" +: "${WHITELIST:=203.0.113.5,192.0.2.0/24}" +: "${CROWDSEC_STRICT:=0}" +CONF +cat > "$T/etc/node-accelerator/na_filter.nft" <<'NFTF' +table inet na_filter { + set whitelist_v4 { + type ipv4_addr + flags interval + elements = { 192.0.2.0/24, 198.51.100.7, 203.0.113.5 } + } +} +NFTF + +# ── Фикстуры nft ────────────────────────────────────────────────────────────── +set_fixture() { # set_fixture <имя> <элементы|""> + if [[ -z "${2:-}" ]]; then + printf 'table inet na_filter {\n\tset %s {\n\t\ttype ipv4_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\ttimeout 30m\n\t}\n}\n' "$1" > "$NFTD/set-$1" + else + printf 'table inet na_filter {\n\tset %s {\n\t\ttype ipv4_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\telements = { %s }\n\t}\n}\n' "$1" "$2" > "$NFTD/set-$1" + fi +} +set_fixture autoban_v4 '203.0.113.10 timeout 1d expires 1h11m50s608ms, 203.0.113.11 timeout 1d expires 2h' +set_fixture autoban_v6 '' +set_fixture suspect_v4 '' +printf 'table inet na_filter {\n\tset whitelist_v4 {\n\t\ttype ipv4_addr\n\t\tflags interval\n\t\telements = { 192.0.2.0/24, 198.51.100.7, 203.0.113.5 }\n\t}\n}\n' > "$NFTD/set-whitelist_v4" +printf 'table inet na_filter {\n\tset whitelist_v6 {\n\t\ttype ipv6_addr\n\t\telements = { 2001:db8::1 }\n\t}\n}\n' > "$NFTD/set-whitelist_v6" +cat > "$NFTD/chain-input" <<'CH' +table inet na_filter { + chain input { + type filter hook input priority filter; policy drop; + iif "lo" accept + ct state established,related accept + ip saddr @whitelist_v4 accept + tcp dport 443 ct state new meter cc4_443 { ip saddr ct count over 8192 } drop + } +} +CH + +# ── Фикстуры ss ─────────────────────────────────────────────────────────────── +# Срез правила `ct count`: loopback-пары nginx↔xray (их правило не видит вовсе), пир из +# вайтлиста (accept раньше лимита) и два внешних. Ожидаемый максимум = 3 (#31). +{ + for i in $(seq 1 12); do printf '0 0 127.0.0.1:8445 127.0.0.1:%d\n' $((40000+i)); done + for i in $(seq 1 5); do printf '0 0 10.0.0.5:443 198.51.100.7:%d\n' $((50000+i)); done + for i in $(seq 1 3); do printf '0 0 10.0.0.5:443 203.0.113.99:%d\n' $((60000+i)); done + for i in $(seq 1 2); do printf '0 0 10.0.0.5:443 [2001:db8::99]:%d\n' $((61000+i)); done +} > "$SSD/estab-ports" +cat > "$SSD/estab-mss" <<'EST' +State Recv-Q Send-Q Local Address:Port Peer Address:Port +ESTAB 0 0 10.0.0.5:443 203.0.113.99:60001 + bbr wscale:7,7 rto:204 mss:1400 cwnd:10 +ESTAB 0 0 10.0.0.5:443 203.0.113.99:60002 + bbr wscale:7,7 rto:204 mss:1400 cwnd:10 +EST +cat > "$SSD/listen-t" <<'LT' +LISTEN 0 511 0.0.0.0:443 0.0.0.0:* +LISTEN 0 128 0.0.0.0:22 0.0.0.0:* +LT +cat > "$SSD/listen-tu" <<'LTU' +tcp LISTEN 0 511 0.0.0.0:443 0.0.0.0:* +tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* +udp UNCONN 0 0 0.0.0.0:443 0.0.0.0:* +LTU + +# ── Серты: живой в /opt/<стек>/certs// и снятый с renew в acme.sh (#39) ─── +printf 'x\n' > "$T/opt/selfsteal/certs/node.example.test/fullchain.cer" +printf 'x\n' > "$T/root/.acme.sh/retired.example.test/fullchain.cer" +# acme.sh --remove переименовал .conf → .conf.removed: продлевать перестали +printf 'Le_Domain=retired.example.test\n' > "$T/root/.acme.sh/retired.example.test/retired.example.test.conf.removed" + +# ── Стабы ───────────────────────────────────────────────────────────────────── +cat > "$BIN/docker" <<'DOC' +#!/bin/sh +# Docker 29.x: `inspect -f` по НЕСУЩЕСТВУЮЩЕМУ объекту печатает в stdout пустую строку +# и только потом падает — ровно это и ломало сенсор (#27). +case "$1" in + inspect) + name="" + for a in "$@"; do name="$a"; done + if [ -n "${NA_TEST_CONTAINER:-}" ] && [ "$name" = "$NA_TEST_CONTAINER" ]; then + case "$*" in + *State.Status*) echo running ;; + *RestartCount*) echo 0 ;; + *) echo '{}' ;; + esac + exit 0 + fi + printf '\n' + exit 1 ;; + logs) exit 0 ;; + ps) exit 0 ;; +esac +exit 1 +DOC + +cat > "$BIN/nft" <<'NFTS' +#!/bin/sh +case "$*" in + "list table inet na_filter") exit 0 ;; + "list table inet na_ctguard") exit 1 ;; + "list table ip crowdsec") exit 1 ;; + "list chain inet na_filter input") cat "$NFTD/chain-input"; exit 0 ;; + "list set inet "*) + # ${*##* } раскрывается по КАЖДОМУ параметру, а не по склейке — берём имя набора + # через промежуточную переменную + all="$*"; s=${all##* } + if [ -f "$NFTD/set-$s" ]; then cat "$NFTD/set-$s"; exit 0; fi + echo "Error: No such file or directory" >&2; exit 1 ;; +esac +exit 1 +NFTS + +cat > "$BIN/ss" <<'SSS' +#!/bin/sh +case "$*" in + *"sport = :"*) cat "$SSD/estab-ports" ;; + "-tulnH") cat "$SSD/listen-tu" ;; + "-tlnH") cat "$SSD/listen-t" ;; + "-Htlnp") : ;; + *) cat "$SSD/estab-mss" ;; +esac +exit 0 +SSS + +cat > "$BIN/cscli" <<'CS' +#!/bin/sh +# `decisions list -o raw` печатает CSV С ЗАГОЛОВКОМ — он и завышал счёт на 1 (#32) +case "$*" in + "decisions list -o raw") + echo "id,source,ip,reason,action,country,as,duration,scenario" + i=0 + while [ "$i" -lt "${NA_TEST_DECISIONS:-0}" ]; do + i=$((i+1)) + echo "$i,crowdsec,203.0.113.$i,ban,ban,--,--,3h,ssh-bf" + done ;; +esac +exit 0 +CS + +cat > "$BIN/journalctl" <<'JC' +#!/bin/sh +case "$*" in + *--help*) echo " -g --grep=PATTERN Show entries with MESSAGE matching PATTERN"; 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 +exit 0 +JC + +cat > "$BIN/systemctl" <<'SC' +#!/bin/sh +case "$*" in + "is-active --quiet na-rps.service") exit 0 ;; # active (exited) + "is-active --quiet crowdsec") exit 0 ;; + "is-active --quiet crowdsec-firewall-bouncer") exit 0 ;; + "is-enabled --quiet na-firewall.service") exit 0 ;; + "show -p DefaultLimitNOFILE --value") echo 524288; exit 0 ;; + "--failed --no-legend") exit 0 ;; +esac +exit 1 +SC + +cat > "$BIN/uname" <<'UN' +#!/bin/sh +[ "$1" = "-r" ] && { echo "6.18.47-x64v3-xanmod1"; exit 0; } +exec /usr/bin/uname "$@" +UN + +cat > "$BIN/sysctl" <<'SY' +#!/bin/sh +[ "$1" = "-n" ] || exit 1 +case "$2" in + net.ipv4.tcp_congestion_control) echo bbr ;; + net.ipv4.tcp_available_congestion_control) echo "reno cubic bbr" ;; + net.core.default_qdisc) echo fq ;; + net.netfilter.nf_conntrack_max) echo 262144 ;; + net.ipv4.tcp_min_snd_mss) echo 512 ;; + net.ipv4.tcp_mtu_probing) echo 0 ;; + net.core.somaxconn) echo 65535 ;; + net.core.rmem_max|net.core.wmem_max) echo 33554432 ;; + net.ipv4.tcp_max_syn_backlog) echo 16384 ;; + fs.file-max|fs.nr_open) echo 2000000 ;; + net.ipv4.tcp_syncookies) echo 1 ;; + net.ipv4.tcp_fastopen) echo 3 ;; + net.ipv4.conf.all.rp_filter) echo 2 ;; + *) exit 1 ;; +esac +exit 0 +SY + +cat > "$BIN/ip" <<'IPS' +#!/bin/sh +case "$*" in + "-o -4 route show default") echo "default via 10.0.0.1 dev eth0 proto static metric 100" ;; + "-6 route show default") : ;; + "-s link show dev eth0") printf '2: eth0\n RX: bytes packets errors dropped\n 100 10 0 0\n TX: bytes packets errors dropped\n 200 20 0 0\n' ;; +esac +exit 0 +IPS + +cat > "$BIN/openssl" <<'SSL' +#!/bin/sh +# Снятый с renew серт истекает РАНЬШЕ живого: если сенсор его учтёт — увидим ✘ (#39) +for a in "$@"; do last="$a"; done +case "$last" in + *retired*) echo "notAfter=RETIRED" ;; + *) echo "notAfter=GOOD" ;; +esac +exit 0 +SSL + +cat > "$BIN/date" <<'DT' +#!/bin/sh +if [ "$1" = "-d" ]; then + now=$(/bin/date +%s) + case "$2" in + *GOOD*) echo $(( now + 60*86400 )) ;; + *RETIRED*) echo $(( now + 3*86400 )) ;; + *) exit 1 ;; + esac + exit 0 +fi +exec /bin/date "$@" +DT + +printf '#!/bin/sh\necho 4\n' > "$BIN/nproc" +printf '#!/bin/sh\necho aarch64\n' > "$BIN/arch" +printf '#!/bin/sh\necho kvm\n' > "$BIN/systemd-detect-virt" +printf '#!/bin/sh\nexit 1\n' > "$BIN/curl" +printf '#!/bin/sh\nexit 1\n' > "$BIN/ping" +printf '#!/bin/sh\nexit 1\n' > "$BIN/swapon" +printf '#!/bin/sh\necho "up 2 days"\n' > "$BIN/uptime" +printf '#!/bin/sh\necho "Mem: 2.0Gi 1.0Gi"\n' > "$BIN/free" +cat > "$BIN/df" <<'DF' +#!/bin/sh +echo "Filesystem 1024-blocks Used Available Capacity Mounted on" +echo "/dev/x 100 12 88 12% /" +DF +chmod +x "$BIN"/* +export PATH="$BIN:$PATH" + +# ── Прогоны ─────────────────────────────────────────────────────────────────── +PASS=0; FAIL=0 +check() { # check "описание" <ожидание> <факт> + if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1" + else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi +} +grep_ok() { local d="$1" p="$2" f="$3"; if grep -qF -- "$p" "$f"; then PASS=$((PASS+1)); echo " ok $d"; else FAIL=$((FAIL+1)); echo " FAIL $d: нет строки [$p]"; fi; } +grep_not() { local d="$1" p="$2" f="$3"; if grep -qF -- "$p" "$f"; then FAIL=$((FAIL+1)); echo " FAIL $d: строка [$p] есть, а быть не должно"; else PASS=$((PASS+1)); echo " ok $d"; fi; } + +# 198.51.100.7 — «IP текущей сессии»: он есть в живом сете и в .nft, но не в WHITELIST= +SSHENV='SSH_CONNECTION=198.51.100.7 51234 10.0.0.5 22' + +jget() { # jget <файл> <поле> — без jq: он на ноде не обязателен, а в CI не гарантирован + python3 - "$1" "$2" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +v=d.get(sys.argv[2], "<НЕТ ПОЛЯ>") +print("true" if v is True else "false" if v is False else v) +PY +} + +command -v python3 >/dev/null 2>&1 || { echo "[x] нужен python3 для валидации JSON"; exit 1; } + +echo "== 1. --json: контракт мониторинга (#27 #31 #32 #34 #35 #37 #38 #39) ==" +env "$SSHENV" TERM=dumb "$WBASH" "$DIAG" --json > "$T/out.json" 2>"$T/err.json" || true +if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$T/out.json" 2>/dev/null; then + PASS=$((PASS+1)); echo " ok --json парсится как валидный JSON (#27)" +else + FAIL=$((FAIL+1)); echo " FAIL --json НЕ парсится: $(head -c 400 "$T/out.json")" +fi +check "одна строка на выходе" 1 "$(wc -l < "$T/out.json" | tr -d ' ')" +check "remnanode_status=absent (не '\\nabsent')" absent "$(jget "$T/out.json" remnanode_status)" +check "autoban_v4=2 (адреса, а не строки nft)" 2 "$(jget "$T/out.json" autoban_v4)" +check "autoban_v6=0 на пустом наборе" 0 "$(jget "$T/out.json" autoban_v6)" +check "suspect=0 на пустом наборе" 0 "$(jget "$T/out.json" suspect)" +check "psi=off-by-default (CONFIG_PSI_DEFAULT_DISABLED)" off-by-default "$(jget "$T/out.json" psi)" +check "max_conn_per_ip=3 (внешний пир, без loopback/вайтлиста)" 3 "$(jget "$T/out.json" max_conn_per_ip)" +check "journal_span_h=10" 10 "$(jget "$T/out.json" journal_span_h)" +check "portscan_log_lines_boot=3" 3 "$(jget "$T/out.json" portscan_log_lines_boot)" +check "whitelist_drift_conf=1" 1 "$(jget "$T/out.json" whitelist_drift_conf)" +check "conf_stale_defaults=1 (CROWDSEC_STRICT=0)" 1 "$(jget "$T/out.json" conf_stale_defaults)" +check "cert_min_days=60 (снятый с renew не считается)" 60 "$(jget "$T/out.json" cert_min_days)" +check "cert_min_file — серт из /opt/<стек>/certs" "$T/opt/selfsteal/certs/node.example.test/fullchain.cer" "$(jget "$T/out.json" cert_min_file)" +check "старые поля на месте: firewall" true "$(jget "$T/out.json" firewall)" +check "старые поля на месте: fw_mode" strict "$(jget "$T/out.json" fw_mode)" + +echo "== 2. --json: пустой autoban не даёт фантомную единицу (#32) ==" +set_fixture autoban_v4 '' +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out2.json" 2>/dev/null || true +check "autoban_v4=0 на пустом динамическом наборе" 0 "$(jget "$T/out2.json" autoban_v4)" +set_fixture autoban_v4 '203.0.113.10 timeout 1d expires 1h11m50s608ms, 203.0.113.11 timeout 1d expires 2h' + +echo "== 3. текстовый отчёт ==" +env "$SSHENV" TERM=dumb "$WBASH" "$DIAG" > "$T/out.txt" 2>"$T/err.txt" || true +grep_ok "контейнера нет — info, а не ✘ (#27)" "remnanode: контейнера нет" "$T/out.txt" +if grep -F 'remnanode' "$T/out.txt" | grep -qF '✘'; then + FAIL=$((FAIL+1)); echo " FAIL ✘ про remnanode есть, а быть не должно (#27)" +else PASS=$((PASS+1)); echo " ok ✘ «node-агент лежит» не печатается (#27)"; fi +grep_ok "autoban печатает адреса, а не только счёт (#32)" "autoban: v4=2 v6=0 — 203.0.113.10 203.0.113.11" "$T/out.txt" +grep_ok "suspect=0 (пустой набор — не «1») (#32)" "suspect (наблюдение) v4=0" "$T/out.txt" +grep_ok "CrowdSec decisions без строки-заголовка CSV (#32)" "CrowdSec decisions (активные баны): 0" "$T/out.txt" +grep_ok "CONN_LIMIT: считаем внешнего пира, не loopback (#31)" "с одного IP = 3 / CONN_LIMIT 8192" "$T/out.txt" +grep_not "loopback-максимум (12) в датчик не попал (#31)" "с одного IP = 12" "$T/out.txt" +grep_ok "whitelist переживёт ребут (live == .nft) (#38)" "переживёт ребут" "$T/out.txt" +grep_ok "дрейф относительно protect.conf найден (#38)" "НЕ переживёт ре-ран protect" "$T/out.txt" +grep_ok "дрейфующий адрес назван (#38)" "198.51.100.7" "$T/out.txt" +grep_ok "опознан IP текущей сессии (#38)" "это IP текущей сессии" "$T/out.txt" +grep_ok "PSI: выключен в сборке ядра (#37)" "CONFIG_PSI_DEFAULT_DISABLED=y" "$T/out.txt" +grep_not "PSI: слов про «старое ядро» больше нет (#37)" "старое ядро" "$T/out.txt" +grep_ok "na-rps: гонка на буте — ▲, а не ✔ (#30)" "na-rps.service active, но rps_cpus пуст" "$T/out.txt" +grep_not "✔ «na-rps.service активен» при пустом rps_cpus (#30)" "✔ na-rps.service активен" "$T/out.txt" +grep_ok "conf пиннит устаревший дефолт (#34)" "пиннит устаревший дефолт: CROWDSEC_STRICT=0" "$T/out.txt" +grep_ok "глубина журнала меньше 48ч (#35)" "журнал вмещает менее 48ч" "$T/out.txt" +grep_ok "строки лога анти-скана посчитаны (#35)" "строк [na portscan] за текущую загрузку: 3" "$T/out.txt" +grep_ok "серт из /opt/<стек>/certs найден (#39)" "ближайший TLS-серт: 60 дн" "$T/out.txt" +grep_not "снятый с renew серт не тревожит (#39)" "retired.example.test" "$T/out.txt" + +echo "== 4. NA_NODE_CONTAINER: бокс с иначе названным контейнером (#27) ==" +env TERM=dumb NA_NODE_CONTAINER=nodeagent NA_TEST_CONTAINER=nodeagent "$WBASH" "$DIAG" > "$T/out4.txt" 2>/dev/null || true +grep_ok "контейнер под своим именем виден как running" "nodeagent: running" "$T/out4.txt" +env TERM=dumb NA_NODE_CONTAINER=nodeagent NA_TEST_CONTAINER=nodeagent "$WBASH" "$DIAG" --json > "$T/out4.json" 2>/dev/null || true +check "json: remnanode_status=running для NA_NODE_CONTAINER" running "$(jget "$T/out4.json" remnanode_status)" + +echo "== 5. сертов нет, а :443 слушает — сенсор слеп, это ▲ (#39) ==" +mv "$T/opt" "$T/opt.off"; mv "$T/root" "$T/root.off" +env TERM=dumb "$WBASH" "$DIAG" > "$T/out5.txt" 2>/dev/null || true +grep_ok "warn «сенсор слеп: задай NA_CERT_PATHS»" "сенсор слеп: задай NA_CERT_PATHS" "$T/out5.txt" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out5.json" 2>/dev/null || true +check "json: cert_min_file пуст, когда сертов нет" "" "$(jget "$T/out5.json" cert_min_file)" +mv "$T/opt.off" "$T/opt"; mv "$T/root.off" "$T/root" + +echo "== 6. CrowdSec: 3 решения = 3, а не 4 (#32) ==" +env TERM=dumb NA_TEST_DECISIONS=3 "$WBASH" "$DIAG" > "$T/out6.txt" 2>/dev/null || true +grep_ok "три решения считаются как 3" "CrowdSec decisions (активные баны): 3" "$T/out6.txt" + +echo +echo " прогон: $PASS ok, $FAIL fail" +if [[ "$FAIL" -ne 0 ]]; then echo "DIAGNOSE-UNIT: FAIL"; exit 1; fi +echo "DIAGNOSE-UNIT: OK (--json валиден и честен, датчики меряют тот же срез, что и правила)" diff --git a/tests/protect-unit.sh b/tests/protect-unit.sh new file mode 100644 index 0000000..4df35e7 --- /dev/null +++ b/tests/protect-unit.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# +# protect-unit.sh — юнит-тесты кусков protect.sh, которые до этого нигде не исполнялись: +# сгенерированный хелпер `na-fw-status`, разбор WHITELIST и сборка анти-скан-правил. +# Все три блока правились по аудиту боевого флота v4.0.1: +# +# 1. na-fw-status (issue #32/#36): счётчики наборов считались `grep -c timeout` по +# выводу nft, где строка `flags dynamic,timeout` есть ВСЕГДА → пустой набор давал +# «1», непустой был завышен, а na-fw-status и na-diagnose расходились между собой. +# Здесь хелпер РЕАЛЬНО исполняется против стаба nft, который печатает наборы так же, +# как nft 1.0.9/1.1.x — пустой динамический, непустой, многострочный, v6. +# 2. add_wl (issue #38): дубликат из CSV оператора уезжал в ruleset/CrowdSec как есть, +# а /24 и шире принимались молча — при том, что whitelist в na это полный обход +# защиты. Проверяем дедуп, нормализацию /32 и /128, предупреждение о широком CIDR. +# 3. анти-скан-лог (issue #35): рейт лога вынесен в PORTSCAN_LOG_RATE (в МИНУТУ), +# 0 = не логировать вовсе; плюс оценка суточного объёма против капа journald. +# +# Не требует root/сети/nft/systemd. Запуск: bash tests/protect-unit.sh +# Проверить на старой версии: NA_PROTECT_SH=<путь> bash tests/protect-unit.sh (упадёт) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROTECT="${NA_PROTECT_SH:-$REPO_ROOT/scripts/protect.sh}" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/etc" "$T/sbin" + +# хелперам и protect.sh нужен bash ≥ 4.2 ([[ -v ]]) — на macOS системный древний +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.4"; exit 1; } + +PASS=0; FAIL=0 +check() { # check "описание" <ожидание> <факт> + if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1" + else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi +} +checkf() { # checkf "описание" — фиксируем провал без сравнения + FAIL=$((FAIL+1)); echo " FAIL $1" +} + +# ─── 1. Сгенерированный na-fw-status ───────────────────────────────────────── +echo "== 1. na-fw-status: счётчики наборов (issue #32/#36) ==" + +# Генератор хелпера — функция write_fw_status в protect.sh: вытаскиваем её целиком и +# исполняем с подсунутым lib/common.sh (оттуда declare -f вшивает nft_set_count). +awk '/^write_fw_status\(\) \{$/{f=1} f{print} f&&/^\}$/{exit}' "$PROTECT" > "$T/gen-fw-status.raw" +if [[ ! -s "$T/gen-fw-status.raw" ]]; then + checkf "не нашёл генератор write_fw_status в $PROTECT (старая версия писала хелпер сырым heredoc'ом — счётчики там по grep -c)" +else + sed -e "s#/usr/local/sbin/#$T/sbin/#g" "$T/gen-fw-status.raw" > "$T/gen-fw-status.sh" + + # стаб nft: печатает наборы ровно так, как настоящий nft 1.0.9/1.1.x + cat > "$T/bin/nft" <<'NFT' +#!/usr/bin/env bash +# nft list table
| nft list set
+if [[ "${1:-}" == "list" && "${2:-}" == "table" ]]; then + case "${4:-}" in + na_filter) printf 'table inet na_filter {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t}\n}\n'; exit 0;; + na_ctguard) exit 0;; + *) exit 1;; + esac +fi +[[ "${1:-}" == "list" && "${2:-}" == "set" ]] || exit 1 +hdr() { printf 'table inet %s {\n\tset %s {\n\t\ttype %s\n\t\tsize 65536\n\t\tflags dynamic,timeout\n' "$3" "$1" "$2"; } +case "${5:-}" in + autoban_v4) + hdr autoban_v4 ipv4_addr na_filter + printf '\t\telements = { 203.0.113.10 timeout 1d expires 1h11m50s608ms,\n\t\t\t 198.51.100.22 timeout 1d expires 55m }\n\t}\n}\n';; + autoban_v6|suspect_v4|suspect_v6|phantom_v4) + # ПУСТОЙ динамический набор: заголовок со словом timeout есть, элементов нет + printf 'table inet t {\n\tset %s {\n\t\ttype addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\ttimeout 30m\n\t}\n}\n' "${5}";; + phantom_v6) + printf 'table inet na_ctguard {\n\tset phantom_v6 {\n\t\ttype ipv6_addr\n\t\tflags dynamic,timeout\n\t\telements = { 2001:db8::66 timeout 15m expires 12m3s }\n\t}\n}\n';; + blocklist_v4) + printf 'table inet na_filter {\n\tset blocklist_v4 {\n\t\ttype ipv4_addr\n\t\tflags interval\n\t\tauto-merge\n\t\telements = { 203.0.113.0/24, 198.51.100.0/24,\n\t\t\t 192.0.2.0/24 }\n\t}\n}\n';; + blocklist_v6) + printf 'table inet na_filter {\n\tset blocklist_v6 {\n\t\ttype ipv6_addr\n\t\tflags interval\n\t\tauto-merge\n\t}\n}\n';; + na_fleet_v4) + printf 'table inet na_filter {\n\tset na_fleet_v4 {\n\t\ttype ipv4_addr\n\t\tflags interval\n\t\tauto-merge\n\t\telements = { 203.0.113.5, 203.0.113.6 }\n\t}\n}\n';; + na_fleet_v6) + printf 'table inet na_filter {\n\tset na_fleet_v6 {\n\t\ttype ipv6_addr\n\t\tflags interval\n\t\tauto-merge\n\t\telements = { 2001:db8::5 }\n\t}\n}\n';; + *) echo "Error: No such file or directory" >&2; exit 1;; +esac +NFT + printf '#!/bin/sh\nexit 0\n' > "$T/bin/journalctl" + chmod +x "$T/bin/nft" "$T/bin/journalctl" + + "$WBASH" -c "set -euo pipefail; . '$REPO_ROOT/scripts/lib/common.sh'; . '$T/gen-fw-status.sh'; write_fw_status" >/dev/null + if [[ ! -x "$T/sbin/na-fw-status" ]]; then + checkf "генератор не создал $T/sbin/na-fw-status" + else + check "тело nft_set_count вшито в хелпер (lib/common.sh рядом с ним не лежит)" \ + 1 "$(grep -c '^nft_set_count ' "$T/sbin/na-fw-status")" + OUT="$(PATH="$T/bin:$PATH" "$WBASH" "$T/sbin/na-fw-status" 2>&1)" + check "autoban: 2 элемента v4 и ПУСТОЙ v6 (не «1» из-за flags dynamic,timeout)" \ + "v4: 2 v6: 0" "$(printf '%s\n' "$OUT" | sed -n 's/^\(v4: [0-9]* v6: [0-9]*\)$/\1/p' | head -1)" + check "suspect: оба набора пусты" \ + "suspect (наблюдение, ban-once) v4: 0 v6: 0" \ + "$(printf '%s\n' "$OUT" | grep -o 'suspect (наблюдение, ban-once) v4: [0-9]* v6: [0-9]*')" + check "blocklist: 3 интервала v4, пусто v6" "v4: 3 v6: 0" \ + "$(printf '%s\n' "$OUT" | grep -o 'v4: [0-9]* v6: [0-9]* (обновляет na-blocklist-update)' | sed 's/ (обновляет.*//')" + check "fleet: 2 v4 + 1 v6" "v4: 2 v6: 1" \ + "$(printf '%s\n' "$OUT" | grep -o 'v4: [0-9]* v6: [0-9]* (последний синк' | sed 's/ (последний.*//')" + check "ctguard: пусто v4, 1 фантом v6" "фантомов в блоке v4: 0 v6: 1" \ + "$(printf '%s\n' "$OUT" | grep -o 'фантомов в блоке v4: [0-9]* v6: [0-9]*')" + check "показаны сами баны (адрес + expires), а не только число" 2 \ + "$(printf '%s\n' "$OUT" | grep -c -E '^(203\.0\.113\.10|198\.51\.100\.22) timeout 1d expires')" + check "строки заголовка набора в список банов не попадают" 0 \ + "$(printf '%s\n' "$OUT" | grep -c 'flags dynamic')" + fi +fi + +# ─── 2. add_wl: дедуп, нормализация, широкий CIDR ──────────────────────────── +echo "== 2. WHITELIST: дедуп / нормализация / широкий CIDR (issue #38) ==" +awk '/^WL4=""; WL6=""$/{f=1} /^add_wl "\$WHITELIST"/{f=0} f' "$PROTECT" > "$T/wl.sh" +if [[ ! -s "$T/wl.sh" ]]; then + checkf "не смог извлечь блок разбора WHITELIST из $PROTECT" +else + wl() { "$WBASH" -c "set -euo pipefail; . '$REPO_ROOT/scripts/lib/common.sh'; . '$T/wl.sh'; $1" 2>&1; } + check "дубль в CSV → один элемент" "203.0.113.7" \ + "$(wl 'add_wl "203.0.113.7,203.0.113.7" >/dev/null; printf "%s" "$WL4"')" + check "дубль замечен вслух (protect.conf не переписываем — чинит оператор)" 1 \ + "$(wl 'add_wl "203.0.113.7,203.0.113.7" | grep -c "указан дважды"')" + check "о тройном повторе предупреждаем один раз, не простынёй" 1 \ + "$(wl 'add_wl "203.0.113.7,203.0.113.7,203.0.113.7" | grep -c "указан дважды"')" + check "/32 и голый адрес — одно и то же значение" "203.0.113.7" \ + "$(wl 'add_wl "203.0.113.7/32,203.0.113.7" >/dev/null; printf "%s" "$WL4"')" + check "/32 нормализован даже без дубля" "198.51.100.5" \ + "$(wl 'add_wl "198.51.100.5/32" >/dev/null; printf "%s" "$WL4"')" + check "/128 нормализован, дубль по v6 ловится" "2001:db8::1" \ + "$(wl 'add_wl "2001:db8::1/128,2001:db8::1" >/dev/null; printf "%s" "$WL6"')" + check "широкий v4 (/24) → warn про полный обход защиты" 1 \ + "$(wl 'add_wl "203.0.113.0/24" | grep -c "ПОЛНЫЙ обход защиты"')" + check "в warn названо число адресов (2^8)" 1 \ + "$(wl 'add_wl "203.0.113.0/24" | grep -c "2\^8 адресов (256)"')" + check "широкий v6 (/48) → warn" 1 \ + "$(wl 'add_wl "2001:db8:abc::/48" | grep -c "2\^80 адресов"')" + check "/29 (порог) — молча" 0 \ + "$(wl 'add_wl "203.0.113.8/29" | grep -c "ПОЛНЫЙ обход"')" + check "/64 v6 (порог) — молча" 0 \ + "$(wl 'add_wl "2001:db8:abc::/64" | grep -c "ПОЛНЫЙ обход"')" + check "хост-адрес — молча" 0 \ + "$(wl 'add_wl "203.0.113.7,2001:db8::1" | grep -c "ПОЛНЫЙ обход\|дважды"')" + check "auto-источник (IP текущей SSH-сессии) дублем не шумит" 0 \ + "$(wl 'add_wl "203.0.113.7" >/dev/null; add_wl "203.0.113.7" auto | grep -c "дважды"')" + check "…и вторым элементом в набор не лезет" "203.0.113.7" \ + "$(wl 'add_wl "203.0.113.7" >/dev/null; add_wl "203.0.113.7" auto >/dev/null; printf "%s" "$WL4"')" + check "мусор по-прежнему отвергается (rc=1)" "rc=1" \ + "$(wl 'add_wl "203.0.113.7; nft flush ruleset" >/dev/null 2>&1 || echo rc=1')" + check "разделитель набора остаётся ', ' (формат elements = { … })" "203.0.113.7, 198.51.100.5" \ + "$(wl 'add_wl "203.0.113.7,198.51.100.5" >/dev/null; printf "%s" "$WL4"')" +fi + +# ─── 3. Сборка анти-скан-правил: PORTSCAN_LOG_RATE ─────────────────────────── +echo "== 3. анти-скан: рейт лога вынесен в ручку (issue #35) ==" +awk '/^PORTSCAN=""$/{f=1} f{print} f&&/^fi$/{exit}' "$PROTECT" > "$T/portscan.sh" +if [[ ! -s "$T/portscan.sh" ]]; then + checkf "не смог извлечь сборку PORTSCAN из $PROTECT" +else + ps_run() { # ps_run "<переопределения через ;>" + "$WBASH" -c "set -euo pipefail +ENABLE_PORTSCAN_BAN=1; FW_MODE=strict; ENABLE_BANONCE=1 +PORTSCAN_RATE=15; PORTSCAN_BURST=30; PORTSCAN_BAN_TIME=1h; SUSPECT_TIME=30m +PORTSCAN_LOG_RATE=60; PORTSCAN_LOG_BURST=30 +$1 +. '$T/portscan.sh' +printf '%s\n' \"\$PORTSCAN\"" 2>&1 + } + check "дефолт v4.1: лог 60/минуту с burst 30" 1 \ + "$(ps_run ':' | grep -c 'limit rate 60/minute burst 30 packets log prefix "\[na portscan\] "')" + check "прежние 5/second из правила ушли" 0 "$(ps_run ':' | grep -c '5/second log prefix "\[na portscan\]')" + check "ручки доезжают до правила" 1 \ + "$(ps_run 'PORTSCAN_LOG_RATE=5; PORTSCAN_LOG_BURST=7' | grep -c 'limit rate 5/minute burst 7 packets')" + check "PORTSCAN_LOG_RATE=0 → лог-правила нет вовсе" 0 \ + "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c '\[na portscan\]')" + check "…но бан продолжает работать (meter → @autoban)" 1 \ + "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c 'add @autoban_v4')" + check "…и наблюдение ban-once тоже (meter → @suspect)" 1 \ + "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c 'add @suspect_v4 { ip saddr timeout 30m }')" + check "в ruleset остаётся видно, ПОЧЕМУ лога нет" 1 \ + "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c 'PORTSCAN_LOG_RATE=0')" + check "без ban-once правило лога такое же" 1 \ + "$(ps_run 'ENABLE_BANONCE=0' | grep -c 'limit rate 60/minute burst 30 packets log prefix "\[na portscan\] "')" +fi + +# ─── 4. Бюджет журнала под лог анти-скана ──────────────────────────────────── +echo "== 4. бюджет journald под [na portscan] (issue #35) ==" +awk '/^NA_JOURNAL_LINE_BYTES=/{f=1} /^check_journal_budget$/{exit} f' "$PROTECT" > "$T/budget.raw" +if [[ ! -s "$T/budget.raw" ]]; then + checkf "в $PROTECT нет оценки бюджета журнала (check_journal_budget)" +else + sed -e "s#/etc/systemd/journald.conf#$T/etc/journald.conf#g" "$T/budget.raw" > "$T/budget.sh" + mkdir -p "$T/etc/journald.conf.d" + bud() { # bud "<переопределения>" — окружение как у боевого прогона + "$WBASH" -c "set -euo pipefail; . '$REPO_ROOT/scripts/lib/common.sh'; . '$T/budget.sh' +ENABLE_PORTSCAN_BAN=1; FW_MODE=strict; PORTSCAN_LOG_RATE=60 +$1" 2>&1 + } + printf '[Journal]\nSystemMaxUse=300M\nSystemKeepFree=500M\n' > "$T/etc/journald.conf.d/na-size.conf" + check "кап 300M из drop-in optimize распознан" 314572800 "$(bud 'journald_cap_bytes')" + check "дефолтный рейт 60/мин при капе 300M — тишина" "" "$(bud 'check_journal_budget')" + check "300/мин (прежние 5/сек) при том же капе → warn" 1 \ + "$(bud 'PORTSCAN_LOG_RATE=300; check_journal_budget' | grep -c 'лог анти-скана')" + check "…в warn названы оба числа: суточный объём и кап" 1 \ + "$(bud 'PORTSCAN_LOG_RATE=300; check_journal_budget' | grep -c 'МБ/сутки при капе journald 300 МБ')" + check "…и сказано, что делать" 1 \ + "$(bud 'PORTSCAN_LOG_RATE=300; check_journal_budget' | grep -c 'снизь PORTSCAN_LOG_RATE')" + check "PORTSCAN_LOG_RATE=0 → считать нечего" "" "$(bud 'PORTSCAN_LOG_RATE=0; check_journal_budget')" + check "автобан за скан выключен → бюджет не при чём" "" \ + "$(bud 'ENABLE_PORTSCAN_BAN=0; PORTSCAN_LOG_RATE=300; check_journal_budget')" + check "FW_MODE=open (анти-скан не ставится) → тишина" "" \ + "$(bud 'FW_MODE=open; PORTSCAN_LOG_RATE=300; check_journal_budget')" + printf '[Journal]\nSystemMaxUse=2G\n' > "$T/etc/journald.conf.d/na-size.conf" + check "суффикс G разбирается" 2147483648 "$(bud 'journald_cap_bytes')" + check "большой кап — 300/мин уже не проблема" "" "$(bud 'PORTSCAN_LOG_RATE=300; check_journal_budget')" + printf '[Journal]\nSystemMaxUse=64M\n' > "$T/etc/journald.conf.d/na-size.conf" + check "маленький кап — предупреждаем даже на дефолтном рейте" 1 \ + "$(bud 'check_journal_budget' | grep -c 'лог анти-скана')" + # drop-in перебивает основной конфиг (порядок чтения systemd) + printf '[Journal]\nSystemMaxUse=1G\n' > "$T/etc/journald.conf" + check "drop-in побеждает journald.conf" 67108864 "$(bud 'journald_cap_bytes')" + rm -f "$T/etc/journald.conf.d/na-size.conf" + check "без drop-in берётся journald.conf" 1073741824 "$(bud 'journald_cap_bytes')" + printf '[Journal]\n#SystemMaxUse=4G\n' > "$T/etc/journald.conf" + check "закомментированный кап игнорируется → дефолт 300M" 314572800 "$(bud 'journald_cap_bytes')" + rm -f "$T/etc/journald.conf" + check "конфига нет вовсе → дефолт 300M (столько ставит optimize)" 314572800 "$(bud 'journald_cap_bytes')" +fi + +# ─── 5. Полный apply: дедуп доезжает до всех мест разом ────────────────────── +# Три места хранят whitelist: nft-сет (через na_filter.nft), CrowdSec-парсер и +# protect.conf. Первые два дедупим, третий обязан сохранить список оператора дословно. +echo "== 5. apply: whitelist в ruleset / CrowdSec-yaml / protect.conf (issue #38) ==" +A="$T/apply" +mkdir -p "$A/bin" "$A/sys" "$A/sbin" "$A/modload" "$A/conf" "$A/state" "$A/backup" "$A/crowdsec" +cp -r "$REPO_ROOT/scripts" "$A/scripts" +cp "$PROTECT" "$A/scripts/protect.sh" +sed -e "s#/etc/systemd/system/#$A/sys/#g" -e "s#/usr/local/sbin/#$A/sbin/#g" \ + -e "s#/etc/modules-load.d/#$A/modload/#g" -e "s#/etc/crowdsec#$A/crowdsec#g" \ + "$A/scripts/protect.sh" > "$A/p.tmp" && mv "$A/p.tmp" "$A/scripts/protect.sh" +for c in systemctl modprobe nft systemd-run sysctl conntrack cscli sleep; do + printf '#!/bin/sh\nexit 0\n' > "$A/bin/$c"; chmod +x "$A/bin/$c" +done +for c in curl docker ss dpkg apt-get; do + printf '#!/bin/sh\nexit 1\n' > "$A/bin/$c"; chmod +x "$A/bin/$c" +done +cat >> "$A/scripts/lib/common.sh" <"$A/apply.log" 2>&1 +rc=$? +set -e +check "apply отработал (exit 0)" 0 "$rc" +check "unbound-переменных нет" 0 "$(grep -ciE 'unbound variable|bad substitution' "$A/apply.log")" +NFTF="$A/conf/na_filter.nft" +check "в ruleset адрес ровно один раз" 1 \ + "$(grep -o '198\.51\.100\.4' "$NFTF" | wc -l | tr -d ' ')" +check "…и нормализованный /32 не задвоил второй" 1 \ + "$(grep -o '203\.0\.113\.7\b' "$NFTF" | wc -l | tr -d ' ')" +check "авто-whitelist SSH-IP на месте" 1 "$(grep -c '203\.0\.113\.9' "$NFTF")" +YAML="$A/crowdsec/parsers/s02-enrich/na-whitelist.yaml" +if [[ ! -f "$YAML" ]]; then + checkf "CrowdSec-whitelist не сгенерирован ($YAML)" +else + check "в CrowdSec-yaml адрес один раз (а не как в CSV оператора)" 1 \ + "$(grep -c '"198\.51\.100\.4"' "$YAML")" + check "…/32 нормализован в ip:, а не в cidr:" 1 "$(grep -c '"203\.0\.113\.7"' "$YAML")" + check "…широкий /24 остался как cidr (оператор так решил, мы лишь предупредили)" 1 \ + "$(grep -c '"203\.0\.113\.0/24"' "$YAML")" + check "…SSH-IP тоже в CrowdSec-whitelist" 1 "$(grep -c '"203\.0\.113\.9"' "$YAML")" +fi +check "protect.conf хранит WHITELIST дословно (это intent оператора)" 1 \ + "$(grep -c "WHITELIST:=$DUPWL}" "$A/conf/protect.conf")" +check "новые ручки персистятся" "60 30" \ + "$(grep -oE '^: "\$\{PORTSCAN_LOG_(RATE|BURST):=[0-9]+\}"$' "$A/conf/protect.conf" | grep -oE '[0-9]+' | paste -sd' ' -)" +check "дубли названы поимённо (оба, включая нормализованный /32)" 2 \ + "$(grep -c 'указан дважды' "$A/apply.log")" +check "широкий /24 предупреждён" 1 "$(grep -c 'ПОЛНЫЙ обход защиты' "$A/apply.log")" +check "хелпер na-fw-status собран со вшитым счётчиком" 1 \ + "$(grep -c '^nft_set_count ' "$A/sbin/na-fw-status")" + +echo +echo "итого: ok=$PASS fail=$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/tests/psi-unit.sh b/tests/psi-unit.sh new file mode 100755 index 0000000..d016280 --- /dev/null +++ b/tests/psi-unit.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# +# psi-unit.sh — ИСПОЛНЯЕТ секцию «PSI (psi=1 в cmdline)» из optimize.sh и парный блок +# отката из rollback.sh в песочнице против стаба update-grub и фейкового +# /etc/default/grub. Секции — top-level код, целиком optimize в CI не гоняется. +# +# Что стережём (issue #37): +# 1. ENABLE_PSI=0 (дефолт) не трогает /etc/default/grub ВООБЩЕ — в т.ч. не снимает +# psi=1, дописанный оператором; +# 2. ENABLE_PSI=1 дописывает psi=1 ВНУТРЬ кавычек GRUB_CMDLINE_LINUX_DEFAULT и зовёт +# update-grub; ре-ран не дублирует параметр; +# 3. маркер optimize.installed получает psi=1 ТОЛЬКО когда параметр дописали мы — +# чужой psi=1 не присваиваем, и rollback его не снимет; +# 4. ядро без CONFIG_PSI и нестандартная строка GRUB — молча пропускаем/предупреждаем, +# прогон не падает (секция не должна ронять optimize посреди мутаций); +# 5. rollback снимает psi=1 только при psi=1 в маркере и оставляет строку опрятной. +# +# Не требует root/сети/systemd. Запуск: bash tests/psi-unit.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +GRUB="$T/etc/default/grub" +REC="$T/rec" +export REC +mkdir -p "$T/bin" "$T/etc/default" "$T/boot" "$T/state" "$T/backup" "$REC" + +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[@]}"' 2>/dev/null; then command -v "$c"; return 0; fi + done + return 1 +} +WBASH="$(pick_bash)" || { echo "[x] не нашёл bash ≥ 4.4"; exit 1; } + +fail=0 +expect() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then echo " ✔ $d"; else echo " ✘ $d"; fail=1; fi; } +expect_not() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then echo " ✘ $d"; fail=1; else echo " ✔ $d"; fi; } + +# ── Стабы ─────────────────────────────────────────────────────────────────────── +cat > "$T/bin/update-grub" <<'UG' +#!/bin/sh +echo "update-grub" >> "$REC/update-grub.calls" +exit 0 +UG +chmod +x "$T/bin/update-grub" + +# Секция написана под GNU sed (`sed -i` без суффикса). На BSD sed (macOS) у -i суффикс +# обязателен — заворачиваем, иначе тест «падал бы» на платформе, а не на коде. +REAL_SED="$(command -v sed)" +if "$REAL_SED" --version 2>/dev/null | grep -q GNU; then + printf '#!/bin/sh\nexec %s "$@"\n' "$REAL_SED" > "$T/bin/sed" +else + cat > "$T/bin/sed" < "$T/section.sh" +[ -s "$T/section.sh" ] || { echo "[x] не смог извлечь секцию 2b из optimize.sh"; exit 1; } + +awk '/^ # psi=1 в GRUB_CMDLINE_LINUX_DEFAULT/{f=1} f{print} f && /^ fi$/{exit}' \ + "$REPO_ROOT/scripts/rollback.sh" | sandbox > "$T/rollback-psi.sh" +[ -s "$T/rollback-psi.sh" ] || { echo "[x] не смог извлечь psi-блок из rollback.sh"; exit 1; } + +cat > "$T/wrap-optimize.sh" < "$T/vars" +WRAP + +cat > "$T/wrap-rollback.sh" < "$GRUB"; } +kernel_cfg() { printf '%s\n' "$@" > "$T/boot/config-$(uname -r)"; } +cmdline() { grep '^GRUB_CMDLINE_LINUX_DEFAULT=' "$GRUB"; } +marker() { printf '%s\n' "installed_at=now" "nic=ens18" "psi=$1" > "$T/state/optimize.installed"; } +reset_case() { : > "$REC/update-grub.calls"; rm -f "$T/state/optimize.installed" "$T/vars" "$T/backup"/*; } +run_opt() { rc=0; "$WBASH" "$T/wrap-optimize.sh" > "$T/out" 2>&1 || rc=$?; return 0; } +run_rb() { rc=0; "$WBASH" "$T/wrap-rollback.sh" > "$T/out" 2>&1 || rc=$?; return 0; } +var() { grep "^$1=" "$T/vars" | cut -d= -f2-; } +ug_calls() { local n; n="$(wc -l < "$REC/update-grub.calls" 2>/dev/null || echo 0)"; echo "${n// /}"; } + +kernel_cfg 'CONFIG_PSI=y' 'CONFIG_PSI_DEFAULT_DISABLED=y' + +# ── Кейс 1: дефолт (ENABLE_PSI=0) ничего не трогает ───────────────────────────── +echo "== ENABLE_PSI=0 (дефолт) ==" +reset_case; set_grub "quiet" +run_opt +expect "секция отработала (rc=0)" test "$rc" -eq 0 +expect "grub не тронут" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet"' +expect "update-grub не звался" test "$(ug_calls)" -eq 0 +expect "маркер psi=0" test "$(var PSI_MARK)" = "0" + +echo "== ENABLE_PSI=0 при уже стоящем psi=1 оператора ==" +reset_case; set_grub "quiet psi=1" +run_opt +expect "чужой psi=1 НЕ снят" grep -q 'psi=1' "$GRUB" +expect "маркер psi=0 (не наш параметр)" test "$(var PSI_MARK)" = "0" + +# ── Кейс 2: ENABLE_PSI=1 дописывает ───────────────────────────────────────────── +echo "== ENABLE_PSI=1 ==" +reset_case; set_grub "quiet" +ENABLE_PSI=1 run_opt +expect "секция отработала (rc=0)" test "$rc" -eq 0 +expect "psi=1 дописан ВНУТРЬ кавычек" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet psi=1"' +expect "update-grub позван" test "$(ug_calls)" -eq 1 +expect "маркер psi=1 (наш параметр)" test "$(var PSI_MARK)" = "1" +expect "затребован reboot" test "$(var REBOOT_NEEDED)" = "1" +expect "причина ребута названа" grep -q 'psi=1' <<<"$(var REBOOT_WHY)" +expect "бэкап grub сделан" test -f "$T/backup/grub" +expect "остальные строки grub целы" grep -qx 'GRUB_TIMEOUT=5' "$GRUB" + +echo "== ре-ран с ENABLE_PSI=1 (идемпотентность) ==" +: > "$REC/update-grub.calls" +marker 1 # маркер, каким его написал бы optimize на прошлом прогоне +ENABLE_PSI=1 run_opt +expect "psi=1 ровно один" test "$(grep -c 'psi=1' "$GRUB")" -eq 1 +expect "строка не дублирует параметр" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet psi=1"' +expect "update-grub повторно не звался" test "$(ug_calls)" -eq 0 +expect "маркер psi=1 перенесён с прошлого прогона" test "$(var PSI_MARK)" = "1" + +echo "== psi=1 от оператора + ENABLE_PSI=1, маркера нет ==" +reset_case; set_grub "quiet psi=1" +ENABLE_PSI=1 run_opt +expect "маркер psi=0 — параметр не присваиваем" test "$(var PSI_MARK)" = "0" +expect "grub не переписан" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet psi=1"' + +echo "== пустое значение GRUB_CMDLINE_LINUX_DEFAULT ==" +reset_case; set_grub "" +ENABLE_PSI=1 run_opt +expect "без ведущего пробела" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="psi=1"' + +# ── Кейс 3: ядро без CONFIG_PSI ───────────────────────────────────────────────── +echo "== ядро без CONFIG_PSI ==" +reset_case; set_grub "quiet"; kernel_cfg 'CONFIG_PSI is not set' +ENABLE_PSI=1 run_opt +expect "секция отработала (rc=0)" test "$rc" -eq 0 +expect "grub не тронут" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet"' +expect "сказано про CONFIG_PSI" grep -q 'CONFIG_PSI' "$T/out" +kernel_cfg 'CONFIG_PSI=y' 'CONFIG_PSI_DEFAULT_DISABLED=y' + +# ── Кейс 4: нестандартная строка и отсутствие grub ────────────────────────────── +echo "== нестандартный GRUB_CMDLINE_LINUX_DEFAULT ==" +reset_case +printf '%s\n' "GRUB_CMDLINE_LINUX_DEFAULT='quiet'" > "$GRUB" +ENABLE_PSI=1 run_opt +expect "секция отработала (rc=0)" test "$rc" -eq 0 +expect "файл не тронут" test "$(cmdline)" = "GRUB_CMDLINE_LINUX_DEFAULT='quiet'" +expect "предупреждение напечатано" grep -q 'вручную' "$T/out" +expect "маркер psi=0" test "$(var PSI_MARK)" = "0" + +echo "== /etc/default/grub нет (контейнер/не-GRUB) ==" +reset_case; rm -f "$GRUB" +ENABLE_PSI=1 run_opt +expect "секция отработала (rc=0)" test "$rc" -eq 0 +expect "файл не создан на пустом месте" test ! -f "$GRUB" +expect "инфо про отсутствие grub" grep -q 'GRUB' "$T/out" + +# ── Кейс 5: откат ─────────────────────────────────────────────────────────────── +echo "== rollback: psi=1 в маркере → снимаем ==" +reset_case; set_grub "quiet psi=1"; marker 1 +run_rb +expect "блок отработал (rc=0)" test "$rc" -eq 0 +expect "psi=1 убран, строка опрятна" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet"' +expect "update-grub позван" test "$(ug_calls)" -eq 1 + +echo "== rollback: psi в середине значения ==" +reset_case; set_grub "quiet psi=1 splash"; marker 1 +run_rb +expect "двойных пробелов не осталось" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"' + +echo "== rollback: единственный параметр ==" +reset_case; set_grub "psi=1"; marker 1 +run_rb +expect "значение пустое, кавычки на месте" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT=""' + +echo "== rollback: psi=0 в маркере → чужой параметр не трогаем ==" +reset_case; set_grub "quiet psi=1"; marker 0 +run_rb +expect "psi=1 на месте" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet psi=1"' +expect "update-grub не звался" test "$(ug_calls)" -eq 0 + +echo "== rollback: маркера нет вовсе ==" +reset_case; set_grub "quiet psi=1" +run_rb +expect "psi=1 на месте" test "$(cmdline)" = 'GRUB_CMDLINE_LINUX_DEFAULT="quiet psi=1"' +expect "update-grub не звался" test "$(ug_calls)" -eq 0 + +# ── Регрессия: на v4.0.1 ручки не было вовсе ─────────────────────────────────── +echo "== код v4.0.1 (регрессия; пропускается без ветки main) ==" +if git -C "$REPO_ROOT" show main:scripts/optimize.sh > "$T/old-optimize.sh" 2>/dev/null; then + expect_not "в v4.0.1 секции PSI нет" grep -q '^# ─── 2b\.' "$T/old-optimize.sh" + expect_not "в v4.0.1 ручки ENABLE_PSI нет" grep -q 'ENABLE_PSI' "$T/old-optimize.sh" + git -C "$REPO_ROOT" show main:scripts/rollback.sh > "$T/old-rollback.sh" 2>/dev/null || true + expect_not "в v4.0.1 откат psi=1 не делался" grep -q 'psi=1' "$T/old-rollback.sh" +else + echo " • ветка main недоступна — сравнение с v4.0.1 пропущено" +fi + +if [ "$fail" -ne 0 ]; then echo "PSI-UNIT: FAIL"; exit 1; fi +echo "PSI-UNIT: OK (ENABLE_PSI=0 не трогает grub, =1 дописывает идемпотентно, маркер только для своего psi=1, откат снимает только свой)" diff --git a/tests/rps-unit.sh b/tests/rps-unit.sh new file mode 100755 index 0000000..12c5cda --- /dev/null +++ b/tests/rps-unit.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# +# rps-unit.sh — ИСПОЛНЯЕТ сгенерированный /usr/local/sbin/na-rps-setup из optimize.sh +# в песочнице против стабов ip/nproc/sleep + статические проверки юнита и «курсорного» +# вывода. Оба бага раскатки v4.0.1 живут именно тут и в CI не ловились ничем. +# +# Что стережём: +# 1. RPS применяется к NIC, вшитому в ExecStart (имя с момента optimize) — без +# обращения к таблице маршрутов вообще; +# 2. интерфейс переименовался после смены ядра (eth0→ens18) → фолбэк на автодетект; +# 3. default route появляется ПОЗЖЕ network-online.target — скрипт ждёт его, а не +# выходит нулём с неприменённым RPS (issue #30: юнит зелёный, rps_cpus=0); +# 4. маршрута нет вовсе → RPS ко ВСЕМ физическим интерфейсам (lo/veth/docker/br- мимо); +# 5. кандидатов нет → exit 1 и «giving up» в stderr (юнит уходит в failed, а не в +# вечное active (exited) с невыполненной работой); +# 6. юнит несёт Restart=on-failure/RestartSec и ExecStart с именем NIC; +# 7. ничего «курсорного» (tput/\033[K) не уезжает в stdout, когда это не терминал +# (issue #28: escape-коды в логах раскатки под nohup … > log). +# +# Не требует root/сети/systemd. Запуск: bash tests/rps-unit.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +SYS="$T/sys/class/net" +REC="$T/rec" +export REC +mkdir -p "$T/bin" "$SYS" "$REC" "$T/proc/sys/net/core" + +# Секции/скрипту нужен bash ≥ 4.4 — системный bash macOS древний, берём первый годный. +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[@]}"' 2>/dev/null; then command -v "$c"; return 0; fi + done + return 1 +} +WBASH="$(pick_bash)" || { echo "[x] не нашёл bash ≥ 4.4"; exit 1; } + +fail=0 +expect() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then echo " ✔ $d"; else echo " ✘ $d"; fail=1; fi; } +expect_not() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then echo " ✘ $d"; fail=1; else echo " ✔ $d"; fi; } + +# ── Стабы ─────────────────────────────────────────────────────────────────────── +# ip: маршрут появляется только с ROUTE_AFTER-го вызова (0 = не появляется никогда) — +# так воспроизводится гонка «network-online.target раньше default route». +cat > "$T/bin/ip" <<'IP' +#!/bin/sh +n=$(( $(cat "$REC/ip.calls" 2>/dev/null || echo 0) + 1 )) +echo "$n" > "$REC/ip.calls" +[ "${ROUTE_AFTER:-0}" -gt 0 ] || exit 0 +[ "$n" -ge "${ROUTE_AFTER:-0}" ] || exit 0 +echo "default via 10.0.0.1 dev ${ROUTE_IFACE:-ens18} proto static metric 100" +IP +printf '#!/bin/sh\necho 3\n' > "$T/bin/nproc" +cat > "$T/bin/sleep" <<'SL' +#!/bin/sh +echo "$*" >> "$REC/sleep.calls" +exit 0 +SL +chmod +x "$T/bin/ip" "$T/bin/nproc" "$T/bin/sleep" +export PATH="$T/bin:$PATH" + +# ── Достаём тело na-rps-setup из heredoc optimize.sh ──────────────────────────── +extract_rps() { # extract_rps <исходник optimize.sh> <куда> + awk "/^cat > \/usr\/local\/sbin\/na-rps-setup <<'RPS'\$/{f=1;next} /^RPS\$/{f=0} f" "$1" \ + | sed -e "s#/sys/class/net#$SYS#g" -e "s#/proc/sys/net/core#$T/proc/sys/net/core#g" > "$2" + [ -s "$2" ] || return 1 + chmod +x "$2" +} +RPS="$T/na-rps-setup" +extract_rps "$REPO_ROOT/scripts/optimize.sh" "$RPS" \ + || { echo "[x] не смог извлечь na-rps-setup из optimize.sh"; exit 1; } + +mk_net() { # mk_net [phys] — очереди rx/tx; phys=1 → есть /device (не veth) + local n="$1" phys="${2:-}" + mkdir -p "$SYS/$n/queues/rx-0" "$SYS/$n/queues/tx-0" + : > "$SYS/$n/queues/rx-0/rps_cpus" + : > "$SYS/$n/queues/rx-0/rps_flow_cnt" + : > "$SYS/$n/queues/tx-0/xps_cpus" + [ -n "$phys" ] && : > "$SYS/$n/device" + return 0 +} +reset_net() { rm -rf "$SYS"; mkdir -p "$SYS"; : > "$REC/ip.calls"; : > "$REC/sleep.calls"; } +run_rps() { # run_rps <скрипт> [аргумент-NIC] → rc в $rc, вывод в $T/out/$T/err + rc=0 + "$WBASH" "$@" > "$T/out" 2> "$T/err" || rc=$? + return 0 +} +ip_calls() { local n; n="$(cat "$REC/ip.calls" 2>/dev/null || true)"; echo "${n:-0}"; } +# Голый `tput` в КОДЕ (комментарии не в счёт — в них слово встречается по делу). +bare_tput() { sed 's/#.*//' "$1" | grep -nE '(^|[^_[:alnum:]])tput '; } +mask_of() { cat "$SYS/$1/queues/rx-0/rps_cpus" 2>/dev/null || echo ""; } + +# ── Кейс (a): NIC вшит в ExecStart и существует ───────────────────────────────── +echo "== (a) NIC из ExecStart существует ==" +reset_net; mk_net ens18 1 +ROUTE_AFTER=0 run_rps "$RPS" ens18 +expect "rc=0" test "$rc" -eq 0 +expect "rps_cpus = маска 3 ядер (7)" test "$(mask_of ens18)" = "7" +expect "rps_flow_cnt проставлен" grep -qx 4096 "$SYS/ens18/queues/rx-0/rps_flow_cnt" +expect "xps_cpus проставлен" grep -qx 7 "$SYS/ens18/queues/tx-0/xps_cpus" +expect "итоговая строка-маркер напечатана" grep -q '^na-rps: NIC=ens18 mask=7 cpus=3$' "$T/out" +expect "таблица маршрутов не опрашивалась вовсе" test "$(ip_calls)" -eq 0 + +# ── Кейс (b): интерфейс переименовался после смены ядра ───────────────────────── +echo "== (b) вшитого интерфейса нет → автодетект ==" +reset_net; mk_net ens18 1 +ROUTE_AFTER=1 ROUTE_IFACE=ens18 run_rps "$RPS" eth0 +expect "rc=0" test "$rc" -eq 0 +expect "применён к найденному по маршруту ens18" test "$(mask_of ens18)" = "7" +expect "в stderr сказано, что имени нет" grep -q "не найден" "$T/err" + +# ── Кейс (c): маршрут появляется на 3-м опросе (гонка на буте, issue #30) ─────── +echo "== (c) default route появляется на 3-м опросе ==" +reset_net; mk_net ens18 1 +ROUTE_AFTER=3 ROUTE_IFACE=ens18 run_rps "$RPS" +expect "rc=0" test "$rc" -eq 0 +expect "RPS всё-таки применён (не молчаливый exit 0)" test "$(mask_of ens18)" = "7" +expect "маршрут опрашивался ≥3 раз" test "$(ip_calls)" -ge 3 +expect "между опросами был sleep" test -s "$REC/sleep.calls" +expect "строка-маркер в stdout" grep -q 'na-rps: NIC=ens18' "$T/out" + +# ── Кейс (d): маршрута нет никогда → все физические интерфейсы ────────────────── +echo "== (d) маршрута нет → все физические NIC ==" +reset_net +mk_net eth0 1; mk_net eth1 1 +mk_net lo 1; mk_net docker0 1; mk_net veth1a2b 1; mk_net br-abc 1 +ROUTE_AFTER=0 run_rps "$RPS" +expect "rc=0" test "$rc" -eq 0 +expect "eth0 затюнен" test "$(mask_of eth0)" = "7" +expect "eth1 затюнен" test "$(mask_of eth1)" = "7" +expect "lo не тронут" test -z "$(mask_of lo)" +expect "docker0 не тронут" test -z "$(mask_of docker0)" +expect "veth не тронут" test -z "$(mask_of veth1a2b)" +expect "br- не тронут" test -z "$(mask_of br-abc)" +expect "маркер на каждый NIC" test "$(grep -c '^na-rps: NIC=' "$T/out")" -eq 2 +expect "ожидание маршрута выработано полностью (20 опросов)" test "$(ip_calls)" -ge 20 + +# ── Кейс (e): кандидатов нет вообще → честный отказ ───────────────────────────── +echo "== (e) ни маршрута, ни физических NIC ==" +reset_net +mk_net veth9z9z # только виртуальный, без /device +ROUTE_AFTER=0 run_rps "$RPS" +expect "rc≠0 (юнит уйдёт в failed, а не в active/exited)" test "$rc" -ne 0 +expect "«giving up» в stderr" grep -q "giving up" "$T/err" +expect_not "маркера успеха нет" grep -q '^na-rps: NIC=' "$T/out" + +# ── Юнит na-rps.service (статически по генератору) ────────────────────────────── +echo "== юнит na-rps.service ==" +awk "/^cat > \/etc\/systemd\/system\/na-rps.service < "$T/unit" +expect "юнит извлечён" test -s "$T/unit" +expect "ExecStart передаёт имя NIC" grep -qF 'ExecStart=/usr/local/sbin/na-rps-setup ${NIC:-}' "$T/unit" +expect "Restart=on-failure" grep -qx 'Restart=on-failure' "$T/unit" +expect "RestartSec задан" grep -qx 'RestartSec=5' "$T/unit" +expect "Type=oneshot + RemainAfterExit (is-active остаётся правдой)" grep -qx 'RemainAfterExit=yes' "$T/unit" +expect "перезапуск ограничен (не долбёжка вечно)" grep -q '^StartLimitBurst=' "$T/unit" +expect "NIC детектится ДО генерации юнита" \ + test "$(grep -n 'NIC="\$(default_iface || true)"' "$REPO_ROOT/scripts/optimize.sh" | cut -d: -f1)" \ + -lt "$(grep -n 'ExecStart=/usr/local/sbin/na-rps-setup' "$REPO_ROOT/scripts/optimize.sh" | cut -d: -f1)" + +# ── #28: ничего «курсорного» в не-терминал ────────────────────────────────────── +echo "== курсорный вывод только в терминал (#28) ==" +expect_not "голых 'tput ' в коде optimize.sh не осталось" bare_tput "$REPO_ROOT/scripts/optimize.sh" +expect "прогресс-бар гейтится is_tty" grep -q 'is_tty || return 0' "$REPO_ROOT/scripts/optimize.sh" + +{ + echo '. "$REPO_ROOT/scripts/lib/common.sh"' + awk '/^draw_progress_bar\(\) \{/,/^\}/' "$REPO_ROOT/scripts/optimize.sh" + awk '/^progress_end\(\) \{/,/^\}/' "$REPO_ROOT/scripts/optimize.sh" + echo 'tty_tput civis' + echo 'draw_progress_bar 42 "Загрузка: linux-xanmod-lts-x64v2"' + echo 'progress_end' +} > "$T/tty.sh" +REPO_ROOT="$REPO_ROOT" TERM=xterm "$WBASH" "$T/tty.sh" > "$T/tty.out" 2>&1 +expect_not "ESC-последовательностей в перенаправленном stdout нет" \ + grep -q $'\033' "$T/tty.out" +expect "и мусорных \\r тоже нет" test ! -s "$T/tty.out" + +# ── Регрессия: на коде v4.0.1 те же кейсы обязаны падать ─────────────────────── +# Не в каждом чекауте есть ветка main (shallow-клон CI) — тогда просто пропускаем. +echo "== код v4.0.1 (регрессия; пропускается без ветки main) ==" +if git -C "$REPO_ROOT" show main:scripts/optimize.sh > "$T/old-optimize.sh" 2>/dev/null \ + && extract_rps "$T/old-optimize.sh" "$T/na-rps-old"; then + reset_net; mk_net ens18 1 + ROUTE_AFTER=3 ROUTE_IFACE=ens18 run_rps "$T/na-rps-old" + expect "(c) старый код НЕ применял RPS при опоздавшем маршруте" test -z "$(mask_of ens18)" + expect "(c) …и рапортовал успех (rc=0) — тот самый зелёный юнит" test "$rc" -eq 0 + reset_net + ROUTE_AFTER=0 run_rps "$T/na-rps-old" + expect "(e) старый код выходил нулём вместо отказа" test "$rc" -eq 0 + expect "старый optimize.sh писал голый tput" bare_tput "$T/old-optimize.sh" +else + echo " • ветка main недоступна — сравнение с v4.0.1 пропущено" +fi + +if [ "$fail" -ne 0 ]; then echo "RPS-UNIT: FAIL"; exit 1; fi +echo "RPS-UNIT: OK (NIC из ExecStart, ожидание маршрута, фолбэк на физические, честный отказ, юнит с Restart=, без escape-кодов в логах)"