Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ jobs:
# whitelist относительно protect.conf (#38); PSI три состояния (#37); серты в
# /opt/*/certs и снятые с renew (#39); глубина журнала (#35); RPS-юнит vs факт (#30).
run: bash tests/diagnose-unit.sh
- name: lint — никаких `cmd | grep -q` под pipefail (SIGPIPE → ложное «нет»)
# grep -q закрывает пайп на первом совпадении; дописывающая команда ловит SIGPIPE,
# пайплайн под pipefail отдаёт 141. В v4.1.2 так через раз гас сенсор лога
# анти-скана, никогда не срабатывал cubic-warn и не виден был дубль logrotate.
run: bash tests/lint-pipefail.sh
- name: Install minisign (install unit — подписи локальных модулей)
run: sudo apt-get install -y -qq minisign || echo "minisign недоступен — секция подписей в install-unit пропустится"
- name: install unit (curl|bash НЕ исполняет scripts/ из текущей папки)
# До v4.1.3 SCRIPT_DIR брался из $0 = "bash" → текущая папка: подложенный scripts/
# исполнялся root'ом мимо скачивания по NA_REF и проверки подписей.
run: bash tests/install-unit.sh
- name: na-report читает журнал всех загрузок (-b all), не только текущей (#41)
# `-k` подразумевает `-b`: после ребута отчёт «за 24ч» молча становился «с момента
# загрузки» (×31 занижение на ноде флота). Оба вызова journalctl -k — с -b all.
Expand All @@ -79,6 +90,20 @@ jobs:
bash scripts/na-report.sh --json | grep -q '"journal_span_h":' || { echo "na-report --json без journal_span_h"; exit 1; }
echo "ok: -b all в обоих вызовах, journal_span_h в json"

# ── Поведение лимитеров на НАСТОЯЩЕМ nft (netns + veth, root) ────────────────
# Текст ruleset проверяют юнит-тесты; здесь — что полный набор не режет новых клиентов,
# записи истекают, а сам лимит работает (v4.1.3: meter без timeout копил адреса до
# ребута и на 65535 записях отсекал каждого нового клиента).
nft-behavior:
runs-on: ubuntu-latest
steps:
# SHA-пин: плавающий тег v4 может быть перенесён на другой коммит
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install nftables
run: sudo apt-get update -qq && sudo apt-get install -y -qq nftables iproute2 python3
- name: real nft in netns (timeout у наборов, fail-open на полном наборе, rate-limit)
run: sudo bash tests/nft-behavior.sh

# ── Smoke-тесты на матрице дистрибутивов ────────────────────────────────────
# Ловит ровно тот класс регрессий, из-за которых появился этот PR: установка
# XanMod на разных Ubuntu/Debian и генерация nftables-ruleset.
Expand Down
137 changes: 137 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,142 @@
# Changelog

## v4.1.3 — 2026-10-03

Хотфикс по аудиту живого флота (около 30 нод на 4.0–4.1.2). Главное — тихий отказ, который
наступает просто со временем: per-IP лимитеры копили адреса до ребута и на полном наборе
начинали отсекать новых клиентов. Плюс три дефекта, из-за которых тулкит говорил неправду о
себе: файл автозагрузки правил перезаписывался до проверки, сенсоры `na-diagnose` гасли от
SIGPIPE, а `install.sh` в режиме `curl | bash` мог исполнить чужой `scripts/` из текущей папки.

### 🔴 Лимитеры копили адреса до ребута и на полном наборе резали новых клиентов

Каждый per-IP лимит (SYN-rate на сервисных портах, UDP-rate, SSH, ICMP, анти-скан, мягкий
лимит node-порта, лимиты режима `open`) был голым `meter … { ip saddr limit rate … } accept`.
Такой набор создаётся как `size 65535; flags dynamic` — **без timeout**, а записи с `limit`
ядро не чистит никогда (в отличие от `ct count`, у которого есть сборщик мусора). В набор
попадал каждый адрес, хоть раз пришедший на порт, и оставался там до ребута или перезагрузки
таблицы. Когда набор полон, ядро не может завести запись новому адресу: правило `… accept`
для него не срабатывает, пакет падает в `ct state new drop`. Для `ssh4` то же самое
означает, что первый же SSH-вход с нового адреса уходит в suspect, а повтор — в бан.

Замер флота: до 22 764 адресов в одном наборе за 32 дня аптайма, рост 700–1300 адресов в
сутки на порт. До отказа — от полутора до трёх месяцев без перезагрузки, и наступил бы он
молча: старые клиенты продолжают работать, отваливаются только новые.

Теперь каждый лимитер — объявленный набор `flags dynamic,timeout`, который `update`
продлевает, пока адрес активен. Timeout — не меньше 60s для секундных лимитов и 10m для
минутных и не меньше двух времён восстановления корзины (burst/rate): при медленном rate он
растёт сам, иначе адрес после паузы получал бы свежий burst. Правило построено «превысил →
drop, иначе accept»: если набор сервисного порта всё же переполнит спуф-флуд с десятков
тысяч адресов, лимит для новых адресов отключается (их по-прежнему держат syncookies и
`ct count`), а не режет их. Сверх-лимитные SYN уходят в цепочку `synflood_drop` (лог под
rate-limit + счётчик; лог-лимит теперь общий на все порты, а не 5/с на каждый — в
`na-report` событий synflood станет меньше), SSH сверх лимита — в `ssh_flood` с той же
логикой ban-once, что и раньше; последний `drop` в ней срабатывает, даже если сет
suspect/autoban полон.

SSH на полном наборе **не** открывается: держать `ssh4` полным дёшево (≈110 SYN/с со
спуфнутых адресов при timeout 10m, для v6 хватает своей /64), и fail-open выключил бы защиту
от перебора. Адрес, которому не нашлось записи, проходит под общий потолок 30 новых SSH в
минуту без бана; админ из `WHITELIST` проходит выше всех лимитов, как и раньше.

`na-diagnose` показывает заполненность наборов-лимитеров и есть ли у них timeout: на нодах,
где правила ещё от 4.1.2, это ▲, а при заполнении такого набора от 80% — ✘ с пожарной мерой
(`systemctl reload na-firewall` очищает наборы, но вместе с ними и баны). Смотрятся только
лимитеры (autoban/suspect nft тоже помечает dynamic, но timeout у них на каждой записи), а
старые meter на nft 1.0.6 — анонимные наборы — находятся по правилам. В `--json` —
`dynset_fill_max_pct`, `dynset_fill_max_set`, `dynset_no_timeout`. Лечится ре-раном
`protect` на 4.1.3. Проверки «таблица na_filter есть» и `na-fw-status` теперь читают таблицу
без содержимого наборов (`nft -t`): лимитеры держат десятки тысяч записей.

### 🟠 Файл автозагрузки правил перезаписывался до проверки

`protect` генерировал ruleset прямо в `/etc/node-accelerator/na_filter.nft` — в тот самый
файл, который грузит `na-firewall.service`, — и только потом звал `nft -c`. Провал проверки
печатал «ничего не применено», но битый файл уже лежал на месте рабочего, и первый же ребут
поднимал ноду без файрвола. Отказ ядра на `nft -f` был ещё хуже: скрипт падал по `set -e` с
ВЗВЕДЁННЫМ сейфти-таймером, и тот через `SAFETY_DELAY` удалял прежнюю, ничем не повреждённую
таблицу и выключал автозагрузку.

Теперь кандидат пишется рядом (`.na_filter.nft.XXXXXX`) и становится файлом автозагрузки
только после `nft -c` **и** успешного `nft -f`; прежний файл уходит в бэкап прогона.
Отвергнутый ruleset сохраняется как `na_filter.nft.rejected`, а при отказе `nft -f`
сейфти-таймер этого прогона снимается (взведённый прошлым неподтверждённым прогоном —
остаётся). `rollback protect` и переключение на `FW_MODE=skip` убирают и эти файлы.

### 🟠 Сенсоры `na-diagnose` гасли от SIGPIPE

`diagnose` живёт под `set -o pipefail`, и конструкция `cmd | grep -q` там ломается: `grep -q`
закрывает пайп на первом совпадении, следующая запись `cmd` ловит SIGPIPE, и пайплайн
отдаёт 141. Условие становится ложным ровно тогда, когда совпадение есть. На флоте это давало:

- `portscan_log_lines_boot` через раз становился `-1`: справка `journalctl` (5 КБ) уходит в
пайп двумя записями. Между соседними прогонами на одной ноде число и `-1` чередовались;
- предупреждение «часть сокетов на cubic при bbr» в `--retrans` не срабатывало никогда (вывод
`ss -tin` — мегабайты);
- дубликат путей в `logrotate -d` не виден именно тогда, когда он есть;
- в `install.sh` проверка GPG-подписи могла отвергнуть валидную подпись (статус gpg пишется
построчно).

Все такие места переписаны (вывод в переменную, `grep -c`, `awk … END{exit !f}`). Новый линтер
`tests/lint-pipefail.sh` в CI запрещает `| grep -q` в скриптах под `pipefail`.

### 🟠 `install.sh` в режиме `curl | bash` мог исполнить чужой `scripts/`

`SCRIPT_DIR` вычислялся из `${BASH_SOURCE[0]:-$0}`. При `curl … | sudo bash -s` `BASH_SOURCE`
пуст, `$0` = `bash`, и каталогом скрипта становилась **текущая папка**. Если в ней был
каталог `scripts/` — старый клон тулкита или подложенный `/tmp/scripts`, — root исполнял его,
минуя и скачивание по `NA_REF`, и проверку подписей `NA_REQUIRE_SIG=1`. Теперь в
`curl | bash`-режиме модули всегда скачиваются во временный каталог, который убирается на
выходе. При запуске из файла соседний `scripts/` берётся, только если он доверенный: владелец
— root или тот, кто зовёт `sudo`, запись для группы и остальных закрыта, все модули на месте
(иначе — скачивание; так закрыт и вариант `sudo bash /tmp/install.sh` рядом с чужим
`/tmp/scripts`). С `NA_REQUIRE_SIG=1` подписи проверяются и у локальных модулей. Заодно
`--help` в `curl | bash`-режиме больше не читает файл с именем `bash` из текущей папки, а
проверка GPG-подписи требует нулевой код возврата gpg и отвергает подпись отозванным или
просроченным ключом (`VALIDSIG` gpg печатает и для таких).

### 🟡 Мелкое

- **`reboot_needed` вычисляется, а не читается из вечного маркера.** `optimize` пишет
`reboot_needed=1`, и раньше его никто не снимал: после ребута `--json` продолжал ждать
перезагрузки (на флоте — на каждой второй ноде, уже работающей на XanMod). Теперь в маркере
есть `boot_id`, и флаг истинен, только пока загрузка та же; для старых маркеров сравнивается
время загрузки с `installed_at`. В текстовом отчёте появилась строка ▲ об ожидании ребута.
- **Числа — с точкой при любой локали.** На ноде с `LANG=de_DE.UTF-8` `load1` в `--json`
становился `0`, а `na-report --json` печатал `0,99`. Теперь `LC_NUMERIC=C` и
`LC_MESSAGES=C` (вывод утилит разбирается, а не переводится); чужой `LC_ALL` переносится в
`LANG`, чтобы кодировка вывода не менялась, а явный `LC_ALL=C`/`C.UTF-8` остаётся как есть.

### 🧪 Тесты

- `tests/nft-behavior.sh` (новый CI-job `nft-behavior`): **настоящий** nft в двух netns.
Контроль: старая схема `meter` без timeout на полном наборе режет новых клиентов. Ruleset
4.1.3 встаёт поверх живой таблицы со старыми meter. Наборы объявлены с timeout; лимит
действует, счётчик `synflood_drop` растёт; записи истекают; полный набор не режет новых —
v4 и v6; перебор SSH приводит к бану (ban-once), а полный `ssh4` — к общему потолку, а не
к открытому порту.
- `tests/apply-smoke.sh`: наборы-лимитеры с timeout и схема «превысил → drop»; файл
автозагрузки не подменяется при отказе `nft -c` и `nft -f`; при отказе `nft -f` сейфти снят;
прежний ruleset попадает в бэкап.
- `tests/diagnose-unit.sh`: справка `journalctl` и `logrotate -d` больше буфера пайпа
(регрессия SIGPIPE); заполненность наборов-лимитеров; `reboot_needed` по `boot_id` и `btime`.
Попутно исправлена песочница теста: `/proc/sys/...` уводился в несуществующий путь.
- `tests/install-unit.sh`: `curl | bash` из папки с подложенным `scripts/` его не исполняет;
`scripts/` с записью для группы не берётся; `NA_REQUIRE_SIG=1` проверяет и локальные модули.
- `tests/lint-pipefail.sh`: нет `| grep -q` под `pipefail`.

Все новые проверки падают на v4.1.2 и проходят на v4.1.3. Сгенерированные ruleset (strict,
полный набор модулей, без ban-once, open) проверены `nft -c` на nft 1.0.6 (Debian 12) и
nft 1.1.3 (Debian 13), на ядрах XanMod 6.18 и стоковом 6.12.

### Миграция

Ре-ран `protect` на 4.1.3 пересоздаёт `na_filter` с новыми наборами; накопленные адреса
лимитеров, а вместе с ними autoban и suspect, при этом обнуляются (как и при любом
ре-ране). Конфиг `protect.conf` не меняется, новых ручек для оператора нет (`NA_RATE_SET_SIZE`,
`NA_RATE_TO_SEC`, `NA_RATE_TO_MIN` — для тестов на настоящем nft; 0 у timeout отвергается).

## v4.1.2 — 2026-09-06

Два дефекта ре-рана, из-за которых состояние ноды расходилось с тем, что печатал установщик
Expand Down
Loading
Loading