From 3daf6929207a2706f7227accd70f3245e08ed07b Mon Sep 17 00:00:00 2001 From: jestivald Date: Sun, 26 Jul 2026 23:40:31 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82-?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20v3.9.2=20=E2=80=94=20=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=B8-=D0=BB=D0=BE=D0=BA=D0=B0=D1=83=D1=82,=20?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=83=D0=BF=20apt,=20=D0=BF=D0=B8=D0=BD?= =?UTF-8?q?=D0=BD=D0=B8=D0=BD=D0=B3=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9?= =?UTF-8?q?,=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D1=8B=20=D0=B8=D0=B7?= =?UTF-8?q?=20argv/=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аудит кодовой базы (бэкдоры/баги/supply-chain) бэкдоров не нашёл; здесь — найденные баги и ужесточения. Анти-локаут: - сработавший сейфти снимал ТОЛЬКО таблицу, а na-firewall.service оставался enabled → первый же ребут возвращал локаут-руллсет уже без подстраховки. Теперь откат идёт через na-fw-safety-revert: таблица + автозагрузка + метка safety-fired.last; - detect_ssh_port был слеп к ssh.socket (Ubuntu 24.04+) и к sshd_config.d → strict открывал :22 вместо реального порта; теперь socket-юнит → sshd -T → ss → конфиг; - порт текущей SSH-сессии (SSH_CONNECTION) всегда попадает в правила + warn. Пакеты/ключи: - setup_xanmod_repo валидировался НЕскоупленным apt-get update → любой чужой битый репо на боксе ронял установку ядра; теперь Dir::Etc-скоуп, как у CrowdSec; - CrowdSec: канонический suite any/any (Debian 13 не имеет своего), CROWDSEC_STRICT=1 запрещает curl|bash-фоллбэк; для bookworm форсится lts-ветка XanMod; - import_pinned_key: в keyring кладётся РОВНО пиненый отпечаток (apt доверяет каждому ключу файла, а keyserver ищет по 64-битному keyid). Секреты: - na-fleet-sync шлёт X-Api-Key/Authorization через -H @file (не argv → /proc/cmdline); - URL в journald пишется без userinfo (basic-auth больше не течёт в лог); - http:// при заданном токене — громкий warn; install.sh качает с --proto-redir '=https'. Прочее: swapon на btrfs больше не роняет optimize посреди прогона (и dd уважает REMNAWAVE_SWAP_SIZE); FLEET_SYNC_INTERVAL персистится и читается верно (парс никогда не совпадал → ложные «токен протух»); flock на прогон; rollback убирает pam_limits и свой swapfile; install.sh -h работает в curl|bash и без root; двойной 0 в diagnose. Тесты: tests/fleet-sync-unit.sh ИСПОЛНЯЕТ сгенерированный хелпер против стабов (секреты не в argv, redact, redirect-политика, fail-safe); apply-smoke стережёт сейфти-откат и порт сессии; CI: checkout по SHA, permissions: contents: read. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 26 +++++- CHANGELOG.md | 107 ++++++++++++++++++++++ README.md | 29 ++++-- install.sh | 44 +++++++-- scripts/diagnose.sh | 37 +++++++- scripts/lib/common.sh | 66 +++++++++++++- scripts/optimize.sh | 105 ++++++++++++++++------ scripts/protect.sh | 187 ++++++++++++++++++++++++++++----------- scripts/rollback.sh | 26 +++++- tests/apply-smoke.sh | 35 +++++++- tests/fleet-sync-unit.sh | 123 +++++++++++++++++++++++++ 11 files changed, 682 insertions(+), 103 deletions(-) create mode 100644 tests/fleet-sync-unit.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f066dd..521cd3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,16 +5,21 @@ on: branches: [ main ] pull_request: +# Токен раннера — только на чтение репозитория (ни один шаг не пишет в GitHub). +permissions: + contents: read + jobs: # ── Статический анализ всех скриптов ──────────────────────────────────────── shellcheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # SHA-пин: плавающий тег v4 может быть перенесён на другой коммит + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install shellcheck run: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - name: Gate (severity=error — блокирует merge) - run: shellcheck -x --severity=error install.sh scripts/*.sh scripts/lib/*.sh + run: shellcheck -x --severity=error install.sh scripts/*.sh scripts/lib/*.sh tests/*.sh - name: Report (severity=style — информационно, не блокирует) continue-on-error: true run: shellcheck -x --severity=style install.sh scripts/*.sh scripts/lib/*.sh @@ -22,6 +27,10 @@ jobs: # Ловит unbound-vars в ветках, исполняемых только при реальном применении # (модули fleet/blocklists/ctguard, маркер, save_conf) — их не видит DRY_RUN/bash -n. run: bash tests/apply-smoke.sh + - name: fleet-sync unit (ИСПОЛНЯЕТ сгенерированный хелпер против стабов) + # Секреты не в argv, уходят через -H @file, URL в логе без userinfo, + # редиректы при токене заблокированы, fail-safe last-known-good работает. + run: bash tests/fleet-sync-unit.sh # ── Smoke-тесты на матрице дистрибутивов ──────────────────────────────────── # Ловит ровно тот класс регрессий, из-за которых появился этот PR: установка @@ -46,7 +55,8 @@ jobs: steps: - name: Install git (нужен checkout) + базовые сертификаты run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates - - uses: actions/checkout@v4 + # SHA-пин: плавающий тег v4 может быть перенесён на другой коммит + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Prep container + runtime-зависимости run: | # Запрещаем запуск сервисов из postinst (в контейнере нет systemd как PID1) @@ -95,6 +105,16 @@ jobs: if NODE_PORT=abc DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh 2>/dev/null; then echo "валидация НЕ сработала на кривом NODE_PORT"; exit 1 else echo "ok: кривой NODE_PORT отвергнут"; fi + - name: protect.sh DRY_RUN (порт текущей SSH-сессии не теряется → nft -c) + run: | + # SSH_CONNECTION = " ": порт сессии — ground truth. + # Ошибка детекта/протухший protect.conf не должны отрезать текущий доступ. + OUT="$(SSH_CONNECTION='203.0.113.9 55000 10.0.0.5 56777' SSH_PORT=22 \ + DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh)" + echo "$OUT" | grep -q 'SSH-сессия пришла на :56777' || { echo "порт SSH-сессии не подхвачен"; exit 1; } + grep -q 'tcp dport { 22, 56777 }' /etc/node-accelerator/na_filter.nft \ + || { echo "в ruleset нет обоих SSH-портов"; exit 1; } + echo "ok: порт сессии добавлен в ruleset" - name: diagnose.sh (read-only, не должен падать жёстко) run: bash scripts/diagnose.sh || true - name: diagnose.sh --json (machine-readable smoke) diff --git a/CHANGELOG.md b/CHANGELOG.md index b41ed07..5d3d29a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,112 @@ # Changelog +## v3.9.2 — 2026-07-26 + +Релиз по итогам сплошного аудита кодовой базы (бэкдоры/баги/supply-chain). Бэкдоров и +утечек данных не нашли; всё ниже — реальные баги и ужесточения. Дефолты поведения не +меняются, кроме перечисленного в «Совместимости». + +### 🛡 Анти-локаут: сейфти-таймер больше не оставляет мину на ребут +- **Сработавший сейфти снимал ТОЛЬКО живую таблицу**, а `na-firewall.service` оставался + в автозагрузке. Сценарий: прогон отрезал SSH → через `SAFETY_DELAY` доступ вернулся → + оператор увидел живой бокс и ушёл → **первый же ребут применил тот же локаут-руллсет + заново, уже без сейфти**. Теперь откат выполняет `/usr/local/sbin/na-fw-safety-revert`: + снимает таблицу **и** выключает `na-firewall.service`, пишет метку `safety-fired.last` + и громкую строку в journald. +- `na-diagnose` получил два сенсора: **«сейфти срабатывал»** (защиты сейчас нет — нужен + повторный прогон) и **«таблица есть, а автозагрузка выключена»** (правила не переживут + ребут). В `--json` — поля `safety_fired_age_s` и `fw_boot_enabled`. + +### 🛡 Анти-локаут: SSH-порт определяется правильно и не теряется +- **`detect_ssh_port` был слеп к socket-активации**: на Ubuntu 24.04+ слушает `ssh.socket` + (владелец сокета — `systemd`, `Port` из `sshd_config` игнорируется), старая ss-эвристика + молча возвращала `22`, и strict открывал не тот порт. Хуже всего, что штатная проверка + «зайди новым окном» этого не ловит: IP админа целиком в whitelist — у него работает, а + у всех остальных локаут после снятия сейфти. Новый порядок источников: активный + `ssh.socket`/`sshd.socket` → **`sshd -T`** (раскрывает `Include sshd_config.d/*.conf`, + куда порт кладёт cloud-init) → `ss` → `sshd_config` → `22`. +- **Порт текущей SSH-сессии теперь всегда попадает в правила.** `SSH_CONNECTION` даёт + ground truth (сессию уже принял sshd), поэтому ошибка детекта, протухший `protect.conf` + или смена порта между прогонами больше не могут отрезать тот самый доступ, из которого + идёт установка: правила ставятся на **оба** порта + громкий warn (та же логика, что для + node-порта в v3.8). `SSH_PORT` принимает список; в маркер пишется эффективный список, + в `protect.conf` — intent оператора. + +### ⚡ XanMod снова ставится на «грязных» нодах +- `setup_xanmod_repo` проверял репозиторий **неskoupленным `apt-get update`**, а тот + возвращает rc≠0 из-за ЛЮБОГО чужого битого источника на боксе (типовой съёмный VPS). + Живой XanMod-репо объявлялся мёртвым, фоллбэк на `bookworm` падал так же — ядро молча + не ставилось с диагнозом «репо недоступен». Теперь вердикт выносится по своему list + (`Dir::Etc::sourcelist`) — как уже было сделано для CrowdSec в v3.6. +- Для **bookworm** XanMod оставил в репо только LTS-ветку: запрос `main`/`edge`/`rt` + автоматически переводится в `lts` с info-строкой вместо «пакет не найден». + +### 🛡 CrowdSec: канонический `any/any` (и Debian 13) +- Suite-кандидаты теперь `any/any` → `/` → `bookworm`/`noble`. На **Debian 13 + (trixie)** у CrowdSec **нет своего suite** (в `debian/trixie` отсутствует `Release`), а + пакет из самого дистрибутива — устаревший 1.4.6; `any/any` это официальный путь апстрима. +- **`CROWDSEC_STRICT=1`** — запретить `curl|bash`-фоллбэк на `install.crowdsec.net`. + Фоллбэк форсируется атакующим: достаточно сделать packagecloud недостижимым + (egress-фильтр, DNS), чтобы проверенный по отпечатку APT-репо подменился на + неверифицированный код из сети, запускаемый root'ом. + +### 🔐 Ключи, токены, логи +- **Пиннинг ключей стал настоящим.** Проверка «наш отпечаток есть среди импортированных» + пропускала лишние ключи, приехавшие тем же блобом, — а apt по `signed-by=` доверяет + **каждому** ключу keyring'а. Особенно важно для фоллбэка XanMod: он ищет ключ на + keyserver **по 64-битному keyid**, коллизию которого сделать дёшево. Новый + `import_pinned_key` импортирует во временный GNUPGHOME и экспортирует в keyring + **ровно пиненый отпечаток**, затем проверяет, что первичный ключ там ровно один и наш. +- **Секреты ушли из argv.** `na-fleet-sync` передавал `X-Api-Key`/`Authorization` через + `-H` в командной строке — аргументы процесса видны всей системе в `/proc//cmdline` + на всё время запроса. Теперь заголовки пишутся в файл внутри `mktemp -d` (0700) и + передаются как `-H @файл`. +- **Пароль больше не течёт в journald.** README сам предлагает закрывать + `REMNAWAVE_NODES_URL` basic-auth'ом, а хелпер логировал URL целиком — то есть + `user:pass@` попадал в системный журнал каждые 5 минут (и повторялся в `na-fw-status`). + Теперь userinfo зачищается (`***@host`). +- Задан `http://`-URL при наличии токена — громкий warn (редирект-даунгрейд мы блокируем + `--proto-redir`, но явную cleartext-схему оператор указывает сам). +- `install.sh` качает модули и подписи с `--proto '=https' --proto-redir '=https'` — + редирект на cleartext http больше невозможен. + +### 🐛 Прочие починки +- **`swapon` больше не роняет весь optimize.** На btrfs/CoW (`swapfile has holes`) или при + запрете свапа у хостера падение под `set -e` обрывало прогон **посередине**: sysctl и + лимиты применены, а journald-cap, THP, governor, маркер и `save_conf` — уже нет, и + тулкит про это не знал. Теперь ошибка → warn и продолжение. dd-фоллбэк уважает + `REMNAWAVE_SWAP_SIZE` (был хардкод 2048 МБ). +- **`FLEET_SYNC_INTERVAL` персистится**, а `na-diagnose` читает его из таймера (или из + `protect.conf` правильным парсером). Старый парс `^FLEET_SYNC_INTERVAL=` не совпадал + **никогда** — конфиг пишется идиомой `: "${KEY:=value}"`, поэтому интервал всегда + считался равным 5 мин и любой более редкий синк выглядел «протухшим токеном». +- `na-diagnose`: строка про решения CrowdSec больше не печатает двойной `0` + (`grep -vc` отдаёт `0` и rc=1 одновременно → `|| echo 0` дописывал второй). +- **`flock` на прогон** `protect.sh`/`optimize.sh`: параллельные запуски (оркестратор из + панели + руками) перекрывались — сейфти-таймер первого удалял таблицу, которую + применил и подтвердил второй. Отключается `NA_NO_LOCK=1`. +- **`rollback` доубирает за собой**: строки `pam_limits` из `common-session*` и `/swapfile` + вместе с записью в `/etc/fstab` — но swap снимается **только** если его создали мы + (метка `swapfile.created`) и только если он не занят. +- `install.sh -h/--help` работает в curl|bash-режиме (там `$0` = `bash`, и `sed` по нему + просто падал) и больше не требует root. + +### 🧪 Тесты и CI +- **`tests/fleet-sync-unit.sh`** — новый тест, который **исполняет** сгенерированный + `na-fleet-sync` против стабов curl/nft/logger: секреты не в argv, реально уходят через + `-H @file`, URL в логе без userinfo, при токене нет `-L`/есть `--max-redirs 0`, парсинг + адресов (в т.ч. CRLF и IPv6) и fail-safe last-known-good. Раньше фикс v3.9.1 был покрыт + только `grep`-ом по тексту хелпера. +- `apply-smoke` проверяет аварийный откат сейфти и подхват порта SSH-сессии; CI гоняет + shellcheck и по `tests/*.sh`, а в матрице появился прогон с `SSH_CONNECTION`. +- CI: `actions/checkout` пиннится по SHA, добавлен `permissions: contents: read`. + +### ⚠️ Совместимость +- Правила SSH рендерятся как `tcp dport { … }` (набор портов) вместо одиночного порта — + ruleset функционально прежний. +- `crowdsec.list` при ре-ране переписывается на `any/any` (пакеты те же, ключ тот же). +- Ре-ран на ноде, где сейфти когда-то срабатывал, чистит метку `safety-fired.last`. + ## v3.9.1 — 2026-07-21 ### 🛡 fleet-sync — `X-Api-Key` больше не утекает на редиректе diff --git a/README.md b/README.md index e50f18b..4674f8f 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,18 @@ - **Remnawave fleet auto-sync** *(opt-in)* — ноды флота сами держат IP друг друга в whitelist (с панели). - **ICMP rate-limit** (пинг жив, флуд режется). - **CrowdSec + `crowdsec-firewall-bouncer-nftables`** — поведенческий IPS + community-блоклист. - Ставится из **пиннингованного APT-репо** (полный отпечаток ключа сверяется; фоллбэк на - официальный установщик — только если репо недоступен, с громким warn). `CROWDSEC_PROBE=1` — - проверить резолв репо/пакетов на этой ОС без установки. + Ставится из **пиннингованного APT-репо**: ключ проверяется по полному отпечатку и в + keyring кладётся **ровно он** (а не всё, что приехало в ответе). Suite берётся канонический + `any/any` — он же работает на Debian 13 (trixie), где у CrowdSec **нет** своего suite, а + родной пакет дистрибутива устарел; дальше фоллбэки на `/` и `bookworm`/`noble`. + Не поднялся вообще — официальный установщик (`curl|bash`, громкий warn), а `CROWDSEC_STRICT=1` + запрещает и это. `CROWDSEC_PROBE=1` — проверить резолв репо/пакетов на этой ОС без установки. - Полный **IPv6-паритет**, **rate-limit на логи**, **авто-whitelist твоего SSH-IP** + **сейфти-таймер** от самоблокировки. +- **Анти-локаут по SSH-порту**: порт берётся из `sshd -T` и socket-юнита (Ubuntu 24.04+ + слушает `ssh.socket`, где `Port` из `sshd_config` игнорируется), а порт **текущей + SSH-сессии** добавляется в правила всегда — ошибка детекта не может отрезать твой доступ. + Сработавший сейфти-таймер снимает и таблицу, **и автозагрузку правил** — локаут не + возвращается после ребута. - **Персист конфига** — ре-ран без ENV не сбрасывает поднятые под ноду ручки. ### 🩺 Диагностика (`scripts/diagnose.sh`) @@ -102,11 +110,11 @@ curl -fsSL https://raw.githubusercontent.com/jestivald/node-accelerator/main/ins # прод-режим: пиньте тег через NA_REF — компрометация ветки main тогда не утечёт # сразу на весь флот (скрипты тянутся из того же тега): -export NA_REF=v3.9.1 +export NA_REF=v3.9.2 curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all # максимум: + проверка minisign-подписей модулей (подписи лежат в дереве с v3.6): -export NA_REF=v3.9.1 NA_REQUIRE_SIG=1 \ +export NA_REF=v3.9.2 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 ``` @@ -120,7 +128,7 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | Переменная | По умолч. | Что | |---|---|---| | `FW_MODE` | `strict` | `strict` — блок всех портов, кроме разрешённых (Remnawave node); `open` — прочие порты не блокируются, но получают per-IP флуд-лимиты как перечисленные (3x-ui: динамические inbound'ы; анти-скан автобан и node-port правила не ставятся); `skip` — nftables не трогать (только CrowdSec) + инструкция по ручной блокировке. Интерактивно спрашивается; найден 3x-ui — предлагается `open` | -| `SSH_PORT` | авто-детект | порт SSH | +| `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) панели/мониторинга — никогда не банятся | @@ -133,9 +141,12 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `ENABLE_PORTSCAN_BAN` | `1` | автобан за скан закрытых портов | | `PORTSCAN_RATE`/`PORTSCAN_BURST` | `15`/`30` | порог скана (SYN на закрытые порты/мин, per-IP) до бана — ниже порога просто дроп, без бана | | `ENABLE_CROWDSEC` | `1` | ставить CrowdSec + bouncer | +| `CROWDSEC_STRICT` | `0` | `1` — только пиннингованный APT-репо; не поднялся → CrowdSec пропускается (без `curl\|bash`-фоллбэка) | | `ENABLE_SYNPROXY` | `0` | nft synproxy на сервисные порты (advanced). На VPN-relay избыточен — syncookies + per-IP ct-лимиты уже дают анти-спуф; включать под подтверждённый спуф-SYN-флуд | | `CROWDSEC_ENROLL_KEY` | _пусто_ | enroll в CrowdSec Console | -| `SAFETY_DELAY` | `300` | сек до авто-сброса правил, если не подтвердить SSH | +| `SAFETY_DELAY` | `300` | сек до авто-сброса правил, если не подтвердить SSH. Сброс снимает и таблицу, и автозагрузку (`na-firewall.service`) — иначе локаут вернулся бы ребутом | +| `FLEET_SYNC_INTERVAL` | `5min` | интервал fleet-sync (персистится; `na-diagnose` считает свежесть по нему, а не по дефолту) | +| `NA_NO_LOCK` | `0` | `1` — не брать flock (по умолчанию параллельный второй прогон отказывается стартовать) | | `DRY_RUN` | `0` | `1` — только сгенерировать + `nft -c`, не применять | | `ENABLE_BANONCE` | `1` | двухступенчатый автобан (suspect→confirmed), анти-CGNAT-FP | | `SUSPECT_TIME` | `30m` | окно наблюдения за «подозреваемым» (ban-once) | @@ -224,6 +235,10 @@ sudo bash install.sh rollback protect # снять firewall (CrowdSec ост sudo bash install.sh rollback optimize # снять тюнинг (XanMod остаётся; NA_REMOVE_XANMOD=1 + загрузка со стока чтобы удалить) ``` +`rollback optimize` убирает и то, что раньше оставалось жить: строки `pam_limits` в +`common-session*` и `/swapfile` вместе с записью в `/etc/fstab` — **swap снимается только +если его создали мы** (метка `swapfile.created`) и только если он не занят. + Бэкапы оригиналов — в `/var/backups/node-accelerator//`. --- diff --git a/install.sh b/install.sh index b289359..6c07f91 100755 --- a/install.sh +++ b/install.sh @@ -13,9 +13,12 @@ # curl-bash: # curl -fsSL https://raw.githubusercontent.com/jestivald/node-accelerator/main/install.sh | sudo bash -s all # # прод: пиньте тег (компрометация ветки main тогда не утечёт сразу на весь флот): -# export NA_REF=v3.9.1; curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all +# export NA_REF=v3.9.2; 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, для форка/зеркала> +# NA_REQUIRE_SIG=1 + NA_MINISIGN_PUBKEY= | NA_SIG_FINGERPRINT= +# # После optimize/protect/all на ноде остаётся read-only команда `na-diagnose --json` # (стабильный JSON для мониторинга/панели — без повторного curl|bash). Снимается rollback'ом. @@ -30,19 +33,48 @@ NA_REF="${NA_REF:-main}" [[ "$NA_REF" =~ ^[A-Za-z0-9._/-]+$ && "$NA_REF" != *..* ]] || { echo "[x] NA_REF '$NA_REF' невалиден"; exit 1; } REPO_URL="${NA_REPO_URL:-https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF}" +# Справка. В curl|bash-режиме $0 = "bash" (файла нет) — `sed по $0` там просто падал +# с ошибкой вместо вывода помощи, поэтому есть встроенный фоллбэк. Обрабатываем ДО +# скачивания модулей и require_root: за справкой не должно требоваться ни root, ни сеть. +usage() { + if [[ -f "$0" ]]; then + sed -n '2,24p' "$0" + else + cat <<'U' +node-accelerator — ⚡ оптимизатор + 🩺 диагностика + 🛡 защита Remnawave/VPN-ноды. + + install.sh — меню + install.sh optimize — ⚡ XanMod+BBRv3 + тюнинг + install.sh protect — 🛡 nftables + CrowdSec + install.sh diagnose [--json|…] — 🩺 диагностика (read-only) + install.sh all — optimize → protect → diagnose + install.sh rollback [optimize|protect|all] + install.sh persist — (пере)создать CLI na-diagnose/na-report + + ENV: NA_REF=<ветка|тег> NA_REPO_URL=<база raw-URL> + NA_REQUIRE_SIG=1 + NA_MINISIGN_PUBKEY= | NA_SIG_FINGERPRINT= + Документация: https://github.com/jestivald/node-accelerator +U + fi +} +case "${1:-}" in -h|--help) usage; exit 0;; esac + # Опц. проверка подписи модулей в curl|bash-режиме (supply-chain hardening). По умолч. # выкл. NA_REQUIRE_SIG=1 + minisign-ключ (NA_MINISIGN_PUBKEY) ИЛИ GPG-отпечаток # (NA_SIG_FINGERPRINT) → каждый модуль проверяется против .minisig/.asc рядом в репо. NA_REQUIRE_SIG="${NA_REQUIRE_SIG:-0}" NA_MINISIGN_PUBKEY="${NA_MINISIGN_PUBKEY:-}" NA_SIG_FINGERPRINT="${NA_SIG_FINGERPRINT:-}" +# Скачиваем строго по https и НЕ разрешаем редиректу увести на cleartext http: +# по умолчанию curl такой даунгрейд-редирект молча выполняет. +CURL_PROTO=(--proto '=https' --proto-redir '=https') verify_sig() { # verify_sig <файл> local file="$1" url="$2" if [[ -n "$NA_MINISIGN_PUBKEY" ]] && command -v minisign >/dev/null 2>&1; then - curl -fsSL "$url.minisig" -o "$file.minisig" 2>/dev/null || { echo "[x] нет .minisig для $(basename "$file")"; return 1; } + curl -fsSL "${CURL_PROTO[@]}" "$url.minisig" -o "$file.minisig" 2>/dev/null || { echo "[x] нет .minisig для $(basename "$file")"; return 1; } minisign -V -P "$NA_MINISIGN_PUBKEY" -m "$file" >/dev/null 2>&1 elif [[ -n "$NA_SIG_FINGERPRINT" ]] && command -v gpg >/dev/null 2>&1; then - curl -fsSL "$url.asc" -o "$file.asc" 2>/dev/null || { echo "[x] нет .asc для $(basename "$file")"; return 1; } + curl -fsSL "${CURL_PROTO[@]}" "$url.asc" -o "$file.asc" 2>/dev/null || { echo "[x] нет .asc для $(basename "$file")"; return 1; } # Судим по МАШИННОМУ статусу (--status-fd), а НЕ по человекочитаемому выводу: # `gpg --verify | grep ` матчил бы 'using RSA key ' — эта строка # печатается из пакета подписи ДАЖЕ для BAD-подписи, а exit-код терялся в пайпе @@ -60,7 +92,7 @@ if [[ ! -d "$SCRIPTS" ]]; then SCRIPTS="$(mktemp -d)/scripts"; mkdir -p "$SCRIPTS/lib" echo "[*] Скачиваю модули из $REPO_URL ..." for f in lib/common.sh optimize.sh protect.sh diagnose.sh na-report.sh rollback.sh; do - curl -fsSL "$REPO_URL/scripts/$f" -o "$SCRIPTS/$f" || { echo "[x] Не скачал $f"; exit 1; } + curl -fsSL "${CURL_PROTO[@]}" "$REPO_URL/scripts/$f" -o "$SCRIPTS/$f" || { echo "[x] Не скачал $f"; exit 1; } if [[ "$NA_REQUIRE_SIG" == "1" ]]; then verify_sig "$SCRIPTS/$f" "$REPO_URL/scripts/$f" \ && echo "[+] подпись $f валидна" \ @@ -159,6 +191,6 @@ case "${1:-}" in rollback) run_rollback "${2:-all}" ;; persist) persist_toolkit ;; "") show_menu ;; - -h|--help) sed -n '2,20p' "$0" ;; - *) err "Неизвестная команда: $1"; sed -n '2,20p' "$0"; exit 1 ;; + -h|--help) usage ;; + *) err "Неизвестная команда: $1"; usage; exit 1 ;; esac diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index ad771b0..14041c1 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -106,6 +106,13 @@ emit_json() { # (миграция агента 2222→3000 при strict) панель видит как «нода недоступна» npd="$(detect_node_port || true)" npfw="$(awk -F= '/^node_port=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)"; npfw="${npfw:-}" + # сейфти УЖЕ срабатывал (−1 = не срабатывал): значение ≥0 = таблица снята и + # автозагрузка выключена, т.е. защиты СЕЙЧАС нет до повторного прогона protect + sfa=-1 + [[ -f "$STATE_DIR/safety-fired.last" ]] && { cf="$(cat "$STATE_DIR/safety-fired.last" 2>/dev/null)"; [[ "$cf" =~ ^[0-9]+$ ]] && sfa=$(( nowsec - cf )); } + # переживут ли правила ребут (na-firewall.service в автозагрузке) + fwboot=0 + systemctl is-enabled --quiet na-firewall.service 2>/dev/null && fwboot=1 # свежесть последнего УСПЕШНОГО синка (−1 = штампа нет / модуль не активен) fsa=-1; bla=-1 [[ -f "$STATE_DIR/fleet-sync.last" ]] && { cf="$(cat "$STATE_DIR/fleet-sync.last" 2>/dev/null)"; [[ "$cf" =~ ^[0-9]+$ ]] && fsa=$(( nowsec - cf )); } @@ -129,7 +136,7 @@ emit_json() { printf '"ulimit_n":%s,"min_snd_mss":%s,"mtu_probing":%s,"mss_collapsed_sockets":%s,' "${uln:-0}" "$minsnd" "$mtuprobe" "${collapsed:-0}" printf '"firewall":%s,"fw_mode":"%s","autoban_v4":%s,"autoban_v6":%s,"suspect":%s,"blocklist_v4":%s,"blocklist_v6":%s,' "$fw" "$fwm" "$ab4" "$ab6" "$susp" "$bl4" "$bl6" printf '"fleet_v4":%s,"fleet_v6":%s,"crowdsec":%s,"ctguard":"%s","synproxy_degraded":%s,' "$fl4" "$fl6" "$crowd" "$ctg" "$syndeg" - printf '"safety_armed":%s,"reboot_needed":%s,' "$safety" "$rebootn" + printf '"safety_armed":%s,"safety_fired_age_s":%s,"fw_boot_enabled":%s,"reboot_needed":%s,' "$safety" "$sfa" "$fwboot" "$rebootn" printf '"na_version":"%s","hostname":"%s","uptime_s":%s,"load1":%s,"mem_used_pct":%s,' "$nav" "$host" "$up" "$load1" "$mempct" printf '"wan_iface":"%s","wan_rx_bytes":%s,"wan_tx_bytes":%s,"ipv6_default":%s,"udp_rcvbuf_errors":%s,' "$wi" "$wanrx" "$wantx" "$ip6def" "$udperr" printf '"remnanode_status":"%s","remnanode_restarts":%s,"remnanode_spawn_errors_1h":%s,' "$rnst" "$rnrc" "$rnse" @@ -491,7 +498,12 @@ if nft list table inet na_filter >/dev/null 2>&1; then _fls="$(cat "$STATE_DIR/fleet-sync.last" 2>/dev/null)" if [[ "$_fls" =~ ^[0-9]+$ ]]; then _age=$(( $(date +%s) - _fls )) - _iv="$(awk -F= '/^FLEET_SYNC_INTERVAL=/{gsub(/[":]/,"",$2);print $2}' "$CONF_DIR/protect.conf" 2>/dev/null)" + # интервал: сперва из самого таймера (ground truth), затем из protect.conf. + # ВАЖНО: conf пишется идиомой `: "${KEY:=value}"`, поэтому наивный + # парс `^FLEET_SYNC_INTERVAL=` не матчил НИКОГДА — интервал молча + # считался 5min и всякий более редкий синк выглядел «протухшим». + _iv="$(awk -F= '/^OnUnitActiveSec=/{print $2; exit}' /etc/systemd/system/na-fleet-sync.timer 2>/dev/null)" + [[ -n "$_iv" ]] || _iv="$(sed -nE 's/.*\{FLEET_SYNC_INTERVAL:=([^}]*)\}.*/\1/p' "$CONF_DIR/protect.conf" 2>/dev/null | tail -1)" _ivs="$(systime_to_s "${_iv:-5min}")"; [[ "$_ivs" -ge 60 ]] || _ivs=300 if [[ "$_age" -gt $((_ivs*3)) ]]; then wrn "последний успешный fleet-sync $((_age/60)) мин назад (> 3× интервала) — токен протух/панель сменила API? journalctl -t na-fleet-sync" @@ -526,10 +538,29 @@ if systemctl is-active --quiet na-fw-safety.timer 2>/dev/null \ || { [[ -f /tmp/na-fw-safety.pid ]] && kill -0 "$(cat /tmp/na-fw-safety.pid 2>/dev/null)" 2>/dev/null; }; then bad "ВЗВЕДЁН сейфти-таймер na-fw-safety — na_filter СКОРО САМОУДАЛИТСЯ! Сними после проверки доступа: systemctl stop na-fw-safety.timer" fi +# Сейфти УЖЕ сработал: таблица снята И автозагрузка правил выключена (иначе локаут-руллсет +# вернулся бы после ребута уже без подстраховки). Значит защиты сейчас нет — это не шум. +if [[ -f "$STATE_DIR/safety-fired.last" ]]; then + _sfd="$(cat "$STATE_DIR/safety-fired.last" 2>/dev/null)" + if [[ "$_sfd" =~ ^[0-9]+$ ]]; then + bad "СЕЙФТИ СРАБАТЫВАЛ $(( ($(date +%s) - _sfd)/60 )) мин назад: na_filter снята, автозагрузка выключена — защиты НЕТ. Проверь SSH_PORT/WHITELIST и прогони protect заново." + else + bad "СЕЙФТИ СРАБАТЫВАЛ: защиты нет до повторного прогона protect" + fi +fi +# Таблица есть, а автозагрузки нет → после ребута нода останется без правил. +if nft list table inet na_filter >/dev/null 2>&1 \ + && [[ -f /etc/systemd/system/na-firewall.service ]] \ + && ! systemctl is-enabled --quiet na-firewall.service 2>/dev/null; then + wrn "na_filter активна, но na-firewall.service ВЫКЛЮЧЕН — после ребута правила не поднимутся. Лечится повторным прогоном protect." +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" - DEC=$(cscli decisions list -o raw 2>/dev/null | grep -vc '^$' || echo 0) + # grep -vc печатает 0 И возвращает rc=1, когда совпадений нет → наивный `|| echo 0` + # дописывал ВТОРОЙ ноль и строка выходила битой. Берём значение, потом валидируем. + DEC="$(cscli decisions list -o raw 2>/dev/null | 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)" else diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 3947ec9..78534f7 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="3.9.1" +NA_VERSION="3.9.2" # shellcheck disable=SC2034 RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' @@ -149,14 +149,72 @@ systime_to_s() { esac } -# SSH-порт: сперва из активной сессии sshd, потом из конфига. +# SSH-порт. Источники по убыванию достоверности (выигрывает первый сработавший): +# 1. активный socket-юнит ssh.socket/sshd.socket — на Ubuntu 24.04+ сокет держит +# systemd, а Port из sshd_config ИГНОРИРУЕТСЯ; в ss владельцем значится "systemd", +# поэтому старая ss-эвристика (/sshd|"ssh"/) молча отдавала 22 — strict-файрвол +# открывал не тот порт, а «проверь SSH в новом окне» этого не ловит (IP админа +# целиком в whitelist: у него работает, у всех остальных — локаут); +# 2. `sshd -T` — раскрывает Include /etc/ssh/sshd_config.d/*.conf (сток bookworm/noble, +# cloud-init пишет порт именно в drop-in), т.е. надёжнее чтения одного файла; +# 3. ss по слушателю; 4. сам sshd_config; 5. 22. detect_ssh_port() { - local p - p="$(ss -tnlp 2>/dev/null | awk '/sshd|"ssh"/{n=split($4,a,":"); print a[n]; exit}')" + local p="" u sshd_bin="" + for u in ssh.socket sshd.socket; do + systemctl is-active --quiet "$u" 2>/dev/null || continue + p="$(systemctl show "$u" -p ListenStream --value 2>/dev/null \ + | awk -F: 'NF{print $NF}' | grep -xE '[0-9]+' | head -1)" + [[ -n "$p" ]] && break + done + if [[ -z "$p" ]]; then + command -v sshd >/dev/null 2>&1 && sshd_bin=sshd + [[ -z "$sshd_bin" && -x /usr/sbin/sshd ]] && sshd_bin=/usr/sbin/sshd + [[ -n "$sshd_bin" ]] && p="$("$sshd_bin" -T 2>/dev/null | awk '$1=="port"{print $2; exit}')" + fi + [[ -z "$p" ]] && p="$(ss -tnlp 2>/dev/null | awk '/sshd|"ssh"|ssh\.socket/{n=split($4,a,":"); print a[n]; exit}')" [[ -z "$p" ]] && p="$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/ {print $2; exit}' /etc/ssh/sshd_config 2>/dev/null)" + [[ "$p" =~ ^[0-9]+$ ]] || p="" echo "${p:-22}" } +# Порт ЭТОГО сервера в текущей SSH-сессии. SSH_CONNECTION=" " — +# сессия уже прошла через sshd, так что 4-е поле это ground truth, а не эвристика. +# Пусто, если запущено не по SSH (консоль/cron). Нужен, чтобы файрвол не отрезал порт, +# на котором ты прямо сейчас сидишь (протухший protect.conf / сменили порт / ошибка детекта). +ssh_session_port() { + local p="${SSH_CONNECTION:-}" + p="$(printf '%s' "$p" | awk '{print $4}')" + [[ "$p" =~ ^[0-9]+$ ]] && (( p>=1 && p<=65535 )) && echo "$p" +} + +# import_pinned_key <файл-с-ключом> <полный-fpr> +# Кладёт в keyring РОВНО пиненый ключ. Почему не `gpg --dearmor` всего ответа: apt по +# signed-by= доверяет КАЖДОМУ ключу файла, а проверка «наш отпечаток среди +# импортированных» пропускает лишние ключи, приехавшие тем же блобом. Это не теория: +# фолбэк за ключом XanMod ходит на keyserver по 64-битному keyid, а коллизию keyid +# сделать дёшево — чужой ключ лёг бы в тот же keyring и apt начал бы ему доверять. +import_pinned_key() { + local src="$1" fp="$2" out="$3" home rc=1 got + command -v gpg >/dev/null 2>&1 || { warn "нет gpg — не могу проверить отпечаток ключа"; return 1; } + home="$(mktemp -d)" || return 1 + chmod 700 "$home" + if GNUPGHOME="$home" gpg --batch --quiet --import "$src" >/dev/null 2>&1; then + if GNUPGHOME="$home" gpg --batch --yes --export "$fp" > "$out" 2>/dev/null && [[ -s "$out" ]]; then + # контроль: в получившемся keyring ровно один ПЕРВИЧНЫЙ ключ и это наш fpr + got="$(gpg --show-keys --with-colons "$out" 2>/dev/null \ + | awk -F: '$1=="pub"{p=1;next} $1=="fpr"&&p{print $10;p=0}')" + [[ "$got" == "$fp" ]] && rc=0 + fi + fi + # gpg 2.x поднимает agent/dirmngr под временный GNUPGHOME — гасим, чтобы на ноде + # не оставался процесс, смотрящий в удалённый каталог + gpgconf --homedir "$home" --kill all >/dev/null 2>&1 || true + rm -rf "$home" + [[ $rc -eq 0 ]] || { rm -f "$out"; return 1; } + chmod 0644 "$out" + return 0 +} + # IP клиента, с которого мы сейчас подключены по SSH (для авто-whitelist от самоблокировки). # ${VAR:-} обязателен: при запуске из консоли (не по SSH) переменных нет, а вызов идёт под set -u. ssh_client_ip() { diff --git a/scripts/optimize.sh b/scripts/optimize.sh index a80546f..0c03179 100755 --- a/scripts/optimize.sh +++ b/scripts/optimize.sh @@ -27,6 +27,12 @@ trap 'tput cnorm 2>/dev/null || true; exit 130' INT require_root detect_os +# Один прогон optimize за раз: параллельные запуски дерутся за apt-lock и sysctl-файлы. +if [[ "${NA_NO_LOCK:-0}" != "1" ]] && command -v flock >/dev/null 2>&1 && mkdir -p "$STATE_DIR" 2>/dev/null; then + exec 9>"$STATE_DIR/optimize.lock" + flock -n 9 || { err "уже идёт другой прогон optimize.sh (лок $STATE_DIR/optimize.lock) — не мешаю"; exit 1; } +fi + # Подхватываем сохранённый конфиг оптимизатора (ENV по-прежнему переопределяет). load_conf "$CONF_DIR/optimize.conf" @@ -85,22 +91,26 @@ XANMOD_FP="D38D7D1DA1349567ADED882D86F7D09EE734E623" # Импорт ключа: 1) напрямую с XanMod; 2) при блокировке (CF-403 типичен для Hetzner/GCP) # — с Ubuntu keyserver. Что бы ни сработало — сверяем полный отпечаток. xanmod_import_key() { - local keyring="$1" + local keyring="$1" tmpkey mkdir -p /etc/apt/keyrings - if ! curl -fsSL --connect-timeout 5 --max-time 20 https://dl.xanmod.org/archive.key \ - | gpg --yes --dearmor -o "$keyring" 2>/dev/null; then + tmpkey="$(mktemp)" || return 1 + if ! curl -fsSL --connect-timeout 5 --max-time 20 https://dl.xanmod.org/archive.key -o "$tmpkey"; then warn "dl.xanmod.org недоступен (обычно CF-403 на хостингах) — пробую Ubuntu keyserver…" - curl -fsSL --connect-timeout 5 --max-time 20 \ - "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${XANMOD_FP: -16}" \ - | gpg --yes --dearmor -o "$keyring" 2>/dev/null \ - || { warn "Ключ XanMod недоступен ни напрямую, ни с keyserver"; return 1; } + if ! curl -fsSL --connect-timeout 5 --max-time 20 \ + "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${XANMOD_FP: -16}" -o "$tmpkey"; then + warn "Ключ XanMod недоступен ни напрямую, ни с keyserver"; rm -f "$tmpkey"; return 1 + fi fi - if ! gpg --show-keys --with-colons "$keyring" 2>/dev/null \ - | awk -F: '/^fpr:/{print $10}' | grep -qx "$XANMOD_FP"; then - warn "Отпечаток ключа XanMod не совпал с $XANMOD_FP — отказываюсь использовать" - rm -f "$keyring"; return 1 + # keyserver ищется ПО 64-битному keyid, а его коллизию сделать дёшево → в ответе + # рядом с настоящим ключом может приехать чужой. apt по signed-by= доверяет КАЖДОМУ + # ключу keyring'а, поэтому кладём туда ровно наш отпечаток (import_pinned_key), + # а не «всё, что прислали». + if ! import_pinned_key "$tmpkey" "$XANMOD_FP" "$keyring"; then + warn "Ключ XanMod не сошёлся с отпечатком $XANMOD_FP — отказываюсь использовать" + rm -f "$tmpkey"; return 1 fi - chmod 0644 "$keyring" + rm -f "$tmpkey" + return 0 } # Готовим репозиторий XanMod (ключ + sources.list + apt update). Идемпотентно; @@ -117,20 +127,33 @@ setup_xanmod_repo() { codename="bookworm"; XANMOD_FLAVOR="lts" ;; "") codename="bookworm" ;; # релиз не определён — берём универсальный LTS-suite esac + # Для oldstable (bookworm) XanMod оставил в репо ТОЛЬКО LTS-ветку — main/edge/rt + # оттуда убраны, и запрос такой сборки просто не нашёл бы пакет. + if [[ "$codename" == "bookworm" && "$XANMOD_FLAVOR" != "lts" ]]; then + info "Debian bookworm: в репо XanMod осталась только LTS-ветка → беру lts вместо '$XANMOD_FLAVOR'" + XANMOD_FLAVOR="lts" + fi xanmod_import_key "$keyring" || return 1 + # Обновляем ТОЛЬКО свой list (как в setup_crowdsec_repo): глобальный apt-get update + # возвращает rc≠0 из-за ЛЮБОГО чужого битого источника на боксе — а это типовая + # ситуация на съёмных VPS. Без скоупа живой XanMod-репо ложно объявлялся мёртвым, + # фоллбэк на bookworm падал так же, и ядро молча не ставилось «репо недоступен». + local -a UPDSC=(-o "Dir::Etc::sourcelist=$list" -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0) echo "deb [signed-by=$keyring] https://deb.xanmod.org $codename main" > "$list" - if ! apt-get update -qq 2>/dev/null; then + if ! apt-get update -qq "${UPDSC[@]}" 2>/dev/null; then if [[ "$codename" != "bookworm" ]]; then warn "Suite '$codename' не поднялся — откатываюсь на 'bookworm' (LTS)" codename="bookworm"; XANMOD_FLAVOR="lts" echo "deb [signed-by=$keyring] https://deb.xanmod.org $codename main" > "$list" - apt-get update -qq 2>/dev/null || { warn "XanMod-репо недоступен"; rm -f "$list"; return 1; } + apt-get update -qq "${UPDSC[@]}" 2>/dev/null || { warn "XanMod-репо недоступен"; rm -f "$list"; return 1; } else warn "XanMod-репо ('bookworm') недоступен"; rm -f "$list"; return 1 fi fi + # общий кэш (чтобы apt-cache/install видели пакеты); чужие битые источники не фатальны + apt-get update -qq 2>/dev/null || true return 0 } @@ -542,6 +565,46 @@ fi # меньше IO-просадок под анти-OOM. На крупных — обычный /swapfile. SETUP_NO_ZRAM=1 форсит swapfile. title "Swap" SETUP_NO_ZRAM="${SETUP_NO_ZRAM:-0}" + +swap_size_mb() { # "2G" / "512M" / "2048" → мегабайты (для dd-фоллбэка) + local v="${1:-2G}" n u + n="${v%[GgMmKk]}"; u="${v#"$n"}" + [[ "$n" =~ ^[0-9]+$ ]] || { echo 2048; return; } + case "$u" in + G|g) echo $((n*1024));; + K|k) echo $((n/1024));; + *) echo "$n";; # M/m или без единицы — уже мегабайты + esac +} + +# Создание /swapfile — с полной обработкой ошибок. Под `set -e` падение swapon (btrfs/CoW: +# «swapfile has holes», запрет свапа у хостера) роняло ВЕСЬ optimize посреди прогона: +# sysctl/лимиты уже применены, а journald-cap, THP, governor, маркер и save_conf — ещё +# нет, и бокс оставался в полу-настроенном состоянии, про которое сам тулкит не знал. +# Swap не стоит того, чтобы бросать тюнинг на полпути — не вышло, предупредили, поехали. +make_swapfile() { + local size="${REMNAWAVE_SWAP_SIZE:-2G}" mb + mb="$(swap_size_mb "$size")" + if swapon --show=NAME --noheadings 2>/dev/null | grep -qx '/swapfile'; then + info "/swapfile уже активен — пропускаю"; return 0 + fi + if ! fallocate -l "$size" /swapfile 2>/dev/null; then + rm -f /swapfile + # dd-фоллбэк уважает запрошенный размер (раньше был хардкод 2048 МБ) + dd if=/dev/zero of=/swapfile bs=1M count="$mb" status=none 2>/dev/null || { + warn "не смог создать /swapfile (нет места?) — swap пропущен"; rm -f /swapfile; return 1; } + fi + chmod 600 /swapfile + mkswap /swapfile >/dev/null 2>&1 || { warn "mkswap не прошёл — swap пропущен"; rm -f /swapfile; return 1; } + if ! swapon /swapfile 2>/dev/null; then + warn "swapon не прошёл (btrfs/CoW «swapfile has holes» или запрет хостера) — swap пропущен" + rm -f /swapfile; return 1 + fi + grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab + # метка «свап наш» — только по ней rollback имеет право его снимать + mkdir -p "$STATE_DIR" && date -Is > "$STATE_DIR/swapfile.created" + ok "Создан /swapfile $size" +} if swapon --show 2>/dev/null | grep -q .; then info "Swap уже есть — пропускаю" elif [[ "$TIER" -le 2 && "$SETUP_NO_ZRAM" != "1" ]] && modprobe zram 2>/dev/null; then @@ -578,20 +641,10 @@ EOF ok "zram-swap включён ($(swapon --show=NAME,SIZE --noheadings 2>/dev/null | grep zram | tr '\n' ' '))" else warn "zram не поднялся — fallback на /swapfile" - SWAP_SIZE="${REMNAWAVE_SWAP_SIZE:-2G}" - fallocate -l "$SWAP_SIZE" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count=2048 status=none - chmod 600 /swapfile; mkswap /swapfile >/dev/null; swapon /swapfile - grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab - ok "Создан /swapfile $SWAP_SIZE" + make_swapfile || true fi else - SWAP_SIZE="${REMNAWAVE_SWAP_SIZE:-2G}" - fallocate -l "$SWAP_SIZE" /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048 status=none - chmod 600 /swapfile - mkswap /swapfile >/dev/null - swapon /swapfile - grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab - ok "Создан /swapfile $SWAP_SIZE" + make_swapfile || true fi # ─── 8. journald cap ───────────────────────────────────────────────────────── diff --git a/scripts/protect.sh b/scripts/protect.sh index cced404..f2b37f2 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -26,6 +26,8 @@ # SSH_RATE=6 SSH_BURST=5 per-IP новых SSH/мин до бана # SSH_BAN_TIME=24h PORTSCAN_BAN_TIME=1h # ENABLE_PORTSCAN_BAN=1 ENABLE_CROWDSEC=1 ENABLE_SYNPROXY=0 +# CROWDSEC_STRICT=0 1 = ставить CrowdSec ТОЛЬКО из пиннингованного +# APT-репо; не поднялся — пропустить (без curl|bash) # FW_MODE=strict|open|skip strict: блок всех портов, кроме разрешённых (дефолт); # open: защита без блокировки прочих портов (3x-ui); # skip: nftables не трогать вообще (только CrowdSec) @@ -39,6 +41,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" require_root detect_os + +# Один прогон protect за раз. Параллельные запуски (оркестратор из панели + руками) +# не рвут nft-транзакцию, но перекрываются сейфти-таймером: таймер прогона A удалит +# таблицу, которую прогон B уже применил и подтвердил. NA_NO_LOCK=1 — отключить. +if [[ "${NA_NO_LOCK:-0}" != "1" ]] && command -v flock >/dev/null 2>&1 && mkdir -p "$STATE_DIR" 2>/dev/null; then + exec 9>"$STATE_DIR/protect.lock" + flock -n 9 || { err "уже идёт другой прогон protect.sh (лок $STATE_DIR/protect.lock) — не мешаю"; exit 1; } +fi + BACKUP="$(backup_dir)" # Подхватываем сохранённый конфиг ноды (если есть): ре-ран без ENV не сбрасывает @@ -73,6 +84,11 @@ PORTSCAN_BAN_TIME="${PORTSCAN_BAN_TIME:-1h}" PORTSCAN_RATE="${PORTSCAN_RATE:-15}"; PORTSCAN_BURST="${PORTSCAN_BURST:-30}" # /minute, per-IP ENABLE_PORTSCAN_BAN="${ENABLE_PORTSCAN_BAN:-1}" ENABLE_CROWDSEC="${ENABLE_CROWDSEC:-1}" +# CROWDSEC_STRICT=1 — никакого curl|bash-фоллбэка: не поднялся пиннингованный репо, +# значит CrowdSec просто не ставим. Фоллбэк форсируется атакующим (достаточно сделать +# packagecloud недостижимым — egress-фильтр, DNS), а это подмена проверенного по +# отпечатку APT-репо на неверифицированный код из сети, запускаемый root'ом. +CROWDSEC_STRICT="${CROWDSEC_STRICT:-0}" ENABLE_SYNPROXY="${ENABLE_SYNPROXY:-0}" # Режим файрвола: # strict — input policy drop: открыты ТОЛЬКО SSH/сервисные/node-agent порты @@ -179,7 +195,9 @@ validate_port_list() { [[ "$v" =~ ^[0-9,]+$ ]] || { err "$name: '$v' — только цифры и запятые"; return 1; } for p in ${v//,/ }; do _is_port "$p" || { err "$name: '$p' вне 1..65535"; return 1; }; done } -_is_port "$SSH_PORT" || { err "SSH_PORT '$SSH_PORT' невалиден"; exit 1; } +# SSH_PORT допускает список (sshd на двух портах — типичная миграция порта). +[[ -n "$SSH_PORT" ]] || { err "SSH_PORT пуст"; exit 1; } +validate_port_list "$SSH_PORT" SSH_PORT || exit 1 [[ "$NODE_PORT" == "auto" ]] || validate_port_list "$NODE_PORT" NODE_PORT || exit 1 validate_port_list "$TCP_PORTS" TCP_PORTS || exit 1 validate_port_list "$UDP_PORTS" UDP_PORTS || exit 1 @@ -207,7 +225,7 @@ for _k in BLOCKLIST_REFRESH FLEET_SYNC_INTERVAL NA_CTG_INTERVAL; do done # enum-флаги 0/1 (+auto где уместно) for _k in ENABLE_PORTSCAN_BAN ENABLE_CROWDSEC ENABLE_SYNPROXY ENABLE_BANONCE \ - ENABLE_BLOCKLISTS BLOCK_TOR ENABLE_CTGUARD NA_CTG_ENFORCE; do + ENABLE_BLOCKLISTS BLOCK_TOR ENABLE_CTGUARD NA_CTG_ENFORCE CROWDSEC_STRICT; do [[ "${!_k}" =~ ^[01]$ ]] || { err "$_k='${!_k}' — ожидается 0 или 1"; exit 1; } done [[ "$NODE_PORT_WHITELIST_ONLY" =~ ^(auto|0|1)$ ]] || { err "NODE_PORT_WHITELIST_ONLY должно быть auto|0|1"; exit 1; } @@ -220,8 +238,30 @@ fi if [[ -n "$REMNAWAVE_NODES_URL" && ! "$REMNAWAVE_NODES_URL" =~ ^https?://[A-Za-z0-9._~:/?#=%@-]+$ ]]; then err "REMNAWAVE_NODES_URL='$REMNAWAVE_NODES_URL' — ожидается http(s)://… без спецсимволов"; exit 1 fi +# http:// + секрет = токен уходит по проводу открытым текстом. Редирект-даунгрейд мы +# блокируем (--proto-redir), а вот явно заданную cleartext-схему запретить нельзя +# (бывают внутренние сети) — но молчать об этом нельзя тем более. +if [[ -n "$CADDY_AUTH_API_TOKEN" || -n "$REMNAWAVE_TOKEN" ]]; then + for _u in "$REMNAWAVE_URL" "$REMNAWAVE_NODES_URL"; do + [[ "$_u" == http://* ]] && warn "'$_u' по http:// — токен панели/Caddy уйдёт открытым текстом. Возьми https." + done + unset _u +fi unset _k +# Порт ТЕКУЩЕЙ SSH-сессии — ground truth (sshd её уже принял, гадать не нужно). Если он +# не входит в SSH_PORT (ошибка детекта, протухший protect.conf, порт меняли между +# прогонами), strict уронил бы его в catch-all drop → после срабатывания сейфти вход +# закрыт. Открываем ОБА + громкий warn — та же логика, что для node-port в v3.8. +# В маркер уходит эффективный список, в protect.conf — intent оператора (SSH_PORT). +SSH_EFF="$SSH_PORT" +SSH_SESSION_PORT="$(ssh_session_port || true)" +if [[ -n "$SSH_SESSION_PORT" && ",$SSH_EFF," != *",$SSH_SESSION_PORT,"* ]]; then + warn "твоя SSH-сессия пришла на :$SSH_SESSION_PORT, а SSH_PORT=$SSH_PORT — открываю ОБА (иначе локаут после сейфти); сверь и закрепи SSH_PORT=$SSH_SESSION_PORT" + SSH_EFF="$SSH_EFF,$SSH_SESSION_PORT" +fi +SSH_NFT="${SSH_EFF//,/, }" + # Резолв NODE_PORT_WHITELIST_ONLY=auto: whitelist-only только если оператор задал # WHITELIST (знает доверенный набор). Пустой WHITELIST → мягкий лимит (не отрезаем панель). # NPWL_SRC помнит, откуда взялось решение: авто-вывод из WHITELIST vs явный intent @@ -274,43 +314,49 @@ ok "ok" # ─── CrowdSec: пиннингованный APT-репозиторий (supply-chain) ───────────────── # Вместо curl|bash с install.crowdsec.net — их packagecloud-репо с проверкой ПОЛНОГО -# отпечатка ключа (64-битный keyid подделать дёшево). Suite нет для этой ОС (свежие -# релизы Ubuntu/Debian) → фоллбэк на noble/bookworm; совсем не поднялся → официальный -# установщик как last-resort (громко, warn). +# отпечатка ключа (64-битный keyid подделать дёшево) и экспортом в keyring РОВНО этого +# ключа (см. import_pinned_key). +# Порядок suite-кандидатов: +# 1. any/any — канон апстрима (их же install.crowdsec.net пишет именно его). Один +# набор пакетов на все дистрибутивы, Release всегда есть; +# 2. / — нативный suite, если он у них собран; +# 3. /bookworm|noble — фоллбэк для свежих релизов. +# Почему any/any первым: под Debian 13 (trixie) suite debian/trixie у CrowdSec ПУСТОЙ — +# нет Release-файла (upstream issues #3834/#3909), а родной пакет самого Debian 13 — +# древний 1.4.6, который апстрим сам не рекомендует. CROWDSEC_FP="6A89E3C2303A901A889971D3376ED5326E93CD0C" setup_crowdsec_repo() { local keyring=/etc/apt/keyrings/crowdsec-archive-keyring.gpg local list=/etc/apt/sources.list.d/crowdsec.list - local os="$OS_ID" codename fb + local os="$OS_ID" codename fb tmpkey cand path suite seen="" okrepo=0 codename="$(os_codename)"; [[ -n "$codename" ]] || codename=bookworm + fb=bookworm; [[ "$os" == "ubuntu" ]] && fb=noble mkdir -p /etc/apt/keyrings - if ! curl -fsSL --connect-timeout 5 --max-time 20 https://packagecloud.io/crowdsec/crowdsec/gpgkey \ - | gpg --yes --dearmor -o "$keyring" 2>/dev/null; then - warn "ключ CrowdSec (packagecloud) недоступен"; rm -f "$keyring"; return 1 + tmpkey="$(mktemp)" || return 1 + if ! curl -fsSL --connect-timeout 5 --max-time 20 \ + https://packagecloud.io/crowdsec/crowdsec/gpgkey -o "$tmpkey"; then + warn "ключ CrowdSec (packagecloud) недоступен"; rm -f "$tmpkey"; return 1 fi - if ! gpg --show-keys --with-colons "$keyring" 2>/dev/null \ - | awk -F: '/^fpr:/{print $10}' | grep -qx "$CROWDSEC_FP"; then - warn "отпечаток ключа CrowdSec не совпал с $CROWDSEC_FP — отказываюсь использовать" - rm -f "$keyring"; return 1 + if ! import_pinned_key "$tmpkey" "$CROWDSEC_FP" "$keyring"; then + warn "ключ CrowdSec не сошёлся с отпечатком $CROWDSEC_FP — отказываюсь использовать" + rm -f "$tmpkey"; return 1 fi - chmod 0644 "$keyring" + rm -f "$tmpkey" # ВАЖНО: обновляем ТОЛЬКО свой list. Глобальный `apt-get update` вернул бы rc≠0 из-за # ЛЮБОГО постороннего битого источника на боксе (протухший сторонний репо — типовой # съёмный VPS), и пиннинг ложно самоотключился бы на живом packagecloud. Скоуп через # Dir::Etc даёт вердикт именно о нашем репо. local -a UPDSC=(-o "Dir::Etc::sourcelist=$list" -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0) - echo "deb [signed-by=$keyring] https://packagecloud.io/crowdsec/crowdsec/$os $codename main" > "$list" - if ! apt-get update -qq "${UPDSC[@]}" 2>/dev/null; then - fb=bookworm; [[ "$os" == "ubuntu" ]] && fb=noble - if [[ "$codename" != "$fb" ]]; then - warn "suite '$codename' в репо CrowdSec не поднялся — пробую '$fb'" - echo "deb [signed-by=$keyring] https://packagecloud.io/crowdsec/crowdsec/$os $fb main" > "$list" - if ! apt-get update -qq "${UPDSC[@]}" 2>/dev/null; then - rm -f "$list"; apt-get update -qq 2>/dev/null || true; return 1 - fi - else - rm -f "$list"; apt-get update -qq 2>/dev/null || true; return 1 - fi + for cand in "any any" "$os $codename" "$os $fb"; do + path="${cand%% *}"; suite="${cand##* }" + [[ ",$seen," == *",$path/$suite,"* ]] && continue + seen+="${seen:+,}$path/$suite" + echo "deb [signed-by=$keyring] https://packagecloud.io/crowdsec/crowdsec/$path $suite main" > "$list" + if apt-get update -qq "${UPDSC[@]}" 2>/dev/null; then okrepo=1; break; fi + warn "репо CrowdSec '$path $suite' не поднялся — пробую следующий вариант" + done + if [[ "$okrepo" != "1" ]]; then + rm -f "$list"; apt-get update -qq 2>/dev/null || true; return 1 fi # общий кэш подтянуть (наш list валиден); чужие битые источники тут не фатальны apt-get update -qq 2>/dev/null || true @@ -331,14 +377,34 @@ if [[ "${CROWDSEC_PROBE:-0}" == "1" ]]; then fi # ─── Сейфти-таймер: если потеряем SSH — снести нашу таблицу через N сек ─────── +# Действие сработавшей подстраховки. Снимает и живую таблицу, И автозагрузку правил. +# Почему второе обязательно: раньше сейфти удалял ТОЛЬКО таблицу, а na-firewall.service +# оставался enabled — доступ возвращался, оператор видел живой бокс и уходил, а ПЕРВЫЙ +# ЖЕ ребут применял тот самый локаут-руллсет заново, теперь уже без всякого сейфти. +write_safety_revert() { + cat > /usr/local/sbin/na-fw-safety-revert <<'SREV' +#!/bin/sh +# na-fw-safety-revert — аварийный откат файрвола (ставится protect.sh, снимается rollback). +/usr/sbin/nft delete table inet na_filter 2>/dev/null +systemctl disable na-firewall.service >/dev/null 2>&1 +mkdir -p /var/lib/node-accelerator 2>/dev/null +date +%s > /var/lib/node-accelerator/safety-fired.last 2>/dev/null +rm -f /var/lib/node-accelerator/na-fw-safety.pid 2>/dev/null +logger -t na-fw-safety "СЕЙФТИ СРАБОТАЛ: na_filter удалена, автозагрузка правил выключена — защиты сейчас НЕТ, нужен повторный прогон protect" +exit 0 +SREV + chmod +x /usr/local/sbin/na-fw-safety-revert +} + arm_safety() { [[ "$DRY_RUN" == "1" ]] && return 0 title "Подстраховка от блокировки" - warn "Если SSH отвалится — таблица na_filter удалится через ${SAFETY_DELAY}s (доступ вернётся)." + warn "Если SSH отвалится — через ${SAFETY_DELAY}s na_filter удалится И автозагрузка правил выключится (доступ вернётся, в т.ч. после ребута)." + write_safety_revert if command -v systemd-run >/dev/null 2>&1; then systemctl stop na-fw-safety.timer 2>/dev/null || true systemd-run --quiet --unit=na-fw-safety --on-active="${SAFETY_DELAY}s" \ - /usr/sbin/nft delete table inet na_filter >/dev/null 2>&1 \ + /usr/local/sbin/na-fw-safety-revert >/dev/null 2>&1 \ && { ok "safety: systemd-таймер na-fw-safety на ${SAFETY_DELAY}s"; return 0; } fi # fallback (нет systemd-run): nohup-таймер. Стейт в $STATE_DIR (root-only), НЕ в общей @@ -347,7 +413,7 @@ arm_safety() { mkdir -p "$STATE_DIR" local pidf="$STATE_DIR/na-fw-safety.pid" logf="$STATE_DIR/na-fw-safety.log" [[ -f "$pidf" && ! -L "$pidf" ]] && { kill "$(cat "$pidf")" 2>/dev/null || true; } - nohup sh -c 'sleep "$1"; /usr/sbin/nft delete table inet na_filter 2>/dev/null; rm -f "$2"' \ + nohup sh -c 'sleep "$1"; /usr/local/sbin/na-fw-safety-revert 2>/dev/null; rm -f "$2"' \ _ "$SAFETY_DELAY" "$pidf" >"$logf" 2>&1 & echo $! > "$pidf" ok "safety: nohup pid $(cat "$pidf")" @@ -668,20 +734,20 @@ fi # SSH connect-flood: с ban-once (suspect→confirmed) или прямой бан. if [[ "$ENABLE_BANONCE" == "1" ]]; then SSH_RULES=" # SSH connect-flood (ban-once): перебор → 1-й раз suspect+drop, 2-й в окне → бан ${SSH_BAN_TIME} - tcp dport ${SSH_PORT} ct state new meter ssh4 { ip saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport ${SSH_PORT} ct state new meter ssh6 { ip6 saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport ${SSH_PORT} ct state new limit rate 5/second log prefix \"[na ssh-flood] \" level warn - tcp dport ${SSH_PORT} ct state new ip saddr @suspect_v4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop - tcp dport ${SSH_PORT} ct state new ip6 saddr @suspect_v6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop - tcp dport ${SSH_PORT} ct state new meta nfproto ipv4 add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } drop - tcp dport ${SSH_PORT} ct state new meta nfproto ipv6 add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} } drop" + tcp dport { ${SSH_NFT} } ct state new meter ssh4 { ip saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept + tcp dport { ${SSH_NFT} } ct state new meter ssh6 { ip6 saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept + tcp dport { ${SSH_NFT} } ct state new limit rate 5/second log prefix \"[na ssh-flood] \" level warn + tcp dport { ${SSH_NFT} } ct state new ip saddr @suspect_v4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop + tcp dport { ${SSH_NFT} } ct state new ip6 saddr @suspect_v6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop + tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv4 add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } drop + tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv6 add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} } drop" else SSH_RULES=" # SSH connect-flood: >${SSH_RATE}/мин новых с одного IP → бан ${SSH_BAN_TIME} - tcp dport ${SSH_PORT} ct state new meter ssh4 { ip saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport ${SSH_PORT} ct state new meter ssh6 { ip6 saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept - tcp dport ${SSH_PORT} ct state new limit rate 5/second log prefix \"[na ssh-flood] \" level warn - tcp dport ${SSH_PORT} ct state new meta nfproto ipv4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop - tcp dport ${SSH_PORT} ct state new meta nfproto ipv6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop" + tcp dport { ${SSH_NFT} } ct state new meter ssh4 { ip saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept + tcp dport { ${SSH_NFT} } ct state new meter ssh6 { ip6 saddr limit rate ${SSH_RATE}/minute burst ${SSH_BURST} packets } accept + tcp dport { ${SSH_NFT} } ct state new limit rate 5/second log prefix \"[na ssh-flood] \" level warn + tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop + tcp dport { ${SSH_NFT} } ct state new meta nfproto ipv6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop" fi WL4_LINE=""; [[ -n "$WL4" ]] && WL4_LINE="elements = { $WL4 }" @@ -848,6 +914,8 @@ fi arm_safety nft -f "$NFT_FILE" ok "nftables na_filter применён" +# новый руллсет применён → прошлое срабатывание сейфти больше не актуально +rm -f "$STATE_DIR/safety-fired.last" 2>/dev/null || true # boot-persist через свой сервис (не трогаем /etc/nftables.conf и чужие таблицы) cat > /etc/systemd/system/na-firewall.service </dev/null 2>&1 || true systemctl enable nftables >/dev/null 2>&1 || true -ok "na-firewall.service включён (правила переживут reboot)" +ok "na-firewall.service включён (правила переживут reboot — если не сработает сейфти-таймер: он теперь снимает и автозагрузку)" fi # ═══ конец блока файрвола (FW_MODE=skip его пропускает) ═══ @@ -886,10 +954,14 @@ if [[ "$ENABLE_CROWDSEC" == "1" ]]; then info "Подключаю APT-репозиторий CrowdSec (пиннингованный ключ $CROWDSEC_FP)…" if setup_crowdsec_repo; then DEBIAN_FRONTEND=noninteractive apt-get install -y -qq crowdsec >/dev/null 2>&1 || warn "crowdsec не установился" + elif [[ "$CROWDSEC_STRICT" == "1" ]]; then + warn "пиннингованный репо CrowdSec не поднялся, CROWDSEC_STRICT=1 → CrowdSec пропущен (curl|bash-фоллбэк запрещён)" else # last-resort: официальный установщик. -fsSL (а не -s): при HTTP-ошибке/ - # редиректе curl падает, а не отдаёт HTML в bash. - warn "пиннингованный репо не поднялся — fallback на официальный установщик (curl|bash)" + # редиректе curl падает, а не отдаёт HTML в bash. Осознанный компромисс: + # достаточно СДЕЛАТЬ packagecloud недостижимым (egress-фильтр/DNS), чтобы + # сюда свалиться — кто параноит, ставит CROWDSEC_STRICT=1. + warn "пиннингованный репо не поднялся — fallback на официальный установщик (curl|bash; отключается CROWDSEC_STRICT=1)" curl -fsSL https://install.crowdsec.net | bash >/dev/null 2>&1 || warn "install.crowdsec.net недоступен" DEBIAN_FRONTEND=noninteractive apt-get install -y -qq crowdsec >/dev/null 2>&1 || warn "crowdsec не установился" fi @@ -1016,8 +1088,15 @@ CADDY="${CADDY_AUTH_API_TOKEN:-}" command -v curl >/dev/null 2>&1 || { logger -t "$TAG" "нет curl"; exit 1; } nft list set inet na_filter na_fleet_v4 >/dev/null 2>&1 || { logger -t "$TAG" "сет na_fleet нет (protect без fleet) — выкл"; exit 0; } TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT -CURL_CADDY=() -[ -n "$CADDY" ] && CURL_CADDY=(-H "X-Api-Key: $CADDY") +# Секреты уходят в ФАЙЛ заголовков (внутри 0700-каталога), а не в argv: аргументы +# процесса видны всей системе через /proc//cmdline на всё время запроса. +HDRF="$TMP/hdr" +: > "$HDRF"; chmod 600 "$HDRF" +[ -n "$CADDY" ] && printf 'X-Api-Key: %s\n' "$CADDY" >> "$HDRF" +# В journald пишем URL без userinfo: README сам предлагает закрывать статический список +# basic-auth'ом (https://user:pass@host/nodes.json), а лог читает кто угодно с доступом +# к journalctl — пароль там жил бы вечно и повторялся каждый тик синка. +redact_url() { printf '%s' "$1" | sed -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)[^/@]*@#\1***@#'; } # curl срезает Authorization на кросс-хост редиректе, но кастомный X-Api-Key — НЕТ: # с -L токен Caddy утёк бы на хост-цель редиректа. При заданном токене редиректы НЕ # следуем (оператор задаёт финальный https-URL сам). Без токена -L оставляем, но @@ -1026,14 +1105,17 @@ FS_REDIR=(-L --max-redirs 3) [ -n "$CADDY" ] && FS_REDIR=(--max-redirs 0) if [ -n "$NURL" ]; then SRC="$NURL" + CURL_HDR=() + [ -s "$HDRF" ] && CURL_HDR=(-H @"$HDRF") HTTP="$(curl -fsS "${FS_REDIR[@]}" --proto-redir '=https' --max-time 15 -o "$TMP/r" -w '%{http_code}' \ - "${CURL_CADDY[@]}" "$NURL" 2>/dev/null || true)" + "${CURL_HDR[@]}" "$NURL" 2>/dev/null || true)" else command -v jq >/dev/null 2>&1 || { logger -t "$TAG" "нет jq (нужен для /api/nodes)"; exit 1; } URL="${URL%/}"; SRC="$URL/api/nodes" + printf 'Authorization: Bearer %s\n' "$TOKEN" >> "$HDRF" + printf 'Accept: application/json\n' >> "$HDRF" HTTP="$(curl -fsS --max-time 15 -o "$TMP/r" -w '%{http_code}' \ - -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \ - "${CURL_CADDY[@]}" "$SRC" 2>/dev/null || true)" + -H @"$HDRF" "$SRC" 2>/dev/null || true)" fi [ "$HTTP" = "200" ] && [ -s "$TMP/r" ] || { logger -t "$TAG" "источник недоступен (HTTP=$HTTP) — last-known-good"; exit 0; } : > "$TMP/addr" @@ -1070,7 +1152,7 @@ n4=$(printf '%s' "$V4" | tr ',' '\n' | grep -c . || true) n6=$(printf '%s' "$V6" | tr ',' '\n' | grep -c . || true) if nft -f "$TMP/upd.nft" 2>/dev/null; then mkdir -p /var/lib/node-accelerator && date +%s > "$STAMP" - logger -t "$TAG" "whitelist нод обновлён: ${n4} v4 + ${n6} v6 (из $SRC)" + logger -t "$TAG" "whitelist нод обновлён: ${n4} v4 + ${n6} v6 (из $(redact_url "$SRC"))" else logger -t "$TAG" "nft apply не прошёл — last-known-good сохранён" fi @@ -1382,7 +1464,7 @@ installed_at=$(date -Is) na_version=$NA_VERSION backup=$BACKUP fw_mode=$FW_MODE -ssh_port=$SSH_PORT +ssh_port=$SSH_EFF tcp_ports=$TCP_PORTS udp_ports=$UDP_PORTS node_port=$NP_EFF @@ -1400,8 +1482,9 @@ save_conf "$CONF_DIR/protect.conf" \ SYN_RATE SYN_BURST UDP_RATE UDP_BURST CONN_LIMIT \ ICMP_RATE ICMP_BURST SSH_RATE SSH_BURST SSH_BAN_TIME \ PORTSCAN_BAN_TIME PORTSCAN_RATE PORTSCAN_BURST \ - ENABLE_PORTSCAN_BAN ENABLE_CROWDSEC ENABLE_SYNPROXY \ + ENABLE_PORTSCAN_BAN ENABLE_CROWDSEC CROWDSEC_STRICT ENABLE_SYNPROXY \ ENABLE_BLOCKLISTS BLOCK_TOR BLOCKLIST_REFRESH ENABLE_BANONCE SUSPECT_TIME \ + FLEET_SYNC FLEET_SYNC_INTERVAL \ NODE_PORT_WHITELIST_ONLY NODE_PORT_LAST NODE_PORT_AUTOWL NODE_PORT_PEERS SAFETY_DELAY \ ENABLE_CTGUARD NA_CTG_ENFORCE NA_CTG_PHANTOM_MIN NA_CTG_LIVE_FLOOR \ NA_CTG_COARSE_MULT NA_CTG_BANTIME NA_CTG_INTERVAL diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 9c3620d..6321bb2 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -23,11 +23,30 @@ rollback_optimize() { rm -f /etc/systemd/journald.conf.d/na-size.conf sed -i '/# === node-accelerator ===/,/# === \/node-accelerator ===/d' /etc/security/limits.conf 2>/dev/null || true + # pam_limits: строку дописывал optimize (её нет в стоке Debian/Ubuntu common-session) + for pam in /etc/pam.d/common-session /etc/pam.d/common-session-noninteractive; do + [[ -f "$pam" ]] || continue + sed -i '/^session required pam_limits.so$/d' "$pam" 2>/dev/null || true + done + for svc in na-rps na-nic-tune na-cpu-perf na-thp-off na-zram na-mss-clamp; do systemctl disable --now "$svc.service" >/dev/null 2>&1 || true rm -f "/etc/systemd/system/$svc.service" done rm -f /usr/local/sbin/na-rps-setup /usr/local/sbin/na-zram-setup + + # /swapfile снимаем ТОЛЬКО если его создали мы (метка swapfile.created) — чужой swap + # трогать нельзя, а снятие живого swap'а на нагруженной ноде это прямой путь к OOM. + if [[ -f "$STATE_DIR/swapfile.created" ]]; then + if swapoff /swapfile 2>/dev/null; then + rm -f /swapfile + sed -i '\#^/swapfile[[:space:]]#d' /etc/fstab 2>/dev/null || true + rm -f "$STATE_DIR/swapfile.created" + ok "/swapfile снят и удалён (запись из fstab убрана)" + else + warn "/swapfile занят — не снял. Вручную: swapoff /swapfile && rm -f /swapfile (и строка в /etc/fstab)" + fi + fi # MSS-clamp: снять свою таблицу nft delete table inet na_mss 2>/dev/null || true rm -f "$CONF_DIR/na_mss.nft" @@ -75,7 +94,12 @@ rollback_protect() { nft delete table inet na_ctguard 2>/dev/null || true rm -f "$CONF_DIR/na_filter.nft" rm -f /usr/local/sbin/na-fw-status /usr/local/sbin/na-fw-top-talkers \ - /usr/local/sbin/na-fleet-sync /usr/local/sbin/na-blocklist-update /usr/local/sbin/na-ctguard + /usr/local/sbin/na-fleet-sync /usr/local/sbin/na-blocklist-update /usr/local/sbin/na-ctguard \ + /usr/local/sbin/na-fw-safety-revert + rm -f "$STATE_DIR/safety-fired.last" "$STATE_DIR/protect.lock" + # nftables.service мы включали (boot-persist), но выключать не будем: он лишь грузит + # /etc/nftables.conf, который тулкит никогда не писал — трогать чужой конфиг нельзя. + systemctl is-enabled --quiet nftables 2>/dev/null && info "nftables.service оставлен включённым (грузит ваш /etc/nftables.conf, наших правил там нет)" rm -f /etc/modules-load.d/na-synproxy.conf "$STATE_DIR/.synproxy-degraded" # конфиги: persisted protect.conf, ctguard.conf, токен панели fleet.env (custom-blocklist.txt — данные оператора, оставляем) rm -f "$STATE_DIR/protect.installed" "$CONF_DIR/protect.conf" "$CONF_DIR/ctguard.conf" "$CONF_DIR/fleet.env" diff --git a/tests/apply-smoke.sh b/tests/apply-smoke.sh index 6045cdd..ad3ecb4 100755 --- a/tests/apply-smoke.sh +++ b/tests/apply-smoke.sh @@ -194,8 +194,41 @@ set -e if [ "$rc" -ne 0 ]; then echo "[x] caddy: apply упал (exit $rc)"; tail -25 "$LOG6"; fail=1; fi grep -qF 'CADDY_AUTH_API_TOKEN=caddy-secret' "$T/conf/fleet.env" \ || { echo "[x] caddy: токен не сохранён в fleet.env"; fail=1; } -grep -qF 'X-Api-Key: $CADDY' "$T/sbin/na-fleet-sync" \ +grep -qF 'X-Api-Key: %s' "$T/sbin/na-fleet-sync" \ || { echo "[x] caddy: na-fleet-sync не шлёт X-Api-Key"; fail=1; } +# секрет должен уходить файлом заголовков, а не в argv (виден в /proc//cmdline) +grep -qF -- '-H @"$HDRF"' "$T/sbin/na-fleet-sync" \ + || { echo "[x] caddy: заголовки с секретом не передаются через файл (-H @…)"; fail=1; } +grep -qF 'redact_url "$SRC"' "$T/sbin/na-fleet-sync" \ + || { echo "[x] caddy: URL уходит в лог без зачистки userinfo"; fail=1; } +# поведенческая проверка того же хелпера — отдельным тестом +bash "$REPO_ROOT/tests/fleet-sync-unit.sh" >/dev/null 2>&1 \ + || { echo "[x] fleet-sync-unit.sh не прошёл (детали: bash tests/fleet-sync-unit.sh)"; fail=1; } + +# ── Сейфти-откат снимает И автозагрузку правил (иначе локаут вернулся бы ребутом) ── +grep -qF 'systemctl disable na-firewall.service' "$T/sbin/na-fw-safety-revert" \ + || { echo "[x] safety: аварийный откат не выключает na-firewall.service"; fail=1; } +grep -qF 'nft delete table inet na_filter' "$T/sbin/na-fw-safety-revert" \ + || { echo "[x] safety: аварийный откат не удаляет na_filter"; fail=1; } + +# ── Порт текущей SSH-сессии не должен потеряться (ground truth из SSH_CONNECTION) ── +reset_t +LOG7="$T/apply-sshport.log" +set +e +SSH_CONNECTION='203.0.113.9 55000 10.0.0.5 56777' SSH_PORT=22 ENABLE_CROWDSEC=0 \ + REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 \ + bash "$T/scripts/protect.sh" >"$LOG7" 2>&1 +rc=$? +set -e +if [ "$rc" -ne 0 ]; then echo "[x] ssh-port: apply упал (exit $rc)"; tail -25 "$LOG7"; fail=1; fi +grep -qF 'SSH-сессия пришла на :56777' "$LOG7" \ + || { echo "[x] ssh-port: нет warn про расхождение SSH_PORT с портом сессии"; fail=1; } +grep -q 'tcp dport { 22, 56777 }' "$NFTF" \ + || { echo "[x] ssh-port: в ruleset должны быть ОБА порта (заданный + порт сессии)"; fail=1; } +grep -q '^ssh_port=22,56777$' "$T/state/protect.installed" \ + || { echo "[x] ssh-port: эффективный список портов не в маркере"; fail=1; } +grep -q 'SSH_PORT:=22}' "$T/conf/protect.conf" \ + || { echo "[x] ssh-port: в protect.conf должен персиститься intent (22), а не транзитный порт сессии"; fail=1; } if [ "$fail" -ne 0 ]; then echo "=== ХВОСТ ЛОГА (strict) ==="; tail -25 "$LOG" diff --git a/tests/fleet-sync-unit.sh b/tests/fleet-sync-unit.sh new file mode 100644 index 0000000..0851bf3 --- /dev/null +++ b/tests/fleet-sync-unit.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# fleet-sync-unit.sh — ИСПОЛНЯЕТ сгенерированный protect.sh хелпер `na-fleet-sync` +# против стабов curl/nft/logger. До этого теста хелперы проверялись только grep'ом по +# тексту: фикс утечки X-Api-Key на редиректе (v3.9.1) не имел ни одного поведенческого +# теста, хотя это ровно тот код, что ходит в сеть с секретом каждые 5 минут. +# +# Что стережём: +# 1. секреты НЕ уходят в argv curl (виден всей системе в /proc//cmdline); +# 2. секреты РЕАЛЬНО отправляются — через файл заголовков (-H @file); +# 3. в journald пишется URL без userinfo (README предлагает basic-auth для NODES_URL); +# 4. при заданном Caddy-токене редиректы не следуются (--max-redirs 0, без -L); +# 5. разобранные адреса доезжают до nft-транзакции. +# +# Не требует root/сети/systemd. Запуск: bash tests/fleet-sync-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/conf" "$T/state" "$T/rec" + +PASSWORD='sup3rs3cret' +CADDY_TOKEN='caddy-token-xyz' +BEARER_TOKEN='panel-bearer-abc' + +# ── Достаём сам хелпер из heredoc protect.sh (без запуска всего модуля) ────────── +awk "/cat > \/usr\/local\/sbin\/na-fleet-sync <<'FSYNC'/{f=1;next} f&&/^FSYNC\$/{exit} f" \ + "$REPO_ROOT/scripts/protect.sh" > "$T/na-fleet-sync.raw" +[ -s "$T/na-fleet-sync.raw" ] || { echo "[x] не смог извлечь na-fleet-sync из protect.sh"; exit 1; } +# системные пути → в песочницу +sed -e "s#/etc/node-accelerator#$T/conf#g" -e "s#/var/lib/node-accelerator#$T/state#g" \ + "$T/na-fleet-sync.raw" > "$T/na-fleet-sync" +chmod +x "$T/na-fleet-sync" + +# ── Стабы ─────────────────────────────────────────────────────────────────────── +export REC="$T/rec" +printf '203.0.113.10\n# комментарий\n203.0.113.11\r\n2001:db8::42\n' > "$REC/fixture.txt" + +# curl: записывает argv и содержимое файла заголовков, отдаёт фикстуру и код 200 +cat > "$T/bin/curl" <<'CURL' +#!/bin/sh +printf '%s\n' "$*" >> "$REC/curl.argv" +OUT=""; HDR="" +while [ $# -gt 0 ]; do + case "$1" in + -o) OUT="$2"; shift;; + -H) case "$2" in @*) HDR="${2#@}";; *) printf '%s\n' "$2" >> "$REC/curl.inline-headers";; esac; shift;; + esac + shift +done +[ -n "$HDR" ] && [ -f "$HDR" ] && cat "$HDR" >> "$REC/curl.headers" +[ -n "$OUT" ] && cp "$REC/fixture.txt" "$OUT" +printf '200' +exit 0 +CURL +# nft: сет «существует», транзакцию складываем в файл +cat > "$T/bin/nft" <<'NFT' +#!/bin/sh +case "$1" in + list) exit 0;; + -f) cat "$2" >> "$REC/nft.applied"; exit 0;; +esac +exit 0 +NFT +cat > "$T/bin/logger" <<'LOG' +#!/bin/sh +# logger [-t TAG] MESSAGE +while [ $# -gt 0 ]; do case "$1" in -t) shift;; *) printf '%s\n' "$1" >> "$REC/logger.out";; esac; shift; done +exit 0 +LOG +chmod +x "$T/bin/curl" "$T/bin/nft" "$T/bin/logger" +export PATH="$T/bin:$PATH" + +fail=0 +check() { if [ "$1" = 0 ]; then echo " ✔ $2"; else echo " ✘ $2"; fail=1; fi; } +nocontain() { if grep -qF "$2" "$1" 2>/dev/null; then return 1; else return 0; fi; } + +# ── Кейс 1: NODES_URL с basic-auth + Caddy-токен ──────────────────────────────── +echo "== NODES_URL (basic-auth в URL) + CADDY_AUTH_API_TOKEN ==" +rm -f "$REC"/curl.* "$REC"/logger.out "$REC"/nft.applied +cat > "$T/conf/fleet.env" < "$T/conf/fleet.env" < "$T/bin/curl"; chmod +x "$T/bin/curl" +bash "$T/na-fleet-sync" +[ ! -f "$REC/nft.applied" ]; check $? "при HTTP 503 nft-транзакция не выполняется" +grep -qF 'last-known-good' "$REC/logger.out"; check $? "в лог ушёл fail-safe last-known-good" + +if [ "$fail" -ne 0 ]; then echo "FLEET-SYNC-UNIT: FAIL"; exit 1; fi +echo "FLEET-SYNC-UNIT: OK (секреты не в argv, уходят через -H @file, URL в логе без userinfo, редиректы заблокированы, парсинг адресов и fail-safe работают)" From da98393dbfa595543ff0531c69ad140e9ea716b1 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sun, 26 Jul 2026 23:40:50 +0300 Subject: [PATCH 2/3] release: sign 6 modules with minisign (v3.9.2) Co-Authored-By: Claude Opus 5 --- scripts/diagnose.sh.minisig | 8 ++++---- scripts/lib/common.sh.minisig | 8 ++++---- scripts/na-report.sh.minisig | 6 +++--- scripts/optimize.sh.minisig | 8 ++++---- scripts/protect.sh.minisig | 8 ++++---- scripts/rollback.sh.minisig | 8 ++++---- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/scripts/diagnose.sh.minisig b/scripts/diagnose.sh.minisig index 2090703..4abdc71 100644 --- a/scripts/diagnose.sh.minisig +++ b/scripts/diagnose.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v3.9.1 -RUQrJghT9nkdBI46ijFVrExbYhpBneRjOTHHG4v92JpqtVBPw+vurMqDQ1GnIu52F2P6fu+ep08BSxNKFMI7S00awQF69rHtAA8= -trusted comment: timestamp:1784653317 file:diagnose.sh hashed -gD+/oCQwHWWXYC10JI3xQTcqpU6v/XgwBKc43gaTiEwq6ZREN+HbfbddosMFDNz0qLriql1DGf6mJv66zGI+Cg== +untrusted comment: node-accelerator v3.9.2 +RUQrJghT9nkdBHzE2TQ1oE/txdKT6UZW6tSTiYU1XoM67seFBmzhycgrZqLyCzRnHW8SHrUihTyMls+Du/Qu2KmbgxzSZeyIbg0= +trusted comment: timestamp:1785098445 file:diagnose.sh hashed +dCegQZs3OD61xD4wIXRXHotS8yaxDw4ygxWJpRRpuuFHZdHyErMCl2YyXUDowcHB92LpRFBgV6sPPcEaN7XZDw== diff --git a/scripts/lib/common.sh.minisig b/scripts/lib/common.sh.minisig index 1096d22..a7c4e7f 100644 --- a/scripts/lib/common.sh.minisig +++ b/scripts/lib/common.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v3.9.1 -RUQrJghT9nkdBGcKg2qkT/5rfdpgSoszxuqaT4iJq0uZHuAE75xnZO7cUPDeZSvkgBRkx9rymLaDbDNLv0IhJICdW31k0gneZAo= -trusted comment: timestamp:1784653316 file:common.sh hashed -rBBmWykvy9uj3WxN+wqCvI2WmmRehekTqJSiYjr4IjJ26h6Ux1vsuFBP/UY/6F38avapCFOqaZCMhiaQL8dwDg== +untrusted comment: node-accelerator v3.9.2 +RUQrJghT9nkdBB0oLlEFxwSJdFc/x5ACuwMr5Wm9WEWTXNIb5lZ4Mf2dn183gu6pOzuqRu2nSQNF2RTae5RUcmApZKjiH7D45Q4= +trusted comment: timestamp:1785098445 file:common.sh hashed +x5+XYcd71zlg7Jxsy4nm1owgNk/dvBGqluvcM8/3u2qN0tcGr9bLckSyjCDVFVrDFkQo/7fb5B2i0pIxU0OmCA== diff --git a/scripts/na-report.sh.minisig b/scripts/na-report.sh.minisig index 8d58702..6e6c871 100644 --- a/scripts/na-report.sh.minisig +++ b/scripts/na-report.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v3.9.1 +untrusted comment: node-accelerator v3.9.2 RUQrJghT9nkdBEMDEW6/CQVlDybd3P2GZLg16wz388LYcClNcsORAsk/77Ejzo1fhcAJJlC6Sxrtk56WUYoplKbZA6fs07GfQwk= -trusted comment: timestamp:1784653317 file:na-report.sh hashed -IQplNGn9Bl1GR/uWKoeypy8KrELp9CLgyRrxaU8dHyWLgL+loHeMyMM79LKyj+tqONTjRlBefDq27cZ/0xb7Dg== +trusted comment: timestamp:1785098445 file:na-report.sh hashed +W6Y0iTWU0xacyt0nNwR+OlDM6Z7op1x6SyrUgF8rmSCfg9cRDEeD1t75KM4Nq8tUeNAceqZyYNjci17Nx1jBDw== diff --git a/scripts/optimize.sh.minisig b/scripts/optimize.sh.minisig index 14e2a2a..dd94083 100644 --- a/scripts/optimize.sh.minisig +++ b/scripts/optimize.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v3.9.1 -RUQrJghT9nkdBBtrVA/mrDyKUWgE89UALc/7kXfIoPUtbd3tngK59Aw63zUfylq3MQ00eT00snTByNhRGByOJERfpwv1TCu2dwk= -trusted comment: timestamp:1784653316 file:optimize.sh hashed -dU9xFFLoYjZ6lmQUBMdQUBH0Q6Ime5JUaBbBOqvJ7CuhG3YEtY1xtVz9+HkMr+haResfJhN0DfOWKj//mGOTAQ== +untrusted comment: node-accelerator v3.9.2 +RUQrJghT9nkdBG1Jd/4a5QxaKeBk/KMcycjxvWhLlth2P4X4fXqvcJab3eYy79IYcCFv9sJTwWqK1+cdwuYij3hUM1G/Hh270AM= +trusted comment: timestamp:1785098445 file:optimize.sh hashed +zo8qLmyuuWNqXSTavsUrpLH/z9QIjt+0NU5ScNjAyENUN+mNC4sWVUJ0RyP5FtQcqLWvdFFU/iAnMWKnw2WKDw== diff --git a/scripts/protect.sh.minisig b/scripts/protect.sh.minisig index cdd477e..d41e8be 100644 --- a/scripts/protect.sh.minisig +++ b/scripts/protect.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v3.9.1 -RUQrJghT9nkdBFFFQd4Th/E+Vf3ZFLFYyAQCAQkj1t2XjghDla9pogPcEcLyfwcQFSjWMaeUK/fdXXnAKh0a8pj1OHSubGZERQY= -trusted comment: timestamp:1784653316 file:protect.sh hashed -DTgLlrLVFp+KlKnnIR3j/HtXJKoZQI5B/Ga2+tx3swjT8OBhc5sh6MgEfYt3KPFNNaPwWeQWYXYenyllFXd+Dg== +untrusted comment: node-accelerator v3.9.2 +RUQrJghT9nkdBCcRRxo9tDAiJyHD/3qyC+tFCMwmSmUFoEpEhxKLNde5PgSrH4L3TlGSgSClb8qHkurEnAQ1SFzcbL72F6wB3AM= +trusted comment: timestamp:1785098445 file:protect.sh hashed +73uHAcQoNw6N3eVkSd1TGCuRb6vP4icHkRQLTTdo5YdqtFI1k2BkRW23/ofNPSIBHRwDUY+45+pENDY5Fm+OCA== diff --git a/scripts/rollback.sh.minisig b/scripts/rollback.sh.minisig index b1df1c7..053fc01 100644 --- a/scripts/rollback.sh.minisig +++ b/scripts/rollback.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v3.9.1 -RUQrJghT9nkdBKeG/JX21K/Og9Ac7Q7fD1w7MsC2qVa64CiK0xCTFR+O664tpEO6XMNSQHV56NAyk/Ie8uIje8iUvelEtfOC4gY= -trusted comment: timestamp:1784653317 file:rollback.sh hashed -kd20IYJdmZP22t1HrOJU0H13P/hGiANab75nxcza8Jfr41BKKYgcTmmpk6ggfAH//OfuBHV11Iwg8MNs/KYRAA== +untrusted comment: node-accelerator v3.9.2 +RUQrJghT9nkdBKQnrdJBlLvjyP9UubGkcQ14/h/qbc+0Rw/3kR4lIqfEpU+P5PLxeKnEiRfINexSmToJr8oZwamiQVCbpcoGhww= +trusted comment: timestamp:1785098445 file:rollback.sh hashed +rVzQo14gWVmYCL4LY8ShCkPvQ3fLH6dR5a2ca7GCri4b/Vu1fZcQSXwJpAe7ESeFVL70NJ7b6RZYyGLliDIgAw== From ce8b07a0fca789a668cdae6eda1a0545bf97d193 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sun, 26 Jul 2026 23:46:56 +0300 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20=D0=B8=D1=81=D0=BA=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20DRY=5FRUN-ruleset=20=D0=B2=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=BC=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B5?= =?UTF-8?q?=20(=D1=88=D0=B0=D0=B3=20SSH-=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В DRY_RUN protect.sh пишет ruleset в mktemp /tmp/na_filter.XXXXXX.nft, а не в $CONF_DIR — проверка искала не тот путь и падала. Сама фича отработала на всех образах: warn про порт сессии есть, nft -c принял правило с набором из двух портов. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 521cd3d..cf97c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,9 +112,12 @@ jobs: OUT="$(SSH_CONNECTION='203.0.113.9 55000 10.0.0.5 56777' SSH_PORT=22 \ DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh)" echo "$OUT" | grep -q 'SSH-сессия пришла на :56777' || { echo "порт SSH-сессии не подхвачен"; exit 1; } - grep -q 'tcp dport { 22, 56777 }' /etc/node-accelerator/na_filter.nft \ - || { echo "в ruleset нет обоих SSH-портов"; exit 1; } - echo "ok: порт сессии добавлен в ruleset" + # в DRY_RUN ruleset генерится во временный файл (/tmp/na_filter.XXXXXX.nft), + # а не в $CONF_DIR — берём самый свежий + F="$(ls -t /tmp/na_filter.*.nft 2>/dev/null | head -1)" + [ -n "$F" ] || { echo "не найден сгенерированный ruleset"; exit 1; } + grep -q 'tcp dport { 22, 56777 }' "$F" || { echo "в ruleset ($F) нет обоих SSH-портов"; exit 1; } + echo "ok: порт сессии добавлен в ruleset ($F)" - name: diagnose.sh (read-only, не должен падать жёстко) run: bash scripts/diagnose.sh || true - name: diagnose.sh --json (machine-readable smoke)