Repository navigation
v4.1 — аудит флота: честные сенсоры, дрейф дефолтов conf, RPS без гонки, рейт лога анти-скана
v4.1 — 2026-09-02
Релиз по итогам аудита боевого флота (11 нод + панель на v4.0.1, Debian 12/13, XanMod 6.18):
десять issue (#27–#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.