From c78b253dd1bd31611441f88579ed1073b9294569 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:02:54 +0300 Subject: [PATCH 01/14] =?UTF-8?q?v4.2=20(protect):=20Docker-DNAT=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=BE=D0=B9,=20Cr?= =?UTF-8?q?owdSec=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=20?= =?UTF-8?q?SSH,=20=D0=BB=D0=BE=D0=B3=20=D1=81=D0=BA=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D1=85,=20na-fw,=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0,=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BD=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=8E=D1=82=20=D1=80=D0=B5-=D1=80=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - forward: новые DNAT-соединения → dnat_guard (whitelist/fleet → пропуск, autoban/ blocklist/bogon → drop); DNAT_GUARD=0 — выкл - CROWDSEC_SCOPE=ssh (дефолт): bouncer set-only + своё правило только на SSH с исключением whitelist; all — как раньше; ручной crowdsec-nft-scope.service поглощается - лог [na portscan] только на переходе в suspect/бан (цепочки ps_suspect*/ps_ban*); именованные счётчики c_* на всех drop; na-fw-status их показывает - SSH ban-once: бан только на повторном переборе (sshs4/6, 3× burst), не на ретрансмите - SYNPROXY до ct state invalid drop + nf_conntrack_tcp_loose=0; забаненные — раньше - анти-амплификация UDP (UDP_AMP_DROP), DHCPv6-ответы, :: убран из bogon_v6 (DAD) - na-fw allow add|del|list, unban — whitelist во всех 4 слоях, nft -c до замены файла - na_filter.d/{table,input}/*.nft — локальные правила, вклеиваются в каждую генерацию - autoban/suspect с оставшимся сроком переносятся через ре-ран - none = явно пусто (WHITELIST/UDP_PORTS/TCP_PORTS/UDP_BULK_PORTS/NODE_PORT_PEERS, NODE_PORT=none); пустой ENV — по-прежнему «как в conf» - SSH: Listen сокет-юнита, все порты sshd -T, SSH_CONNECTION у предков (sudo), автодетект SSH_PORT больше не персистится (устаревший — оба порта + warn) - detect_node_port: rw-node → host-сеть контейнеры по .Config.Image → .env; bridge-тенант не в счёт; несколько агентов — warn - ctguard: ENABLE_CTGUARD=0 снимает таймер/таблицу/конфиг; DNAT-клиенты не фантомы - fleet.env: токены валидируются, значения %q; фиды: префиксы шире /8 (v6 /19) — отказ - na-firewall.service до network-pre (DefaultDependencies=no), SAFETY_DELAY ≥ 30, CrowdSec ставится до взвода сейфти, «y» проверяет, что таблица на месте - маркер: crowdsec_scope, dnat_guard, nft_sha256, include_dir/files, node_port=none - tests: apply-smoke/protect-unit/common-unit/ctguard-unit/nft-behavior (DNAT, SYNPROXY, ban-once, счётчики на настоящем ядре) Co-Authored-By: Claude Opus 5.5 --- scripts/lib/common.sh | 89 +++-- scripts/protect.sh | 891 ++++++++++++++++++++++++++++++++++-------- scripts/rollback.sh | 23 +- tests/apply-smoke.sh | 83 +++- tests/common-unit.sh | 75 ++++ tests/ctguard-unit.sh | 7 + tests/nft-behavior.sh | 81 +++- tests/protect-unit.sh | 156 +++++++- 8 files changed, 1192 insertions(+), 213 deletions(-) diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 45b2838..e2c87f9 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -235,31 +235,54 @@ systime_to_s() { # 2. `sshd -T` — раскрывает Include /etc/ssh/sshd_config.d/*.conf (сток bookworm/noble, # cloud-init пишет порт именно в drop-in), т.е. надёжнее чтения одного файла; # 3. ss по слушателю; 4. сам sshd_config; 5. 22. +# Все источники отдают ВСЕ порты (sshd на двух портах — типичная миграция), через запятую. +# Свойство сокет-юнита называется `Listen` (формат «[::]:22 (Stream)»): до v4.2 тут читали +# несуществующее `ListenStream` — ветка ssh.socket была мёртвой, и на Ubuntu 24.04+ с портом +# в drop-in сокета детект молча отдавал 22 из `sshd -T`. +_ports_csv() { tr -c '0-9\n' '\n' | grep -xE '[0-9]{1,5}' | awk '$1>=1 && $1<=65535 && !s[$1]++' | paste -sd, -; } detect_ssh_port() { 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)" + p="$(systemctl show "$u" -p Listen --value 2>/dev/null \ + | grep -oE ':[0-9]+ \(Stream\)' | _ports_csv)" [[ -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}')" + [[ -n "$sshd_bin" ]] && p="$("$sshd_bin" -T 2>/dev/null | awk '$1=="port"{print $2}' | _ports_csv)" 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="" + [[ -z "$p" ]] && p="$(ss -tnlp 2>/dev/null | awk '/sshd|"ssh"|ssh\.socket/{n=split($4,a,":"); print a[n]}' | _ports_csv)" + [[ -z "$p" ]] && p="$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/ {print $2}' /etc/ssh/sshd_config 2>/dev/null | _ports_csv)" + [[ "$p" =~ ^[0-9]+(,[0-9]+)*$ ]] || p="" echo "${p:-22}" } +# SSH_CONNECTION текущей сессии. `sudo` (env_reset) его вычищает — а README сам предлагает +# `sudo bash …`: без него не было ни авто-whitelist IP админа, ни защиты порта сессии, и +# об этом никто не узнавал. Ищем у предков процесса (root читает их environ). +ssh_conn_env() { + local pid="$$" n=0 v + if [[ -n "${SSH_CONNECTION:-}" ]]; then printf '%s' "$SSH_CONNECTION"; return 0; fi + while [[ "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 && "$n" -lt 16 ]]; do + if [[ -r "/proc/$pid/environ" ]]; then + v="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)" + [[ -n "$v" ]] && { printf '%s' "$v"; return 0; } + fi + pid="$(awk '/^PPid:/{print $2; exit}' "/proc/$pid/status" 2>/dev/null)" + n=$((n+1)) + done + return 1 +} + # Порт ЭТОГО сервера в текущей SSH-сессии. SSH_CONNECTION=" " — # сессия уже прошла через sshd, так что 4-е поле это ground truth, а не эвристика. # Пусто, если запущено не по SSH (консоль/cron). Нужен, чтобы файрвол не отрезал порт, # на котором ты прямо сейчас сидишь (протухший protect.conf / сменили порт / ошибка детекта). ssh_session_port() { - local p="${SSH_CONNECTION:-}" + local p + p="$(ssh_conn_env 2>/dev/null || true)" p="$(printf '%s' "$p" | awk '{print $4}')" [[ "$p" =~ ^[0-9]+$ ]] && (( p>=1 && p<=65535 )) && echo "$p" } @@ -295,34 +318,44 @@ import_pinned_key() { # IP клиента, с которого мы сейчас подключены по SSH (для авто-whitelist от самоблокировки). # ${VAR:-} обязателен: при запуске из консоли (не по SSH) переменных нет, а вызов идёт под set -u. ssh_client_ip() { - local ip="${SSH_CONNECTION:-}"; ip="${ip%% *}" + local ip; ip="$(ssh_conn_env 2>/dev/null || true)"; ip="${ip%% *}" [[ -z "$ip" ]] && { ip="${SSH_CLIENT:-}"; ip="${ip%% *}"; } # отфильтруем мусор/локалхост [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ || "$ip" == *:* ]] && [[ "$ip" != "127.0.0.1" && "$ip" != "::1" ]] && echo "$ip" } # ─── Порт node-agent (Remnawave node) ──────────────────────────────────────── -# Фактический порт node-агента этой ноды. Источники по приоритету (выигрывает первый, -# где нашлось; несколько контейнеров внутри источника → объединяем): -# 1. env работающих контейнеров образа remnawave/node* (NODE_PORT= / APP_PORT=); -# 2. .env compose-каталога контейнера (label working_dir), затем NA_REMNANODE_ENV -# (по умолчанию /opt/remnanode/.env) — ловит и временно остановленный контейнер; -# 3. ss: listening-порт процесса rw-node (бинарь node-агента 2.x). +# Фактический порт node-агента этой ноды. В правила input идут только порты, которые +# слушает ХОСТ, поэтому источники по приоритету (выигрывает первый, где нашлось): +# 1. ss: listening-порт процесса rw-node (бинарь node-агента 2.x) в сети хоста — факт; +# 2. контейнеры образа remnawave/node с network_mode: host — NODE_PORT=/APP_PORT= из env +# (ловит временно остановленный контейнер). Образ — из `.Config.Image` (inspect): при +# закреплении по digest `docker ps` показывает ID вместо имени, и регэксп его не ловил; +# bridge-контейнеры не в счёт — их порт внутри своего netns, на хост он опубликован +# DNAT'ом и в цепочку input не попадает (до v4.2 детект брал NODE_PORT контейнера +# ВТОРОГО тенанта на боксе и выдавал его за порт этой ноды); +# 3. .env compose-каталога такого контейнера, затем NA_REMNANODE_ENV (/opt/remnanode/.env). +# Несколько агентов на хосте (два тенанта с host-сетью) → все порты + warn у вызывающего. # echo: порт(ы) через запятую; пусто = определить не удалось. Read-only, best-effort. detect_node_port() { - local out="" p c d f cands="" + local out="" p c d f img net cands="" _np_add() { # 10#: ведущий ноль из чужого .env не должен читаться как октал (арифм. ошибка) [[ "$1" =~ ^[0-9]+$ ]] && (( 10#$1>=1 && 10#$1<=65535 )) || return 0 [[ ",$out," == *",$1,"* ]] || out+="${out:+,}$1" } + while read -r p; do _np_add "$p"; done < <( + ss -Htlnp 2>/dev/null | awk '/"rw-node"/{n=split($4,a,":"); print a[n]}' | sort -un) + [[ -n "$out" ]] && { unset -f _np_add; echo "$out"; return 0; } # docker-пайпы под `|| true`: protect живёт под set -e/pipefail — лежащий docker-демон - # не должен убивать детект раньше фолбэков на .env/ss + # не должен убивать детект раньше фолбэков на .env if command -v docker >/dev/null 2>&1; then - cands="$(docker ps --format '{{.Names}} {{.Image}}' 2>/dev/null \ - | awk '$2 ~ /remnawave\/node(:|$)/{print $1}' || true)" - # каноничное имя на случай кастом-образа/форка - [[ -z "$cands" ]] && docker inspect remnanode >/dev/null 2>&1 && cands="remnanode" + for c in $(docker ps -a --format '{{.Names}}' 2>/dev/null || true); do + img="$(docker inspect -f '{{.Config.Image}}' "$c" 2>/dev/null || true)" + [[ "$img" =~ remnawave/node([:@]|$) || "$c" == "${NA_NODE_CONTAINER:-remnanode}" ]] || continue + net="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$c" 2>/dev/null || true)" + [[ "$net" == host ]] && cands+="${cands:+ }$c" + done for c in $cands; do p="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$c" 2>/dev/null \ | awk -F= '$1=="NODE_PORT"||$1=="APP_PORT"{print $2; exit}' || true)" @@ -344,10 +377,6 @@ detect_node_port() { _np_add "${p:-}" fi fi - if [[ -z "$out" ]]; then - while read -r p; do _np_add "$p"; done < <( - ss -Htlnp 2>/dev/null | awk '/"rw-node"/{n=split($4,a,":"); print a[n]}' | sort -u) - fi unset -f _np_add [[ -n "$out" ]] && echo "$out" } @@ -411,6 +440,9 @@ conf_stale_defaults() { return 0 } +# conf_is_explicit — ключ в файле помечен как заданный оператором +conf_is_explicit() { [[ -f "$1" ]] && grep -qE "^# explicit: ${2}$" "$1" 2>/dev/null; } + # load_conf — подхватить сохранённый конфиг (no-op если файла нет), затем # доложить о замороженных дефолтах (или принять новые при NA_ADOPT_NEW_DEFAULTS=1). load_conf() { @@ -452,7 +484,9 @@ save_conf() { for k in "$@"; do v="${!k-}" case "$v" in - *'"'*|*'`'*|*'$'*|*'}'*|*$'\n'*) + # обратный слэш в конце значения экранировал бы закрывающую кавычку — и + # load_conf на следующем прогоне падал бы на синтаксисе всего файла + *'"'*|*'`'*|*'$'*|*'}'*|*'\'*|*$'\n'*) warn "node.conf: пропускаю $k (спецсимволы в значении)"; continue;; esac printf ': "${%s:=%s}"\n' "$k" "$v" @@ -464,6 +498,11 @@ save_conf() { echo "# explicit: $k" fi done <<< "$NA_DEFAULT_CHANGES" + # NA_EXPLICIT_KEYS — ключи вне таблицы дефолтов, которые вызывающий отметил как + # заданные оператором (напр. SSH_PORT из ENV/интерактива, а не из автодетекта) + for k in ${NA_EXPLICIT_KEYS:-}; do + [[ " $* " == *" $k "* ]] && echo "# explicit: $k" + done } > "$tmp" chmod 0600 "$tmp" mv -f "$tmp" "$f" diff --git a/scripts/protect.sh b/scripts/protect.sh index dac6c36..1e36cb8 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -57,12 +57,34 @@ fi BACKUP="$(backup_dir)" +# SSH_PORT задан оператором (ENV сейчас) — запоминаем ДО подхвата conf: после него ключ +# «задан» всегда, и отличить намерение оператора от сохранённого автодетекта уже нельзя. +NA_SSH_PORT_ENV="${SSH_PORT:-}" + # Подхватываем сохранённый конфиг ноды (если есть): ре-ран без ENV не сбрасывает -# поднятые под эту ноду ручки на дефолты. ENV по-прежнему всё переопределяет. +# поднятые под эту ноду ручки на дефолты. ENV по-прежнему всё переопределяет. Пустой ENV +# значит «как в conf» (обёртка с незаданной переменной не должна молча стереть whitelist); +# явное «пусто» — слово none (WHITELIST=none, UDP_PORTS=none, NODE_PORT=none). load_conf "$CONF_DIR/protect.conf" # ─── Параметры ─────────────────────────────────────────────────────────────── -SSH_PORT="${SSH_PORT:-$(detect_ssh_port)}" +# SSH_PORT: явный (ENV / помечен explicit в conf) — как есть. Сохранённый БЕЗ пометки — +# это автодетект прошлых версий (до v4.2 он персистился и на ре-ране побеждал новый +# детект: сменил порт sshd — strict открывал старый). Такой сверяем с фактом: совпал — +# дальше не храним; расходится — открываем ОБА и предупреждаем. +NA_SSH_DETECTED="$(detect_ssh_port)" +NA_EXPLICIT_KEYS="" +if [[ -n "$NA_SSH_PORT_ENV" ]] || conf_is_explicit "$CONF_DIR/protect.conf" SSH_PORT; then + NA_EXPLICIT_KEYS+=" SSH_PORT" +elif [[ -n "${SSH_PORT:-}" && "$SSH_PORT" != "$NA_SSH_DETECTED" ]]; then + warn "SSH_PORT=$SSH_PORT в protect.conf сохранён старой версией (автодетект), а sshd сейчас слушает $NA_SSH_DETECTED — открываю ОБА; закрепи нужный: SSH_PORT=<порт> ре-ран" + for _p in ${NA_SSH_DETECTED//,/ }; do [[ ",$SSH_PORT," == *",$_p,"* ]] || SSH_PORT+=",$_p"; done + NA_EXPLICIT_KEYS+=" SSH_PORT" + unset _p +else + SSH_PORT="" +fi +SSH_PORT="${SSH_PORT:-$NA_SSH_DETECTED}" TCP_PORTS="${TCP_PORTS:-443,2087}" UDP_PORTS="${UDP_PORTS:-443,2087}" # Порт(ы) node-agent. 'auto' (дефолт) = взять с самой ноды: env работающего контейнера @@ -171,6 +193,22 @@ NA_CTG_LIVE_FLOOR="${NA_CTG_LIVE_FLOOR:-2}" # ≤ столько живы NA_CTG_COARSE_MULT="${NA_CTG_COARSE_MULT:-3}" # дамп conntrack только если ct ≥ ss×N NA_CTG_BANTIME="${NA_CTG_BANTIME:-15m}" NA_CTG_INTERVAL="${NA_CTG_INTERVAL:-20s}" +# Где действуют блок-листы CrowdSec. ssh (дефолт с v4.2) — только SSH-порт: community-лист +# CAPI (~25k адресов) на ВСЕХ портах отрезал целые пулы мобильных абонентов за CGNAT (адрес +# попал в лист за чужой перебор — аудит флота: сотни тысяч дропов клиентского трафика на +# ноде, где это не сузили руками). all — как раньше, на всех портах (bouncer сам ставит +# правила). CrowdSec на ноде парсит только журнал sshd, так что его собственные решения — +# это переборщики SSH: для них SSH-порт и есть нужная область. +CROWDSEC_SCOPE="${CROWDSEC_SCOPE:-ssh}" +# Опубликованные Docker-порты (bridge, DNAT) идут через hook forward и цепочку input не +# проходят: до v4.2 на них не действовали ни autoban, ни блок-листы, ни анти-спуф. 1 (дефолт) +# — в forward для НОВЫХ DNAT-соединений те же вердикты по источнику, что в input +# (whitelist → пропуск, autoban/blocklist/bogon → drop). 0 — forward не трогаем. +DNAT_GUARD="${DNAT_GUARD:-1}" +# Анти-амплификация: новые UDP на сервисные порты с исходных портов отражателей (DNS, NTP, +# SSDP, memcached, CLDAP, chargen) — это отражённый флуд, а не клиенты: per-IP лимиты против +# тысяч отражателей бессильны. Ответы на свои запросы ноды — established, их не касается. +UDP_AMP_DROP="${UDP_AMP_DROP:-1}" # 3x-ui на этой машине? У него панель + inbound-порты создаются динамически — # strict-файрвол молча отрежет всё, чего нет в TCP_PORTS/UDP_PORTS. Детект по @@ -201,6 +239,7 @@ if [[ -t 0 && -z "${REMNAWAVE_NONINTERACTIVE:-}" && "$DRY_RUN" != "1" && "${CROW esac if [[ "$FW_MODE" != "skip" ]]; then read -rp "SSH порт [$SSH_PORT]: " _v && SSH_PORT="${_v:-$SSH_PORT}" + [[ -n "${_v:-}" ]] && NA_EXPLICIT_KEYS+=" SSH_PORT" read -rp "TCP порты сервиса (через ,) [$TCP_PORTS]: " _v && TCP_PORTS="${_v:-$TCP_PORTS}" read -rp "UDP порты сервиса (через ,) [$UDP_PORTS]: " _v && UDP_PORTS="${_v:-$UDP_PORTS}" # node-agent порт — понятие Remnawave; в open-режиме его правила не ставятся @@ -211,6 +250,14 @@ fi # Неинтерактивно и без явного FW_MODE — strict (прежнее поведение не меняется). [[ -z "$FW_MODE" ]] && FW_MODE=strict +# none = осознанно пусто (пустой ENV — «как в conf», см. load_conf выше). В conf уходит само +# слово none — иначе на следующем прогоне вернулся бы встроенный дефолт (UDP_PORTS → 443,2087). +NA_NONE_KEYS="" +for _k in TCP_PORTS UDP_PORTS WHITELIST UDP_BULK_PORTS NODE_PORT_PEERS; do + if [[ "${!_k:-}" == none ]]; then NA_NONE_KEYS+=" $_k"; printf -v "$_k" '%s' ""; fi +done +unset _k + # ─── Валидация ─────────────────────────────────────────────────────────────── _is_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( $1>=1 && $1<=65535 )); } validate_port_list() { @@ -222,7 +269,7 @@ validate_port_list() { # 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 +[[ "$NODE_PORT" == "auto" || "$NODE_PORT" == "none" ]] || 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 # Список, а не число: валидировать его как uint — значит уронить любой прогон с дефолтом @@ -252,13 +299,17 @@ 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 CROWDSEC_STRICT; do + ENABLE_BLOCKLISTS BLOCK_TOR ENABLE_CTGUARD NA_CTG_ENFORCE CROWDSEC_STRICT DNAT_GUARD UDP_AMP_DROP; 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; } [[ "$NODE_PORT_AUTOWL" =~ ^(auto|0|1)$ ]] || { err "NODE_PORT_AUTOWL должно быть auto|0|1"; exit 1; } [[ "$FLEET_SYNC" =~ ^(auto|0|1)$ ]] || { err "FLEET_SYNC должно быть auto|0|1"; exit 1; } [[ "$FW_MODE" =~ ^(strict|open|skip)$ ]] || { err "FW_MODE='$FW_MODE' — ожидается strict|open|skip"; exit 1; } +[[ "$CROWDSEC_SCOPE" =~ ^(ssh|all)$ ]] || { err "CROWDSEC_SCOPE='$CROWDSEC_SCOPE' — ожидается ssh|all"; exit 1; } +# Сейфти короче 30с снёс бы правила раньше, чем оператор успеет проверить вход (0 — сразу, +# наперегонки с nft -f и включением автозагрузки). +(( SAFETY_DELAY >= 30 )) || { err "SAFETY_DELAY=$SAFETY_DELAY — минимум 30 секунд"; exit 1; } if [[ -n "$REMNAWAVE_URL" && ! "$REMNAWAVE_URL" =~ ^https?://[A-Za-z0-9._~:/?#=%@-]+$ ]]; then err "REMNAWAVE_URL='$REMNAWAVE_URL' — ожидается http(s)://… без спецсимволов"; exit 1 fi @@ -374,6 +425,10 @@ ADMIN_IP="$(ssh_client_ip || true)" if [[ -n "$ADMIN_IP" ]]; then add_wl "$ADMIN_IP" auto || true info "Авто-whitelist твоего SSH-IP: $ADMIN_IP (защита от самоблокировки)" +elif [[ -t 0 && "$FW_MODE" != "skip" && "$DRY_RUN" != "1" ]]; then + # Сессия есть (терминал), а SSH_CONNECTION не нашли ни у себя, ни у предков: screen/tmux, + # su без -l, нестандартный sudo. Молчать нельзя — защиты от самоблокировки сейчас нет. + warn "SSH-сессию не вижу (нет SSH_CONNECTION у процесса и предков: screen/tmux/su?) — твой IP НЕ добавлен в whitelist и порт сессии не закреплён. Проверь SSH_PORT и доступ из второго окна до снятия сейфти." fi # ─── Зависимости ───────────────────────────────────────────────────────────── @@ -445,6 +500,117 @@ if [[ "${CROWDSEC_PROBE:-0}" == "1" ]]; then exit 0 fi +# ─── Область действия блок-листов CrowdSec (CROWDSEC_SCOPE) ───────────────────── +# ssh: bouncer только наполняет наборы (set-only), а правило, которое по ним дропает, ставим +# мы — на SSH-порт(ы), с исключением для whitelist (адрес админа/панели может оказаться в +# community-листе: whitelist парсера CrowdSec от решений CAPI не защищает). Таблицы и наборы +# — те, что прописаны в конфиге bouncer'а; файл грузит na-crowdsec-scope.service ДО +# bouncer'а. all: прежнее поведение — правила ставит сам bouncer, на всех портах. +CS_BOUNCER_YAML="${CS_BOUNCER_YAML:-/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml}" +CS_SCOPE_NFT="$CONF_DIR/na-crowdsec-scope.nft" +CS_SCOPE_UNIT="/etc/systemd/system/na-crowdsec-scope.service" +CROWDSEC_SCOPE_EFF="" +_cs_yaml_get() { # _cs_yaml_get <ключ верхнего уровня> | <ключ> — значение из yaml bouncer'а + if [[ $# -eq 1 ]]; then + awk -F': *' -v k="$1" '$1==k{gsub(/[" ]/,"",$2); print $2; exit}' "$CS_BOUNCER_YAML" 2>/dev/null + else + awk -v fam="$1" -v k="$2" ' + /^nftables:/ {in_nft=1; next} + in_nft && /^[^ ]/ {in_nft=0} + in_nft && $1==fam":" {in_fam=1; next} + in_nft && in_fam && /^ [a-z]/ && $1!=k":" && $1 ~ /^(ipv4|ipv6):$/ {in_fam=0} + in_nft && in_fam && $1==k":" {v=$2; gsub(/"/,"",v); print v; exit}' "$CS_BOUNCER_YAML" 2>/dev/null + fi +} +crowdsec_scope_apply() { + local want="$CROWDSEC_SCOPE" t4 t6 s4 s6 wl4="" wl6="" legacy=crowdsec-nft-scope.service + [[ -f "$CS_BOUNCER_YAML" ]] || { info "CrowdSec scope: конфига bouncer'а нет ($CS_BOUNCER_YAML) — пропуск"; return 0; } + if [[ "$want" == ssh ]] && ! grep -qE '^[[:space:]]+set-only:' "$CS_BOUNCER_YAML"; then + warn "CrowdSec scope: bouncer без опции set-only (старая версия) — блок-листы остаются на ВСЕХ портах (как CROWDSEC_SCOPE=all)" + want=all + fi + backup_file "$CS_BOUNCER_YAML" "$BACKUP" + if [[ "$want" == all ]]; then + sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*true[[:space:]]*$/\1 false/' "$CS_BOUNCER_YAML" && rm -f "$CS_BOUNCER_YAML.na-tmp" + if [[ -f "$CS_SCOPE_UNIT" ]]; then + systemctl disable na-crowdsec-scope.service >/dev/null 2>&1 || true + rm -f "$CS_SCOPE_UNIT" "$CS_SCOPE_NFT" + systemctl daemon-reload 2>/dev/null || true + fi + systemctl is-enabled --quiet "$legacy" 2>/dev/null \ + && warn "CrowdSec scope: найден ручной $legacy (сужает блок-листы до SSH) — CROWDSEC_SCOPE=all его не трогает; сними сам, если нужно" + systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true + CROWDSEC_SCOPE_EFF=all + info "CrowdSec: блок-листы действуют на ВСЕХ портах (CROWDSEC_SCOPE=all)" + return 0 + fi + t4="$(_cs_yaml_get ipv4 table)"; t4="${t4:-crowdsec}" + t6="$(_cs_yaml_get ipv6 table)"; t6="${t6:-crowdsec6}" + s4="$(_cs_yaml_get blacklists_ipv4)"; s4="${s4:-crowdsec-blacklists}" + s6="$(_cs_yaml_get blacklists_ipv6)"; s6="${s6:-crowdsec6-blacklists}" + # строки с меткой na-wl4/na-wl6 переписывает `na-fw allow` — не трогать руками + wl4=" # na-wl4 (whitelist пуст)"; wl6=" # na-wl6 (whitelist пуст)" + [[ -n "$WL4" ]] && wl4=" ip saddr { $WL4 } return # na-wl4" + [[ -n "$WL6" ]] && wl6=" ip6 saddr { $WL6 } return # na-wl6" + cat > "$CS_SCOPE_NFT.new" </dev/null; then + rm -f "$CS_SCOPE_NFT.new" + warn "CrowdSec scope: сгенерированные правила не прошли nft -c — оставляю как было" + return 0 + fi + mv -f "$CS_SCOPE_NFT.new" "$CS_SCOPE_NFT" + sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*false[[:space:]]*$/\1 true/' "$CS_BOUNCER_YAML" && rm -f "$CS_BOUNCER_YAML.na-tmp" + cat > "$CS_SCOPE_UNIT" </dev/null; then + systemctl disable "$legacy" >/dev/null 2>&1 || true + info "CrowdSec scope: ручной $legacy выключен из автозагрузки — его роль теперь у na-crowdsec-scope.service (файл оставлен)" + fi + systemctl daemon-reload 2>/dev/null || true + systemctl enable na-crowdsec-scope.service >/dev/null 2>&1 || true + nft -f "$CS_SCOPE_NFT" 2>/dev/null || warn "CrowdSec scope: nft -f $CS_SCOPE_NFT не прошёл" + # bouncer в set-only заполняет уже существующие наборы — перезапуск после таблиц + systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true + CROWDSEC_SCOPE_EFF=ssh + ok "CrowdSec: блок-листы — только на SSH (:${SSH_EFF}); whitelist исключён; клиентские порты их не видят (CROWDSEC_SCOPE=all — как раньше)" +} + # ─── Сейфти-таймер: если потеряем SSH — снести нашу таблицу через N сек ─────── # Действие сработавшей подстраховки. Снимает и живую таблицу, И автозагрузку правил. # Почему второе обязательно: раньше сейфти удалял ТОЛЬКО таблицу, а na-firewall.service @@ -509,7 +675,9 @@ print_fw_howto() { echo " nft add rule inet my_fw input meta l4proto { icmp, ipv6-icmp } accept" echo " nft add rule inet my_fw input tcp dport { 22, 443 } accept # СНАЧАЛА впиши свой SSH-порт!" echo " nft add rule inet my_fw input udp dport { 443 } accept" - echo " Персист через reboot: nft list ruleset > /etc/nftables.conf && systemctl enable nftables" + echo " Персист через reboot: положи ТОЛЬКО свою таблицу в файл (nft list table inet my_fw > /etc/nftables.d/my_fw.nft)" + echo " и грузи его своим юнитом. НЕ делай 'nft list ruleset > /etc/nftables.conf': туда уедут" + echo " таблицы CrowdSec/Docker, а дефолтный nftables.service начинает с 'flush ruleset'." echo " C) Или ufw: ufw default deny incoming && ufw allow 22/tcp && ufw allow 443 && ufw enable" } if [[ "$FW_MODE" == "skip" ]]; then @@ -538,6 +706,101 @@ if [[ "$FW_MODE" == "skip" ]]; then fi fi +# ─── CrowdSec + firewall-bouncer ───────────────────────────────────────────── +# Стоит ДО файрвола: установка CrowdSec — самая долгая сетевая операция прогона, и раньше +# она шла уже под взведённым сейфти — таймер мог сработать посреди прогона, а на «y» скрипт +# всё равно печатал «защита активна». В DRY_RUN не ставим ничего. +if [[ "$ENABLE_CROWDSEC" == "1" && "$DRY_RUN" != "1" ]]; then + title "CrowdSec + nftables firewall-bouncer" + if ! command -v cscli >/dev/null 2>&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. Осознанный компромисс: + # достаточно СДЕЛАТЬ 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 + fi + if command -v cscli >/dev/null 2>&1; then + systemctl enable --now crowdsec >/dev/null 2>&1 || true + sleep 2 + cscli collections install crowdsecurity/sshd crowdsecurity/linux >/dev/null 2>&1 || true + + # whitelist админа/панели в самом CrowdSec — чтобы IPS их не банил. + # Ключи ip:/cidr: пишем ТОЛЬКО при наличии записей (пустые ключи валят парсер). + mkdir -p /etc/crowdsec/parsers/s02-enrich + # Источник — УЖЕ разобранные WL4/WL6 (дедуп + нормализация /32,/128), а не сырой + # WHITELIST: иначе дубликат из CSV оператора попадал сюда третьим экземпляром, + # мимо чистки, сделанной для nft-сета и na_filter.nft (issue #38). ADMIN_IP тут + # отдельно не нужен — add_wl уже влил его в WL4/WL6. + IP_ITEMS=""; CIDR_ITEMS="" + for x in ${WL4//,/ } ${WL6//,/ }; do + [[ -z "$x" ]] && continue + if [[ "$x" == */* ]]; then CIDR_ITEMS+=" - \"$x\""$'\n'; else IP_ITEMS+=" - \"$x\""$'\n'; fi + done + if [[ -n "$IP_ITEMS$CIDR_ITEMS" ]]; then + { + echo "name: node-accelerator/whitelist" + echo "description: never ban admin/panel" + echo "whitelist:" + echo " reason: node-accelerator trusted" + [[ -n "$IP_ITEMS" ]] && { echo " ip:"; printf "%s" "$IP_ITEMS"; } + [[ -n "$CIDR_ITEMS" ]] && { echo " cidr:"; printf "%s" "$CIDR_ITEMS"; } + } > /etc/crowdsec/parsers/s02-enrich/na-whitelist.yaml + else + rm -f /etc/crowdsec/parsers/s02-enrich/na-whitelist.yaml + fi + + # источник логов sshd через journald (на системах без /var/log/auth.log) + mkdir -p /etc/crowdsec/acquis.d + cat > /etc/crowdsec/acquis.d/na-sshd.yaml <<'ACQ' +source: journalctl +journalctl_filter: + - "_SYSTEMD_UNIT=ssh.service" +labels: + type: syslog +--- +source: journalctl +journalctl_filter: + - "_SYSTEMD_UNIT=sshd.service" +labels: + type: syslog +ACQ + systemctl reload crowdsec >/dev/null 2>&1 || systemctl restart crowdsec >/dev/null 2>&1 || true + + # firewall-bouncer (nftables-режим): своя таблица crowdsec/crowdsec6, priority -10 + if ! dpkg -s crowdsec-firewall-bouncer-nftables >/dev/null 2>&1; then + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq crowdsec-firewall-bouncer-nftables >/dev/null 2>&1 \ + || warn "bouncer не установился" + fi + systemctl enable --now crowdsec-firewall-bouncer >/dev/null 2>&1 || true + crowdsec_scope_apply + + # опциональный enroll в Console + if [[ -n "${CROWDSEC_ENROLL_KEY:-}" ]]; then + cscli console enroll "$CROWDSEC_ENROLL_KEY" >/dev/null 2>&1 \ + && { systemctl reload crowdsec >/dev/null 2>&1 || true; ok "enroll в CrowdSec Console отправлен"; } \ + || warn "enroll не прошёл (проверь ключ)" + fi + + if systemctl is-active --quiet crowdsec && systemctl is-active --quiet crowdsec-firewall-bouncer; then + ok "CrowdSec + bouncer активны (community-блоклист + поведенческий бан)" + else + warn "CrowdSec/bouncer установлены, но сервис не active — проверь: cscli metrics" + fi + fi +elif [[ "$ENABLE_CROWDSEC" != "1" ]]; then + info "ENABLE_CROWDSEC=0 — CrowdSec пропущен" +fi + + # fleet-sync живёт в сетах таблицы na_filter → при FW_MODE=skip невозможен. FLEET_ON=0 if [[ "$FW_MODE" != "skip" ]]; then @@ -562,7 +825,15 @@ if [[ "$FW_MODE" != "skip" ]]; then # явный порт ≠ детекту → правила на ОБА + громкий warn (кейс миграции агента 2222→3000: # сохранённый conf держал 2222, strict ронял :3000 в catch-all drop). NP_DETECTED="$(detect_node_port || true)" -if [[ "$NODE_PORT" == "auto" ]]; then +if [[ "$NP_DETECTED" == *,* ]]; then + warn "node-agent: на хосте несколько агентов с host-сетью (порты $NP_DETECTED) — правила node-порта ставлю на все; если часть не твоя — закрепи NODE_PORT=<порт>" +fi +if [[ "$NODE_PORT" == "none" ]]; then + # Явный отказ от правил node-порта (бокс без агента: панель, CDN-origin). Фолбэк на + # 2222,3000 там открывал бы миру два порта, которые никто не слушает. + NP_EFF="" + info "NODE_PORT=none: правила node-порта не ставятся" +elif [[ "$NODE_PORT" == "auto" ]]; then if [[ -n "$NP_DETECTED" ]]; then NP_EFF="$NP_DETECTED" ok "node-agent: автодетект порта → $NP_EFF" @@ -652,12 +923,21 @@ for p in ${UDP_PORTS//,/ }; do fi UDP_RULES+=" # порт ${p}/udp: per-IP rate (QUIC/Hysteria2/TUIC) — ${_ukind} - udp dport ${p} update @udp4_${p} { ip saddr limit rate over ${_urate}/second burst ${_uburst} packets } counter drop - udp dport ${p} update @udp6_${p} { ip6 saddr limit rate over ${_urate}/second burst ${_uburst} packets } counter drop + udp dport ${p} update @udp4_${p} { ip saddr limit rate over ${_urate}/second burst ${_uburst} packets } counter name c_udpflood drop + udp dport ${p} update @udp6_${p} { ip6 saddr limit rate over ${_urate}/second burst ${_uburst} packets } counter name c_udpflood drop udp dport ${p} accept" rate_set "udp4_${p}" ipv4_addr "$_urate" "$_uburst" second rate_set "udp6_${p}" ipv6_addr "$_urate" "$_uburst" second done +# анти-амплификация: новые UDP на сервисные порты с исходных портов типичных отражателей +# (chargen 19, qotd 17, DNS 53, NTP 123, CLDAP 389, SSDP 1900, memcached 11211) — отражённый +# флуд, а не клиенты (их исходный порт — эфемерный). Стоит ДО per-IP лимитов: против тысяч +# отражателей они бессильны. Ответы на запросы самой ноды — established, сюда не доходят. +UDP_AMP="" +if [[ "$UDP_AMP_DROP" == "1" && -n "$UDP_PORTS" ]]; then + UDP_AMP=" # анти-амплификация (UDP_AMP_DROP=0 — выключить) + udp sport { 17, 19, 53, 123, 389, 1900, 11211 } udp dport { ${UDP_PORTS//,/, } } ct state new counter name c_udp_amp drop" +fi # Порт в bulk-списке, но не в UDP_PORTS — правило для него не сгенерится вообще: молчать нельзя. for p in ${UDP_BULK_PORTS//,/ }; do [[ -z "$p" ]] && continue @@ -667,10 +947,13 @@ done # anti-spoofing (только на WAN-интерфейсе) ANTISPOOF="" if [[ -n "$WAN" ]]; then + # DHCP-ответы (v4 и v6) — до анти-спуфа: в strict их отрезал бы catch-all, и на хосте со + # stateful DHCPv6 адрес/маршрут v6 пропадали бы с истечением аренды ANTISPOOF=" # anti-spoofing: приватные/bogon источники на WAN = спуф udp sport 67 udp dport 68 accept - iifname \"${WAN}\" ip saddr @bogon_v4 drop - iifname \"${WAN}\" ip6 saddr @bogon_v6 drop" + ip6 saddr fe80::/10 udp sport 547 udp dport 546 accept + iifname \"${WAN}\" ip saddr @bogon_v4 counter name c_bogon drop + iifname \"${WAN}\" ip6 saddr @bogon_v6 counter name c_bogon drop" fi # node-agent порт: whitelist-only (drop мир) или мягкий per-IP лимит для неизвестных. @@ -712,7 +995,9 @@ add_npwl() { # как add_wl, но в сет только-node-порта; б done } NP_SETS="" -if [[ "$FW_MODE" == "open" ]]; then +if [[ -z "$NP_EFF" ]]; then + NODE_RULES="" # NODE_PORT=none +elif [[ "$FW_MODE" == "open" ]]; then NODE_RULES="" [[ "$NODE_PORT_WHITELIST_ONLY" == "1" ]] && \ info "FW_MODE=open: node-port правила не ставятся — NODE_PORT_WHITELIST_ONLY не действует (на 3x-ui порт(ы) ${NP_EFF} могут быть inbound'ом)" @@ -746,7 +1031,7 @@ elif [[ "$NODE_PORT_WHITELIST_ONLY" == "1" ]]; then # Пожарно пустить панель без ре-рана: nft add element inet na_filter na_nodeport_wl_v4 '{ }' tcp dport { ${NP_NFT} } ip saddr @na_nodeport_wl_v4 accept tcp dport { ${NP_NFT} } ip6 saddr @na_nodeport_wl_v6 accept - tcp dport { ${NP_NFT} } ct state new drop" + tcp dport { ${NP_NFT} } ct state new counter name c_nodeport drop" info "node-agent порт(ы) ${NP_EFF}: whitelist-only (WHITELIST задан)" if [[ "$NP_AUTOWL_ON" == "1" && -n "$NODE_PORT_PEERS" ]]; then ok "node-agent: авто-допуск established-пиров (панель): $NODE_PORT_PEERS (сет na_nodeport_wl_*; выкл: NODE_PORT_AUTOWL=0)" @@ -757,8 +1042,8 @@ elif [[ "$NODE_PORT_WHITELIST_ONLY" == "1" ]]; then fi else NODE_RULES=" # node-agent: whitelist (выше) + мягкий per-IP лимит для неизвестных - tcp dport { ${NP_NFT} } ct state new update @na4 { ip saddr limit rate over 30/second burst 60 packets } counter drop - tcp dport { ${NP_NFT} } ct state new update @na6 { ip6 saddr limit rate over 30/second burst 60 packets } counter drop + tcp dport { ${NP_NFT} } ct state new update @na4 { ip saddr limit rate over 30/second burst 60 packets } counter name c_nodeport drop + tcp dport { ${NP_NFT} } ct state new update @na6 { ip6 saddr limit rate over 30/second burst 60 packets } counter name c_nodeport drop tcp dport { ${NP_NFT} } ct state new accept" rate_set na4 ipv4_addr 30 60 second rate_set na6 ipv6_addr 30 60 second @@ -768,32 +1053,55 @@ fi # 1-й быстрый скан → suspect (наблюдение, БЕЗ полного бана: скан-пакеты и так дропает # финальный catch-all, но легит-трафик IP не режется), повторный в окне SUSPECT_TIME → # confirmed-бан. Снимает ложные баны целых CGNAT-операторов из-за одного шального скана. -PORTSCAN="" +PORTSCAN=""; PORTSCAN_CHAINS="" if [[ "$ENABLE_PORTSCAN_BAN" == "1" && "$FW_MODE" != "open" ]]; then - # Единственное лог-правило тулкита, которое пишет в журнал в ШТАТНОМ режиме: оно - # стоит первым в анти-скан-блоке и матчит КАЖДЫЙ новый SYN, дошедший сюда (то есть - # любой стук в закрытый порт), а не превышение лимита. Поэтому именно его рейт вынесен - # в ручку и понижен до PORTSCAN_LOG_RATE/минуту (issue #35). Остальные лог-правила - # ([na synflood], [na ssh-flood], [na badflags]) оставлены на 5/second сознательно: - # они стоят ПОСЛЕ accept'ов с лимитом и срабатывают только сверх порога / на битых - # флагах, то есть в норме молчат и объёма в журнале не создают. - _ps_log4="# лог анти-скана выключен (PORTSCAN_LOG_RATE=0) — бан считают meter'ы ниже, не лог" + # Лог анти-скана — ТОЛЬКО на переходе адреса в suspect/бан (цепочки ps_suspect*/ps_ban*). + # До v4.2 лог-правило стояло первым и писало КАЖДЫЙ новый SYN в закрытый порт: одиночные + # стуки интернет-шума в 23/445/3389 давали 70–99% всех строк журнала (аудит флота), а + # настоящие сканеры среди них терялись. Теперь в журнал попадает адрес, который превысил + # порог, — один раз при пометке suspect и один раз при бане; PORTSCAN_LOG_RATE — общий + # потолок этих строк в минуту (0 = не логировать; бан от лога не зависит). Объём всего + # отброшенного сканом — в счётчиках (c_catchall, c_portscan), а не в строках журнала. + _ps_log="# лог анти-скана выключен (PORTSCAN_LOG_RATE=0) — бан и счётчики работают и без него" [[ "$PORTSCAN_LOG_RATE" != "0" ]] && \ - _ps_log4="meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new limit rate ${PORTSCAN_LOG_RATE}/minute burst ${PORTSCAN_LOG_BURST} packets log prefix \"[na portscan] \" level info" + _ps_log="limit rate ${PORTSCAN_LOG_RATE}/minute burst ${PORTSCAN_LOG_BURST} packets log prefix \"[na portscan] \" level info" + _syn="tcp flags & (fin|syn|rst|ack) == syn ct state new" + PORTSCAN_CHAINS=" # адрес превысил порог скана → бан ${PORTSCAN_BAN_TIME} (лог — один раз, дальше его режет autoban) + chain ps_ban4 { + add @autoban_v4 { ip saddr timeout ${PORTSCAN_BAN_TIME} } + $_ps_log + counter name c_portscan drop + } + chain ps_ban6 { + add @autoban_v6 { ip6 saddr timeout ${PORTSCAN_BAN_TIME} } + $_ps_log + counter name c_portscan drop + }" if [[ "$ENABLE_BANONCE" == "1" ]]; then + PORTSCAN_CHAINS+=" + # первый быстрый скан → suspect на ${SUSPECT_TIME} (без бана; сам пакет дропнет catch-all); + # уже suspect — без повторного лога + chain ps_suspect4 { + ip saddr @suspect_v4 return + add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } + $_ps_log + } + chain ps_suspect6 { + ip6 saddr @suspect_v6 return + add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} } + $_ps_log + }" PORTSCAN=" # ANTI-SCAN (ban-once): 1-й быстрый скан → suspect, 2-й в окне ${SUSPECT_TIME} → бан. - $_ps_log4 # уже suspect и снова бьёт быстрее порога → confirmed-бан - meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new ip saddr @suspect_v4 update @psc4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v4 { ip saddr timeout ${PORTSCAN_BAN_TIME} } drop - meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new ip6 saddr @suspect_v6 update @psc6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v6 { ip6 saddr timeout ${PORTSCAN_BAN_TIME} } drop - # ещё не suspect и бьёт быстрее порога → пометить suspect (без бана; скан дропнет catch-all) - meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } - meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} }" + meta nfproto ipv4 $_syn ip saddr @suspect_v4 update @psc4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } jump ps_ban4 + meta nfproto ipv6 $_syn ip6 saddr @suspect_v6 update @psc6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } jump ps_ban6 + # ещё не suspect и бьёт быстрее порога → пометить suspect + meta nfproto ipv4 $_syn update @ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } jump ps_suspect4 + meta nfproto ipv6 $_syn update @ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } jump ps_suspect6" else PORTSCAN=" # ANTI-SCAN: бьёт по закрытым портам быстрее ${PORTSCAN_RATE}/min → бан ${PORTSCAN_BAN_TIME}. - $_ps_log4 - meta nfproto ipv4 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v4 { ip saddr timeout ${PORTSCAN_BAN_TIME} } drop - meta nfproto ipv6 tcp flags & (fin|syn|rst|ack) == syn ct state new update @ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } add @autoban_v6 { ip6 saddr timeout ${PORTSCAN_BAN_TIME} } drop" + meta nfproto ipv4 $_syn update @ps4 { ip saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } jump ps_ban4 + meta nfproto ipv6 $_syn update @ps6 { ip6 saddr limit rate over ${PORTSCAN_RATE}/minute burst ${PORTSCAN_BURST} packets } jump ps_ban6" fi fi # наборы анти-скана — вне блока выше: tests/protect-unit.sh исполняет его отдельно @@ -835,7 +1143,16 @@ if [[ "$ENABLE_SYNPROXY" == "1" ]]; then type filter hook prerouting priority -300; policy accept; fib daddr type local tcp dport { ${SP_SET} } tcp flags syn notrack }" - SYNPROXY_IN=" tcp dport { ${SP_SET} } ct state invalid,untracked synproxy mss ${SP_MSS} wscale 7 timestamp sack-perm" + # Место правила — ДО `ct state invalid drop`: при nf_conntrack_tcp_loose=0 (обязателен для + # synproxy, ставится ниже) третий ACK рукопожатия conntrack видит как invalid, и до + # v4.2 его съедал invalid-drop раньше synproxy — новые TCP на TCP_PORTS не + # устанавливались вовсе (а при loose=1 ACK шёл мимо synproxy в сокет и получал RST). + # Забаненных режем раньше: иначе synproxy дописал бы им рукопожатие, а дальше + # established прошёл бы мимо autoban. + SYNPROXY_IN=" # SYNPROXY (до invalid-drop; забаненные — раньше него) + tcp dport { ${SP_SET} } ip saddr @autoban_v4 counter name c_autoban drop + tcp dport { ${SP_SET} } ip6 saddr @autoban_v6 counter name c_autoban drop + tcp dport { ${SP_SET} } ct state invalid,untracked synproxy mss ${SP_MSS} wscale 7 timestamp sack-perm" ok "SYNPROXY: ядро $(uname -r) ок, mss ${SP_MSS} wscale 7 (notrack только host-local)" else warn "SYNPROXY запрошен, но недоступен (нужно ядро ≥5.14 + модуль nf_synproxy). Защита БЕЗ synproxy." @@ -857,8 +1174,8 @@ if [[ "$ENABLE_BLOCKLISTS" == "1" ]]; then BLOCKLIST_SETS=" set blocklist_v4 { type ipv4_addr; flags interval; auto-merge; } set blocklist_v6 { type ipv6_addr; flags interval; auto-merge; }" BLOCKLIST_DROP=" # статич-блоклисты (Spamhaus DROP / FireHOL L1 [/ Tor]) — обновляет na-blocklist-update - ip saddr @blocklist_v4 drop - ip6 saddr @blocklist_v6 drop" + ip saddr @blocklist_v4 counter name c_blocklist drop + ip6 saddr @blocklist_v6 counter name c_blocklist drop" fi # fleet-сеты (наполняет na-fleet-sync с панели Remnawave) + accept сразу после whitelist. @@ -887,26 +1204,36 @@ SSH_RULES=" # SSH connect-flood: >${SSH_RATE}/мин новых с одн tcp dport { ${SSH_NFT} } ct state new ip6 saddr @ssh6 accept # набор переполнен (флуд со множества адресов) — общий потолок, без бана tcp dport { ${SSH_NFT} } ct state new limit rate 30/minute burst 30 packets accept - tcp dport { ${SSH_NFT} } ct state new counter drop" + tcp dport { ${SSH_NFT} } ct state new counter name c_sshflood drop" rate_set ssh4 ipv4_addr "$SSH_RATE" "$SSH_BURST" minute rate_set ssh6 ipv6_addr "$SSH_RATE" "$SSH_BURST" minute if [[ "$ENABLE_BANONCE" == "1" ]]; then - SSH_FLOOD_CHAIN=" # SSH сверх лимита (ban-once): 1-й раз suspect+drop, повтор в окне ${SUSPECT_TIME} → бан ${SSH_BAN_TIME} + # Ban-once по-настоящему: «2-й раз» — это повторный ПЕРЕБОР, а не следующий пакет. До + # v4.2 любой SYN сверх лимита от уже-suspect адреса банил на SSH_BAN_TIME — а клиент, + # которому дропнули SYN, сам перешлёт его через ~1 с: CI/Ansible, открывшие на одно + # соединение больше лимита, ловили суточный бан с первого превышения. Теперь suspect + # банится, только когда его пакеты сверх лимита сами превышают втрое больший burst. + _ssh_esc_burst=$(( SSH_BURST * 3 )) + SSH_FLOOD_CHAIN=" # SSH сверх лимита (ban-once): 1-й раз suspect+drop; повторный перебор в окне ${SUSPECT_TIME} → бан ${SSH_BAN_TIME} chain ssh_flood { limit rate 5/second log prefix \"[na ssh-flood] \" level warn - ip saddr @suspect_v4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop - ip6 saddr @suspect_v6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop - meta nfproto ipv4 add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } drop - meta nfproto ipv6 add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} } drop - counter drop + ip saddr @suspect_v4 update @sshs4 { ip saddr limit rate over ${SSH_RATE}/minute burst ${_ssh_esc_burst} packets } add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } counter name c_sshflood drop + ip6 saddr @suspect_v6 update @sshs6 { ip6 saddr limit rate over ${SSH_RATE}/minute burst ${_ssh_esc_burst} packets } add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } counter name c_sshflood drop + ip saddr @suspect_v4 counter name c_sshflood drop + ip6 saddr @suspect_v6 counter name c_sshflood drop + meta nfproto ipv4 add @suspect_v4 { ip saddr timeout ${SUSPECT_TIME} } counter name c_sshflood drop + meta nfproto ipv6 add @suspect_v6 { ip6 saddr timeout ${SUSPECT_TIME} } counter name c_sshflood drop + counter name c_sshflood drop }" + rate_set sshs4 ipv4_addr "$SSH_RATE" "$_ssh_esc_burst" minute + rate_set sshs6 ipv6_addr "$SSH_RATE" "$_ssh_esc_burst" minute else SSH_FLOOD_CHAIN=" # SSH сверх лимита: бан ${SSH_BAN_TIME} chain ssh_flood { limit rate 5/second log prefix \"[na ssh-flood] \" level warn - meta nfproto ipv4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } drop - meta nfproto ipv6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } drop - counter drop + meta nfproto ipv4 add @autoban_v4 { ip saddr timeout ${SSH_BAN_TIME} } counter name c_sshflood drop + meta nfproto ipv6 add @autoban_v6 { ip6 saddr timeout ${SSH_BAN_TIME} } counter name c_sshflood drop + counter name c_sshflood drop }" fi @@ -920,7 +1247,7 @@ WL6_LINE=""; [[ -n "$WL6" ]] && WL6_LINE="elements = { $WL6 }" # динамические inbound'ы 3x-ui — ради которых open и существует — оставались бы совсем # без анти-флуда. Прочие протоколы (ICMP отработан выше, GRE/ESP и т.п.) — accept. FW_POLICY=drop -FW_CATCHALL="counter drop" +FW_CATCHALL="counter name c_catchall drop" if [[ "$FW_MODE" == "open" ]]; then FW_POLICY=accept FW_CATCHALL="# FW_MODE=open: не перечисленные порты НЕ блокируются (динамические inbound'ы 3x-ui), @@ -929,8 +1256,8 @@ if [[ "$FW_MODE" == "open" ]]; then meta l4proto tcp ct state new meter occ6 { ip6 saddr ct count over ${CONN_LIMIT} } drop meta l4proto tcp ct state new update @osyn4 { ip saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop meta l4proto tcp ct state new update @osyn6 { ip6 saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop - meta l4proto udp update @oudp4 { ip saddr limit rate over ${UDP_RATE}/second burst ${UDP_BURST} packets } counter drop - meta l4proto udp update @oudp6 { ip6 saddr limit rate over ${UDP_RATE}/second burst ${UDP_BURST} packets } counter drop + meta l4proto udp update @oudp4 { ip saddr limit rate over ${UDP_RATE}/second burst ${UDP_BURST} packets } counter name c_udpflood drop + meta l4proto udp update @oudp6 { ip6 saddr limit rate over ${UDP_RATE}/second burst ${UDP_BURST} packets } counter name c_udpflood drop counter accept" rate_set osyn4 ipv4_addr "$SYN_RATE" "$SYN_BURST" second; rate_set osyn6 ipv6_addr "$SYN_RATE" "$SYN_BURST" second rate_set oudp4 ipv4_addr "$UDP_RATE" "$UDP_BURST" second; rate_set oudp6 ipv6_addr "$UDP_RATE" "$UDP_BURST" second @@ -940,6 +1267,70 @@ fi rate_set icmp4 ipv4_addr "$ICMP_RATE" "$ICMP_BURST" second rate_set icmp6 ipv6_addr "$ICMP_RATE" "$ICMP_BURST" second +# ─── Локальные правила оператора (na_filter.d) ─────────────────────────────── +# Ручные правки na_filter.nft (свой сет фронт-прокси, лишний порт) молча стирал любой +# ре-ран, а диагностика их не видела. Свои фрагменты кладутся сюда и вклеиваются в КАЖДУЮ +# генерацию (проверяются общим nft -c: битый фрагмент = ничего не применено): +# na_filter.d/table/*.nft — на уровне таблицы (set …, свои цепочки); +# na_filter.d/input/*.nft — в цепочку input сразу после whitelist/fleet (accept-правила +# своих адресов идут раньше автобана и лимитов). +NA_INCLUDE_DIR="$CONF_DIR/na_filter.d" +INCLUDE_TABLE=""; INCLUDE_INPUT=""; INCLUDE_N=0 +for _kind in table input; do + for _f in "$NA_INCLUDE_DIR/$_kind"/*.nft; do + [[ -f "$_f" && ! -L "$_f" ]] || continue + _body="$(cat "$_f")" + if [[ "$_kind" == table ]]; then + INCLUDE_TABLE+=$'\n'" # ── локальное: $_f"$'\n'"$_body"$'\n' + else + INCLUDE_INPUT+=$'\n'" # ── локальное: $_f"$'\n'"$_body"$'\n' + fi + INCLUDE_N=$((INCLUDE_N+1)) + done +done +[[ "$INCLUDE_N" -gt 0 ]] && info "локальные правила оператора: $INCLUDE_N файл(ов) из $NA_INCLUDE_DIR" +unset _kind _f _body + +# ─── Опубликованные Docker-порты (forward) ─────────────────────────────────── +# Трафик к контейнеру с `-p` (bridge) после DNAT идёт через hook forward, а не input: на +# него не действовали ни autoban, ни блок-листы, ни анти-спуф, и strict не закрывал такие +# порты (аудит флота: Caddy панели и второй тенант на нодах — целиком мимо na_filter). +# Здесь — те же вердикты ПО ИСТОЧНИКУ для новых DNAT-соединений. Лимиты и список портов +# не трогаем: у контейнеров свой жизненный цикл и свои потребители (CDN, второй тенант). +DNAT_JUMP=""; DNAT_CHAIN="" +if [[ "$DNAT_GUARD" == "1" ]]; then + DNAT_JUMP=" ct status dnat ct state new jump dnat_guard" + _dnat_bogon="" + [[ -n "$WAN" ]] && _dnat_bogon=" iifname \"${WAN}\" ip saddr @bogon_v4 counter name c_dnat_guard drop + iifname \"${WAN}\" ip6 saddr @bogon_v6 counter name c_dnat_guard drop" + _dnat_fleet="" + [[ "$FLEET_ON" == "1" ]] && _dnat_fleet=" ip saddr @na_fleet_v4 return + ip6 saddr @na_fleet_v6 return" + _dnat_bl="" + [[ "$ENABLE_BLOCKLISTS" == "1" ]] && _dnat_bl=" ip saddr @blocklist_v4 counter name c_dnat_guard drop + ip6 saddr @blocklist_v6 counter name c_dnat_guard drop" + DNAT_CHAIN=" # новые соединения к опубликованным Docker-портам: вердикты по источнику (DNAT_GUARD=0 — выкл) + chain dnat_guard { + ip saddr @whitelist_v4 return + ip6 saddr @whitelist_v6 return +$_dnat_fleet + ip saddr @autoban_v4 counter name c_dnat_guard drop + ip6 saddr @autoban_v6 counter name c_dnat_guard drop +$_dnat_bl +$_dnat_bogon + }" + unset _dnat_bogon _dnat_fleet _dnat_bl +fi + +# Именованные счётчики отброшенного: объём атаки — в цифрах, а не в строках журнала +# (лог под rate-limit, на насыщении он молчит). Читает na-fw-status / na-diagnose. +COUNTERS="" +for _c in c_synflood c_udpflood c_udp_amp c_portscan c_autoban c_blocklist c_bogon c_badflags \ + c_nodeport c_catchall c_dnat_guard c_sshflood; do + COUNTERS+=" counter $_c { packets 0 bytes 0 }"$'\n' +done +unset _c + # ─── Генерация nft-файла ───────────────────────────────────────────────────── # NFT_FILE — файл автозагрузки (его грузит na-firewall.service). Кандидат пишется рядом # (та же ФС → атомарный mv) и становится файлом автозагрузки только после `nft -c` И @@ -968,6 +1359,7 @@ delete table inet na_filter table inet na_filter { +$COUNTERS set whitelist_v4 { type ipv4_addr; flags interval; auto-merge; $WL4_LINE } set whitelist_v6 { type ipv6_addr; flags interval; auto-merge; $WL6_LINE } @@ -983,6 +1375,7 @@ $NP_SETS # per-IP лимитеры: записи живут, пока адрес активен (timeout продлевается update'ом) $RATE_SETS +$INCLUDE_TABLE # bogon/martian источники (RFC1918, CGNAT, loopback, link-local, TEST-NET, multicast) set bogon_v4 { @@ -996,29 +1389,34 @@ $RATE_SETS } # bogon-источники IPv6, которые НЕ могут легитимно прийти как saddr на WAN. - # СОЗНАТЕЛЬНО без fe80::/10 (NDP/RA — link-local source) и без ff00::/8 (multicast): - # их дроп убил бы соседство/автоконфиг IPv6. Только однозначно поддельные диапазоны. + # СОЗНАТЕЛЬНО без fe80::/10 (NDP/RA — link-local source), без ff00::/8 (multicast) и без + # :: — неуказанного адреса: с него идут NS проверки дублей (DAD), до v4.2 он тут был и + # ломал автоконфиг v6. Только однозначно поддельные диапазоны. set bogon_v6 { type ipv6_addr; flags interval; auto-merge elements = { - ::1/128, ::/128, ::ffff:0:0/96, 100::/64, 2001:db8::/32, fc00::/7 + ::1/128, ::ffff:0:0/96, 100::/64, 2001:db8::/32, fc00::/7 } } # битые TCP-флаги / скан-пакеты → лог(rl) + drop chain scan_drop { limit rate 5/second log prefix "[na badflags] " level info - counter drop + counter name c_badflags drop } # новые TCP сверх per-IP SYN-rate → лог(rl) + drop chain synflood_drop { limit rate 5/second log prefix "[na synflood] " level info - counter drop + counter name c_synflood drop } $SSH_FLOOD_CHAIN +$PORTSCAN_CHAINS + +$DNAT_CHAIN + $SYNPROXY_PRE chain input { @@ -1026,16 +1424,17 @@ $SYNPROXY_PRE iif lo accept ct state established,related accept +$SYNPROXY_IN ct state invalid drop # whitelist — всегда сверху (в т.ч. твой текущий SSH-IP) ip saddr @whitelist_v4 accept ip6 saddr @whitelist_v6 accept $FLEET_ACCEPT - +$INCLUDE_INPUT # уже забаненные - ip saddr @autoban_v4 drop - ip6 saddr @autoban_v6 drop + ip saddr @autoban_v4 counter name c_autoban drop + ip6 saddr @autoban_v6 counter name c_autoban drop $BLOCKLIST_DROP $ANTISPOOF @@ -1060,14 +1459,13 @@ $ANTISPOOF icmpv6 type echo-request accept icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, packet-too-big, time-exceeded, parameter-problem, destination-unreachable, mld-listener-query, mld-listener-report, mld-listener-done } accept -$SYNPROXY_IN - $SSH_RULES # сервисные TCP-порты (per-IP лимиты) $TCP_RULES # сервисные UDP-порты (per-IP лимиты) +$UDP_AMP $UDP_RULES $NODE_RULES @@ -1077,7 +1475,10 @@ $PORTSCAN $FW_CATCHALL } - chain forward { type filter hook forward priority filter; policy accept; } + chain forward { + type filter hook forward priority filter; policy accept; +$DNAT_JUMP + } chain output { type filter hook output priority filter; policy accept; } } NFT @@ -1155,6 +1556,42 @@ if [[ "$DRY_RUN" == "1" ]]; then exit 0 fi +# ─── Активные баны переживают ре-ран ───────────────────────────────────────── +# Ре-ран пересоздаёт таблицу целиком — до v4.2 вместе с ней обнулялись autoban и suspect: +# сканер, забаненный на час, возвращался сразу после любого ре-рана protect (а ре-раны +# на флоте делаются при каждой правке whitelist). Снимаем живые записи с ОСТАВШИМСЯ сроком +# и возвращаем их в новую таблицу. Best-effort: не вышло — новая таблица просто без них. +BAN_SNAP="" +snapshot_bans() { + local set + mkdir -p "$STATE_DIR" 2>/dev/null || return 0 + BAN_SNAP="$(mktemp "$STATE_DIR/.bans.XXXXXX")" || { BAN_SNAP=""; return 0; } + for set in autoban_v4 autoban_v6 suspect_v4 suspect_v6; do + [[ "$set" == suspect_* && "$ENABLE_BANONCE" != "1" ]] && continue + nft list set inet na_filter "$set" 2>/dev/null | sed -n '/elements = {/,/^[[:space:]]*}/p' \ + | grep -oE '[0-9a-fA-F:.]+(/[0-9]+)? timeout [0-9dhms]+ expires [0-9dhms]+' \ + | awk -v set="$set" '{ e=$5; sub(/[0-9]+ms$/, "", e); if (e != "") print set, $1, e }' || true + done | awk ' + { k=$1; v=$2 " timeout " $3 + n[k]++; buf[k]=buf[k] (buf[k]==""?"":", ") v + if (n[k] % 500 == 0) { print "add element inet na_filter " k " { " buf[k] " }"; buf[k]="" } } + END { for (k in buf) if (buf[k] != "") print "add element inet na_filter " k " { " buf[k] " }" } + ' > "$BAN_SNAP" + [[ -s "$BAN_SNAP" ]] || { rm -f "$BAN_SNAP"; BAN_SNAP=""; } +} +restore_bans() { + [[ -n "$BAN_SNAP" && -s "$BAN_SNAP" ]] || return 0 + local n + n="$(grep -oE 'timeout' "$BAN_SNAP" | wc -l | tr -d ' ')" + if nft -f "$BAN_SNAP" 2>/dev/null; then + ok "перенесено записей autoban/suspect из прошлой таблицы: $n (с оставшимся сроком)" + else + warn "не удалось перенести $n записей autoban/suspect — новая таблица начнёт с чистого листа" + fi + rm -f "$BAN_SNAP"; BAN_SNAP="" +} +snapshot_bans + # ─── Применяем (с сейфти-таймером) ─────────────────────────────────────────── # Был ли сейфти взведён ДО нас (прошлый неинтерактивный прогон без подтверждения): # arm_safety его перевзводит, и при отказе nft -f его надо вернуть, а не снять. @@ -1167,6 +1604,7 @@ if ! nft -f "$NFT_NEW"; then # через SAFETY_DELAY снёс бы её И выключил автозагрузку: нода, которой ре-ран не # изменил ничего, осталась бы без защиты. Чужой (прошлого прогона) сейфти — оставляем. [[ "$SAFETY_PRE" == "1" ]] || disarm_safety + [[ -n "$BAN_SNAP" ]] && rm -f "$BAN_SNAP" mv -f "$NFT_NEW" "$NFT_REJECTED" err "nft -f не применил ruleset (ядро отвергло транзакцию) — живые правила и файл автозагрузки прежние. Отвергнутый ruleset: $NFT_REJECTED" exit 1 @@ -1174,16 +1612,25 @@ fi backup_file "$NFT_FILE" "$BACKUP" mv -f "$NFT_NEW" "$NFT_FILE" rm -f "$NFT_REJECTED" 2>/dev/null || true +# хэш файла автозагрузки: ручная правка na_filter.nft видна в na-diagnose (ре-ран её сотрёт) +NFT_SHA="$(sha256sum "$NFT_FILE" 2>/dev/null | awk '{print $1}')" ok "nftables na_filter применён" +restore_bans # новый руллсет применён → прошлое срабатывание сейфти больше не актуально rm -f "$STATE_DIR/safety-fired.last" 2>/dev/null || true # boot-persist через свой сервис (не трогаем /etc/nftables.conf и чужие таблицы) +# Порядок как у дистрибутивного nftables.service: правила встают ДО network-pre.target, то +# есть до подъёма сети. До v4.2 стояло After=network-pre.target без DefaultDependencies=no — +# окно, в котором сеть уже поднята, а na_filter ещё нет. cat > /etc/systemd/system/na-firewall.service < /etc/modules-load.d/na-synproxy.conf + printf '# node-accelerator: SYNPROXY (ENABLE_SYNPROXY=1)\nnet.netfilter.nf_conntrack_tcp_loose = 0\n' > /etc/sysctl.d/99-na-synproxy.conf + sysctl -q -w net.netfilter.nf_conntrack_tcp_loose=0 2>/dev/null || warn "не смог выставить nf_conntrack_tcp_loose=0 — SYNPROXY не будет завершать рукопожатия" else rm -f /etc/modules-load.d/na-synproxy.conf 2>/dev/null || true + if [[ -f /etc/sysctl.d/99-na-synproxy.conf ]]; then + rm -f /etc/sysctl.d/99-na-synproxy.conf + sysctl -q -w net.netfilter.nf_conntrack_tcp_loose=1 2>/dev/null || true + fi fi systemctl daemon-reload systemctl enable na-firewall.service >/dev/null 2>&1 || true @@ -1213,96 +1669,6 @@ ok "na-firewall.service включён (правила переживут reboot fi # ═══ конец блока файрвола (FW_MODE=skip его пропускает) ═══ -# ─── CrowdSec + firewall-bouncer ───────────────────────────────────────────── -if [[ "$ENABLE_CROWDSEC" == "1" ]]; then - title "CrowdSec + nftables firewall-bouncer" - if ! command -v cscli >/dev/null 2>&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. Осознанный компромисс: - # достаточно СДЕЛАТЬ 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 - fi - if command -v cscli >/dev/null 2>&1; then - systemctl enable --now crowdsec >/dev/null 2>&1 || true - sleep 2 - cscli collections install crowdsecurity/sshd crowdsecurity/linux >/dev/null 2>&1 || true - - # whitelist админа/панели в самом CrowdSec — чтобы IPS их не банил. - # Ключи ip:/cidr: пишем ТОЛЬКО при наличии записей (пустые ключи валят парсер). - mkdir -p /etc/crowdsec/parsers/s02-enrich - # Источник — УЖЕ разобранные WL4/WL6 (дедуп + нормализация /32,/128), а не сырой - # WHITELIST: иначе дубликат из CSV оператора попадал сюда третьим экземпляром, - # мимо чистки, сделанной для nft-сета и na_filter.nft (issue #38). ADMIN_IP тут - # отдельно не нужен — add_wl уже влил его в WL4/WL6. - IP_ITEMS=""; CIDR_ITEMS="" - for x in ${WL4//,/ } ${WL6//,/ }; do - [[ -z "$x" ]] && continue - if [[ "$x" == */* ]]; then CIDR_ITEMS+=" - \"$x\""$'\n'; else IP_ITEMS+=" - \"$x\""$'\n'; fi - done - if [[ -n "$IP_ITEMS$CIDR_ITEMS" ]]; then - { - echo "name: node-accelerator/whitelist" - echo "description: never ban admin/panel" - echo "whitelist:" - echo " reason: node-accelerator trusted" - [[ -n "$IP_ITEMS" ]] && { echo " ip:"; printf "%s" "$IP_ITEMS"; } - [[ -n "$CIDR_ITEMS" ]] && { echo " cidr:"; printf "%s" "$CIDR_ITEMS"; } - } > /etc/crowdsec/parsers/s02-enrich/na-whitelist.yaml - else - rm -f /etc/crowdsec/parsers/s02-enrich/na-whitelist.yaml - fi - - # источник логов sshd через journald (на системах без /var/log/auth.log) - mkdir -p /etc/crowdsec/acquis.d - cat > /etc/crowdsec/acquis.d/na-sshd.yaml <<'ACQ' -source: journalctl -journalctl_filter: - - "_SYSTEMD_UNIT=ssh.service" -labels: - type: syslog ---- -source: journalctl -journalctl_filter: - - "_SYSTEMD_UNIT=sshd.service" -labels: - type: syslog -ACQ - systemctl reload crowdsec >/dev/null 2>&1 || systemctl restart crowdsec >/dev/null 2>&1 || true - - # firewall-bouncer (nftables-режим): своя таблица crowdsec/crowdsec6, priority -10 - if ! dpkg -s crowdsec-firewall-bouncer-nftables >/dev/null 2>&1; then - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq crowdsec-firewall-bouncer-nftables >/dev/null 2>&1 \ - || warn "bouncer не установился" - fi - systemctl enable --now crowdsec-firewall-bouncer >/dev/null 2>&1 || true - - # опциональный enroll в Console - if [[ -n "${CROWDSEC_ENROLL_KEY:-}" ]]; then - cscli console enroll "$CROWDSEC_ENROLL_KEY" >/dev/null 2>&1 \ - && { systemctl reload crowdsec >/dev/null 2>&1 || true; ok "enroll в CrowdSec Console отправлен"; } \ - || warn "enroll не прошёл (проверь ключ)" - fi - - if systemctl is-active --quiet crowdsec && systemctl is-active --quiet crowdsec-firewall-bouncer; then - ok "CrowdSec + bouncer активны (community-блоклист + поведенческий бан)" - else - warn "CrowdSec/bouncer установлены, но сервис не active — проверь: cscli metrics" - fi - fi -else - info "ENABLE_CROWDSEC=0 — CrowdSec пропущен" -fi - # ═══ v3.0 МОДУЛИ: fleet-sync · blocklists · ctguard ═══════════════════════════ # Зависимости только под включённые модули (jq — fleet/blocklists, conntrack — ctguard). _dep_list=() @@ -1317,11 +1683,20 @@ if [[ "$FLEET_ON" == "1" ]]; then title "Fleet auto-sync (ноды флота → whitelist)" if [[ -n "$REMNAWAVE_NODES_URL" ]] || [[ -n "$REMNAWAVE_URL" && -n "$REMNAWAVE_TOKEN" ]]; then umask 077; mkdir -p "$CONF_DIR" + # fleet.env хелпер подключает через `.` от root каждые 5 минут: до v4.2 значения + # писались как есть, и токен вида `abc;cmd` исполнял cmd, а `abc def` молча давал + # пустой токен (синк навсегда на last-known-good). Набор символов токена проверяем, + # значения пишем в экранированном виде (%q). + for _k in REMNAWAVE_TOKEN CADDY_AUTH_API_TOKEN; do + [[ -z "${!_k}" || "${!_k}" =~ ^[A-Za-z0-9._~+/=:-]+$ ]] \ + || { err "$_k: недопустимые символы (ожидается base64/JWT/hex-подобный токен)"; exit 1; } + done + unset _k { - [[ -z "$REMNAWAVE_URL" ]] || printf 'REMNAWAVE_URL=%s\n' "$REMNAWAVE_URL" - [[ -z "$REMNAWAVE_TOKEN" ]] || printf 'REMNAWAVE_TOKEN=%s\n' "$REMNAWAVE_TOKEN" - [[ -z "$REMNAWAVE_NODES_URL" ]] || printf 'REMNAWAVE_NODES_URL=%s\n' "$REMNAWAVE_NODES_URL" - [[ -z "$CADDY_AUTH_API_TOKEN" ]] || printf 'CADDY_AUTH_API_TOKEN=%s\n' "$CADDY_AUTH_API_TOKEN" + [[ -z "$REMNAWAVE_URL" ]] || printf 'REMNAWAVE_URL=%q\n' "$REMNAWAVE_URL" + [[ -z "$REMNAWAVE_TOKEN" ]] || printf 'REMNAWAVE_TOKEN=%q\n' "$REMNAWAVE_TOKEN" + [[ -z "$REMNAWAVE_NODES_URL" ]] || printf 'REMNAWAVE_NODES_URL=%q\n' "$REMNAWAVE_NODES_URL" + [[ -z "$CADDY_AUTH_API_TOKEN" ]] || printf 'CADDY_AUTH_API_TOKEN=%q\n' "$CADDY_AUTH_API_TOKEN" } > "$CONF_DIR/fleet.env" chmod 0600 "$CONF_DIR/fleet.env"; chown root:root "$CONF_DIR/fleet.env" 2>/dev/null || true if [[ -n "$REMNAWAVE_NODES_URL" ]]; then @@ -1487,8 +1862,15 @@ fetch https://iplists.firehol.org/files/firehol_level1.netset | grep -vE '^#' >> grep -hoE '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?' "$TMP/v4.raw" 2>/dev/null \ | grep -vE '^(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.)' \ | sort -u > "$TMP/v4.clean" +# Слишком широкие префиксы — отказ: одна строка `x.x.x.x/0` в custom-blocklist.txt или в +# испорченном фиде заблокировала бы весь интернет (v4 короче /8, v6 короче /19 не бывает +# в настоящих списках злоумышленников). +awk -F/ 'NF==1 || $2>=8' "$TMP/v4.clean" > "$TMP/v4.ok" +WIDE4=$(( $(grep -c . "$TMP/v4.clean" 2>/dev/null || true) - $(grep -c . "$TMP/v4.ok" 2>/dev/null || true) )) +mv -f "$TMP/v4.ok" "$TMP/v4.clean" +[ "$WIDE4" -gt 0 ] && logger -t "$TAG" "отброшено $WIDE4 v4-префиксов шире /8 (ошибка фида/опечатка в custom)" # v6: из jq-чистых .cidr (+ кастомные), базовая sanity -grep -hE '^[0-9a-fA-F:/]+$' "$TMP/v6.raw" 2>/dev/null | grep ':' | sort -u > "$TMP/v6.clean" +grep -hE '^[0-9a-fA-F:/]+$' "$TMP/v6.raw" 2>/dev/null | grep ':' | awk -F/ 'NF==1 || $2>=19' | sort -u > "$TMP/v6.clean" # `grep -c` на пустом файле САМ печатает 0 и возвращает rc=1, поэтому `|| echo 0` дописал бы # второй ноль и превратил число в "0\n0" — арифметика ниже сломалась бы на ровном месте. N4="$(grep -c . "$TMP/v4.clean" 2>/dev/null)"; N4="${N4:-0}" @@ -1608,8 +1990,16 @@ SELF_RE="$( { ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut printf '127.0.0.1\n::1\n'; } | sed 's/\./\\./g' | paste -sd'|' - )" PRIV_RE='^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|f[cd]|fe[89ab])' # conntrack по ПЕРВОМУ src= — только tcp, без собственных и служебных адресов +# DNAT-потоки (опубликованные порты контейнеров, второй тенант на боксе) пропускаем: +# их живые сокеты — в netns контейнера, ss хоста их не видит, и клиент выглядел бы фантомом +# (бан на input отрезал бы его от сервисов ЭТОЙ ноды). DNAT = reply-src ≠ orig-dst. conntrack -L -p tcp 2>/dev/null \ - | awk '{for(i=1;i<=NF;i++) if($i ~ /^src=/){print substr($i,5); break}}' \ + | awk '{ s=""; d=""; rs=""; + for(i=1;i<=NF;i++){ + if($i ~ /^src=/){ if(s=="") s=substr($i,5); else if(rs=="") rs=substr($i,5) } + else if($i ~ /^dst=/ && d=="") d=substr($i,5) + } + if (s != "" && (rs == "" || rs == d)) print s }' \ | grep -Ev "^(${SELF_RE})$" | grep -Ev "$PRIV_RE" \ | sort | uniq -c | sort -rn > "$TMP/ct" @@ -1670,6 +2060,17 @@ EOF fi fi +# ctguard выключен, а от прошлого прогона остались таймер/таблица/конфиг (NA_CTG_ENFORCE=1 +# эвиктил бы и дальше каждые 20с): до v4.2 ENABLE_CTGUARD=0 на ре-ране не снимал ничего. +if [[ "$ENABLE_CTGUARD" != "1" ]] && { [[ -f /etc/systemd/system/na-ctguard.timer ]] || nft list table inet na_ctguard >/dev/null 2>&1; }; then + systemctl disable --now na-ctguard.timer na-ctguard.service >/dev/null 2>&1 || true + rm -f /etc/systemd/system/na-ctguard.timer /etc/systemd/system/na-ctguard.service \ + /usr/local/sbin/na-ctguard "$CONF_DIR/ctguard.conf" + systemctl daemon-reload 2>/dev/null || true + nft delete table inet na_ctguard 2>/dev/null || true + ok "ctguard снят (ENABLE_CTGUARD=0): таймер, таблица na_ctguard и конфиг удалены" +fi + # ─── fw-status хелпер ──────────────────────────────────────────────────────── # Все счётчики наборов идут через nft_set_count из lib/common.sh, а тело функций # ВШИВАЕТСЯ в хелпер через `declare -f`: na-fw-status — самостоятельный скрипт на ноде, @@ -1690,7 +2091,11 @@ write_fw_status() { cat <<'STAT' echo "── nft table inet na_filter ──" # -t: без содержимого наборов — лимитеры держат десятки тысяч записей -nft -t list table inet na_filter 2>/dev/null | grep -E 'policy|counter' | head -40 +nft -t list table inet na_filter 2>/dev/null | grep -E 'policy' | head -5 +echo +echo "── отброшено (именованные счётчики, с загрузки таблицы) ──" +nft list counters table inet na_filter 2>/dev/null \ + | awk '/counter c_/{n=$2} /packets/{for(i=1;i<=NF;i++) if($i=="packets"){p=$(i+1); b=$(i+3)}; if(n!=""){printf " %-14s %12s пакетов %14s байт\n", n, p, b; n=""}}' echo echo "── autoban (живые баны) ──" echo "v4: $(nft_set_count inet na_filter autoban_v4) v6: $(nft_set_count inet na_filter autoban_v6)" @@ -1735,6 +2140,143 @@ STAT } write_fw_status +# ─── na-fw: whitelist и разбан одной командой ──────────────────────────────── +# Whitelist живёт в четырёх местах (живой сет, na_filter.nft, WHITELIST= в protect.conf, +# CrowdSec-yaml), и до v4.2 штатного способа поменять его, кроме полного ре-рана, не было: +# на флоте их правили sed'ом по отдельности (сотни *.bak-*), слои расходились, а ре-ран +# молча возвращал старое. na-fw меняет все слои разом, файл проверяет nft -c до замены. +write_na_fw() { + cat > /usr/local/sbin/na-fw <<'NAFW' +#!/usr/bin/env bash +# na-fw — whitelist и разбан для na_filter (ставит protect.sh, снимает rollback). +# na-fw allow add … — в whitelist: protect.conf, na_filter.nft, живой сет, +# CrowdSec (парсер + правило CROWDSEC_SCOPE=ssh) +# na-fw allow del … — убрать оттуда же +# na-fw allow list — что сейчас в whitelist (живой сет и protect.conf) +# na-fw unban … — снять autoban/suspect и решения CrowdSec +set -euo pipefail +CONF_DIR=/etc/node-accelerator +STATE_DIR=/var/lib/node-accelerator +CONF="$CONF_DIR/protect.conf" +NFT_FILE="$CONF_DIR/na_filter.nft" +CS_YAML=/etc/crowdsec/parsers/s02-enrich/na-whitelist.yaml +SCOPE_NFT="$CONF_DIR/na-crowdsec-scope.nft" +die() { echo "[x] $*" >&2; exit 1; } +say() { echo "[+] $*"; } +[[ ${EUID:-$(id -u)} -eq 0 ]] || die "нужен root" +fam_of() { # fam_of <адрес> → 4|6; rc=1 — не адрес + if [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$ ]]; then echo 4 + elif [[ "$1" =~ ^[0-9a-fA-F:]+(/[0-9]{1,3})?$ && "$1" == *:* ]]; then echo 6 + else return 1; fi +} +norm() { local a="$1"; a="${a%/32}"; [[ "$a" == *:* ]] && a="${a%/128}"; printf '%s' "$a"; } +conf_list() { sed -nE 's/^: "\$\{WHITELIST:?=([^}]*)\}".*/\1/p' "$CONF" 2>/dev/null | tail -1; } +conf_set() { # conf_set — переписать WHITELIST= в protect.conf (none = пусто) + local v="${1:-none}" tmp + [[ -f "$CONF" ]] || { umask 077; : > "$CONF"; } + tmp="$(mktemp "$CONF.XXXXXX")" + if grep -qE '^: "\$\{WHITELIST:?=' "$CONF"; then + sed -E "s|^: \"\\\$\\{WHITELIST(:?)=[^}]*\\}\"|: \"\\\${WHITELIST\\1=$v}\"|" "$CONF" > "$tmp" + else + cat "$CONF" > "$tmp"; printf ': "${WHITELIST:=%s}"\n' "$v" >> "$tmp" + fi + chmod 0600 "$tmp"; mv -f "$tmp" "$CONF" +} +file_elems() { # file_elems 4|6 — элементы whitelist_vN из na_filter.nft + sed -nE "s/.*set whitelist_v$1 \\{.*elements = \\{ ([^}]*) \\}.*/\\1/p" "$NFT_FILE" 2>/dev/null | tr -d ' ' | tr ',' '\n' | grep -v '^$' || true +} +file_set() { # file_set 4|6 <элементы через перевод строки> + local fam="$1" list="$2" typ=ipv4_addr line tmp + [[ "$fam" == 6 ]] && typ=ipv6_addr + list="$(printf '%s\n' "$list" | grep -v '^$' | sort -u | paste -sd, - | sed 's/,/, /g')" + if [[ -n "$list" ]]; then line=" set whitelist_v$fam { type $typ; flags interval; auto-merge; elements = { $list } }" + else line=" set whitelist_v$fam { type $typ; flags interval; auto-merge; }"; fi + tmp="$(mktemp "$NFT_FILE.XXXXXX")" + awk -v fam="$fam" -v line="$line" '$0 ~ "^[[:space:]]*set whitelist_v" fam " \\{" {print line; next} {print}' "$NFT_FILE" > "$tmp" + nft -c -f "$tmp" >/dev/null 2>&1 || { rm -f "$tmp"; die "изменённый na_filter.nft не прошёл nft -c — ничего не тронуто"; } + chmod 0644 "$tmp"; mv -f "$tmp" "$NFT_FILE" +} +sync_aux() { # CrowdSec-yaml, правило scope, хэш в маркере — из итогового файла + local w4 w6 ip="" cidr="" x + w4="$(file_elems 4)"; w6="$(file_elems 6)" + for x in $w4 $w6; do + if [[ "$x" == */* ]]; then cidr+=" - \"$x\""$'\n'; else ip+=" - \"$x\""$'\n'; fi + done + if [[ -d "$(dirname "$CS_YAML")" ]]; then + if [[ -n "$ip$cidr" ]]; then + { echo "name: node-accelerator/whitelist"; echo "description: never ban admin/panel"; echo "whitelist:" + echo " reason: node-accelerator trusted" + [[ -n "$ip" ]] && { echo " ip:"; printf "%s" "$ip"; } + [[ -n "$cidr" ]] && { echo " cidr:"; printf "%s" "$cidr"; }; } > "$CS_YAML" + else rm -f "$CS_YAML"; fi + systemctl reload crowdsec >/dev/null 2>&1 || true + fi + if [[ -f "$SCOPE_NFT" ]]; then + local l4=" # na-wl4 (whitelist пуст)" l6=" # na-wl6 (whitelist пуст)" tmp + [[ -n "$w4" ]] && l4=" ip saddr { $(paste -sd, - <<<"$w4" | sed 's/,/, /g') } return # na-wl4" + [[ -n "$w6" ]] && l6=" ip6 saddr { $(paste -sd, - <<<"$w6" | sed 's/,/, /g') } return # na-wl6" + tmp="$(mktemp "$SCOPE_NFT.XXXXXX")" + awk -v l4="$l4" -v l6="$l6" '/na-wl4/{print l4; next} /na-wl6/{print l6; next} {print}' "$SCOPE_NFT" > "$tmp" + if nft -c -f "$tmp" >/dev/null 2>&1; then + mv -f "$tmp" "$SCOPE_NFT"; nft -f "$SCOPE_NFT" 2>/dev/null || true + systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true + else rm -f "$tmp"; echo "[!] правило CrowdSec scope не обновлено (nft -c)" >&2; fi + fi + if [[ -f "$STATE_DIR/protect.installed" ]]; then + local sha; sha="$(sha256sum "$NFT_FILE" | awk '{print $1}')" + sed -i.na-tmp -E "s/^nft_sha256=.*/nft_sha256=$sha/" "$STATE_DIR/protect.installed" && rm -f "$STATE_DIR/protect.installed.na-tmp" + fi +} +cmd="${1:-}"; sub="${2:-}" +case "$cmd" in + allow) + [[ -f "$NFT_FILE" ]] || die "нет $NFT_FILE — protect не ставился (или FW_MODE=skip)" + case "$sub" in + list) + echo "живой сет v4: $(nft list set inet na_filter whitelist_v4 2>/dev/null | sed -n '/elements = {/,/}/p' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]+)?' | paste -sd' ' -)" + echo "живой сет v6: $(nft list set inet na_filter whitelist_v6 2>/dev/null | sed -n '/elements = {/,/}/p' | grep -oE '[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){2,7}(/[0-9]+)?' | paste -sd' ' -)" + echo "protect.conf: $(conf_list)"; exit 0 ;; + add|del) ;; + *) die "использование: na-fw allow add|del|list …" ;; + esac + shift 2; [[ $# -gt 0 ]] || die "не указан адрес" + cur="$(conf_list)"; [[ "$cur" == none ]] && cur="" + e4="$(file_elems 4)"; e6="$(file_elems 6)" + for a in "$@"; do + fam="$(fam_of "$a")" || die "'$a' — не IPv4/IPv6/CIDR" + a="$(norm "$a")" + if [[ "$sub" == add ]]; then + [[ ",$cur," == *",$a,"* ]] || cur+="${cur:+,}$a" + if [[ "$fam" == 4 ]]; then e4+=$'\n'"$a"; else e6+=$'\n'"$a"; fi + nft add element inet na_filter "whitelist_v$fam" "{ $a }" 2>/dev/null || true + nft delete element inet na_filter "autoban_v$fam" "{ $a }" 2>/dev/null || true + say "allow $a" + else + cur="$(tr ',' '\n' <<<"$cur" | grep -vxF -- "$a" | paste -sd, - || true)" + if [[ "$fam" == 4 ]]; then e4="$(grep -vxF -- "$a" <<<"$e4" || true)"; else e6="$(grep -vxF -- "$a" <<<"$e6" || true)"; fi + nft delete element inet na_filter "whitelist_v$fam" "{ $a }" 2>/dev/null || true + say "deny $a (снят из whitelist)" + fi + done + file_set 4 "$e4"; file_set 6 "$e6" + conf_set "$cur" + sync_aux + say "whitelist обновлён во всех слоях (сет, na_filter.nft, protect.conf, CrowdSec)" ;; + unban) + shift; [[ $# -gt 0 ]] || die "не указан адрес" + for a in "$@"; do + fam="$(fam_of "$a")" || die "'$a' — не IP" + for st in autoban suspect; do nft delete element inet na_filter "${st}_v$fam" "{ $a }" 2>/dev/null || true; done + command -v cscli >/dev/null 2>&1 && cscli decisions delete --ip "$a" >/dev/null 2>&1 || true + say "unban $a (autoban/suspect/CrowdSec)" + done ;; + *) sed -n '2,7p' "$0"; exit 1 ;; +esac +NAFW + chmod +x /usr/local/sbin/na-fw +} +write_na_fw + # ─── top-talkers хелпер ────────────────────────────────────────────────────── # Если нода за реверс-прокси/балансировщиком/CDN — трафик идёт с горстки upstream-IP, # и per-IP лимиты их режут. Хелпер показывает топ источников → кандидаты в WHITELIST=. @@ -1759,8 +2301,9 @@ for p in ${PORTS//,/ }; do done [ -n "$filt" ] || { echo "нет портов для анализа"; exit 1; } echo "── Топ-$N удалённых IP по established TCP на портах: $PORTS ──" +# при `state established` ss не печатает колонку State — адрес пира последний, а не 5-й ss -Hnt state established "( $filt )" 2>/dev/null \ - | awk '{print $5}' \ + | awk '{print $NF}' \ | sed -E 's/:[0-9]+$//; s/^\[//; s/\]$//; s/^::ffff:([0-9.]+)$/\1/' \ | sort | uniq -c | sort -rn | head -n "$N" TT @@ -1776,9 +2319,14 @@ fw_mode=$FW_MODE ssh_port=$SSH_EFF tcp_ports=$TCP_PORTS udp_ports=$UDP_PORTS -node_port=$NP_EFF +node_port=${NP_EFF:-none} crowdsec=$ENABLE_CROWDSEC nft_file=${NFT_FILE:-} +crowdsec_scope=${CROWDSEC_SCOPE_EFF:-} +dnat_guard=$([[ "$FW_MODE" != "skip" ]] && echo "$DNAT_GUARD" || echo 0) +nft_sha256=${NFT_SHA:-} +include_dir=$CONF_DIR/na_filter.d +include_files=${INCLUDE_N:-0} EOF # Персист эффективного конфига → ре-ран без ENV сохранит эти значения (ENV всё ещё @@ -1786,8 +2334,13 @@ EOF # авто-IP текущей SSH-сессии — тот добавляется в WL4/WL6 отдельно). # REMNAWAVE_URL/TOKEN сюда НЕ пишем — токен живёт в fleet.env (0600), fleet-режим # восстанавливается по наличию fleet.env. +# none-ключи возвращаем словом none (иначе следующий прогон взял бы встроенный дефолт); +# SSH_PORT пишем, только если он задан оператором — автодетект каждый прогон делает заново. +for _k in $NA_NONE_KEYS; do printf -v "$_k" '%s' none; done +_ssh_key=""; [[ " $NA_EXPLICIT_KEYS " == *" SSH_PORT "* ]] && _ssh_key=SSH_PORT +export NA_EXPLICIT_KEYS save_conf "$CONF_DIR/protect.conf" \ - FW_MODE SSH_PORT TCP_PORTS UDP_PORTS NODE_PORT WHITELIST \ + FW_MODE $_ssh_key TCP_PORTS UDP_PORTS NODE_PORT WHITELIST CROWDSEC_SCOPE DNAT_GUARD UDP_AMP_DROP \ SYN_RATE SYN_BURST UDP_RATE UDP_BURST UDP_BULK_PORTS UDP_BULK_RATE UDP_BULK_BURST CONN_LIMIT \ ICMP_RATE ICMP_BURST SSH_RATE SSH_BURST SSH_BAN_TIME \ PORTSCAN_BAN_TIME PORTSCAN_RATE PORTSCAN_BURST PORTSCAN_LOG_RATE PORTSCAN_LOG_BURST \ @@ -1806,15 +2359,25 @@ if [[ "$FW_MODE" == "skip" ]]; then else title "Подтверждение (защита от самоблокировки)" echo " Открой НОВОЕ окно и проверь: ssh root@<этот сервер>" - echo " (твой текущий IP $ADMIN_IP уже в whitelist, но лучше убедиться.)" + if [[ -n "$ADMIN_IP" ]]; then + echo " (твой текущий IP $ADMIN_IP уже в whitelist, но лучше убедиться.)" + else + echo " (IP текущей сессии не определён — в whitelist его НЕТ: проверь обязательно.)" + fi echo if [[ -t 0 && -z "${REMNAWAVE_NONINTERACTIVE:-}" ]]; then read -r -p "Соединение работает? [y/N]: " c if [[ "$c" =~ ^[yYдД] ]]; then - disarm_safety; ok "Сейфти-таймер снят. Защита активна." + disarm_safety + # сейфти мог сработать, пока шли установки модулей: «защита активна» без таблицы — ложь + if nft -t list table inet na_filter >/dev/null 2>&1; then + ok "Сейфти-таймер снят. Защита активна." + else + err "Сейфти-таймер УЖЕ сработал (таблицы na_filter нет, автозагрузка выключена) — защиты сейчас нет. Прогони protect заново (SAFETY_DELAY=… побольше)." + fi else warn "Сейфти оставлен: через ${SAFETY_DELAY}s na_filter удалится сам." - warn "Если всё ок — сними: systemctl stop na-fw-safety.timer (или kill из /tmp/na-fw-safety.pid)" + warn "Если всё ок — сними: systemctl stop na-fw-safety.timer (или kill \$(cat $STATE_DIR/na-fw-safety.pid))" fi else warn "Неинтерактивно: сейфти-таймер на ${SAFETY_DELAY}s АКТИВЕН." diff --git a/scripts/rollback.sh b/scripts/rollback.sh index d5df0d1..6eb4bc4 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -121,9 +121,30 @@ rollback_protect() { # + отвергнутый ruleset и недописанные кандидаты (protect v4.1.3+ генерирует рядом и # переименовывает в na_filter.nft только после успешного nft -f) rm -f "$CONF_DIR/na_filter.nft" "$CONF_DIR/na_filter.nft.rejected" "$CONF_DIR"/.na_filter.nft.* - rm -f /usr/local/sbin/na-fw-status /usr/local/sbin/na-fw-top-talkers \ + rm -f /usr/local/sbin/na-fw-status /usr/local/sbin/na-fw-top-talkers /usr/local/sbin/na-fw \ /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"/.bans.* 2>/dev/null || true + [[ -d "$CONF_DIR/na_filter.d" ]] && info "оставлен $CONF_DIR/na_filter.d (локальные правила оператора)" + # SYNPROXY: свой sysctl снимаем и возвращаем дефолт ядра + if [[ -f /etc/sysctl.d/99-na-synproxy.conf ]]; then + rm -f /etc/sysctl.d/99-na-synproxy.conf + sysctl -q -w net.netfilter.nf_conntrack_tcp_loose=1 2>/dev/null || true + fi + # CROWDSEC_SCOPE=ssh: наш юнит/правила снимаем, bouncer возвращаем к своим правилам на + # всех портах (set-only: false) — иначе он только наполнял бы наборы, которые никто не читает + if [[ -f /etc/systemd/system/na-crowdsec-scope.service ]]; then + systemctl disable na-crowdsec-scope.service >/dev/null 2>&1 || true + rm -f /etc/systemd/system/na-crowdsec-scope.service "$CONF_DIR/na-crowdsec-scope.nft" + systemctl daemon-reload 2>/dev/null || true + _by=/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml + if [[ -f "$_by" ]]; then + sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*true[[:space:]]*$/\1 false/' "$_by" && rm -f "$_by.na-tmp" + systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true + fi + unset _by + info "CrowdSec scope снят: bouncer снова ставит свои правила (на всех портах)" + fi rm -f "$STATE_DIR/safety-fired.last" "$STATE_DIR/protect.lock" # nftables.service до v4.1.2 включал сам protect (boot-persist), но выключать не будем: он # лишь грузит /etc/nftables.conf, который тулкит никогда не писал — трогать чужой конфиг нельзя. diff --git a/tests/apply-smoke.sh b/tests/apply-smoke.sh index 2b94c38..ceae0aa 100755 --- a/tests/apply-smoke.sh +++ b/tests/apply-smoke.sh @@ -13,7 +13,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" T="$(mktemp -d)" trap 'rm -rf "$T"' EXIT -mkdir -p "$T/bin" "$T/sys" "$T/sbin" "$T/modload" "$T/conf" "$T/state" "$T/backup" +mkdir -p "$T/bin" "$T/sys" "$T/sbin" "$T/modload" "$T/conf" "$T/state" "$T/backup" "$T/sysctl.d" cp -r "$REPO_ROOT/scripts" "$T/scripts" # Перенаправляем хардкод-системные пути на писабельные /tmp (portable sed: без -i). @@ -21,6 +21,7 @@ P="$T/scripts/protect.sh" sed -e "s#/etc/systemd/system/#$T/sys/#g" \ -e "s#/usr/local/sbin/#$T/sbin/#g" \ -e "s#/etc/modules-load.d/#$T/modload/#g" \ + -e "s#/etc/sysctl.d/#$T/sysctl.d/#g" \ "$P" > "$P.tmp" && mv "$P.tmp" "$P" # Стаб-бинари (no-op) в PATH. @@ -104,7 +105,7 @@ NFTF="$T/conf/na_filter.nft" grep -q 'hook input priority filter; policy drop;' "$NFTF" || { echo "[x] strict: нет policy drop на input"; fail=1; } grep -q 'ANTI-SCAN' "$NFTF" || { echo "[x] strict: нет анти-скан правил"; fail=1; } # NODE_PORT не задан, детект пуст (docker/ss стабы) → фолбэк на ОБА известных дефолта -grep -q 'tcp dport { 2222, 3000 } ct state new drop' "$NFTF" || { echo "[x] strict: нет node-port правил на фолбэк 2222,3000"; fail=1; } +grep -q 'tcp dport { 2222, 3000 } ct state new counter name c_nodeport drop' "$NFTF" || { echo "[x] strict: нет node-port правил на фолбэк 2222,3000"; fail=1; } grep -q 'set na_nodeport_wl_v4' "$NFTF" || { echo "[x] strict: нет сета na_nodeport_wl_v4 (пожарный допуск панели)"; fail=1; } grep -q '^node_port=2222,3000$' "$T/state/protect.installed" || { echo "[x] strict: node_port=2222,3000 не в маркере"; fail=1; } # wl-only задан ЯВНО → авто-допуск пиров выключен: warn с их списком, элементов в сете нет @@ -134,6 +135,27 @@ for st in ps4 psc4 icmp4 udp4_443; do grep -q "set $st { type ipv4_addr; size 65535; flags dynamic,timeout;" "$NFTF" || { echo "[x] strict: нет набора $st с timeout"; fail=1; } done grep -q 'chain ssh_flood {' "$NFTF" || { echo "[x] strict: нет цепочки ssh_flood"; fail=1; } +# ── v4.2 ── +for c in c_synflood c_udpflood c_udp_amp c_portscan c_autoban c_blocklist c_bogon c_badflags c_nodeport c_catchall c_dnat_guard c_sshflood; do + grep -q "counter $c { packets 0 bytes 0 }" "$NFTF" || { echo "[x] v4.2: не объявлен счётчик $c"; fail=1; } +done +grep -q 'ct status dnat ct state new jump dnat_guard' "$NFTF" || { echo "[x] v4.2: forward не ведёт новые DNAT-соединения в dnat_guard"; fail=1; } +grep -q 'chain dnat_guard {' "$NFTF" || { echo "[x] v4.2: нет цепочки dnat_guard"; fail=1; } +grep -q 'udp sport { 17, 19, 53, 123, 389, 1900, 11211 } udp dport' "$NFTF" || { echo "[x] v4.2: нет анти-амплификации UDP"; fail=1; } +grep -q 'ip6 saddr fe80::/10 udp sport 547 udp dport 546 accept' "$NFTF" || { echo "[x] v4.2: DHCPv6-ответы не пропускаются"; fail=1; } +grep -q '::1/128, ::ffff:0:0/96' "$NFTF" || { echo "[x] v4.2: :: (DAD) всё ещё в bogon_v6"; fail=1; } +grep -q 'update @sshs4 {' "$NFTF" || { echo "[x] v4.2: SSH ban-once без порога эскалации sshs4"; fail=1; } +# SYNPROXY — ДО invalid-drop (иначе третий ACK рукопожатия съедается раньше synproxy) +_sp=$(grep -n 'synproxy mss' "$NFTF" | head -1 | cut -d: -f1); _inv=$(grep -n 'ct state invalid drop' "$NFTF" | head -1 | cut -d: -f1) +[ -n "$_sp" ] && [ -n "$_inv" ] && [ "$_sp" -lt "$_inv" ] || { echo "[x] v4.2: synproxy стоит не до ct state invalid drop ($_sp vs $_inv)"; fail=1; } +grep -q 'net.netfilter.nf_conntrack_tcp_loose = 0' "$T/sysctl.d/99-na-synproxy.conf" 2>/dev/null || { echo "[x] v4.2: SYNPROXY без nf_conntrack_tcp_loose=0"; fail=1; } +grep -qE '^nft_sha256=[0-9a-f]{64}$' "$T/state/protect.installed" || { echo "[x] v4.2: нет nft_sha256 в маркере"; fail=1; } +[ "$(grep -E '^nft_sha256=' "$T/state/protect.installed" | cut -d= -f2)" = "$(sha256sum "$NFTF" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$NFTF" | awk '{print $1}')" ] \ + || { echo "[x] v4.2: nft_sha256 не совпадает с файлом"; fail=1; } +grep -q '^dnat_guard=1$' "$T/state/protect.installed" || { echo "[x] v4.2: dnat_guard=1 не в маркере"; fail=1; } +[ -x "$T/sbin/na-fw" ] || { echo "[x] v4.2: не создан na-fw"; fail=1; } +# SSH_PORT из автодетекта (стаб: 22) в conf НЕ пишется — следующий прогон детектит заново +grep -q 'SSH_PORT' "$T/conf/protect.conf" && { echo "[x] v4.2: автодетект SSH_PORT персистится"; fail=1; } # Сброс окружения между прогонами режимов reset_t() { @@ -199,7 +221,7 @@ set -e if [ "$rc" -ne 0 ]; then echo "[x] autodetect: apply упал (exit $rc)"; tail -25 "$LOG4"; fail=1; fi grep -qiE 'unbound variable|bad substitution' "$LOG4" && { echo "[x] autodetect: unbound-переменная:"; grep -iE 'unbound variable|bad substitution' "$LOG4"; fail=1; } grep -qF 'автодетект порта → 3000' "$LOG4" || { echo "[x] autodetect: нет лога про автодетект 3000"; fail=1; } -grep -q 'tcp dport { 3000 } ct state new drop' "$NFTF" || { echo "[x] autodetect: нет wl-only правил на детект-порт 3000"; fail=1; } +grep -q 'tcp dport { 3000 } ct state new counter name c_nodeport drop' "$NFTF" || { echo "[x] autodetect: нет wl-only правил на детект-порт 3000"; fail=1; } grep -q 'dport { 2222' "$NFTF" && { echo "[x] autodetect: фолбэк 2222 не должен ставиться при удачном детекте"; fail=1; } grep -q '198.51.100.7' "$NFTF" || { echo "[x] autodetect: established-пир панели не попал в na_nodeport_wl (авто-допуск)"; fail=1; } grep -q '^node_port=3000$' "$T/state/protect.installed" || { echo "[x] autodetect: node_port=3000 не в маркере"; fail=1; } @@ -271,6 +293,61 @@ grep -q '^ssh_port=22,56777$' "$T/state/protect.installed" \ grep -q 'SSH_PORT:=22}' "$T/conf/protect.conf" \ || { echo "[x] ssh-port: в protect.conf должен персиститься intent (22), а не транзитный порт сессии"; fail=1; } +# ── v4.2: none, явный SSH_PORT, локальные правила, DNAT_GUARD=0, снятие ctguard ── +reset_t +mkdir -p "$T/conf/na_filter.d/table" "$T/conf/na_filter.d/input" +echo ' set frontlist_v4 { type ipv4_addr; elements = { 192.0.2.10 } }' > "$T/conf/na_filter.d/table/front.nft" +echo ' ip saddr @frontlist_v4 tcp dport { 80, 443 } accept' > "$T/conf/na_filter.d/input/front.nft" +set +e +WHITELIST=203.0.113.50 UDP_PORTS=none NODE_PORT=none SSH_PORT=2222 DNAT_GUARD=0 ENABLE_CTGUARD=1 \ + ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-v42.log" 2>&1 +rc=$? +set -e +[ "$rc" -eq 0 ] || { echo "[x] v4.2: прогон упал"; tail -15 "$T/apply-v42.log"; fail=1; } +grep -q 'udp dport' "$NFTF" && grep -q 'udp dport 443 update' "$NFTF" && { echo "[x] v4.2: UDP_PORTS=none — UDP-правила всё ещё ставятся"; fail=1; } +grep -q 'counter name c_nodeport drop' "$NFTF" && { echo "[x] v4.2: NODE_PORT=none — правила node-порта есть"; fail=1; } +grep -q '^node_port=none$' "$T/state/protect.installed" || { echo "[x] v4.2: node_port=none не в маркере"; fail=1; } +grep -q 'UDP_PORTS:=none' "$T/conf/protect.conf" || { echo "[x] v4.2: UDP_PORTS=none не персистится словом none"; fail=1; } +grep -q 'NODE_PORT:=none' "$T/conf/protect.conf" || { echo "[x] v4.2: NODE_PORT=none не персистится"; fail=1; } +grep -q 'SSH_PORT:=2222' "$T/conf/protect.conf" && grep -qx '# explicit: SSH_PORT' "$T/conf/protect.conf" || { echo "[x] v4.2: явный SSH_PORT не персистится с меткой explicit"; fail=1; } +grep -q 'set frontlist_v4' "$NFTF" && grep -q 'ip saddr @frontlist_v4 tcp dport { 80, 443 } accept' "$NFTF" || { echo "[x] v4.2: локальные правила na_filter.d не вклеены"; fail=1; } +grep -q '^include_files=2$' "$T/state/protect.installed" || { echo "[x] v4.2: include_files=2 не в маркере"; fail=1; } +grep -q 'jump dnat_guard' "$NFTF" && { echo "[x] v4.2: DNAT_GUARD=0 — forward всё ещё ведёт в dnat_guard"; fail=1; } +[ -e "$T/sbin/na-ctguard" ] || { echo "[x] v4.2: ctguard не поставился (подготовка проверки снятия)"; fail=1; } +# второй прогон БЕЗ ENV: none держится, явный SSH_PORT держится; ctguard выключаем — снимается +set +e +ENABLE_CTGUARD=0 ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-v42b.log" 2>&1 +rc=$? +set -e +[ "$rc" -eq 0 ] || { echo "[x] v4.2: ре-ран упал"; tail -15 "$T/apply-v42b.log"; fail=1; } +grep -q 'udp dport 443 update' "$NFTF" && { echo "[x] v4.2: ре-ран без ENV вернул UDP_PORTS по умолчанию"; fail=1; } +grep -q 'tcp dport { 2222 } ct state new update @ssh4' "$NFTF" || { echo "[x] v4.2: ре-ран потерял явный SSH_PORT=2222"; fail=1; } +[ -e "$T/sbin/na-ctguard" ] && { echo "[x] v4.2: ENABLE_CTGUARD=0 не снял ctguard"; fail=1; } +[ -e "$T/conf/ctguard.conf" ] && { echo "[x] v4.2: ENABLE_CTGUARD=0 оставил ctguard.conf"; fail=1; } +# WHITELIST=none — явная очистка (пустой ENV значит «как в conf») +set +e +WHITELIST=none ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-v42c.log" 2>&1 +WHITELIST= ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-v42d.log" 2>&1 +set -e +grep -q '203.0.113.50' "$NFTF" && { echo "[x] v4.2: WHITELIST=none не очистил whitelist"; fail=1; } +grep -q 'WHITELIST:=none' "$T/conf/protect.conf" || { echo "[x] v4.2: WHITELIST=none не персистится"; fail=1; } +# валидация новых ручек +for bad in 'SAFETY_DELAY=10' 'CROWDSEC_SCOPE=ports' 'DNAT_GUARD=2' 'REMNAWAVE_URL=https://p.example.com REMNAWAVE_TOKEN=abc;id FLEET_SYNC=1'; do + set +e + env $bad ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-bad.log" 2>&1 + rcb=$? + set -e + [ "$rcb" -ne 0 ] || { echo "[x] v4.2: не отвергнуто: $bad"; fail=1; } +done +# устаревший conf: SSH_PORT сохранён старой версией (без explicit), sshd на 22 → оба порта + warn +reset_t +printf ': "${SSH_PORT:=2200}"\n' > "$T/conf/protect.conf" +set +e +ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect.sh" >"$T/apply-v42e.log" 2>&1 +set -e +grep -q 'tcp dport { 2200, 22 } ct state new update @ssh4' "$NFTF" || { echo "[x] v4.2: устаревший SSH_PORT из conf + детект — не оба порта"; fail=1; } +grep -qF 'сохранён старой версией (автодетект)' "$T/apply-v42e.log" || { echo "[x] v4.2: нет warn про устаревший SSH_PORT"; fail=1; } + # ── timeout набора растёт с медленным rate; NA_RATE_TO_*=0 (вечные записи) отвергается ── reset_t set +e diff --git a/tests/common-unit.sh b/tests/common-unit.sh index 0fdc3d6..72337bb 100644 --- a/tests/common-unit.sh +++ b/tests/common-unit.sh @@ -133,6 +133,81 @@ printf ': "${CROWDSEC_STRICT:=0}"\n' > "$T/etc/second.conf" OUT="$(run "load_conf '$CONF' >/dev/null; load_conf '$T/etc/second.conf'")" check "второй load_conf: ключ из первого файла — не ENV (warn есть)" 1 "$(printf '%s\n' "$OUT" | grep -c 'записан старой версией')" +echo "== 6. v4.2: save_conf отсекает обратный слэш ==" +OUT="$(run "export REMNAWAVE_SWAP_SIZE='2G\\'; save_conf '$T/etc/bs.conf' REMNAWAVE_SWAP_SIZE; cat '$T/etc/bs.conf'")" +check "значение с \\ пропущено (иначе load_conf падал бы на синтаксисе всего файла)" 0 \ + "$(printf '%s\n' "$OUT" | grep -c 'REMNAWAVE_SWAP_SIZE:=')" +check "…и об этом предупреждено" 1 "$(printf '%s\n' "$OUT" | grep -c 'пропускаю REMNAWAVE_SWAP_SIZE')" +OUT="$(run "export NA_EXPLICIT_KEYS=' SSH_PORT'; export SSH_PORT=2222; save_conf '$T/etc/ex.conf' SSH_PORT; conf_is_explicit '$T/etc/ex.conf' SSH_PORT && echo EXPL")" || true +check "NA_EXPLICIT_KEYS → метка # explicit: SSH_PORT" 1 "$(grep -cx '# explicit: SSH_PORT' "$T/etc/ex.conf" 2>/dev/null || true)" +check "conf_is_explicit её видит" EXPL "$(printf '%s\n' "$OUT" | tail -1)" + +echo "== 7. v4.2: detect_ssh_port — все порты, свойство Listen сокет-юнита ==" +mkdir -p "$T/bin2" +cat > "$T/bin2/systemctl" <<'SC' +#!/bin/sh +case "$*" in + "is-active --quiet ssh.socket") [ "${NA_T_SOCK:-0}" = 1 ] && exit 0; exit 3 ;; + "is-active --quiet sshd.socket") exit 3 ;; + "show ssh.socket -p Listen --value") echo "[::]:2222 (Stream) 0.0.0.0:22 (Stream)" ;; +esac +exit 0 +SC +cat > "$T/bin2/sshd" <<'SD' +#!/bin/sh +[ "$1" = "-T" ] && printf 'port 22\nport 2200\naddressfamily any\n' +SD +chmod +x "$T/bin2"/* +check "ssh.socket активен → оба порта из Listen" "2222,22" "$(PATH="$T/bin2:$PATH" NA_T_SOCK=1 run 'detect_ssh_port')" +check "без сокета → все port из sshd -T" "22,2200" "$(PATH="$T/bin2:$PATH" NA_T_SOCK=0 run 'detect_ssh_port')" + +echo "== 8. v4.2: detect_node_port — свой агент, а не второй тенант ==" +cat > "$T/bin2/ss" <<'SS' +#!/bin/sh +[ "${NA_T_RWNODE:-0}" = 1 ] && echo 'LISTEN 0 511 *:2222 *:* users:(("rw-node",pid=10,fd=20))' +exit 0 +SS +cat > "$T/bin2/docker" <<'DK' +#!/bin/sh +# три контейнера: свой агент (образ по digest, host-сеть), второй тенант (bridge, тот же +# внутренний порт), агент третьего тенанта (host-сеть, свой порт) +case "$*" in + "ps -a --format {{.Names}}") printf 'remnanode\nreka-remnanode\nremnanode-dbn\nnginx\n' ;; + "inspect -f {{.Config.Image}} remnanode") echo 'remnawave/node@sha256:0cdf386dd49f' ;; + "inspect -f {{.Config.Image}} reka-remnanode") echo 'remnawave/node:3.4.1' ;; + "inspect -f {{.Config.Image}} remnanode-dbn") echo 'remnawave/node:latest' ;; + "inspect -f {{.Config.Image}} nginx") echo 'nginx:alpine' ;; + "inspect -f {{.HostConfig.NetworkMode}} remnanode") echo host ;; + "inspect -f {{.HostConfig.NetworkMode}} reka-remnanode") echo reka-node ;; + "inspect -f {{.HostConfig.NetworkMode}} remnanode-dbn") echo host ;; + *"{{range .Config.Env}}"*remnanode-dbn) printf 'NODE_PORT=2322\n' ;; + *"{{range .Config.Env}}"*reka-remnanode) printf 'NODE_PORT=9999\n' ;; + *"{{range .Config.Env}}"*remnanode) printf 'NODE_PORT=2222\n' ;; + *) exit 1 ;; +esac +DK +chmod +x "$T/bin2"/* +check "слушатель rw-node — первый источник" "2222" "$(PATH="$T/bin2:$PATH" NA_T_RWNODE=1 NA_REMNANODE_ENV=/nonexistent run 'detect_node_port')" +check "без слушателя: host-сеть агенты (digest-образ найден, bridge-тенант не в счёт)" "2222,2322" \ + "$(PATH="$T/bin2:$PATH" NA_T_RWNODE=0 NA_REMNANODE_ENV=/nonexistent run 'detect_node_port')" + +echo "== 9. v4.2: SSH_CONNECTION у предков (sudo вычищает его из env) ==" +# /proc в песочнице: процесс теста → «предок» 4242 (sshd-сессия) с SSH_CONNECTION +PCOPY="$T/common-proc.sh" +sed -e "s#/proc/#$T/proc/#g" "$REPO_ROOT/scripts/lib/common.sh" > "$PCOPY" +cat > "$T/ancestor.sh" < "$T/proc/\$\$/status" +: > "$T/proc/\$\$/environ" +printf 'HOME=/root\0SSH_CONNECTION=198.51.100.77 50022 10.0.0.5 2200\0' > "$T/proc/4242/environ" +printf 'Name:\tsshd\nPPid:\t1\n' > "$T/proc/4242/status" +unset SSH_CONNECTION SSH_CLIENT +. "$PCOPY" +echo "\$(ssh_client_ip) \$(ssh_session_port)" +ANC +check "IP и порт сессии найдены у предка" "198.51.100.77 2200" "$("$WBASH" "$T/ancestor.sh" 2>&1)" +check "без SSH_CONNECTION нигде — пусто, без ошибок" "" "$(env -u SSH_CONNECTION "$WBASH" -c ". '$PCOPY'; ssh_client_ip" 2>&1)" + echo echo "итого: ok=$PASS fail=$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/tests/ctguard-unit.sh b/tests/ctguard-unit.sh index 11382aa..fcbff0f 100755 --- a/tests/ctguard-unit.sh +++ b/tests/ctguard-unit.sh @@ -27,6 +27,7 @@ SELF_IP='198.51.100.7' # адрес самой ноды PHANTOM='203.0.113.66' # холдер без живых сокетов — законный кандидат LEGIT='192.0.2.55' # клиент за CGNAT: много conntrack, но есть живые сокеты DOCKER='172.17.0.4' # контейнерный бридж +DNATCLI='192.0.2.77' # клиент второго тенанта: DNAT в контейнер, сокеты в его netns # ── Достаём хелпер из heredoc protect.sh ──────────────────────────────────────── awk "/cat > \/usr\/local\/sbin\/na-ctguard <<'CTG'/{f=1;next} f&&/^CTG\$/{exit} f" \ @@ -67,6 +68,10 @@ i=0; while [ \$i -lt 4200 ]; do i=0; while [ \$i -lt 4100 ]; do echo "ipv4 2 tcp 6 7440 ESTABLISHED src=$DOCKER dst=$SELF_IP sport=\$((60000+i)) dport=443" i=\$((i+1)); done +# DNAT: reply-src (контейнер) ≠ orig-dst (адрес ноды) — живые сокеты не в netns хоста +i=0; while [ \$i -lt 4300 ]; do + echo "ipv4 2 tcp 6 7440 ESTABLISHED src=$DNATCLI dst=$SELF_IP sport=\$((20000+i)) dport=8443 src=172.31.250.2 dst=$DNATCLI sport=443 dport=\$((20000+i)) [ASSURED]" + i=\$((i+1)); done CT cat > "$T/bin/ip" </dev/null || true + [[ -n "${CPID:-}" ]] && kill "$CPID" 2>/dev/null || true + ip netns del na-t-ctr 2>/dev/null || true ip netns del "$SRV" 2>/dev/null || true ip netns del "$CLI" 2>/dev/null || true rm -rf "$T" @@ -50,11 +55,18 @@ OUT="$(TERM=dumb NA_NO_LOCK=1 DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 ENABLE_CROWDS F="$(sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' <<<"$OUT" | sed -nE 's/.*Генерация nftables → (\/tmp\/na_filter\.[A-Za-z0-9]+\.nft).*/\1/p' | tail -1)" [[ -n "$F" && -s "$F" ]] || { echo "[x] не нашёл сгенерированный ruleset"; echo "$OUT" | tail -20; exit 1; } cp "$F" "$T/ruleset.nft"; rm -f "$F" +# вариант с SYNPROXY — отдельный ruleset (synproxy меняет путь рукопожатия на TCP_PORTS) +OUT="$(TERM=dumb NA_NO_LOCK=1 DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 ENABLE_CROWDSEC=0 \ + TCP_PORTS=8443 UDP_PORTS=none NODE_PORT=none ENABLE_SYNPROXY=1 \ + bash "$T/scripts/protect.sh" 2>&1)" || { echo "$OUT" | tail -20; echo "[x] генерация SYNPROXY-варианта не прошла"; exit 1; } +F="$(sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' <<<"$OUT" | sed -nE 's/.*Генерация nftables → (\/tmp\/na_filter\.[A-Za-z0-9]+\.nft).*/\1/p' | tail -1)" +cp "$F" "$T/ruleset-sp.nft"; rm -f "$F" # timeout набора = max(пол, 2× восстановление корзины): 3/2 с → 2 с × 2 = 4 с SET_TO=4 # ── Стенд: srv ←veth→ cli (10 адресов v4 и 8 адресов v6) ───────────────────────── -ip netns add "$SRV"; ip netns add "$CLI" +CTR=na-t-ctr +ip netns add "$SRV"; ip netns add "$CLI"; ip netns add "$CTR" ip link add na-t0 type veth peer name na-t1 ip link set na-t0 netns "$SRV"; ip link set na-t1 netns "$CLI" ip -n "$SRV" addr add 10.77.0.1/24 dev na-t0 @@ -63,6 +75,26 @@ for i in $(seq 11 20); do ip -n "$CLI" addr add "10.77.0.$i/24" dev na-t1; done for i in $(seq 11 18); do ip -n "$CLI" -6 addr add "fd77::$i/64" dev na-t1 nodad; done for ns in "$SRV" "$CLI"; do ip -n "$ns" link set lo up; done ip -n "$SRV" link set na-t0 up; ip -n "$CLI" link set na-t1 up +# «контейнер» за DNAT: srv:9090 → ctr:80 (как docker -p 9090:80 в bridge-сети) +ip link add na-c0 type veth peer name na-c1 +ip link set na-c0 netns "$SRV"; ip link set na-c1 netns "$CTR" +ip -n "$SRV" addr add 10.78.0.1/24 dev na-c0; ip -n "$CTR" addr add 10.78.0.2/24 dev na-c1 +ip -n "$SRV" link set na-c0 up; ip -n "$CTR" link set na-c1 up; ip -n "$CTR" link set lo up +ip -n "$CTR" route add default via 10.78.0.1 +ip netns exec "$SRV" sysctl -q -w net.ipv4.ip_forward=1 +ip netns exec "$SRV" nft -f - <<'DNAT' +table ip t_docker { + chain pre { type nat hook prerouting priority dstnat; policy accept; tcp dport 9090 dnat to 10.78.0.2:80; } +} +DNAT +ip netns exec "$CTR" python3 - <<'CTRPY' & +import socket +s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("10.78.0.2", 80)); s.listen(128) +while True: + c, _ = s.accept(); c.close() +CTRPY +CPID=$! ip netns exec "$SRV" python3 - <<'PY' & import socket, threading @@ -123,7 +155,7 @@ echo "== 1. ruleset 4.1.3 встаёт поверх живой таблицы с if ip netns exec "$SRV" nft -f "$T/ruleset.nft"; then ok_ "nft -f поверх таблицы с meter прошёл"; else fail_ "nft -f поверх таблицы с meter НЕ прошёл"; exit 1; fi echo "== 2. наборы-лимитеры объявлены с timeout ==" -for st in syn4_8443 syn6_8443 udp4_8443 ssh4 icmp4 ps4; do +for st in syn4_8443 syn6_8443 udp4_8443 ssh4 sshs4 icmp4 ps4; do hdr="$(ip netns exec "$SRV" nft list set inet na_filter "$st" 2>/dev/null | grep -E 'flags|timeout' | tr -s '\t ' ' ')" if [[ "$hdr" == *"dynamic,timeout"* && "$hdr" == *" timeout "* ]]; then ok_ "$st: flags dynamic,timeout + timeout" else fail_ "$st: нет timeout в объявлении набора ($hdr)"; fi @@ -133,8 +165,9 @@ echo "== 3. лимит действует: сверх burst $BURST при $RATE/ okr="$(connects 10.77.0.19 10.77.0.1 8443 10)" [[ "$okr" -lt 10 ]] && ok_ "из 10 быстрых соединений прошли не все (лимит сработал), прошло $okr" || fail_ "лимит не сработал: прошло $okr из 10" [[ "$okr" -ge "$BURST" ]] && ok_ "burst отработал: прошло не меньше $BURST ($okr)" || fail_ "burst не отработал: прошло $okr < $BURST" -drops="$(ip netns exec "$SRV" nft list chain inet na_filter synflood_drop | grep -oE 'counter packets [0-9]+' | grep -oE '[0-9]+' | head -1)" -[[ "${drops:-0}" -gt 0 ]] && ok_ "счётчик synflood_drop вырос (${drops})" || fail_ "счётчик synflood_drop не вырос" +cnt() { ip netns exec "$SRV" nft list counter inet na_filter "$1" 2>/dev/null | grep -oE 'packets [0-9]+' | grep -oE '[0-9]+' | head -1; } +drops="$(cnt c_synflood)" +[[ "${drops:-0}" -gt 0 ]] && ok_ "именованный счётчик c_synflood вырос (${drops})" || fail_ "счётчик c_synflood не вырос" echo "== 4. записи истекают после простоя (timeout ${SET_TO}s) ==" for _ in $(seq 1 20); do [[ "$(elems set syn4_8443)" == 0 ]] && break; sleep 1; done @@ -153,17 +186,43 @@ for i in $(seq 11 18); do okc=$(( okc + $(connects "fd77::$i" fd77::1 8443 1) )) check "v6: 8 новых адресов при наборе на $SIZE — все соединились" 8 "$okc" echo "== 6. SSH: ban-once и полный набор ==" -# .20: burst 2 → 3-я попытка сверх лимита → suspect, 4-я → autoban -connects 10.77.0.20 10.77.0.1 22 4 >/dev/null -in_set autoban_v4 10.77.0.20 && ok_ "перебор SSH: адрес в autoban (ban-once suspect→бан)" || fail_ "перебор SSH не привёл к бану" -# ssh4 полон (.20 + три новых), новые адреса — под общий потолок 30/мин, а не открытая дверь -for i in 11 12 13; do connects "10.77.0.$i" 10.77.0.1 22 1 >/dev/null; done +# .19: burst 2, ещё 3 попытки сверх — это не перебор: suspect, но БЕЗ бана (v4.2) +connects 10.77.0.19 10.77.0.1 22 5 >/dev/null +in_set suspect_v4 10.77.0.19 && ok_ "пара лишних попыток → suspect" || fail_ "лишние попытки не дали suspect" +in_set autoban_v4 10.77.0.19 && fail_ "пара лишних попыток → бан (ban-once банит с первого превышения)" || ok_ "пара лишних попыток — без бана" +# .20: настоящий перебор — сверх лимита и дальше, пока suspect → бан +connects 10.77.0.20 10.77.0.1 22 14 >/dev/null +in_set autoban_v4 10.77.0.20 && ok_ "перебор SSH: адрес в autoban (suspect → повторный перебор → бан)" || fail_ "перебор SSH не привёл к бану" +[[ "$(cnt c_sshflood)" -gt 0 ]] && ok_ "счётчик c_sshflood вырос" || fail_ "счётчик c_sshflood не вырос" +# ssh4 полон (.19, .20 + два новых), новые адреса — под общий потолок 30/мин, а не открытая дверь +for i in 11 12; do connects "10.77.0.$i" 10.77.0.1 22 1 >/dev/null; done check "ssh4 заполнен до $SIZE" "$SIZE" "$(elems set ssh4)" okc=0 for i in 14 15 16 17; do okc=$(( okc + $(connects "10.77.0.$i" 10.77.0.1 22 1) )); done check "полный ssh4: новые адреса проходят по общему потолку" 4 "$okc" in_set autoban_v4 10.77.0.14 && fail_ "полный ssh4: невиновный адрес забанен" || ok_ "полный ssh4: без бана невиновных" +echo "== 7. опубликованный (DNAT) порт: вердикты по источнику в forward ==" +check "DNAT: обычный адрес проходит к «контейнеру»" 1 "$(connects 10.77.0.18 10.77.0.1 9090 1)" +ip netns exec "$SRV" nft add element inet na_filter autoban_v4 '{ 10.77.0.18 timeout 5m }' +check "DNAT: забаненный адрес режется (до v4.2 проходил мимо na_filter)" 0 "$(connects 10.77.0.18 10.77.0.1 9090 1)" +[[ "$(cnt c_dnat_guard)" -gt 0 ]] && ok_ "счётчик c_dnat_guard вырос" || fail_ "счётчик c_dnat_guard не вырос" +ip netns exec "$SRV" nft add element inet na_filter whitelist_v4 '{ 10.77.0.18 }' +check "DNAT: whitelist сильнее autoban" 1 "$(connects 10.77.0.18 10.77.0.1 9090 1)" + +echo "== 8. SYNPROXY: рукопожатие на защищённом порту завершается ==" +if modprobe nf_synproxy 2>/dev/null || [[ -d /sys/module/nf_synproxy_core ]]; then + ip netns exec "$SRV" sysctl -q -w net.netfilter.nf_conntrack_tcp_loose=0 + if ip netns exec "$SRV" nft -f "$T/ruleset-sp.nft"; then + ok_ "SYNPROXY-ruleset загружен" + check "соединение через synproxy устанавливается (до v4.2 — нет)" 3 "$(connects 10.77.0.13 10.77.0.1 8443 3)" + else + fail_ "SYNPROXY-ruleset не загрузился" + fi +else + echo " skip (нет nf_synproxy в ядре раннера)" +fi + echo echo " прогон: $PASS ok, $FAIL fail" [[ "$FAIL" -eq 0 ]] || { echo "NFT-BEHAVIOR: FAIL"; exit 1; } diff --git a/tests/protect-unit.sh b/tests/protect-unit.sh index 4df35e7..50ed0c5 100644 --- a/tests/protect-unit.sh +++ b/tests/protect-unit.sh @@ -160,7 +160,7 @@ fi # ─── 3. Сборка анти-скан-правил: PORTSCAN_LOG_RATE ─────────────────────────── echo "== 3. анти-скан: рейт лога вынесен в ручку (issue #35) ==" -awk '/^PORTSCAN=""$/{f=1} f{print} f&&/^fi$/{exit}' "$PROTECT" > "$T/portscan.sh" +awk '/^PORTSCAN=""/{f=1} f{print} f&&/^fi$/{exit}' "$PROTECT" > "$T/portscan.sh" if [[ ! -s "$T/portscan.sh" ]]; then checkf "не смог извлечь сборку PORTSCAN из $PROTECT" else @@ -171,22 +171,30 @@ PORTSCAN_RATE=15; PORTSCAN_BURST=30; PORTSCAN_BAN_TIME=1h; SUSPECT_TIME=30m PORTSCAN_LOG_RATE=60; PORTSCAN_LOG_BURST=30 $1 . '$T/portscan.sh' -printf '%s\n' \"\$PORTSCAN\"" 2>&1 +printf '%s\n' \"\$PORTSCAN\" \"\${PORTSCAN_CHAINS:-}\"" 2>&1 } - check "дефолт v4.1: лог 60/минуту с burst 30" 1 \ + # v4.2: лог — только на переходе адреса в suspect/бан (цепочки ps_suspect*/ps_ban*), а не + # на каждый SYN в закрытый порт: ban-once → 4 лог-правила (v4/v6 × suspect/ban) + check "лог 60/минуту с burst 30 — в цепочках перехода (ban-once: 4)" 4 \ "$(ps_run ':' | grep -c 'limit rate 60/minute burst 30 packets log prefix "\[na portscan\] "')" + check "лога на КАЖДЫЙ SYN больше нет (правило с ct state new + log)" 0 \ + "$(ps_run ':' | grep -c 'ct state new limit rate .* log prefix "\[na portscan\]')" + check "suspect логируется один раз: уже-suspect — return до лога" 1 \ + "$(ps_run ':' | grep -c 'ip saddr @suspect_v4 return')" check "прежние 5/second из правила ушли" 0 "$(ps_run ':' | grep -c '5/second log prefix "\[na portscan\]')" - check "ручки доезжают до правила" 1 \ + check "ручки доезжают до правила" 4 \ "$(ps_run 'PORTSCAN_LOG_RATE=5; PORTSCAN_LOG_BURST=7' | grep -c 'limit rate 5/minute burst 7 packets')" - check "PORTSCAN_LOG_RATE=0 → лог-правила нет вовсе" 0 \ + check "PORTSCAN_LOG_RATE=0 → лог-правил нет вовсе" 0 \ "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c '\[na portscan\]')" - check "…но бан продолжает работать (meter → @autoban)" 1 \ + check "…но бан продолжает работать (→ @autoban)" 1 \ "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c 'add @autoban_v4')" - check "…и наблюдение ban-once тоже (meter → @suspect)" 1 \ + check "…и наблюдение ban-once тоже (→ @suspect)" 1 \ "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c 'add @suspect_v4 { ip saddr timeout 30m }')" - check "в ruleset остаётся видно, ПОЧЕМУ лога нет" 1 \ + check "бан считает именованный счётчик c_portscan" 2 \ + "$(ps_run ':' | grep -c 'counter name c_portscan drop')" + check "в ruleset остаётся видно, ПОЧЕМУ лога нет" 4 \ "$(ps_run 'PORTSCAN_LOG_RATE=0' | grep -c 'PORTSCAN_LOG_RATE=0')" - check "без ban-once правило лога такое же" 1 \ + check "без ban-once — лог только на бане (v4/v6)" 2 \ "$(ps_run 'ENABLE_BANONCE=0' | grep -c 'limit rate 60/minute burst 30 packets log prefix "\[na portscan\] "')" fi @@ -297,6 +305,136 @@ check "широкий /24 предупреждён" 1 "$(grep -c 'ПОЛНЫЙ check "хелпер na-fw-status собран со вшитым счётчиком" 1 \ "$(grep -c '^nft_set_count ' "$A/sbin/na-fw-status")" +# ─── 6. v4.2: CROWDSEC_SCOPE — блок-листы только на SSH ─────────────────────── +echo "== 6. CrowdSec scope: set-only bouncer + правило только на SSH (v4.2) ==" +CS="$T/cs"; mkdir -p "$CS/bin" "$CS/conf" "$CS/sys" "$CS/backup" +cat > "$CS/bouncer.yaml" <<'YML' +mode: nftables +deny_action: DROP +blacklists_ipv4: crowdsec-blacklists +blacklists_ipv6: crowdsec6-blacklists +nftables: + ipv4: + enabled: true + set-only: false + table: crowdsec + chain: crowdsec-chain + priority: -10 + ipv6: + enabled: true + set-only: false + table: crowdsec6 + chain: crowdsec6-chain + priority: -10 +YML +printf '#!/bin/sh\nexit 0\n' > "$CS/bin/nft" +printf '#!/bin/sh\necho "$*" >> "%s/systemctl.log"\ncase "$*" in "is-enabled --quiet crowdsec-nft-scope.service") exit 1;; esac\nexit 0\n' "$CS" > "$CS/bin/systemctl" +chmod +x "$CS/bin"/* +sed -n '/^# ─── Область действия блок-листов CrowdSec/,/^# ─── Сейфти-таймер/p' "$PROTECT" | sed '$d' \ + | sed -e "s#/etc/systemd/system/#$CS/sys/#g" > "$CS/scope.sh" +if [[ ! -s "$CS/scope.sh" ]]; then + checkf "в $PROTECT нет crowdsec_scope_apply (CROWDSEC_SCOPE)" +else + cs_run() { # cs_run + PATH="$CS/bin:$PATH" "$WBASH" -c "set -euo pipefail +. '$REPO_ROOT/scripts/lib/common.sh' +CONF_DIR='$CS/conf'; BACKUP='$CS/backup'; CS_BOUNCER_YAML='$CS/bouncer.yaml' +CROWDSEC_SCOPE='$1'; SSH_NFT='22, 2200'; SSH_EFF='22,2200'; WL4='203.0.113.7, 198.51.100.0/29'; WL6='' +. '$CS/scope.sh' +crowdsec_scope_apply" 2>&1 + } + OUT="$(cs_run ssh)" + check "ssh: bouncer переведён в set-only (обе семьи)" 2 "$(grep -c 'set-only: true' "$CS/bouncer.yaml")" + check "ssh: правило дропа — только на SSH-портах" 1 \ + "$(grep -c 'tcp dport { 22, 2200 } ip saddr @crowdsec-blacklists counter drop' "$CS/conf/na-crowdsec-scope.nft")" + check "ssh: whitelist исключён раньше дропа (CAPI-адрес админа не режет SSH)" 1 \ + "$(grep -c 'ip saddr { 203.0.113.7, 198.51.100.0/29 } return # na-wl4' "$CS/conf/na-crowdsec-scope.nft")" + check "ssh: пустой v6-whitelist — метка-заглушка для na-fw" 1 "$(grep -c '# na-wl6 (whitelist пуст)' "$CS/conf/na-crowdsec-scope.nft")" + check "ssh: таблица/набор из конфига bouncer'а" 2 \ + "$(grep -cE '^table ip crowdsec \{$|set crowdsec-blacklists \{' "$CS/conf/na-crowdsec-scope.nft")" + check "ssh: юнит грузит правила ДО bouncer'а" 1 "$(grep -c 'Before=crowdsec-firewall-bouncer.service' "$CS/sys/na-crowdsec-scope.service")" + check "ssh: bouncer перезапущен (заполнит наборы)" 1 "$(grep -c 'restart crowdsec-firewall-bouncer' "$CS/systemctl.log")" + : > "$CS/systemctl.log" + OUT="$(cs_run all)" + check "all: set-only снят" 0 "$(grep -c 'set-only: true' "$CS/bouncer.yaml")" + check "all: наш юнит и правила убраны" 0 "$(ls "$CS/sys/na-crowdsec-scope.service" "$CS/conf/na-crowdsec-scope.nft" 2>/dev/null | wc -l | tr -d ' ')" + sed -i.bak '/set-only/d' "$CS/bouncer.yaml"; rm -f "$CS/bouncer.yaml.bak" + OUT="$(cs_run ssh)" + check "старый bouncer без set-only → честный откат на all" 1 "$(printf '%s\n' "$OUT" | grep -c 'без опции set-only')" +fi + +# ─── 7. v4.2: na-fw — whitelist во всех слоях одной командой ────────────────── +echo "== 7. na-fw allow/unban (v4.2) ==" +F="$T/nafw"; mkdir -p "$F/bin" "$F/etc" "$F/state" "$F/crowdsec/parsers/s02-enrich" +sed -n "/cat > \/usr\/local\/sbin\/na-fw <<'NAFW'/,/^NAFW\$/p" "$PROTECT" | sed '1d;$d' \ + | sed -e "s#/etc/node-accelerator#$F/etc#g" -e "s#/var/lib/node-accelerator#$F/state#g" \ + -e "s#/etc/crowdsec/parsers/s02-enrich#$F/crowdsec/parsers/s02-enrich#g" \ + -e 's#\[\[ \${EUID:-\$(id -u)} -eq 0 \]\] || die "нужен root"#:#' > "$F/na-fw" +if [[ ! -s "$F/na-fw" ]]; then + checkf "в $PROTECT нет генератора na-fw" +else + printf '#!/bin/sh\necho "$*" >> "%s/nft.log"\nexit 0\n' "$F" > "$F/bin/nft" + printf '#!/bin/sh\nexit 0\n' > "$F/bin/systemctl"; printf '#!/bin/sh\necho "$*" >> "%s/cscli.log"\nexit 0\n' "$F" > "$F/bin/cscli" + printf '#!/bin/sh\nshasum -a 256 "$@" 2>/dev/null || /usr/bin/sha256sum "$@"\n' > "$F/bin/sha256sum" + chmod +x "$F/bin"/* + printf ': "${FW_MODE:=strict}"\n: "${WHITELIST:=203.0.113.7}"\n' > "$F/etc/protect.conf" + cat > "$F/etc/na_filter.nft" <<'NF' +table inet na_filter { + set whitelist_v4 { type ipv4_addr; flags interval; auto-merge; elements = { 203.0.113.7 } } + set whitelist_v6 { type ipv6_addr; flags interval; auto-merge; } +} +NF + printf 'nft_sha256=old\n' > "$F/state/protect.installed" + fw() { PATH="$F/bin:$PATH" "$WBASH" "$F/na-fw" "$@" 2>&1; } + fw allow add 198.51.100.9 2001:db8::5 >/dev/null + check "allow add: protect.conf дополнен" 1 "$(grep -c 'WHITELIST:=203.0.113.7,198.51.100.9,2001:db8::5}' "$F/etc/protect.conf")" + check "allow add: v4 в na_filter.nft" 1 "$(grep -c 'elements = { 198.51.100.9, 203.0.113.7 }' "$F/etc/na_filter.nft")" + check "allow add: v6 в na_filter.nft" 1 "$(grep -c 'set whitelist_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2001:db8::5 } }' "$F/etc/na_filter.nft")" + check "allow add: живой сет (nft add element)" 1 "$(grep -c 'add element inet na_filter whitelist_v4 { 198.51.100.9 }' "$F/nft.log")" + check "allow add: снят из autoban" 1 "$(grep -c 'delete element inet na_filter autoban_v4 { 198.51.100.9 }' "$F/nft.log")" + check "allow add: CrowdSec-yaml пересобран" 1 "$(grep -c '"198.51.100.9"' "$F/crowdsec/parsers/s02-enrich/na-whitelist.yaml")" + check "allow add: хэш в маркере обновлён" 0 "$(grep -c '^nft_sha256=old$' "$F/state/protect.installed")" + fw allow del 203.0.113.7 >/dev/null + check "allow del: убран из conf" 1 "$(grep -c 'WHITELIST:=198.51.100.9,2001:db8::5}' "$F/etc/protect.conf")" + check "allow del: убран из файла" 0 "$(grep -c '203.0.113.7' "$F/etc/na_filter.nft")" + check "allow del: убран из живого сета" 1 "$(grep -c 'delete element inet na_filter whitelist_v4 { 203.0.113.7 }' "$F/nft.log")" + check "мусор вместо адреса — отказ" 1 "$(fw allow add '1.2.3.4;rm' | grep -c 'не IPv4/IPv6/CIDR')" + fw unban 192.0.2.66 >/dev/null + check "unban: autoban и suspect" 2 "$(grep -cE 'delete element inet na_filter (autoban|suspect)_v4 \{ 192.0.2.66 \}' "$F/nft.log")" + check "unban: решения CrowdSec" 1 "$(grep -c 'decisions delete --ip 192.0.2.66' "$F/cscli.log")" +fi + +# ─── 8. v4.2: активные баны переживают ре-ран ──────────────────────────────── +echo "== 8. перенос autoban/suspect через ре-ран (v4.2) ==" +B="$T/bans"; mkdir -p "$B/bin" "$B/state" +cat > "$B/bin/nft" <<'NFTB' +#!/bin/sh +case "$*" in + "list set inet na_filter autoban_v4") + printf 'table inet na_filter {\n\tset autoban_v4 {\n\t\ttype ipv4_addr\n\t\tsize 65536\n\t\tflags dynamic,timeout\n\t\telements = { 203.0.113.10 timeout 1h expires 59m10s608ms,\n\t\t\t 198.51.100.22 timeout 1d expires 220ms }\n\t}\n}\n' ;; + "list set inet na_filter suspect_v4") + printf 'table inet na_filter {\n\tset suspect_v4 {\n\t\ttype ipv4_addr\n\t\tflags dynamic,timeout\n\t\telements = { 192.0.2.5 timeout 30m expires 12m3s }\n\t}\n}\n' ;; + "-f "*) cp "$2" "$NA_T_BANS_OUT" ;; +esac +exit 0 +NFTB +chmod +x "$B/bin/nft" +sed -n '/^# ─── Активные баны переживают ре-ран/,/^snapshot_bans$/p' "$PROTECT" | sed '$d' > "$B/bans.sh" +if [[ ! -s "$B/bans.sh" ]]; then + checkf "в $PROTECT нет snapshot_bans/restore_bans" +else + OUT="$(PATH="$B/bin:$PATH" NA_T_BANS_OUT="$B/restored.nft" "$WBASH" -c "set -euo pipefail +. '$REPO_ROOT/scripts/lib/common.sh' +STATE_DIR='$B/state'; ENABLE_BANONCE=1 +. '$B/bans.sh' +snapshot_bans; restore_bans" 2>&1)" + check "бан с остатком срока возвращён (мс отброшены)" 1 \ + "$(grep -c 'add element inet na_filter autoban_v4 { 203.0.113.10 timeout 59m10s }' "$B/restored.nft" 2>/dev/null || true)" + check "бан, которому осталось < 1с, не возвращается" 0 "$(grep -c '198.51.100.22' "$B/restored.nft" 2>/dev/null || true)" + check "suspect возвращён" 1 "$(grep -c 'add element inet na_filter suspect_v4 { 192.0.2.5 timeout 12m3s }' "$B/restored.nft" 2>/dev/null || true)" + check "временный файл убран" 0 "$(ls "$B/state"/.bans.* 2>/dev/null | wc -l | tr -d ' ')" +fi + echo echo "итого: ok=$PASS fail=$FAIL" [[ "$FAIL" -eq 0 ]] From 947942756e96819dea4b57561251ff2d3ea393eb Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:05:38 +0300 Subject: [PATCH 02/14] =?UTF-8?q?v4.2:=20optimize=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20sysctl=20=D1=81=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BD=D0=B8=D0=BA=D0=BE=D0=BC,=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=20=D0=BF=D0=BE=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=BE=D0=B4=D1=8B,=20RPS/XPS=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B3=D0=B4=D0=B5=20=D0=BD=D1=83?= =?UTF-8?q?=D0=B6=D0=BD=D1=8B,=20journald=2010-*.conf=20+=20persistent,=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20v6-=D1=84=D0=BE=D1=80=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B3=D0=B0,=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=20runtime=20=D0=B1=D0=B5=D0=B7=20=D1=80=D0=B5=D0=B1?= =?UTF-8?q?=D1=83=D1=82=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8=20XanMod;=20common=20=E2=80=94=20default=5Fiface?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20dev,=20apt=20=D0=B6=D0=B4?= =?UTF-8?q?=D1=91=D1=82=20dpkg-=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sysctl: после применения значения наших файлов сверяются с ядром; перебивший ключ файл назван (systemd-analyze cat-config или перебор каталогов по имени), незаписанные ключи — отдельно. Снимок исходных значений — только при первом применении (sysctl.orig). - ip_local_reserved_ports: LISTEN-порты xray/rw-core/rw-node (вкл. 127.0.0.1) и порт node-агента внутри 10000-65535; резерв оператора сохраняется, ушедший порт снимается. - net.ipv6.conf.all.forwarding убран: при accept_ra=1 ядро перестаёт принимать RA. - na-rps-setup: без RPS на 1 vCPU и при RX-очередей ≥ ядер, XPS только с NA_XPS=1, исходные маски — снимок rps-orig.tsv; маршрут — токен после dev. - journald: 10-na-size.conf (старое na-size.conf снимается), volatile → Storage=persistent, если Storage= не задан явно. - XanMod: отказ при Secure Boot и /boot < 200 МБ; initrd и драйвер NIC проверяются до «нужна перезагрузка». - rollback optimize: sysctl/маски RPS-XPS/THP из снимков без ребута, оба имени drop-in. - common: default_iface — токен после `dev` (venet0, multipath); apt_install — DPkg::Lock::Timeout=120. - Тесты: новый tests/optimize-unit.sh, расширен tests/rps-unit.sh. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/common.sh | 27 ++- scripts/optimize.sh | 462 +++++++++++++++++++++++++++++++++++++--- scripts/rollback.sh | 56 ++++- tests/optimize-unit.sh | 471 +++++++++++++++++++++++++++++++++++++++++ tests/rps-unit.sh | 70 +++++- 5 files changed, 1043 insertions(+), 43 deletions(-) create mode 100755 tests/optimize-unit.sh diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 45b2838..ee5ef5d 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -187,17 +187,23 @@ backup_file() { return 0 } +# apt по умолчанию НЕ ждёт чужой dpkg-лок, а падает сразу. На свежем боксе первые минуты +# лок держит unattended-upgrades: обе попытки ниже падали подряд, и optimize/protect +# умирали под set -e на первой же зависимости — при живом репозитории и исправном боксе. +# 120 с хватает, чтобы дождаться штатного прогона unattended-upgrades. +NA_APT_LOCK_TIMEOUT="${NA_APT_LOCK_TIMEOUT:-120}" apt_install() { + local -a lk=(-o "DPkg::Lock::Timeout=$NA_APT_LOCK_TIMEOUT") export DEBIAN_FRONTEND=noninteractive - apt-get update -qq 2>/dev/null || true - if ! apt-get install -y -qq --no-install-recommends "$@" >/dev/null 2>&1; then + apt-get "${lk[@]}" update -qq 2>/dev/null || true + if ! apt-get "${lk[@]}" install -y -qq --no-install-recommends "$@" >/dev/null 2>&1; then # Прерванный прошлый прогон / битый dpkg — частый кейс на чужих нодах: # dpkg --configure -a + `apt-get -f install` чинят состояние, затем один ретрай. warn "apt install $*: первая попытка не прошла — чиню dpkg и повторяю" dpkg --configure -a >/dev/null 2>&1 || true - apt-get install -y -qq -f >/dev/null 2>&1 || true - apt-get update -qq 2>/dev/null || true - apt-get install -y -qq --no-install-recommends "$@" >/dev/null + apt-get "${lk[@]}" install -y -qq -f >/dev/null 2>&1 || true + apt-get "${lk[@]}" update -qq 2>/dev/null || true + apt-get "${lk[@]}" install -y -qq --no-install-recommends "$@" >/dev/null fi } @@ -207,8 +213,15 @@ confirm() { [[ "$ans" =~ ^[yYдД] ]] } -# Основной интерфейс по default route. -default_iface() { ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}'; } +# Основной интерфейс по default route — токен ПОСЛЕ `dev`, а не пятое поле: `$5` верен +# только для `default via dev …`. На OpenVZ маршрут без шлюза +# (`default dev venet0 scope link`) давал «scope», а у multipath (`default proto static +# … nexthop via … dev eth0 …`) — мусор; дальше это имя шло в RPS, NIC-tune и diagnose. +default_iface() { + # awk дочитывает вход до конца (без exit): под pipefail ранний выход дал бы SIGPIPE у ip + ip -o -4 route show default 2>/dev/null \ + | awk '!f { for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); f = 1; break } }' +} # systemd-интервал ("5min"/"12h"/"90s"/"2d"/"300") → секунды. Для расчёта возраста # последнего успешного синка (fleet/blocklist) в диагностике. diff --git a/scripts/optimize.sh b/scripts/optimize.sh index eee2613..8f2015d 100755 --- a/scripts/optimize.sh +++ b/scripts/optimize.sh @@ -18,6 +18,8 @@ # NA_LOG_MAXSIZE=200M NA_LOG_ROTATE=4 NA_LOG_INTERVAL=hourly # NA_JOURNAL_MAX_USE=300M потолок journald (публичную ноду 300M держат <суток) # ENABLE_PSI=0 дописать psi=1 в cmdline → /proc/pressure (нужен reboot; 1 = вкл) +# NA_XPS=0 XPS (раскладка TX-очередей по CPU): 0 — не трогать, её задаёт драйвер +# NA_BOOT_MIN_MB=200 минимум свободного места в /boot для установки ядра XanMod set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -28,7 +30,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # при любом выходе, включая Ctrl-C, чтобы не оставить терминал с невидимым курсором. # tty_tput, а не голый tput: tput решает по TERM, а не по isatty(1), и под # `nohup … > log` управляющие последовательности уезжали в лог раскатки (issue #28). -trap 'tty_tput cnorm' EXIT +# Заодно убираем недописанные кандидаты sysctl-файлов (секция 3), если прогон оборвался. +trap 'tty_tput cnorm; rm -f "${SYSCTL_NEW:-}" "${SYSCTL_CT_NEW:-}" 2>/dev/null || true' EXIT trap 'tty_tput cnorm; exit 130' INT require_root @@ -76,6 +79,8 @@ ENABLE_XANMOD="${ENABLE_XANMOD:-1}" XANMOD_FLAVOR="${XANMOD_FLAVOR:-lts}" BACKUP="$(backup_dir)" REBOOT_NEEDED=0 +# Непусто = ядро поставлено, но проверка готовности к загрузке не прошла (причина). +XANMOD_RISK="" # Причин ребута теперь может быть две (ядро и psi=1 в cmdline) — копим текстом, # чтобы в финале не обещать «установлено новое ядро» там, где его не ставили. REBOOT_WHY="" @@ -196,6 +201,93 @@ xanmod_candidates() { esac } +# ─── Предполётные проверки ядра ────────────────────────────────────────────── +# XanMod не подписан ключом дистрибутива: при включённом UEFI Secure Boot shim откажется +# его грузить, и после ребута нода не поднимется (на облаке без консоли — до тикета +# хостеру). Тесный /boot — та же беда тише: postinst не дописывает initrd, а GRUB уже +# выбрал новое ядро. В обоих случаях ядро пропускаем: остальной тюнинг от него не зависит. +NA_SB_EFIVAR=/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c +NA_BOOT_MIN_MB="${NA_BOOT_MIN_MB:-200}" +[[ "$NA_BOOT_MIN_MB" =~ ^[0-9]+$ ]] || NA_BOOT_MIN_MB=200 +secure_boot_on() { + local st b + if command -v mokutil >/dev/null 2>&1; then + st="$(mokutil --sb-state 2>/dev/null || true)" + case "$st" in + *"SecureBoot enabled"*) return 0 ;; + *"SecureBoot disabled"*) return 1 ;; + esac + fi + # efivar без mokutil: 4 байта атрибутов, затем байт значения (1 = включён) + [[ -r "$NA_SB_EFIVAR" ]] || return 1 + b="$(od -An -t u1 -j4 -N1 "$NA_SB_EFIVAR" 2>/dev/null | tr -d '[:space:]')" + [[ "$b" == 1 ]] +} +# Свободно в /boot, МБ (/boot не отдельным разделом — df покажет корень). Пусто = не измерить. +boot_free_mb() { df -Pm /boot 2>/dev/null | awk 'NR==2 && $4 ~ /^[0-9]+$/ {print $4}'; } +xanmod_preflight() { + local fr + if secure_boot_on; then + warn "UEFI Secure Boot включён: ядро XanMod не подписано ключом дистрибутива, shim его не загрузит — после ребута нода не поднимется. Ядро пропускаю, остальной тюнинг продолжается (нужен XanMod — выключи Secure Boot в прошивке/панели хостера)" + return 1 + fi + fr="$(boot_free_mb)" + if [[ "$fr" =~ ^[0-9]+$ ]] && (( fr < NA_BOOT_MIN_MB )); then + warn "/boot: свободно ${fr} МБ (нужно ≥ ${NA_BOOT_MIN_MB}): ядро и initrd XanMod могут не поместиться, а недописанный initrd — незагружаемая нода. Ядро пропускаю, остальной тюнинг продолжается (освободить: apt-get autoremove --purge — старые ядра)" + return 1 + fi + return 0 +} + +# Версия ядра, которую принёс мета-пакет XanMod: Depends → linux-image-<версия>. +# Фоллбэк — самый свежий по времени vmlinuz XanMod в /boot. +xanmod_kver() { + local v + v="$(dpkg-query -W -f='${Depends}' "$1" 2>/dev/null | grep -oE 'linux-image-[^ ,()|]+' | head -1 || true)" + v="${v#linux-image-}" + if [[ -z "$v" ]]; then + # shellcheck disable=SC2012 # имена ядер без пробелов; нужна сортировка по mtime + v="$(ls -1t /boot/vmlinuz-*xanmod* 2>/dev/null | head -1 || true)" + v="${v#/boot/vmlinuz-}" + fi + [[ -n "$v" ]] && echo "$v" +} +# Драйвер NIC по sysfs: …/device/driver → ../../bus/virtio/drivers/virtio_net. +nic_driver() { + local d; d="$(readlink "/sys/class/net/$1/device/driver" 2>/dev/null || true)" + [[ -n "$d" ]] && basename "$d" +} +# xanmod_postcheck <версия> — поднимется ли новое ядро с сетью: initrd собран, а +# драйвер текущего NIC есть в /lib/modules/<версия> модулем или встроенным. Ядро без +# драйвера сетевой карты загрузится — и оставит ноду без сети. stdout — причины, rc=1. +xanmod_postcheck() { + local v="$1" nic="${2:-}" drv alt found why="" + if [[ ! -s "/boot/initrd.img-$v" ]]; then + # оборванный postinst (место, kill) — одна попытка собрать initrd заново + if command -v update-initramfs >/dev/null 2>&1; then + update-initramfs -c -k "$v" >/dev/null 2>&1 || true + fi + [[ -s "/boot/initrd.img-$v" ]] || why+="нет /boot/initrd.img-$v; " + fi + if [[ ! -d "/lib/modules/$v" ]]; then + why+="нет /lib/modules/$v; " + elif [[ -n "$nic" ]]; then + drv="$(nic_driver "$nic" || true)" + if [[ -n "$drv" ]]; then + found=0 + for alt in "$drv" "${drv//_/-}" "${drv//-/_}"; do + # модулем (любое сжатие: .ko/.ko.xz/.ko.zst) или встроенным в ядро + if [[ -n "$(find "/lib/modules/$v" -name "$alt.ko*" -print -quit 2>/dev/null)" ]]; then found=1; break; fi + if grep -qE "/${alt}\.ko(\.[a-z]+)?\$" "/lib/modules/$v/modules.builtin" 2>/dev/null; then found=1; break; fi + done + [[ "$found" == 1 ]] || why+="драйвера NIC $nic ($drv) нет в /lib/modules/$v ни модулем, ни встроенным; " + fi + fi + [[ -z "$why" ]] && return 0 + printf '%s' "${why%; }" + return 1 +} + install_xanmod() { setup_xanmod_repo || return 1 info "psABI уровень CPU: x86-64-v$(cpu_psabi_level), сборка: $XANMOD_FLAVOR" @@ -239,7 +331,19 @@ install_xanmod() { mkdir -p "$STATE_DIR"; echo "$pkg" > "$STATE_DIR/xanmod.pkg" update-grub >/dev/null 2>&1 || true - ok "XanMod установлен: $pkg (активируется ПОСЛЕ перезагрузки)" + # «Нужна перезагрузка» обещаем, только убедившись, что новое ядро поднимет ноду: + # GRUB уже выбрал его по умолчанию, и ошибку увидят только после ребута. + local kv why + kv="$(xanmod_kver "$pkg" || true)" + if [[ -z "$kv" ]]; then + XANMOD_RISK="версия ядра пакета $pkg не определена — initrd и драйвер NIC не проверены (ls /boot/initrd.img-*xanmod* /lib/modules)" + warn "XanMod установлен: $pkg, но $XANMOD_RISK" + elif why="$(xanmod_postcheck "$kv" "$(default_iface || true)")"; then + ok "XanMod установлен: $pkg (ядро $kv: initrd и драйвер NIC на месте; активируется ПОСЛЕ перезагрузки)" + else + XANMOD_RISK="$why" + warn "XanMod установлен: $pkg ($kv), но к загрузке НЕ готов: $why" + fi REBOOT_NEEDED=1 REBOOT_WHY="${REBOOT_WHY:+$REBOOT_WHY; }новое ядро XanMod ($pkg)" return 0 @@ -273,7 +377,7 @@ if [[ "$ENABLE_XANMOD" == "1" ]]; then elif [[ "$(uname -r)" == *xanmod* ]]; then ok "XanMod уже стоит ($(uname -r)) — обновляю только репозиторий (чтобы шли апдейты ядра)" setup_xanmod_repo || warn "репозиторий XanMod не обновлён (само ядро не тронуто)" - else + elif xanmod_preflight; then install_xanmod || warn "XanMod не установлен — продолжаю с текущим ядром" fi else @@ -361,8 +465,79 @@ QDISC="${QDISC:-fq}" # overcommit: на tier1 (≤1.2G) heuristic (0) безопаснее агрессивного always-overcommit (1). OVERCOMMIT=1; [[ "$TIER" -le 1 ]] && OVERCOMMIT=0 info "RAM-tier $TIER (~${_mem_mb} MB): sock_max=$SOCK_MAX def=$SOCK_DEF ecn=$TCP_ECN_MODE tfo=$TFO_VAL overcommit=$OVERCOMMIT qdisc=$QDISC" -backup_file /etc/sysctl.d/99-node-accelerator.conf "$BACKUP" -cat > /etc/sysctl.d/99-node-accelerator.conf < <список «a,b-c»> — порт покрыт списком (числом или диапазоном)? +port_in_list() { + local p="$1" t + for t in ${2//,/ }; do + [[ "$t" =~ ^[0-9]+(-[0-9]+)?$ ]] || continue + if [[ "$t" == *-* ]]; then + (( p >= ${t%-*} && p <= ${t#*-} )) && return 0 + elif (( p == t )); then + return 0 + fi + done + return 1 +} +# LISTEN-порты процессов ноды (xray / rw-core / rw-node; TCP и UDP — диапазон общий) + +# порт node-агента. По строке на порт. +na_node_listen_ports() { + { ss -Htlnp 2>/dev/null; ss -Hulnp 2>/dev/null; } \ + | awk '/users:\(\("(xray|rw-core|rw-node)"/ { la = $4; sub(/.*:/, "", la); if (la ~ /^[0-9]+$/) print la }' || true + detect_node_port 2>/dev/null | tr ',' '\n' || true +} +# Итоговый ip_local_reserved_ports (NA_RESV_LIST): резерв оператора (текущее значение +# минус то, что ставили мы прошлым прогоном — иначе порт, с которого xray давно ушёл, жил +# бы вечно) + порты ноды в эфемерном диапазоне. В NA_RESV_OURS — только наши. Зовётся +# БЕЗ $(…): обе переменные — результат, подоболочка их бы потеряла. +NA_RESV_OURS=""; NA_RESV_LIST="" +na_reserved_ports() { + local prev="" cur t p list="" + prev="$(cat "$NA_RESV_STATE" 2>/dev/null || true)" + cur="$(sysctl -n net.ipv4.ip_local_reserved_ports 2>/dev/null || true)" + for t in ${cur//,/ }; do + [[ "$t" =~ ^[0-9]+(-[0-9]+)?$ ]] || continue + [[ ",$prev," == *",$t,"* ]] && continue + list+="${list:+,}$t" + done + NA_RESV_OURS="" + for p in $(na_node_listen_ports | sort -un); do + [[ "$p" =~ ^[0-9]+$ ]] || continue + (( p >= NA_EPH_LO && p <= NA_EPH_HI )) || continue + port_in_list "$p" "$list" && continue + list+="${list:+,}$p"; NA_RESV_OURS+="${NA_RESV_OURS:+,}$p" + done + # ядро печатает список отсортированным — пишем так же, чтобы сверка ниже не спотыкалась + NA_RESV_LIST="" + [[ -n "$list" ]] && NA_RESV_LIST="$(printf '%s\n' "${list//,/$'\n'}" | sort -t- -k1,1n | paste -sd, -)" + return 0 +} +na_reserved_ports + +backup_file "$SYSCTL_FILE" "$BACKUP" +backup_file "$SYSCTL_CT_FILE" "$BACKUP" +# Пишем во временный файл рядом (не *.conf — sysctl --system его не прочтёт): снимок +# исходных значений нужно снять ДО того, как наш файл начнёт действовать. +SYSCTL_NEW="$(mktemp /etc/sysctl.d/.na-sysctl.XXXXXX)" +SYSCTL_CT_NEW="$(mktemp /etc/sysctl.d/.na-sysctl-ct.XXXXXX)" +cat > "$SYSCTL_NEW" <> "$SYSCTL_NEW" < /etc/sysctl.d/99-node-accelerator-conntrack.conf < "$SYSCTL_CT_NEW" </dev/null || true modprobe nf_conntrack 2>/dev/null || true + +# sysctl_keys <файл…> — «ключзначение» по строкам sysctl-файла (комментарии мимо). +sysctl_keys() { + awk ' + /^[[:space:]]*([#;]|$)/ { next } + { line = $0; sub(/^[[:space:]]*-/, "", line); i = index(line, "="); if (!i) next + k = substr(line, 1, i - 1); gsub(/[[:space:]]/, "", k); gsub(/\//, ".", k) + v = substr(line, i + 1); gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) + print k "\t" v }' "$@" 2>/dev/null +} +if [[ "$SYSCTL_FIRST" == 1 ]]; then + mkdir -p "$STATE_DIR" + { + # ip_local_reserved_ports — всегда: даже если сейчас нечего резервировать, ре-ран + # может начать его писать, а откат должен вернуть значение оператора + { sysctl_keys "$SYSCTL_NEW" "$SYSCTL_CT_NEW" | cut -f1; echo net.ipv4.ip_local_reserved_ports; } \ + | sort -u | while IFS= read -r _k; do + [[ -e "/proc/sys/${_k//.//}" ]] || continue + _v="$(sysctl -n "$_k" 2>/dev/null)" || continue + printf '%s\t%s\n' "$_k" "$_v" + done + } > "$SYSCTL_ORIG.tmp" 2>/dev/null && mv -f "$SYSCTL_ORIG.tmp" "$SYSCTL_ORIG" || rm -f "$SYSCTL_ORIG.tmp" + [[ -s "$SYSCTL_ORIG" ]] && info "снимок исходных значений sysctl: $SYSCTL_ORIG (для отката без ребута)" +fi +chmod 0644 "$SYSCTL_NEW" "$SYSCTL_CT_NEW" +mv -f "$SYSCTL_NEW" "$SYSCTL_FILE" +mv -f "$SYSCTL_CT_NEW" "$SYSCTL_CT_FILE" echo "tcp_bbr" > /etc/modules-load.d/na-bbr.conf echo "nf_conntrack" > /etc/modules-load.d/na-conntrack.conf sysctl --system >/dev/null 2>&1 || true +# Своих портов больше нет, а прошлым прогоном мы их резервировали — `sysctl --system` +# ключ, который никто не задаёт, не сбрасывает: возвращаем значение оператора руками. +if [[ -z "$NA_RESV_LIST" && -s "$NA_RESV_STATE" && -w /proc/sys/net/ipv4/ip_local_reserved_ports ]]; then + printf '\n' > /proc/sys/net/ipv4/ip_local_reserved_ports 2>/dev/null || true +fi +if [[ -n "$NA_RESV_OURS" ]]; then + mkdir -p "$STATE_DIR"; printf '%s\n' "$NA_RESV_OURS" > "$NA_RESV_STATE" + ok "ip_local_reserved_ports: порты ноды $NA_RESV_OURS вынесены из эфемерного диапазона (итог: $NA_RESV_LIST)" +else + rm -f "$NA_RESV_STATE" + info "ip_local_reserved_ports: портов ноды в эфемерном диапазоне не нашёл (xray ещё не запущен? ре-ран optimize после старта ноды)" +fi + +# ─── Сверка: действуют ли НАШИ значения ────────────────────────────────────── +# Файл, который сортируется позже нашего (99-tuning.conf > 99-node-accelerator.conf), +# молча перебивает ключи — на ноде флота так буферы урезались до 16M, а optimize печатал +# «применено». Сверяем ядро с нашими файлами и называем виновника. +# sysctl_conf_order — файлы в порядке применения (последний, задавший ключ, побеждает): +# как у systemd-sysctl и `sysctl --system` — по ИМЕНИ через все каталоги, одноимённый в +# /etc перекрывает /run и /usr/lib; /etc/sysctl.conf procps дочитывает последним. +sysctl_conf_order() { + local out="" d f b + if command -v systemd-analyze >/dev/null 2>&1; then + out="$(systemd-analyze cat-config sysctl.d 2>/dev/null | sed -nE 's|^# (/[^ ]+\.conf)$|\1|p' || true)" + fi + if [[ -z "$out" ]]; then + local -A seen=() + for d in /etc/sysctl.d /run/sysctl.d /usr/local/lib/sysctl.d /usr/lib/sysctl.d /lib/sysctl.d; do + for f in "$d"/*.conf; do + [[ -f "$f" ]] || continue + b="${f##*/}"; [[ -n "${seen[$b]:-}" ]] || seen[$b]="$f" + done + done + out="$(for b in "${!seen[@]}"; do printf '%s\t%s\n' "$b" "${seen[$b]}"; done | LC_ALL=C sort | cut -f2-)" + fi + printf '%s\n' "$out" + [[ -f /etc/sysctl.conf ]] && echo /etc/sysctl.conf + return 0 +} +# sysctl_winner <ключ> — «файлзначение» последнего файла, задающего ключ. +sysctl_winner() { + local k="$1" f kv w="" wv="" + while IFS= read -r f; do + [[ -n "$f" && -f "$f" ]] || continue + kv="$(sysctl_keys "$f" | awk -F'\t' -v k="$k" '$1 == k { v = $2; n = 1 } END { if (n) print v; else exit 1 }')" || continue + w="$f"; wv="$kv" + done < <(sysctl_conf_order) + [[ -n "$w" ]] && printf '%s\t%s\n' "$w" "$wv" + return 0 +} +_sy_norm() { tr -s '[:space:]' ' ' <<<"$1" | sed -E 's/^ //; s/ $//'; } +sysctl_verify() { + local f k want have win wf wv nb="" p ok_all=1 miss + for f in "$@"; do + [[ -f "$f" ]] || continue + while IFS=$'\t' read -r k want; do + # BBR — своё сообщение ниже; ключа нет в этом ядре — сверять нечего + [[ "$k" == net.ipv4.tcp_congestion_control ]] && continue + [[ -e "/proc/sys/${k//.//}" ]] || continue + have="$(_sy_norm "$(sysctl -n "$k" 2>/dev/null || true)")"; want="$(_sy_norm "$want")" + [[ "$have" == "$want" ]] && continue + if [[ "$k" == net.ipv4.ip_local_reserved_ports ]]; then + # ядро сливает соседние порты в диапазон — сверяем покрытие, а не строку + miss="" + for p in ${want//,/ }; do port_in_list "${p%-*}" "$have" && port_in_list "${p#*-}" "$have" || miss+=" $p"; done + [[ -z "$miss" ]] && continue + fi + ok_all=0 + win="$(sysctl_winner "$k")"; wf="${win%%$'\t'*}"; wv="${win#*$'\t'}" + if [[ -n "$wf" && "$wf" != "$f" ]]; then + warn "sysctl $k = ${have:-?}, а $f ставит $want — перебивает $wf (применяется позже${wv:+: $wv}). Осознанно — оставь; иначе убери ключ оттуда" + else + nb+="${nb:+, }$k=${have:-?} (ждали $want)" + fi + done < <(sysctl_keys "$f") + done + [[ -n "$nb" ]] && warn "sysctl: не применились (ядро/контейнер не дал записать?): $nb" + [[ "$ok_all" == 1 ]] && ok "sysctl: значения наших файлов действуют (перебивающих файлов нет)" + return 0 +} +sysctl_verify "$SYSCTL_FILE" "$SYSCTL_CT_FILE" if [[ "$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)" == "bbr" ]]; then ok "BBR активен (под XanMod это BBRv3)" @@ -521,11 +815,37 @@ title "RPS/RFS/XPS (масштабирование приёма пакетов # в ExecStart юнита RPS — на буте default route появляется ПОЗЖЕ network-online.target, # и автодетект в этот момент пуст (issue #30). Секция 6 берёт уже готовое значение. NIC="$(default_iface || true)" +NA_XPS="${NA_XPS:-0}" +[[ "$NA_XPS" =~ ^[01]$ ]] || { warn "NA_XPS='$NA_XPS' — ожидается 0|1, беру 0"; NA_XPS=0; } +# Снимок исходных масок очередей — при ПЕРВОМ применении (хелпера ещё нет): откат +# возвращает их без ребута, а хелпер — когда RPS на этой ноде не нужен. На ре-ране в +# sysfs уже наши маски, и «исходными» записались бы они. +RPS_ORIG="$STATE_DIR/rps-orig.tsv" +if [[ ! -e /usr/local/sbin/na-rps-setup && ! -f "$RPS_ORIG" ]]; then + mkdir -p "$STATE_DIR" + for _q in /sys/class/net/*/queues/rx-*/rps_cpus /sys/class/net/*/queues/rx-*/rps_flow_cnt \ + /sys/class/net/*/queues/tx-*/xps_cpus; do + [[ -r "$_q" ]] || continue + case "$_q" in /sys/class/net/lo/*|/sys/class/net/veth*|/sys/class/net/docker*|/sys/class/net/br-*) continue;; esac + _v="$(cat "$_q" 2>/dev/null)" || continue + # `[[ -z ]] ||`, а не `[[ -n ]] &&`: пустое значение у последней очереди дало бы + # циклу rc=1, и снимок молча выбросился бы вместо записи + [[ -z "$_v" ]] || printf '%s\t%s\n' "$_q" "$_v" + done > "$RPS_ORIG.tmp" 2>/dev/null && mv -f "$RPS_ORIG.tmp" "$RPS_ORIG" || rm -f "$RPS_ORIG.tmp" + unset _q _v +fi cat > /usr/local/sbin/na-rps-setup <<'RPS' #!/usr/bin/env bash -# Включает Receive/Transmit Packet Steering на основном интерфейсе. +# Включает Receive Packet Steering на основном интерфейсе (XPS — только с NA_XPS=1). # На virtio/single-queue VPS весь RX-softirq иначе висит на cpu0 — это потолок PPS. # +# RPS нужен не везде. На 1 vCPU размазывать нечего, а когда RX-очередей не меньше, чем +# ядер, приём уже раскидан аппаратно (RSS): программный RPS поверх него только гоняет +# пакеты между CPU (на ноде флота — NIC с 12 аппаратными очередями). Там RPS не +# включаем, а маски прошлых версий возвращаем к исходным (снимок optimize) или к 0. +# XPS по умолчанию не трогаем вовсе: раскладку TX-очередей по CPU задаёт драйвер +# (virtio/ena — очередь на ядро), а маска «все CPU» её затирала. +# # Почему тут ожидание маршрута и два фолбэка: network-online.target на буте # достигается РАНЬШЕ, чем в таблице появляется default route (гонка с dhcpcd, та же, # что известна по /etc/resolv.conf). Прежняя версия в этот момент молча делала exit 0, @@ -535,6 +855,9 @@ cat > /usr/local/sbin/na-rps-setup <<'RPS' set -u want="${1:-}" sysdir=/sys/class/net +orig=/var/lib/node-accelerator/rps-orig.tsv +# orig_val <путь sysfs> — значение из снимка optimize (rc=1 — снимка/записи нет) +orig_val() { [ -r "$orig" ] && awk -F'\t' -v p="$1" '$1 == p { v = $2; n = 1 } END { if (n) print v; else exit 1 }' "$orig"; } nics="" # 1) Интерфейс, определённый при установке. После смены ядра он мог переименоваться @@ -546,7 +869,9 @@ else # 2) Ждём default route до ~20 с: сеть на буте поднимается позже юнита. i=0 while [ "$i" -lt 20 ]; do - nics="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')" + # токен после `dev`, а не $5: `default dev venet0 scope link` (OpenVZ) и multipath + nics="$(ip -o -4 route show default 2>/dev/null \ + | awk '!f { for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); f = 1; break } }')" if [ -n "$nics" ]; then break; fi i=$((i + 1)) sleep 1 @@ -575,16 +900,36 @@ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries 2>/dev/null || true applied=0 for NIC in $nics; do [ -d "$sysdir/$NIC" ] || continue - for q in "$sysdir/$NIC"/queues/rx-*; do - [ -e "$q/rps_cpus" ] && echo "$mask" > "$q/rps_cpus" 2>/dev/null || true - [ -e "$q/rps_flow_cnt" ] && echo 4096 > "$q/rps_flow_cnt" 2>/dev/null || true - done - for q in "$sysdir/$NIC"/queues/tx-*; do - [ -e "$q/xps_cpus" ] && echo "$mask" > "$q/xps_cpus" 2>/dev/null || true - done - # Эта строка — маркер успеха в журнале: её отсутствие в `journalctl -u na-rps` - # означает, что RPS не применён, чем бы ни рапортовал статус юнита. - echo "na-rps: NIC=$NIC mask=$mask cpus=$ncpu" + rxq=0 + for q in "$sysdir/$NIC"/queues/rx-*; do [ -e "$q" ] && rxq=$((rxq + 1)); done + if [ "$ncpu" -le 1 ] || [ "$rxq" -ge "$ncpu" ]; then + for q in "$sysdir/$NIC"/queues/rx-*; do + [ -e "$q/rps_cpus" ] && { orig_val "$q/rps_cpus" || echo 0; } > "$q/rps_cpus" 2>/dev/null || true + [ -e "$q/rps_flow_cnt" ] && { orig_val "$q/rps_flow_cnt" || echo 0; } > "$q/rps_flow_cnt" 2>/dev/null || true + done + # Маркер успеха: RPS сознательно не включён (а не «не применился»). + echo "na-rps: NIC=$NIC rps=off rxq=$rxq cpus=$ncpu (RSS/1 CPU — RPS не нужен)" + else + for q in "$sysdir/$NIC"/queues/rx-*; do + [ -e "$q/rps_cpus" ] && echo "$mask" > "$q/rps_cpus" 2>/dev/null || true + [ -e "$q/rps_flow_cnt" ] && echo 4096 > "$q/rps_flow_cnt" 2>/dev/null || true + done + # Эта строка — маркер успеха в журнале: её отсутствие в `journalctl -u na-rps` + # означает, что RPS не применён, чем бы ни рапортовал статус юнита. + echo "na-rps: NIC=$NIC mask=$mask cpus=$ncpu" + fi + if [ "${NA_XPS:-0}" = 1 ]; then + for q in "$sysdir/$NIC"/queues/tx-*; do + [ -e "$q/xps_cpus" ] && echo "$mask" > "$q/xps_cpus" 2>/dev/null || true + done + echo "na-rps: NIC=$NIC xps=$mask" + else + # XPS не наш: возвращаем только то, что записали прошлые версии (есть в снимке) + for q in "$sysdir/$NIC"/queues/tx-*; do + [ -e "$q/xps_cpus" ] || continue + v="$(orig_val "$q/xps_cpus")" && echo "$v" > "$q/xps_cpus" 2>/dev/null || true + done + fi applied=1 done @@ -611,6 +956,7 @@ StartLimitBurst=5 [Service] Type=oneshot RemainAfterExit=yes +Environment=NA_XPS=${NA_XPS} ExecStart=/usr/local/sbin/na-rps-setup ${NIC:-} Restart=on-failure RestartSec=5 @@ -624,7 +970,7 @@ systemctl daemon-reload # буте — нулём), при этом печатался ok. Поэтому: enable + явный restart на каждом прогоне. systemctl enable na-rps.service >/dev/null 2>&1 || true if systemctl restart na-rps.service >/dev/null 2>&1; then - ok "RPS/RFS/XPS включены ($(nproc) ядер, NIC=${NIC:-автодетект на буте})" + ok "na-rps применён ($(nproc) ядер, NIC=${NIC:-автодетект на буте}; RPS — когда RX-очередей меньше, чем ядер; XPS — $([[ "$NA_XPS" == 1 ]] && echo 'все CPU (NA_XPS=1)' || echo 'не трогаем, раскладка драйвера'))" else warn "na-rps.service: не удалось применить RPS — проверь journalctl -u na-rps.service" fi @@ -799,16 +1145,62 @@ title "journald (ограничение логов)" NA_JOURNAL_MAX_USE="${NA_JOURNAL_MAX_USE:-300M}" [[ "$NA_JOURNAL_MAX_USE" =~ ^[0-9]+[KMG]?$ ]] \ || { warn "NA_JOURNAL_MAX_USE='$NA_JOURNAL_MAX_USE' — ожидается размер вида 300M/1G; беру 300M"; NA_JOURNAL_MAX_USE=300M; } -mkdir -p /etc/systemd/journald.conf.d -cat > /etc/systemd/journald.conf.d/na-size.conf </dev/null | tr -d '[]' || true)" + [[ -z "$_v" ]] || printf '%s\t%s\n' "$_k" "$_v" + done > "$THP_ORIG.tmp" 2>/dev/null && mv -f "$THP_ORIG.tmp" "$THP_ORIG" || rm -f "$THP_ORIG.tmp" + [[ -s "$THP_ORIG" ]] || rm -f "$THP_ORIG" + unset _k _v +fi cat > /etc/systemd/system/na-thp-off.service <<'EOF' [Unit] Description=node-accelerator disable THP @@ -1067,6 +1470,9 @@ xanmod=$([[ -f "$STATE_DIR/xanmod.pkg" ]] && cat "$STATE_DIR/xanmod.pkg" || echo reboot_needed=$REBOOT_NEEDED boot_id=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true) psi=$PSI_MARK +tier=$TIER +sock_max=$SOCK_MAX +sock_def=$SOCK_DEF EOF # Персист конфига оптимизатора → ре-ран без ENV не сбрасывает выбор сборки/флейвора. @@ -1074,7 +1480,7 @@ save_conf "$CONF_DIR/optimize.conf" \ ENABLE_XANMOD XANMOD_FLAVOR REMNAWAVE_SWAP_SIZE \ DISABLE_TFO TCP_ECN_MODE ENABLE_MSS_CLAMP SETUP_NO_ZRAM CT_EST_TIMEOUT QDISC \ ENABLE_LOGROTATE NA_LOG_PATHS NA_LOG_MAXSIZE NA_LOG_ROTATE NA_LOG_INTERVAL \ - ENABLE_PSI NA_JOURNAL_MAX_USE + ENABLE_PSI NA_JOURNAL_MAX_USE NA_XPS title "ГОТОВО" ok "Оптимизатор применён." @@ -1085,6 +1491,10 @@ printf " %-32s %s\n" "somaxconn:" "$(sysctl -n net.core.somaxconn 2> printf " %-32s %s\n" "nf_conntrack_max:" "$(sysctl -n net.netfilter.nf_conntrack_max 2>/dev/null || echo n/a)" printf " %-32s %s\n" "file-max:" "$(sysctl -n fs.file-max 2>/dev/null || echo n/a)" echo +if [[ -n "$XANMOD_RISK" ]]; then + err "НЕ ПЕРЕЗАГРУЖАЙСЯ, пока не исправлено: $XANMOD_RISK" + err " GRUB уже выбрал новое ядро. Исправить (update-initramfs -c -k <версия>) или снять ядро XanMod: apt-get purge 'linux-image-*xanmod*' && update-grub" +fi if [[ "$REBOOT_NEEDED" == "1" ]]; then warn "НУЖНА ПЕРЕЗАГРУЗКА (reboot): ${REBOOT_WHY:-изменения в параметрах загрузки}" if [[ "$REBOOT_WHY" == *XanMod* ]]; then diff --git a/scripts/rollback.sh b/scripts/rollback.sh index d5df0d1..165fe75 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -20,7 +20,8 @@ rollback_optimize() { rm -f /etc/sysctl.d/99-node-accelerator.conf /etc/sysctl.d/99-node-accelerator-conntrack.conf rm -f /etc/modules-load.d/na-bbr.conf /etc/modules-load.d/na-conntrack.conf rm -f /etc/systemd/system.conf.d/na-limits.conf /etc/systemd/user.conf.d/na-limits.conf - rm -f /etc/systemd/journald.conf.d/na-size.conf + # journald: имя drop-in'а до v4.2 — na-size.conf, с v4.2 — 10-na-size.conf + rm -f /etc/systemd/journald.conf.d/10-na-size.conf /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) @@ -59,9 +60,60 @@ rollback_optimize() { rm -f "$CONF_DIR/na_mss.nft" systemctl daemon-reload + + # ── Runtime без ребута ─────────────────────────────────────────────────── + # Удалить файлы мало: `sysctl --system` вернёт только ключи, которые задаёт другой + # файл, а остальные держат наши значения до перезагрузки; маски очередей и THP тоже. + # Возвращаем то, что было ДО первого применения (снимки optimize v4.2+), затем + # `sysctl --system` — чужие файлы по-прежнему главнее снимка. + local rt="" n line k v p + if [[ -s "$STATE_DIR/sysctl.orig" ]]; then + n=0 + while IFS= read -r line; do + k="${line%%$'\t'*}"; v="${line#*$'\t'}" + [[ "$k" =~ ^[a-z0-9_.-]+$ ]] || continue + p="/proc/sys/${k//.//}" + [[ -w "$p" ]] || continue + printf '%s\n' "$v" > "$p" 2>/dev/null && n=$((n+1)) + done < "$STATE_DIR/sysctl.orig" + rt+="sysctl — $n ключ(ей) возвращены к значениям до первого прогона; " + else + rt+="sysctl — снимка нет (optimize ставился до v4.2): ключи, которых не задаёт другой файл, держат наши значения до ребута; " + fi sysctl --system >/dev/null 2>&1 || true systemctl restart systemd-journald 2>/dev/null || true + if [[ -s "$STATE_DIR/rps-orig.tsv" ]]; then + while IFS=$'\t' read -r p v; do + [[ "$p" == /sys/class/net/*/queues/* && -e "$p" ]] || continue + printf '%s\n' "$v" > "$p" 2>/dev/null || true + done < "$STATE_DIR/rps-orig.tsv" + rt+="маски RPS/XPS — из снимка; " + else + # RPS включал тулкит, дефолт ядра — 0; раскладку XPS драйвер восстановит на ребуте + local nic; nic="$(awk -F= '/^nic=/{print $2; exit}' "$STATE_DIR/optimize.installed" 2>/dev/null)" + if [[ -n "$nic" && "$nic" != none && -d "/sys/class/net/$nic" ]]; then + for p in /sys/class/net/"$nic"/queues/rx-*/rps_cpus; do + [[ -e "$p" ]] && { printf '0\n' > "$p" 2>/dev/null || true; } + done + fi + rt+="RPS выключен (снимка масок нет), XPS — к раскладке драйвера после ребута; " + fi + if [[ -s "$STATE_DIR/thp.orig" ]]; then + while IFS=$'\t' read -r k v; do + [[ "$k" == enabled || "$k" == defrag ]] || continue + printf '%s\n' "$v" > "/sys/kernel/mm/transparent_hugepage/$k" 2>/dev/null || true + done < "$STATE_DIR/thp.orig" + rt+="THP — из снимка; " + else + rt+="THP — never до ребута (снимка нет); " + fi + if [[ -f "$STATE_DIR/journald.persistent" ]]; then + info "журнал остаётся постоянным: /var/log/journal создал optimize, в нём история (удалить: rm -rf /var/log/journal — история пропадёт)" + fi + rm -f "$STATE_DIR/sysctl.orig" "$STATE_DIR/rps-orig.tsv" "$STATE_DIR/thp.orig" \ + "$STATE_DIR/reserved-ports.na" "$STATE_DIR/journald.persistent" + # psi=1 в GRUB_CMDLINE_LINUX_DEFAULT снимаем ТОЛЬКО если дописывали его МЫ: ровно # в этом случае в маркере есть строка psi=1. Оператор мог включить учёт давления # сам (или он приехал из образа хостера) — чужой параметр загрузки не наш артефакт. @@ -97,7 +149,7 @@ rollback_optimize() { fi fi rm -f "$STATE_DIR/optimize.installed" "$CONF_DIR/optimize.conf" - ok "optimize откатан (значения sysctl вернутся к дефолтам; XanMod — по флагу)" + ok "optimize откатан. Runtime: ${rt%; }. Лимиты nofile для сервисов — после ребута; XanMod — по флагу" } rollback_protect() { diff --git a/tests/optimize-unit.sh b/tests/optimize-unit.sh new file mode 100755 index 0000000..2d88536 --- /dev/null +++ b/tests/optimize-unit.sh @@ -0,0 +1,471 @@ +#!/usr/bin/env bash +# +# optimize-unit.sh — ИСПОЛНЯЕТ секции optimize.sh (sysctl, journald, снимки RPS/THP, +# проверки ядра XanMod), блок отката runtime из rollback.sh и хелперы common.sh +# (default_iface, apt_install) в песочнице против стабов sysctl/ss/systemctl/mokutil/df. +# Секции — top-level код, целиком optimize в CI не гоняется: тот же приём, что в +# logrotate-unit.sh / psi-unit.sh. +# +# Что стережём (v4.2, аудит флота): +# 1. sysctl: чужой файл, сортирующийся позже нашего (99-tuning.conf), перебивает ключ — +# optimize называет виновника, а не печатает «применено»; ключ, который ядро не дало +# записать, — отдельное предупреждение; работает и через `systemd-analyze cat-config`; +# 2. ip_local_reserved_ports: LISTEN-порты xray (включая 127.0.0.1:10085) внутри +# эфемерного диапазона резервируются, резерв оператора не затирается, а порт, с которого +# xray ушёл, на ре-ране снимается; +# 3. net.ipv6.conf.all.forwarding больше не ставится (RA на ядерном SLAAC); +# 4. снимок исходных значений sysctl — только при ПЕРВОМ применении, ре-ран его не портит; +# откат возвращает runtime (sysctl, маски RPS/XPS, THP) без ребута; +# 5. journald: drop-in 10-na-size.conf (операторский 99-* главнее), старый na-size.conf +# снимается; volatile-журнал → Storage=persistent, но явный Storage= оператора не трогаем; +# 6. XanMod: Secure Boot и тесный /boot — отказ от ядра; после установки — initrd и драйвер +# текущего NIC в /lib/modules/<версия> до рапорта «нужна перезагрузка»; +# 7. common: default_iface берёт токен после `dev` (OpenVZ venet0, multipath); +# apt_install ждёт чужой dpkg-лок (DPkg::Lock::Timeout), а не падает сразу. +# +# Не требует root/сети/systemd. Запуск: bash tests/optimize-unit.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap '[ -n "${KEEP:-}" ] || rm -rf "$T"' EXIT +export NA_T="$T" +OPT="$REPO_ROOT/scripts/optimize.sh" + +pick_bash() { + local c + for c in bash /opt/homebrew/bin/bash /usr/local/bin/bash; do + command -v "$c" >/dev/null 2>&1 || continue + if "$c" -c 'set -u; a=(); : "${a[@]}"; [[ -v HOME ]]' 2>/dev/null; then command -v "$c"; return 0; fi + done + return 1 +} +WBASH="$(pick_bash)" || { echo "[x] не нашёл bash ≥ 4.4"; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "[x] нужен python3 (стаб sysctl)"; exit 1; } + +fail=0 +# tsv_has <файл> <ключ> <значение> — строка «ключзначение» (grep -P есть не везде) +tsv_has() { awk -F'\t' -v k="$2" -v v="$3" '$1 == k && substr($0, length(k) + 2) == v { f = 1 } END { exit !f }' "$1"; } +expect() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then echo " ✔ $d"; else echo " ✘ $d"; fail=1; fi; } +expect_not() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then echo " ✘ $d"; fail=1; else echo " ✔ $d"; fi; } + +BIN="$T/bin"; REC="$T/rec"; export REC +mkdir -p "$BIN" "$REC" "$T/state" "$T/backup" "$T/etc/sysctl.d" "$T/etc/modules-load.d" \ + "$T/usr/lib/sysctl.d" "$T/proc/sys" "$T/conf" + +# ── Стабы ─────────────────────────────────────────────────────────────────────── +# sysctl: `-n` читает песочный /proc/sys; `--system` применяет файлы в порядке procps/ +# systemd-sysctl (по ИМЕНИ через каталоги, /etc главнее, /etc/sysctl.conf последним). +# NA_TEST_RO — ключи, которые «ядро» записать не даёт (контейнер). +cat > "$BIN/sysctl" <<'SY' +#!/usr/bin/env python3 +import glob, os, sys +T = os.environ["NA_T"] +def path(k): return os.path.join(T, "proc/sys", k.replace(".", "/")) +a = sys.argv[1:] +if a[:1] == ["-n"]: + p = path(a[1]) + if not os.path.exists(p): sys.exit(1) + print(open(p).read().rstrip("\n")); sys.exit(0) +if a[:1] == ["--system"]: + ro = set(filter(None, os.environ.get("NA_TEST_RO", "").split(","))) + seen = {} + for d in ["etc/sysctl.d", "run/sysctl.d", "usr/local/lib/sysctl.d", "usr/lib/sysctl.d", "lib/sysctl.d"]: + for f in glob.glob(os.path.join(T, d, "*.conf")): + seen.setdefault(os.path.basename(f), f) + files = [seen[b] for b in sorted(seen)] + if os.path.exists(os.path.join(T, "etc/sysctl.conf")): files.append(os.path.join(T, "etc/sysctl.conf")) + with open(os.path.join(T, "rec/sysctl-order"), "w") as o: o.write("\n".join(files) + "\n") + for f in files: + for line in open(f): + line = line.strip() + if not line or line[0] in "#;" or "=" not in line: continue + k, v = line.split("=", 1) + k = k.strip().lstrip("-").replace("/", "."); v = " ".join(v.split()) + p = path(k) + if k in ro or not os.path.exists(p): continue + open(p, "w").write(v.replace(" ", "\t") + "\n") + sys.exit(0) +sys.exit(1) +SY +# systemd-analyze: по умолчанию «нет» (фолбэк на перебор каталогов); NA_TEST_SA=1 — +# печатает cat-config как настоящий: заголовок «# <путь>» + содержимое, в порядке применения +cat > "$BIN/systemd-analyze" <<'SA' +#!/bin/sh +[ "${NA_TEST_SA:-0}" = 1 ] || exit 1 +[ "$1 $2" = "cat-config sysctl.d" ] || exit 1 +for f in $(cat "$NA_T/rec/sa-order"); do printf '# %s\n' "$f"; cat "$f"; echo; done +SA +cat > "$BIN/ss" <<'SS' +#!/bin/sh +case "$*" in + "-Htlnp") [ -f "$NA_T/ss-t" ] && cat "$NA_T/ss-t" ;; + "-Hulnp") [ -f "$NA_T/ss-u" ] && cat "$NA_T/ss-u" ;; +esac +exit 0 +SS +printf '#!/bin/sh\nexit 1\n' > "$BIN/docker" +printf '#!/bin/sh\nexit 0\n' > "$BIN/modprobe" +cat > "$BIN/systemctl" <<'SC' +#!/bin/sh +echo "systemctl $*" >> "$REC/calls" +exit 0 +SC +cat > "$BIN/journalctl" <<'JC' +#!/bin/sh +echo "journalctl $*" >> "$REC/calls" +exit 0 +JC +printf '#!/bin/sh\nexit 0\n' > "$BIN/systemd-tmpfiles" +# date -Is (GNU) — для маркеров; на BSD date его нет +cat > "$BIN/date" <<'DT' +#!/bin/sh +[ "$1" = "-Is" ] && { /bin/date -u +%Y-%m-%dT%H:%M:%S+00:00; exit 0; } +exec /bin/date "$@" +DT +chmod +x "$BIN"/* +export PATH="$BIN:$PATH" + +# GNU/BSD sed: секции пишут `sed -i` без суффикса — на macOS заворачиваем +REAL_SED="$(command -v sed)" + +# ════════════════════════════════════════════════════════════════════════════ +echo "== 1. sysctl: снимок, резерв портов, сверка с перебивающим файлом ==" +# ════════════════════════════════════════════════════════════════════════════ +awk '/^# ─── 3\. Sysctl/{f=1} /^# ─── 4\. Лимиты/{f=0} f' "$OPT" \ + | "$REAL_SED" -e "s#/etc/sysctl\.d#$T/etc/sysctl.d#g" \ + -e "s#/etc/sysctl\.conf#$T/etc/sysctl.conf#g" \ + -e "s#/run/sysctl\.d#$T/run/sysctl.d#g" \ + -e "s#/usr/local/lib/sysctl\.d#$T/usr/local/lib/sysctl.d#g" \ + -e "s#/usr/lib/sysctl\.d#$T/usr/lib/sysctl.d#g" \ + -e "s# /lib/sysctl\.d# $T/lib/sysctl.d#g" \ + -e "s#/etc/modules-load\.d#$T/etc/modules-load.d#g" \ + -e "s#/proc/sys/#$T/proc/sys/#g" \ + -e "s#/proc/meminfo#$T/proc/meminfo#g" > "$T/sysctl-section.sh" +[ -s "$T/sysctl-section.sh" ] || { echo "[x] не смог извлечь секцию 3 из optimize.sh"; exit 1; } +cat > "$T/wrap-sysctl.sh" < "$T/proc/meminfo" +# «ядро»: ключи, которые есть в этом ядре, со стоковыми значениями +kset() { mkdir -p "$T/proc/sys/$(dirname "${1//.//}")"; printf '%s\n' "$2" > "$T/proc/sys/${1//.//}"; } +kget() { cat "$T/proc/sys/${1//.//}" 2>/dev/null; } +kset net.core.rmem_max 212992 +kset net.core.wmem_max 212992 +kset net.core.rmem_default 212992 +kset net.ipv4.tcp_congestion_control cubic +kset net.ipv4.tcp_rmem $'4096\t131072\t6291456' +kset net.ipv4.ip_local_port_range $'32768\t60999' +kset net.ipv4.ip_local_reserved_ports "20000-20010" +kset net.ipv6.conf.all.forwarding 0 +kset vm.swappiness 60 +kset net.netfilter.nf_conntrack_max 65536 +# xray слушает 0.0.0.0:443 и API на 127.0.0.1:10085, node-агент — 2222 (ниже диапазона) +cat > "$T/ss-t" <<'L' +LISTEN 0 4096 0.0.0.0:443 0.0.0.0:* users:(("xray",pid=10,fd=7)) +LISTEN 0 4096 127.0.0.1:10085 0.0.0.0:* users:(("xray",pid=10,fd=8)) +LISTEN 0 511 0.0.0.0:2222 0.0.0.0:* users:(("rw-node",pid=11,fd=3)) +LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1,fd=3)) +L +printf 'UNCONN 0 0 0.0.0.0:443 0.0.0.0:* users:(("xray",pid=10,fd=9))\n' > "$T/ss-u" +# чужой файл сортируется ПОЗЖЕ нашего (t > n) и урезает буфер +printf 'net.core.rmem_max = 16777216\n' > "$T/etc/sysctl.d/99-tuning.conf" +run_sysctl() { rc=0; "$WBASH" "$T/wrap-sysctl.sh" > "$T/out" 2>&1 || rc=$?; return 0; } +NA_TEST_RO=vm.swappiness run_sysctl +F="$T/etc/sysctl.d/99-node-accelerator.conf" +expect "секция отработала (rc=0)" test "$rc" -eq 0 +expect "файл записан" test -s "$F" +expect_not "временный кандидат не остался" compgen -G "$T/etc/sysctl.d/.na-sysctl*" +expect_not "net.ipv6.conf.all.forwarding больше не ставится" grep -qE '^[[:space:]]*net\.ipv6\.conf\.all\.forwarding' "$F" +expect "net.ipv4.ip_forward на месте" grep -qE '^net.ipv4.ip_forward[[:space:]]+= 1' "$F" +expect "резерв: порт xray 10085 (127.0.0.1) + резерв оператора" grep -qE '^net.ipv4.ip_local_reserved_ports = 10085,20000-20010$' "$F" +expect_not "резерв: 443 и 2222 ниже диапазона — не резервируются" grep -qE 'reserved_ports = .*(443|2222)' "$F" +expect "состояние: наши порты = 10085" grep -qx 10085 "$T/state/reserved-ports.na" +expect "снимок исходных значений создан" test -s "$T/state/sysctl.orig" +expect "снимок: rmem_max = сток 212992" tsv_has "$T/state/sysctl.orig" net.core.rmem_max 212992 +expect "снимок: cc = cubic" tsv_has "$T/state/sysctl.orig" net.ipv4.tcp_congestion_control cubic +expect "снимок: многозначный tcp_rmem целиком" tsv_has "$T/state/sysctl.orig" net.ipv4.tcp_rmem $'4096\t131072\t6291456' +expect "снимок: резерв оператора" tsv_has "$T/state/sysctl.orig" net.ipv4.ip_local_reserved_ports 20000-20010 +expect_not "снимок: ключа, которого нет в ядре, нет" grep -q 'tcp_notsent_lowat' "$T/state/sysctl.orig" +expect "сверка: виновник назван (99-tuning.conf)" grep -q 'net.core.rmem_max = 16777216, а .*99-node-accelerator.conf ставит 33554432 — перебивает .*99-tuning.conf' "$T/out" +expect "сверка: незаписанный ключ — отдельный warn" grep -q 'не применились (ядро/контейнер не дал записать?): vm.swappiness=60' "$T/out" +expect_not "сверка: «действуют» не печатается при расхождении" grep -q 'значения наших файлов действуют' "$T/out" +expect "в ядре наш резерв" test "$(kget net.ipv4.ip_local_reserved_ports)" = "10085,20000-20010" + +echo "== 1b. ре-ран: снимок не портится, ушедший порт xray снимается ==" +cp "$T/state/sysctl.orig" "$T/orig.first" 2>/dev/null || : > "$T/orig.first" +"$REAL_SED" -e 's/:10085 /:10086 /' "$T/ss-t" > "$T/ss-t.new" && mv "$T/ss-t.new" "$T/ss-t" +rm -f "$T/etc/sysctl.d/99-tuning.conf" +run_sysctl +expect "rc=0" test "$rc" -eq 0 +expect "снимок не перезаписан ре-раном" cmp -s "$T/orig.first" "$T/state/sysctl.orig" +expect "резерв: 10085 снят, 10086 добавлен, оператор сохранён" grep -qE '^net.ipv4.ip_local_reserved_ports = 10086,20000-20010$' "$F" +expect "без перебивающего файла и RO-ключей — «действуют»" grep -q 'значения наших файлов действуют' "$T/out" + +echo "== 1c. сверка через systemd-analyze cat-config ==" +printf 'net.core.wmem_max = 4194304\n' > "$T/etc/sysctl.d/99-zz-ops.conf" +printf '#\n# /etc/sysctl.conf - Configuration file for setting system variables\n' > "$T/etc/sysctl.conf" +printf '%s\n' "$T/etc/sysctl.d/99-node-accelerator-conntrack.conf" "$T/etc/sysctl.d/99-node-accelerator.conf" \ + "$T/etc/sysctl.d/99-zz-ops.conf" "$T/etc/sysctl.conf" > "$REC/sa-order" +NA_TEST_SA=1 run_sysctl +expect "виновник найден по cat-config" grep -q 'net.core.wmem_max = 4194304, а .* — перебивает .*99-zz-ops.conf' "$T/out" +expect_not "шапка /etc/sysctl.conf не принята за файл" grep -q 'Configuration file for' "$T/out" +rm -f "$T/etc/sysctl.d/99-zz-ops.conf" "$T/etc/sysctl.conf" + +echo "== 1d. снимок НЕ снимается, если наш файл уже стоял (апгрейд с ≤4.1) ==" +rm -f "$T/state/sysctl.orig" +run_sysctl +expect_not "наш файл есть, снимка не было → не создаём (в ядре уже наши значения)" test -e "$T/state/sysctl.orig" + +# ════════════════════════════════════════════════════════════════════════════ +echo "== 2. journald: 10-na-size.conf, volatile → persistent, явный Storage оператора ==" +# ════════════════════════════════════════════════════════════════════════════ +JD="$T/etc/systemd/journald.conf.d" +awk '/^# ─── 8\. journald cap/{f=1} /^# ─── 8b\./{f=0} f' "$OPT" \ + | "$REAL_SED" -e "s#/etc/systemd/journald\.conf#$T/etc/systemd/journald.conf#g" \ + -e "s#/run/systemd/journald\.conf\.d#$T/run/systemd/journald.conf.d#g" \ + -e "s#/usr/local/lib/systemd/journald\.conf\.d#$T/usr/local/lib/systemd/journald.conf.d#g" \ + -e "s#/usr/lib/systemd/journald\.conf\.d#$T/usr/lib/systemd/journald.conf.d#g" \ + -e "s#/run/log/journal#$T/run/log/journal#g" \ + -e "s#/var/log/journal#$T/var/log/journal#g" > "$T/jd-section.sh" +[ -s "$T/jd-section.sh" ] || { echo "[x] не смог извлечь секцию 8 из optimize.sh"; exit 1; } +cat > "$T/wrap-jd.sh" < "$REC/calls"; "$WBASH" "$T/wrap-jd.sh" > "$T/out" 2>&1 || rc=$?; return 0; } +mkdir -p "$JD" "$T/run/log/journal" +printf '[Journal]\nSystemMaxUse=300M\n' > "$JD/na-size.conf" +printf '[Journal]\nSystemMaxUse=2G\n' > "$JD/99-ops.conf" +run_jd +expect "rc=0" test "$rc" -eq 0 +expect "новый drop-in 10-na-size.conf" test -s "$JD/10-na-size.conf" +expect "старый na-size.conf снят" test ! -e "$JD/na-size.conf" +expect "старый — в бэкапе" test -f "$T/backup/na-size.conf" +expect "операторский 99-ops.conf сортируется ПОСЛЕ нашего (он главнее)" \ + test "$(printf '%s\n' "$JD"/*.conf | LC_ALL=C sort | tail -1)" = "$JD/99-ops.conf" +expect "volatile → Storage=persistent" grep -qx 'Storage=persistent' "$JD/10-na-size.conf" +expect "каталог /var/log/journal создан" test -d "$T/var/log/journal" +expect "накопленное в RAM перенесено (journalctl --flush)" grep -q 'journalctl --flush' "$REC/calls" +expect "маркер «persistent включили мы»" test -f "$T/state/journald.persistent" +run_jd +expect "ре-ран: Storage=persistent сохраняется (маркер)" grep -qx 'Storage=persistent' "$JD/10-na-size.conf" +rm -rf "$T/var/log/journal" "$T/state/journald.persistent" +mkdir -p "$T/etc/systemd"; printf '[Journal]\nStorage=volatile\n' > "$T/etc/systemd/journald.conf" +run_jd +expect "явный Storage=volatile оператора — не трогаем" test ! -d "$T/var/log/journal" +expect_not "…и Storage в нашем drop-in нет" grep -q '^Storage=' "$JD/10-na-size.conf" +expect "…с пояснением" grep -q 'Storage= задан явно' "$T/out" +# явный Storage= оператора при уже постоянном журнале — тоже не трогаем и не падаем +mkdir -p "$T/var/log/journal" +run_jd +expect "явный Storage + persistent-журнал: rc=0" test "$rc" -eq 0 +rm -f "$T/etc/systemd/journald.conf"; rm -rf "$T/run/log/journal" +run_jd +expect_not "журнал уже persistent — Storage не пишем" grep -q '^Storage=' "$JD/10-na-size.conf" + +# ════════════════════════════════════════════════════════════════════════════ +echo "== 3. снимки RPS/XPS и THP при первом применении ==" +# ════════════════════════════════════════════════════════════════════════════ +SYSN="$T/sys/class/net" +for n in ens18 lo veth1 zz0; do + mkdir -p "$SYSN/$n/queues/rx-0" "$SYSN/$n/queues/tx-0" + echo 0 > "$SYSN/$n/queues/rx-0/rps_cpus"; echo 0 > "$SYSN/$n/queues/rx-0/rps_flow_cnt" + echo 3 > "$SYSN/$n/queues/tx-0/xps_cpus" +done +# у последнего по глобу интерфейса xps_cpus пустой (так бывает у одноочередных драйверов) +: > "$SYSN/zz0/queues/tx-0/xps_cpus" +mkdir -p "$T/sys/kernel/mm/transparent_hugepage" "$T/sbin" +echo 'always [madvise] never' > "$T/sys/kernel/mm/transparent_hugepage/enabled" +echo 'always defer [defer+madvise] madvise never' > "$T/sys/kernel/mm/transparent_hugepage/defrag" +{ awk '/^RPS_ORIG=/{f=1} f{print} f && /^fi$/{exit}' "$OPT" + awk '/^THP_ORIG=/{f=1} f{print} f && /^fi$/{exit}' "$OPT"; } \ + | "$REAL_SED" -e "s#/sys/class/net#$SYSN#g" -e "s#/sys/kernel/mm#$T/sys/kernel/mm#g" \ + -e "s#/usr/local/sbin/na-rps-setup#$T/sbin/na-rps-setup#g" \ + -e "s#/etc/systemd/system/#$T/sbin/#g" > "$T/snap-section.sh" +cat > "$T/wrap-snap.sh" < "$T/out" 2>&1 || rc=$? +expect "rc=0" test "$rc" -eq 0 +expect "снимок масок: ens18 rx/tx" tsv_has "$T/state/rps-orig.tsv" "$SYSN/ens18/queues/tx-0/xps_cpus" 3 +expect_not "снимок масок: lo/veth мимо" grep -qE '/(lo|veth1)/' "$T/state/rps-orig.tsv" +expect "снимок не выброшен из-за пустой маски последней очереди" tsv_has "$T/state/rps-orig.tsv" "$SYSN/zz0/queues/rx-0/rps_cpus" 0 +expect "снимок THP: enabled=madvise" tsv_has "$T/state/thp.orig" enabled madvise +expect "снимок THP: defrag=defer+madvise" tsv_has "$T/state/thp.orig" defrag defer+madvise +{ cp "$T/state/rps-orig.tsv" "$T/rps.first" 2>/dev/null || : > "$T/rps.first"; }; echo 7 > "$SYSN/ens18/queues/tx-0/xps_cpus" +: > "$T/sbin/na-rps-setup"; rm -f "$T/state/rps-orig.tsv" +"$WBASH" "$T/wrap-snap.sh" > "$T/out" 2>&1 || true +expect_not "хелпер уже стоял (ре-ран/апгрейд) — снимок не снимаем" test -e "$T/state/rps-orig.tsv" + +# ════════════════════════════════════════════════════════════════════════════ +echo "== 4. rollback optimize: runtime возвращается без ребута ==" +# ════════════════════════════════════════════════════════════════════════════ +awk '/^ # ── Runtime без ребута/{f=1} f{print} f && /reserved-ports\.na" "\$STATE_DIR\/journald\.persistent"$/{exit}' \ + "$REPO_ROOT/scripts/rollback.sh" \ + | "$REAL_SED" -e "s#/proc/sys/#$T/proc/sys/#g" -e "s#/sys/class/net#$SYSN#g" \ + -e "s#/sys/kernel/mm#$T/sys/kernel/mm#g" > "$T/rb-section.sh" +[ -s "$T/rb-section.sh" ] || { echo " ✘ не смог извлечь блок runtime из rollback.sh"; fail=1; } +cat > "$T/wrap-rb.sh" < "$T/sys/kernel/mm/transparent_hugepage/enabled" +printf 'nic=ens18\n' > "$T/state/optimize.installed" +"$WBASH" "$T/wrap-rb.sh" > "$T/out" 2>&1 || true +expect "sysctl: cc вернулся к cubic" test "$(kget net.ipv4.tcp_congestion_control)" = cubic +expect "sysctl: многозначный tcp_rmem вернулся" test "$(kget net.ipv4.tcp_rmem | tr '\t' ' ')" = "4096 131072 6291456" +expect "sysctl: резерв оператора вернулся" test "$(kget net.ipv4.ip_local_reserved_ports)" = "20000-20010" +expect "XPS: маска из снимка (3)" test "$(cat "$SYSN/ens18/queues/tx-0/xps_cpus")" = 3 +expect "THP: madvise из снимка" grep -qx madvise "$T/sys/kernel/mm/transparent_hugepage/enabled" +expect "честное сообщение: ключи из снимка" grep -q 'RT=sysctl — .* ключ(ей) возвращены к значениям до первого прогона' "$T/out" +expect "снимки убраны" test ! -e "$T/state/sysctl.orig" -a ! -e "$T/state/rps-orig.tsv" -a ! -e "$T/state/thp.orig" +# без снимков (optimize ставился до v4.2): RPS — в 0 по nic из маркера, честно про остальное +echo f > "$SYSN/ens18/queues/rx-0/rps_cpus" +"$WBASH" "$T/wrap-rb.sh" > "$T/out" 2>&1 || true +expect "без снимка: rps_cpus в 0 (дефолт ядра)" test "$(cat "$SYSN/ens18/queues/rx-0/rps_cpus")" = 0 +expect "без снимка: сказано, что sysctl держит наши значения до ребута" grep -q 'снимка нет (optimize ставился до v4.2)' "$T/out" +expect "без снимка: про THP — до ребута" grep -q 'THP — never до ребута' "$T/out" +expect "rollback снимает оба имени journald drop-in" \ + grep -q 'rm -f /etc/systemd/journald.conf.d/10-na-size.conf /etc/systemd/journald.conf.d/na-size.conf' "$REPO_ROOT/scripts/rollback.sh" + +# ════════════════════════════════════════════════════════════════════════════ +echo "== 5. XanMod: Secure Boot, место в /boot, готовность нового ядра ==" +# ════════════════════════════════════════════════════════════════════════════ +awk '/^NA_SB_EFIVAR=/{f=1} /^install_xanmod\(\) \{/{f=0} f' "$OPT" \ + | "$REAL_SED" -e "s#/sys/firmware#$T/sys/firmware#g" -e "s#/boot/#$T/boot/#g" \ + -e "s#/lib/modules#$T/lib/modules#g" -e "s#/sys/class/net#$SYSN#g" > "$T/xm-section.sh" +[ -s "$T/xm-section.sh" ] || { echo " ✘ не смог извлечь проверки XanMod из optimize.sh"; fail=1; } +cat > "$BIN/df" <<'DF' +#!/bin/sh +echo "Filesystem 1M-blocks Used Available Capacity Mounted on" +echo "/dev/vda1 1000 100 ${NA_TEST_BOOT_FREE:-900} 10% /boot" +DF +cat > "$BIN/dpkg-query" <<'DQ' +#!/bin/sh +echo "linux-image-6.18.52-x64v3-xanmod1, linux-headers-6.18.52-x64v3-xanmod1" +DQ +cat > "$BIN/update-initramfs" <<'UI' +#!/bin/sh +echo "update-initramfs $*" >> "$REC/calls" +[ "${NA_TEST_INITRD_FIX:-0}" = 1 ] && echo x > "$NA_T/boot/initrd.img-$3" +exit 0 +UI +chmod +x "$BIN/df" "$BIN/dpkg-query" "$BIN/update-initramfs" +cat > "$T/wrap-xm.sh" < "$REC/calls"; "$WBASH" "$T/wrap-xm.sh" "$@" > "$T/out" 2>&1 || rc=$?; return 0; } +mkdir -p "$T/sys/firmware/efi/efivars" "$T/boot" +printf '#!/bin/sh\necho "SecureBoot enabled"\n' > "$BIN/mokutil"; chmod +x "$BIN/mokutil" +xm pre +expect "Secure Boot (mokutil) → отказ от ядра" test "$rc" -ne 0 +expect "…с причиной" grep -q 'UEFI Secure Boot включён' "$T/out" +# mokutil «не знает» (как на BIOS/без efivars) — решает efivar. Не удаляем стаб: на +# CI-раннере может стоять настоящий mokutil со своим ответом про Secure Boot. +printf '#!/bin/sh\necho "EFI variables are not supported on this system"\nexit 1\n' > "$BIN/mokutil" +EV="$T/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c" +printf '\007\000\000\000\001' > "$EV" +xm pre +expect "Secure Boot (efivar, без mokutil) → отказ" test "$rc" -ne 0 +printf '\007\000\000\000\000' > "$EV" +xm pre +expect "Secure Boot выключен, места хватает → можно" test "$rc" -eq 0 +NA_TEST_BOOT_FREE=150 xm pre +expect "/boot: 150 МБ → отказ" test "$rc" -ne 0 +expect "…с причиной и подсказкой" grep -q 'свободно 150 МБ (нужно ≥ 200)' "$T/out" +# новое ядро: initrd + драйвер NIC текущего интерфейса +KV=6.18.52-x64v3-xanmod1 +mkdir -p "$SYSN/ens18/device" "$T/lib/modules/$KV/kernel/drivers/net" +ln -sfn ../../../bus/virtio/drivers/virtio_net "$SYSN/ens18/device/driver" +echo x > "$T/boot/initrd.img-$KV" +: > "$T/lib/modules/$KV/kernel/drivers/net/virtio_net.ko.zst" +xm post +expect "версия ядра из Depends мета-пакета" grep -qx "KVER=$KV" "$T/out" +expect "initrd + virtio_net на месте → READY" grep -qx READY "$T/out" +rm -f "$T/lib/modules/$KV/kernel/drivers/net/virtio_net.ko.zst" +xm post +expect "драйвера NIC нет → риск назван" grep -q 'RISK=драйвера NIC ens18 (virtio_net) нет' "$T/out" +printf 'kernel/drivers/net/virtio_net.ko\n' > "$T/lib/modules/$KV/modules.builtin" +xm post +expect "драйвер встроен (modules.builtin) → READY" grep -qx READY "$T/out" +rm -f "$T/boot/initrd.img-$KV" +xm post +expect "нет initrd → пробуем пересобрать (update-initramfs -c -k)" grep -q "update-initramfs -c -k $KV" "$REC/calls" +expect "…не вышло → риск назван" grep -q "RISK=нет $T/boot/initrd.img-$KV" "$T/out" +NA_TEST_INITRD_FIX=1 xm post +expect "пересборка initrd удалась → READY" grep -qx READY "$T/out" +expect "установка ядра идёт только после предполётной проверки" grep -q 'elif xanmod_preflight; then' "$OPT" +expect "риск печатается до рапорта «нужна перезагрузка»" grep -q 'НЕ ПЕРЕЗАГРУЖАЙСЯ, пока не исправлено' "$OPT" + +# ════════════════════════════════════════════════════════════════════════════ +echo "== 6. common: default_iface и apt_install ==" +# ════════════════════════════════════════════════════════════════════════════ +cat > "$T/wrap-common.sh" </dev/null; } +expect "обычный маршрут → eth0" test "$(iface 'default via 10.0.0.1 dev eth0 proto static metric 100')" = eth0 +expect "OpenVZ: default dev venet0 scope link → venet0" test "$(iface 'default dev venet0 scope link')" = venet0 +expect "multipath → первый nexthop dev" \ + test "$(iface 'default proto static metric 100 nexthop via 10.0.0.1 dev ens3 weight 1 nexthop via 10.0.1.1 dev ens4 weight 1')" = ens3 +expect "маршрута нет → пусто" test -z "$(iface '')" +cat > "$BIN/apt-get" <<'AG' +#!/bin/sh +echo "apt-get $*" >> "$REC/apt" +case "$*" in *" install "*) [ "${NA_TEST_APT_FAIL_ONCE:-0}" = 1 ] && [ ! -e "$REC/apt.failed" ] && { : > "$REC/apt.failed"; exit 100; } ;; esac +exit 0 +AG +printf '#!/bin/sh\nexit 0\n' > "$BIN/dpkg"; chmod +x "$BIN/apt-get" "$BIN/dpkg" +: > "$REC/apt" +rc=0; "$WBASH" "$T/wrap-common.sh" apt > "$T/out" 2>&1 || rc=$? +expect "apt_install rc=0" test "$rc" -eq 0 +expect "install ждёт чужой dpkg-лок (DPkg::Lock::Timeout=120)" grep -q 'apt-get -o DPkg::Lock::Timeout=120 install -y -qq --no-install-recommends foo bar' "$REC/apt" +expect "update — тоже с таймаутом лока" grep -q 'apt-get -o DPkg::Lock::Timeout=120 update' "$REC/apt" +: > "$REC/apt"; rm -f "$REC/apt.failed" +rc=0; NA_TEST_APT_FAIL_ONCE=1 "$WBASH" "$T/wrap-common.sh" apt > "$T/out" 2>&1 || rc=$? +expect "ретрай после неудачи тоже с таймаутом лока" test "$(grep -c 'DPkg::Lock::Timeout=120 install -y -qq --no-install-recommends foo bar' "$REC/apt")" -eq 2 +expect_not "ни одного вызова apt-get без таймаута лока" grep -qv 'DPkg::Lock::Timeout=120' "$REC/apt" + +if [ "$fail" -ne 0 ]; then echo "OPTIMIZE-UNIT: FAIL"; exit 1; fi +echo "OPTIMIZE-UNIT: OK (sysctl: виновник назван, резерв портов, снимок только при первом прогоне; journald 10-*, persistent; откат runtime; XanMod: Secure Boot//boot/initrd/драйвер NIC; default_iface после dev; apt ждёт лок)" diff --git a/tests/rps-unit.sh b/tests/rps-unit.sh index 1535399..2abce64 100755 --- a/tests/rps-unit.sh +++ b/tests/rps-unit.sh @@ -15,7 +15,11 @@ # вечное active (exited) с невыполненной работой); # 6. юнит несёт Restart=on-failure/RestartSec и ExecStart с именем NIC; # 7. ничего «курсорного» (tput/\033[K) не уезжает в stdout, когда это не терминал -# (issue #28: escape-коды в логах раскатки под nohup … > log). +# (issue #28: escape-коды в логах раскатки под nohup … > log); +# 8. v4.2: RPS не включается на 1 vCPU и при RX-очередей ≥ ядер (аппаратный RSS) — +# маски прошлых версий возвращаются к снимку optimize или к 0; XPS не трогается +# без NA_XPS=1 (раскладку TX-очередей задаёт драйвер); `default dev venet0 scope +# link` (OpenVZ) даёт venet0, а не «scope». # # Не требует root/сети/systemd. Запуск: bash tests/rps-unit.sh set -euo pipefail @@ -26,7 +30,7 @@ trap 'rm -rf "$T"' EXIT SYS="$T/sys/class/net" REC="$T/rec" export REC -mkdir -p "$T/bin" "$SYS" "$REC" "$T/proc/sys/net/core" +mkdir -p "$T/bin" "$SYS" "$REC" "$T/proc/sys/net/core" "$T/state" # Секции/скрипту нужен bash ≥ 4.4 — системный bash macOS древний, берём первый годный. pick_bash() { @@ -52,9 +56,10 @@ n=$(( $(cat "$REC/ip.calls" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$REC/ip.calls" [ "${ROUTE_AFTER:-0}" -gt 0 ] || exit 0 [ "$n" -ge "${ROUTE_AFTER:-0}" ] || exit 0 +if [ -n "${ROUTE_LINE:-}" ]; then echo "$ROUTE_LINE"; exit 0; fi echo "default via 10.0.0.1 dev ${ROUTE_IFACE:-ens18} proto static metric 100" IP -printf '#!/bin/sh\necho 3\n' > "$T/bin/nproc" +printf '#!/bin/sh\necho "${NA_TEST_NCPU:-3}"\n' > "$T/bin/nproc" cat > "$T/bin/sleep" <<'SL' #!/bin/sh echo "$*" >> "$REC/sleep.calls" @@ -66,7 +71,8 @@ export PATH="$T/bin:$PATH" # ── Достаём тело na-rps-setup из heredoc optimize.sh ──────────────────────────── extract_rps() { # extract_rps <исходник optimize.sh> <куда> awk "/^cat > \/usr\/local\/sbin\/na-rps-setup <<'RPS'\$/{f=1;next} /^RPS\$/{f=0} f" "$1" \ - | sed -e "s#/sys/class/net#$SYS#g" -e "s#/proc/sys/net/core#$T/proc/sys/net/core#g" > "$2" + | sed -e "s#/sys/class/net#$SYS#g" -e "s#/proc/sys/net/core#$T/proc/sys/net/core#g" \ + -e "s#/var/lib/node-accelerator#$T/state#g" > "$2" [ -s "$2" ] || return 1 chmod +x "$2" } @@ -83,7 +89,17 @@ mk_net() { # mk_net [phys] — очереди rx/tx; phys=1 → ест [ -n "$phys" ] && : > "$SYS/$n/device" return 0 } -reset_net() { rm -rf "$SYS"; mkdir -p "$SYS"; : > "$REC/ip.calls"; : > "$REC/sleep.calls"; } +reset_net() { rm -rf "$SYS"; mkdir -p "$SYS"; : > "$REC/ip.calls"; : > "$REC/sleep.calls"; rm -f "$T/state/rps-orig.tsv"; } +mk_queues() { # mk_queues — multi-queue NIC (RSS), маски заранее «наши» + local n="$1" i + mkdir -p "$SYS/$n"; : > "$SYS/$n/device" + for ((i = 0; i < $2; i++)); do + mkdir -p "$SYS/$n/queues/rx-$i" "$SYS/$n/queues/tx-$i" + echo 7 > "$SYS/$n/queues/rx-$i/rps_cpus"; echo 4096 > "$SYS/$n/queues/rx-$i/rps_flow_cnt" + echo 7 > "$SYS/$n/queues/tx-$i/xps_cpus" + done +} +q_of() { cat "$SYS/$1/queues/$2" 2>/dev/null || echo ""; } run_rps() { # run_rps <скрипт> [аргумент-NIC] → rc в $rc, вывод в $T/out/$T/err rc=0 "$WBASH" "$@" > "$T/out" 2> "$T/err" || rc=$? @@ -101,9 +117,13 @@ ROUTE_AFTER=0 run_rps "$RPS" ens18 expect "rc=0" test "$rc" -eq 0 expect "rps_cpus = маска 3 ядер (7)" test "$(mask_of ens18)" = "7" expect "rps_flow_cnt проставлен" grep -qx 4096 "$SYS/ens18/queues/rx-0/rps_flow_cnt" -expect "xps_cpus проставлен" grep -qx 7 "$SYS/ens18/queues/tx-0/xps_cpus" +expect "xps_cpus НЕ тронут без NA_XPS=1 (раскладка драйвера)" test -z "$(q_of ens18 tx-0/xps_cpus)" expect "итоговая строка-маркер напечатана" grep -q '^na-rps: NIC=ens18 mask=7 cpus=3$' "$T/out" expect "таблица маршрутов не опрашивалась вовсе" test "$(ip_calls)" -eq 0 +reset_net; mk_net ens18 1 +NA_XPS=1 ROUTE_AFTER=0 run_rps "$RPS" ens18 +expect "NA_XPS=1 → xps_cpus проставлен" test "$(q_of ens18 tx-0/xps_cpus)" = 7 +expect "NA_XPS=1 → маркер XPS" grep -q '^na-rps: NIC=ens18 xps=7$' "$T/out" # ── Кейс (b): интерфейс переименовался после смены ядра ───────────────────────── echo "== (b) вшитого интерфейса нет → автодетект ==" @@ -148,12 +168,46 @@ expect "rc≠0 (юнит уйдёт в failed, а не в active/exited)" test " expect "«giving up» в stderr" grep -q "giving up" "$T/err" expect_not "маркера успеха нет" grep -q '^na-rps: NIC=' "$T/out" +# ── Кейс (f): RX-очередей ≥ ядер — RSS уже размазывает, RPS не нужен (v4.2) ────── +echo "== (f) RX-очередей ≥ ядер: RPS не включаем, маски прошлых версий снимаем ==" +reset_net; mk_queues ens18 3 +ROUTE_AFTER=0 run_rps "$RPS" ens18 +expect "rc=0 (это успех, а не отказ)" test "$rc" -eq 0 +expect "rps_cpus снят в 0 на всех очередях" test "$(q_of ens18 rx-0/rps_cpus)$(q_of ens18 rx-2/rps_cpus)" = 00 +expect "rps_flow_cnt снят в 0" test "$(q_of ens18 rx-1/rps_flow_cnt)" = 0 +expect "маркер «rps=off» с причиной" grep -q '^na-rps: NIC=ens18 rps=off rxq=3 cpus=3' "$T/out" +expect "XPS прошлых версий без снимка не трогаем" test "$(q_of ens18 tx-0/xps_cpus)" = 7 +# снимок optimize (первое применение) — возвращаем исходные маски, а не 0 +reset_net; mk_queues ens18 3 +printf '%s\t%s\n' "$SYS/ens18/queues/rx-0/rps_cpus" 5 "$SYS/ens18/queues/rx-0/rps_flow_cnt" 2048 \ + "$SYS/ens18/queues/tx-0/xps_cpus" 1 "$SYS/ens18/queues/tx-1/xps_cpus" 2 > "$T/state/rps-orig.tsv" +ROUTE_AFTER=0 run_rps "$RPS" ens18 +expect "rps_cpus из снимка (5), а не 0" test "$(q_of ens18 rx-0/rps_cpus)" = 5 +expect "rps_flow_cnt из снимка (2048)" test "$(q_of ens18 rx-0/rps_flow_cnt)" = 2048 +expect "очередь без записи в снимке → 0" test "$(q_of ens18 rx-1/rps_cpus)" = 0 +expect "XPS без NA_XPS=1 → исходная раскладка из снимка (1, 2)" test "$(q_of ens18 tx-0/xps_cpus)$(q_of ens18 tx-1/xps_cpus)" = 12 +expect "очередь XPS без записи в снимке не тронута" test "$(q_of ens18 tx-2/xps_cpus)" = 7 + +echo "== (g) 1 vCPU: размазывать нечего ==" +reset_net; mk_net ens18 1; echo 1 > "$SYS/ens18/queues/rx-0/rps_cpus" +NA_TEST_NCPU=1 ROUTE_AFTER=0 run_rps "$RPS" ens18 +expect "rc=0" test "$rc" -eq 0 +expect "rps_cpus=0" test "$(q_of ens18 rx-0/rps_cpus)" = 0 +expect "маркер «rps=off … cpus=1»" grep -q 'rps=off rxq=1 cpus=1' "$T/out" + +echo "== (h) OpenVZ: default dev venet0 scope link ==" +reset_net; mk_net venet0 1 +ROUTE_AFTER=1 ROUTE_LINE="default dev venet0 scope link" run_rps "$RPS" +expect "интерфейс venet0 (токен после dev, а не \$5=scope)" test "$(mask_of venet0)" = "7" +expect_not "в stderr нет «scope»" grep -q "scope" "$T/err" + # ── Юнит na-rps.service (статически по генератору) ────────────────────────────── echo "== юнит na-rps.service ==" awk "/^cat > \/etc\/systemd\/system\/na-rps.service < "$T/unit" expect "юнит извлечён" test -s "$T/unit" expect "ExecStart передаёт имя NIC" grep -qF 'ExecStart=/usr/local/sbin/na-rps-setup ${NIC:-}' "$T/unit" +expect "NA_XPS доезжает до юнита (Environment=)" grep -qF 'Environment=NA_XPS=${NA_XPS}' "$T/unit" expect "Restart=on-failure" grep -qx 'Restart=on-failure' "$T/unit" expect "RestartSec задан" grep -qx 'RestartSec=5' "$T/unit" expect "Type=oneshot + RemainAfterExit (is-active остаётся правдой)" grep -qx 'RemainAfterExit=yes' "$T/unit" @@ -191,7 +245,7 @@ systemctl() { . "$ACTIVATION" DRIVER run_activation() { - REPO_ROOT="$REPO_ROOT" WBASH="$WBASH" RPS="$RPS" NIC=ens18 \ + REPO_ROOT="$REPO_ROOT" WBASH="$WBASH" RPS="$RPS" NIC=ens18 NA_XPS=0 \ ACTIVATION="$1" "$WBASH" "$T/activation-driver.sh" > "$T/activation.out" 2>&1 } reset_net; mk_net ens18 1 @@ -200,7 +254,7 @@ expect "ре-ран применяет RPS даже при активном ст reset_net; mk_net ens18 1 RPS_FORCE_FAILURE=1 run_activation "$T/activation.sh" expect "отказ применения — warn" grep -q 'не удалось применить RPS' "$T/activation.out" -expect_not "при отказе нет рапорта об успехе" grep -q 'RPS/RFS/XPS включены' "$T/activation.out" +expect_not "при отказе нет рапорта об успехе" grep -q 'na-rps применён' "$T/activation.out" if git -C "$REPO_ROOT" show v4.1.1:scripts/optimize.sh > "$T/v411-optimize.sh" 2>/dev/null; then extract_activation "$T/v411-optimize.sh" "$T/v411-activation.sh" reset_net; mk_net ens18 1 From 1d64a7c13e17050f325880bf92afda4f48600b52 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:05:52 +0300 Subject: [PATCH 03/14] =?UTF-8?q?v4.2:=20diagnose=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D1=82=D1=8B=20=D1=81=D1=87=D1=91=D1=82=D1=87?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=BC=D0=B8,=20steal?= =?UTF-8?q?=20=D0=BE=D0=BA=D0=BD=D0=BE=D0=BC=203=20=D1=81,=20PSI=20avg60,?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20=D0=B8=D0=B7=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D1=8B=D1=85=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB,=20Cr?= =?UTF-8?q?owdSec=20=D0=BF=D0=BE=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D0=BC,=204=20=D1=81=D0=BB=D0=BE=D1=8F=20whit?= =?UTF-8?q?elist,=20DNAT=20=D0=BC=D0=B8=D0=BC=D0=BE=20na=5Ffilter,=20?= =?UTF-8?q?=D1=81=D0=BB=D1=83=D1=88=D0=B0=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=84=D0=B0=D0=B9=D1=80=D0=B2=D0=BE=D0=BB=D0=B0?= =?UTF-8?q?,=20=D0=BF=D1=80=D0=BE=D1=81=D1=80=D0=BE=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=81=D0=B5=D1=80=D1=82=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D1=8B=20Caddy/nginx,=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=BD=D1=8B=D1=85=20=E2=96=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Снимок diag-counters.last (boot_id, окно ≥30 с): UDP RcvbufErrors (v4+v6), softnet drop/squeeze, ListenOverflows, steal — по приросту, а не с загрузки. - Ложные ▲: ENABLE_XANMOD=0, уступка ротации стансе с капом, volatile-журнал и журнал ≈ аптайму, лог анти-скана — доля журнала за сутки (не «>100000 за загрузку»), буферы от RAM-tier (+rmem_default), RPS при RX-очередей ≥ ядер, reboot-required от стоковых ядер. - Порты для сенсоров — живые правила → protect.conf (обе идиомы := и =) → маркер; расхождение правил с conf — ▲ с последствиями ре-рана. - CrowdSec: решения с -a по origin (локальные / CAPI+lists), scope из маркера, источники acquisition; эффективный allowlist (все s02-enrich/*.yaml) как 4-й слой. - Новые сенсоры: DNAT-порты (docker + nft), публичные слушатели и порты без слушателя, MemAvailable/своп/balloon, nftables.service + flush ruleset, хэш na_filter.nft, счётчики c_*, порт xray в эфемерном диапазоне, обновление XanMod, docker без max-size, OOM за сутки по всем загрузкам. - Серты: Caddy (docker volume / пакетный), ssl_certificate из nginx; просроченный — ✘, флаг «найден» отдельно. - --json: новые поля только в хвосте (16 шт.). - tests/diagnose-unit.sh: +12 разделов, сверка текста без escape-кодов цвета. Co-Authored-By: Claude Opus 5.5 --- scripts/diagnose.sh | 918 +++++++++++++++++++++++++++++++++++++---- tests/diagnose-unit.sh | 482 +++++++++++++++++++++- 2 files changed, 1301 insertions(+), 99 deletions(-) diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index 4da7e27..c6a9d65 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # -# diagnose.sh — 🩺 Диагностика ноды (read-only, ничего не меняет). +# diagnose.sh — 🩺 Диагностика ноды (read-only: конфигурацию не меняет; пишет только свой +# снимок счётчиков $STATE_DIR/diag-counters.last, чтобы считать дельты между запусками). # Проверяет ядро/BBR, sysctl, лимиты, conntrack, MSS-коллапс, NIC/RPS, firewall, # blocklists/fleet/ctguard, CrowdSec — печатает итог ✔/▲/✘ с рекомендациями. # diagnose.sh — человекочитаемый отчёт @@ -187,7 +188,10 @@ cert_retired() { # Пути сертов: LE, acme.sh, фасадный selfsteal-каталог (/opt/<стек>/certs/...) и # NA_CERT_PATHS. Нестандартного /opt-каталога в v4.0 не было, и на selfsteal-ноде # сенсор отдавал cert_min_days=-1 — молчал ровно там, где серт и надо стеречь (#39). +# v4.2: хранилище Caddy — в docker volume (панель/CDN-origin за Caddy) и у пакетного +# Caddy; плюс ssl_certificate из конфигов nginx (nginx_cert_paths). NA_CERT_GLOBS='/etc/letsencrypt/live/*/fullchain.pem /root/.acme.sh/*/fullchain.cer /opt/*/certs/*/fullchain.cer /opt/*/certs/*/fullchain.pem /opt/*/certs/fullchain.cer /opt/*/certs/fullchain.pem' +NA_CERT_GLOBS+=' /var/lib/docker/volumes/*/_data/caddy/certificates/*/*/*.crt /var/lib/caddy/.local/share/caddy/certificates/*/*/*.crt' # tls_listening — на :443/:8443 кто-то слушает? Если сертов при этом не нашли, сенсор # не «чист», а слеп — это warn, а не info (issue #39). @@ -255,16 +259,429 @@ dynset_fill() { ' <<<"$terse") } +# ─── v4.2: хелперы сенсоров по аудиту флота ────────────────────────────────── + +# conf_val <файл> <КЛЮЧ> — значение из сохранённого conf модуля. save_conf пишет +# `: "${KEY:=v}"`, protect v4.2 — `: "${KEY=v}"` (пустое ENV очищает значение): понимаем +# обе идиомы, последняя строка выигрывает (как при source). +conf_val() { + [[ -r "$1" ]] || return 0 + sed -nE 's/^[[:space:]]*:[[:space:]]+"\$\{'"$2"':?=([^}]*)\}"[[:space:]]*$/\1/p' "$1" 2>/dev/null | tail -1 +} +# conf_has <файл> <КЛЮЧ> — ключ вообще записан (пустое значение — тоже значение: UDP_PORTS=) +conf_has() { grep -qE '^[[:space:]]*:[[:space:]]+"\$\{'"$2"':?=' "$1" 2>/dev/null; } +# mark_val <ключ> [файл] — значение из маркера protect (или указанного маркера) +mark_val() { + awk -F= -v k="$1" '$1 == k { sub(/^[^=]*=/, ""); v = $0 } END { printf "%s", v }' \ + "${2:-$STATE_DIR/protect.installed}" 2>/dev/null +} +# csv_norm <список> — порты через запятую: только числа, по возрастанию, без повторов +csv_norm() { tr ', ' '\n\n' <<<"$1" | grep -E '^[0-9]+$' | sort -un | paste -sd, -; } + +# Живая input-цепочка na_filter — читаем ОДИН раз (её смотрят несколько сенсоров). +NA_CHAIN=""; NA_CHAIN_READ=0 +chain_load() { + [[ "$NA_CHAIN_READ" == 1 ]] && return 0 + NA_CHAIN_READ=1 + NA_CHAIN="$(_to 10 nft list chain inet na_filter input 2>/dev/null)" + return 0 +} +# fw_live_ports tcp|udp — сервисные порты из ЖИВЫХ правил (то, что действует сейчас): +# per-port наборы cc4_<порт> (TCP: `meter cc4_443` на nft 1.0.6, `@cc4_443` на 1.1) и +# udp4_<порт> (UDP). +fw_live_ports() { + local pre=cc4_; [[ "$1" == udp ]] && pre=udp4_ + chain_load + grep -oE "(^|[^a-z0-9_])${pre}[0-9]+" <<<"$NA_CHAIN" | grep -oE '[0-9]+$' | sort -un | paste -sd, - +} +# fw_ports tcp|udp — порты для сенсоров. Таблица есть → живые правила (истина «сейчас»); +# нет → protect.conf → маркер. tcp_ports= в маркере пишется только прогоном protect и +# устаревает после ручной правки правил/conf (аудит флота: порты «мимо маркера»). +fw_ports() { + local K=TCP_PORTS mk=tcp_ports + [[ "$1" == udp ]] && { K=UDP_PORTS; mk=udp_ports; } + chain_load + if [[ -n "$NA_CHAIN" ]]; then fw_live_ports "$1"; return 0; fi + local v; v="$(csv_norm "$(conf_val "$CONF_DIR/protect.conf" "$K")")" + [[ -n "$v" ]] || v="$(csv_norm "$(mark_val "$mk")")" + echo "$v" +} + +# ─── CPU steal: окно + среднее с загрузки ──────────────────────────────────── +# Секундный семпл на флоте прыгал 0↔8% между соседними прогонами: решение принималось по +# шуму. Окно 3 с + среднее с загрузки (/proc/stat копит steal с момента старта). +cpu_stat() { awk '/^cpu /{ t = 0; for (i = 2; i <= 9; i++) t += $i; printf "%.0f %.0f\n", $9, t; exit }' /proc/stat 2>/dev/null; } +cpu_steal_measure() { # → «окно% с_загрузки%» + local w="${1:-3}" s1 t1 s2 t2 win=0 boot=0 + read -r s1 t1 <<<"$(cpu_stat)" + [[ "$s1" =~ ^[0-9]+$ && "$t1" =~ ^[0-9]+$ ]] || { echo "0 0"; return 1; } + (( t1 > 0 )) && boot=$(( s1 * 100 / t1 )) + sleep "$w" + read -r s2 t2 <<<"$(cpu_stat)" + if [[ "$s2" =~ ^[0-9]+$ && "$t2" =~ ^[0-9]+$ ]] && (( t2 > t1 && s2 >= s1 )); then + win=$(( (s2 - s1) * 100 / (t2 - t1) )) + fi + echo "$win $boot" +} + +# ─── Дельты счётчиков между запусками ──────────────────────────────────────── +# UDP RcvbufErrors, softnet drop/squeeze, ListenOverflows, steal копятся с загрузки: на +# ноде с аптаймом в месяц одна давняя вспышка горела ▲ вечно, а свежий рост тонул в старом +# итоге (аудит флота: «UDP RcvbufErrors» на всех нодах при нулевом приросте). Каждый запуск +# оставляет снимок, следующий считает разницу, если загрузка та же и прошло ≥30 с; окно +# короче — копим от старого снимка, иначе частый опрос мониторинга не дал бы окна никогда. +# Это ЕДИНСТВЕННОЕ, что diagnose пишет на диск; не вышло (не root) — просто нет дельт. +NA_CNT_FILE="$STATE_DIR/diag-counters.last" +NA_DELTA_MIN_S=30 +counters_now() { + local bid ep u4 u6 sn lo st + bid="$(cat /proc/sys/kernel/random/boot_id 2>/dev/null)" + ep="$(date +%s)" + u4="$(awk '/^Udp:/ { if (!h) { for (i = 2; i <= NF; i++) n[i] = $i; h = 1; next } + for (i = 2; i <= NF; i++) { if (n[i] == "RcvbufErrors") e = $i; if (n[i] == "InDatagrams") d = $i } } + END { printf "%.0f %.0f", e, d }' /proc/net/snmp 2>/dev/null)" + u6="$(awk '$1 == "Udp6RcvbufErrors" { e = $2 } $1 == "Udp6InDatagrams" { d = $2 } + END { printf "%.0f %.0f", e, d }' /proc/net/snmp6 2>/dev/null)" + # softnet_stat: строка на CPU, hex; 2-я колонка — dropped (backlog полон), 3-я — time_squeeze + sn="$(awk 'function hx(s, i, c, v) { v = 0; s = tolower(s) + for (i = 1; i <= length(s); i++) { c = index("0123456789abcdef", substr(s, i, 1)); if (!c) break; v = v * 16 + c - 1 } + return v } + NF >= 3 { d += hx($2); q += hx($3) } END { printf "%.0f %.0f", d, q }' /proc/net/softnet_stat 2>/dev/null)" + lo="$(awk '/^TcpExt:/ { if (!h) { for (i = 2; i <= NF; i++) n[i] = $i; h = 1; next } + for (i = 2; i <= NF; i++) { if (n[i] == "ListenOverflows") o = $i; if (n[i] == "ListenDrops") d = $i } } + END { printf "%.0f %.0f", o, d }' /proc/net/netstat 2>/dev/null)" + st="$(cpu_stat)" + local ue ui ve vi sd sq lov ldr cs ct + read -r ue ui <<<"$u4"; read -r ve vi <<<"$u6"; read -r sd sq <<<"$sn" + read -r lov ldr <<<"$lo"; read -r cs ct <<<"$st" + printf 'boot_id=%s\nepoch=%s\nudp_err=%s\nudp_in=%s\nsn_drop=%s\nsn_squeeze=%s\nlisten_ovf=%s\nlisten_drops=%s\nsteal=%s\ncpu_total=%s\n' \ + "$bid" "$ep" "$(( ${ue:-0} + ${ve:-0} ))" "$(( ${ui:-0} + ${vi:-0} ))" "${sd:-0}" "${sq:-0}" \ + "${lov:-0}" "${ldr:-0}" "${cs:-0}" "${ct:-0}" +} +# delta_load — глобальные C_* (с загрузки) и D_* (за окно DW секунд; −1 = окна нет). +NA_DELTA_DONE=0 +DW=-1; D_UDPERR=-1; D_UDPIN=-1; D_SNDROP=-1; D_SQZ=-1; D_LOVF=-1; D_LDROP=-1; D_STEAL=-1 +C_UDPERR=0; C_UDPIN=0; C_SNDROP=0; C_SQZ=0; C_LOVF=0; C_LDROP=0 +delta_load() { + [[ "$NA_DELTA_DONE" == 1 ]] && return 0 + NA_DELTA_DONE=1 + local now k v w keep=0 + local -A cur=() prev=() + now="$(counters_now)" + while IFS='=' read -r k v; do [[ -n "$k" ]] && cur[$k]="$v"; done <<<"$now" + if [[ -r "$NA_CNT_FILE" ]]; then + while IFS='=' read -r k v; do [[ -n "$k" ]] && prev[$k]="$v"; done < "$NA_CNT_FILE" + fi + C_UDPERR="${cur[udp_err]:-0}"; C_UDPIN="${cur[udp_in]:-0}"; C_SNDROP="${cur[sn_drop]:-0}" + C_SQZ="${cur[sn_squeeze]:-0}"; C_LOVF="${cur[listen_ovf]:-0}"; C_LDROP="${cur[listen_drops]:-0}" + if [[ -n "${cur[boot_id]:-}" && "${prev[boot_id]:-}" == "${cur[boot_id]}" \ + && "${prev[epoch]:-}" =~ ^[0-9]+$ && "${cur[epoch]:-}" =~ ^[0-9]+$ ]]; then + w=$(( cur[epoch] - prev[epoch] )) + if (( w >= NA_DELTA_MIN_S )); then + DW="$w" + _dl() { local a="${cur[$1]:-}" b="${prev[$1]:-}"; [[ "$a" =~ ^[0-9]+$ && "$b" =~ ^[0-9]+$ ]] && (( a >= b )) && echo $(( a - b )) || echo -1; } + D_UDPERR="$(_dl udp_err)"; D_UDPIN="$(_dl udp_in)"; D_SNDROP="$(_dl sn_drop)" + D_SQZ="$(_dl sn_squeeze)"; D_LOVF="$(_dl listen_ovf)"; D_LDROP="$(_dl listen_drops)" + local ds dt; ds="$(_dl steal)"; dt="$(_dl cpu_total)" + [[ "$ds" -ge 0 && "$dt" -gt 0 ]] && D_STEAL=$(( ds * 100 / dt )) + unset -f _dl + elif (( w >= 0 )); then + keep=1 + fi + fi + if [[ "$keep" == 0 ]]; then + { mkdir -p "$STATE_DIR" && printf '%s\n' "$now" > "$NA_CNT_FILE.tmp.$$" \ + && mv -f "$NA_CNT_FILE.tmp.$$" "$NA_CNT_FILE"; } 2>/dev/null || rm -f "$NA_CNT_FILE.tmp.$$" 2>/dev/null + fi + return 0 +} + +# ─── Слушатели и опубликованные порты ──────────────────────────────────────── +NA_LT=""; NA_LU=""; NA_L_READ=0 +listen_load() { + [[ "$NA_L_READ" == 1 ]] && return 0 + NA_L_READ=1 + NA_LT="$(ss -Htlnp 2>/dev/null)"; NA_LU="$(ss -Hulnp 2>/dev/null)" + return 0 +} +# listen_rows tcp|udp — «адреспортпроцесс» (адрес без [] и %iface) +listen_rows() { + listen_load + local src="$NA_LT"; [[ "$1" == udp ]] && src="$NA_LU" + awk '$1 == "LISTEN" || $1 == "UNCONN" { + la = $4; p = la; sub(/.*:/, "", p); a = substr(la, 1, length(la) - length(p) - 1) + sub(/%.*/, "", a); gsub(/[][]/, "", a) + proc = "?"; if (match($0, /users:\(\("[^"]+"/)) proc = substr($0, RSTART + 9, RLENGTH - 10) + if (p ~ /^[0-9]+$/) print a "\t" p "\t" proc + }' <<<"$src" +} +# addr_public <адрес> — wildcard или глобальный (не loopback/link-local/частный/CGNAT) +addr_public() { + case "$1" in + "*"|0.0.0.0|::) return 0 ;; + 127.*|::1|::ffff:127.*|fe80:*|10.*|192.168.*|169.254.*|fc*|fd*) return 1 ;; + 172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 1 ;; + 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 1 ;; + esac + return 0 +} +# dnat_ports — публичные порты, приходящие на ноду через DNAT (Docker `-p`, ручные relay): +# «порт/proto» через запятую. Такой трафик идёт хуком forward, мимо input-цепочки +# na_filter: strict его не закрывает, autoban/блоклисты/лимиты не действуют (аудит флота: +# Caddy панели за Docker, тенант на соседнем контейнере, ручные relay-DNAT). +dnat_ports() { + { + if command -v docker >/dev/null 2>&1; then + docker ps --format '{{.Ports}}' 2>/dev/null | tr ',' '\n' \ + | sed -nE 's#^[[:space:]]*\[?([0-9a-fA-F:.]*)\]?:([0-9]+(-[0-9]+)?)->[0-9]+(-[0-9]+)?/(tcp|udp|sctp).*$#\1 \2/\5#p' \ + | awk '$1 !~ /^(127\.|::1$)/ { print $2 }' + fi + # -t: без содержимого наборов (у CrowdSec — десятки тысяч адресов) + _to 10 nft -t list ruleset 2>/dev/null | awk ' + / dnat / { + if ($0 ~ /ip6? daddr (127\.|::1)/) next + if ($0 ~ /tcp dport/) proto = "tcp"; else if ($0 ~ /udp dport/) proto = "udp"; else next + s = $0; sub(/.*(tcp|udp) dport /, "", s) + if (s ~ /^\{/) { + sub(/^\{ */, "", s); sub(/ *\}.*/, "", s); n = split(s, a, / *, */) + for (i = 1; i <= n; i++) if (a[i] ~ /^[0-9]+(-[0-9]+)?$/) print a[i] "/" proto + } else { sub(/ .*/, "", s); if (s ~ /^[0-9]+(-[0-9]+)?$/) print s "/" proto } + }' + } | sort -u | sort -t/ -k1,1n | paste -sd, - +} + +# ─── CrowdSec ──────────────────────────────────────────────────────────────── +# cs_decisions — «локальные CAPI/списки»: `-a` включает решения центрального API. Без +# него `cscli decisions list` показывает только локальные: на ноде с 25 тыс. адресов +# community-блоклиста отчёт печатал «1», а режет bouncer по всем. rc=1 — не измерено. +cs_decisions() { + command -v cscli >/dev/null 2>&1 || return 1 + local out + out="$(_to 30 cscli decisions list -a -o raw 2>/dev/null)" || return 1 + # строки данных начинаются с числового id (заголовок CSV — нет); 2-я колонка — origin + awk -F, '$1 ~ /^[0-9]+$/ { s = tolower($2); if (s ~ /^(capi|lists)/) c++; else l++ } + END { printf "%d %d\n", l, c }' <<<"$out" +} +# cs_acquis — источники логов CrowdSec из acquis.yaml / acquis.d: «тип[фильтр/пути]» по строке +cs_acquis() { + local f + for f in /etc/crowdsec/acquis.yaml /etc/crowdsec/acquis.d/*.yaml /etc/crowdsec/acquis.d/*.yml; do + [[ -f "$f" ]] || continue + awk ' + function flush() { if (src != "" || items != "") { if (src == "") src = "file"; print src "[" items "]" } src = ""; items = ""; lk = 0 } + function add(v) { gsub(/["'\'']/, "", v); sub(/[[:space:]]+#.*/, "", v); sub(/^[[:space:]]+/, "", v); if (v != "") items = items (items == "" ? "" : ",") v } + /^---/ { flush(); next } + /^source:/ { v = $0; sub(/^source:[[:space:]]*/, "", v); gsub(/["'\'']/, "", v); src = v; lk = 0; next } + # значение в строке (`filename: /var/log/x`, `filenames: [a, b]`) или списком ниже + /^(filenames?|journalctl_filter|container_name|container_id):/ { + v = $0; sub(/^[a-z_]+:[[:space:]]*/, "", v); gsub(/[][]/, "", v) + lk = (v == ""); n = split(v, a, ","); for (i = 1; i <= n; i++) add(a[i]); next } + /^[^[:space:]-]/ { lk = 0; next } + lk && /^[[:space:]]*-[[:space:]]*/ { v = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", v); add(v); next } + END { flush() }' "$f" + done | sort -u +} +# cs_allowlist — ЭФФЕКТИВНЫЙ allowlist CrowdSec: объединение ip/cidr всех парсеров +# s02-enrich с секцией whitelist (наш na-whitelist.yaml, хабовый whitelists, ручные +# файлы операторов). Сверять только наш файл — значит не видеть, что адрес прикрыт +# соседним, или считать прикрытым то, что из нашего файла выпало. +cs_allowlist() { + local f + for f in /etc/crowdsec/parsers/s02-enrich/*.yaml /etc/crowdsec/parsers/s02-enrich/*.yml; do + [[ -f "$f" ]] || continue + awk ' + /^[^[:space:]#-]/ { inwl = ($1 == "whitelist:"); mode = 0; next } + !inwl { next } + /^[[:space:]]+(ip|cidr):/ { + mode = 1; v = $0; sub(/^[^:]*:/, "", v) + if (v ~ /\[/) { gsub(/[][ "'\'']/, "", v); n = split(v, a, ","); for (i = 1; i <= n; i++) if (a[i] != "") print a[i] } + next } + /^[[:space:]]+[A-Za-z_]+:/ { mode = 0; next } + mode && /^[[:space:]]*-[[:space:]]*/ { v = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", v); gsub(/["'\'']/, "", v); sub(/[[:space:]]*#.*/, "", v); if (v != "") print v } + ' "$f" + done | sed -E 's#/32$##; s#/128$##' | sort -u +} +# wl_covered <адрес|сеть> — прикрыт ли адрес (v4 — с учётом CIDR: +# сеть прикрыта, если её адрес входит в сеть allowlist с префиксом не длиннее; v6 — точно) +wl_covered() { + local e="$1" a pe pa + while IFS= read -r a; do + [[ -n "$a" ]] || continue + [[ "$a" == "$e" ]] && return 0 + [[ "$e" == *:* || "$a" == *:* || "$a" != */* ]] && continue + pa="${a##*/}"; pe=32; [[ "$e" == */* ]] && pe="${e##*/}" + [[ "$pa" =~ ^[0-9]+$ && "$pe" =~ ^[0-9]+$ ]] && (( pa <= pe )) || continue + ip4_in_cidr "${e%%/*}" "$a" && return 0 + done <<<"$2" + return 1 +} + +# ─── Прочее ────────────────────────────────────────────────────────────────── +# fw_counters — ненулевые именованные счётчики na_filter (protect v4.2: c_synflood, +# c_portscan, c_autoban…): «имя=пакетов» через пробел. Лог ограничен по скорости, а +# счётчик — нет: это форензика, когда лог уже молчит. +fw_counters() { + _to 5 nft list counters table inet na_filter 2>/dev/null | awk ' + /counter c_[A-Za-z0-9_]+ \{/ { n = $2; next } + n != "" && /packets [0-9]+/ { for (i = 1; i < NF; i++) if ($i == "packets") p = $(i + 1); if (p + 0 > 0) printf "%s%s=%s", (o++ ? " " : ""), n, p; n = "" }' +} +# file_sha256 <файл> +file_sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + fi +} +# xanmod_update — «пакет кандидат», если apt знает версию мета-пакета XanMod новее +# установленной И она из репозитория XanMod. По кэшу apt: сеть не трогаем. rc=1 — нет. +xanmod_update() { + command -v apt-cache >/dev/null 2>&1 || return 1 + local pkg pol inst cand + pkg="$(cat "$STATE_DIR/xanmod.pkg" 2>/dev/null)" + if [[ -z "$pkg" ]] && command -v dpkg-query >/dev/null 2>&1; then + pkg="$(dpkg-query -W -f='${Package} ${db:Status-Abbrev}\n' 'linux-xanmod*' 2>/dev/null \ + | awk '$2 ~ /^ii/ && $1 !~ /(image|headers)/ { print $1; exit }')" + fi + [[ "$pkg" =~ ^[a-z0-9.+-]+$ ]] || return 1 + pol="$(_to 10 apt-cache policy "$pkg" 2>/dev/null)" || return 1 + inst="$(awk '$1 == "Installed:" { print $2; exit }' <<<"$pol")" + cand="$(awk '$1 == "Candidate:" { print $2; exit }' <<<"$pol")" + [[ -n "$inst" && "$inst" != "(none)" && -n "$cand" && "$cand" != "(none)" && "$inst" != "$cand" ]] || return 1 + # источник кандидата: строки репозиториев под строкой его версии в «Version table» + awk -v c="$cand" ' + /^ +(\*\*\* )?[^ ]+ [0-9]+$/ { v = $1; if (v == "***") v = $2; inv = (v == c); next } + inv && /xanmod/ { f = 1 } + END { exit !f }' <<<"$pol" || return 1 + echo "$pkg $cand" +} +# reboot_required_pkgs — /run/reboot-required: «whyпакеты». why: stock — только +# стоковые ядра при работающем XanMod (ребут ради них ничего не даст), other — иное. +reboot_required_pkgs() { + [[ -f /run/reboot-required ]] || return 1 + local pk kr why=other + pk="$(sort -u /run/reboot-required.pkgs 2>/dev/null | paste -sd' ' -)" + kr="$(uname -r)" + if [[ "${kr,,}" == *xanmod* && -n "$pk" ]] \ + && ! grep -qvE '^linux-(image|headers|modules|modules-extra)-[0-9].*$|^linux-(image|headers)-(amd64|generic|virtual|cloud-amd64)$' \ + <<<"$(tr ' ' '\n' <<<"$pk")" \ + && ! grep -qi xanmod <<<"$pk"; then + why=stock + fi + printf '%s\t%s\n' "$why" "$pk" +} +# Пути сертов Caddy (docker volume и пакетный) и nginx ssl_certificate: на панели/ +# CDN-origin серт живёт именно там, и сенсор сроков молчал (аудит флота). +nginx_cert_paths() { + [[ -d /etc/nginx ]] || return 0 + local f p + while IFS= read -r f; do + while IFS= read -r p; do + [[ -n "$p" && "$p" != *'$'* ]] || continue + [[ "$p" == /* ]] || p="/etc/nginx/$p" + printf '%s\n' "$p" + done < <(sed -nE "s/^[[:space:]]*ssl_certificate[[:space:]]+[\"']?([^\"'; ]+)[\"']?[[:space:]]*;.*/\1/p" "$f" 2>/dev/null) + done < <(find -L /etc/nginx -type f \( -path '*/sites-enabled/*' -o \( -name '*.conf' ! -path '*/sites-available/*' \) \) 2>/dev/null) +} +cert_candidates() { + local cf + # shellcheck disable=SC2086 # глобы и NA_CERT_PATHS обязаны раскрыться + for cf in $NA_CERT_GLOBS ${NA_CERT_PATHS:-}; do [[ -f "$cf" ]] && printf '%s\n' "$cf"; done + nginx_cert_paths +} +# cert_scan — ближайший к истечению серт: CERT_FOUND (0|1), CERT_MIN (дни, ≤0 = истёк или +# истекает в ближайшие сутки), CERT_MIN_F. Раньше −1 значил «не найдено», и просроченный +# серт (−10 дней) терялся: следующий живой (30) его «перекрывал». +CERT_FOUND=0; CERT_MIN=-1; CERT_MIN_F="" +cert_scan() { + CERT_FOUND=0; CERT_MIN=-1; CERT_MIN_F="" + command -v openssl >/dev/null 2>&1 || return 1 + local cf cend cends now df d + now="$(date +%s)" + while IFS= read -r cf; do + [[ -f "$cf" ]] || continue + cert_retired "$cf" && continue + cend="$(openssl x509 -enddate -noout -in "$cf" 2>/dev/null | cut -d= -f2)"; [[ -n "$cend" ]] || continue + cends="$(date -d "$cend" +%s 2>/dev/null)" || continue; [[ "$cends" =~ ^[0-9]+$ ]] || continue + df=$(( cends - now )) + if (( df >= 0 )); then d=$(( df / 86400 )); else d=$(( -((-df + 86399) / 86400) )); fi + if [[ "$CERT_FOUND" == 0 ]] || (( d < CERT_MIN )); then CERT_MIN="$d"; CERT_MIN_F="$cf"; fi + CERT_FOUND=1 + done < <(cert_candidates | awk '!seen[$0]++') + return 0 +} +# docker_nocap — работающие контейнеры с json-file БЕЗ max-size: их лог растёт без предела +# (daemon.json log-opts в inspect уже подмешаны — пусто значит «капа нет») +docker_nocap() { + command -v docker >/dev/null 2>&1 || return 0 + local ids; ids="$(docker ps -q 2>/dev/null)"; [[ -n "$ids" ]] || return 0 + # shellcheck disable=SC2086 # список id — нарочно словами + docker inspect -f '{{.Name}} {{.HostConfig.LogConfig.Type}} {{index .HostConfig.LogConfig.Config "max-size"}}' $ids 2>/dev/null \ + | awk '($2 == "json-file" || $2 == "") && ($3 == "" || $3 == "/dev/null 2>&1 || return 1 + local help n tot rc + help="$(_to 5 journalctl --help 2>/dev/null)" + [[ "$help" == *--grep* ]] || return 1 + n="$(_to 15 journalctl -q --no-pager --since -24h _TRANSPORT=kernel -g '\[na portscan\]' 2>/dev/null | wc -l | tr -d ' ')"; rc=$? + [[ "$rc" -eq 124 || ! "$n" =~ ^[0-9]+$ ]] && return 1 + tot="$(_to 20 journalctl -q --no-pager --since -24h -o cat 2>/dev/null | wc -l | tr -d ' ')"; rc=$? + [[ "$rc" -eq 124 || ! "$tot" =~ ^[0-9]+$ ]] && tot=-1 + echo "$n $tot" +} +# journal_volatile — журнал только в RAM (/run/log/journal, нет /var/log/journal) +journal_volatile() { [[ -d /run/log/journal && ! -d /var/log/journal ]]; } +# sock_tier — «sock_max sock_def» от RAM, как ставит optimize (маркер; нет — тот же расчёт): +# tier1 (≤1.2G) получает 16M, и порог 32M давал ложный ▲ на каждой маленькой ноде. +sock_tier() { + local f="$STATE_DIR/optimize.installed" mx df mb + mx="$(mark_val sock_max "$f")"; df="$(mark_val sock_def "$f")" + if [[ "$mx" =~ ^[0-9]+$ && "$df" =~ ^[0-9]+$ ]]; then echo "$mx $df"; return 0; fi + mb="$(awk '/^MemTotal:/{ printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null)"; [[ "$mb" =~ ^[0-9]+$ ]] || mb=4096 + if (( mb <= 1200 )); then echo "16777216 524288" + elif (( mb <= 2500 )); then echo "33554432 1048576" + elif (( mb <= 8500 )); then echo "67108864 2097152" + else echo "134217728 2097152"; fi +} +# port_in_list <порт> <список «a,b-c»> — порт покрыт списком (числом или диапазоном)? +port_in_list() { + local p="$1" t + for t in ${2//,/ }; do + [[ "$t" =~ ^[0-9]+(-[0-9]+)?$ ]] || continue + if [[ "$t" == *-* ]]; then (( p >= ${t%-*} && p <= ${t#*-} )) && return 0 + elif (( p == t )); then return 0; fi + done + return 1 +} +# node_eph_unreserved — порты ноды (xray/rw-core/rw-node, включая 127.0.0.1) внутри +# ip_local_port_range и вне ip_local_reserved_ports: «порт(процесс)» через пробел. +node_eph_unreserved() { + local lo hi resv a p pr out="" + read -r lo hi <<<"$(val net.ipv4.ip_local_port_range)" + [[ "$lo" =~ ^[0-9]+$ && "$hi" =~ ^[0-9]+$ ]] || return 0 + resv="$(val net.ipv4.ip_local_reserved_ports)" + while IFS=$'\t' read -r a p pr; do + [[ "$pr" == xray || "$pr" == rw-core || "$pr" == rw-node ]] || continue + (( p >= lo && p <= hi )) || continue + port_in_list "$p" "$resv" && continue + [[ " $out " == *" $p($pr) "* ]] || out+="${out:+ }$p($pr)" + done < <(listen_rows tcp; listen_rows udp) + echo "$out" +} + # ─── JSON-режим (для флот-мониторинга: Zabbix/Prometheus/SSH-поллинг) ───────── # `diagnose.sh --json` печатает один машинно-читаемый объект и выходит. Read-only. emit_json() { local kern xanmod virt cc qd ctmax ctcnt ctpct uln minsnd mtuprobe collapsed local fw fwm ab4 ab6 susp bl4 bl6 fl4 fl6 crowd ctg syndeg safety rebootn - local u1 n1 s1 i1 w1 q1 sq1 st1 u2 n2 s2 i2 w2 q2 sq2 st2 dt steal out rtx rtxpct + local steal out rtx rtxpct local nav host up load1 mempct wi wanrx wantx ip6def udperr - local rnst rnrc rnse fsa bla certd certf nowsec cf cend cends cd npd npfw + local rnst rnrc rnse fsa bla certd certf nowsec cf npd npfw local mcpi jsp psl psist wdc csd tcpp wl_live wl_conf tmpv local dsf dsfn dsnt _dn _dc _ds _dt + local stealb csl csc wdcs wl_all csal dnp dng xmu cfound ps24 pst psh csscope kern="$(uname -r)"; [[ "${kern,,}" == *xanmod* ]] && xanmod=true || xanmod=false virt="$(detect_virt)" cc="$(val net.ipv4.tcp_congestion_control)"; qd="$(val net.core.default_qdisc)" @@ -276,14 +693,12 @@ emit_json() { mtuprobe="$(val net.ipv4.tcp_mtu_probing)"; mtuprobe="${mtuprobe:-0}" # только established: отмирающие сокеты (TIME-WAIT и пр.) давали ложный «коллапс» collapsed="$(ss -tin state established 2>/dev/null | grep -oE 'mss:[0-9]+' | awk -F: '$2>0 && $2<256{c++} END{print c+0}')" - # CPU steal (1с-сэмпл) — только если есть /proc/stat - steal=0 - if [[ -r /proc/stat ]]; then - read -r _ u1 n1 s1 i1 w1 q1 sq1 st1 _ < /proc/stat; sleep 1 - read -r _ u2 n2 s2 i2 w2 q2 sq2 st2 _ < /proc/stat - dt=$(( (u2+n2+s2+i2+w2+q2+sq2+st2) - (u1+n1+s1+i1+w1+q1+sq1+st1) )) - [[ "${dt:-0}" -gt 0 ]] && steal=$(( (st2 - st1) * 100 / dt )) - fi + # CPU steal: окно 3 с + среднее с загрузки (секундный семпл был шумом) + steal=0; stealb=0 + [[ -r /proc/stat ]] && read -r steal stealb <<<"$(cpu_steal_measure 3)" + [[ "$steal" =~ ^[0-9]+$ ]] || steal=0; [[ "$stealb" =~ ^[0-9]+$ ]] || stealb=0 + # дельты счётчиков к прошлому запуску (diag-counters.last) + delta_load out=0; rtx=0; rtxpct=0 if [[ -r /proc/net/snmp ]]; then eval "$(awk '/^Tcp:/{ if(!h){for(i=2;i<=NF;i++)nm[i]=$i;h=1;next} for(i=2;i<=NF;i++){if(nm[i]=="OutSegs")print "out="$i;if(nm[i]=="RetransSegs")print "rtx="$i} }' /proc/net/snmp 2>/dev/null)" @@ -362,38 +777,37 @@ emit_json() { 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 )); } [[ -f "$STATE_DIR/blocklist.last" ]] && { cf="$(cat "$STATE_DIR/blocklist.last" 2>/dev/null)"; [[ "$cf" =~ ^[0-9]+$ ]] && bla=$(( nowsec - cf )); } - # ближайший к истечению серт (−1 = нет openssl / сертов не нашли). NA_CERT_PATHS — - # доп.пути через пробел; используются только в [ -f ] и openssl (без eval). - certd=-1; certf="" - if command -v openssl >/dev/null 2>&1; then - # shellcheck disable=SC2086 # глобы и NA_CERT_PATHS обязаны раскрыться - for cf in $NA_CERT_GLOBS ${NA_CERT_PATHS:-}; do - [[ -f "$cf" ]] || continue - cert_retired "$cf" && continue - cend="$(openssl x509 -enddate -noout -in "$cf" 2>/dev/null | cut -d= -f2)"; [[ -n "$cend" ]] || continue - cends="$(date -d "$cend" +%s 2>/dev/null)" || continue; [[ "$cends" =~ ^[0-9]+$ ]] || continue - cd=$(( (cends - nowsec) / 86400 )) - { [[ "$certd" -lt 0 ]] || [[ "$cd" -lt "$certd" ]]; } && { certd="$cd"; certf="$cf"; } - done - fi + # ближайший к истечению серт. cert_min_days = −1 и cert_found=false — не нашли/нет + # openssl; при cert_found=true значение может быть ≤0 (истёк). NA_CERT_PATHS — доп. + # пути через пробел; используются только в [ -f ] и openssl (без eval). + cert_scan || true + certd="$CERT_MIN"; certf="$CERT_MIN_F"; cfound=false + [[ "$CERT_FOUND" == 1 ]] && cfound=true || certd=-1 # ── v4.1: метрики, которых мониторингу не хватало ────────────────────────── # max_conn_per_ip: по тому же срезу, что и правило `ct count` (−1 = мерить не по чему) mcpi=-1 - tcpp="$(awk -F= '/^tcp_ports=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" + # порты — из живых правил (маркер устаревает после ручных правок), затем conf/маркер + tcpp="$(fw_ports tcp)" if [[ -n "${tcpp:-}" ]]; then tmpv="$(conn_per_ip_max "$tcpp")" && [[ "$tmpv" =~ ^[0-9]+$ ]] && mcpi="$tmpv" fi # глубина журнала и объём лога анти-скана (−1 = не измерено) jsp=-1; tmpv="$(journal_span_h)" && [[ "$tmpv" =~ ^[0-9]+$ ]] && jsp="$tmpv" psl=-1; tmpv="$(portscan_log_lines)" && [[ "$tmpv" =~ ^[0-9]+$ ]] && psl="$tmpv" + # за сутки по всем загрузкам + доля от всех строк журнала за сутки (−1 = не измерено) + ps24=-1; psh=-1 + if tmpv="$(portscan_24h)"; then + read -r ps24 pst <<<"$tmpv" + [[ "$pst" =~ ^[0-9]+$ ]] && (( pst > 0 )) && psh=$(( ps24 * 100 / pst )) + fi psist="$(psi_state)"; psist="${psist:-unknown}" # whitelist_drift_conf: адреса живого whitelist_v4, которых НЕТ в WHITELIST= из # protect.conf — они не переживут ре-ран protect (−1 = conf нет, сверять не с чем) wdc=-1 if [[ -f "$CONF_DIR/protect.conf" ]]; then wl_live="$(nft_set_elems inet na_filter whitelist_v4 2>/dev/null | sed -E 's#/32$##' | sort -u)" - wl_conf="$(sed -nE 's/.*\{WHITELIST:=([^}]*)\}.*/\1/p' "$CONF_DIR/protect.conf" 2>/dev/null | tail -1 \ + wl_conf="$(conf_val "$CONF_DIR/protect.conf" WHITELIST \ | tr ',' '\n' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]+)?' | sed -E 's#/32$##' | sort -u)" wdc="$(comm -23 <(printf '%s\n' "$wl_live" | grep -v '^$' | sort -u) \ <(printf '%s\n' "$wl_conf" | grep -v '^$' | sort -u) 2>/dev/null | grep -c .)" @@ -429,6 +843,30 @@ emit_json() { done < <(dynset_fill 2>/dev/null) dsfn="$(json_escape "$dsfn")" + # v4.2: CrowdSec по источникам решений (−1 = нет cscli / не ответил) и область действия + csl=-1; csc=-1 + if tmpv="$(cs_decisions)"; then read -r csl csc <<<"$tmpv"; fi + [[ "$csl" =~ ^-?[0-9]+$ ]] || csl=-1; [[ "$csc" =~ ^-?[0-9]+$ ]] || csc=-1 + csscope="$(mark_val crowdsec_scope)" + # живой whitelist, не прикрытый ЭФФЕКТИВНЫМ allowlist CrowdSec (−1 = сверять не с чем): + # такой адрес CrowdSec может забанить, а bouncer режет раньше na_filter + wdcs=-1 + if [[ "$fw" == true ]] && { command -v cscli >/dev/null 2>&1 || [[ -d /etc/crowdsec ]]; }; then + wdcs=0 + wl_all="$( { nft_set_elems inet na_filter whitelist_v4; nft_set_elems inet na_filter whitelist_v6; } 2>/dev/null \ + | sed -E 's#/32$##; s#/128$##' | sort -u)" + csal="$(cs_allowlist)" + while IFS= read -r tmpv; do + [[ -n "$tmpv" ]] || continue + wl_covered "$tmpv" "$csal" || wdcs=$((wdcs+1)) + done <<<"$wl_all" + fi + # опубликованные через DNAT порты и защищает ли их na_filter (dnat_guard: −1 = нет ключа) + dnp="$(dnat_ports)" + dng="$(mark_val dnat_guard)"; [[ "$dng" =~ ^[01]$ ]] || dng=-1 + # доступное обновление XanMod (по кэшу apt; пусто = нет/не знаем) + xmu=""; tmpv="$(xanmod_update)" && xmu="${tmpv#* }" + # Каждое строковое значение — через json_escape. Раньше каждое поле полагалось на то, # что источник «и так чистый», и одного сырого перевода строки из docker хватило, # чтобы уронить парсер на ВСЁМ документе (issue #27). @@ -439,6 +877,8 @@ emit_json() { wi="$(json_escape "$wi")"; rnst="$(json_escape "$rnst")" npd="$(json_escape "${npd:-}")"; npfw="$(json_escape "$npfw")" psist="$(json_escape "$psist")"; certf="$(json_escape "$certf")" + dnp="$(json_escape "$dnp")"; xmu="$(json_escape "$xmu")" + csscope="$(json_escape "$csscope")" printf '{' printf '"kernel":"%s","xanmod":%s,"virt":"%s","cpu_steal_pct":%s,"tcp_retrans_pct":%s,' "$kern" "$xanmod" "$virt" "$steal" "$rtxpct" @@ -462,8 +902,18 @@ emit_json() { "$wdc" "$csd" "$certf" printf '"logrotate_owned_masks":"%s","logrotate_ceded_masks":"%s","logrotate_ceded_nocap":%s,' \ "$lro" "$lrc" "$lrcn" - printf '"dynset_fill_max_pct":%s,"dynset_fill_max_set":"%s","dynset_no_timeout":%s}\n' \ + printf '"dynset_fill_max_pct":%s,"dynset_fill_max_set":"%s","dynset_no_timeout":%s,' \ "$dsf" "$dsfn" "$dsnt" + # v4.2 (аудит флота): дельты к прошлому запуску (−1 = окна ещё нет: первый запуск, + # ребут или прошло <30 с), steal с загрузки, CrowdSec по источникам, 4-й слой + # whitelist, DNAT мимо na_filter, обновление XanMod, флаг «серт найден», лог анти-скана за сутки + printf '"udp_rcvbuf_errors_delta":%s,"softnet_drops_delta":%s,"time_squeeze_delta":%s,"listen_overflows_delta":%s,"delta_window_s":%s,' \ + "$D_UDPERR" "$D_SNDROP" "$D_SQZ" "$D_LOVF" "$DW" + printf '"cpu_steal_boot_pct":%s,"crowdsec_decisions_local":%s,"crowdsec_decisions_capi":%s,"crowdsec_scope":"%s",' \ + "$stealb" "$csl" "$csc" "$csscope" + printf '"whitelist_drift_crowdsec":%s,"dnat_ports":"%s","dnat_guard":%s,"xanmod_update":"%s","cert_found":%s,' \ + "$wdcs" "$dnp" "$dng" "$xmu" "$cfound" + printf '"portscan_log_lines_24h":%s,"portscan_log_share_pct":%s}\n' "$ps24" "$psh" } if [[ "${1:-}" == "--json" ]]; then emit_json; exit 0; fi @@ -609,16 +1059,19 @@ info "Kernel: $(uname -r) Arch: $(arch)" info "Virt: $VIRT CPU: ${CORES} ядер RAM: ${MEM:-?}" info "Uptime: $(uptime -p 2>/dev/null || uptime)" # CPU steal: сколько CPU у нашей VPS отбирает гипервизор — главный скрытый потолок, -# не виден в load/governor. Дельта за 1 секунду по агрегату /proc/stat. -read -r _ u1 n1 s1 i1 w1 q1 sq1 st1 _ < /proc/stat -sleep 1 -read -r _ u2 n2 s2 i2 w2 q2 sq2 st2 _ < /proc/stat -DT=$(( (u2+n2+s2+i2+w2+q2+sq2+st2) - (u1+n1+s1+i1+w1+q1+sq1+st1) )) -if [[ "${DT:-0}" -gt 0 ]]; then - STEAL=$(( (st2 - st1) * 100 / DT )) - if [[ "$STEAL" -ge 10 ]]; then bad "CPU steal = ${STEAL}% — гипервизор активно отбирает CPU (оверселл/шумный сосед)" - elif [[ "$STEAL" -ge 3 ]]; then wrn "CPU steal = ${STEAL}% (заметный — под пиками может проседать)" - else pass "CPU steal = ${STEAL}% (CPU ноды не отбирают)" +# не виден в load/governor. Окно 3 с (секундный семпл на флоте прыгал 0↔8%) + среднее с +# загрузки и с прошлого запуска: всплеск ≠ хронический оверселл. +delta_load +if [[ -r /proc/stat ]]; then + read -r STEAL STEAL_BOOT <<<"$(cpu_steal_measure 3)" + [[ "$STEAL" =~ ^[0-9]+$ ]] || STEAL=0; [[ "$STEAL_BOOT" =~ ^[0-9]+$ ]] || STEAL_BOOT=0 + STEAL_LONG="$STEAL_BOOT"; [[ "$D_STEAL" -ge 0 ]] && STEAL_LONG="$D_STEAL" + STEAL_TXT="CPU steal = ${STEAL}% за 3 с, в среднем с загрузки ${STEAL_BOOT}%" + [[ "$D_STEAL" -ge 0 ]] && STEAL_TXT+=", за $((DW/60)) мин с прошлого запуска ${D_STEAL}%" + if [[ "$STEAL" -ge 10 && "$STEAL_LONG" -ge 5 ]]; then bad "$STEAL_TXT — гипервизор хронически отбирает CPU (оверселл/шумный сосед)" + elif [[ "$STEAL" -ge 10 ]]; then wrn "$STEAL_TXT — всплеск: в моменте отбирают заметно, в среднем терпимо" + elif [[ "$STEAL" -ge 3 || "$STEAL_LONG" -ge 3 ]]; then wrn "$STEAL_TXT (заметный — под пиками может проседать)" + else pass "$STEAL_TXT (CPU ноды не отбирают)" fi fi # Сохранённый conf ПИННИТ дефолты той версии, при которой ноду настраивали: идиома @@ -641,10 +1094,17 @@ unset _cf _k _old _new _ver _why # ─── Ядро / BBR ────────────────────────────────────────────────────────────── title "Ядро и congestion control" KREL="$(uname -r)" +XM_EN="$(conf_val "$CONF_DIR/optimize.conf" ENABLE_XANMOD)" if [[ "${KREL,,}" == *xanmod* ]]; then pass "XanMod-ядро активно ($KREL) → BBRv3 доступен" + if XMU="$(xanmod_update)"; then + info "доступно обновление XanMod: ${XMU#* } (пакет ${XMU%% *}, по кэшу apt) — apt-get install --only-upgrade ${XMU%% *} + ребут в окно обслуживания" + fi else - if can_install_kernel; then + if [[ "$XM_EN" == 0 ]]; then + # осознанный выбор оператора (ENABLE_XANMOD=0 в optimize.conf), а не недоделка + info "Стоковое ядро: XanMod выключен осознанно (ENABLE_XANMOD=0 в optimize.conf) — BBR из стокового ядра" + elif can_install_kernel; then wrn "Ядро не XanMod — BBRv3 нет. Поставь оптимизатор (XanMod), будет +скорость." else [[ "$VIRT" != none && "$VIRT" != kvm && "$VIRT" != unknown ]] \ @@ -666,6 +1126,16 @@ fi if reboot_pending; then wrn "optimize поставил ядро/параметры загрузки, а перезагрузки с тех пор не было — reboot (XanMod/BBRv3, psi=1 включатся после него)" fi +# /run/reboot-required на флоте почти всегда — от стоковых ядер, которые ставит apt рядом +# с работающим XanMod: ребут ради них ничего не даст, а вечный ▲ приучает его не читать. +if RR="$(reboot_required_pkgs)"; then + RR_PK="${RR#*$'\t'}" + if [[ "${RR%%$'\t'*}" == stock ]]; then + info "/run/reboot-required — от стоковых ядер ($RR_PK), а работает XanMod: ребут ради них не нужен" + else + wrn "система ждёт перезагрузки (/run/reboot-required${RR_PK:+: $RR_PK})" + fi +fi # Реальность поверх sysctl: сколько живых TCP-сокетов реально на BBR + доля ретрансмитов. BBRN="$(ss -tin 2>/dev/null | grep -c bbr || true)" [[ "${BBRN:-0}" -gt 0 ]] && info "Живых TCP-сокетов на BBR сейчас: $BBRN" @@ -686,9 +1156,16 @@ chk() { # chk key min "human" if [[ -z "$cur" ]]; then wrn "$k не задан"; return; fi if [[ "$cur" -ge "$want" ]] 2>/dev/null; then pass "$k = $cur"; else wrn "$k = $cur (рекоменд. ≥ $want)"; fi } +# Порог буферов — от RAM-tier, как ставит optimize: tier1 (≤1.2G) получает 16M, и +# фиксированный порог 32M давал ложный ▲ на каждой маленькой ноде. rmem_default — буфер +# сокета, которому приложение размер не задавало (исходящие UDP xray): чужой sysctl.d, +# урезавший его, даёт UDP RcvbufErrors при «правильном» rmem_max. +read -r SOCK_MAX SOCK_DEF <<<"$(sock_tier)" chk net.core.somaxconn 32768 -chk net.core.rmem_max 33554432 -chk net.core.wmem_max 33554432 +chk net.core.rmem_max "$SOCK_MAX" +chk net.core.wmem_max "$SOCK_MAX" +chk net.core.rmem_default "$SOCK_DEF" +chk net.core.wmem_default "$SOCK_DEF" chk net.ipv4.tcp_max_syn_backlog 16384 chk fs.file-max 1000000 chk fs.nr_open 1000000 @@ -697,6 +1174,12 @@ chk fs.nr_open 1000000 RPF="$(val net.ipv4.conf.all.rp_filter)" [[ "$RPF" == "2" ]] && pass "rp_filter = 2 (loose, ок для host-network)" \ || { [[ "$RPF" == "1" ]] && wrn "rp_filter = 1 (strict) — может рубить асимметричный трафик VPN" || info "rp_filter = ${RPF:-?}"; } +# Порт ноды внутри эфемерного диапазона и не зарезервирован: исходящее соединение может +# занять его раньше, чем xray забиндит (рестарт контейнера) — inbound/API не поднимется. +EPH_BAD="$(node_eph_unreserved)" +if [[ -n "$EPH_BAD" ]]; then + wrn "порты ноды в эфемерном диапазоне ($(val net.ipv4.ip_local_port_range | tr -s '[:space:]' '-' | sed 's/-$//')) без резерва: $EPH_BAD — исходящие могут занять их до старта xray (bind: address in use). Ре-ран optimize (v4.2 пишет ip_local_reserved_ports)" +fi # ─── Лимиты ────────────────────────────────────────────────────────────────── title "Лимиты" @@ -768,13 +1251,19 @@ NIC="$(default_iface || true)" if [[ -n "$NIC" ]]; then DRV="$(ethtool -i "$NIC" 2>/dev/null | awk '/^driver:/{print $2}')" info "NIC: $NIC driver: ${DRV:-?} txqueuelen: $(cat /sys/class/net/$NIC/tx_queue_len 2>/dev/null || echo '?')" - RXQ=$(ls -d /sys/class/net/"$NIC"/queues/rx-* 2>/dev/null | wc -l) + RXQ=$(ls -d /sys/class/net/"$NIC"/queues/rx-* 2>/dev/null | wc -l | tr -d " ") RPS_ON=0 for q in /sys/class/net/"$NIC"/queues/rx-*/rps_cpus; do [[ -f "$q" ]] && grep -qvE '^0+$' "$q" 2>/dev/null && RPS_ON=1 done [[ "$RXQ" -gt 1 ]] && info "RX-очередей: $RXQ (multi-queue)" || info "RX-очередей: $RXQ (single-queue — RPS критичен)" - if [[ "$RPS_ON" == "1" ]]; then pass "RPS включён (приём размазан по ядрам)"; else + # RX-очередей не меньше, чем ядер: приём уже размазан аппаратно (RSS), RPS поверх + # только гоняет пакеты между CPU — его отсутствие здесь норма, а не ▲ (optimize v4.2 + # в этом случае RPS и не включает). + RSS_OK=0; [[ "$CORES" =~ ^[0-9]+$ && "$RXQ" -ge "$CORES" && "$CORES" -gt 1 ]] && RSS_OK=1 + if [[ "$RPS_ON" == "1" ]]; then pass "RPS включён (приём размазан по ядрам)" + elif [[ "$RSS_OK" == 1 ]]; then info "RPS выключен — и не нужен: RX-очередей ($RXQ) ≥ ядер ($CORES), приём размазывает RSS" + else [[ "$CORES" -gt 1 ]] && wrn "RPS выключен — на $CORES ядрах приём может висеть на cpu0" || info "1 ядро — RPS не нужен" fi if command -v ethtool >/dev/null; then @@ -796,6 +1285,8 @@ if [[ -n "$NIC" ]]; then if systemctl is-active --quiet na-rps.service 2>/dev/null; then if [[ "$RPS_ON" == "1" ]]; then pass "na-rps.service активен" + elif [[ "$RSS_OK" == 1 ]]; then + info "na-rps.service активен, RPS сознательно не включён (RSS: очередей ≥ ядер)" elif [[ "$CORES" -gt 1 ]] 2>/dev/null; then wrn "na-rps.service active, но rps_cpus пуст — юнит отработал до появления default route (гонка на буте) и вышел нулём: systemctl restart na-rps.service; v4.1 optimize чинит юнит" else @@ -807,9 +1298,47 @@ if [[ -n "$NIC" ]]; then else wrn "Основной интерфейс не определён" fi +# softnet: dropped — пакет не влез в backlog (netdev_max_backlog), time_squeeze — softirq +# исчерпал бюджет и отложил приём. Оба с загрузки — смотрим прирост к прошлому запуску. +if [[ -r /proc/net/softnet_stat ]]; then + if [[ "$DW" -ge 0 && "$D_SNDROP" -ge 0 && "$D_SQZ" -ge 0 ]]; then + if [[ "$D_SNDROP" -gt 0 ]]; then + wrn "softnet: +$D_SNDROP пакетов отброшено за $((DW/60)) мин (backlog полон, netdev_max_backlog=$(val net.core.netdev_max_backlog)) — приём не успевает: RPS/ядра/CPU steal" + elif [[ $(( D_SQZ / (DW > 0 ? DW : 1) )) -ge 1 ]]; then + wrn "softnet: time_squeeze +$D_SQZ за $((DW/60)) мин (≥1/с) — softirq упирается в бюджет (netdev_budget=$(val net.core.netdev_budget)); дропов нет" + else + pass "softnet: за $((DW/60)) мин дропов нет, time_squeeze +$D_SQZ" + fi + else + info "softnet с загрузки: dropped=$C_SNDROP time_squeeze=$C_SQZ (прирост покажет следующий запуск — снимок сохранён)" + fi +fi # ─── Память / прочее ───────────────────────────────────────────────────────── title "Память, swap, THP, governor" +# MemAvailable — то, что ядро реально может отдать без свопа; «used» из free врёт из-за кэша +read -r MEM_T MEM_A SW_T SW_F <<<"$(awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} + END{printf "%d %d %d %d", t, a, st, sf}' /proc/meminfo 2>/dev/null)" +if [[ "${MEM_T:-0}" -gt 0 ]]; then + MEM_AP=$(( MEM_A * 100 / MEM_T )) + if [[ "$MEM_AP" -lt 15 ]]; then + wrn "MemAvailable ${MEM_AP}% ($(( MEM_A / 1024 )) из $(( MEM_T / 1024 )) МБ) — памяти впритык: OOM-killer рядом" + else + pass "MemAvailable ${MEM_AP}% ($(( MEM_A / 1024 )) из $(( MEM_T / 1024 )) МБ)" + fi +fi +[[ "${SW_T:-0}" -gt 0 && $(( SW_T - SW_F )) -gt 0 ]] && info "своп занят: $(( (SW_T - SW_F) / 1024 )) из $(( SW_T / 1024 )) МБ" +# balloon: гипервизор забирает RAM у гостя «на лету» — MemTotal остаётся прежним, а памяти +# фактически меньше (на ноде флота balloon гипервизора держал 2.5 ГБ) +BALLOON="" +for _b in vmw_balloon virtio_balloon; do + if awk -v m="$_b" '$1 == m { f = 1 } END { exit !f }' /proc/modules 2>/dev/null \ + || [[ -d "/sys/bus/virtio/drivers/$_b" || -d "/sys/bus/pci/drivers/$_b" ]]; then + BALLOON+="${BALLOON:+, }$_b" + fi +done +unset _b +[[ -n "$BALLOON" ]] && info "balloon-драйвер: $BALLOON — гипервизор может забирать RAM у ноды (MemTotal этого не покажет)" if [[ -n "$(swapon --show 2>/dev/null)" ]]; then pass "swap: $(swapon --show=NAME,SIZE --noheadings 2>/dev/null | tr '\n' ' ')"; else wrn "swap отсутствует"; fi THP="$(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null | grep -oE '\[.*\]' | tr -d '[]')" [[ "$THP" == "never" ]] && pass "THP = never" || wrn "THP = ${THP:-?} (для сетевых нагрузок лучше never)" @@ -867,10 +1396,15 @@ if nft -t list table inet na_filter >/dev/null 2>&1; then else pass "whitelist в файле и в памяти совпадают (переживёт ребут)" fi + # обратная сторона: в файле есть, в памяти нет — удалён руками на ходу; вернётся + # после ребута или reload na-firewall (расхождение, а не авария) + FBACK="$(comm -13 <(printf '%s\n' "$LIVE_WL" | grep -v '^$' | sort -u) <(printf '%s\n' "$FILE_WL" | grep -v '^$' | sort -u) 2>/dev/null | paste -sd' ' -)" + [[ -n "$FBACK" ]] && info "в $NFT_FILE, но не в живом whitelist_v4: $FBACK (вернутся после ребута/reload na-firewall)" fi if [[ -f "$CONF_DIR/protect.conf" ]]; then - # conf пишется идиомой `: "${WHITELIST:=…}"` — наивный `^WHITELIST=` не матчит - CONF_WL="$(sed -nE 's/.*\{WHITELIST:=([^}]*)\}.*/\1/p' "$CONF_DIR/protect.conf" 2>/dev/null | tail -1 \ + # conf пишется идиомой `: "${WHITELIST:=…}"` (protect v4.2 — `=`) — наивный + # `^WHITELIST=` не матчит + CONF_WL="$(conf_val "$CONF_DIR/protect.conf" WHITELIST \ | tr ',' '\n' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?' | sed -E 's#/32$##' | sort -u)" CDRIFT_L="$(comm -23 <(printf '%s\n' "$LIVE_WL" | grep -v '^$' | sort -u) <(printf '%s\n' "$CONF_WL" | grep -v '^$' | sort -u) 2>/dev/null)" CDRIFT="$(printf '%s\n' "$CDRIFT_L" | grep -c .)"; [[ "$CDRIFT" =~ ^[0-9]+$ ]] || CDRIFT=0 @@ -883,6 +1417,41 @@ if nft -t list table inet na_filter >/dev/null 2>&1; then else pass "whitelist ⊆ WHITELIST= в protect.conf (переживёт ре-ран protect)" fi + # обратная сторона: в conf есть, в памяти нет — кто-то удалил элемент руками, + # ре-ран protect вернёт его (не авария, но расхождение стоит знать) + CBACK="$(comm -13 <(printf '%s\n' "$LIVE_WL" | grep -v '^$' | sort -u) <(printf '%s\n' "$CONF_WL" | grep -v '^$' | sort -u) 2>/dev/null | paste -sd' ' -)" + [[ -n "$CBACK" ]] && info "в WHITELIST= из protect.conf, но не в живом whitelist_v4: $CBACK (вернутся ре-раном protect)" + fi + # 4-й слой — ЭФФЕКТИВНЫЙ allowlist CrowdSec (все s02-enrich/*.yaml с whitelist:). + # Bouncer CrowdSec стоит РАНЬШЕ na_filter (priority -10): адрес из whitelist na_filter, + # которого нет в allowlist CrowdSec, всё равно может быть забанен (аудит флота: адрес + # hub'а управления был в whitelist, но не в allowlist CrowdSec). + if command -v cscli >/dev/null 2>&1 || [[ -d /etc/crowdsec/parsers ]]; then + CS_AL="$(cs_allowlist)" + WL_ALL="$( { nft_set_elems inet na_filter whitelist_v4; nft_set_elems inet na_filter whitelist_v6; } 2>/dev/null \ + | sed -E 's#/32$##; s#/128$##' | sort -u)" + CS_MISS="" + while IFS= read -r _w; do + [[ -n "$_w" ]] || continue + wl_covered "$_w" "$CS_AL" || CS_MISS+="${CS_MISS:+ }$_w" + done <<<"$WL_ALL" + CS_EXTRA="" + while IFS= read -r _w; do + [[ -n "$_w" ]] || continue + # хабовый whitelists.yaml прикрывает приватные сети — это не дрейф + case "$_w" in 127.*|::1|10.0.0.0/8|172.16.0.0/12|192.168.0.0/16|fe80::/10|fc00::/7) continue;; esac + grep -qxF -- "$_w" <<<"$WL_ALL" || CS_EXTRA+="${CS_EXTRA:+ }$_w" + done <<<"$CS_AL" + unset _w + if [[ -n "$CS_MISS" ]] && nft list table ip crowdsec >/dev/null 2>&1; then + wrn "whitelist na_filter не прикрыт в CrowdSec: $CS_MISS — CrowdSec может их забанить, а его bouncer режет раньше na_filter. Ре-ран protect перепишет na-whitelist.yaml (или добавь в /etc/crowdsec/parsers/s02-enrich/)" + elif [[ -n "$CS_MISS" ]]; then + # без bouncer'а решения CrowdSec ничего не режут — расхождение, но не угроза + info "whitelist na_filter не прикрыт в CrowdSec: $CS_MISS (bouncer'а nft нет — сейчас это не режет)" + elif [[ -n "$WL_ALL" ]]; then + pass "whitelist na_filter целиком прикрыт allowlist CrowdSec" + fi + [[ -n "$CS_EXTRA" ]] && info "в allowlist CrowdSec, но не в whitelist na_filter: $CS_EXTRA (CrowdSec их не тронет, а autoban na_filter — может)" fi # Датчик per-IP потолка. Считаем ТОТ ЖЕ срез, что режет правило `ct count`: только # входящие established на порты из tcp_ports=, без loopback и вайтлиста. v4.0 брал @@ -890,11 +1459,13 @@ if nft -t list table inet na_filter >/dev/null 2>&1; then # дважды), исходящие плечи и вайтлист, до которых правило не доходит вовсе, — и # предупреждение горело вечно, подталкивая поднять CONN_LIMIT, т.е. ослабить # защиту от exhaustion ради несуществующей проблемы (issue #31). - CLIM=$(nft list chain inet na_filter input 2>/dev/null | grep -oE 'ct count over [0-9]+' | head -1 | grep -oE '[0-9]+') + chain_load + CLIM="$(grep -oE 'ct count over [0-9]+' <<<"$NA_CHAIN" | head -1 | grep -oE '[0-9]+')" if [[ -n "$CLIM" ]]; then - TCPP_INST="$(awk -F= '/^tcp_ports=/{print $2}' "$STATE_DIR/protect.installed" 2>/dev/null)" + # порты — из живых правил: маркер tcp_ports= устаревает после ручной правки + TCPP_INST="$(fw_ports tcp)" if [[ -z "${TCPP_INST:-}" ]]; then - info "per-IP лимит $CLIM есть, но список портов правила неизвестен (в $STATE_DIR/protect.installed нет tcp_ports=) — датчик пропущен" + info "per-IP лимит $CLIM есть, но список портов правила неизвестен (ни в правилах, ни в protect.conf/маркере) — датчик пропущен" else MAXIP="$(conn_per_ip_max "$TCPP_INST")"; [[ "$MAXIP" =~ ^[0-9]+$ ]] || MAXIP=0 if [[ "$MAXIP" -ge $((CLIM*80/100)) ]]; then @@ -930,6 +1501,37 @@ if nft -t list table inet na_filter >/dev/null 2>&1; then fi fi unset _dn _dc _ds _dt DS_NTMAX DS_NTMAXN + # Порты: живые правила ↔ protect.conf ↔ маркер. Живые — то, что действует; conf — то, + # что применит ре-ран protect; маркер пишет только прогон protect. Ручная правка правил + # (порт добавлен `nft add rule`) живёт до ре-рана — и молча исчезает (аудит флота). + for _pr in tcp udp; do + _K=TCP_PORTS; _mk=tcp_ports; [[ "$_pr" == udp ]] && { _K=UDP_PORTS; _mk=udp_ports; } + _live="$(fw_live_ports "$_pr")" + _conf="$(csv_norm "$(conf_val "$CONF_DIR/protect.conf" "$_K")")" + _mark="$(csv_norm "$(mark_val "$_mk")")" + if conf_has "$CONF_DIR/protect.conf" "$_K" && [[ "$_live" != "$_conf" ]]; then + _gone="$(comm -23 <(tr ',' '\n' <<<"$_live" | grep . | sort) <(tr ',' '\n' <<<"$_conf" | grep . | sort) | paste -sd, -)" + _new="$(comm -13 <(tr ',' '\n' <<<"$_live" | grep . | sort) <(tr ',' '\n' <<<"$_conf" | grep . | sort) | paste -sd, -)" + wrn "${_pr^^}-порты в живых правилах (${_live:-нет}) ≠ $_K в protect.conf (${_conf:-нет}): ре-ран protect${_gone:+ закроет $_gone}${_new:+${_gone:+,} откроет $_new}. Ручная правка? Перенеси в $_K" + elif [[ -n "$_mark" && "$_mark" != "$_live" ]]; then + info "маркер protect.installed устарел: ${_mk}=$_mark, в правилах ${_live:-нет} — сенсоры берут порты из правил" + fi + done + unset _pr _K _mk _live _conf _mark _gone _new + # Хэш файла автозагрузки (protect v4.2 пишет nft_sha256 в маркер): несовпадение = файл + # правили руками, а ре-ран protect его молча перезапишет. + NFT_SHA_MARK="$(mark_val nft_sha256)" + if [[ -n "$NFT_SHA_MARK" && -r "$NFT_FILE" ]]; then + NFT_SHA_NOW="$(file_sha256 "$NFT_FILE")" + if [[ -n "$NFT_SHA_NOW" && "$NFT_SHA_NOW" != "$NFT_SHA_MARK" ]]; then + wrn "$NFT_FILE правлен руками после прогона protect (sha256 ≠ маркеру) — ре-ран protect его перезапишет; локальные правила — в $(mark_val include_dir | grep . || echo "$CONF_DIR/na_filter.d")/*.nft" + else + [[ -n "$NFT_SHA_NOW" ]] && pass "$NFT_FILE совпадает с записанным protect (ручных правок нет)" + fi + fi + # Именованные счётчики дропов (protect v4.2): лог ограничен по скорости, счётчик — нет + FW_CNT="$(fw_counters)" + [[ -n "$FW_CNT" ]] && info "счётчики дропов na_filter (с загрузки правил): $FW_CNT" # v3.0 компоненты if nft list set inet na_filter suspect_v4 >/dev/null 2>&1; then SUSP="$(nft_set_count inet na_filter suspect_v4)"; [[ "$SUSP" =~ ^[0-9]+$ ]] || SUSP=0 @@ -959,7 +1561,7 @@ if nft -t list table inet na_filter >/dev/null 2>&1; then # парс `^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)" + [[ -n "$_iv" ]] || _iv="$(conf_val "$CONF_DIR/protect.conf" FLEET_SYNC_INTERVAL)" _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" @@ -1010,6 +1612,68 @@ if nft -t list table inet na_filter >/dev/null 2>&1 \ && ! systemctl is-enabled --quiet na-firewall.service 2>/dev/null; then wrn "na_filter активна, но na-firewall.service ВЫКЛЮЧЕН — после ребута правила не поднимутся. Лечится повторным прогоном protect." fi +# Дистрибутивный nftables.service: ExecStop/ExecReload = `flush ruleset`, и его конфиг +# обычно начинается с того же. Включённым его оставили старые версии protect (на флоте — +# enabled+active на части нод): любой restart/reload этого юнита сносит ВСЕ таблицы — +# na_filter, CrowdSec, Docker. +NFTS_ST="" +systemctl is-enabled --quiet nftables.service 2>/dev/null && NFTS_ST="enabled" +systemctl is-active --quiet nftables.service 2>/dev/null && NFTS_ST+="${NFTS_ST:++}active" +if [[ -n "$NFTS_ST" ]] && grep -qE '^[[:space:]]*flush[[:space:]]+ruleset' /etc/nftables.conf 2>/dev/null; then + wrn "nftables.service $NFTS_ST, а /etc/nftables.conf делает flush ruleset — restart/reload этого юнита снесёт все таблицы (na_filter, CrowdSec, Docker). Если конфиг не ведёшь сам: systemctl disable nftables.service (без stop/--now)" +fi +# Порты, опубликованные через DNAT (Docker -p, ручные relay), идут хуком forward мимо +# input-цепочки na_filter: strict их не закрывает, autoban/блоклисты/лимиты не действуют. +# protect v4.2 умеет их стеречь (dnat_guard=1 в маркере). +DNAT_P="$(dnat_ports)" +if [[ -n "$DNAT_P" ]]; then + if [[ "$(mark_val dnat_guard)" == 1 ]]; then + info "опубликованы через DNAT: $DNAT_P — под защитой na_filter (dnat_guard=1)" + elif nft -t list table inet na_filter >/dev/null 2>&1; then + wrn "опубликованы через DNAT мимо na_filter: $DNAT_P — трафик идёт хуком forward: strict их не закрывает, autoban/блоклисты/лимиты не действуют (protect v4.2 — dnat_guard)" + else + info "опубликованы через DNAT: $DNAT_P" + fi +fi +# Публичные слушатели вне разрешённых портов и разрешённые порты без слушателя. При strict +# первые отрезаны (сервис доступен только из whitelist/флота — на флоте так «молчал» +# агент мониторинга, а его опросы копились в логе как скан); вне strict — открыты миру. +FW_TCP_OK="$(fw_ports tcp)"; FW_UDP_OK="$(fw_ports udp)" +FW_MODE_NOW="$FW_MODE_INST"; nft -t list table inet na_filter >/dev/null 2>&1 || FW_MODE_NOW=none +SSH_OK="$(csv_norm "$(mark_val ssh_port),$(detect_ssh_port 2>/dev/null)")" +NODE_OK="$(csv_norm "$(mark_val node_port),$(detect_node_port 2>/dev/null)")" +LST_OUT="" +while IFS=$'\t' read -r _a _p _pr; do + [[ -n "$_p" ]] && addr_public "$_a" || continue + [[ ",$FW_TCP_OK,$SSH_OK,$NODE_OK," == *",$_p,"* || ",$DNAT_P," == *",$_p/tcp,"* ]] && continue + [[ " $LST_OUT " == *" tcp/$_p("* ]] || LST_OUT+="${LST_OUT:+ }tcp/$_p($_pr)" +done < <(listen_rows tcp) +while IFS=$'\t' read -r _a _p _pr; do + [[ -n "$_p" ]] && addr_public "$_a" || continue + # DHCP/NTP/mDNS-клиенты: ответы им проходят по conntrack, «сервисом» они не являются + case "$_p" in 67|68|123|546|547|5353) continue;; esac + [[ ",$FW_UDP_OK," == *",$_p,"* || ",$DNAT_P," == *",$_p/udp,"* ]] && continue + [[ " $LST_OUT " == *" udp/$_p("* ]] || LST_OUT+="${LST_OUT:+ }udp/$_p($_pr)" +done < <(listen_rows udp) +if [[ -n "$LST_OUT" ]]; then + case "$FW_MODE_NOW" in + strict) info "слушают публично, но strict их режет (доступны только whitelist/флоту): $LST_OUT — если сервис нужен всем, добавь порт в TCP_PORTS/UDP_PORTS" ;; + # open — по замыслу (динамические inbound'ы 3x-ui): открыты, но с per-IP лимитами + open) info "слушают вне перечисленных портов (FW_MODE=open — открыты по замыслу, с per-IP лимитами): $LST_OUT" ;; + *) wrn "слушают публично вне разрешённых портов и открыты миру (na_filter ${FW_MODE_NOW:-нет} — правил нет): $LST_OUT" ;; + esac +fi +NOL="" +for _p in ${FW_TCP_OK//,/ }; do + [[ ",$DNAT_P," == *",$_p/tcp,"* ]] && continue + awk -F'\t' -v p="$_p" '$2 == p && $1 !~ /^(127\.|::1$)/ { f = 1 } END { exit !f }' <<<"$(listen_rows tcp)" || NOL+="${NOL:+ }tcp/$_p" +done +for _p in ${FW_UDP_OK//,/ }; do + [[ ",$DNAT_P," == *",$_p/udp,"* ]] && continue + awk -F'\t' -v p="$_p" '$2 == p && $1 !~ /^(127\.|::1$)/ { f = 1 } END { exit !f }' <<<"$(listen_rows udp)" || NOL+="${NOL:+ }udp/$_p" +done +[[ -n "$NOL" && "$FW_MODE_NOW" != none ]] && info "разрешены в файрволе, но никто не слушает: $NOL (лишняя дыра — убери из TCP_PORTS/UDP_PORTS, если сервиса не будет)" +unset _a _p _pr 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" @@ -1018,10 +1682,35 @@ if command -v cscli >/dev/null 2>&1; then # Срезаем первую строку (`-o json` + jq не годится: jq на ноде не обязателен). # grep -vc печатает 0 И возвращает rc=1, когда совпадений нет → наивный `|| echo 0` # дописывал ВТОРОЙ ноль и строка выходила битой. Берём значение, потом валидируем. - DEC="$(cscli decisions list -o raw 2>/dev/null | tail -n +2 | grep -vc '^$')" - [[ "$DEC" =~ ^[0-9]+$ ]] || DEC=0 - info "CrowdSec decisions (активные баны): $DEC" - nft list table ip crowdsec >/dev/null 2>&1 && info "таблица bouncer'а ip crowdsec присутствует (priority -10, раньше na_filter)" + # С `-a`: без него cscli показывает только локальные решения, а bouncer режет и + # community-блоклист (CAPI, на флоте ~25 тыс. адресов — «1 решение» в отчёте). + if DEC="$(cs_decisions)"; then + read -r DEC_L DEC_C <<<"$DEC" + info "CrowdSec decisions (активные баны): ${DEC_L:-0} локальных (crowdsec/cscli), ${DEC_C:-0} из CAPI/списков" + else + info "CrowdSec decisions: не измерено (cscli не ответил)"; DEC_C=0 + fi + CS_SCOPE="$(mark_val crowdsec_scope)" + if nft list table ip crowdsec >/dev/null 2>&1; then + case "$CS_SCOPE" in + ssh) info "блок-листы CrowdSec действуют только на SSH-порт (crowdsec_scope=ssh)" ;; + all) info "блок-листы CrowdSec действуют на ВСЕ порты (crowdsec_scope=all — выбор оператора)" ;; + *) if [[ "${DEC_C:-0}" -gt 0 ]]; then + wrn "bouncer CrowdSec (priority -10, раньше na_filter) режет ${DEC_C} адресов CAPI/списков на ВСЕХ портах — в т.ч. мобильный CGNAT; protect v4.2 сужает до SSH (CROWDSEC_SCOPE=ssh)" + else + info "таблица bouncer'а ip crowdsec присутствует (priority -10, раньше na_filter; область — все порты)" + fi ;; + esac + fi + # откуда CrowdSec вообще берёт события: на флоте — только journal sshd + CS_ACQ="$(cs_acquis | paste -sd' ' -)" + if [[ -n "$CS_ACQ" ]]; then + if ! grep -qviE 'ssh' <<<"$(tr ' ' '\n' <<<"$CS_ACQ")"; then + info "CrowdSec читает: $CS_ACQ — только sshd: атаки на сервисные порты он не видит (их держит na_filter)" + else + info "CrowdSec читает: $CS_ACQ" + fi + fi else info "CrowdSec не установлен (ставится модулем 🛡 protect)" fi @@ -1042,13 +1731,42 @@ if [[ -n "$(ip -6 route show default 2>/dev/null)" ]]; then else info "IPv6 default-route: нет (v4-only нода)" fi -# UDP RcvbufErrors — переполнение приёмного буфера UDP (QUIC/Hysteria2/TUIC): пакеты -# отброшены до приложения. Кумулятивно с загрузки; растущее — сигнал поднять буферы/PPS. -UDPERR="$(awk '/^Udp:/{if(!h){for(i=2;i<=NF;i++)n[i]=$i;h=1;next} for(i=2;i<=NF;i++) if(n[i]=="RcvbufErrors") print $i}' /proc/net/snmp 2>/dev/null)" -if [[ -n "$UDPERR" && "$UDPERR" -gt 0 ]] 2>/dev/null; then - wrn "UDP RcvbufErrors = $UDPERR (с загрузки) — переполнение UDP-буфера для QUIC/Hysteria2; следи за ростом" +# UDP RcvbufErrors (v4 + v6) — датаграммы, отброшенные до приложения: приёмный буфер +# сокета полон. Счётчик копится с загрузки, и одна давняя вспышка горела ▲ вечно (аудит +# флота: на всех нодах при нулевом приросте). ▲ — только по росту за окно с прошлого +# запуска и по доле от принятых. Чьи это сокеты: при публичном UDP-слушателе — входящий +# QUIC/Hysteria2/TUIC; без него — исходящие UDP xray/DNS с буфером по умолчанию. +UDP_SVC=0 +while IFS=$'\t' read -r _a _p _pr; do + [[ -n "$_p" ]] && addr_public "$_a" || continue + case "$_p" in 67|68|123|546|547|5353) continue;; esac + UDP_SVC=1; break +done < <(listen_rows udp) +unset _a _p _pr +if [[ "$UDP_SVC" == 1 ]]; then UDP_CTX="входящий UDP (QUIC/Hysteria2/TUIC): поднять rmem_max/PPS" +else UDP_CTX="UDP-слушателей нет — это исходящие сокеты (xray/DNS) с буфером по умолчанию: net.core.rmem_default=$(val net.core.rmem_default)"; fi +if [[ "$DW" -ge 0 && "$D_UDPERR" -ge 0 ]]; then + if [[ "$D_UDPERR" -gt 0 ]] && { [[ "$D_UDPIN" -le 0 ]] || [[ $(( D_UDPERR * 1000 / D_UDPIN )) -ge 1 ]]; }; then + wrn "UDP RcvbufErrors +$D_UDPERR за $((DW/60)) мин ($(awk -v e="$D_UDPERR" -v d="$D_UDPIN" 'BEGIN{ printf "%.2f", (d > 0 ? e * 100 / d : 100) }')% принятых) — $UDP_CTX" + elif [[ "$D_UDPERR" -gt 0 ]]; then + info "UDP RcvbufErrors +$D_UDPERR за $((DW/60)) мин (<0.1% принятых — шум)" + else + pass "UDP RcvbufErrors не растут (за $((DW/60)) мин +0; с загрузки $C_UDPERR)" + fi +elif [[ "$C_UDPERR" -gt 0 && "$C_UDPIN" -gt 0 && $(( C_UDPERR * 100 / C_UDPIN )) -ge 1 ]]; then + wrn "UDP RcvbufErrors = $C_UDPERR с загрузки (≥1% принятых) — $UDP_CTX; рост покажет следующий запуск" else - info "UDP RcvbufErrors = ${UDPERR:-0} (приёмный буфер QUIC/UDP не переполняется)" + info "UDP RcvbufErrors = $C_UDPERR с загрузки (рост к следующему запуску покажет дельта — снимок сохранён)" +fi +# Переполнение accept-очереди: SYN/ACK отброшен, клиент ждёт ретрансмита (секунды) +if [[ "$DW" -ge 0 && "$D_LOVF" -ge 0 ]]; then + if [[ "$D_LOVF" -gt 0 ]]; then + wrn "accept-очередь переполнялась: ListenOverflows +$D_LOVF (ListenDrops +$D_LDROP) за $((DW/60)) мин (somaxconn=$(val net.core.somaxconn)) — приложение не успевает accept'ить" + else + pass "accept-очереди не переполнялись за $((DW/60)) мин" + fi +elif [[ "$C_LOVF" -gt 0 ]]; then + info "ListenOverflows = $C_LOVF (ListenDrops = $C_LDROP) с загрузки (рост покажет следующий запуск)" fi if command -v ping >/dev/null; then RTT="$(ping -c2 -W2 1.1.1.1 2>/dev/null | awk -F'/' '/rtt|round-trip/{print $5" ms"}')" @@ -1108,19 +1826,12 @@ if [[ -n "$NP_DET" && -f "$STATE_DIR/protect.installed" ]]; then elif [[ -n "$NP_DET" ]]; then info "node-agent порт(ы): $NP_DET (protect ещё не гонялся — сверять не с чем)" fi -# сертификаты: ближайший к истечению (Let's Encrypt / acme.sh / NA_CERT_PATHS) +# сертификаты: ближайший к истечению (LE / acme.sh / Caddy / nginx ssl_certificate / +# NA_CERT_PATHS). «Найден» — отдельный флаг: −1 дней раньше значил «не найдено», и +# просроченный серт терялся за следующим живым. if command -v openssl >/dev/null 2>&1; then - CERT_MIN=-1; CERT_MIN_F="" - # shellcheck disable=SC2086 # глобы и NA_CERT_PATHS обязаны раскрыться - for cf in $NA_CERT_GLOBS ${NA_CERT_PATHS:-}; do - [[ -f "$cf" ]] || continue - cert_retired "$cf" && continue - cend="$(openssl x509 -enddate -noout -in "$cf" 2>/dev/null | cut -d= -f2)"; [[ -n "$cend" ]] || continue - cends="$(date -d "$cend" +%s 2>/dev/null)" || continue; [[ "$cends" =~ ^[0-9]+$ ]] || continue - cdays=$(( (cends - $(date +%s)) / 86400 )) - { [[ "$CERT_MIN" -lt 0 ]] || [[ "$cdays" -lt "$CERT_MIN" ]]; } && { CERT_MIN="$cdays"; CERT_MIN_F="$cf"; } - done - if [[ "$CERT_MIN" -lt 0 ]]; then + cert_scan || true + if [[ "$CERT_FOUND" != 1 ]]; then # «сертов нет» и «сенсор слеп» были неотличимы и оба тихие: на selfsteal-ноде с # сертом в /opt/<стек>/certs это молчание означало «о протухании не предупредим # вообще» — ровно то, ради чего сенсор и нужен (issue #39). @@ -1129,6 +1840,8 @@ if command -v openssl >/dev/null 2>&1; then else info "TLS-сертификатов в стандартных путях не найдено (задай NA_CERT_PATHS, если selfsteal-серт лежит иначе)" fi + elif [[ "$CERT_MIN" -lt 0 ]]; then bad "TLS-серт ИСТЁК $(( -CERT_MIN )) дн назад ($CERT_MIN_F) — клиенты получают ошибку TLS; renewal сломан" + elif [[ "$CERT_MIN" -eq 0 ]]; then bad "TLS-серт истекает в ближайшие сутки ($CERT_MIN_F) — renewal сломан?" elif [[ "$CERT_MIN" -lt 7 ]]; then bad "TLS-серт истекает через ${CERT_MIN} дн ($CERT_MIN_F) — renewal сломан?" elif [[ "$CERT_MIN" -lt 14 ]]; then wrn "TLS-серт истекает через ${CERT_MIN} дн ($CERT_MIN_F) — проверь авто-renew" else pass "ближайший TLS-серт: ${CERT_MIN} дн до истечения ($CERT_MIN_F)"; fi @@ -1138,11 +1851,15 @@ fi # ─── Здоровье: давление, диск, инциденты ───────────────────────────────────── title "Здоровье: давление, диск, инциденты" -# PSI — стол ядра под нагрузкой (avg10 по 'some'); современные ядра +# PSI — стол ядра под нагрузкой ('some'). ▲ по avg60, а не avg10: 10-секундное окно +# ловит единичный всплеск (сборка initrd, logrotate), минутное — устойчивое давление. if [[ -r /proc/pressure/cpu ]]; then for r in cpu memory io; do - A="$(awk '/^some/{for(i=1;i<=NF;i++) if($i ~ /^avg10=/){sub(/avg10=/,"",$i); print $i}}' "/proc/pressure/$r" 2>/dev/null)"; A="${A:-0}" - if awk -v x="$A" 'BEGIN{exit !(x+0>=10)}'; then wrn "PSI $r some avg10=${A}% — заметный стол ядра"; else info "PSI $r some avg10=${A}%"; fi + read -r A10 A60 <<<"$(awk '/^some/{ for (i = 1; i <= NF; i++) { if ($i ~ /^avg10=/) { a = $i; sub(/avg10=/, "", a) } if ($i ~ /^avg60=/) { b = $i; sub(/avg60=/, "", b) } } } + END { printf "%s %s", (a == "" ? 0 : a), (b == "" ? 0 : b) }' "/proc/pressure/$r" 2>/dev/null)" + A10="${A10:-0}"; A60="${A60:-0}" + if awk -v x="$A60" 'BEGIN{exit !(x+0>=10)}'; then wrn "PSI $r some avg60=${A60}% (avg10=${A10}%) — устойчивый стол ядра" + else info "PSI $r some avg60=${A60}% avg10=${A10}%"; fi done else # Три состояния вместо двух: «ядро без CONFIG_PSI» ≠ «PSI собран, но выключен по @@ -1178,6 +1895,10 @@ fi # только в /etc/docker/daemon.json (log-opts max-size) и лишь для НОВЫХ контейнеров. DKBIG="$(find /var/lib/docker/containers -xdev -type f -name '*-json.log' -size +200M -printf '%s %p\n' 2>/dev/null | sort -rn | head -1)" [[ -n "$DKBIG" ]] && wrn "json-лог контейнера: $(awk '{printf "%.1f ГБ", $1/1073741824}' <<<"$DKBIG") — задай log-opts max-size в /etc/docker/daemon.json" +# Кап, заданный в daemon.json, получают только контейнеры, созданные ПОСЛЕ него: старый +# контейнер с json-file без max-size пишет лог без предела (на флоте — сам node-агент) +DK_NOCAP="$(docker_nocap | paste -sd' ' -)" +[[ -n "$DK_NOCAP" ]] && wrn "контейнеры с json-логом без max-size (растёт без предела): $DK_NOCAP — logging.options.max-size в compose (или daemon.json) и пересоздать контейнер" if ! command -v logrotate >/dev/null 2>&1; then wrn "logrotate не установлен — стансы в /etc/logrotate.d не выполняются вообще" elif systemctl is-active --quiet na-logrotate.timer 2>/dev/null; then @@ -1188,10 +1909,14 @@ elif systemctl is-active --quiet na-logrotate.timer 2>/dev/null; then if [[ -s "$STATE_DIR/logrotate.ceded" ]]; then LR_CEDED="$(awk -F'\t' '{printf "%s%s → %s%s", (NR>1?", ":""), $1, $2, ($3=="none"?" (БЕЗ maxsize/size!)":($3=="unknown"?" (кап неизвестен)":""))}' "$STATE_DIR/logrotate.ceded" 2>/dev/null)" LR_NOCAP="$(awk -F'\t' '$3=="none"' "$STATE_DIR/logrotate.ceded" 2>/dev/null | grep -c .)" + LR_UNK="$(awk -F'\t' '$3=="unknown"' "$STATE_DIR/logrotate.ceded" 2>/dev/null | grep -c .)" if [[ "${LR_NOCAP:-0}" -gt 0 ]]; then wrn "ротация: таймер активен, но маски отданы чужим стансам, и у $LR_NOCAP из них НЕТ капа по размеру: $LR_CEDED — добавь maxsize в чужую стансу или сузь NA_LOG_PATHS (ре-ран optimize)" + elif [[ "${LR_UNK:-0}" -gt 0 ]]; then + wrn "ротация: таймер активен, часть масок у чужих стансы, и кап у $LR_UNK из них не проверить: $LR_CEDED" else - wrn "ротация: таймер активен, но часть масок ротируют чужие стансы (у них кап есть): $LR_CEDED" + # кап по размеру у владельцев есть — ротация работает, тревожить нечем + pass "ротация: часовой таймер активен; часть масок держат чужие стансы с капом: $LR_CEDED" fi [[ -s "$STATE_DIR/logrotate.owned" ]] && info "наша станса держит: $(paste -sd' ' "$STATE_DIR/logrotate.owned" 2>/dev/null)" elif [[ "${LR_EN:-1}" == "1" && ! -s /etc/logrotate.d/na-node-logs ]]; then @@ -1213,24 +1938,47 @@ fi # сутки) вытесняет всё остальное и живёт меньше суток: разбор вчерашнего инцидента уже # невозможен, а на Debian 13 minimal journald — единственный источник истории входов # (issue #35). Меряем возраст самой старой записи, а не размер. +# Лог анти-скана за СУТКИ по всем загрузкам и его доля в журнале. Порог «>100000 строк за +# загрузку» рос с аптаймом (ложные ▲ на долгоживущих нодах) и молчал после ребута; доля +# за сутки — это ровно то, что вытесняет историю из журнала. +PS_N=""; PS_T=""; PS_SH=-1 +if PS24="$(portscan_24h)"; then + read -r PS_N PS_T <<<"$PS24" + [[ "$PS_T" =~ ^[0-9]+$ ]] && (( PS_T > 0 )) && PS_SH=$(( PS_N * 100 / PS_T )) +fi JSPAN="$(journal_span_h)" || JSPAN="" -if [[ "$JSPAN" =~ ^[0-9]+$ ]]; then - if [[ "$JSPAN" -lt 48 ]]; then - wrn "журнал вмещает менее 48ч (самая старая запись ${JSPAN}ч назад) — форензика вчерашнего инцидента уже недоступна: снизь PORTSCAN_LOG_RATE (ре-ран protect) или подними SystemMaxUse" +UP_H="$(awk '{printf "%d", $1/3600}' /proc/uptime 2>/dev/null)"; [[ "$UP_H" =~ ^[0-9]+$ ]] || UP_H=-1 +if journal_volatile; then + # журнал в RAM: «мелкий» он из-за ребута, а не из-за вытеснения — PORTSCAN тут ни при чём + wrn "журнал volatile (только /run/log/journal, нет /var/log/journal): история не переживает ребут${JSPAN:+, сейчас ${JSPAN}ч} — ре-ран optimize (v4.2 включает Storage=persistent) или mkdir -p /var/log/journal" +elif [[ "$JSPAN" =~ ^[0-9]+$ ]]; then + if [[ "$JSPAN" -lt 48 && "$UP_H" -ge 0 && $(( UP_H - JSPAN )) -le 1 ]]; then + # самая старая запись ≈ момент загрузки: журнал держит всю текущую загрузку, а + # прошлых в нём нет (почищен/новый каталог) — это не вытеснение + info "глубина журнала ${JSPAN}ч ≈ аптайму: журнал держит всю текущую загрузку, прошлых загрузок в нём нет" + elif [[ "$JSPAN" -lt 48 ]]; then + PS_HINT="подними SystemMaxUse (NA_JOURNAL_MAX_USE, ре-ран optimize)" + [[ "$PS_SH" -ge 20 ]] && PS_HINT="главный источник — лог анти-скана (${PS_SH}% строк за сутки): снизь PORTSCAN_LOG_RATE (ре-ран protect) или подними SystemMaxUse" + wrn "журнал вмещает менее 48ч (самая старая запись ${JSPAN}ч назад) — форензика вчерашнего инцидента уже недоступна: $PS_HINT" else info "глубина журнала: ${JSPAN}ч (~$((JSPAN/24)) сут)" fi fi -PSLINES="$(portscan_log_lines)" || PSLINES="" -if [[ "$PSLINES" =~ ^[0-9]+$ ]]; then - if [[ "$PSLINES" -gt 100000 ]]; then - wrn "строк [na portscan] за текущую загрузку: $PSLINES — лог анти-скана вытесняет журнал (бан работает по счётчикам сета, а не по строкам лога: снизь PORTSCAN_LOG_RATE)" +if [[ "$PS_N" =~ ^[0-9]+$ ]]; then + PS_TXT="строк [na portscan] за 24ч: $PS_N" + [[ "$PS_SH" -ge 0 ]] && PS_TXT+=" (${PS_SH}% журнала за сутки)" + if [[ "$PS_SH" -ge 50 ]] || { [[ "$PS_SH" -ge 20 && "$JSPAN" =~ ^[0-9]+$ && "$JSPAN" -lt 48 ]] && ! journal_volatile; }; then + wrn "$PS_TXT — лог анти-скана вытесняет журнал (бан работает по наборам, а не по строкам лога): снизь PORTSCAN_LOG_RATE (ре-ран protect; v4.2 пишет только переходы в suspect/autoban)" else - info "строк [na portscan] за текущую загрузку: $PSLINES" + info "$PS_TXT" fi +else + info "строк [na portscan] за 24ч: не измерено (journalctl без --grep или не уложился в таймаут)" fi -# Инциденты ядра/сервисов -OOM="$(journalctl -k --since '-24h' --no-pager 2>/dev/null | grep -ciE 'out of memory|oom-killer|soft lockup|hung task')" +# Инциденты ядра/сервисов — за сутки по ВСЕМ загрузкам: `-k` подразумевает `-b` (только +# текущая загрузка), и OOM, из-за которого нода перезагрузилась, был не виден ровно тогда, +# когда важнее всего. _TRANSPORT=kernel — тот же фильтр без неявного `-b`. +OOM="$(journalctl -q --no-pager --since '-24h' _TRANSPORT=kernel 2>/dev/null | grep -ciE 'out of memory|oom-killer|soft lockup|hung task')" if [[ "${OOM:-0}" -gt 0 ]]; then wrn "kern-лог за 24ч: $OOM строк OOM/lockup/hung — память/перегруз"; else pass "kern-лог чист (OOM/lockup/hung за 24ч нет)"; fi FAILED="$(systemctl --failed --no-legend 2>/dev/null | grep -c .)" if [[ "${FAILED:-0}" -gt 0 ]]; then wrn "упавших systemd-юнитов: $FAILED (см. systemctl --failed)"; else pass "упавших systemd-юнитов нет"; fi diff --git a/tests/diagnose-unit.sh b/tests/diagnose-unit.sh index 6ade048..b107623 100755 --- a/tests/diagnose-unit.sh +++ b/tests/diagnose-unit.sh @@ -21,6 +21,16 @@ # #38 whitelist сверяется с ТРЕМЯ источниками: дрейф относительно protect.conf назван # по адресу и опознан как IP текущей сессии; # #39 серт в /opt/<стек>/certs/*/ находится, серт acme.sh, снятый с renew, — нет. +# v4.2 (аудит флота): дельты счётчиков между запусками вместо накопленного с загрузки +# (UDP RcvbufErrors v4+v6, softnet, ListenOverflows); steal окном 3 с + с загрузки; +# PSI по avg60; ложные ▲ (ENABLE_XANMOD=0, logrotate с капом, volatile-журнал, +# журнал ≈ аптайму, лог анти-скана — доля за сутки); порты из живых правил/conf; +# CrowdSec по источникам решений + scope + acquisition; 4-й слой whitelist (allowlist +# CrowdSec); DNAT мимо na_filter; слушатели вне файрвола; память/balloon; +# nftables.service с flush; хэш na_filter.nft; c_*-счётчики; буферы от tier; +# порт xray в эфемерном диапазоне; обновление XanMod и reboot-required; просроченный +# серт и серты Caddy/nginx; docker без max-size; OOM за сутки по всем загрузкам; +# RPS при RX-очередей ≥ ядер. # # Не требует root/сети/nft/docker. Запуск: bash tests/diagnose-unit.sh set -euo pipefail @@ -52,6 +62,7 @@ mkdir -p "$BIN" "$NFTD" "$SSD" \ "$T/root/.acme.sh/retired.example.test" KREL="6.18.47-x64v3-xanmod1" +NA_TEST_PROC="$T/proc"; export NA_TEST_PROC JSTART=$(( $(date +%s) - 10*3600 )) # журнал вмещает 10ч → «менее 48ч» export JSTART @@ -83,6 +94,12 @@ sandbox_paths() { -e "s#/etc/letsencrypt#$T/etc/letsencrypt#g" \ -e "s#/root/.acme.sh#$T/root/.acme.sh#g" \ -e "s#/opt/\*/certs#$T/opt/*/certs#g" \ + -e "s#/run/log/journal#$T/run/log/journal#g" \ + -e "s#/run/reboot-required#$T/run/reboot-required#g" \ + -e "s#/etc/crowdsec#$T/etc/crowdsec#g" \ + -e "s#/etc/nginx#$T/etc/nginx#g" \ + -e "s#/etc/nftables.conf#$T/etc/nftables.conf#g" \ + -e "s#/var/lib/caddy#$T/var/lib/caddy#g" \ "$1" rm -f "$1.bak" } @@ -208,6 +225,9 @@ cat > "$BIN/docker" <<'DOC' # и только потом падает — ровно это и ломало сенсор (#27). case "$1" in inspect) + case "$*" in + *LogConfig*) echo "/remnanode json-file "; exit 0 ;; + esac name="" for a in "$@"; do name="$a"; done if [ -n "${NA_TEST_CONTAINER:-}" ] && [ "$name" = "$NA_TEST_CONTAINER" ]; then @@ -221,7 +241,13 @@ case "$1" in printf '\n' exit 1 ;; logs) exit 0 ;; - ps) exit 0 ;; + ps) + # опубликованные порты (v4.2: сенсор DNAT мимо na_filter) и id для LogConfig + case "$*" in + *"{{.Ports}}"*) [ -f "$NFTD/docker-ports" ] && cat "$NFTD/docker-ports" ;; + "ps -q") [ "${NA_TEST_DK_NOCAP:-0}" = 1 ] && echo abc123 ;; + esac + exit 0 ;; esac exit 1 DOC @@ -232,7 +258,9 @@ case "$*" in "-t list table inet na_filter") [ -f "$NFTD/terse" ] && cat "$NFTD/terse"; exit 0 ;; "list table inet na_filter") exit 0 ;; "list table inet na_ctguard") exit 1 ;; - "list table ip crowdsec") exit 1 ;; + "list table ip crowdsec") [ "${NA_TEST_CS_TABLE:-0}" = 1 ] && exit 0; exit 1 ;; + "list counters table inet na_filter") [ -f "$NFTD/counters" ] && cat "$NFTD/counters"; exit 0 ;; + "-t list ruleset") [ -f "$NFTD/ruleset" ] && cat "$NFTD/ruleset"; exit 0 ;; "list chain inet na_filter input") cat "$NFTD/chain-input"; exit 0 ;; "list meter inet "*) all="$*"; s=${all##* } @@ -254,7 +282,8 @@ case "$*" in *"sport = :"*) cat "$SSD/estab-ports" ;; "-tulnH") cat "$SSD/listen-tu" ;; "-tlnH") cat "$SSD/listen-t" ;; - "-Htlnp") : ;; + "-Htlnp") [ -f "$SSD/listen-tp" ] && cat "$SSD/listen-tp" ;; + "-Hulnp") [ -f "$SSD/listen-up" ] && cat "$SSD/listen-up" ;; *) cat "$SSD/estab-mss" ;; esac exit 0 @@ -263,14 +292,25 @@ SSS cat > "$BIN/cscli" <<'CS' #!/bin/sh # `decisions list -o raw` печатает CSV С ЗАГОЛОВКОМ — он и завышал счёт на 1 (#32) +# `-a` — вместе с CAPI/списками: NA_TEST_CAPI решений origin=CAPI и NA_TEST_LISTS — lists case "$*" in - "decisions list -o raw") - echo "id,source,ip,reason,action,country,as,duration,scenario" + "decisions list -o raw"|"decisions list -a -o raw") + echo "id,source,ip,reason,action,country,as,events_count,expiration,simulated,alert_id" i=0 while [ "$i" -lt "${NA_TEST_DECISIONS:-0}" ]; do i=$((i+1)) - echo "$i,crowdsec,203.0.113.$i,ban,ban,--,--,3h,ssh-bf" - done ;; + echo "$i,crowdsec,Ip:203.0.113.$i,crowdsecurity/ssh-bf,ban,,\"64500 EXAMPLE, Inc\",3,3h,false,$i" + done + if [ "$*" = "decisions list -a -o raw" ]; then + j=0 + while [ "$j" -lt "${NA_TEST_CAPI:-0}" ]; do + j=$((j+1)); echo "$((1000+j)),CAPI,Ip:198.18.0.$j,crowdsecurity/http-probing,ban,,,0,160h,false,0" + done + j=0 + while [ "$j" -lt "${NA_TEST_LISTS:-0}" ]; do + j=$((j+1)); echo "$((2000+j)),lists,Ip:198.18.1.$j,firehol_level1,ban,,,0,24h,false,0" + done + fi ;; esac exit 0 CS @@ -279,6 +319,7 @@ cat > "$BIN/journalctl" <<'JC' #!/bin/sh case "$*" in *--help*) + [ "${NA_TEST_NO_GREP:-0}" = 1 ] && { echo " -k --dmesg Show kernel message log from the current boot"; exit 0; } echo " -g --grep=PATTERN Show entries with MESSAGE matching PATTERN" # NA_TEST_BIG_HELP: справка больше буфера пайпа — `| grep -q` закрыл бы пайп на # первой строке, и следующая запись словила бы SIGPIPE (141 под pipefail) @@ -288,6 +329,13 @@ case "$*" in exit 0 ;; *short-unix*) printf '%s.000000 node kernel: Linux version\n' "$JSTART"; exit 0 ;; *"na portscan"*) printf 'a\nb\nc\n'; exit 0 ;; + # все строки журнала за сутки (знаменатель доли лога анти-скана) + *"-o cat"*) + i=0; while [ "$i" -lt "${NA_TEST_JTOTAL:-0}" ]; do echo "line $i"; i=$((i+1)); done; exit 0 ;; + # журнал ядра за сутки по всем загрузкам (OOM прошлой загрузки) + *_TRANSPORT=kernel*) + [ "${NA_TEST_OOM:-0}" = 1 ] && echo "Oct 02 03:00:00 node kernel: Out of memory: Killed process 1234 (xray)" + exit 0 ;; esac exit 0 JC @@ -302,6 +350,7 @@ case "$*" in "is-enabled --quiet na-firewall.service") exit 0 ;; "show -p DefaultLimitNOFILE --value") echo 524288; exit 0 ;; "--failed --no-legend") exit 0 ;; + "is-enabled --quiet nftables.service") [ "${NA_TEST_NFTS:-0}" = 1 ] && exit 0; exit 1 ;; esac exit 1 SC @@ -321,7 +370,8 @@ LR cat > "$BIN/uname" <<'UN' #!/bin/sh -[ "$1" = "-r" ] && { echo "6.18.47-x64v3-xanmod1"; exit 0; } +[ "$1" = "-r" ] && { echo "${NA_TEST_KREL:-6.18.47-x64v3-xanmod1}"; exit 0; } +[ "$1" = "-m" ] && [ -n "${NA_TEST_ARCH:-}" ] && { echo "$NA_TEST_ARCH"; exit 0; } exec /usr/bin/uname "$@" UN @@ -336,7 +386,12 @@ case "$2" in net.ipv4.tcp_min_snd_mss) echo 512 ;; net.ipv4.tcp_mtu_probing) echo 0 ;; net.core.somaxconn) echo 65535 ;; - net.core.rmem_max|net.core.wmem_max) echo 33554432 ;; + net.core.rmem_max|net.core.wmem_max) echo "${NA_TEST_RMEM_MAX:-33554432}" ;; + net.core.rmem_default|net.core.wmem_default) echo "${NA_TEST_RMEM_DEF:-1048576}" ;; + net.ipv4.ip_local_port_range) printf '10000\t65535\n' ;; + net.ipv4.ip_local_reserved_ports) echo "${NA_TEST_RESV:-}" ;; + net.core.netdev_max_backlog) echo 250000 ;; + net.core.netdev_budget) echo 600 ;; net.ipv4.tcp_max_syn_backlog) echo 16384 ;; fs.file-max|fs.nr_open) echo 2000000 ;; net.ipv4.tcp_syncookies) echo 1 ;; @@ -363,6 +418,9 @@ cat > "$BIN/openssl" <<'SSL' for a in "$@"; do last="$a"; done case "$last" in *retired*) echo "notAfter=RETIRED" ;; + *expired*) echo "notAfter=EXPIRED" ;; + *nginx*) echo "notAfter=NGINX" ;; + *caddy*) echo "notAfter=CADDY" ;; *) echo "notAfter=GOOD" ;; esac exit 0 @@ -375,6 +433,9 @@ if [ "$1" = "-d" ]; then case "$2" in *GOOD*) echo $(( now + 60*86400 )) ;; *RETIRED*) echo $(( now + 3*86400 )) ;; + *EXPIRED*) echo $(( now - 10*86400 + 3600 )) ;; + *NGINX*) echo $(( now + 20*86400 + 3600 )) ;; + *CADDY*) echo $(( now + 15*86400 + 3600 )) ;; 2026-09-01T00:00:00+00:00) echo 1788220800 ;; # installed_at старого маркера optimize *) exit 1 ;; esac @@ -383,8 +444,21 @@ fi exec /bin/date "$@" DT +# sleep: steal меряется окном 3 с — в тесте ждать нечего. NA_TEST_STAT_AFTER — подменить +# /proc/stat «после окна» (steal за окно считается из разницы двух чтений). +cat > "$BIN/sleep" <<'SLP' +#!/bin/sh +[ -n "${NA_TEST_STAT_AFTER:-}" ] && cp "$NA_TEST_STAT_AFTER" "$NA_TEST_PROC/stat" +exit 0 +SLP printf '#!/bin/sh\necho 4\n' > "$BIN/nproc" -printf '#!/bin/sh\necho aarch64\n' > "$BIN/arch" +printf '#!/bin/sh\necho "${NA_TEST_ARCH:-aarch64}"\n' > "$BIN/arch" +# apt-cache policy для сенсора обновления XanMod (NA_TEST_APTPOL — файл с выводом) +cat > "$BIN/apt-cache" <<'AC' +#!/bin/sh +[ "$1" = policy ] && [ -n "${NA_TEST_APTPOL:-}" ] && { cat "$NA_TEST_APTPOL"; exit 0; } +exit 1 +AC printf '#!/bin/sh\necho kvm\n' > "$BIN/systemd-detect-virt" printf '#!/bin/sh\nexit 1\n' > "$BIN/curl" printf '#!/bin/sh\nexit 1\n' > "$BIN/ping" @@ -405,8 +479,11 @@ check() { # check "описание" <ожидание> <факт> if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1" else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi } -grep_ok() { local d="$1" p="$2" f="$3"; if grep -qF -- "$p" "$f"; then PASS=$((PASS+1)); echo " ok $d"; else FAIL=$((FAIL+1)); echo " FAIL $d: нет строки [$p]"; fi; } -grep_not() { local d="$1" p="$2" f="$3"; if grep -qF -- "$p" "$f"; then FAIL=$((FAIL+1)); echo " FAIL $d: строка [$p] есть, а быть не должно"; else PASS=$((PASS+1)); echo " ok $d"; fi; } +# Сверяем текст БЕЗ цветовых escape-кодов: status_line красит значок, и «▲ текст» в +# файле разорван кодом сброса цвета — иначе проверка значка молча не работала бы. +plain() { sed $'s/\033\\[[0-9;]*m//g' "$1"; } +grep_ok() { local d="$1" p="$2" c; c="$(plain "$3")"; if grep -qF -- "$p" <<<"$c"; then PASS=$((PASS+1)); echo " ok $d"; else FAIL=$((FAIL+1)); echo " FAIL $d: нет строки [$p]"; fi; } +grep_not() { local d="$1" p="$2" c; c="$(plain "$3")"; if grep -qF -- "$p" <<<"$c"; then FAIL=$((FAIL+1)); echo " FAIL $d: строка [$p] есть, а быть не должно"; else PASS=$((PASS+1)); echo " ok $d"; fi; } # 198.51.100.7 — «IP текущей сессии»: он есть в живом сете и в .nft, но не в WHITELIST= SSHENV='SSH_CONNECTION=198.51.100.7 51234 10.0.0.5 22' @@ -472,7 +549,7 @@ grep_ok "na-rps: гонка на буте — ▲, а не ✔ (#30)" "n grep_not "✔ «na-rps.service активен» при пустом rps_cpus (#30)" "✔ na-rps.service активен" "$T/out.txt" grep_ok "conf пиннит устаревший дефолт (#34)" "пиннит устаревший дефолт: CROWDSEC_STRICT=0" "$T/out.txt" grep_ok "глубина журнала меньше 48ч (#35)" "журнал вмещает менее 48ч" "$T/out.txt" -grep_ok "строки лога анти-скана посчитаны (#35)" "строк [na portscan] за текущую загрузку: 3" "$T/out.txt" +grep_ok "строки лога анти-скана посчитаны за сутки (#35, v4.2)" "строк [na portscan] за 24ч: 3" "$T/out.txt" grep_ok "серт из /opt/<стек>/certs найден (#39)" "ближайший TLS-серт: 60 дн" "$T/out.txt" grep_not "снятый с renew серт не тревожит (#39)" "retired.example.test" "$T/out.txt" @@ -492,7 +569,7 @@ mv "$T/opt.off" "$T/opt"; mv "$T/root.off" "$T/root" echo "== 6. CrowdSec: 3 решения = 3, а не 4 (#32) ==" env TERM=dumb NA_TEST_DECISIONS=3 "$WBASH" "$DIAG" > "$T/out6.txt" 2>/dev/null || true -grep_ok "три решения считаются как 3" "CrowdSec decisions (активные баны): 3" "$T/out6.txt" +grep_ok "три решения считаются как 3 (заголовок CSV и запятая в имени AS не в счёт)" "CrowdSec decisions (активные баны): 3 локальных" "$T/out6.txt" echo "== 7. ротация: «таймер активен» ≠ «станса есть» (#40) ==" LRSTATE="$T/var/lib/node-accelerator"; mkdir -p "$T/etc/logrotate.d" @@ -639,6 +716,383 @@ env TERM=dumb "$WBASH" "$DIAG" --json > "$T/out10d.json" 2>/dev/null || true check "маркер без дат → как раньше, true" true "$(jget "$T/out10d.json" reboot_needed)" rm -f "$OPTM" +# ════════════════════════════════════════════════════════════════════════════ +# v4.2 — сенсоры по аудиту флота +# ════════════════════════════════════════════════════════════════════════════ +STATE="$T/var/lib/node-accelerator" +PROT="$STATE/protect.installed" +cp "$PROT" "$T/protect.installed.orig" +cp "$T/proc/net/snmp" "$T/snmp.orig" +NOW_S="$(/bin/date +%s)" + +echo "== 11. дельты счётчиков между запусками вместо накопленного с загрузки (v4.2) ==" +# С загрузки ошибок много (накопились давно), за окно — ни одной: ▲ быть не должно. +cat > "$T/proc/net/snmp" <<'SNMP' +Tcp: RtoAlgorithm RtoMin RtoMax MaxConn ActiveOpens PassiveOpens AttemptFails EstabResets CurrEstab InSegs OutSegs RetransSegs InErrs OutRsts +Tcp: 1 200 120000 -1 100 200 0 0 10 5000 10000 100 0 0 +Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors +Udp: 100000 0 0 900 5000 0 +SNMP +printf 'Udp6InDatagrams 2000\nUdp6RcvbufErrors 100\n' > "$T/proc/net/snmp6" +printf '00000100 00000002 00000003 0 0 0 0 0 0 0 0\n00000200 00000002 0000000a 0 0 0 0 0 0 0 0\n' > "$T/proc/net/softnet_stat" +printf 'TcpExt: SyncookiesSent ListenOverflows ListenDrops\nTcpExt: 0 7 9\n' > "$T/proc/net/netstat" +mk_snap() { # mk_snap <возраст, с> + printf 'boot_id=%s\nepoch=%s\nudp_err=%s\nudp_in=%s\nsn_drop=%s\nsn_squeeze=%s\nlisten_ovf=%s\nlisten_drops=0\nsteal=0\ncpu_total=1050\n' \ + "$1" "$(( NOW_S - $2 ))" "$3" "$4" "$5" "$6" "$7" > "$STATE/diag-counters.last" +} +mk_snap boot-current 600 5100 102000 4 13 7 +env TERM=dumb "$WBASH" "$DIAG" > "$T/o11.txt" 2>/dev/null || true +grep_ok "текст: рост за окно 0 → ✔ «не растут»" "UDP RcvbufErrors не растут" "$T/o11.txt" +grep_not "текст: накопленные с загрузки 5100 не дают ▲" "UDP RcvbufErrors = 5100" "$T/o11.txt" +# окно ≥30 с → снимок перезаписан текущими значениями +check "снимок перезаписан (udp_err = v4+v6 = 5100)" "udp_err=5100" "$(grep '^udp_err=' "$STATE/diag-counters.last")" +# рост за окно: +60 ошибок на 1500 датаграмм (4%) — ▲, без UDP-слушателя — без «QUIC» +mk_snap boot-current 120 5040 100600 0 0 0 +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o11.json" 2>/dev/null || true +check "json: udp_rcvbuf_errors_delta=60 (v4 + Udp6)" 60 "$(jget "$T/o11.json" udp_rcvbuf_errors_delta)" +check "json: softnet_drops_delta=4" 4 "$(jget "$T/o11.json" softnet_drops_delta)" +check "json: time_squeeze_delta=13" 13 "$(jget "$T/o11.json" time_squeeze_delta)" +check "json: listen_overflows_delta=7" 7 "$(jget "$T/o11.json" listen_overflows_delta)" +W11="$(jget "$T/o11.json" delta_window_s)" +if [[ "$W11" =~ ^[0-9]+$ && "$W11" -ge 119 && "$W11" -le 140 ]]; then PASS=$((PASS+1)); echo " ok json: delta_window_s≈120 ($W11)" +else FAIL=$((FAIL+1)); echo " FAIL json: delta_window_s ожидалось ≈120, получено [$W11]"; fi +check "json: старое поле udp_rcvbuf_errors не тронуто (v4, с загрузки)" 5000 "$(jget "$T/o11.json" udp_rcvbuf_errors)" +mk_snap boot-current 120 5040 100600 0 0 0 +env TERM=dumb "$WBASH" "$DIAG" > "$T/o11b.txt" 2>/dev/null || true +grep_ok "текст: ▲ рост RcvbufErrors за окно" "UDP RcvbufErrors +60 за 2 мин" "$T/o11b.txt" +grep_ok "текст: без UDP-слушателя — исходящие сокеты" "UDP-слушателей нет — это исходящие сокеты" "$T/o11b.txt" +grep_not "текст: про QUIC/Hysteria2 без UDP-слушателя ни слова" "QUIC" "$T/o11b.txt" +grep_ok "текст: softnet-дропы за окно — ▲" "softnet: +4 пакетов отброшено" "$T/o11b.txt" +grep_ok "текст: ListenOverflows за окно — ▲" "ListenOverflows +7" "$T/o11b.txt" +# окно <30 с: дельты нет, а старый снимок НЕ затирается (копим окно — частый опрос) +mk_snap boot-current 10 5040 100600 0 0 0 +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o11c.json" 2>/dev/null || true +check "json: окно <30 с → delta_window_s=-1" -1 "$(jget "$T/o11c.json" delta_window_s)" +check "окно <30 с → старый снимок сохранён" "udp_err=5040" "$(grep '^udp_err=' "$STATE/diag-counters.last")" +# другая загрузка: дельт нет, снимок переписан +mk_snap boot-other 600 1 1 0 0 0 +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o11d.json" 2>/dev/null || true +check "json: другой boot_id → udp_rcvbuf_errors_delta=-1" -1 "$(jget "$T/o11d.json" udp_rcvbuf_errors_delta)" +check "другой boot_id → снимок текущей загрузки" "boot_id=boot-current" "$(grep '^boot_id=' "$STATE/diag-counters.last")" +# публичный UDP-слушатель → контекст QUIC/Hysteria2 +printf 'UNCONN 0 0 0.0.0.0:443 0.0.0.0:* users:(("xray",pid=10,fd=9))\n' > "$SSD/listen-up" +mk_snap boot-current 120 5040 100600 0 0 0 +env TERM=dumb "$WBASH" "$DIAG" > "$T/o11e.txt" 2>/dev/null || true +grep_ok "текст: есть UDP-слушатель → входящий QUIC/Hysteria2" "входящий UDP (QUIC/Hysteria2/TUIC)" "$T/o11e.txt" +rm -f "$SSD/listen-up" "$STATE/diag-counters.last" "$T/proc/net/snmp6" "$T/proc/net/softnet_stat" "$T/proc/net/netstat" +cp "$T/snmp.orig" "$T/proc/net/snmp" + +echo "== 12. CPU steal: окно 3 с + среднее с загрузки; PSI — по avg60 (v4.2) ==" +printf 'cpu 4000 0 500 5000 0 0 0 500 0 0\nbtime 1788307200\n' > "$T/proc/stat" +printf 'cpu 4200 0 520 5030 0 0 0 530 0 0\nbtime 1788307200\n' > "$T/stat.after" +env TERM=dumb NA_TEST_STAT_AFTER="$T/stat.after" "$WBASH" "$DIAG" --json > "$T/o12.json" 2>/dev/null || true +check "json: cpu_steal_pct=10 (за окно)" 10 "$(jget "$T/o12.json" cpu_steal_pct)" +check "json: cpu_steal_boot_pct=5 (с загрузки)" 5 "$(jget "$T/o12.json" cpu_steal_boot_pct)" +rm -f "$STATE/diag-counters.last" +printf 'cpu 4000 0 500 5000 0 0 0 500 0 0\nbtime 1788307200\n' > "$T/proc/stat" +env TERM=dumb NA_TEST_STAT_AFTER="$T/stat.after" "$WBASH" "$DIAG" > "$T/o12.txt" 2>/dev/null || true +grep_ok "текст: окно и среднее с загрузки в одной строке" "CPU steal = 10% за 3 с, в среднем с загрузки 5%" "$T/o12.txt" +printf 'cpu 100 0 50 900 0 0 0 0 0 0\nbtime 1788307200\n' > "$T/proc/stat" +rm -f "$STATE/diag-counters.last" +mkdir -p "$T/proc/pressure" +printf 'some avg10=55.00 avg60=2.00 avg300=1.00 total=1\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n' > "$T/proc/pressure/cpu" +printf 'some avg10=0.00 avg60=12.50 avg300=3.00 total=1\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n' > "$T/proc/pressure/memory" +printf 'some avg10=0.00 avg60=0.00 avg300=0.00 total=1\n' > "$T/proc/pressure/io" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o12b.txt" 2>/dev/null || true +grep_not "PSI: всплеск avg10=55 при avg60=2 — не ▲" "PSI cpu some avg10=55.00% — заметный" "$T/o12b.txt" +grep_ok "PSI: всплеск avg10 виден как info с avg60" "PSI cpu some avg60=2.00% avg10=55.00%" "$T/o12b.txt" +grep_ok "PSI: устойчивое давление avg60=12.5 — ▲" "PSI memory some avg60=12.50% (avg10=0.00%) — устойчивый" "$T/o12b.txt" +rm -rf "$T/proc/pressure" + +echo "== 13. ложные ▲: XanMod выключен осознанно, logrotate с капом, журнал (v4.2) ==" +printf ': "${ENABLE_XANMOD:=0}"\n' > "$T/etc/node-accelerator/optimize.conf" +env TERM=dumb NA_TEST_KREL=6.12.48+deb13-amd64 NA_TEST_ARCH=x86_64 "$WBASH" "$DIAG" > "$T/o13.txt" 2>/dev/null || true +grep_ok "ENABLE_XANMOD=0 → info, а не ▲" "XanMod выключен осознанно (ENABLE_XANMOD=0" "$T/o13.txt" +grep_not "ENABLE_XANMOD=0 → нет «Ядро не XanMod»" "Ядро не XanMod" "$T/o13.txt" +printf ': "${ENABLE_XANMOD=1}"\n' > "$T/etc/node-accelerator/optimize.conf" +env TERM=dumb NA_TEST_KREL=6.12.48+deb13-amd64 NA_TEST_ARCH=x86_64 "$WBASH" "$DIAG" > "$T/o13b.txt" 2>/dev/null || true +grep_ok "ENABLE_XANMOD=1 (идиома protect v4.2 «=») → ▲ как раньше" "Ядро не XanMod" "$T/o13b.txt" +rm -f "$T/etc/node-accelerator/optimize.conf" +# все уступленные маски — у стансы с капом: ✔, без ▲ +printf '/var/log/nginx/*.log\t/etc/logrotate.d/nginx\tcapped\n' > "$STATE/logrotate.ceded" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o13c.txt" 2>/dev/null || true +grep_ok "logrotate: уступка стансе с капом — ✔" "✔ ротация: часовой таймер активен; часть масок держат чужие стансы с капом" "$T/o13c.txt" +grep_not "logrotate: ▲ про чужие стансы нет" "▲ ротация: таймер активен, но часть масок" "$T/o13c.txt" +rm -f "$STATE/logrotate.ceded" +# volatile-журнал: свой текст, без совета про PORTSCAN_LOG_RATE +mkdir -p "$T/run/log/journal" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o13d.txt" 2>/dev/null || true +grep_ok "журнал volatile — свой ▲" "▲ журнал volatile (только" "$T/o13d.txt" +grep_not "volatile: «вмещает менее 48ч» не печатается" "журнал вмещает менее 48ч" "$T/o13d.txt" +rm -rf "$T/run/log/journal" +# глубина ≈ аптайму (одна загрузка): info, PORTSCAN не советуем +printf '36500.00 88888.00\n' > "$T/proc/uptime" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o13e.txt" 2>/dev/null || true +grep_ok "span ≈ uptime — info «держит всю текущую загрузку»" "≈ аптайму: журнал держит всю текущую загрузку" "$T/o13e.txt" +grep_not "span ≈ uptime — PORTSCAN_LOG_RATE не советуем" "PORTSCAN_LOG_RATE" "$T/o13e.txt" +printf '99999.00 88888.00\n' > "$T/proc/uptime" +# лог анти-скана: доля за сутки 75% — ▲; 0.3% — info; без --grep — «не измерено» +env TERM=dumb NA_TEST_JTOTAL=4 "$WBASH" "$DIAG" > "$T/o13f.txt" 2>/dev/null || true +grep_ok "portscan: 75% журнала за сутки → ▲" "▲ строк [na portscan] за 24ч: 3 (75% журнала за сутки)" "$T/o13f.txt" +env TERM=dumb NA_TEST_JTOTAL=1000 "$WBASH" "$DIAG" --json > "$T/o13g.json" 2>/dev/null || true +check "json: portscan_log_lines_24h=3" 3 "$(jget "$T/o13g.json" portscan_log_lines_24h)" +check "json: portscan_log_share_pct=0" 0 "$(jget "$T/o13g.json" portscan_log_share_pct)" +env TERM=dumb NA_TEST_NO_GREP=1 "$WBASH" "$DIAG" > "$T/o13h.txt" 2>/dev/null || true +grep_ok "portscan: journalctl без --grep → info «не измерено»" "строк [na portscan] за 24ч: не измерено" "$T/o13h.txt" +# OOM прошлой загрузки виден (журнал ядра за сутки по ВСЕМ загрузкам) +env TERM=dumb NA_TEST_OOM=1 "$WBASH" "$DIAG" > "$T/o13i.txt" 2>/dev/null || true +grep_ok "OOM за 24ч по всем загрузкам → ▲" "kern-лог за 24ч: 1 строк OOM" "$T/o13i.txt" + +echo "== 14. порты — из живых правил и conf, а не из маркера (v4.2) ==" +grep -v '^tcp_ports=' "$T/protect.installed.orig" > "$PROT" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o14.json" 2>/dev/null || true +check "json: маркер без tcp_ports= → max_conn_per_ip по живым правилам" 3 "$(jget "$T/o14.json" max_conn_per_ip)" +env "$SSHENV" TERM=dumb "$WBASH" "$DIAG" > "$T/o14.txt" 2>/dev/null || true +grep_ok "текст: датчик CONN_LIMIT работает без tcp_ports= в маркере" "с одного IP = 3 / CONN_LIMIT 8192 (порты 443," "$T/o14.txt" +cp "$T/protect.installed.orig" "$PROT" +cp "$T/etc/node-accelerator/protect.conf" "$T/protect.conf.orig" +printf ': "${TCP_PORTS=443,8443}"\n' >> "$T/etc/node-accelerator/protect.conf" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o14b.txt" 2>/dev/null || true +grep_ok "conf (идиома «=») ≠ живым правилам → ▲ с последствием ре-рана" "TCP-порты в живых правилах (443) ≠ TCP_PORTS в protect.conf (443,8443): ре-ран protect откроет 8443" "$T/o14b.txt" +cp "$T/protect.conf.orig" "$T/etc/node-accelerator/protect.conf" +printf ': "${TCP_PORTS:=443}"\n' >> "$T/etc/node-accelerator/protect.conf" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o14c.txt" 2>/dev/null || true +grep_not "conf = живым → ▲ нет" "TCP-порты в живых правилах" "$T/o14c.txt" +grep_ok "маркер tcp_ports=443,8445 устарел → info" "маркер protect.installed устарел: tcp_ports=443,8445, в правилах 443" "$T/o14c.txt" +cp "$T/protect.conf.orig" "$T/etc/node-accelerator/protect.conf" + +echo "== 15. CrowdSec: решения по источникам, scope, acquisition (v4.2) ==" +env TERM=dumb NA_TEST_DECISIONS=2 NA_TEST_CAPI=33 NA_TEST_LISTS=2 "$WBASH" "$DIAG" --json > "$T/o15.json" 2>/dev/null || true +check "json: crowdsec_decisions_local=2" 2 "$(jget "$T/o15.json" crowdsec_decisions_local)" +check "json: crowdsec_decisions_capi=35 (CAPI + lists)" 35 "$(jget "$T/o15.json" crowdsec_decisions_capi)" +mkdir -p "$T/etc/crowdsec/acquis.d" +cat > "$T/etc/crowdsec/acquis.d/na-sshd.yaml" <<'ACQ' +source: journalctl +journalctl_filter: + - "_SYSTEMD_UNIT=ssh.service" +labels: + type: syslog +--- +source: journalctl +journalctl_filter: + - "_SYSTEMD_UNIT=sshd.service" +labels: + type: syslog +ACQ +env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_DECISIONS=2 NA_TEST_CAPI=33 "$WBASH" "$DIAG" > "$T/o15.txt" 2>/dev/null || true +grep_ok "текст: локальные и CAPI раздельно" "2 локальных (crowdsec/cscli), 33 из CAPI/списков" "$T/o15.txt" +grep_ok "текст: scope неизвестен (≤4.1) + CAPI → ▲ «на ВСЕХ портах»" "режет 33 адресов CAPI/списков на ВСЕХ портах" "$T/o15.txt" +grep_ok "текст: acquisition — только sshd" "CrowdSec читает: journalctl[_SYSTEMD_UNIT=ssh.service] journalctl[_SYSTEMD_UNIT=sshd.service] — только sshd" "$T/o15.txt" +printf 'crowdsec_scope=ssh\n' >> "$PROT" +env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_CAPI=33 "$WBASH" "$DIAG" > "$T/o15b.txt" 2>/dev/null || true +grep_ok "scope=ssh из маркера → info" "блок-листы CrowdSec действуют только на SSH-порт" "$T/o15b.txt" +grep_not "scope=ssh → ▲ «на ВСЕХ портах» нет" "на ВСЕХ портах" "$T/o15b.txt" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o15c.json" 2>/dev/null || true +check "json: crowdsec_scope из маркера" ssh "$(jget "$T/o15c.json" crowdsec_scope)" +cp "$T/protect.installed.orig" "$PROT" + +echo "== 16. whitelist: 4-й слой — эффективный allowlist CrowdSec (v4.2) ==" +S2="$T/etc/crowdsec/parsers/s02-enrich"; mkdir -p "$S2" +cat > "$S2/na-whitelist.yaml" <<'YML' +name: node-accelerator/whitelist +description: never ban admin/panel +whitelist: + reason: node-accelerator trusted + ip: + - "203.0.113.5" + cidr: + - "192.0.2.0/24" +YML +# ручной файл оператора — прикрывает v6-адрес; и парсер без секции whitelist — мимо +printf 'name: ops/extra\nwhitelist:\n reason: ops\n ip: ["2001:db8::1", "198.18.9.9"]\n' > "$S2/ops-extra.yaml" +printf 'name: crowdsecurity/geoip-enrich\nfilter: "1==1"\nip:\n - "198.51.100.7"\n' > "$S2/geoip-enrich.yaml" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o16.json" 2>/dev/null || true +check "json: whitelist_drift_crowdsec=1 (198.51.100.7 не прикрыт)" 1 "$(jget "$T/o16.json" whitelist_drift_crowdsec)" +env TERM=dumb NA_TEST_CS_TABLE=1 "$WBASH" "$DIAG" > "$T/o16.txt" 2>/dev/null || true +grep_ok "текст: ▲ называет непрокрытый адрес (bouncer стоит)" "▲ whitelist na_filter не прикрыт в CrowdSec: 198.51.100.7" "$T/o16.txt" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o16n.txt" 2>/dev/null || true +grep_not "без bouncer'а — не ▲" "▲ whitelist na_filter не прикрыт" "$T/o16n.txt" +grep_ok "текст: лишний в allowlist CrowdSec — info" "в allowlist CrowdSec, но не в whitelist na_filter: 198.18.9.9" "$T/o16.txt" +# обратный дрейф: адрес есть в na_filter.nft и conf, но удалён из живого сета руками +cp "$NFTD/set-whitelist_v4" "$T/wl4.orig" +printf 'table inet na_filter {\n\tset whitelist_v4 {\n\t\ttype ipv4_addr\n\t\tflags interval\n\t\telements = { 198.51.100.7 }\n\t}\n}\n' > "$NFTD/set-whitelist_v4" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o16r.txt" 2>/dev/null || true +grep_ok "файл → живой: удалённый на ходу адрес назван" "но не в живом whitelist_v4: 192.0.2.0/24 203.0.113.5 (вернутся после ребута" "$T/o16r.txt" +grep_ok "conf → живой: тоже" "в WHITELIST= из protect.conf, но не в живом whitelist_v4: 192.0.2.0/24 203.0.113.5" "$T/o16r.txt" +cp "$T/wl4.orig" "$NFTD/set-whitelist_v4" +printf ' - "198.51.100.0/24"\n' >> "$S2/na-whitelist.yaml" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o16b.json" 2>/dev/null || true +check "json: адрес прикрыт CIDR'ом allowlist → 0" 0 "$(jget "$T/o16b.json" whitelist_drift_crowdsec)" +rm -rf "$T/etc/crowdsec" + +echo "== 17. DNAT мимо na_filter, слушатели вне файрвола (v4.2) ==" +printf '0.0.0.0:80->80/tcp, [::]:80->80/tcp, 127.0.0.1:9000->9000/tcp\n\n' > "$NFTD/docker-ports" +cat > "$NFTD/ruleset" <<'RS' +table ip nat { + chain PREROUTING { + type nat hook prerouting priority dstnat; policy accept; + iifname "eth0" tcp dport 8443 counter packets 0 bytes 0 dnat to 10.0.0.9:443 + ip daddr 127.0.0.1 tcp dport 9100 dnat to 172.17.0.3:9100 + } +} +RS +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o17.json" 2>/dev/null || true +check "json: dnat_ports (docker + ручной relay, без loopback)" "80/tcp,8443/tcp" "$(jget "$T/o17.json" dnat_ports)" +check "json: dnat_guard=-1 (маркер ≤4.1 без ключа)" -1 "$(jget "$T/o17.json" dnat_guard)" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o17.txt" 2>/dev/null || true +grep_ok "текст: ▲ DNAT мимо na_filter с перечнем" "опубликованы через DNAT мимо na_filter: 80/tcp,8443/tcp" "$T/o17.txt" +printf 'dnat_guard=1\n' >> "$PROT" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o17b.txt" 2>/dev/null || true +grep_ok "dnat_guard=1 → info" "под защитой na_filter (dnat_guard=1)" "$T/o17b.txt" +grep_not "dnat_guard=1 → ▲ нет" "мимо na_filter" "$T/o17b.txt" +cp "$T/protect.installed.orig" "$PROT" +rm -f "$NFTD/docker-ports" "$NFTD/ruleset" +cat > "$SSD/listen-tp" <<'LTP' +LISTEN 0 4096 0.0.0.0:443 0.0.0.0:* users:(("xray",pid=10,fd=7)) +LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1,fd=3)) +LISTEN 0 128 0.0.0.0:10050 0.0.0.0:* users:(("monitor-agent",pid=20,fd=4)) +LISTEN 0 128 [::]:10050 [::]:* users:(("monitor-agent",pid=20,fd=5)) +LISTEN 0 4096 127.0.0.1:10085 0.0.0.0:* users:(("xray",pid=10,fd=8)) +LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=5,fd=14)) +LTP +printf 'UNCONN 0 0 10.0.0.5%%eth0:68 0.0.0.0:* users:(("dhclient",pid=3,fd=6))\n' > "$SSD/listen-up" +cp "$NFTD/chain-input" "$T/chain.orig" +awk '{print} /meter cc4_443/ { print "\t\ttcp dport 8445 ct state new meter cc4_8445 { ip saddr ct count over 8192 } drop" }' "$T/chain.orig" > "$NFTD/chain-input" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o17c.txt" 2>/dev/null || true +grep_ok "strict: слушатель вне портов — info, назван процесс" "strict их режет (доступны только whitelist/флоту): tcp/10050(monitor-agent)" "$T/o17c.txt" +grep_not "DHCP-клиент не в списке слушателей" "udp/68" "$T/o17c.txt" +grep_ok "разрешён, но никто не слушает — info" "разрешены в файрволе, но никто не слушает: tcp/8445" "$T/o17c.txt" +sed 's/^fw_mode=strict/fw_mode=open/' "$T/protect.installed.orig" > "$PROT" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o17d.txt" 2>/dev/null || true +grep_ok "FW_MODE=open: вне перечисленных — по замыслу, info" "слушают вне перечисленных портов (FW_MODE=open — открыты по замыслу" "$T/o17d.txt" +grep_not "FW_MODE=open: ▲ нет" "▲ слушают" "$T/o17d.txt" +sed 's/^fw_mode=strict/fw_mode=skip/' "$T/protect.installed.orig" > "$PROT" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o17f.txt" 2>/dev/null || true +grep_ok "FW_MODE=skip: слушатель вне портов открыт миру — ▲" "▲ слушают публично вне разрешённых портов и открыты миру (na_filter skip — правил нет): tcp/10050(monitor-agent)" "$T/o17f.txt" +cp "$T/protect.installed.orig" "$PROT" +# порт xray в эфемерном диапазоне без резерва — ▲; зарезервирован — тишина +grep_ok "xray :10085 в эфемерном диапазоне без резерва → ▲" "без резерва: 10085(xray)" "$T/o17c.txt" +env TERM=dumb NA_TEST_RESV=10085,20000-20010 "$WBASH" "$DIAG" > "$T/o17e.txt" 2>/dev/null || true +grep_not "зарезервирован → ▲ нет" "без резерва:" "$T/o17e.txt" +cp "$T/chain.orig" "$NFTD/chain-input" +rm -f "$SSD/listen-tp" "$SSD/listen-up" + +echo "== 18. память, nftables.service, счётчики, хэш файла, буферы от tier (v4.2) ==" +cp "$T/proc/meminfo" "$T/meminfo.orig" +printf 'MemTotal: 1000000 kB\nMemAvailable: 100000 kB\nSwapTotal: 524288 kB\nSwapFree: 262144 kB\n' > "$T/proc/meminfo" +printf 'virtio_balloon 28672 0 - Live 0x0000000000000000\nvirtio_net 61440 0 - Live 0x0\n' > "$T/proc/modules" +printf 'flush ruleset\ntable inet filter {\n}\n' > "$T/etc/nftables.conf" +cat > "$NFTD/counters" <<'CNT' +table inet na_filter { + counter c_synflood { + packets 0 bytes 0 + } + counter c_portscan { + packets 1234 bytes 74040 + } +} +CNT +printf 'nft_sha256=0000000000000000000000000000000000000000000000000000000000000000\n' >> "$PROT" +env TERM=dumb NA_TEST_NFTS=1 NA_TEST_RMEM_MAX=16777216 NA_TEST_RMEM_DEF=212992 "$WBASH" "$DIAG" > "$T/o18.txt" 2>/dev/null || true +grep_ok "MemAvailable 10% → ▲" "▲ MemAvailable 10%" "$T/o18.txt" +grep_ok "своп занят — info" "своп занят: 256 из 512 МБ" "$T/o18.txt" +grep_ok "balloon-драйвер — info" "balloon-драйвер: virtio_balloon" "$T/o18.txt" +grep_ok "nftables.service + flush ruleset → ▲" "▲ nftables.service enabled, а" "$T/o18.txt" +grep_ok "…с советом disable без stop" "systemctl disable nftables.service (без stop/--now)" "$T/o18.txt" +grep_ok "ненулевые счётчики c_* показаны" "счётчики дропов na_filter (с загрузки правил): c_portscan=1234" "$T/o18.txt" +grep_not "нулевой c_synflood не показан" "c_synflood=" "$T/o18.txt" +grep_ok "nft_sha256 ≠ файлу → ▲ «правлен руками»" "правлен руками после прогона protect" "$T/o18.txt" +grep_ok "tier1 (≤1.2G): rmem_max 16M — ✔, а не ▲" "✔ net.core.rmem_max = 16777216" "$T/o18.txt" +grep_ok "rmem_default ниже tier → ▲" "▲ net.core.rmem_default = 212992 (рекоменд. ≥ 524288)" "$T/o18.txt" +SHA="$( { sha256sum "$T/etc/node-accelerator/na_filter.nft" 2>/dev/null || shasum -a 256 "$T/etc/node-accelerator/na_filter.nft"; } | awk '{print $1}')" +cp "$T/protect.installed.orig" "$PROT"; printf 'nft_sha256=%s\n' "$SHA" >> "$PROT" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o18b.txt" 2>/dev/null || true +grep_ok "nft_sha256 совпадает → ✔" "совпадает с записанным protect" "$T/o18b.txt" +cp "$T/protect.installed.orig" "$PROT"; cp "$T/meminfo.orig" "$T/proc/meminfo" +rm -f "$T/proc/modules" "$T/etc/nftables.conf" "$NFTD/counters" + +echo "== 19. XanMod: доступное обновление; reboot-required от стоковых ядер (v4.2) ==" +printf 'linux-xanmod-x64v3\n' > "$STATE/xanmod.pkg" +cat > "$T/aptpol" <<'POL' +linux-xanmod-x64v3: + Installed: 6.18.47-x64v3-xanmod1-0~20260901.gabc + Candidate: 6.18.52-x64v3-xanmod1-0~20260928.gdef + Version table: + 6.18.52-x64v3-xanmod1-0~20260928.gdef 500 + 500 https://deb.xanmod.org trixie/main amd64 Packages + *** 6.18.47-x64v3-xanmod1-0~20260901.gabc 100 + 100 /var/lib/dpkg/status +POL +env TERM=dumb NA_TEST_APTPOL="$T/aptpol" "$WBASH" "$DIAG" --json > "$T/o19.json" 2>/dev/null || true +check "json: xanmod_update = версия кандидата" "6.18.52-x64v3-xanmod1-0~20260928.gdef" "$(jget "$T/o19.json" xanmod_update)" +env TERM=dumb NA_TEST_APTPOL="$T/aptpol" "$WBASH" "$DIAG" > "$T/o19.txt" 2>/dev/null || true +grep_ok "текст: info про обновление XanMod" "доступно обновление XanMod: 6.18.52-x64v3-xanmod1-0~20260928.gdef" "$T/o19.txt" +sed 's#deb.xanmod.org#deb.example.test#' "$T/aptpol" > "$T/aptpol2" +env TERM=dumb NA_TEST_APTPOL="$T/aptpol2" "$WBASH" "$DIAG" --json > "$T/o19b.json" 2>/dev/null || true +check "json: кандидат не из репо XanMod → пусто" "" "$(jget "$T/o19b.json" xanmod_update)" +rm -f "$STATE/xanmod.pkg" +mkdir -p "$T/run" +: > "$T/run/reboot-required"; printf 'linux-image-6.12.48+deb13-amd64\nlinux-image-amd64\n' > "$T/run/reboot-required.pkgs" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o19c.txt" 2>/dev/null || true +grep_ok "reboot-required от стоковых ядер при XanMod → info" "от стоковых ядер (linux-image-6.12.48+deb13-amd64 linux-image-amd64), а работает XanMod" "$T/o19c.txt" +grep_not "…и не ▲" "система ждёт перезагрузки" "$T/o19c.txt" +printf 'libc6\n' > "$T/run/reboot-required.pkgs" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o19d.txt" 2>/dev/null || true +grep_ok "reboot-required от libc6 → ▲" "reboot-required: libc6)" "$T/o19d.txt" +grep_ok "…именно ▲" "▲ система ждёт перезагрузки" "$T/o19d.txt" +rm -f "$T/run/reboot-required" "$T/run/reboot-required.pkgs" + +echo "== 20. сертификаты: просроченный не маскируется, Caddy и nginx находятся (v4.2) ==" +mkdir -p "$T/etc/letsencrypt/live/expired.example.test" +printf 'x\n' > "$T/etc/letsencrypt/live/expired.example.test/fullchain.pem" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20.json" 2>/dev/null || true +check "json: просроченный серт: cert_min_days=-10" -10 "$(jget "$T/o20.json" cert_min_days)" +check "json: cert_found=true" true "$(jget "$T/o20.json" cert_found)" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o20.txt" 2>/dev/null || true +grep_ok "текст: ✘ «ИСТЁК»" "✘ TLS-серт ИСТЁК 10 дн назад" "$T/o20.txt" +rm -rf "$T/etc/letsencrypt" +CADDY_D="$T/var/lib/docker/volumes/caddy_data/_data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/panel.example.test" +mkdir -p "$CADDY_D"; printf 'x\n' > "$CADDY_D/panel.example.test.crt" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20b.json" 2>/dev/null || true +check "json: серт Caddy в docker volume найден (15 дн)" 15 "$(jget "$T/o20b.json" cert_min_days)" +rm -rf "$T/var/lib/docker/volumes" +mkdir -p "$T/etc/nginx/sites-enabled" "$T/etc/nginx/sites-available" "$T/srv/tls" "$T/srv/old" +printf 'x\n' > "$T/srv/tls/nginx-site.pem" +printf 'server {\n listen 443 ssl;\n ssl_certificate %s;\n ssl_certificate_key /srv/tls/k.pem;\n}\n' "$T/srv/tls/nginx-site.pem" > "$T/etc/nginx/sites-enabled/default" +printf 'x\n' > "$T/srv/old/expired-old.pem" +printf 'server { ssl_certificate %s; }\n' "$T/srv/old/expired-old.pem" > "$T/etc/nginx/sites-available/old" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20c.json" 2>/dev/null || true +check "json: nginx ssl_certificate найден (20 дн)" 20 "$(jget "$T/o20c.json" cert_min_days)" +check "json: sites-available (не включён) не считается" "$T/srv/tls/nginx-site.pem" "$(jget "$T/o20c.json" cert_min_file)" +rm -rf "$T/etc/nginx" "$T/srv" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20d.json" 2>/dev/null || true +check "json: снова только живой серт (60), cert_found=true" "60 true" "$(jget "$T/o20d.json" cert_min_days) $(jget "$T/o20d.json" cert_found)" + +echo "== 21. docker: лог без max-size; RPS при RX-очередей ≥ ядер (v4.2) ==" +env TERM=dumb NA_TEST_DK_NOCAP=1 "$WBASH" "$DIAG" > "$T/o21.txt" 2>/dev/null || true +grep_ok "контейнер json-file без max-size → ▲" "контейнеры с json-логом без max-size (растёт без предела): remnanode" "$T/o21.txt" +for q in 1 2 3; do mkdir -p "$T/sys/class/net/eth0/queues/rx-$q"; printf '0\n' > "$T/sys/class/net/eth0/queues/rx-$q/rps_cpus"; done +env TERM=dumb "$WBASH" "$DIAG" > "$T/o21b.txt" 2>/dev/null || true +grep_ok "RX-очередей (4) ≥ ядер (4) → info «не нужен»" "RPS выключен — и не нужен: RX-очередей (4) ≥ ядер (4)" "$T/o21b.txt" +grep_not "…и не ▲ «RPS выключен»" "▲ RPS выключен" "$T/o21b.txt" +grep_not "…и не ▲ «гонка на буте»" "na-rps.service active, но rps_cpus пуст" "$T/o21b.txt" +rm -rf "$T/sys/class/net/eth0/queues/rx-1" "$T/sys/class/net/eth0/queues/rx-2" "$T/sys/class/net/eth0/queues/rx-3" + +echo "== 22. --json по-прежнему одна валидная строка, новые поля — строго в хвосте ==" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o22.json" 2>/dev/null || true +check "одна строка" 1 "$(wc -l < "$T/o22.json" | tr -d ' ')" +check "новые поля строго после прежнего хвоста (dynset_no_timeout)" ok "$(python3 - "$T/o22.json" <<'PYK' +import json,sys +d=json.load(open(sys.argv[1])); k=list(d) +new=["udp_rcvbuf_errors_delta","softnet_drops_delta","time_squeeze_delta","listen_overflows_delta","delta_window_s", + "cpu_steal_boot_pct","crowdsec_decisions_local","crowdsec_decisions_capi","crowdsec_scope", + "whitelist_drift_crowdsec","dnat_ports","dnat_guard","xanmod_update","cert_found", + "portscan_log_lines_24h","portscan_log_share_pct"] +i=k.index("dynset_no_timeout") +print("ok" if k[i+1:]==new and k[0]=="kernel" else "keys after tail: %s" % k[i+1:]) +PYK +)" + echo echo " прогон: $PASS ok, $FAIL fail" if [[ "$FAIL" -ne 0 ]]; then echo "DIAGNOSE-UNIT: FAIL"; exit 1; fi From c9a27545effc6a2d23dadff4cf4fef7025fa9c1a Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:05:52 +0300 Subject: [PATCH 04/14] =?UTF-8?q?v4.2:=20na-report=20=E2=80=94=20CrowdSec?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=D0=BC=20?= =?UTF-8?q?raw=20(=D0=BD=D0=B5=20=C3=9733=20=D0=BF=D0=BE=20"value"),=20?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20events=5F5m/unique?= =?UTF-8?q?=5Fsrc=5F5m=20=D1=80=D1=8F=D0=B4=D0=BE=D0=BC=20=D1=81=20ban=5Fr?= =?UTF-8?q?ate=5F5m,=20asn=5Fenrichment=20ok|partial|unavailable,=20JSON?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20json=5Fescape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drops_by_reason.crowdsec — активные локальные решения (строки `-o raw` с числовым id), crowdsec_decisions_all — с CAPI/списками (-a). - ban_rate_5m сохранён ради совместимости; новые поля — в хвосте объекта. - Имя AS из whois с управляющим символом больше не ломает --json. - Новый tests/na-report-unit.sh. Co-Authored-By: Claude Opus 5.5 --- scripts/na-report.sh | 90 +++++++++++++++++------- tests/na-report-unit.sh | 152 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 25 deletions(-) create mode 100755 tests/na-report-unit.sh diff --git a/scripts/na-report.sh b/scripts/na-report.sh index 2518f39..fcfbfb3 100755 --- a/scripts/na-report.sh +++ b/scripts/na-report.sh @@ -17,7 +17,12 @@ # {window_hours, generated_at, events_total, ban_rate_5m, # drops_by_reason{portscan,synflood,ssh-flood,badflags,crowdsec}, # timeline[12], # последние 60 мин, бакеты по 5 мин -# top_asn[{asn,name,country,pct}], top_ips[{ip,asn,country,hits,verdict}]} +# top_asn[{asn,name,country,pct}], top_ips[{ip,asn,country,hits,verdict}], +# events_5m, unique_src_5m, crowdsec_decisions_all, asn_enrichment} +# ban_rate_5m — исторически так названное ЧИСЛО СОБЫТИЙ drop-лога за 5 мин (не банов); +# оставлено ради совместимости, честные имена — events_5m / unique_src_5m. +# drops_by_reason.crowdsec — активные ЛОКАЛЬНЫЕ решения CrowdSec; crowdsec_decisions_all +# — вместе с CAPI/списками (-1 = cscli нет). asn_enrichment: ok|partial|unavailable. # # JSON-схема (--proxyware --json): # {verdict:"clean|suspect", generated_at, @@ -77,7 +82,8 @@ collect_events() { } # ─── helpers ────────────────────────────────────────────────────────────────── -json_str() { local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; } +# Через общий json_escape: сырой \r/\t из ответа whois (имя AS) ломал бы документ. +json_str() { json_escape "$1"; } # Множество забаненных (autoban v4+v6) — для вердикта. load_autoban() { @@ -91,13 +97,30 @@ is_banned() { grep -qxF "$1" "$TMP".ab 2>/dev/null; } count_reason() { awk -F'\t' -v r="$1" '$2==r{c++} END{print c+0}' "$TMP"; } events_total() { wc -l < "$TMP" | tr -d ' '; } ban_rate_5m() { awk -F'\t' -v now="$NOW" '$1>=now-300{c++} END{print c+0}' "$TMP"; } +# уникальные источники за 5 мин — то, что ban_rate_5m обещал названием +unique_src_5m() { awk -F'\t' -v now="$NOW" '$1>=now-300 && !s[$3]++{c++} END{print c+0}' "$TMP"; } +# crowdsec_count [-a] — активные решения CrowdSec: строки CSV `-o raw` с числовым id +# (заголовок не в счёт). Раньше считались строки `"value"` в `-o json`: одно решение +# содержит несколько таких полей — на ноде флота выходило ×33 к реальности. Без `-a` — +# только локальные (crowdsec/cscli), с `-a` — плюс CAPI/списки. Ровно ОДНО целое на +# stdout (уходит в JSON); cscli нет — 0 без `-a`, −1 с `-a`. crowdsec_count() { - # ВАЖНО: ровно ОДНО целое на stdout (значение уходит в JSON). grep -c печатает 0 - # и выходит с кодом 1 при нуле совпадений — старый `grep -oc … || echo 0` давал - # "0\n0" и ломал JSON на нодах с cscli, но 0 решений. Захватываем в переменную. - command -v cscli >/dev/null 2>&1 || { echo 0; return; } - local c; c="$(cscli decisions list -o json 2>/dev/null | grep -c '"value"')" - echo "${c:-0}" + local all="" c + [[ "${1:-}" == -a ]] && all=-a + command -v cscli >/dev/null 2>&1 || { [[ -n "$all" ]] && echo -1 || echo 0; return; } + c="$(cscli decisions list $all -o raw 2>/dev/null | awk -F, '$1 ~ /^[0-9]+$/ {c++} END {print c+0}')" + [[ "$c" =~ ^[0-9]+$ ]] || c=0 + echo "$c" +} +# asn_enrichment <всего IP> <обогащено> — ok | partial | unavailable: без флага «ASN +# пуст» не отличить от «атакуют адреса без ASN» (на ноде без dig/whois — всегда пусто) +asn_status() { + local tot="$1" got="$2" + command -v dig >/dev/null 2>&1 || command -v whois >/dev/null 2>&1 || { echo unavailable; return; } + if (( tot == 0 )); then echo ok + elif (( got == 0 )); then echo unavailable + elif (( got < tot )); then echo partial + else echo ok; fi } # timeline: 12 бакетов по 5 мин за последний час, число событий в каждом. @@ -179,11 +202,11 @@ verdict() { # verdict # ─── JSON-режим ──────────────────────────────────────────────────────────────── emit_json() { collect_events; load_autoban - local total rate tl portscan synflood sshflood badflags crowd + local total rate tl portscan synflood sshflood badflags crowd crowd_all usrc asnst total="$(events_total)"; rate="$(ban_rate_5m)"; tl="$(timeline_csv)" portscan="$(count_reason portscan)"; synflood="$(count_reason synflood)" sshflood="$(count_reason ssh-flood)"; badflags="$(count_reason badflags)" - crowd="$(crowdsec_count)" + crowd="$(crowdsec_count)"; crowd_all="$(crowdsec_count -a)"; usrc="$(unique_src_5m)" # top IP + ASN-обогащение local -a ips=() hitsarr=() @@ -193,6 +216,9 @@ emit_json() { ips+=("$ip"); hitsarr+=("$h"); [[ "$h" -gt "$maxhits" ]] && maxhits="$h" done < <(top_ips_raw) enrich_asn "${ips[@]}" + local got=0 + for ip in "${ips[@]}"; do [[ -n "${ASN[$ip]:-}" ]] && got=$((got+1)); done + asnst="$(asn_status "${#ips[@]}" "$got")" # top_ips JSON local ips_json="" i a c @@ -202,9 +228,14 @@ emit_json() { ips_json+="${ips_json:+,}{\"ip\":\"$(json_str "$ip")\",\"asn\":\"$(json_str "$a")\",\"country\":\"$(json_str "$c")\",\"hits\":$h,\"verdict\":\"$(json_str "$(verdict "$ip" "$h" "$maxhits")")\"}" done - # top_asn: агрегируем хиты по ASN (среди top-IP), pct от суммы - local asn_json="" - asn_json="$( + # top_asn: агрегируем хиты по ASN (среди top-IP), pct от суммы. awk только считает и + # сортирует (TSV), JSON собирает bash через json_escape: имя AS приходит из whois/DNS + # как есть, и экранирования одних \ и " (как было) не хватало на управляющие символы. + local asn_json="" an anm acc apct + while IFS=$'\t' read -r an apct acc anm; do + [[ -n "$an" ]] || continue + asn_json+="${asn_json:+,}{\"asn\":\"$(json_str "$an")\",\"name\":\"$(json_str "$anm")\",\"country\":\"$(json_str "$acc")\",\"pct\":$apct}" + done < <( for i in "${!ips[@]}"; do ip="${ips[$i]}"; printf '%s\t%s\t%s\t%s\n' "${ASN[$ip]:-?}" "${hitsarr[$i]}" "${CC[$ip]:-}" "${ANAME[$ip]:-}" done | awk -F'\t' ' @@ -214,13 +245,9 @@ emit_json() { n=0; for(a in hit){ arr[n++]=a } # простая сортировка по hit desc for(i=0;ihit[arr[i]]){t=arr[i];arr[i]=arr[j];arr[j]=t} - out="" - for(i=0;i/dev/null 2>&1 || command -v whois >/dev/null 2>&1 \ - || info "ASN не обогащён: нет dig/whois (best-effort — колонка ASN останется '?')" + local got=0 + for ip in "${ips[@]}"; do [[ -n "${ASN[$ip]:-}" ]] && got=$((got+1)); done + case "$(asn_status "${#ips[@]}" "$got")" in + unavailable) + if command -v dig >/dev/null 2>&1 || command -v whois >/dev/null 2>&1; then + info "ASN не обогащён: Team Cymru не ответил (нужен исходящий DNS/43) — колонка ASN останется '?'" + else + info "ASN не обогащён: нет dig/whois (best-effort — колонка ASN останется '?')" + fi ;; + partial) info "ASN обогащён частично ($got из ${#ips[@]}: v6 и хвост списка не спрашиваем)" ;; + esac printf " %-18s %6s %-22s %s\n" "IP" "hits" "ASN" "вердикт" for i in "${!ips[@]}"; do ip="${ips[$i]}"; h="${hitsarr[$i]}" diff --git a/tests/na-report-unit.sh b/tests/na-report-unit.sh new file mode 100755 index 0000000..05eef56 --- /dev/null +++ b/tests/na-report-unit.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# +# na-report-unit.sh — ИСПОЛНЯЕТ scripts/na-report.sh (--json и человекочитаемый отчёт) против +# стабов journalctl/nft/cscli/whois/dig. Что стережём (v4.2, аудит флота): +# 1. CrowdSec считается по строкам `decisions list -o raw` с числовым id: заголовок CSV +# и запятая в имени AS не в счёт, а `"value"` из `-o json` (×33 на ноде флота) — тем +# более; отдельно `-a` (с CAPI/списками) → crowdsec_decisions_all; +# 2. ban_rate_5m сохранён (совместимость), рядом честные events_5m и unique_src_5m; +# 3. asn_enrichment: ok | partial | unavailable — «ASN пуст» отличим от «не спросили»; +# 4. строки JSON экранируются через json_escape: управляющий символ в имени AS из whois +# не ломает документ; новые поля — только в хвосте. +# +# Не требует root/сети/nft/CrowdSec. Запуск: bash tests/na-report-unit.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +BIN="$T/bin"; mkdir -p "$BIN" +export NA_T="$T" + +pick_bash() { + local c + for c in bash /opt/homebrew/bin/bash /usr/local/bin/bash; do + command -v "$c" >/dev/null 2>&1 || continue + if "$c" -c 'set -u; a=(); : "${a[@]}"; [[ -v HOME ]]' 2>/dev/null; then command -v "$c"; return 0; fi + done + return 1 +} +WBASH="$(pick_bash)" || { echo "[x] не нашёл bash ≥ 4.4"; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "[x] нужен python3 для валидации JSON"; exit 1; } + +NOW="$(date +%s)" +# drop-лог: 3 события за последние 5 минут от 2 адресов + 2 старых (час назад) от третьего +{ + printf '%s.000001 node kernel: [na portscan] IN=eth0 OUT= SRC=203.0.113.10 DST=192.0.2.1 PROTO=TCP DPT=22\n' "$(( NOW - 100 ))" + printf '%s.000001 node kernel: [na portscan] IN=eth0 OUT= SRC=203.0.113.10 DST=192.0.2.1 PROTO=TCP DPT=23\n' "$(( NOW - 90 ))" + printf '%s.000001 node kernel: [na synflood] IN=eth0 OUT= SRC=203.0.113.20 DST=192.0.2.1 PROTO=TCP DPT=443\n' "$(( NOW - 80 ))" + printf '%s.000001 node kernel: [na portscan] IN=eth0 OUT= SRC=198.51.100.30 DST=192.0.2.1 PROTO=TCP DPT=25\n' "$(( NOW - 3000 ))" + printf '%s.000001 node kernel: [na portscan] IN=eth0 OUT= SRC=198.51.100.30 DST=192.0.2.1 PROTO=TCP DPT=26\n' "$(( NOW - 3100 ))" +} > "$T/klog" + +cat > "$BIN/journalctl" <<'JC' +#!/bin/sh +case "$*" in + *"-k -b all"*) cat "$NA_T/klog" ;; + *short-unix*) printf '%s.000000 node kernel: Linux version\n' "$(( $(date +%s) - 72*3600 ))" ;; +esac +exit 0 +JC +cat > "$BIN/nft" <<'NF' +#!/bin/sh +case "$*" in + "list set inet na_filter autoban_v4") + printf 'table inet na_filter {\n\tset autoban_v4 {\n\t\ttype ipv4_addr\n\t\tflags timeout\n\t\telements = { 203.0.113.10 timeout 1d expires 23h }\n\t}\n}\n' ;; +esac +exit 0 +NF +# 2 локальных решения (имя AS с запятой — внутри кавычек CSV), с -a ещё 33 CAPI. В -o json +# у каждого решения несколько полей "value" — по ним v4.1 и насчитывал ×N. +cat > "$BIN/cscli" <<'CS' +#!/bin/sh +case "$*" in + "decisions list -o raw"|"decisions list -a -o raw") + echo "id,source,ip,reason,action,country,as,events_count,expiration,simulated,alert_id" + echo '1,crowdsec,Ip:203.0.113.10,crowdsecurity/ssh-bf,ban,ZZ,"64500 EXAMPLE, Inc",5,3h,false,1' + echo '2,cscli,Ip:203.0.113.20,manual,ban,ZZ,,0,24h,false,2' + if [ "$*" = "decisions list -a -o raw" ]; then + i=0; while [ "$i" -lt 33 ]; do i=$((i+1)); echo "$((100+i)),CAPI,Ip:198.18.0.$i,crowdsecurity/http-probing,ban,,,0,160h,false,0"; done + fi ;; + "decisions list -o json") + i=0; while [ "$i" -lt 2 ]; do i=$((i+1)); printf '{"decisions":[{"value":"x","scope":"Ip"}],"source":{"value":"y"},"meta":[{"value":"z"}]}\n'; done ;; +esac +exit 0 +CS +# whois (bulk-режим Cymru): NA_TEST_WHOIS = all | first | none. В имени AS — управляющий +# символ (\001) и таб: сырой он ломал JSON. +cat > "$BIN/whois" <<'WH' +#!/bin/sh +in="$(cat)" +mode="${NA_TEST_WHOIS:-all}" +[ "$mode" = none ] && exit 0 +echo "Bulk mode; whois.cymru.com [2026-10-03 00:00:00 +0000]" +n=0 +for ip in $(printf '%s\n' "$in" | grep -E '^[0-9]+\.'); do + n=$((n+1)) + [ "$mode" = first ] && [ "$n" -gt 1 ] && break + printf '64500 | %s | 203.0.113.0/24 | ZZ | arin | 2000-01-01 | EXAMPLE\001NET\tSUB, ZZ\n' "$ip" +done +WH +printf '#!/bin/sh\nexit 1\n' > "$BIN/dig" +chmod +x "$BIN"/* +export PATH="$BIN:$PATH" + +PASS=0; FAIL=0 +check() { if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1"; else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi; } +jget() { + python3 - "$1" "$2" <<'PY' +import json, sys +try: + d = json.load(open(sys.argv[1])) +except Exception as e: + print("<НЕВАЛИДНЫЙ JSON: %s>" % e); sys.exit(0) +v = d +for k in sys.argv[2].split("."): + v = v.get(k, "<НЕТ ПОЛЯ>") if isinstance(v, dict) else "<НЕТ ПОЛЯ>" +print(v) +PY +} +R="$REPO_ROOT/scripts/na-report.sh" + +echo "== 1. --json: CrowdSec, честные метрики 5 мин, ASN (v4.2) ==" +NA_TEST_WHOIS=all TERM=dumb "$WBASH" "$R" --json > "$T/o1.json" 2>"$T/e1" || true +check "одна строка" 1 "$(wc -l < "$T/o1.json" | tr -d ' ')" +check "JSON валиден (управляющий символ в имени AS экранирован)" ok \ + "$(python3 -c 'import json,sys; json.load(open(sys.argv[1])); print("ok")' "$T/o1.json" 2>&1 | tail -1)" +check "drops_by_reason.crowdsec = 2 локальных (не ×N по \"value\")" 2 "$(jget "$T/o1.json" drops_by_reason.crowdsec)" +check "crowdsec_decisions_all = 35 (с CAPI)" 35 "$(jget "$T/o1.json" crowdsec_decisions_all)" +check "ban_rate_5m сохранён (совместимость) = 3" 3 "$(jget "$T/o1.json" ban_rate_5m)" +check "events_5m = 3" 3 "$(jget "$T/o1.json" events_5m)" +check "unique_src_5m = 2" 2 "$(jget "$T/o1.json" unique_src_5m)" +check "asn_enrichment = ok" ok "$(jget "$T/o1.json" asn_enrichment)" +check "top_asn: имя без управляющего символа, таб не рвёт строку" "EXAMPLE" \ + "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["top_asn"][0]["name"].split("\t")[0][:7])' "$T/o1.json")" +check "новые поля — строго в хвосте, после top_ips" ok "$(python3 - "$T/o1.json" <<'PY' +import json, sys +k = list(json.load(open(sys.argv[1]))) +i = k.index("top_ips") +print("ok" if k[i+1:] == ["events_5m", "unique_src_5m", "crowdsec_decisions_all", "asn_enrichment"] else k[i+1:]) +PY +)" + +echo "== 2. asn_enrichment: partial и unavailable ==" +NA_TEST_WHOIS=first TERM=dumb "$WBASH" "$R" --json > "$T/o2.json" 2>/dev/null || true +check "обогащён 1 из 3 адресов → partial" partial "$(jget "$T/o2.json" asn_enrichment)" +NA_TEST_WHOIS=none TERM=dumb "$WBASH" "$R" --json > "$T/o3.json" 2>/dev/null || true +check "Cymru не ответил → unavailable" unavailable "$(jget "$T/o3.json" asn_enrichment)" + +echo "== 3. человекочитаемый отчёт ==" +NA_TEST_WHOIS=none TERM=dumb "$WBASH" "$R" > "$T/o4.txt" 2>/dev/null || true +txt="$(sed $'s/\033\\[[0-9;]*m//g' "$T/o4.txt")" +if grep -qF 'за 5 мин: 3 от 2 адрес(ов)' <<<"$txt"; then PASS=$((PASS+1)); echo " ok шапка: события и уникальные адреса за 5 мин" +else FAIL=$((FAIL+1)); echo " FAIL шапка без уникальных адресов за 5 мин"; fi +if grep -qF '2 активных локальных решений (с CAPI/списками: 35)' <<<"$txt"; then PASS=$((PASS+1)); echo " ok crowdsec: локальные и с CAPI раздельно" +else FAIL=$((FAIL+1)); echo " FAIL crowdsec: нет строки «2 активных локальных решений (с CAPI/списками: 35)»"; fi +if grep -qF 'ASN не обогащён: Team Cymru не ответил' <<<"$txt"; then PASS=$((PASS+1)); echo " ok ASN: «не ответил» отличим от «нет dig/whois»" +else FAIL=$((FAIL+1)); echo " FAIL ASN: нет пояснения про неответивший Cymru"; fi + +echo +echo " прогон: $PASS ok, $FAIL fail" +if [[ "$FAIL" -ne 0 ]]; then echo "NA-REPORT-UNIT: FAIL"; exit 1; fi +echo "NA-REPORT-UNIT: OK (CrowdSec по строкам raw, честные метрики 5 мин, asn_enrichment, JSON экранирован)" From 0285abb738c104f83877a47dc1c887714e77099b Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:05:52 +0300 Subject: [PATCH 05/14] =?UTF-8?q?v4.2:=20CI=20=E2=80=94=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B3=D0=B8=20optimize-unit=20=D0=B8=20na-report-unit;=20READM?= =?UTF-8?q?E=20=E2=80=94=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80;=20=D1=84=D1=80?= =?UTF-8?q?=D0=B0=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=20CHANGELOG=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20diag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 19 +++- CHANGELOG-v4.2-diag.md | 231 +++++++++++++++++++++++++++++++++++++++ README.md | 27 ++++- 3 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG-v4.2-diag.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b930087..5a23d87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,8 @@ jobs: # Юнит рапортовал success при неприменённом RPS: default route на буте появляется # позже network-online.target, скрипт молча выходил нулём (#30). Теперь NIC из # ExecStart, ожидание маршрута, фолбэк на физические, честный отказ; плюс #28 — - # в перенаправленном stdout нет escape-кодов курсора. + # в перенаправленном stdout нет escape-кодов курсора. v4.2: без RPS на 1 vCPU и + # при RX-очередей ≥ ядер, XPS только с NA_XPS=1, маски из снимка, venet0. run: bash tests/rps-unit.sh - name: psi unit (секция PSI optimize.sh + откат против стаба update-grub) # ENABLE_PSI=1 дописывает psi=1 в GRUB_CMDLINE_LINUX_DEFAULT идемпотентно, =0 не @@ -66,7 +67,23 @@ jobs: # считают адреса (#32); датчик CONN_LIMIT без loopback/whitelist (#31); дрейф # whitelist относительно protect.conf (#38); PSI три состояния (#37); серты в # /opt/*/certs и снятые с renew (#39); глубина журнала (#35); RPS-юнит vs факт (#30). + # v4.2: дельты счётчиков между запусками, steal окном 3 с, PSI avg60, ложные ▲ по + # аудиту флота, порты из живых правил, CrowdSec по источникам, allowlist CrowdSec, + # DNAT мимо na_filter, слушатели вне файрвола, просроченный серт, Caddy/nginx. run: bash tests/diagnose-unit.sh + - name: optimize unit (секции sysctl/journald/снимки/проверки XanMod + откат runtime против стабов) + # v4.2: чужой sysctl.d, перебивающий наш ключ, назван по имени; порты xray внутри + # эфемерного диапазона (вкл. 127.0.0.1:10085) резервируются без потери резерва + # оператора; без net.ipv6.conf.all.forwarding; journald 10-na-size.conf + + # persistent при volatile; откат возвращает sysctl/RPS/XPS/THP без ребута; Secure + # Boot и тесный /boot — без ядра, initrd и драйвер NIC — до «нужна перезагрузка»; + # default_iface после `dev` (venet0/multipath); apt ждёт чужой dpkg-лок. + run: bash tests/optimize-unit.sh + - name: na-report unit (CrowdSec по строкам raw, честные метрики 5 мин, asn_enrichment) + # v4.2: решения считались по полям "value" в -o json (×33 на ноде флота); + # ban_rate_5m был числом строк лога — рядом events_5m/unique_src_5m; флаг + # ok|partial|unavailable для ASN; управляющий символ из whois не ломает JSON. + run: bash tests/na-report-unit.sh - name: lint — никаких `cmd | grep -q` под pipefail (SIGPIPE → ложное «нет») # grep -q закрывает пайп на первом совпадении; дописывающая команда ловит SIGPIPE, # пайплайн под pipefail отдаёт 141. В v4.1.2 так через раз гас сенсор лога diff --git a/CHANGELOG-v4.2-diag.md b/CHANGELOG-v4.2-diag.md new file mode 100644 index 0000000..c405376 --- /dev/null +++ b/CHANGELOG-v4.2-diag.md @@ -0,0 +1,231 @@ + + +### 🟠 `na-diagnose` судил по накопленному с загрузки + +UDP `RcvbufErrors`, softnet drop/`time_squeeze`, `ListenOverflows` и CPU steal ядро копит с +момента загрузки. На ноде с аптаймом в месяц одна давняя вспышка горела ▲ вечно, а свежий +рост тонул в старом итоге: аудит флота нашёл ▲ «UDP RcvbufErrors» почти на каждой ноде — при +нулевом приросте за время наблюдения. Steal мерился одной секундой и между соседними +прогонами прыгал от 0 до 8%; PSI тревожил по `avg10`, то есть по любому десятисекундному +всплеску (сборка initrd, logrotate). + +Теперь каждый запуск оставляет снимок счётчиков `/var/lib/node-accelerator/diag-counters.last` +(с `boot_id`), а следующий считает прирост — если загрузка та же и прошло не меньше 30 с. +Окно короче — снимок не перезаписывается, чтобы частый опрос мониторинга всё-таки получил +окно. Это единственное, что diagnose пишет на диск; без прав на запись дельт просто нет. +▲ по UDP — только при росте за окно и доле от принятых датаграмм не меньше 0.1% (Udp6 из +`/proc/net/snmp6` учитывается); если на ноде нет публичного UDP-слушателя, текст говорит про +исходящие сокеты и `rmem_default`, а не про QUIC/Hysteria2. Steal — окно 3 с плюс среднее с +загрузки и с прошлого запуска: ✘ только при всплеске на фоне хронического отбора. PSI — по +`avg60`. + +### 🟠 Ложные ▲, которые приучали не читать отчёт + +- «Ядро не XanMod» при `ENABLE_XANMOD=0` в `optimize.conf` — теперь info: это выбор оператора. +- Уступка маски ротации чужой стансе, у которой кап по размеру есть, — ✔, а не ▲. +- Глубина журнала: volatile-журнал (только `/run/log/journal`) и журнал, который держит ровно + текущую загрузку (самая старая запись ≈ аптайму), получили свой текст — без совета снизить + `PORTSCAN_LOG_RATE`, к которому они отношения не имеют. +- Лог анти-скана оценивался порогом «>100000 строк за загрузку»: порог рос с аптаймом и молчал + после ребута. Теперь — строки за сутки по всем загрузкам и их доля в журнале за те же сутки + (▲ от 50% или от 20% при глубине журнала меньше 48 ч); не измерили — так и пишем. +- Буферы: порог `rmem_max`/`wmem_max` был фиксированными 32M, а optimize на RAM-tier 1 ставит + 16M — ▲ на каждой маленькой ноде. Порог теперь от tier (как в optimize); добавлена проверка + `rmem_default`/`wmem_default`. +- «RPS выключен» при RX-очередей не меньше, чем ядер, — info: приём размазывает RSS. +- `/run/reboot-required` от стоковых ядер при работающем XanMod — info, а не ▲. + +### 🟠 Порты для сенсоров брались из маркера, который устаревает + +`tcp_ports=` в `protect.installed` пишет только прогон `protect`; после ручной правки правил +или `protect.conf` маркер врал (аудит флота: порты «мимо маркера» на нескольких нодах), и +датчик `CONN_LIMIT` мерил не тот срез. Теперь порты берутся из живых правил (`cc4_<порт>`, +`udp4_<порт>`), при отсутствии таблицы — из `protect.conf` (понимаются обе идиомы: +`: "${K:=v}"` и `: "${K=v}"` из protect 4.2), и лишь затем из маркера. Расхождение живых +правил с `protect.conf` — ▲ с тем, что сделает ре-ран (какие порты закроет и откроет). + +### 🟠 CrowdSec: «1 решение» при 25 тысячах заблокированных адресов + +`cscli decisions list` без `-a` показывает только локальные решения, а bouncer режет и +community-блоклист: на ноде флота отчёт печатал «1» при ~25 тыс. адресов CAPI. `na-report` +считал строки `"value"` в `-o json` — у одного решения таких полей несколько, выходило ×33. +Теперь решения считаются по строкам `-o raw` с числовым id (заголовок и запятая в имени AS не +мешают), раздельно локальные и CAPI/списки. Diagnose показывает область действия +блок-листов из маркера (`crowdsec_scope`; без него при решениях CAPI — ▲ «режет все порты») +и источники логов из `acquis.yaml`/`acquis.d` (на флоте — только journal sshd). + +### 🟠 Whitelist сверялся в трёх слоях из четырёх + +Сверялись живой сет, `na_filter.nft` и `protect.conf`, а allowlist CrowdSec — нет, хотя его +bouncer стоит раньше `na_filter` (priority −10): адрес из whitelist, которого нет в allowlist, +CrowdSec может забанить (аудит флота: адрес узла управления был в whitelist, но не в +allowlist). Теперь берётся ЭФФЕКТИВНЫЙ allowlist — объединение ip/cidr всех +`/etc/crowdsec/parsers/s02-enrich/*.yaml` с секцией `whitelist` (CIDR учитывается), дрейф +показывается в обе стороны (▲ — только когда bouncer CrowdSec действительно стоит), а также +«в файле/conf есть, в памяти нет». + +### 🟠 Пробелы сенсоров + +- **Порты, опубликованные через DNAT** (Docker `-p`, ручные relay-DNAT): трафик идёт хуком + forward мимо input-цепочки `na_filter` — strict их не закрывает, autoban/блоклисты/лимиты не + действуют. Источники — `docker ps` и dnat-правила `nft -t list ruleset` (loopback мимо). + При `dnat_guard=1` в маркере (protect 4.2) — info, иначе ▲ с перечнем. +- **Публичные слушатели вне разрешённых портов** (strict — info «доступны только + whitelist/флоту»; `FW_MODE=open` — info, так задумано; без правил `na_filter` — ▲ «открыты + миру») и **разрешённые порты без слушателя** (info). +- **Память**: MemAvailable < 15% — ▲; занятый своп и balloon-драйвер (virtio/VMware) — info + (на ноде флота balloon гипервизора держал 2.5 ГБ, а MemTotal этого не показывал). +- **`nftables.service`** enabled/active при `flush ruleset` в `/etc/nftables.conf` — ▲ с + советом `systemctl disable` без stop. +- **Ручная правка `na_filter.nft`** (sha256 из маркера protect 4.2 ≠ файлу) — ▲: ре-ран её + перезапишет. Ненулевые именованные счётчики дропов `c_*` — в секции файрвола. +- **Порт xray в эфемерном диапазоне без резерва** — ▲ (см. optimize ниже). +- **Обновление XanMod** по кэшу apt (кандидат только из репозитория XanMod) — info. +- **Сертификаты**: добавлены хранилище Caddy (docker volume и пакетный) и `ssl_certificate` из + включённых конфигов nginx. Просроченный серт раньше маскировался под «не найден» (−1 значил + «нет», и −10 дней «перекрывал» следующий живой серт) — теперь отдельный флаг «найден» и ✘ при + ≤0 дней. +- **json-логи контейнеров без `max-size`** — ▲ (кап из `daemon.json` получают только + контейнеры, созданные после него). +- **OOM/lockup за сутки** — по всем загрузкам (`_TRANSPORT=kernel` = `-k` без неявного `-b`): + OOM, из-за которого нода перезагрузилась, был не виден именно тогда, когда важнее всего. + +Новые поля `--json` — только в хвосте объекта: `udp_rcvbuf_errors_delta`, +`softnet_drops_delta`, `time_squeeze_delta`, `listen_overflows_delta`, `delta_window_s` (−1 — +окна нет), `cpu_steal_boot_pct`, `crowdsec_decisions_local`, `crowdsec_decisions_capi`, +`crowdsec_scope`, `whitelist_drift_crowdsec`, `dnat_ports`, `dnat_guard` (−1 — ключа в +маркере нет), `xanmod_update`, `cert_found`, `portscan_log_lines_24h`, +`portscan_log_share_pct`. Смысл и порядок прежних полей не менялся (`cpu_steal_pct` теперь +мерится окном 3 с, `cert_min_days` при `cert_found=true` может быть ≤0). + +### 🟠 `na-report`: метрики, которые значили не то, что написано + +`ban_rate_5m` всегда был числом строк drop-лога за 5 минут, а не банов. Поле оставлено ради +совместимости, рядом — честные `events_5m` и `unique_src_5m`. `drops_by_reason.crowdsec` — +активные локальные решения (см. выше), `crowdsec_decisions_all` — вместе с CAPI/списками. +Новое `asn_enrichment` = `ok|partial|unavailable`: пустой ASN больше не путается с «не +спросили». Строки JSON экранируются общим `json_escape`: управляющий символ в имени AS из +whois раньше ломал весь документ. Новые поля — в хвосте. + +### 🟠 `optimize`: чужой sysctl-файл молча перебивал наши значения + +Файл, сортирующийся позже `99-node-accelerator.conf`, перебивает ключи: на ноде флота так +урезались буферы сокетов до 16M, а optimize печатал «применено». Теперь после `sysctl +--system` значения наших файлов сверяются с ядром; при расхождении называется файл-виновник +(порядок — `systemd-analyze cat-config sysctl.d`, без него — тот же перебор каталогов по имени, +что у systemd-sysctl, с `/etc/sysctl.conf` последним), а ключи, которые ядро записать не дало +(контейнер), — отдельной строкой. Имя нашего файла не менялось. + +### 🟠 Порты ноды внутри `ip_local_port_range` + +optimize ставит `ip_local_port_range = 10000 65535`, и в него попадают порты самой ноды — API +xray на `127.0.0.1:10085`, высокие inbound'ы. Исходящее соединение может занять такой порт +раньше, чем xray его забиндит (рестарт контейнера под нагрузкой). На флоте их резервировали +руками. Теперь LISTEN-порты процессов xray/rw-core/rw-node (включая loopback) и порт +node-агента, попадающие в диапазон, пишутся в `net.ipv4.ip_local_reserved_ports` вместе с +резервом оператора; свои порты помнятся в `reserved-ports.na`, и порт, с которого xray ушёл, +на ре-ране снимается. xray ещё не запущен — нечего резервировать: ре-ран optimize после старта +ноды (diagnose подскажет). + +### 🟠 RPS/XPS там, где они вредят + +RPS ставился на все CPU даже на 1 vCPU и на сетевой карте с RX-очередями не меньше числа +ядер (на ноде флота — 12 аппаратных очередей): аппаратный RSS уже раскидывает приём, а RPS +поверх только гоняет пакеты между CPU. XPS-маска «все CPU» затирала раскладку TX-очередей, +которую задаёт драйвер. Теперь `na-rps-setup` в этих случаях RPS не включает (и снимает маски +прошлых версий — к снимку или к 0), XPS трогает только с `NA_XPS=1` (ручка сохраняется в +`optimize.conf`). Исходные маски снимаются при первом применении в `rps-orig.tsv` и +возвращаются при откате. + +### 🟠 journald: наш drop-in перебивал операторский + +`na-size.conf` сортировался после `99-*.conf` и молча перебивал операторский +`SystemMaxUse`. Теперь drop-in — `10-na-size.conf` (старое имя снимается при апгрейде и +откате). Журнал в RAM (volatile) не переживает ребут — история входов и инцидентов +пропадает; если `Storage=` никто не задал явно, optimize ставит `Storage=persistent`, создаёт +`/var/log/journal` и сразу переносит накопленное (`journalctl --flush`). Осознанный +`Storage=volatile` оператора не трогается. + +### 🟠 `net.ipv6.conf.all.forwarding=1` убран + +Включённый v6-форвардинг переводит ядро в режим роутера: при `accept_ra=1` оно перестаёт +принимать Router Advertisement, и на хостах с ядерным SLAAC v6 default route истекает через +router lifetime — нода молча теряет IPv6. Выбран вариант «убрать», а не «`accept_ra=2` на +WAN»: xray в host-network форвардинг не нужен вовсе; Docker с включённым IPv6 сам ставит +v6-форвардинг при старте демона; `accept_ra=2` пришлось бы писать по имени интерфейса +(меняется после смены ядра), а на хостах с systemd-networkd RA обрабатывает networkd и +`accept_ra` ядра ни на что не влияет. На уже настроенных нодах runtime-значение не +сбрасывается на ходу (Docker с IPv6 мог на него опереться) — оно уйдёт с ближайшим ребутом. +IPv4-форвардинг (Docker bridge) остаётся. + +### 🟠 `rollback optimize` возвращал runtime только после ребута + +Удаление файлов не возвращает значения: `sysctl --system` перечитывает лишь чужие файлы, а +ключи, которых больше никто не задаёт, держат наши значения до перезагрузки; маски очередей и +THP — тоже. Теперь при ПЕРВОМ применении снимаются исходные значения ключей наших +sysctl-файлов (`sysctl.orig`), маски RPS/XPS и режим THP; откат возвращает их сразу (затем +`sysctl --system`, так что чужие файлы по-прежнему главнее снимка) и честно пишет, что +вернулось, а что — нет (ноды, где optimize ставился до v4.2, снимка не имеют: RPS +выключается, остальное — до ребута). + +### 🟠 Ядро XanMod: проверки до и после установки + +XanMod не подписан ключом дистрибутива: при включённом UEFI Secure Boot shim его не загрузит, +и после ребута нода не поднимется. Тесный `/boot` — та же беда тише: postinst не дописывает +initrd, а GRUB уже выбрал новое ядро. Теперь установка ядра пропускается (warn, остальной +тюнинг продолжается) при включённом Secure Boot (`mokutil --sb-state` или efivar +`SecureBoot-8be4df61-…`) и при свободном месте в `/boot` меньше 200 МБ (`NA_BOOT_MIN_MB`). +После установки, до слов «нужна перезагрузка», проверяется, что у нового ядра есть initrd +(одна попытка пересобрать) и драйвер текущего NIC в `/lib/modules/<версия>` — модулем или +встроенным; иначе громкое «НЕ ПЕРЕЗАГРУЖАЙСЯ» с причиной. + +### 🟡 Мелкое + +- **`default_iface`** брал пятое поле маршрута: для `default dev venet0 scope link` (OpenVZ) + выходило `scope`, для multipath — мусор, и это имя шло в RPS, NIC-tune и diagnose. Теперь — + токен после `dev` (и в `common.sh`, и в генерируемом `na-rps-setup`). +- **`apt_install`** не ждал чужой dpkg-лок: на свежем боксе с работающим unattended-upgrades + обе попытки падали подряд и роняли optimize/protect под `set -e`. Теперь + `-o DPkg::Lock::Timeout=120` (`NA_APT_LOCK_TIMEOUT`). + +### 🧪 Тесты + +- `tests/diagnose-unit.sh`: +12 разделов — дельты счётчиков (рост/нет роста, окно <30 с не + затирает снимок, другая загрузка), steal окном и с загрузки, PSI avg60, ложные ▲ (XanMod + выключен, кап у чужой стансы, volatile-журнал, журнал ≈ аптайму, доля лога анти-скана, «не + измерено», OOM прошлой загрузки), порты из живых правил и обе идиомы conf, CrowdSec по + источникам/scope/acquisition, allowlist CrowdSec (CIDR, ручной файл, парсер без whitelist), + DNAT и слушатели, память/balloon, `nftables.service`, хэш файла, счётчики `c_*`, буферы от + tier, порт xray без резерва, обновление XanMod, reboot-required, просроченный серт, Caddy, + nginx (sites-available не считается), docker без `max-size`, RSS; новые поля строго в хвосте + JSON. Проверка текста теперь без цветовых escape-кодов — значок ✔/▲/✘ реально сверяется. +- `tests/optimize-unit.sh` (новый): секция sysctl против эмулятора `sysctl --system` — + виновник назван (и через `systemd-analyze cat-config`), незаписанный ключ, резерв портов с + сохранением резерва оператора и снятием ушедшего порта, снимок только при первом прогоне, + без v6-форвардинга; journald (`10-na-size.conf`, старое имя снято, persistent, явный + `Storage=` оператора); снимки RPS/THP (в т.ч. пустая маска последней очереди не выбрасывает + снимок); блок отката runtime со снимками и без; проверки XanMod (Secure Boot через mokutil и + efivar, `/boot`, версия ядра из Depends, initrd и его пересборка, драйвер NIC модулем и + встроенный); `default_iface` (venet0, multipath) и `apt_install` (таймаут лока во всех + вызовах, в т.ч. в ретрае). +- `tests/na-report-unit.sh` (новый): CrowdSec по строкам raw (не ×N), `-a`, `events_5m` / + `unique_src_5m`, `asn_enrichment` ok/partial/unavailable, управляющий символ из whois не ломает + JSON, новые поля в хвосте. +- `tests/rps-unit.sh`: XPS не трогается без `NA_XPS=1`, RPS снимается при RX-очередей ≥ ядер и + на 1 vCPU (к снимку или к 0), `Environment=NA_XPS` в юните, `default dev venet0`. + +Каждый новый и расширенный тест падает на v4.1.3 (diagnose-unit — 90 проверок, +optimize-unit — 59, na-report-unit — 14, rps-unit — 13) и проходит на этой ветке. + +### Миграция + +- Ре-ран optimize на уже настроенной ноде: снимки для отката не создаются (в ядре уже наши + значения) — откат на таких нодах честно говорит, что вернётся после ребута. XPS-маска прошлых + версий без снимка остаётся до ребута, после него — раскладка драйвера. На нодах с RSS + (очередей ≥ ядер) RPS выключается сразу. v6-форвардинг уходит с ребутом. +- `na-diagnose` создаёт `diag-counters.last`; первый запуск после обновления (и после каждого + ребута) дельт не даёт (`delta_window_s=-1`). +- В `optimize.conf` появляется ключ `NA_XPS`; `optimize.installed` — `tier`, `sock_max`, + `sock_def` (по ним diagnose берёт пороги буферов). diff --git a/README.md b/README.md index 1444f62..e6652a2 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,26 @@ - **sysctl (tier-aware)**: BBR + `fq`, буферы/`somaxconn`/conntrack **масштабируются от RAM** (TIER 1–4: мелкая VPS не уходит в OOM, крупная получает полный размер), syncookies, anti-spoof (`rp_filter=2`), `netdev_budget` под высокий PPS, пассивный `tcp_ecn=2`. -- **RPS/RFS/XPS** — раскидывает обработку пакетов по всем ядрам. На virtio/single-queue VPS - иначе весь RX-softirq висит на cpu0 — это и есть реальный потолок PPS. +- **RPS/RFS** — раскидывает обработку пакетов по всем ядрам. На virtio/single-queue VPS + иначе весь RX-softirq висит на cpu0 — это и есть реальный потолок PPS. На 1 vCPU и когда + RX-очередей не меньше, чем ядер (аппаратный RSS), RPS не включается; XPS (раскладку + TX-очередей задаёт драйвер) — только с `NA_XPS=1`. +- **Сверка sysctl**: после применения значения наших файлов сверяются с ядром; если ключ + перебивает чужой файл, сортирующийся позже (`99-…` после `99-node-accelerator.conf`), + optimize называет его, а не печатает «применено». +- **Порты ноды вне эфемерного диапазона**: LISTEN-порты xray/rw-core/rw-node (включая + `127.0.0.1:10085`) внутри `ip_local_port_range` пишутся в `ip_local_reserved_ports` — + резерв оператора сохраняется. xray ещё не запущен — ре-ран optimize после старта ноды. +- **Ядро XanMod — с проверками**: при включённом UEFI Secure Boot и при свободном месте в + `/boot` < 200 МБ ядро не ставится (остальной тюнинг продолжается); после установки — + проверка initrd и драйвера текущего NIC в новом ядре до слов «нужна перезагрузка». - **zram-swap** на мелких нодах (tier 1/2), иначе `/swapfile`; **MSS clamp к PMTU** (opt-in, для routed/WireGuard); **`tcp_min_snd_mss`-пол** от MSS-коллапса на туннелях. - **nofile/nproc → 1 048 576**, journald-cap, THP=never, governor=performance, NIC tune, irqbalance. + Drop-in journald — `10-na-size.conf`: операторский `99-*.conf` главнее; журнал в RAM + (volatile) переводится в `Storage=persistent`, если `Storage=` никто не задал явно. +- **Откат без ребута**: при первом прогоне снимаются исходные значения sysctl, маски + RPS/XPS и режим THP; `rollback optimize` возвращает их сразу. - **Ротация логов ноды + часовой таймер.** journald-cap держит только журнал systemd, а `access.log` от nginx и ядра ноды растёт в /var/log без предела — на боевой ноде это сотни МБ в сутки, и диск уходит в 100% за недели (тихо: контейнеры перестают писать логи, `acme.sh` не может обновить @@ -88,10 +103,14 @@ - **Персист конфига** — ре-ран без ENV не сбрасывает поднятые под ноду ручки. ### 🩺 Диагностика (`scripts/diagnose.sh`) -Read-only отчёт: ядро/BBR, sysctl, лимиты, conntrack, NIC/RPS, swap/THP/governor, firewall, CrowdSec, порты, RTT — с итогом ✔/▲/✘ и рекомендациями. Плюс сенсоры **стека ноды**: контейнер `remnanode` (статус/рестарты/`SPAWN_ERROR` за час — ловит коллизию node-address в панели), **рассинхрон порта node-агента с файрволом** (агент слушает `:3000`, а strict-правила держат `:2222` → «нода недоступна» для панели; в отчёте — с командой пожарного фикса, в `--json` — `node_port_detected`/`node_port_fw`), **сроки TLS-сертификатов** (LE/acme.sh/`/opt/*/certs`; серты, снятые с renew в acme.sh, не считаются; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`), **whitelist в трёх местах** (живой сет vs `na_filter.nft` vs `protect.conf`: адрес, который переживёт ребут, но не ре-ран `protect`, называется по имени), **датчик `CONN_LIMIT` по тому же срезу, что и правило** (только входящие на сервисные порты, без loopback/whitelist — раньше на ноде с внутренним nginx он горел всегда), **PSI** (различает «не собран», «выключен в сборке ядра» и «работает»), **ретеншен journald в часах**, **замороженные дефолты в сохранённом конфиге**, **свежесть fleet-sync/blocklist**, IPv6 default-route, UDP `RcvbufErrors` (QUIC/Hysteria2), **заполненность наборов-лимитеров** (и есть ли у них timeout), **ожидание перезагрузки по факту загрузки** (`reboot_needed` сверяется с `boot_id`, а не верит вечному маркеру). После установки доступна как команда **`na-diagnose`** (`--json` для мониторинга/панели — теперь с `na_version`, `hostname`, `uptime_s`, `load1`, `mem_used_pct`, WAN rx/tx-байтами; `--retrans [--window N]` — разбор причин TCP-retransmits). +Read-only отчёт: ядро/BBR, sysctl, лимиты, conntrack, NIC/RPS, swap/THP/governor, firewall, CrowdSec, порты, RTT — с итогом ✔/▲/✘ и рекомендациями. Плюс сенсоры **стека ноды**: контейнер `remnanode` (статус/рестарты/`SPAWN_ERROR` за час — ловит коллизию node-address в панели), **рассинхрон порта node-агента с файрволом** (агент слушает `:3000`, а strict-правила держат `:2222` → «нода недоступна» для панели; в отчёте — с командой пожарного фикса, в `--json` — `node_port_detected`/`node_port_fw`), **сроки TLS-сертификатов** (LE/acme.sh/`/opt/*/certs`; серты, снятые с renew в acme.sh, не считаются; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`), **whitelist в трёх местах** (живой сет vs `na_filter.nft` vs `protect.conf`: адрес, который переживёт ребут, но не ре-ран `protect`, называется по имени), **датчик `CONN_LIMIT` по тому же срезу, что и правило** (только входящие на сервисные порты, без loopback/whitelist — раньше на ноде с внутренним nginx он горел всегда), **PSI** (различает «не собран», «выключен в сборке ядра» и «работает»), **ретеншен journald в часах**, **замороженные дефолты в сохранённом конфиге**, **свежесть fleet-sync/blocklist**, IPv6 default-route, UDP `RcvbufErrors` (QUIC/Hysteria2), **заполненность наборов-лимитеров** (и есть ли у них timeout), **ожидание перезагрузки по факту загрузки** (`reboot_needed` сверяется с `boot_id`, а не верит вечному маркеру). + +С v4.2 накопительные счётчики (UDP `RcvbufErrors` v4+v6, softnet drop/`time_squeeze`, `ListenOverflows`, steal) оцениваются **по приросту с прошлого запуска**, а не «с загрузки»: каждый запуск оставляет снимок `/var/lib/node-accelerator/diag-counters.last` (единственное, что diagnose пишет; окно ≥30 с, после ребута — заново). CPU steal — окно 3 с плюс среднее с загрузки, PSI тревожит по `avg60`. Порты для сенсоров берутся из **живых правил** (`protect.conf` и маркер — запасные источники), расхождение правил с `protect.conf` называется вместе с тем, что изменит ре-ран. Новые сенсоры: **CrowdSec по источникам решений** (локальные vs CAPI/списки, область действия блок-листов, откуда читаются логи), **whitelist в четырёх слоях** (живой сет, `na_filter.nft`, `protect.conf`, эффективный allowlist CrowdSec из всех `s02-enrich/*.yaml`; дрейф в обе стороны), **порты, опубликованные через DNAT** (Docker `-p`, ручные relay) мимо `na_filter`, **публичные слушатели вне разрешённых портов** и разрешённые порты без слушателя, **MemAvailable/своп/balloon**, `nftables.service` с `flush ruleset`, ручная правка `na_filter.nft` (хэш из маркера), именованные счётчики дропов, порт xray в эфемерном диапазоне без резерва, доступное обновление XanMod, `/run/reboot-required` от стоковых ядер (info, а не ▲), json-логи контейнеров без `max-size`, OOM за сутки по всем загрузкам. Серты ищутся и в хранилище **Caddy** (docker volume и пакетный), и по `ssl_certificate` из конфигов nginx; **просроченный серт — ✘**, а не «не найден». Убраны ложные ▲: «ядро не XanMod» при `ENABLE_XANMOD=0`, уступка ротации стансе с капом, буферы ниже 32M на маленьком RAM-tier, «RPS выключен» при RX-очередей ≥ ядер, глубина volatile-журнала и журнала ≈ аптайму; лог анти-скана оценивается долей журнала за сутки. Новые поля `--json` — только в хвосте объекта: `udp_rcvbuf_errors_delta`, `softnet_drops_delta`, `time_squeeze_delta`, `listen_overflows_delta`, `delta_window_s` (−1 — окна ещё нет), `cpu_steal_boot_pct`, `crowdsec_decisions_local`, `crowdsec_decisions_capi`, `crowdsec_scope`, `whitelist_drift_crowdsec`, `dnat_ports`, `dnat_guard`, `xanmod_update`, `cert_found`, `portscan_log_lines_24h`, `portscan_log_share_pct`. + +После установки доступна как команда **`na-diagnose`** (`--json` для мониторинга/панели — теперь с `na_version`, `hostname`, `uptime_s`, `load1`, `mem_used_pct`, WAN rx/tx-байтами; `--retrans [--window N]` — разбор причин TCP-retransmits). ### 🔥 Форензика атак (`scripts/na-report.sh`) -Read-only: кто/откуда/чем/когда — из журнала ядра, nft-сетов и CrowdSec. **`na-report`** (человекочитаемо) или **`na-report --json`**: `drops_by_reason`, `timeline`, `top_ips` с вердиктом, `top_asn` (ASN/гео — best-effort через Team Cymru whois). Флаги: `--hours N`, `--top N`, `--ip `. Журнал читается по всем загрузкам (`-b all`), а не с момента ребута; в шапке — фактическая глубина журнала (`journal_span_h`), чтобы «событий мало» не путать с «журнал вытеснен». +Read-only: кто/откуда/чем/когда — из журнала ядра, nft-сетов и CrowdSec. **`na-report`** (человекочитаемо) или **`na-report --json`**: `drops_by_reason`, `timeline`, `top_ips` с вердиктом, `top_asn` (ASN/гео — best-effort через Team Cymru whois). Флаги: `--hours N`, `--top N`, `--ip `. Журнал читается по всем загрузкам (`-b all`), а не с момента ребута; в шапке — фактическая глубина журнала (`journal_span_h`), чтобы «событий мало» не путать с «журнал вытеснен». `drops_by_reason.crowdsec` — активные локальные решения CrowdSec, `crowdsec_decisions_all` — вместе с CAPI/списками; `ban_rate_5m` (исторически — число событий за 5 мин) сохранён, честные имена — `events_5m` и `unique_src_5m`; `asn_enrichment` = `ok|partial|unavailable`. --- From 52f3bbf35ff8ec563c26ccd87083e52369e10e27 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:06:14 +0300 Subject: [PATCH 06/14] =?UTF-8?q?v4.2=20(protect):=20SYNPROXY=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20nft=5Fsyn?= =?UTF-8?q?proxy=20(=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20nf=5Fsynproxy?= =?UTF-8?q?=20=D0=B2=20=D1=81=D0=BE=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=8F=D0=B4=D1=80=D0=B0=D1=85=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=82),=20README/CHANGELOG=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D0=B9=20=D1=87=D0=B0=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-v4.2-protect.md | 140 ++++++++++++++++++++++++++++++++++++++ README.md | 61 +++++++++++++---- scripts/protect.sh | 17 +++-- tests/nft-behavior.sh | 6 +- 4 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 CHANGELOG-v4.2-protect.md diff --git a/CHANGELOG-v4.2-protect.md b/CHANGELOG-v4.2-protect.md new file mode 100644 index 0000000..9a81972 --- /dev/null +++ b/CHANGELOG-v4.2-protect.md @@ -0,0 +1,140 @@ +### 🔴 Опубликованные Docker-порты шли мимо na_filter + +Трафик к контейнеру с `-p` (bridge-сеть) после DNAT идёт через hook forward, а не input, а +цепочка forward в `na_filter` была пустой. На такие порты не действовали ни autoban, ни +блок-листы, ни анти-спуф, и strict их не закрывал. Аудит флота: Caddy панелей и второй тенант +на нодах (свой контейнер с опубликованными портами) жили целиком мимо защиты; `na-diagnose` +этого не показывал. + +Теперь (`DNAT_GUARD=1`, дефолт) новые DNAT-соединения проходят цепочку `dnat_guard` с теми же +проверками источника, что в input: whitelist и ноды флота — пропуск, autoban, блок-листы и +bogon на WAN — drop, со счётчиком `c_dnat_guard`. Лимиты и список портов контейнеров не +трогаются: у контейнеров свои потребители (CDN, второй тенант), и решать за них о порогах +тулкит не должен. `DNAT_GUARD=0` — прежнее поведение. + +### 🟠 CrowdSec резал мобильных абонентов на всех портах + +Community-лист CAPI (~25 тыс. адресов) bouncer применял ко всем портам. CGNAT-адрес +мобильного оператора, попавший в лист за чужой перебор, отрезал от VPN всех абонентов за ним +(аудит флота: сотни тысяч дропов клиентского трафика на ноде; на части флота это уже сузили +руками до SSH). При этом CrowdSec на ноде разбирает только журнал sshd — его собственные +решения и так касаются только перебора SSH. + +Новая ручка `CROWDSEC_SCOPE`: `ssh` (дефолт) — bouncer переводится в `set-only` и только +наполняет наборы, а правило, которое по ним дропает, ставит тулкит — на SSH-порт(ы), с +исключением для whitelist (whitelist парсера CrowdSec от решений CAPI не защищает: адрес +админа в community-листе отрезал бы ему SSH). Правила грузит `na-crowdsec-scope.service` до +bouncer'а; ручной юнит, сужавший CrowdSec до SSH, выключается из автозагрузки (его файл +остаётся). `all` — как раньше, на всех портах. Старый bouncer без `set-only` — честный откат +на `all` с предупреждением. `rollback protect` возвращает bouncer к своим правилам. + +### 🟠 Лог анти-скана занимал 70–99% журнала + +Лог-правило анти-скана стояло первым и писало каждый новый SYN в закрытый порт. Интернет-шум +(одиночные стуки в 23/445/3389) давал 70–99% всех строк журнала на нодах флота, а настоящие +сканеры среди них терялись. Теперь `[na portscan]` пишется только на переходе адреса — один +раз при пометке suspect и один раз при бане (цепочки `ps_suspect*`/`ps_ban*`). +`PORTSCAN_LOG_RATE` остаётся общим потолком этих строк. Объём отброшенного виден в +именованных счётчиках. + +### 🟠 Именованные счётчики отброшенного + +У всех drop-правил теперь именованные счётчики: `c_synflood c_udpflood c_udp_amp c_portscan +c_autoban c_blocklist c_bogon c_badflags c_nodeport c_catchall c_dnat_guard c_sshflood`. +`na-fw-status` показывает их, `na-diagnose` читает. Объём атаки виден в цифрах, а не в строках +лога, который на насыщении молчит. + +### 🟠 SSH ban-once банил с первого превышения + +Клиент, которому дропнули SYN, сам пересылает его через ~1 с. Под ban-once такой ретрансмит +от уже-suspect адреса сразу давал бан на `SSH_BAN_TIME`. CI или Ansible, открывшие на одно +соединение больше лимита, получали суточный бан. Теперь suspect банится, только когда его +пакеты сверх лимита сами превышают втрое больший burst (`sshs4/6`), то есть когда перебор +продолжается. + +### 🟠 SYNPROXY не завершал рукопожатия + +Правило synproxy стояло после `ct state invalid drop`, а `nf_conntrack_tcp_loose` не +выставлялся в 0. При `loose=1` третий ACK рукопожатия conntrack принимал за новое соединение, +он шёл мимо synproxy в сокет и получал RST. При `loose=0` тот же ACK съедал invalid-drop. +Итог: с `ENABLE_SYNPROXY=1` новые TCP на `TCP_PORTS` не устанавливались вовсе. А до этого +дело и не доходило: доступность проверялась `modprobe nf_synproxy`, но модуля с таким именем +в современных ядрах нет (есть `nf_synproxy_core` и `nft_synproxy`). На всех ядрах флота +(сток 6.12, XanMod 6.18) режим уходил в degraded с ✘ в диагностике. Теперь проверяется +`nft_synproxy`, правило стоит до invalid-drop, забаненные режутся раньше него, `nf_conntrack_tcp_loose=0` ставится +через `/etc/sysctl.d/99-na-synproxy.conf` (и снимается при выключении и в rollback). + +### 🟡 Whitelist и разбан одной командой: `na-fw` + +Whitelist живёт в четырёх местах: живой сет, `na_filter.nft`, `WHITELIST=` в `protect.conf`, +CrowdSec-yaml. Штатного способа поменять его, кроме полного ре-рана, не было, поэтому на флоте +его правили `sed`'ом по отдельности (сотни `*.bak-*`), слои расходились, а ре-ран молча +возвращал старое. + +`na-fw allow add|del|list ` меняет все слои разом. Файл перед заменой проверяется +`nft -c`. Обновляются правило CROWDSEC_SCOPE и хэш в маркере, добавленный адрес снимается из +autoban. `na-fw unban ` снимает autoban/suspect и решения CrowdSec. + +### 🟡 Локальные правила оператора переживают ре-ран + +Ручные правки `na_filter.nft` (свой сет фронт-прокси, лишний порт) любой ре-ран молча стирал. +Теперь свои фрагменты кладутся в `/etc/node-accelerator/na_filter.d/table/*.nft` (на уровне +таблицы) и `…/input/*.nft` (в input сразу после whitelist). Они вклеиваются в каждую +генерацию и проверяются общим `nft -c`: битый фрагмент — ничего не применено. В маркере — +`nft_sha256` файла: `na-diagnose` видит ручную правку. + +### 🟡 Баны переживают ре-ран + +Ре-ран пересоздаёт таблицу целиком, и до v4.2 вместе с ней обнулялись autoban и suspect: +сканер, забаненный на час, возвращался сразу после любого ре-рана. Теперь записи с оставшимся +сроком переносятся в новую таблицу. + +### 🟡 Конфиг: `none`, SSH_PORT без вечного автодетекта + +- **Явное «пусто» — слово `none`.** Оно действует для `WHITELIST`, `TCP_PORTS`, `UDP_PORTS`, + `UDP_BULK_PORTS`, `NODE_PORT_PEERS` и `NODE_PORT=none` (бокс без агента: фолбэк `2222,3000` + открывал миру два порта, которые никто не слушает). Пустой ENV по-прежнему значит «как в + conf»: обёртка с незаданной переменной не должна молча стереть whitelist. Слово `none` + сохраняется в conf как есть, поэтому встроенный дефолт не возвращается. +- **SSH_PORT из автодетекта больше не персистится.** Раньше он перебивал свежий детект на + ре-ране: сменил порт sshd — strict открывал старый. Порт, сохранённый старой версией без + пометки `explicit`, сверяется с фактом: если расходится, открываются оба порта и выводится + предупреждение. + +### 🟡 Детект SSH и node-агента + +- **SSH.** Ветка `ssh.socket` была мёртвой: читалось несуществующее свойство `ListenStream` + вместо `Listen`. Источники теперь отдают все порты, а не первый. +- **Сессия под `sudo`.** `sudo` вычищает `SSH_CONNECTION`, а README сам предлагает `sudo + bash …`. Раньше в этом случае не было ни авто-whitelist IP админа, ни защиты порта сессии, и + никто об этом не узнавал. Теперь `SSH_CONNECTION` ищется у предков процесса; не нашёлся — + громкое предупреждение. +- **node-агент.** Порядок источников: слушатель `rw-node` → контейнеры `remnawave/node` с + host-сетью, образ по `.Config.Image` (при закреплении по digest `docker ps` показывал ID, и + свой агент не находился) → `.env`. Bridge-контейнер второго тенанта больше не выдаётся за + порт этой ноды. Несколько агентов с host-сетью — правила на все и предупреждение. + +### 🟡 Мелкое + +- **ctguard.** `ENABLE_CTGUARD=0` на ре-ране теперь снимает таймер, таблицу `na_ctguard` и + конфиг (раньше эвикт в `ENFORCE` продолжался). DNAT-клиенты больше не считаются фантомами: + их сокеты в netns контейнера, ss хоста их не видит. +- **`fleet.env`.** Файл подключается через `.` от root. Токены теперь проверяются по набору + символов, значения пишутся экранированными (`%q`): раньше токен `abc;cmd` исполнял `cmd`. +- **Блок-листы.** Префиксы шире `/8` (v6 — `/19`) отбрасываются: одна строка `x.x.x.x/0` в + `custom-blocklist.txt` блокировала бы весь интернет. +- **Анти-амплификация.** `UDP_AMP_DROP=1`: новые UDP на сервисные порты с исходных портов + отражателей режутся до per-IP лимитов. +- **DHCPv6-ответы** пропускаются (раньше strict их отрезал). +- **`::` убран из `bogon_v6`.** Он ломал DAD. +- **`na-firewall.service`** поднимает правила до `network-pre.target` + (`DefaultDependencies=no`), как дистрибутивный `nftables.service`. Раньше было окно, когда + сеть уже поднята, а правил ещё нет. +- **Сейфти и подтверждение.** + - CrowdSec ставится до взвода сейфти: его установка больше не съедает окно. + - Ответ «y» проверяет, что таблица на месте. + - `SAFETY_DELAY` — минимум 30 с. +- **Подсказка в режиме skip** больше не советует `nft list ruleset > /etc/nftables.conf`. +- **`na-fw-top-talkers`** снова показывает адреса: колонку пира он брал не ту. +- **Новые ключи маркера:** `crowdsec_scope`, `dnat_guard`, `nft_sha256`, `include_dir`, + `include_files`, `node_port=none`. diff --git a/README.md b/README.md index 1444f62..12e0926 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,22 @@ - **AntiScan** — SYN на несервисный порт → автобан. С **ban-once** (дефолт): 1-й быстрый скан → `suspect` (наблюдение), 2-й в окне → бан. Снимает ложные баны CGNAT-операторов. (В `FW_MODE=open` не ставится: «закрытых» портов нет — банил бы легитимные inbound'ы.) + В журнал `[na portscan]` пишется **только переход адреса** в suspect/бан, а не каждый стук в + закрытый порт (до v4.2 интернет-шум давал 70–99% строк журнала); объёмы — в счётчиках `c_*`. - **flag-drop** — XMAS, NULL, SYN+FIN, SYN+RST, FIN+RST и прочие скан-пакеты. - **anti-spoofing** — bogon/RFC1918/CGNAT источники на WAN (v4 **и** v6 bogon). - **SYN-flood / UDP-flood** — **per-IP** rate-limit (масштабируется по числу клиентов, а не глобальный потолок). Состояние лимитов — в наборах с `timeout` (запись живёт, пока адрес активен), правило «превысил → drop»: переполненный спуф-флудом набор отключает лимит для новых адресов, а не отрезает их. -- **connect-flood SSH** — >6 новых/мин с IP → бан (с ban-once). +- **connect-flood SSH** — >6 новых/мин с IP → `suspect`; бан — только если перебор + **продолжается** (втрое больший burst в окне suspect), а не за ретрансмит одного SYN. + Переполненный набор SSH не открывает дверь: новые адреса идут под общий потолок 30/мин. - **per-IP connlimit** (`ct count`) — кап одновременных коннектов с одного адреса. -- **node-agent порт** — **автодетект с ноды** (`NODE_PORT=auto`, дефолт: env контейнера - `remnawave/node` → `.env` compose → `ss`; не нашлось — правила на оба известных дефолта - `2222,3000`); whitelist-only при заданном `WHITELIST` (контрол-порт не светится в мир). +- **node-agent порт** — **автодетект с ноды** (`NODE_PORT=auto`, дефолт: слушатель `rw-node` + → контейнеры `remnawave/node` с **host-сетью** (образ по `.Config.Image`, работает и с + закреплением по digest; bridge-контейнер второго тенанта не в счёт) → `.env` compose; не + нашлось — правила на оба известных дефолта `2222,3000`; бокс без агента — `NODE_PORT=none`); + whitelist-only при заданном `WHITELIST` (контрол-порт не светится в мир). Явный `NODE_PORT`, расходящийся с фактом, открывает **оба** порта + громкий warn — панель не теряет ноду молча при миграции агента `2222→3000`. При whitelist-only established-пиры порта (= панель) пускаются отдельным сетом `na_nodeport_wl_*` — анти-самоотстрел, если IP @@ -66,12 +72,24 @@ - **conntrack phantom-eviction** *(opt-in)* — защита от **distributed connect-and-hold** по живым сокетам (`conntrack ≫ ss`), CGNAT-safe, observe-режим по умолчанию. - **SYNPROXY** *(opt-in, done-right)* — `notrack` только host-local (`fib daddr type local`, - не ломает Docker/транзит), verify ядра/модуля, fail-loud при недоступности. На VPN-relay - обычно избыточен (syncookies + per-IP ct-лимиты уже дают анти-спуф) — **default off**. + не ломает Docker/транзит), правило до `ct state invalid drop` + `nf_conntrack_tcp_loose=0` + (иначе третий ACK рукопожатия не доходил до synproxy), verify ядра/модуля, fail-loud при + недоступности. На VPN-relay обычно избыточен (syncookies + per-IP ct-лимиты уже дают + анти-спуф) — **default off**. Опубликованные Docker-порты в `TCP_PORTS` с ним не сочетать. +- **Опубликованные Docker-порты** (`-p`, bridge, DNAT) — трафик к ним идёт через hook forward, + мимо цепочки input. С v4.2 (`DNAT_GUARD=1`) новые DNAT-соединения проходят те же проверки + источника: whitelist → пропуск, autoban/блок-листы/bogon → drop. Лимиты и список портов + контейнеров не трогаются — у них свои потребители. +- **Анти-амплификация** (`UDP_AMP_DROP=1`) — новые UDP на сервисные порты с исходных портов + отражателей (DNS, NTP, SSDP, memcached, CLDAP) режутся до per-IP лимитов. - **статич-блоклисты** *(opt-in)* — Spamhaus DROP + FireHOL L1 (+ Tor), bogon-фильтр, таймер. - **Remnawave fleet auto-sync** *(opt-in)* — ноды флота сами держат IP друг друга в whitelist (с панели). - **ICMP rate-limit** (пинг жив, флуд режется). - **CrowdSec + `crowdsec-firewall-bouncer-nftables`** — поведенческий IPS + community-блоклист. + С v4.2 блок-листы по умолчанию действуют **только на SSH** (`CROWDSEC_SCOPE=ssh`: bouncer в + `set-only`, правило ставит тулкит, whitelist исключён): community-лист на всех портах отрезал + мобильных абонентов за CGNAT, а CrowdSec на ноде и так разбирает только журнал sshd. + `CROWDSEC_SCOPE=all` — прежнее поведение, на всех портах. Ставится из **пиннингованного APT-репо**: ключ проверяется по полному отпечатку и в keyring кладётся **ровно он** (а не всё, что приехало в ответе). Suite берётся канонический `any/any` — он же работает на Debian 13 (trixie), где у CrowdSec **нет** своего suite, а @@ -85,7 +103,14 @@ SSH-сессии** добавляется в правила всегда — ошибка детекта не может отрезать твой доступ. Сработавший сейфти-таймер снимает и таблицу, **и автозагрузку правил** — локаут не возвращается после ребута. -- **Персист конфига** — ре-ран без ENV не сбрасывает поднятые под ноду ручки. +- **Персист конфига** — ре-ран без ENV не сбрасывает поднятые под ноду ручки. Пустой ENV + значит «как в conf»; явно очистить список — словом `none` (`WHITELIST=none`, `UDP_PORTS=none`). +- **`na-fw`** — whitelist и разбан одной командой во всех слоях сразу (живой сет, `na_filter.nft`, + `protect.conf`, CrowdSec): `na-fw allow add|del|list `, `na-fw unban `. +- **Локальные правила** — `/etc/node-accelerator/na_filter.d/table/*.nft` (наборы, свои цепочки) и + `…/input/*.nft` (правила в input сразу после whitelist) вклеиваются в каждую генерацию и + проверяются общим `nft -c`; ручная правка самого `na_filter.nft` ре-раном стирается. +- **Баны переживают ре-ран** — записи autoban/suspect с оставшимся сроком переносятся в новую таблицу. ### 🩺 Диагностика (`scripts/diagnose.sh`) Read-only отчёт: ядро/BBR, sysctl, лимиты, conntrack, NIC/RPS, swap/THP/governor, firewall, CrowdSec, порты, RTT — с итогом ✔/▲/✘ и рекомендациями. Плюс сенсоры **стека ноды**: контейнер `remnanode` (статус/рестарты/`SPAWN_ERROR` за час — ловит коллизию node-address в панели), **рассинхрон порта node-агента с файрволом** (агент слушает `:3000`, а strict-правила держат `:2222` → «нода недоступна» для панели; в отчёте — с командой пожарного фикса, в `--json` — `node_port_detected`/`node_port_fw`), **сроки TLS-сертификатов** (LE/acme.sh/`/opt/*/certs`; серты, снятые с renew в acme.sh, не считаются; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`), **whitelist в трёх местах** (живой сет vs `na_filter.nft` vs `protect.conf`: адрес, который переживёт ребут, но не ре-ран `protect`, называется по имени), **датчик `CONN_LIMIT` по тому же срезу, что и правило** (только входящие на сервисные порты, без loopback/whitelist — раньше на ноде с внутренним nginx он горел всегда), **PSI** (различает «не собран», «выключен в сборке ядра» и «работает»), **ретеншен journald в часах**, **замороженные дефолты в сохранённом конфиге**, **свежесть fleet-sync/blocklist**, IPv6 default-route, UDP `RcvbufErrors` (QUIC/Hysteria2), **заполненность наборов-лимитеров** (и есть ли у них timeout), **ожидание перезагрузки по факту загрузки** (`reboot_needed` сверяется с `boot_id`, а не верит вечному маркеру). После установки доступна как команда **`na-diagnose`** (`--json` для мониторинга/панели — теперь с `na_version`, `hostname`, `uptime_s`, `load1`, `mem_used_pct`, WAN rx/tx-байтами; `--retrans [--window N]` — разбор причин TCP-retransmits). @@ -141,10 +166,10 @@ 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.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) панели/мониторинга — никогда не банятся. Это **полный обход** защиты (accept раньше автобана/CrowdSec/лимитов), а не «доверенный список»: префиксы шире `/29` (v6 — `/64`) вызывают предупреждение; дубли и `/32` нормализуются перед записью в правила | +| `SSH_PORT` | авто-детект | порт(ы) SSH через запятую. Детект (все порты): активный `ssh.socket`/`sshd.socket` (свойство `Listen`) → `sshd -T` (учитывает `sshd_config.d/*`) → `ss` → `sshd_config`. Порт текущей SSH-сессии добавляется к правилам автоматически + warn (сессия ищется и у предков процесса — `sudo` вычищает `SSH_CONNECTION`). В `protect.conf` сохраняется только заданный оператором порт; автодетект каждый прогон делается заново | +| `TCP_PORTS` / `UDP_PORTS` | `443,2087` | сервисные порты; `none` — ни одного (сохраняется словом `none`) | +| `NODE_PORT` | `auto` | порт(ы) node-agent через запятую. `auto` — детект с ноды (слушатель `rw-node` → контейнеры `remnawave/node` с host-сетью → `.env`; агент молчит → прошлый детект, иначе оба дефолта `2222,3000`). Явный порт ≠ факту → правила на оба + warn. `none` — правил node-порта нет (панель, CDN-origin) | +| `WHITELIST` | _пусто_ | IP/CIDR (v4+v6) панели/мониторинга — никогда не банятся (`none` — очистить; менять на ходу — `na-fw allow`). Это **полный обход** защиты (accept раньше автобана/CrowdSec/лимитов), а не «доверенный список»: префиксы шире `/29` (v6 — `/64`) вызывают предупреждение; дубли и `/32` нормализуются перед записью в правила | | `SYN_RATE`/`SYN_BURST` | `200`/`400` | **per-IP** новых TCP-конн./сек на порт | | `UDP_RATE`/`UDP_BURST` | `200`/`400` | **per-IP** UDP пакетов/сек | | `UDP_BULK_PORTS` | _пусто_ | порты объёмного UDP-туннеля (Hysteria2/TUIC). Общий `UDP_RATE` — это потолок около 2 Мбит/с: он душит туннель, клиент ретранслитит и видит огромную задержку. Порт должен быть и в `UDP_PORTS` | @@ -155,12 +180,15 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `SSH_BAN_TIME`/`PORTSCAN_BAN_TIME` | `24h`/`1h` | сроки бана | | `ENABLE_PORTSCAN_BAN` | `1` | автобан за скан закрытых портов | | `PORTSCAN_RATE`/`PORTSCAN_BURST` | `15`/`30` | порог скана (SYN на закрытые порты/мин, per-IP) до бана — ниже порога просто дроп, без бана | -| `PORTSCAN_LOG_RATE`/`PORTSCAN_LOG_BURST` | `60`/`30` | сколько строк `[na portscan]` в минуту уходит в journald (до v4.1 — `5/second` ≈ 432 000 строк/сутки: на публичной ноде журнал в 300M жил меньше суток и форензика старше вчера была невозможна). Бан работает по счётчикам, не по логу; `0` — лог анти-скана не ставить. `protect` считает суточный бюджет против капа journald и предупреждает, если он больше 30% | +| `PORTSCAN_LOG_RATE`/`PORTSCAN_LOG_BURST` | `60`/`30` | потолок строк `[na portscan]` в минуту (с v4.2 строка пишется только при переходе адреса в suspect/бан) (до v4.1 — `5/second` ≈ 432 000 строк/сутки: на публичной ноде журнал в 300M жил меньше суток и форензика старше вчера была невозможна). Бан работает по счётчикам, не по логу; `0` — лог анти-скана не ставить. `protect` считает суточный бюджет против капа journald и предупреждает, если он больше 30% | | `ENABLE_CROWDSEC` | `1` | ставить CrowdSec + bouncer | +| `CROWDSEC_SCOPE` | `ssh` | где действуют блок-листы CrowdSec: `ssh` — только SSH-порт(ы), whitelist исключён (bouncer в `set-only`, правило — `na-crowdsec-scope.service`); `all` — на всех портах (как до v4.2) | +| `DNAT_GUARD` | `1` | проверки источника (whitelist/autoban/блок-листы/bogon) для новых соединений к опубликованным Docker-портам (forward) | +| `UDP_AMP_DROP` | `1` | drop новых UDP на `UDP_PORTS` с исходных портов отражателей (17, 19, 53, 123, 389, 1900, 11211) | | `CROWDSEC_STRICT` | `1` | только пиннингованный APT-репо; не поднялся → CrowdSec пропускается. `0` возвращает `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. Сброс снимает и таблицу, и автозагрузку (`na-firewall.service`) — иначе локаут вернулся бы ребутом | +| `SAFETY_DELAY` | `300` | сек до авто-сброса правил, если не подтвердить SSH (минимум 30). Сброс снимает и таблицу, и автозагрузку (`na-firewall.service`) — иначе локаут вернулся бы ребутом | | `FLEET_SYNC_INTERVAL` | `5min` | интервал fleet-sync (персистится; `na-diagnose` считает свежесть по нему, а не по дефолту) | | `NA_NO_LOCK` | `0` | `1` — не брать flock (по умолчанию параллельный второй прогон отказывается стартовать) | | `NA_ADOPT_NEW_DEFAULTS` | `0` | `1` — принять новые дефолты тулкита разом там, где сохранённый конфиг пиннит старый (см. [Сохранённый конфиг и смена дефолтов](#сохранённый-конфиг-и-смена-дефолтов)). Работает и для `optimize` | @@ -189,7 +217,9 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF ## Проверка и эксплуатация ```bash -na-fw-status # баны, suspect, blocklist, fleet, ctguard, synproxy, CrowdSec +na-fw-status # баны, suspect, счётчики отброшенного, blocklist, fleet, ctguard, CrowdSec +na-fw allow add 203.0.113.7 # whitelist во всех слоях сразу (del / list — так же) +na-fw unban 198.51.100.9 # снять autoban/suspect и решения CrowdSec na-fw-top-talkers # топ источников по сервисным портам na-diagnose # 🩺 health-отчёт (read-only) na-diagnose --json # JSON для флот-мониторинга (Zabbix/Prometheus/панель) @@ -248,7 +278,8 @@ sudo ENABLE_CTGUARD=1 NA_CTG_ENFORCE=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/p Эффективные параметры каждого прогона сохраняются в `/etc/node-accelerator/{protect,optimize}.conf` идиомой `: "${KEY:=value}"` — ре-ран без ENV не сбрасывает то, что оператор задал под ноду -(`WHITELIST`, `CONN_LIMIT`, `NODE_PORT`…). Обратная сторона: файл так же пиннит и **дефолты той +(`WHITELIST`, `CONN_LIMIT`, `NODE_PORT`…). Пустой ENV тоже значит «как в conf» (обёртка с +незаданной переменной не должна молча стереть whitelist); явное «пусто» — слово `none`. Обратная сторона: файл так же пиннит и **дефолты той версии, при которой ноду настраивали**, и встроенный дефолт новой версии не может выиграть у записанного никогда. Ужесточение по безопасности (например, `CROWDSEC_STRICT` `0→1` в v4.0) на уже настроенных нодах молча не применялось. diff --git a/scripts/protect.sh b/scripts/protect.sh index 1e36cb8..9f01cb8 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -1125,20 +1125,23 @@ fi # # notrack ТОЛЬКО для трафика к самому хосту (fib daddr type local): иначе правило в # prerouting цепляет conntrack/NAT ТРАНЗИТА (Docker-контейнер панели → удалённая нода) -# и ломает его. Требует ядро ≥5.14 + модуль nf_synproxy. Запрошен, но недоступен → +# и ломает его. Требует ядро ≥5.14 + модуль nft_synproxy (тянет nf_synproxy_core). Модуля +# с именем nf_synproxy в современных ядрах нет вовсе — до v4.2 проверка `modprobe nf_synproxy` +# проваливалась на любом ядре флота (сток 6.12, XanMod 6.18), и ENABLE_SYNPROXY=1 всегда +# уходил в degraded. Запрошен, но недоступен → # fail-loud (маркер degraded + warn), БЕЗ тихой деградации; synproxy-правила не ставятся. SYNPROXY_PRE=""; SYNPROXY_IN=""; SP_MODPROBE=""; SYNPROXY_OK=0 rm -f "$STATE_DIR/.synproxy-degraded" 2>/dev/null || true if [[ "$ENABLE_SYNPROXY" == "1" ]]; then _kmaj="$(uname -r | cut -d. -f1)"; _kmin="$(uname -r | cut -d. -f2)" [[ "$_kmaj" =~ ^[0-9]+$ ]] || _kmaj=0; [[ "$_kmin" =~ ^[0-9]+$ ]] || _kmin=0 - if { [[ "$_kmaj" -gt 5 ]] || { [[ "$_kmaj" -eq 5 ]] && [[ "$_kmin" -ge 14 ]]; }; } && modprobe nf_synproxy 2>/dev/null; then + if { [[ "$_kmaj" -gt 5 ]] || { [[ "$_kmaj" -eq 5 ]] && [[ "$_kmin" -ge 14 ]]; }; } && modprobe nft_synproxy 2>/dev/null; then SYNPROXY_OK=1 SP_SET="$TCP_PORTS" # mss из MTU аплинка (−40Б IPv4+TCP), wscale 7 (дефолт Linux); клампим в 536..1460. _mtu="$(cat /sys/class/net/"$WAN"/mtu 2>/dev/null || echo 1500)"; [[ "$_mtu" =~ ^[0-9]+$ ]] || _mtu=1500 SP_MSS=$(( _mtu - 40 )); { [[ "$SP_MSS" -gt 1460 ]] || [[ "$SP_MSS" -lt 536 ]]; } && SP_MSS=1460 - SP_MODPROBE="ExecStartPre=/bin/sh -c 'modprobe nf_synproxy 2>/dev/null || true'" + SP_MODPROBE="ExecStartPre=/bin/sh -c 'modprobe nft_synproxy 2>/dev/null || true'" SYNPROXY_PRE=" chain prerouting { type filter hook prerouting priority -300; policy accept; fib daddr type local tcp dport { ${SP_SET} } tcp flags syn notrack @@ -1155,8 +1158,8 @@ if [[ "$ENABLE_SYNPROXY" == "1" ]]; then tcp dport { ${SP_SET} } ct state invalid,untracked synproxy mss ${SP_MSS} wscale 7 timestamp sack-perm" ok "SYNPROXY: ядро $(uname -r) ок, mss ${SP_MSS} wscale 7 (notrack только host-local)" else - warn "SYNPROXY запрошен, но недоступен (нужно ядро ≥5.14 + модуль nf_synproxy). Защита БЕЗ synproxy." - mkdir -p "$STATE_DIR"; echo "kernel=$(uname -r) reason=no_nf_synproxy at=$(date -Is)" > "$STATE_DIR/.synproxy-degraded" + warn "SYNPROXY запрошен, но недоступен (нужно ядро ≥5.14 + модуль nft_synproxy). Защита БЕЗ synproxy." + mkdir -p "$STATE_DIR"; echo "kernel=$(uname -r) reason=no_nft_synproxy at=$(date -Is)" > "$STATE_DIR/.synproxy-degraded" fi fi @@ -1642,12 +1645,12 @@ ExecReload=/usr/sbin/nft -f $NFT_FILE [Install] WantedBy=multi-user.target EOF -# nf_synproxy грузим на boot (на стоковых ядрах модульный; на XanMod встроен — no-op). +# nft_synproxy грузим на boot (и на стоковых, и на XanMod — модуль, CONFIG_NFT_SYNPROXY=m). # SYNPROXY требует nf_conntrack_tcp_loose=0: при loose=1 conntrack «подхватывает» третий # ACK как новое соединение, synproxy его не видит, и сокет отвечает RST. Плата: после # сброса conntrack (ребут модуля, failover) уже установленные соединения не подхватываются. if [[ "$SYNPROXY_OK" == "1" ]]; then - echo "nf_synproxy" > /etc/modules-load.d/na-synproxy.conf + echo "nft_synproxy" > /etc/modules-load.d/na-synproxy.conf printf '# node-accelerator: SYNPROXY (ENABLE_SYNPROXY=1)\nnet.netfilter.nf_conntrack_tcp_loose = 0\n' > /etc/sysctl.d/99-na-synproxy.conf sysctl -q -w net.netfilter.nf_conntrack_tcp_loose=0 2>/dev/null || warn "не смог выставить nf_conntrack_tcp_loose=0 — SYNPROXY не будет завершать рукопожатия" else diff --git a/tests/nft-behavior.sh b/tests/nft-behavior.sh index c00eb1e..5d91ebb 100755 --- a/tests/nft-behavior.sh +++ b/tests/nft-behavior.sh @@ -211,8 +211,10 @@ ip netns exec "$SRV" nft add element inet na_filter whitelist_v4 '{ 10.77.0.18 } check "DNAT: whitelist сильнее autoban" 1 "$(connects 10.77.0.18 10.77.0.1 9090 1)" echo "== 8. SYNPROXY: рукопожатие на защищённом порту завершается ==" -if modprobe nf_synproxy 2>/dev/null || [[ -d /sys/module/nf_synproxy_core ]]; then +if modprobe nft_synproxy 2>/dev/null || [[ -d /sys/module/nft_synproxy ]]; then ip netns exec "$SRV" sysctl -q -w net.netfilter.nf_conntrack_tcp_loose=0 + grep -q 'synproxy mss' "$T/ruleset-sp.nft" && ok_ "SYNPROXY в ruleset (не degraded: до v4.2 проверялся несуществующий модуль nf_synproxy)" \ + || fail_ "SYNPROXY-вариант сгенерирован без synproxy (degraded)" if ip netns exec "$SRV" nft -f "$T/ruleset-sp.nft"; then ok_ "SYNPROXY-ruleset загружен" check "соединение через synproxy устанавливается (до v4.2 — нет)" 3 "$(connects 10.77.0.13 10.77.0.1 8443 3)" @@ -220,7 +222,7 @@ if modprobe nf_synproxy 2>/dev/null || [[ -d /sys/module/nf_synproxy_core ]]; th fail_ "SYNPROXY-ruleset не загрузился" fi else - echo " skip (нет nf_synproxy в ядре раннера)" + echo " skip (нет nft_synproxy в ядре раннера)" fi echo From 129d05beb8ac8f158f32beb609e9218c164aa5ed Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:10:07 +0300 Subject: [PATCH 07/14] =?UTF-8?q?v4.2:=20=D1=81=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B5=D1=82=D0=BE=D0=BA=20protect?= =?UTF-8?q?=20+=20diag=20=E2=80=94=20CHANGELOG=20v4.2,=20NA=5FVERSION=204.?= =?UTF-8?q?2,=20NA=5FREF-=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-v4.2-diag.md | 231 --------------------- CHANGELOG-v4.2-protect.md | 140 ------------- CHANGELOG.md | 416 ++++++++++++++++++++++++++++++++++++++ README.md | 4 +- install.sh | 2 +- scripts/lib/common.sh | 2 +- tests/common-unit.sh | 14 +- 7 files changed, 427 insertions(+), 382 deletions(-) delete mode 100644 CHANGELOG-v4.2-diag.md delete mode 100644 CHANGELOG-v4.2-protect.md diff --git a/CHANGELOG-v4.2-diag.md b/CHANGELOG-v4.2-diag.md deleted file mode 100644 index c405376..0000000 --- a/CHANGELOG-v4.2-diag.md +++ /dev/null @@ -1,231 +0,0 @@ - - -### 🟠 `na-diagnose` судил по накопленному с загрузки - -UDP `RcvbufErrors`, softnet drop/`time_squeeze`, `ListenOverflows` и CPU steal ядро копит с -момента загрузки. На ноде с аптаймом в месяц одна давняя вспышка горела ▲ вечно, а свежий -рост тонул в старом итоге: аудит флота нашёл ▲ «UDP RcvbufErrors» почти на каждой ноде — при -нулевом приросте за время наблюдения. Steal мерился одной секундой и между соседними -прогонами прыгал от 0 до 8%; PSI тревожил по `avg10`, то есть по любому десятисекундному -всплеску (сборка initrd, logrotate). - -Теперь каждый запуск оставляет снимок счётчиков `/var/lib/node-accelerator/diag-counters.last` -(с `boot_id`), а следующий считает прирост — если загрузка та же и прошло не меньше 30 с. -Окно короче — снимок не перезаписывается, чтобы частый опрос мониторинга всё-таки получил -окно. Это единственное, что diagnose пишет на диск; без прав на запись дельт просто нет. -▲ по UDP — только при росте за окно и доле от принятых датаграмм не меньше 0.1% (Udp6 из -`/proc/net/snmp6` учитывается); если на ноде нет публичного UDP-слушателя, текст говорит про -исходящие сокеты и `rmem_default`, а не про QUIC/Hysteria2. Steal — окно 3 с плюс среднее с -загрузки и с прошлого запуска: ✘ только при всплеске на фоне хронического отбора. PSI — по -`avg60`. - -### 🟠 Ложные ▲, которые приучали не читать отчёт - -- «Ядро не XanMod» при `ENABLE_XANMOD=0` в `optimize.conf` — теперь info: это выбор оператора. -- Уступка маски ротации чужой стансе, у которой кап по размеру есть, — ✔, а не ▲. -- Глубина журнала: volatile-журнал (только `/run/log/journal`) и журнал, который держит ровно - текущую загрузку (самая старая запись ≈ аптайму), получили свой текст — без совета снизить - `PORTSCAN_LOG_RATE`, к которому они отношения не имеют. -- Лог анти-скана оценивался порогом «>100000 строк за загрузку»: порог рос с аптаймом и молчал - после ребута. Теперь — строки за сутки по всем загрузкам и их доля в журнале за те же сутки - (▲ от 50% или от 20% при глубине журнала меньше 48 ч); не измерили — так и пишем. -- Буферы: порог `rmem_max`/`wmem_max` был фиксированными 32M, а optimize на RAM-tier 1 ставит - 16M — ▲ на каждой маленькой ноде. Порог теперь от tier (как в optimize); добавлена проверка - `rmem_default`/`wmem_default`. -- «RPS выключен» при RX-очередей не меньше, чем ядер, — info: приём размазывает RSS. -- `/run/reboot-required` от стоковых ядер при работающем XanMod — info, а не ▲. - -### 🟠 Порты для сенсоров брались из маркера, который устаревает - -`tcp_ports=` в `protect.installed` пишет только прогон `protect`; после ручной правки правил -или `protect.conf` маркер врал (аудит флота: порты «мимо маркера» на нескольких нодах), и -датчик `CONN_LIMIT` мерил не тот срез. Теперь порты берутся из живых правил (`cc4_<порт>`, -`udp4_<порт>`), при отсутствии таблицы — из `protect.conf` (понимаются обе идиомы: -`: "${K:=v}"` и `: "${K=v}"` из protect 4.2), и лишь затем из маркера. Расхождение живых -правил с `protect.conf` — ▲ с тем, что сделает ре-ран (какие порты закроет и откроет). - -### 🟠 CrowdSec: «1 решение» при 25 тысячах заблокированных адресов - -`cscli decisions list` без `-a` показывает только локальные решения, а bouncer режет и -community-блоклист: на ноде флота отчёт печатал «1» при ~25 тыс. адресов CAPI. `na-report` -считал строки `"value"` в `-o json` — у одного решения таких полей несколько, выходило ×33. -Теперь решения считаются по строкам `-o raw` с числовым id (заголовок и запятая в имени AS не -мешают), раздельно локальные и CAPI/списки. Diagnose показывает область действия -блок-листов из маркера (`crowdsec_scope`; без него при решениях CAPI — ▲ «режет все порты») -и источники логов из `acquis.yaml`/`acquis.d` (на флоте — только journal sshd). - -### 🟠 Whitelist сверялся в трёх слоях из четырёх - -Сверялись живой сет, `na_filter.nft` и `protect.conf`, а allowlist CrowdSec — нет, хотя его -bouncer стоит раньше `na_filter` (priority −10): адрес из whitelist, которого нет в allowlist, -CrowdSec может забанить (аудит флота: адрес узла управления был в whitelist, но не в -allowlist). Теперь берётся ЭФФЕКТИВНЫЙ allowlist — объединение ip/cidr всех -`/etc/crowdsec/parsers/s02-enrich/*.yaml` с секцией `whitelist` (CIDR учитывается), дрейф -показывается в обе стороны (▲ — только когда bouncer CrowdSec действительно стоит), а также -«в файле/conf есть, в памяти нет». - -### 🟠 Пробелы сенсоров - -- **Порты, опубликованные через DNAT** (Docker `-p`, ручные relay-DNAT): трафик идёт хуком - forward мимо input-цепочки `na_filter` — strict их не закрывает, autoban/блоклисты/лимиты не - действуют. Источники — `docker ps` и dnat-правила `nft -t list ruleset` (loopback мимо). - При `dnat_guard=1` в маркере (protect 4.2) — info, иначе ▲ с перечнем. -- **Публичные слушатели вне разрешённых портов** (strict — info «доступны только - whitelist/флоту»; `FW_MODE=open` — info, так задумано; без правил `na_filter` — ▲ «открыты - миру») и **разрешённые порты без слушателя** (info). -- **Память**: MemAvailable < 15% — ▲; занятый своп и balloon-драйвер (virtio/VMware) — info - (на ноде флота balloon гипервизора держал 2.5 ГБ, а MemTotal этого не показывал). -- **`nftables.service`** enabled/active при `flush ruleset` в `/etc/nftables.conf` — ▲ с - советом `systemctl disable` без stop. -- **Ручная правка `na_filter.nft`** (sha256 из маркера protect 4.2 ≠ файлу) — ▲: ре-ран её - перезапишет. Ненулевые именованные счётчики дропов `c_*` — в секции файрвола. -- **Порт xray в эфемерном диапазоне без резерва** — ▲ (см. optimize ниже). -- **Обновление XanMod** по кэшу apt (кандидат только из репозитория XanMod) — info. -- **Сертификаты**: добавлены хранилище Caddy (docker volume и пакетный) и `ssl_certificate` из - включённых конфигов nginx. Просроченный серт раньше маскировался под «не найден» (−1 значил - «нет», и −10 дней «перекрывал» следующий живой серт) — теперь отдельный флаг «найден» и ✘ при - ≤0 дней. -- **json-логи контейнеров без `max-size`** — ▲ (кап из `daemon.json` получают только - контейнеры, созданные после него). -- **OOM/lockup за сутки** — по всем загрузкам (`_TRANSPORT=kernel` = `-k` без неявного `-b`): - OOM, из-за которого нода перезагрузилась, был не виден именно тогда, когда важнее всего. - -Новые поля `--json` — только в хвосте объекта: `udp_rcvbuf_errors_delta`, -`softnet_drops_delta`, `time_squeeze_delta`, `listen_overflows_delta`, `delta_window_s` (−1 — -окна нет), `cpu_steal_boot_pct`, `crowdsec_decisions_local`, `crowdsec_decisions_capi`, -`crowdsec_scope`, `whitelist_drift_crowdsec`, `dnat_ports`, `dnat_guard` (−1 — ключа в -маркере нет), `xanmod_update`, `cert_found`, `portscan_log_lines_24h`, -`portscan_log_share_pct`. Смысл и порядок прежних полей не менялся (`cpu_steal_pct` теперь -мерится окном 3 с, `cert_min_days` при `cert_found=true` может быть ≤0). - -### 🟠 `na-report`: метрики, которые значили не то, что написано - -`ban_rate_5m` всегда был числом строк drop-лога за 5 минут, а не банов. Поле оставлено ради -совместимости, рядом — честные `events_5m` и `unique_src_5m`. `drops_by_reason.crowdsec` — -активные локальные решения (см. выше), `crowdsec_decisions_all` — вместе с CAPI/списками. -Новое `asn_enrichment` = `ok|partial|unavailable`: пустой ASN больше не путается с «не -спросили». Строки JSON экранируются общим `json_escape`: управляющий символ в имени AS из -whois раньше ломал весь документ. Новые поля — в хвосте. - -### 🟠 `optimize`: чужой sysctl-файл молча перебивал наши значения - -Файл, сортирующийся позже `99-node-accelerator.conf`, перебивает ключи: на ноде флота так -урезались буферы сокетов до 16M, а optimize печатал «применено». Теперь после `sysctl ---system` значения наших файлов сверяются с ядром; при расхождении называется файл-виновник -(порядок — `systemd-analyze cat-config sysctl.d`, без него — тот же перебор каталогов по имени, -что у systemd-sysctl, с `/etc/sysctl.conf` последним), а ключи, которые ядро записать не дало -(контейнер), — отдельной строкой. Имя нашего файла не менялось. - -### 🟠 Порты ноды внутри `ip_local_port_range` - -optimize ставит `ip_local_port_range = 10000 65535`, и в него попадают порты самой ноды — API -xray на `127.0.0.1:10085`, высокие inbound'ы. Исходящее соединение может занять такой порт -раньше, чем xray его забиндит (рестарт контейнера под нагрузкой). На флоте их резервировали -руками. Теперь LISTEN-порты процессов xray/rw-core/rw-node (включая loopback) и порт -node-агента, попадающие в диапазон, пишутся в `net.ipv4.ip_local_reserved_ports` вместе с -резервом оператора; свои порты помнятся в `reserved-ports.na`, и порт, с которого xray ушёл, -на ре-ране снимается. xray ещё не запущен — нечего резервировать: ре-ран optimize после старта -ноды (diagnose подскажет). - -### 🟠 RPS/XPS там, где они вредят - -RPS ставился на все CPU даже на 1 vCPU и на сетевой карте с RX-очередями не меньше числа -ядер (на ноде флота — 12 аппаратных очередей): аппаратный RSS уже раскидывает приём, а RPS -поверх только гоняет пакеты между CPU. XPS-маска «все CPU» затирала раскладку TX-очередей, -которую задаёт драйвер. Теперь `na-rps-setup` в этих случаях RPS не включает (и снимает маски -прошлых версий — к снимку или к 0), XPS трогает только с `NA_XPS=1` (ручка сохраняется в -`optimize.conf`). Исходные маски снимаются при первом применении в `rps-orig.tsv` и -возвращаются при откате. - -### 🟠 journald: наш drop-in перебивал операторский - -`na-size.conf` сортировался после `99-*.conf` и молча перебивал операторский -`SystemMaxUse`. Теперь drop-in — `10-na-size.conf` (старое имя снимается при апгрейде и -откате). Журнал в RAM (volatile) не переживает ребут — история входов и инцидентов -пропадает; если `Storage=` никто не задал явно, optimize ставит `Storage=persistent`, создаёт -`/var/log/journal` и сразу переносит накопленное (`journalctl --flush`). Осознанный -`Storage=volatile` оператора не трогается. - -### 🟠 `net.ipv6.conf.all.forwarding=1` убран - -Включённый v6-форвардинг переводит ядро в режим роутера: при `accept_ra=1` оно перестаёт -принимать Router Advertisement, и на хостах с ядерным SLAAC v6 default route истекает через -router lifetime — нода молча теряет IPv6. Выбран вариант «убрать», а не «`accept_ra=2` на -WAN»: xray в host-network форвардинг не нужен вовсе; Docker с включённым IPv6 сам ставит -v6-форвардинг при старте демона; `accept_ra=2` пришлось бы писать по имени интерфейса -(меняется после смены ядра), а на хостах с systemd-networkd RA обрабатывает networkd и -`accept_ra` ядра ни на что не влияет. На уже настроенных нодах runtime-значение не -сбрасывается на ходу (Docker с IPv6 мог на него опереться) — оно уйдёт с ближайшим ребутом. -IPv4-форвардинг (Docker bridge) остаётся. - -### 🟠 `rollback optimize` возвращал runtime только после ребута - -Удаление файлов не возвращает значения: `sysctl --system` перечитывает лишь чужие файлы, а -ключи, которых больше никто не задаёт, держат наши значения до перезагрузки; маски очередей и -THP — тоже. Теперь при ПЕРВОМ применении снимаются исходные значения ключей наших -sysctl-файлов (`sysctl.orig`), маски RPS/XPS и режим THP; откат возвращает их сразу (затем -`sysctl --system`, так что чужие файлы по-прежнему главнее снимка) и честно пишет, что -вернулось, а что — нет (ноды, где optimize ставился до v4.2, снимка не имеют: RPS -выключается, остальное — до ребута). - -### 🟠 Ядро XanMod: проверки до и после установки - -XanMod не подписан ключом дистрибутива: при включённом UEFI Secure Boot shim его не загрузит, -и после ребута нода не поднимется. Тесный `/boot` — та же беда тише: postinst не дописывает -initrd, а GRUB уже выбрал новое ядро. Теперь установка ядра пропускается (warn, остальной -тюнинг продолжается) при включённом Secure Boot (`mokutil --sb-state` или efivar -`SecureBoot-8be4df61-…`) и при свободном месте в `/boot` меньше 200 МБ (`NA_BOOT_MIN_MB`). -После установки, до слов «нужна перезагрузка», проверяется, что у нового ядра есть initrd -(одна попытка пересобрать) и драйвер текущего NIC в `/lib/modules/<версия>` — модулем или -встроенным; иначе громкое «НЕ ПЕРЕЗАГРУЖАЙСЯ» с причиной. - -### 🟡 Мелкое - -- **`default_iface`** брал пятое поле маршрута: для `default dev venet0 scope link` (OpenVZ) - выходило `scope`, для multipath — мусор, и это имя шло в RPS, NIC-tune и diagnose. Теперь — - токен после `dev` (и в `common.sh`, и в генерируемом `na-rps-setup`). -- **`apt_install`** не ждал чужой dpkg-лок: на свежем боксе с работающим unattended-upgrades - обе попытки падали подряд и роняли optimize/protect под `set -e`. Теперь - `-o DPkg::Lock::Timeout=120` (`NA_APT_LOCK_TIMEOUT`). - -### 🧪 Тесты - -- `tests/diagnose-unit.sh`: +12 разделов — дельты счётчиков (рост/нет роста, окно <30 с не - затирает снимок, другая загрузка), steal окном и с загрузки, PSI avg60, ложные ▲ (XanMod - выключен, кап у чужой стансы, volatile-журнал, журнал ≈ аптайму, доля лога анти-скана, «не - измерено», OOM прошлой загрузки), порты из живых правил и обе идиомы conf, CrowdSec по - источникам/scope/acquisition, allowlist CrowdSec (CIDR, ручной файл, парсер без whitelist), - DNAT и слушатели, память/balloon, `nftables.service`, хэш файла, счётчики `c_*`, буферы от - tier, порт xray без резерва, обновление XanMod, reboot-required, просроченный серт, Caddy, - nginx (sites-available не считается), docker без `max-size`, RSS; новые поля строго в хвосте - JSON. Проверка текста теперь без цветовых escape-кодов — значок ✔/▲/✘ реально сверяется. -- `tests/optimize-unit.sh` (новый): секция sysctl против эмулятора `sysctl --system` — - виновник назван (и через `systemd-analyze cat-config`), незаписанный ключ, резерв портов с - сохранением резерва оператора и снятием ушедшего порта, снимок только при первом прогоне, - без v6-форвардинга; journald (`10-na-size.conf`, старое имя снято, persistent, явный - `Storage=` оператора); снимки RPS/THP (в т.ч. пустая маска последней очереди не выбрасывает - снимок); блок отката runtime со снимками и без; проверки XanMod (Secure Boot через mokutil и - efivar, `/boot`, версия ядра из Depends, initrd и его пересборка, драйвер NIC модулем и - встроенный); `default_iface` (venet0, multipath) и `apt_install` (таймаут лока во всех - вызовах, в т.ч. в ретрае). -- `tests/na-report-unit.sh` (новый): CrowdSec по строкам raw (не ×N), `-a`, `events_5m` / - `unique_src_5m`, `asn_enrichment` ok/partial/unavailable, управляющий символ из whois не ломает - JSON, новые поля в хвосте. -- `tests/rps-unit.sh`: XPS не трогается без `NA_XPS=1`, RPS снимается при RX-очередей ≥ ядер и - на 1 vCPU (к снимку или к 0), `Environment=NA_XPS` в юните, `default dev venet0`. - -Каждый новый и расширенный тест падает на v4.1.3 (diagnose-unit — 90 проверок, -optimize-unit — 59, na-report-unit — 14, rps-unit — 13) и проходит на этой ветке. - -### Миграция - -- Ре-ран optimize на уже настроенной ноде: снимки для отката не создаются (в ядре уже наши - значения) — откат на таких нодах честно говорит, что вернётся после ребута. XPS-маска прошлых - версий без снимка остаётся до ребута, после него — раскладка драйвера. На нодах с RSS - (очередей ≥ ядер) RPS выключается сразу. v6-форвардинг уходит с ребутом. -- `na-diagnose` создаёт `diag-counters.last`; первый запуск после обновления (и после каждого - ребута) дельт не даёт (`delta_window_s=-1`). -- В `optimize.conf` появляется ключ `NA_XPS`; `optimize.installed` — `tier`, `sock_max`, - `sock_def` (по ним diagnose берёт пороги буферов). diff --git a/CHANGELOG-v4.2-protect.md b/CHANGELOG-v4.2-protect.md deleted file mode 100644 index 9a81972..0000000 --- a/CHANGELOG-v4.2-protect.md +++ /dev/null @@ -1,140 +0,0 @@ -### 🔴 Опубликованные Docker-порты шли мимо na_filter - -Трафик к контейнеру с `-p` (bridge-сеть) после DNAT идёт через hook forward, а не input, а -цепочка forward в `na_filter` была пустой. На такие порты не действовали ни autoban, ни -блок-листы, ни анти-спуф, и strict их не закрывал. Аудит флота: Caddy панелей и второй тенант -на нодах (свой контейнер с опубликованными портами) жили целиком мимо защиты; `na-diagnose` -этого не показывал. - -Теперь (`DNAT_GUARD=1`, дефолт) новые DNAT-соединения проходят цепочку `dnat_guard` с теми же -проверками источника, что в input: whitelist и ноды флота — пропуск, autoban, блок-листы и -bogon на WAN — drop, со счётчиком `c_dnat_guard`. Лимиты и список портов контейнеров не -трогаются: у контейнеров свои потребители (CDN, второй тенант), и решать за них о порогах -тулкит не должен. `DNAT_GUARD=0` — прежнее поведение. - -### 🟠 CrowdSec резал мобильных абонентов на всех портах - -Community-лист CAPI (~25 тыс. адресов) bouncer применял ко всем портам. CGNAT-адрес -мобильного оператора, попавший в лист за чужой перебор, отрезал от VPN всех абонентов за ним -(аудит флота: сотни тысяч дропов клиентского трафика на ноде; на части флота это уже сузили -руками до SSH). При этом CrowdSec на ноде разбирает только журнал sshd — его собственные -решения и так касаются только перебора SSH. - -Новая ручка `CROWDSEC_SCOPE`: `ssh` (дефолт) — bouncer переводится в `set-only` и только -наполняет наборы, а правило, которое по ним дропает, ставит тулкит — на SSH-порт(ы), с -исключением для whitelist (whitelist парсера CrowdSec от решений CAPI не защищает: адрес -админа в community-листе отрезал бы ему SSH). Правила грузит `na-crowdsec-scope.service` до -bouncer'а; ручной юнит, сужавший CrowdSec до SSH, выключается из автозагрузки (его файл -остаётся). `all` — как раньше, на всех портах. Старый bouncer без `set-only` — честный откат -на `all` с предупреждением. `rollback protect` возвращает bouncer к своим правилам. - -### 🟠 Лог анти-скана занимал 70–99% журнала - -Лог-правило анти-скана стояло первым и писало каждый новый SYN в закрытый порт. Интернет-шум -(одиночные стуки в 23/445/3389) давал 70–99% всех строк журнала на нодах флота, а настоящие -сканеры среди них терялись. Теперь `[na portscan]` пишется только на переходе адреса — один -раз при пометке suspect и один раз при бане (цепочки `ps_suspect*`/`ps_ban*`). -`PORTSCAN_LOG_RATE` остаётся общим потолком этих строк. Объём отброшенного виден в -именованных счётчиках. - -### 🟠 Именованные счётчики отброшенного - -У всех drop-правил теперь именованные счётчики: `c_synflood c_udpflood c_udp_amp c_portscan -c_autoban c_blocklist c_bogon c_badflags c_nodeport c_catchall c_dnat_guard c_sshflood`. -`na-fw-status` показывает их, `na-diagnose` читает. Объём атаки виден в цифрах, а не в строках -лога, который на насыщении молчит. - -### 🟠 SSH ban-once банил с первого превышения - -Клиент, которому дропнули SYN, сам пересылает его через ~1 с. Под ban-once такой ретрансмит -от уже-suspect адреса сразу давал бан на `SSH_BAN_TIME`. CI или Ansible, открывшие на одно -соединение больше лимита, получали суточный бан. Теперь suspect банится, только когда его -пакеты сверх лимита сами превышают втрое больший burst (`sshs4/6`), то есть когда перебор -продолжается. - -### 🟠 SYNPROXY не завершал рукопожатия - -Правило synproxy стояло после `ct state invalid drop`, а `nf_conntrack_tcp_loose` не -выставлялся в 0. При `loose=1` третий ACK рукопожатия conntrack принимал за новое соединение, -он шёл мимо synproxy в сокет и получал RST. При `loose=0` тот же ACK съедал invalid-drop. -Итог: с `ENABLE_SYNPROXY=1` новые TCP на `TCP_PORTS` не устанавливались вовсе. А до этого -дело и не доходило: доступность проверялась `modprobe nf_synproxy`, но модуля с таким именем -в современных ядрах нет (есть `nf_synproxy_core` и `nft_synproxy`). На всех ядрах флота -(сток 6.12, XanMod 6.18) режим уходил в degraded с ✘ в диагностике. Теперь проверяется -`nft_synproxy`, правило стоит до invalid-drop, забаненные режутся раньше него, `nf_conntrack_tcp_loose=0` ставится -через `/etc/sysctl.d/99-na-synproxy.conf` (и снимается при выключении и в rollback). - -### 🟡 Whitelist и разбан одной командой: `na-fw` - -Whitelist живёт в четырёх местах: живой сет, `na_filter.nft`, `WHITELIST=` в `protect.conf`, -CrowdSec-yaml. Штатного способа поменять его, кроме полного ре-рана, не было, поэтому на флоте -его правили `sed`'ом по отдельности (сотни `*.bak-*`), слои расходились, а ре-ран молча -возвращал старое. - -`na-fw allow add|del|list ` меняет все слои разом. Файл перед заменой проверяется -`nft -c`. Обновляются правило CROWDSEC_SCOPE и хэш в маркере, добавленный адрес снимается из -autoban. `na-fw unban ` снимает autoban/suspect и решения CrowdSec. - -### 🟡 Локальные правила оператора переживают ре-ран - -Ручные правки `na_filter.nft` (свой сет фронт-прокси, лишний порт) любой ре-ран молча стирал. -Теперь свои фрагменты кладутся в `/etc/node-accelerator/na_filter.d/table/*.nft` (на уровне -таблицы) и `…/input/*.nft` (в input сразу после whitelist). Они вклеиваются в каждую -генерацию и проверяются общим `nft -c`: битый фрагмент — ничего не применено. В маркере — -`nft_sha256` файла: `na-diagnose` видит ручную правку. - -### 🟡 Баны переживают ре-ран - -Ре-ран пересоздаёт таблицу целиком, и до v4.2 вместе с ней обнулялись autoban и suspect: -сканер, забаненный на час, возвращался сразу после любого ре-рана. Теперь записи с оставшимся -сроком переносятся в новую таблицу. - -### 🟡 Конфиг: `none`, SSH_PORT без вечного автодетекта - -- **Явное «пусто» — слово `none`.** Оно действует для `WHITELIST`, `TCP_PORTS`, `UDP_PORTS`, - `UDP_BULK_PORTS`, `NODE_PORT_PEERS` и `NODE_PORT=none` (бокс без агента: фолбэк `2222,3000` - открывал миру два порта, которые никто не слушает). Пустой ENV по-прежнему значит «как в - conf»: обёртка с незаданной переменной не должна молча стереть whitelist. Слово `none` - сохраняется в conf как есть, поэтому встроенный дефолт не возвращается. -- **SSH_PORT из автодетекта больше не персистится.** Раньше он перебивал свежий детект на - ре-ране: сменил порт sshd — strict открывал старый. Порт, сохранённый старой версией без - пометки `explicit`, сверяется с фактом: если расходится, открываются оба порта и выводится - предупреждение. - -### 🟡 Детект SSH и node-агента - -- **SSH.** Ветка `ssh.socket` была мёртвой: читалось несуществующее свойство `ListenStream` - вместо `Listen`. Источники теперь отдают все порты, а не первый. -- **Сессия под `sudo`.** `sudo` вычищает `SSH_CONNECTION`, а README сам предлагает `sudo - bash …`. Раньше в этом случае не было ни авто-whitelist IP админа, ни защиты порта сессии, и - никто об этом не узнавал. Теперь `SSH_CONNECTION` ищется у предков процесса; не нашёлся — - громкое предупреждение. -- **node-агент.** Порядок источников: слушатель `rw-node` → контейнеры `remnawave/node` с - host-сетью, образ по `.Config.Image` (при закреплении по digest `docker ps` показывал ID, и - свой агент не находился) → `.env`. Bridge-контейнер второго тенанта больше не выдаётся за - порт этой ноды. Несколько агентов с host-сетью — правила на все и предупреждение. - -### 🟡 Мелкое - -- **ctguard.** `ENABLE_CTGUARD=0` на ре-ране теперь снимает таймер, таблицу `na_ctguard` и - конфиг (раньше эвикт в `ENFORCE` продолжался). DNAT-клиенты больше не считаются фантомами: - их сокеты в netns контейнера, ss хоста их не видит. -- **`fleet.env`.** Файл подключается через `.` от root. Токены теперь проверяются по набору - символов, значения пишутся экранированными (`%q`): раньше токен `abc;cmd` исполнял `cmd`. -- **Блок-листы.** Префиксы шире `/8` (v6 — `/19`) отбрасываются: одна строка `x.x.x.x/0` в - `custom-blocklist.txt` блокировала бы весь интернет. -- **Анти-амплификация.** `UDP_AMP_DROP=1`: новые UDP на сервисные порты с исходных портов - отражателей режутся до per-IP лимитов. -- **DHCPv6-ответы** пропускаются (раньше strict их отрезал). -- **`::` убран из `bogon_v6`.** Он ломал DAD. -- **`na-firewall.service`** поднимает правила до `network-pre.target` - (`DefaultDependencies=no`), как дистрибутивный `nftables.service`. Раньше было окно, когда - сеть уже поднята, а правил ещё нет. -- **Сейфти и подтверждение.** - - CrowdSec ставится до взвода сейфти: его установка больше не съедает окно. - - Ответ «y» проверяет, что таблица на месте. - - `SAFETY_DELAY` — минимум 30 с. -- **Подсказка в режиме skip** больше не советует `nft list ruleset > /etc/nftables.conf`. -- **`na-fw-top-talkers`** снова показывает адреса: колонку пира он брал не ту. -- **Новые ключи маркера:** `crowdsec_scope`, `dnat_guard`, `nft_sha256`, `include_dir`, - `include_files`, `node_port=none`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 842b852..7b6779d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,421 @@ # Changelog +## v4.2 — 2026-10-03 + +Вторая часть исправлений по аудиту живого флота (около 30 нод): защита закрывает то, что шло +мимо неё (опубликованные Docker-порты), перестаёт резать своих (CrowdSec на всех портах, +ban-once с первого превышения, лог скана на 70–99% журнала), а диагностика перестаёт врать +(накопленные с загрузки счётчики, ложные ▲, слепые зоны). Дефолты меняются в трёх местах — +см. «Миграцию»: `CROWDSEC_SCOPE=ssh`, `DNAT_GUARD=1`, `UDP_AMP_DROP=1`. + +### Защита + +#### 🔴 Опубликованные Docker-порты шли мимо na_filter + +Трафик к контейнеру с `-p` (bridge-сеть) после DNAT идёт через hook forward, а не input, а +цепочка forward в `na_filter` была пустой. На такие порты не действовали ни autoban, ни +блок-листы, ни анти-спуф, и strict их не закрывал. Аудит флота: Caddy панелей и второй тенант +на нодах (свой контейнер с опубликованными портами) жили целиком мимо защиты; `na-diagnose` +этого не показывал. + +Теперь (`DNAT_GUARD=1`, дефолт) новые DNAT-соединения проходят цепочку `dnat_guard` с теми же +проверками источника, что в input: whitelist и ноды флота — пропуск, autoban, блок-листы и +bogon на WAN — drop, со счётчиком `c_dnat_guard`. Лимиты и список портов контейнеров не +трогаются: у контейнеров свои потребители (CDN, второй тенант), и решать за них о порогах +тулкит не должен. `DNAT_GUARD=0` — прежнее поведение. + +#### 🟠 CrowdSec резал мобильных абонентов на всех портах + +Community-лист CAPI (~25 тыс. адресов) bouncer применял ко всем портам. CGNAT-адрес +мобильного оператора, попавший в лист за чужой перебор, отрезал от VPN всех абонентов за ним +(аудит флота: сотни тысяч дропов клиентского трафика на ноде; на части флота это уже сузили +руками до SSH). При этом CrowdSec на ноде разбирает только журнал sshd — его собственные +решения и так касаются только перебора SSH. + +Новая ручка `CROWDSEC_SCOPE`: `ssh` (дефолт) — bouncer переводится в `set-only` и только +наполняет наборы, а правило, которое по ним дропает, ставит тулкит — на SSH-порт(ы), с +исключением для whitelist (whitelist парсера CrowdSec от решений CAPI не защищает: адрес +админа в community-листе отрезал бы ему SSH). Правила грузит `na-crowdsec-scope.service` до +bouncer'а; ручной юнит, сужавший CrowdSec до SSH, выключается из автозагрузки (его файл +остаётся). `all` — как раньше, на всех портах. Старый bouncer без `set-only` — честный откат +на `all` с предупреждением. `rollback protect` возвращает bouncer к своим правилам. + +#### 🟠 Лог анти-скана занимал 70–99% журнала + +Лог-правило анти-скана стояло первым и писало каждый новый SYN в закрытый порт. Интернет-шум +(одиночные стуки в 23/445/3389) давал 70–99% всех строк журнала на нодах флота, а настоящие +сканеры среди них терялись. Теперь `[na portscan]` пишется только на переходе адреса — один +раз при пометке suspect и один раз при бане (цепочки `ps_suspect*`/`ps_ban*`). +`PORTSCAN_LOG_RATE` остаётся общим потолком этих строк. Объём отброшенного виден в +именованных счётчиках. + +#### 🟠 Именованные счётчики отброшенного + +У всех drop-правил теперь именованные счётчики: `c_synflood c_udpflood c_udp_amp c_portscan +c_autoban c_blocklist c_bogon c_badflags c_nodeport c_catchall c_dnat_guard c_sshflood`. +`na-fw-status` показывает их, `na-diagnose` читает. Объём атаки виден в цифрах, а не в строках +лога, который на насыщении молчит. + +#### 🟠 SSH ban-once банил с первого превышения + +Клиент, которому дропнули SYN, сам пересылает его через ~1 с. Под ban-once такой ретрансмит +от уже-suspect адреса сразу давал бан на `SSH_BAN_TIME`. CI или Ansible, открывшие на одно +соединение больше лимита, получали суточный бан. Теперь suspect банится, только когда его +пакеты сверх лимита сами превышают втрое больший burst (`sshs4/6`), то есть когда перебор +продолжается. + +#### 🟠 SYNPROXY не завершал рукопожатия + +Правило synproxy стояло после `ct state invalid drop`, а `nf_conntrack_tcp_loose` не +выставлялся в 0. При `loose=1` третий ACK рукопожатия conntrack принимал за новое соединение, +он шёл мимо synproxy в сокет и получал RST. При `loose=0` тот же ACK съедал invalid-drop. +Итог: с `ENABLE_SYNPROXY=1` новые TCP на `TCP_PORTS` не устанавливались вовсе. А до этого +дело и не доходило: доступность проверялась `modprobe nf_synproxy`, но модуля с таким именем +в современных ядрах нет (есть `nf_synproxy_core` и `nft_synproxy`). На всех ядрах флота +(сток 6.12, XanMod 6.18) режим уходил в degraded с ✘ в диагностике. Теперь проверяется +`nft_synproxy`, правило стоит до invalid-drop, забаненные режутся раньше него, `nf_conntrack_tcp_loose=0` ставится +через `/etc/sysctl.d/99-na-synproxy.conf` (и снимается при выключении и в rollback). + +#### 🟡 Whitelist и разбан одной командой: `na-fw` + +Whitelist живёт в четырёх местах: живой сет, `na_filter.nft`, `WHITELIST=` в `protect.conf`, +CrowdSec-yaml. Штатного способа поменять его, кроме полного ре-рана, не было, поэтому на флоте +его правили `sed`'ом по отдельности (сотни `*.bak-*`), слои расходились, а ре-ран молча +возвращал старое. + +`na-fw allow add|del|list ` меняет все слои разом. Файл перед заменой проверяется +`nft -c`. Обновляются правило CROWDSEC_SCOPE и хэш в маркере, добавленный адрес снимается из +autoban. `na-fw unban ` снимает autoban/suspect и решения CrowdSec. + +#### 🟡 Локальные правила оператора переживают ре-ран + +Ручные правки `na_filter.nft` (свой сет фронт-прокси, лишний порт) любой ре-ран молча стирал. +Теперь свои фрагменты кладутся в `/etc/node-accelerator/na_filter.d/table/*.nft` (на уровне +таблицы) и `…/input/*.nft` (в input сразу после whitelist). Они вклеиваются в каждую +генерацию и проверяются общим `nft -c`: битый фрагмент — ничего не применено. В маркере — +`nft_sha256` файла: `na-diagnose` видит ручную правку. + +#### 🟡 Баны переживают ре-ран + +Ре-ран пересоздаёт таблицу целиком, и до v4.2 вместе с ней обнулялись autoban и suspect: +сканер, забаненный на час, возвращался сразу после любого ре-рана. Теперь записи с оставшимся +сроком переносятся в новую таблицу. + +#### 🟡 Конфиг: `none`, SSH_PORT без вечного автодетекта + +- **Явное «пусто» — слово `none`.** Оно действует для `WHITELIST`, `TCP_PORTS`, `UDP_PORTS`, + `UDP_BULK_PORTS`, `NODE_PORT_PEERS` и `NODE_PORT=none` (бокс без агента: фолбэк `2222,3000` + открывал миру два порта, которые никто не слушает). Пустой ENV по-прежнему значит «как в + conf»: обёртка с незаданной переменной не должна молча стереть whitelist. Слово `none` + сохраняется в conf как есть, поэтому встроенный дефолт не возвращается. +- **SSH_PORT из автодетекта больше не персистится.** Раньше он перебивал свежий детект на + ре-ране: сменил порт sshd — strict открывал старый. Порт, сохранённый старой версией без + пометки `explicit`, сверяется с фактом: если расходится, открываются оба порта и выводится + предупреждение. + +#### 🟡 Детект SSH и node-агента + +- **SSH.** Ветка `ssh.socket` была мёртвой: читалось несуществующее свойство `ListenStream` + вместо `Listen`. Источники теперь отдают все порты, а не первый. +- **Сессия под `sudo`.** `sudo` вычищает `SSH_CONNECTION`, а README сам предлагает `sudo + bash …`. Раньше в этом случае не было ни авто-whitelist IP админа, ни защиты порта сессии, и + никто об этом не узнавал. Теперь `SSH_CONNECTION` ищется у предков процесса; не нашёлся — + громкое предупреждение. +- **node-агент.** Порядок источников: слушатель `rw-node` → контейнеры `remnawave/node` с + host-сетью, образ по `.Config.Image` (при закреплении по digest `docker ps` показывал ID, и + свой агент не находился) → `.env`. Bridge-контейнер второго тенанта больше не выдаётся за + порт этой ноды. Несколько агентов с host-сетью — правила на все и предупреждение. + +#### 🟡 Мелкое + +- **ctguard.** `ENABLE_CTGUARD=0` на ре-ране теперь снимает таймер, таблицу `na_ctguard` и + конфиг (раньше эвикт в `ENFORCE` продолжался). DNAT-клиенты больше не считаются фантомами: + их сокеты в netns контейнера, ss хоста их не видит. +- **`fleet.env`.** Файл подключается через `.` от root. Токены теперь проверяются по набору + символов, значения пишутся экранированными (`%q`): раньше токен `abc;cmd` исполнял `cmd`. +- **Блок-листы.** Префиксы шире `/8` (v6 — `/19`) отбрасываются: одна строка `x.x.x.x/0` в + `custom-blocklist.txt` блокировала бы весь интернет. +- **Анти-амплификация.** `UDP_AMP_DROP=1`: новые UDP на сервисные порты с исходных портов + отражателей режутся до per-IP лимитов. +- **DHCPv6-ответы** пропускаются (раньше strict их отрезал). +- **`::` убран из `bogon_v6`.** Он ломал DAD. +- **`na-firewall.service`** поднимает правила до `network-pre.target` + (`DefaultDependencies=no`), как дистрибутивный `nftables.service`. Раньше было окно, когда + сеть уже поднята, а правил ещё нет. +- **Сейфти и подтверждение.** + - CrowdSec ставится до взвода сейфти: его установка больше не съедает окно. + - Ответ «y» проверяет, что таблица на месте. + - `SAFETY_DELAY` — минимум 30 с. +- **Подсказка в режиме skip** больше не советует `nft list ruleset > /etc/nftables.conf`. +- **`na-fw-top-talkers`** снова показывает адреса: колонку пира он брал не ту. +- **Новые ключи маркера:** `crowdsec_scope`, `dnat_guard`, `nft_sha256`, `include_dir`, + `include_files`, `node_port=none`. + +### Диагностика, оптимизатор, отчёт + +#### 🟠 `na-diagnose` судил по накопленному с загрузки + +UDP `RcvbufErrors`, softnet drop/`time_squeeze`, `ListenOverflows` и CPU steal ядро копит с +момента загрузки. На ноде с аптаймом в месяц одна давняя вспышка горела ▲ вечно, а свежий +рост тонул в старом итоге: аудит флота нашёл ▲ «UDP RcvbufErrors» почти на каждой ноде — при +нулевом приросте за время наблюдения. Steal мерился одной секундой и между соседними +прогонами прыгал от 0 до 8%; PSI тревожил по `avg10`, то есть по любому десятисекундному +всплеску (сборка initrd, logrotate). + +Теперь каждый запуск оставляет снимок счётчиков `/var/lib/node-accelerator/diag-counters.last` +(с `boot_id`), а следующий считает прирост — если загрузка та же и прошло не меньше 30 с. +Окно короче — снимок не перезаписывается, чтобы частый опрос мониторинга всё-таки получил +окно. Это единственное, что diagnose пишет на диск; без прав на запись дельт просто нет. +▲ по UDP — только при росте за окно и доле от принятых датаграмм не меньше 0.1% (Udp6 из +`/proc/net/snmp6` учитывается); если на ноде нет публичного UDP-слушателя, текст говорит про +исходящие сокеты и `rmem_default`, а не про QUIC/Hysteria2. Steal — окно 3 с плюс среднее с +загрузки и с прошлого запуска: ✘ только при всплеске на фоне хронического отбора. PSI — по +`avg60`. + +#### 🟠 Ложные ▲, которые приучали не читать отчёт + +- «Ядро не XanMod» при `ENABLE_XANMOD=0` в `optimize.conf` — теперь info: это выбор оператора. +- Уступка маски ротации чужой стансе, у которой кап по размеру есть, — ✔, а не ▲. +- Глубина журнала: volatile-журнал (только `/run/log/journal`) и журнал, который держит ровно + текущую загрузку (самая старая запись ≈ аптайму), получили свой текст — без совета снизить + `PORTSCAN_LOG_RATE`, к которому они отношения не имеют. +- Лог анти-скана оценивался порогом «>100000 строк за загрузку»: порог рос с аптаймом и молчал + после ребута. Теперь — строки за сутки по всем загрузкам и их доля в журнале за те же сутки + (▲ от 50% или от 20% при глубине журнала меньше 48 ч); не измерили — так и пишем. +- Буферы: порог `rmem_max`/`wmem_max` был фиксированными 32M, а optimize на RAM-tier 1 ставит + 16M — ▲ на каждой маленькой ноде. Порог теперь от tier (как в optimize); добавлена проверка + `rmem_default`/`wmem_default`. +- «RPS выключен» при RX-очередей не меньше, чем ядер, — info: приём размазывает RSS. +- `/run/reboot-required` от стоковых ядер при работающем XanMod — info, а не ▲. + +#### 🟠 Порты для сенсоров брались из маркера, который устаревает + +`tcp_ports=` в `protect.installed` пишет только прогон `protect`; после ручной правки правил +или `protect.conf` маркер врал (аудит флота: порты «мимо маркера» на нескольких нодах), и +датчик `CONN_LIMIT` мерил не тот срез. Теперь порты берутся из живых правил (`cc4_<порт>`, +`udp4_<порт>`), при отсутствии таблицы — из `protect.conf` (понимаются обе идиомы: +`: "${K:=v}"` и `: "${K=v}"` из protect 4.2), и лишь затем из маркера. Расхождение живых +правил с `protect.conf` — ▲ с тем, что сделает ре-ран (какие порты закроет и откроет). + +#### 🟠 CrowdSec: «1 решение» при 25 тысячах заблокированных адресов + +`cscli decisions list` без `-a` показывает только локальные решения, а bouncer режет и +community-блоклист: на ноде флота отчёт печатал «1» при ~25 тыс. адресов CAPI. `na-report` +считал строки `"value"` в `-o json` — у одного решения таких полей несколько, выходило ×33. +Теперь решения считаются по строкам `-o raw` с числовым id (заголовок и запятая в имени AS не +мешают), раздельно локальные и CAPI/списки. Diagnose показывает область действия +блок-листов из маркера (`crowdsec_scope`; без него при решениях CAPI — ▲ «режет все порты») +и источники логов из `acquis.yaml`/`acquis.d` (на флоте — только journal sshd). + +#### 🟠 Whitelist сверялся в трёх слоях из четырёх + +Сверялись живой сет, `na_filter.nft` и `protect.conf`, а allowlist CrowdSec — нет, хотя его +bouncer стоит раньше `na_filter` (priority −10): адрес из whitelist, которого нет в allowlist, +CrowdSec может забанить (аудит флота: адрес узла управления был в whitelist, но не в +allowlist). Теперь берётся ЭФФЕКТИВНЫЙ allowlist — объединение ip/cidr всех +`/etc/crowdsec/parsers/s02-enrich/*.yaml` с секцией `whitelist` (CIDR учитывается), дрейф +показывается в обе стороны (▲ — только когда bouncer CrowdSec действительно стоит), а также +«в файле/conf есть, в памяти нет». + +#### 🟠 Пробелы сенсоров + +- **Порты, опубликованные через DNAT** (Docker `-p`, ручные relay-DNAT): трафик идёт хуком + forward мимо input-цепочки `na_filter` — strict их не закрывает, autoban/блоклисты/лимиты не + действуют. Источники — `docker ps` и dnat-правила `nft -t list ruleset` (loopback мимо). + При `dnat_guard=1` в маркере (protect 4.2) — info, иначе ▲ с перечнем. +- **Публичные слушатели вне разрешённых портов** (strict — info «доступны только + whitelist/флоту»; `FW_MODE=open` — info, так задумано; без правил `na_filter` — ▲ «открыты + миру») и **разрешённые порты без слушателя** (info). +- **Память**: MemAvailable < 15% — ▲; занятый своп и balloon-драйвер (virtio/VMware) — info + (на ноде флота balloon гипервизора держал 2.5 ГБ, а MemTotal этого не показывал). +- **`nftables.service`** enabled/active при `flush ruleset` в `/etc/nftables.conf` — ▲ с + советом `systemctl disable` без stop. +- **Ручная правка `na_filter.nft`** (sha256 из маркера protect 4.2 ≠ файлу) — ▲: ре-ран её + перезапишет. Ненулевые именованные счётчики дропов `c_*` — в секции файрвола. +- **Порт xray в эфемерном диапазоне без резерва** — ▲ (см. optimize ниже). +- **Обновление XanMod** по кэшу apt (кандидат только из репозитория XanMod) — info. +- **Сертификаты**: добавлены хранилище Caddy (docker volume и пакетный) и `ssl_certificate` из + включённых конфигов nginx. Просроченный серт раньше маскировался под «не найден» (−1 значил + «нет», и −10 дней «перекрывал» следующий живой серт) — теперь отдельный флаг «найден» и ✘ при + ≤0 дней. +- **json-логи контейнеров без `max-size`** — ▲ (кап из `daemon.json` получают только + контейнеры, созданные после него). +- **OOM/lockup за сутки** — по всем загрузкам (`_TRANSPORT=kernel` = `-k` без неявного `-b`): + OOM, из-за которого нода перезагрузилась, был не виден именно тогда, когда важнее всего. + +Новые поля `--json` — только в хвосте объекта: `udp_rcvbuf_errors_delta`, +`softnet_drops_delta`, `time_squeeze_delta`, `listen_overflows_delta`, `delta_window_s` (−1 — +окна нет), `cpu_steal_boot_pct`, `crowdsec_decisions_local`, `crowdsec_decisions_capi`, +`crowdsec_scope`, `whitelist_drift_crowdsec`, `dnat_ports`, `dnat_guard` (−1 — ключа в +маркере нет), `xanmod_update`, `cert_found`, `portscan_log_lines_24h`, +`portscan_log_share_pct`. Смысл и порядок прежних полей не менялся (`cpu_steal_pct` теперь +мерится окном 3 с, `cert_min_days` при `cert_found=true` может быть ≤0). + +#### 🟠 `na-report`: метрики, которые значили не то, что написано + +`ban_rate_5m` всегда был числом строк drop-лога за 5 минут, а не банов. Поле оставлено ради +совместимости, рядом — честные `events_5m` и `unique_src_5m`. `drops_by_reason.crowdsec` — +активные локальные решения (см. выше), `crowdsec_decisions_all` — вместе с CAPI/списками. +Новое `asn_enrichment` = `ok|partial|unavailable`: пустой ASN больше не путается с «не +спросили». Строки JSON экранируются общим `json_escape`: управляющий символ в имени AS из +whois раньше ломал весь документ. Новые поля — в хвосте. + +#### 🟠 `optimize`: чужой sysctl-файл молча перебивал наши значения + +Файл, сортирующийся позже `99-node-accelerator.conf`, перебивает ключи: на ноде флота так +урезались буферы сокетов до 16M, а optimize печатал «применено». Теперь после `sysctl +--system` значения наших файлов сверяются с ядром; при расхождении называется файл-виновник +(порядок — `systemd-analyze cat-config sysctl.d`, без него — тот же перебор каталогов по имени, +что у systemd-sysctl, с `/etc/sysctl.conf` последним), а ключи, которые ядро записать не дало +(контейнер), — отдельной строкой. Имя нашего файла не менялось. + +#### 🟠 Порты ноды внутри `ip_local_port_range` + +optimize ставит `ip_local_port_range = 10000 65535`, и в него попадают порты самой ноды — API +xray на `127.0.0.1:10085`, высокие inbound'ы. Исходящее соединение может занять такой порт +раньше, чем xray его забиндит (рестарт контейнера под нагрузкой). На флоте их резервировали +руками. Теперь LISTEN-порты процессов xray/rw-core/rw-node (включая loopback) и порт +node-агента, попадающие в диапазон, пишутся в `net.ipv4.ip_local_reserved_ports` вместе с +резервом оператора; свои порты помнятся в `reserved-ports.na`, и порт, с которого xray ушёл, +на ре-ране снимается. xray ещё не запущен — нечего резервировать: ре-ран optimize после старта +ноды (diagnose подскажет). + +#### 🟠 RPS/XPS там, где они вредят + +RPS ставился на все CPU даже на 1 vCPU и на сетевой карте с RX-очередями не меньше числа +ядер (на ноде флота — 12 аппаратных очередей): аппаратный RSS уже раскидывает приём, а RPS +поверх только гоняет пакеты между CPU. XPS-маска «все CPU» затирала раскладку TX-очередей, +которую задаёт драйвер. Теперь `na-rps-setup` в этих случаях RPS не включает (и снимает маски +прошлых версий — к снимку или к 0), XPS трогает только с `NA_XPS=1` (ручка сохраняется в +`optimize.conf`). Исходные маски снимаются при первом применении в `rps-orig.tsv` и +возвращаются при откате. + +#### 🟠 journald: наш drop-in перебивал операторский + +`na-size.conf` сортировался после `99-*.conf` и молча перебивал операторский +`SystemMaxUse`. Теперь drop-in — `10-na-size.conf` (старое имя снимается при апгрейде и +откате). Журнал в RAM (volatile) не переживает ребут — история входов и инцидентов +пропадает; если `Storage=` никто не задал явно, optimize ставит `Storage=persistent`, создаёт +`/var/log/journal` и сразу переносит накопленное (`journalctl --flush`). Осознанный +`Storage=volatile` оператора не трогается. + +#### 🟠 `net.ipv6.conf.all.forwarding=1` убран + +Включённый v6-форвардинг переводит ядро в режим роутера: при `accept_ra=1` оно перестаёт +принимать Router Advertisement, и на хостах с ядерным SLAAC v6 default route истекает через +router lifetime — нода молча теряет IPv6. Выбран вариант «убрать», а не «`accept_ra=2` на +WAN»: xray в host-network форвардинг не нужен вовсе; Docker с включённым IPv6 сам ставит +v6-форвардинг при старте демона; `accept_ra=2` пришлось бы писать по имени интерфейса +(меняется после смены ядра), а на хостах с systemd-networkd RA обрабатывает networkd и +`accept_ra` ядра ни на что не влияет. На уже настроенных нодах runtime-значение не +сбрасывается на ходу (Docker с IPv6 мог на него опереться) — оно уйдёт с ближайшим ребутом. +IPv4-форвардинг (Docker bridge) остаётся. + +#### 🟠 `rollback optimize` возвращал runtime только после ребута + +Удаление файлов не возвращает значения: `sysctl --system` перечитывает лишь чужие файлы, а +ключи, которых больше никто не задаёт, держат наши значения до перезагрузки; маски очередей и +THP — тоже. Теперь при ПЕРВОМ применении снимаются исходные значения ключей наших +sysctl-файлов (`sysctl.orig`), маски RPS/XPS и режим THP; откат возвращает их сразу (затем +`sysctl --system`, так что чужие файлы по-прежнему главнее снимка) и честно пишет, что +вернулось, а что — нет (ноды, где optimize ставился до v4.2, снимка не имеют: RPS +выключается, остальное — до ребута). + +#### 🟠 Ядро XanMod: проверки до и после установки + +XanMod не подписан ключом дистрибутива: при включённом UEFI Secure Boot shim его не загрузит, +и после ребута нода не поднимется. Тесный `/boot` — та же беда тише: postinst не дописывает +initrd, а GRUB уже выбрал новое ядро. Теперь установка ядра пропускается (warn, остальной +тюнинг продолжается) при включённом Secure Boot (`mokutil --sb-state` или efivar +`SecureBoot-8be4df61-…`) и при свободном месте в `/boot` меньше 200 МБ (`NA_BOOT_MIN_MB`). +После установки, до слов «нужна перезагрузка», проверяется, что у нового ядра есть initrd +(одна попытка пересобрать) и драйвер текущего NIC в `/lib/modules/<версия>` — модулем или +встроенным; иначе громкое «НЕ ПЕРЕЗАГРУЖАЙСЯ» с причиной. + +#### 🟡 Мелкое + +- **`default_iface`** брал пятое поле маршрута: для `default dev venet0 scope link` (OpenVZ) + выходило `scope`, для multipath — мусор, и это имя шло в RPS, NIC-tune и diagnose. Теперь — + токен после `dev` (и в `common.sh`, и в генерируемом `na-rps-setup`). +- **`apt_install`** не ждал чужой dpkg-лок: на свежем боксе с работающим unattended-upgrades + обе попытки падали подряд и роняли optimize/protect под `set -e`. Теперь + `-o DPkg::Lock::Timeout=120` (`NA_APT_LOCK_TIMEOUT`). + +### 🧪 Тесты (защита) + +- `tests/nft-behavior.sh` на **настоящем** nft (CI-job `nft-behavior`): опубликованный DNAT-порт — + забаненный адрес режется в forward (до v4.2 проходил), whitelist сильнее autoban, счётчик + `c_dnat_guard` растёт; SSH — пара лишних попыток даёт только suspect, продолжение перебора — + бан; именованные счётчики растут; SYNPROXY — рукопожатие завершается (до v4.2 режим вообще не + включался: проверялся несуществующий модуль). +- `tests/apply-smoke.sh`: счётчики, `dnat_guard`, анти-амплификация, DHCPv6, `::` вне + `bogon_v6`, порядок synproxy/invalid-drop и `tcp_loose=0`, `nft_sha256` = файлу, `none` + держится через ре-ран, явный `SSH_PORT` персистится с меткой, автодетект — нет, устаревший + `SSH_PORT` → оба порта, `na_filter.d` вклеивается, `DNAT_GUARD=0`, снятие ctguard, + `WHITELIST=none`, отказ на `SAFETY_DELAY<30`/кривом `CROWDSEC_SCOPE`/токене с `;` — + 42 новые проверки падают на v4.1.3. +- `tests/protect-unit.sh`: лог анти-скана только в цепочках перехода; `CROWDSEC_SCOPE` + (set-only, правило только на SSH, исключение whitelist, `all` всё снимает, старый bouncer + без set-only); `na-fw` (add/del во всех слоях, мусор вместо адреса, unban); перенос банов + (остаток срока, < 1 с не переносится). +- `tests/common-unit.sh`: `\` в conf, метка `explicit` для `SSH_PORT`, `detect_ssh_port` + (`Listen`, все `port` из `sshd -T`), `detect_node_port` (образ по digest, bridge-тенант не в + счёт, два агента), `SSH_CONNECTION` у предка процесса. +- `tests/ctguard-unit.sh`: клиент за DNAT не фантом. + +### 🧪 Тесты (диагностика и оптимизатор) +- `tests/diagnose-unit.sh`: +12 разделов — дельты счётчиков (рост/нет роста, окно <30 с не + затирает снимок, другая загрузка), steal окном и с загрузки, PSI avg60, ложные ▲ (XanMod + выключен, кап у чужой стансы, volatile-журнал, журнал ≈ аптайму, доля лога анти-скана, «не + измерено», OOM прошлой загрузки), порты из живых правил и обе идиомы conf, CrowdSec по + источникам/scope/acquisition, allowlist CrowdSec (CIDR, ручной файл, парсер без whitelist), + DNAT и слушатели, память/balloon, `nftables.service`, хэш файла, счётчики `c_*`, буферы от + tier, порт xray без резерва, обновление XanMod, reboot-required, просроченный серт, Caddy, + nginx (sites-available не считается), docker без `max-size`, RSS; новые поля строго в хвосте + JSON. Проверка текста теперь без цветовых escape-кодов — значок ✔/▲/✘ реально сверяется. +- `tests/optimize-unit.sh` (новый): секция sysctl против эмулятора `sysctl --system` — + виновник назван (и через `systemd-analyze cat-config`), незаписанный ключ, резерв портов с + сохранением резерва оператора и снятием ушедшего порта, снимок только при первом прогоне, + без v6-форвардинга; journald (`10-na-size.conf`, старое имя снято, persistent, явный + `Storage=` оператора); снимки RPS/THP (в т.ч. пустая маска последней очереди не выбрасывает + снимок); блок отката runtime со снимками и без; проверки XanMod (Secure Boot через mokutil и + efivar, `/boot`, версия ядра из Depends, initrd и его пересборка, драйвер NIC модулем и + встроенный); `default_iface` (venet0, multipath) и `apt_install` (таймаут лока во всех + вызовах, в т.ч. в ретрае). +- `tests/na-report-unit.sh` (новый): CrowdSec по строкам raw (не ×N), `-a`, `events_5m` / + `unique_src_5m`, `asn_enrichment` ok/partial/unavailable, управляющий символ из whois не ломает + JSON, новые поля в хвосте. +- `tests/rps-unit.sh`: XPS не трогается без `NA_XPS=1`, RPS снимается при RX-очередей ≥ ядер и + на 1 vCPU (к снимку или к 0), `Environment=NA_XPS` в юните, `default dev venet0`. + +Каждый новый и расширенный тест падает на v4.1.3 (diagnose-unit — 90 проверок, +optimize-unit — 59, na-report-unit — 14, rps-unit — 13) и проходит на этой ветке. + +### Миграция + +- **`CROWDSEC_SCOPE=ssh` — новый дефолт.** Ре-ран `protect` переводит bouncer в `set-only`, и + блок-листы CrowdSec начинают действовать только на SSH. Нужно прежнее поведение (на всех + портах) — `CROWDSEC_SCOPE=all` при ре-ране, сохранится в conf. Ручной + `crowdsec-nft-scope.service` выключается из автозагрузки (его роль у `na-crowdsec-scope`). +- **`DNAT_GUARD=1` — новый дефолт.** Опубликованные Docker-порты начинают получать проверки + источника. Если за контейнером стоит CDN или фронт, чьи адреса могли попасть в autoban, + внесите их в `WHITELIST` (или `na-fw allow add`). +- **`UDP_AMP_DROP=1` — новый дефолт.** Касается только `UDP_PORTS`. +- **Лог `[na portscan]` резко сократится.** Пишутся только переходы в suspect/бан; объёмы — + в `na-fw-status` (счётчики). +- **`SSH_PORT` из автодетекта больше не хранится в `protect.conf`.** Сохранённый старой + версией и совпадающий с фактом — исчезнет; расходящийся — будут открыты оба порта с warn. + + +- Ре-ран optimize на уже настроенной ноде: снимки для отката не создаются (в ядре уже наши + значения) — откат на таких нодах честно говорит, что вернётся после ребута. XPS-маска прошлых + версий без снимка остаётся до ребута, после него — раскладка драйвера. На нодах с RSS + (очередей ≥ ядер) RPS выключается сразу. v6-форвардинг уходит с ребутом. +- `na-diagnose` создаёт `diag-counters.last`; первый запуск после обновления (и после каждого + ребута) дельт не даёт (`delta_window_s=-1`). +- В `optimize.conf` появляется ключ `NA_XPS`; `optimize.installed` — `tier`, `sock_max`, + `sock_def` (по ним diagnose берёт пороги буферов). + ## v4.1.3 — 2026-10-03 Хотфикс по аудиту живого флота (около 30 нод на 4.0–4.1.2). Главное — тихий отказ, который diff --git a/README.md b/README.md index c1ba918..6ac6a90 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,11 @@ curl -fsSL https://raw.githubusercontent.com/jestivald/node-accelerator/main/ins # прод-режим: пиньте тег через NA_REF — компрометация ветки main тогда не утечёт # сразу на весь флот (скрипты тянутся из того же тега): -export NA_REF=v4.1.3 +export NA_REF=v4.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=v4.1.3 NA_REQUIRE_SIG=1 \ +export NA_REF=v4.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 ``` diff --git a/install.sh b/install.sh index dafdbff..ad95956 100755 --- a/install.sh +++ b/install.sh @@ -13,7 +13,7 @@ # curl-bash: # curl -fsSL https://raw.githubusercontent.com/jestivald/node-accelerator/main/install.sh | sudo bash -s all # # прод: пиньте тег (компрометация ветки main тогда не утечёт сразу на весь флот): -# export NA_REF=v4.1.3; curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF/install.sh" | sudo -E bash -s all +# export NA_REF=v4.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, для форка/зеркала> diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 3e7e210..0c92f10 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -6,7 +6,7 @@ # Версия тулкита — ЕДИНСТВЕННЫЙ источник. Пишется в installed-маркеры и отдаётся # в na-diagnose/na-report --json, чтобы флот-мониторинг видел version-drift по нодам. # shellcheck disable=SC2034 -NA_VERSION="4.1.3" +NA_VERSION="4.2" # Числа — только с десятичной ТОЧКОЙ, сообщения утилит — на C. На ноде с LANG/LC_ALL= # de_DE.UTF-8 mawk читал «0.35» из /proc/loadavg как 0 (в JSON уезжал "load1":0), printf diff --git a/tests/common-unit.sh b/tests/common-unit.sh index 72337bb..5824564 100644 --- a/tests/common-unit.sh +++ b/tests/common-unit.sh @@ -172,16 +172,16 @@ cat > "$T/bin2/docker" <<'DK' # три контейнера: свой агент (образ по digest, host-сеть), второй тенант (bridge, тот же # внутренний порт), агент третьего тенанта (host-сеть, свой порт) case "$*" in - "ps -a --format {{.Names}}") printf 'remnanode\nreka-remnanode\nremnanode-dbn\nnginx\n' ;; + "ps -a --format {{.Names}}") printf 'remnanode\ntenant2-remnanode\nremnanode-t3\nnginx\n' ;; "inspect -f {{.Config.Image}} remnanode") echo 'remnawave/node@sha256:0cdf386dd49f' ;; - "inspect -f {{.Config.Image}} reka-remnanode") echo 'remnawave/node:3.4.1' ;; - "inspect -f {{.Config.Image}} remnanode-dbn") echo 'remnawave/node:latest' ;; + "inspect -f {{.Config.Image}} tenant2-remnanode") echo 'remnawave/node:3.4.1' ;; + "inspect -f {{.Config.Image}} remnanode-t3") echo 'remnawave/node:latest' ;; "inspect -f {{.Config.Image}} nginx") echo 'nginx:alpine' ;; "inspect -f {{.HostConfig.NetworkMode}} remnanode") echo host ;; - "inspect -f {{.HostConfig.NetworkMode}} reka-remnanode") echo reka-node ;; - "inspect -f {{.HostConfig.NetworkMode}} remnanode-dbn") echo host ;; - *"{{range .Config.Env}}"*remnanode-dbn) printf 'NODE_PORT=2322\n' ;; - *"{{range .Config.Env}}"*reka-remnanode) printf 'NODE_PORT=9999\n' ;; + "inspect -f {{.HostConfig.NetworkMode}} tenant2-remnanode") echo tenant2-net ;; + "inspect -f {{.HostConfig.NetworkMode}} remnanode-t3") echo host ;; + *"{{range .Config.Env}}"*remnanode-t3) printf 'NODE_PORT=2322\n' ;; + *"{{range .Config.Env}}"*tenant2-remnanode) printf 'NODE_PORT=9999\n' ;; *"{{range .Config.Env}}"*remnanode) printf 'NODE_PORT=2222\n' ;; *) exit 1 ;; esac From 6535b19befd9de9888b2788d277b8b58f0d8a696 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:22:24 +0300 Subject: [PATCH 08/14] =?UTF-8?q?v4.2:=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=BD=D1=83=20=D0=BD=D0=B0=20=D0=BD=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D1=85=20=E2=80=94=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D1=8F?= =?UTF-8?q?=D1=89=D0=B8=D0=B5=20UDP-=D1=81=D0=BE=D0=BA=D0=B5=D1=82=D1=8B?= =?UTF-8?q?=20xray=20=D0=BD=D0=B5=20=C2=AB=D1=81=D0=BB=D1=83=D1=88=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B8=C2=BB=20=D0=B8=20=D0=BD=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=B8=D1=80=D1=83=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F;=20=D0=B0=D0=BD=D1=82=D0=B8-=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=B0=20100%=20=E2=80=94=20=C2=AB=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=D0=BF=C2=BB,=20=D0=B0=20=D0=BD=D0=B5=20=C2=AB?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B5=D1=82=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - diagnose listen_rows udp: UNCONN xray/rw-core на эфемерном порту вне UDP_PORTS — исходящий сокет (на нодах их десятки), не в «слушают публично» и не в «без резерва» - optimize na_node_listen_ports: только TCP-LISTEN + UDP_PORTS из protect.conf — иначе ip_local_reserved_ports засорялся случайными портами и менялся каждый прогон - dynset: ps*/psc* без timeout на 100% — ▲ «новые сканеры не ловятся», ✘ — только для лимитеров клиентов (SYN/UDP/SSH/ICMP/node-port) - окно дельт «45 с», а не «за 0 мин» Co-Authored-By: Claude Opus 5.5 --- scripts/diagnose.sh | 73 +++++++++++++++++++++++++++++------------- scripts/optimize.sh | 11 +++++-- tests/diagnose-unit.sh | 16 +++++++-- tests/optimize-unit.sh | 5 ++- 4 files changed, 77 insertions(+), 28 deletions(-) diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index c6a9d65..6f6890e 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -277,6 +277,8 @@ mark_val() { } # csv_norm <список> — порты через запятую: только числа, по возрастанию, без повторов csv_norm() { tr ', ' '\n\n' <<<"$1" | grep -E '^[0-9]+$' | sort -un | paste -sd, -; } +# окно дельт по-человечески: «45 с» / «12 мин» (а не «за 0 мин» при коротком окне) +dw_h() { if (( ${DW:-0} < 60 )); then echo "${DW:-0} с"; else echo "$(( DW / 60 )) мин"; fi; } # Живая input-цепочка na_filter — читаем ОДИН раз (её смотрят несколько сенсоров). NA_CHAIN=""; NA_CHAIN_READ=0 @@ -404,15 +406,30 @@ listen_load() { NA_LT="$(ss -Htlnp 2>/dev/null)"; NA_LU="$(ss -Hulnp 2>/dev/null)" return 0 } -# listen_rows tcp|udp — «адреспортпроцесс» (адрес без [] и %iface) +# listen_rows tcp|udp — «адреспортпроцесс» (адрес без [] и %iface). +# UDP: исходящие сокеты xray (UNCONN на случайном эфемерном порту — каждый UDP-запрос +# клиента через ноду) в `ss -ul` неотличимы от слушателей. Их десятки, и без фильтра они +# шли в «слушают публично» и в резерв эфемерных портов (проверено на нодах флота). Входящий +# UDP у xray — всегда явный порт из UDP_PORTS: UDP-сокет xray/rw-core в эфемерном диапазоне +# вне UDP_PORTS — исходящий, пропускаем. +NA_UDP_OK=""; NA_UDP_OK_READ=0 listen_rows() { listen_load - local src="$NA_LT"; [[ "$1" == udp ]] && src="$NA_LU" - awk '$1 == "LISTEN" || $1 == "UNCONN" { + local src="$NA_LT" lo=0 hi=0 + if [[ "$1" == udp ]]; then + src="$NA_LU" + if [[ "$NA_UDP_OK_READ" == 0 ]]; then NA_UDP_OK="$(fw_ports udp 2>/dev/null)"; NA_UDP_OK_READ=1; fi + read -r lo hi <<<"$(val net.ipv4.ip_local_port_range)" + [[ "$lo" =~ ^[0-9]+$ && "$hi" =~ ^[0-9]+$ ]] || { lo=0; hi=0; } + fi + awk -v lo="$lo" -v hi="$hi" -v ok=",$NA_UDP_OK," '$1 == "LISTEN" || $1 == "UNCONN" { la = $4; p = la; sub(/.*:/, "", p); a = substr(la, 1, length(la) - length(p) - 1) sub(/%.*/, "", a); gsub(/[][]/, "", a) proc = "?"; if (match($0, /users:\(\("[^"]+"/)) proc = substr($0, RSTART + 9, RLENGTH - 10) - if (p ~ /^[0-9]+$/) print a "\t" p "\t" proc + if (p !~ /^[0-9]+$/) next + if ($1 == "UNCONN" && hi > 0 && (proc == "xray" || proc == "rw-core") \ + && p + 0 >= lo + 0 && p + 0 <= hi + 0 && index(ok, "," p ",") == 0) next + print a "\t" p "\t" proc }' <<<"$src" } # addr_public <адрес> — wildcard или глобальный (не loopback/link-local/частный/CGNAT) @@ -1067,7 +1084,7 @@ if [[ -r /proc/stat ]]; then [[ "$STEAL" =~ ^[0-9]+$ ]] || STEAL=0; [[ "$STEAL_BOOT" =~ ^[0-9]+$ ]] || STEAL_BOOT=0 STEAL_LONG="$STEAL_BOOT"; [[ "$D_STEAL" -ge 0 ]] && STEAL_LONG="$D_STEAL" STEAL_TXT="CPU steal = ${STEAL}% за 3 с, в среднем с загрузки ${STEAL_BOOT}%" - [[ "$D_STEAL" -ge 0 ]] && STEAL_TXT+=", за $((DW/60)) мин с прошлого запуска ${D_STEAL}%" + [[ "$D_STEAL" -ge 0 ]] && STEAL_TXT+=", за $(dw_h) с прошлого запуска ${D_STEAL}%" if [[ "$STEAL" -ge 10 && "$STEAL_LONG" -ge 5 ]]; then bad "$STEAL_TXT — гипервизор хронически отбирает CPU (оверселл/шумный сосед)" elif [[ "$STEAL" -ge 10 ]]; then wrn "$STEAL_TXT — всплеск: в моменте отбирают заметно, в среднем терпимо" elif [[ "$STEAL" -ge 3 || "$STEAL_LONG" -ge 3 ]]; then wrn "$STEAL_TXT (заметный — под пиками может проседать)" @@ -1303,11 +1320,11 @@ fi if [[ -r /proc/net/softnet_stat ]]; then if [[ "$DW" -ge 0 && "$D_SNDROP" -ge 0 && "$D_SQZ" -ge 0 ]]; then if [[ "$D_SNDROP" -gt 0 ]]; then - wrn "softnet: +$D_SNDROP пакетов отброшено за $((DW/60)) мин (backlog полон, netdev_max_backlog=$(val net.core.netdev_max_backlog)) — приём не успевает: RPS/ядра/CPU steal" + wrn "softnet: +$D_SNDROP пакетов отброшено за $(dw_h) (backlog полон, netdev_max_backlog=$(val net.core.netdev_max_backlog)) — приём не успевает: RPS/ядра/CPU steal" elif [[ $(( D_SQZ / (DW > 0 ? DW : 1) )) -ge 1 ]]; then - wrn "softnet: time_squeeze +$D_SQZ за $((DW/60)) мин (≥1/с) — softirq упирается в бюджет (netdev_budget=$(val net.core.netdev_budget)); дропов нет" + wrn "softnet: time_squeeze +$D_SQZ за $(dw_h) (≥1/с) — softirq упирается в бюджет (netdev_budget=$(val net.core.netdev_budget)); дропов нет" else - pass "softnet: за $((DW/60)) мин дропов нет, time_squeeze +$D_SQZ" + pass "softnet: за $(dw_h) дропов нет, time_squeeze +$D_SQZ" fi else info "softnet с загрузки: dropped=$C_SNDROP time_squeeze=$C_SQZ (прирост покажет следующий запуск — снимок сохранён)" @@ -1479,28 +1496,40 @@ if nft -t list table inet na_filter >/dev/null 2>&1; then # полном наборе новые клиенты на порт отсекаются (для ssh4 — новый IP админа в бан). # максимум считаем ОТДЕЛЬНО по наборам без timeout: ✘ — про них (режут клиентов), # заполненный набор с timeout — другой случай (флуд, лимит отключается) - DS_MAX=-1; DS_MAXN=""; DS_NT=0; DS_N=0; DS_NTMAX=-1; DS_NTMAXN="" + DS_MAX=-1; DS_MAXN=""; DS_NT=0; DS_N=0 + # без timeout — два разных отказа: лимитер «в лимите → accept» (SYN/UDP/SSH/ICMP/node-port) + # на полном наборе РЕЖЕТ новых клиентов; анти-скан (ps*/psc*, «превысил → suspect/бан») — + # СЛЕПНЕТ: новые сканеры не попадают ни в suspect, ни в бан (на ноде флота ps4 стоял на 100%) + DS_CMAX=-1; DS_CMAXN=""; DS_PMAX=-1; DS_PMAXN="" while IFS=$'\t' read -r _dn _dc _ds _dt; do [[ -n "$_dn" ]] || continue DS_N=$((DS_N+1)) (( _dc * 100 / _ds > DS_MAX )) && { DS_MAX=$(( _dc * 100 / _ds )); DS_MAXN="$_dn ($_dc из $_ds)"; } if [[ "$_dt" == "0" ]]; then DS_NT=$((DS_NT+1)) - (( _dc * 100 / _ds > DS_NTMAX )) && { DS_NTMAX=$(( _dc * 100 / _ds )); DS_NTMAXN="$_dn ($_dc из $_ds)"; } + if [[ "$_dn" =~ ^psc?[46]$ ]]; then + (( _dc * 100 / _ds > DS_PMAX )) && { DS_PMAX=$(( _dc * 100 / _ds )); DS_PMAXN="$_dn ($_dc из $_ds)"; } + else + (( _dc * 100 / _ds > DS_CMAX )) && { DS_CMAX=$(( _dc * 100 / _ds )); DS_CMAXN="$_dn ($_dc из $_ds)"; } + fi fi done < <(dynset_fill 2>/dev/null) if [[ "$DS_N" -gt 0 ]]; then - if [[ "$DS_NT" -gt 0 && "$DS_NTMAX" -ge 80 ]]; then - bad "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2) заполнены до ${DS_NTMAX}%: $DS_NTMAXN — на 100% новые клиенты на порт отсекаются. Ре-ран protect (4.1.3+); пожарно: systemctl reload na-firewall (сбросит и баны)" - elif [[ "$DS_NT" -gt 0 ]]; then - wrn "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2): адреса копятся до ребута, сейчас максимум ${DS_NTMAX}% — $DS_NTMAXN. На 100% новые клиенты на порт отсекаются: ре-ран protect (4.1.3+)" - elif [[ "$DS_MAX" -ge 80 ]]; then + if [[ "$DS_CMAX" -ge 80 ]]; then + bad "наборы-лимитеры без timeout (правила ≤4.1.2) заполнены до ${DS_CMAX}%: $DS_CMAXN — на 100% новые клиенты на порт отсекаются. Ре-ран protect (4.1.3+); пожарно: systemctl reload na-firewall (сбросит и баны)" + fi + if [[ "$DS_PMAX" -ge 80 ]]; then + wrn "анти-скан без timeout (правила ≤4.1.2) заполнен до ${DS_PMAX}%: $DS_PMAXN — на 100% новые сканеры не попадают ни в suspect, ни в бан (клиентов не режет). Ре-ран protect (4.1.3+)" + fi + if [[ "$DS_NT" -gt 0 && "$DS_CMAX" -lt 80 && "$DS_PMAX" -lt 80 ]]; then + wrn "наборы-лимитеры без timeout ($DS_NT шт., правила ≤4.1.2): адреса копятся до ребута, сейчас максимум ${DS_CMAX}% у лимитеров клиентов${DS_CMAXN:+ ($DS_CMAXN)}. На 100% новые клиенты на порт отсекаются: ре-ран protect (4.1.3+)" + elif [[ "$DS_NT" -eq 0 && "$DS_MAX" -ge 80 ]]; then wrn "набор-лимитер заполнен на ${DS_MAX}%: $DS_MAXN — флуд с множества адресов; на 100% лимит перестаёт действовать для новых адресов (клиентов не режет)" - else + elif [[ "$DS_NT" -eq 0 ]]; then pass "наборы-лимитеры с timeout, максимум заполнения ${DS_MAX}% ($DS_MAXN)" fi fi - unset _dn _dc _ds _dt DS_NTMAX DS_NTMAXN + unset _dn _dc _ds _dt DS_CMAX DS_CMAXN DS_PMAX DS_PMAXN # Порты: живые правила ↔ protect.conf ↔ маркер. Живые — то, что действует; conf — то, # что применит ре-ран protect; маркер пишет только прогон protect. Ручная правка правил # (порт добавлен `nft add rule`) живёт до ре-рана — и молча исчезает (аудит флота). @@ -1747,11 +1776,11 @@ if [[ "$UDP_SVC" == 1 ]]; then UDP_CTX="входящий UDP (QUIC/Hysteria2/TUI else UDP_CTX="UDP-слушателей нет — это исходящие сокеты (xray/DNS) с буфером по умолчанию: net.core.rmem_default=$(val net.core.rmem_default)"; fi if [[ "$DW" -ge 0 && "$D_UDPERR" -ge 0 ]]; then if [[ "$D_UDPERR" -gt 0 ]] && { [[ "$D_UDPIN" -le 0 ]] || [[ $(( D_UDPERR * 1000 / D_UDPIN )) -ge 1 ]]; }; then - wrn "UDP RcvbufErrors +$D_UDPERR за $((DW/60)) мин ($(awk -v e="$D_UDPERR" -v d="$D_UDPIN" 'BEGIN{ printf "%.2f", (d > 0 ? e * 100 / d : 100) }')% принятых) — $UDP_CTX" + wrn "UDP RcvbufErrors +$D_UDPERR за $(dw_h) ($(awk -v e="$D_UDPERR" -v d="$D_UDPIN" 'BEGIN{ printf "%.2f", (d > 0 ? e * 100 / d : 100) }')% принятых) — $UDP_CTX" elif [[ "$D_UDPERR" -gt 0 ]]; then - info "UDP RcvbufErrors +$D_UDPERR за $((DW/60)) мин (<0.1% принятых — шум)" + info "UDP RcvbufErrors +$D_UDPERR за $(dw_h) (<0.1% принятых — шум)" else - pass "UDP RcvbufErrors не растут (за $((DW/60)) мин +0; с загрузки $C_UDPERR)" + pass "UDP RcvbufErrors не растут (за $(dw_h) +0; с загрузки $C_UDPERR)" fi elif [[ "$C_UDPERR" -gt 0 && "$C_UDPIN" -gt 0 && $(( C_UDPERR * 100 / C_UDPIN )) -ge 1 ]]; then wrn "UDP RcvbufErrors = $C_UDPERR с загрузки (≥1% принятых) — $UDP_CTX; рост покажет следующий запуск" @@ -1761,9 +1790,9 @@ fi # Переполнение accept-очереди: SYN/ACK отброшен, клиент ждёт ретрансмита (секунды) if [[ "$DW" -ge 0 && "$D_LOVF" -ge 0 ]]; then if [[ "$D_LOVF" -gt 0 ]]; then - wrn "accept-очередь переполнялась: ListenOverflows +$D_LOVF (ListenDrops +$D_LDROP) за $((DW/60)) мин (somaxconn=$(val net.core.somaxconn)) — приложение не успевает accept'ить" + wrn "accept-очередь переполнялась: ListenOverflows +$D_LOVF (ListenDrops +$D_LDROP) за $(dw_h) (somaxconn=$(val net.core.somaxconn)) — приложение не успевает accept'ить" else - pass "accept-очереди не переполнялись за $((DW/60)) мин" + pass "accept-очереди не переполнялись за $(dw_h)" fi elif [[ "$C_LOVF" -gt 0 ]]; then info "ListenOverflows = $C_LOVF (ListenDrops = $C_LDROP) с загрузки (рост покажет следующий запуск)" diff --git a/scripts/optimize.sh b/scripts/optimize.sh index 8f2015d..d3a3448 100755 --- a/scripts/optimize.sh +++ b/scripts/optimize.sh @@ -496,11 +496,16 @@ port_in_list() { done return 1 } -# LISTEN-порты процессов ноды (xray / rw-core / rw-node; TCP и UDP — диапазон общий) + -# порт node-агента. По строке на порт. +# TCP-LISTEN процессов ноды (xray / rw-core / rw-node) + входящие UDP из UDP_PORTS +# protect.conf + порт node-агента. По строке на порт. UDP из `ss -ul` НЕ берём: там же +# висят исходящие UDP-сокеты xray на случайных эфемерных портах (десятки на ноде флота) — +# зарезервировать их значит засорить ip_local_reserved_ports и менять его каждый прогон. na_node_listen_ports() { - { ss -Htlnp 2>/dev/null; ss -Hulnp 2>/dev/null; } \ + local u + ss -Htlnp 2>/dev/null \ | awk '/users:\(\("(xray|rw-core|rw-node)"/ { la = $4; sub(/.*:/, "", la); if (la ~ /^[0-9]+$/) print la }' || true + u="$(sed -nE 's/^: "\$\{UDP_PORTS:?=([^}]*)\}".*/\1/p' "$CONF_DIR/protect.conf" 2>/dev/null | tail -1)" + for u in ${u//,/ }; do [[ "$u" =~ ^[0-9]+$ ]] && echo "$u"; done detect_node_port 2>/dev/null | tr ',' '\n' || true } # Итоговый ip_local_reserved_ports (NA_RESV_LIST): резерв оператора (текущее значение diff --git a/tests/diagnose-unit.sh b/tests/diagnose-unit.sh index b107623..3f930b4 100755 --- a/tests/diagnose-unit.sh +++ b/tests/diagnose-unit.sh @@ -644,7 +644,14 @@ check "json: dynset_fill_max_pct=85 (autoban не лимитер)" 85 "$(jget "$ check "json: dynset_fill_max_set=syn4_443" syn4_443 "$(jget "$T/out9.json" dynset_fill_max_set)" check "json: dynset_no_timeout=1 (autoban и cc4 не в счёт)" 1 "$(jget "$T/out9.json" dynset_no_timeout)" env TERM=dumb "$WBASH" "$DIAG" > "$T/out9.txt" 2>/dev/null || true -grep_ok "текст: ✘ legacy-набор почти полон" "наборы-лимитеры без timeout (1 шт., правила ≤4.1.2) заполнены до 85%" "$T/out9.txt" +grep_ok "текст: ✘ legacy-набор почти полон" "✘ наборы-лимитеры без timeout (правила ≤4.1.2) заполнены до 85%: syn4_443" "$T/out9.txt" +# анти-скан на 100% — слепнет, но клиентов не режет: ▲, а не ✘ +sed -i.bak 's/set syn4_443 {/set ps4 {/' "$NFTD/terse"; rm -f "$NFTD/terse.bak" +mk_set ps4 100 dynamic "" 100 set +env TERM=dumb "$WBASH" "$DIAG" > "$T/out9p.txt" 2>/dev/null || true +grep_ok "текст: ▲ полный ps4 — анти-скан ослеп" "▲ анти-скан без timeout (правила ≤4.1.2) заполнен до 100%: ps4" "$T/out9p.txt" +grep_not "текст: полный ps4 — не ✘ «клиенты отсекаются»" "✘ наборы-лимитеры" "$T/out9p.txt" +sed -i.bak 's/set ps4 {/set syn4_443 {/' "$NFTD/terse"; rm -f "$NFTD/terse.bak" "$NFTD/set-ps4" # nft 1.0.6, правила ≤4.1.2: meter анонимный — в -t есть только внутри правила cat > "$NFTD/terse" <<'TERSE' @@ -955,12 +962,17 @@ LISTEN 0 128 [::]:10050 [::]:* users:(("monitor-agent",pid=20,fd=5)) LISTEN 0 4096 127.0.0.1:10085 0.0.0.0:* users:(("xray",pid=10,fd=8)) LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=5,fd=14)) LTP -printf 'UNCONN 0 0 10.0.0.5%%eth0:68 0.0.0.0:* users:(("dhclient",pid=3,fd=6))\n' > "$SSD/listen-up" +# + исходящие UDP-сокеты xray на эфемерных портах (на ноде их десятки): не слушатели +{ printf 'UNCONN 0 0 10.0.0.5%%eth0:68 0.0.0.0:* users:(("dhclient",pid=3,fd=6))\n' + printf 'UNCONN 0 0 *:23456 *:* users:(("rw-core",pid=10,fd=31))\n' + printf 'UNCONN 0 0 *:51234 *:* users:(("rw-core",pid=10,fd=32))\n'; } > "$SSD/listen-up" cp "$NFTD/chain-input" "$T/chain.orig" awk '{print} /meter cc4_443/ { print "\t\ttcp dport 8445 ct state new meter cc4_8445 { ip saddr ct count over 8192 } drop" }' "$T/chain.orig" > "$NFTD/chain-input" env TERM=dumb "$WBASH" "$DIAG" > "$T/o17c.txt" 2>/dev/null || true grep_ok "strict: слушатель вне портов — info, назван процесс" "strict их режет (доступны только whitelist/флоту): tcp/10050(monitor-agent)" "$T/o17c.txt" grep_not "DHCP-клиент не в списке слушателей" "udp/68" "$T/o17c.txt" +grep_not "исходящий UDP-сокет xray — не «слушатель» (проверено на нодах флота)" "udp/23456" "$T/o17c.txt" +grep_not "…и не «порт ноды без резерва»" "23456(rw-core)" "$T/o17c.txt" grep_ok "разрешён, но никто не слушает — info" "разрешены в файрволе, но никто не слушает: tcp/8445" "$T/o17c.txt" sed 's/^fw_mode=strict/fw_mode=open/' "$T/protect.installed.orig" > "$PROT" env TERM=dumb "$WBASH" "$DIAG" > "$T/o17d.txt" 2>/dev/null || true diff --git a/tests/optimize-unit.sh b/tests/optimize-unit.sh index 2d88536..1ee15db 100755 --- a/tests/optimize-unit.sh +++ b/tests/optimize-unit.sh @@ -172,7 +172,9 @@ LISTEN 0 4096 127.0.0.1:10085 0.0.0.0:* users:(("xray",pid=10,fd=8)) LISTEN 0 511 0.0.0.0:2222 0.0.0.0:* users:(("rw-node",pid=11,fd=3)) LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1,fd=3)) L -printf 'UNCONN 0 0 0.0.0.0:443 0.0.0.0:* users:(("xray",pid=10,fd=9))\n' > "$T/ss-u" +# + исходящие UDP-сокеты xray на эфемерных портах — резервировать их нельзя +{ printf 'UNCONN 0 0 0.0.0.0:443 0.0.0.0:* users:(("xray",pid=10,fd=9))\n' + printf 'UNCONN 0 0 *:40404 *:* users:(("rw-core",pid=10,fd=31))\n'; } > "$T/ss-u" # чужой файл сортируется ПОЗЖЕ нашего (t > n) и урезает буфер printf 'net.core.rmem_max = 16777216\n' > "$T/etc/sysctl.d/99-tuning.conf" run_sysctl() { rc=0; "$WBASH" "$T/wrap-sysctl.sh" > "$T/out" 2>&1 || rc=$?; return 0; } @@ -185,6 +187,7 @@ expect_not "net.ipv6.conf.all.forwarding больше не ставится" gre expect "net.ipv4.ip_forward на месте" grep -qE '^net.ipv4.ip_forward[[:space:]]+= 1' "$F" expect "резерв: порт xray 10085 (127.0.0.1) + резерв оператора" grep -qE '^net.ipv4.ip_local_reserved_ports = 10085,20000-20010$' "$F" expect_not "резерв: 443 и 2222 ниже диапазона — не резервируются" grep -qE 'reserved_ports = .*(443|2222)' "$F" +expect_not "резерв: исходящий UDP-сокет xray (40404) не резервируется" grep -qE 'reserved_ports = .*40404' "$F" expect "состояние: наши порты = 10085" grep -qx 10085 "$T/state/reserved-ports.na" expect "снимок исходных значений создан" test -s "$T/state/sysctl.orig" expect "снимок: rmem_max = сток 212992" tsv_has "$T/state/sysctl.orig" net.core.rmem_max 212992 From 11f83ef5eb2a620183bb3a5e6b110d0b46bf6ddb Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 17:26:25 +0300 Subject: [PATCH 09/14] =?UTF-8?q?v4.2:=20diagnose=20=E2=80=94=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D1=8C=20CrowdSec=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=B6=D0=B8=D0=B2=D1=8B=D0=BC=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D0=B0=D0=BC=20(=D1=80=D1=83=D1=87=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D1=81=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=20:22=20=D0=BD=D0=B5=20=E2=96=B2;=20set-only=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=D1=85?= =?UTF-8?q?=20=E2=80=94=20=E2=96=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/diagnose.sh | 44 ++++++++++++++++++++++++++++++++---------- tests/diagnose-unit.sh | 26 +++++++++++++++++++++---- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index 6f6890e..73c96e5 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -278,6 +278,21 @@ mark_val() { # csv_norm <список> — порты через запятую: только числа, по возрастанию, без повторов csv_norm() { tr ', ' '\n\n' <<<"$1" | grep -E '^[0-9]+$' | sort -un | paste -sd, -; } # окно дельт по-человечески: «45 с» / «12 мин» (а не «за 0 мин» при коротком окне) +# cs_live_scope — где РЕАЛЬНО действуют блок-листы CrowdSec, по живым правилам таблиц +# bouncer'а (маркер есть только после protect 4.2, а на части флота CrowdSec сужали до SSH +# руками): all — есть drop по набору без ограничения портом; ports:<список> — все такие +# правила с dport; none — правил нет вовсе (set-only без своих правил: решения никого не +# режут); rc=1 — таблиц bouncer'а нет. +cs_live_scope() { + local t rules + t="$( { nft -t list table ip crowdsec 2>/dev/null; nft -t list table ip6 crowdsec6 2>/dev/null; } )" + [[ -n "$t" ]] || return 1 + rules="$(grep -E 'saddr @[A-Za-z0-9_.-]+ .*(drop|reject)' <<<"$t" || true)" + if [[ -z "$rules" ]]; then echo none + elif grep -qvE 'dport' <<<"$rules"; then echo all + else echo "ports:$(grep -oE 'dport (\{[^}]*\}|[0-9]+)' <<<"$rules" | grep -oE '[0-9]+' | sort -un | paste -sd, -)" + fi +} dw_h() { if (( ${DW:-0} < 60 )); then echo "${DW:-0} с"; else echo "$(( DW / 60 )) мин"; fi; } # Живая input-цепочка na_filter — читаем ОДИН раз (её смотрят несколько сенсоров). @@ -864,7 +879,7 @@ emit_json() { csl=-1; csc=-1 if tmpv="$(cs_decisions)"; then read -r csl csc <<<"$tmpv"; fi [[ "$csl" =~ ^-?[0-9]+$ ]] || csl=-1; [[ "$csc" =~ ^-?[0-9]+$ ]] || csc=-1 - csscope="$(mark_val crowdsec_scope)" + csscope="$(cs_live_scope 2>/dev/null || mark_val crowdsec_scope)" # живой whitelist, не прикрытый ЭФФЕКТИВНЫМ allowlist CrowdSec (−1 = сверять не с чем): # такой адрес CrowdSec может забанить, а bouncer режет раньше na_filter wdcs=-1 @@ -1720,15 +1735,24 @@ if command -v cscli >/dev/null 2>&1; then info "CrowdSec decisions: не измерено (cscli не ответил)"; DEC_C=0 fi CS_SCOPE="$(mark_val crowdsec_scope)" - if nft list table ip crowdsec >/dev/null 2>&1; then - case "$CS_SCOPE" in - ssh) info "блок-листы CrowdSec действуют только на SSH-порт (crowdsec_scope=ssh)" ;; - all) info "блок-листы CrowdSec действуют на ВСЕ порты (crowdsec_scope=all — выбор оператора)" ;; - *) if [[ "${DEC_C:-0}" -gt 0 ]]; then - wrn "bouncer CrowdSec (priority -10, раньше na_filter) режет ${DEC_C} адресов CAPI/списков на ВСЕХ портах — в т.ч. мобильный CGNAT; protect v4.2 сужает до SSH (CROWDSEC_SCOPE=ssh)" - else - info "таблица bouncer'а ip crowdsec присутствует (priority -10, раньше na_filter; область — все порты)" - fi ;; + if CS_LIVE="$(cs_live_scope)"; then + case "$CS_LIVE" in + ports:*) + info "блок-листы CrowdSec действуют только на портах ${CS_LIVE#ports:} (по живым правилам$([[ -z "$CS_SCOPE" ]] && echo ', настроено вручную'))" ;; + all) + if [[ "$CS_SCOPE" == all ]]; then + info "блок-листы CrowdSec действуют на ВСЕ порты (CROWDSEC_SCOPE=all — выбор оператора)" + elif [[ "${DEC_C:-0}" -gt 0 ]]; then + wrn "bouncer CrowdSec (priority -10, раньше na_filter) режет ${DEC_C} адресов CAPI/списков на ВСЕХ портах — в т.ч. мобильный CGNAT; protect v4.2 сужает до SSH (CROWDSEC_SCOPE=ssh)" + else + info "таблица bouncer'а ip crowdsec присутствует (priority -10, раньше na_filter; область — все порты)" + fi ;; + none) + if [[ "$(( ${DEC_L:-0} + ${DEC_C:-0} ))" -gt 0 ]]; then + wrn "решений CrowdSec $(( ${DEC_L:-0} + ${DEC_C:-0} )), а правил, которые их применяют, нет (bouncer в set-only без своих правил?) — блок-листы никого не режут: ре-ран protect (CROWDSEC_SCOPE=ssh|all)" + else + info "таблицы bouncer'а есть, правил нет (решений тоже нет)" + fi ;; esac fi # откуда CrowdSec вообще берёт события: на флоте — только journal sshd diff --git a/tests/diagnose-unit.sh b/tests/diagnose-unit.sh index 3f930b4..773ae1f 100755 --- a/tests/diagnose-unit.sh +++ b/tests/diagnose-unit.sh @@ -259,6 +259,17 @@ case "$*" in "list table inet na_filter") exit 0 ;; "list table inet na_ctguard") exit 1 ;; "list table ip crowdsec") [ "${NA_TEST_CS_TABLE:-0}" = 1 ] && exit 0; exit 1 ;; + # живые правила bouncer'а: all — дроп без порта (дефолт bouncer'а), ssh — только :22 + # (CROWDSEC_SCOPE=ssh или ручная настройка), none — set-only без правил + "-t list table ip crowdsec") + [ "${NA_TEST_CS_TABLE:-0}" = 1 ] || exit 1 + printf 'table ip crowdsec {\n\tset crowdsec-blacklists-CAPI {\n\t\ttype ipv4_addr\n\t\tflags timeout\n\t}\n' + case "${NA_TEST_CS_RULES:-all}" in + all) printf '\tchain crowdsec-chain-input {\n\t\ttype filter hook input priority filter - 10; policy accept;\n\t\tip saddr @crowdsec-blacklists-CAPI drop\n\t}\n' ;; + ssh) printf '\tchain na-scope-input {\n\t\ttype filter hook input priority filter - 10; policy accept;\n\t\tip saddr { 203.0.113.5 } return # na-wl4\n\t\ttcp dport { 22 } ip saddr @crowdsec-blacklists-CAPI counter packets 5 bytes 300 drop\n\t}\n' ;; + esac + printf '}\n'; exit 0 ;; + "-t list table ip6 crowdsec6") exit 1 ;; "list counters table inet na_filter") [ -f "$NFTD/counters" ] && cat "$NFTD/counters"; exit 0 ;; "-t list ruleset") [ -f "$NFTD/ruleset" ] && cat "$NFTD/ruleset"; exit 0 ;; "list chain inet na_filter input") cat "$NFTD/chain-input"; exit 0 ;; @@ -890,12 +901,19 @@ env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_DECISIONS=2 NA_TEST_CAPI=33 "$WBASH" "$ grep_ok "текст: локальные и CAPI раздельно" "2 локальных (crowdsec/cscli), 33 из CAPI/списков" "$T/o15.txt" grep_ok "текст: scope неизвестен (≤4.1) + CAPI → ▲ «на ВСЕХ портах»" "режет 33 адресов CAPI/списков на ВСЕХ портах" "$T/o15.txt" grep_ok "текст: acquisition — только sshd" "CrowdSec читает: journalctl[_SYSTEMD_UNIT=ssh.service] journalctl[_SYSTEMD_UNIT=sshd.service] — только sshd" "$T/o15.txt" +# правила сужены до :22 руками (маркера 4.2 ещё нет) — не ▲, а info «вручную» (по живым правилам) +env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_CS_RULES=ssh NA_TEST_CAPI=33 "$WBASH" "$DIAG" > "$T/o15m.txt" 2>/dev/null || true +grep_ok "ручной scope :22 → info по живым правилам" "блок-листы CrowdSec действуют только на портах 22 (по живым правилам, настроено вручную)" "$T/o15m.txt" +grep_not "ручной scope → ▲ «на ВСЕХ портах» нет" "на ВСЕХ портах" "$T/o15m.txt" printf 'crowdsec_scope=ssh\n' >> "$PROT" -env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_CAPI=33 "$WBASH" "$DIAG" > "$T/o15b.txt" 2>/dev/null || true -grep_ok "scope=ssh из маркера → info" "блок-листы CrowdSec действуют только на SSH-порт" "$T/o15b.txt" +env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_CS_RULES=ssh NA_TEST_CAPI=33 "$WBASH" "$DIAG" > "$T/o15b.txt" 2>/dev/null || true +grep_ok "scope=ssh → info" "блок-листы CrowdSec действуют только на портах 22 (по живым правилам)" "$T/o15b.txt" grep_not "scope=ssh → ▲ «на ВСЕХ портах» нет" "на ВСЕХ портах" "$T/o15b.txt" -env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o15c.json" 2>/dev/null || true -check "json: crowdsec_scope из маркера" ssh "$(jget "$T/o15c.json" crowdsec_scope)" +env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_CS_RULES=ssh "$WBASH" "$DIAG" --json > "$T/o15c.json" 2>/dev/null || true +check "json: crowdsec_scope по живым правилам" "ports:22" "$(jget "$T/o15c.json" crowdsec_scope)" +# set-only без правил при наличии решений — блок-листы никого не режут: ▲ +env TERM=dumb NA_TEST_CS_TABLE=1 NA_TEST_CS_RULES=none NA_TEST_CAPI=33 "$WBASH" "$DIAG" > "$T/o15n.txt" 2>/dev/null || true +grep_ok "решения есть, правил нет → ▲" "а правил, которые их применяют, нет" "$T/o15n.txt" cp "$T/protect.installed.orig" "$PROT" echo "== 16. whitelist: 4-й слой — эффективный allowlist CrowdSec (v4.2) ==" From c9fb77833c530097838f34e8d14bf3c94f4fe884 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 21:25:55 +0300 Subject: [PATCH 10/14] =?UTF-8?q?v4.2:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=E2=80=94=20CrowdSec=20scope=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20bouncer'=D0=B0,=20na-fw?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=20pipefail,=20SYNPROXY=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B0=20=D0=BB=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D0=BE=D0=B2,=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D1=80=D0=B2=D0=B0=D1=80=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0,=20=D0=B1=D1=80=D0=BE=D1=88=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=B5=D1=80=D1=82=D1=8B,=20CI=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D1=85=20nft=20=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B5=D0=BC=20CrowdS?= =?UTF-8?q?ec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protect: переключение CROWDSEC_SCOPE в порядке stop → таблицы/режим → start → проверка (restart после наших таблиц: старый процесс сносил таблицу, новый в set-only не стартовал); не поднялся — откат на all; all удаляет наши таблицы и не перезапускает обычный bouncer; whitelist правила — именованные наборы с auto-merge; ручной crowdsec-nft-scope возвращается при откате; SYNPROXY: бан/блок-листы/SYN-rate до synproxy, whitelist сильнее бана, пустой TCP_PORTS не ставит synproxy, warn про глобальный tcp_loose=0; устаревший SSH_PORT без метки explicit; бюджет журнала по сумме лог-правил; detect_ssh_port без loopback - na-fw: nft -c до любых изменений, лок protect, отказ на префиксах шире /8 (/16), наборы правила scope на ходу, не падает под pipefail на пустой семье - rollback optimize: форвардинг не возвращается из снимка, conntrack_max не ниже 2× count - optimize: NA_IPV6_FORWARD (авто по Ip6OutForwDatagrams, закрепление в conf); наш порт вырезается из диапазона, склеенного ядром с портом оператора - diagnose: --json --fast; cscli --limit 0; брошенный серт — ▲ и cert_stale_*, а не ✘ - na-report: разделитель \x1f (пустая страна не сдвигает имя AS); cscli --limit 0 - тесты: cs-scope-nft (настоящий nft, в smoke + nft-behavior), crowdsec-e2e (настоящий bouncer), SYNPROXY в nft-behavior, Debian 11 / Ubuntu 20.04 в smoke-матрице Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 25 ++++ CHANGELOG.md | 125 ++++++++++++++----- README.md | 22 ++-- scripts/diagnose.sh | 124 +++++++++++++++---- scripts/lib/common.sh | 3 +- scripts/na-report.sh | 12 +- scripts/optimize.sh | 47 ++++++- scripts/protect.sh | 259 ++++++++++++++++++++++++++------------- scripts/rollback.sh | 42 +++++-- tests/apply-smoke.sh | 4 + tests/crowdsec-e2e.sh | 90 ++++++++++++++ tests/cs-scope-nft.sh | 86 +++++++++++++ tests/diagnose-unit.sh | 45 ++++++- tests/na-report-unit.sh | 15 ++- tests/nft-behavior.sh | 11 +- tests/optimize-unit.sh | 44 ++++++- tests/protect-unit.sh | 42 +++++-- 17 files changed, 820 insertions(+), 176 deletions(-) create mode 100755 tests/crowdsec-e2e.sh create mode 100755 tests/cs-scope-nft.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a23d87..f1961fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,27 @@ jobs: 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 + - name: правило CROWDSEC_SCOPE=ssh на настоящем nft (auto-merge, правка как в na-fw, ре-загрузка) + run: sudo bash tests/cs-scope-nft.sh + + # ── CROWDSEC_SCOPE на НАСТОЯЩЕМ crowdsec-firewall-bouncer ─────────────────── + # Блокер ревью v4.2: restart bouncer'а после наших таблиц — старый процесс на выходе + # удалял таблицу, новый в set-only не стартовал, и CrowdSec не блокировал ничего. Юнит + # подменяет systemctl/nft; здесь bouncer настоящий (репо packagecloud, как в protect.sh). + crowdsec-e2e: + runs-on: ubuntu-latest + steps: + # SHA-пин: плавающий тег v4 может быть перенесён на другой коммит + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install CrowdSec + nftables bouncer (packagecloud any/any, ключ — в keyring) + run: | + curl -fsSL https://packagecloud.io/crowdsec/crowdsec/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/crowdsec.gpg + echo "deb [signed-by=/usr/share/keyrings/crowdsec.gpg] https://packagecloud.io/crowdsec/crowdsec/any any main" \ + | sudo tee /etc/apt/sources.list.d/crowdsec.list >/dev/null + sudo apt-get update -qq + sudo apt-get install -y -qq nftables crowdsec crowdsec-firewall-bouncer-nftables + - name: ssh ↔ all на настоящем bouncer'е (не падает, решения доезжают) + run: sudo bash tests/crowdsec-e2e.sh # ── Smoke-тесты на матрице дистрибутивов ──────────────────────────────────── # Ловит ровно тот класс регрессий, из-за которых появился этот PR: установка @@ -130,6 +151,8 @@ jobs: fail-fast: false matrix: image: + - "debian:bullseye" # nft 0.9.8 — в README заявлен, в матрице не было + - "ubuntu:20.04" # focal, nft 0.9.3 — то же - "debian:bookworm" - "debian:trixie" - "ubuntu:22.04" # jammy → подменяется на bookworm @@ -154,6 +177,8 @@ jobs: bash curl gnupg nftables iproute2 gawk procps kmod coreutils irqbalance ethtool jq - name: Syntax (bash -n) run: bash -n install.sh scripts/*.sh scripts/lib/*.sh + - name: правило CROWDSEC_SCOPE=ssh грузится nft этой ОС (whitelist с auto-merge) + run: bash tests/cs-scope-nft.sh - name: protect.sh DRY_RUN (дефолты — генерация + nft -c) run: DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 bash scripts/protect.sh - name: protect.sh DRY_RUN (full-feature — blocklists/fleet/whitelist-only/ban-once → nft -c) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6779d..6941b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,18 @@ Community-лист CAPI (~25 тыс. адресов) bouncer применял к исключением для whitelist (whitelist парсера CrowdSec от решений CAPI не защищает: адрес админа в community-листе отрезал бы ему SSH). Правила грузит `na-crowdsec-scope.service` до bouncer'а; ручной юнит, сужавший CrowdSec до SSH, выключается из автозагрузки (его файл -остаётся). `all` — как раньше, на всех портах. Старый bouncer без `set-only` — честный откат -на `all` с предупреждением. `rollback protect` возвращает bouncer к своим правилам. +остаётся; флаг в состоянии запоминает это, и `rollback protect` включает его обратно вместе с +`set-only`, а не раскрывает community-лист на все порты). `all` — как раньше, на всех портах. +Старый bouncer без `set-only` — честный откат на `all` с предупреждением. `rollback protect` +без ручного юнита возвращает bouncer к своим правилам. + +Порядок переключения: `stop` bouncer'а → свои таблицы и режим → `start` → проверка, что +bouncer active и набор на месте; не поднялся — откат на `all` с предупреждением. Bouncer в +обычном режиме на остановке удаляет свою таблицу, а в `set-only` при старте ищет готовую: +`restart` после загрузки наших таблиц убивал бы их на выходе старого процесса, и новый не +стартовал бы вовсе. Whitelist в правиле — именованные интервальные наборы с `auto-merge` +(адрес админа внутри CIDR из того же `WHITELIST` не ломает загрузку). Переход на `all` удаляет +наши таблицы; повторный `all` на обычном bouncer'е его не перезапускает. #### 🟠 Лог анти-скана занимал 70–99% журнала @@ -46,7 +56,9 @@ bouncer'а; ручной юнит, сужавший CrowdSec до SSH, выкл (одиночные стуки в 23/445/3389) давал 70–99% всех строк журнала на нодах флота, а настоящие сканеры среди них терялись. Теперь `[na portscan]` пишется только на переходе адреса — один раз при пометке suspect и один раз при бане (цепочки `ps_suspect*`/`ps_ban*`). -`PORTSCAN_LOG_RATE` остаётся общим потолком этих строк. Объём отброшенного виден в +`PORTSCAN_LOG_RATE` — потолок каждого из лог-правил (v4/v6 × suspect/бан, лимиты +независимые); предупреждение о бюджете журнала считает их сумму и звучит, только если даже +теоретический потолок съедает весь кап journald за сутки. Объём отброшенного виден в именованных счётчиках. #### 🟠 Именованные счётчики отброшенного @@ -73,8 +85,17 @@ c_autoban c_blocklist c_bogon c_badflags c_nodeport c_catchall c_dnat_guard c_ss дело и не доходило: доступность проверялась `modprobe nf_synproxy`, но модуля с таким именем в современных ядрах нет (есть `nf_synproxy_core` и `nft_synproxy`). На всех ядрах флота (сток 6.12, XanMod 6.18) режим уходил в degraded с ✘ в диагностике. Теперь проверяется -`nft_synproxy`, правило стоит до invalid-drop, забаненные режутся раньше него, `nf_conntrack_tcp_loose=0` ставится -через `/etc/sysctl.d/99-na-synproxy.conf` (и снимается при выключении и в rollback). +`nft_synproxy`, правило стоит до invalid-drop, `nf_conntrack_tcp_loose=0` ставится через +`/etc/sysctl.d/99-na-synproxy.conf` (и снимается при выключении и в rollback). + +После synproxy соединение сразу established, и всё, что стоит ниже на `ct state new` +(per-IP лимиты, блок-листы), его уже не видит. Поэтому до synproxy решаются: autoban и +блок-листы (whitelist сильнее бана, как в основной цепочке) и SYN-rate по адресу — своим +набором `spsyn4/6` на untracked SYN. `CONN_LIMIT` (`ct count`) на этих портах не действует: у +untracked SYN ещё нет состояния. `tcp_loose=0` действует на весь хост — при включении +выводится предупреждение (TCP-сессии, пережившие сброс conntrack или простоявшие дольше +`CT_EST_TIMEOUT` без пакетов, рвутся как invalid). Пустой `TCP_PORTS` — synproxy не ставится +(раньше `tcp dport { }` ронял весь прогон на `nft -c`). #### 🟡 Whitelist и разбан одной командой: `na-fw` @@ -83,9 +104,13 @@ CrowdSec-yaml. Штатного способа поменять его, кром его правили `sed`'ом по отдельности (сотни `*.bak-*`), слои расходились, а ре-ран молча возвращал старое. -`na-fw allow add|del|list ` меняет все слои разом. Файл перед заменой проверяется -`nft -c`. Обновляются правило CROWDSEC_SCOPE и хэш в маркере, добавленный адрес снимается из -autoban. `na-fw unban ` снимает autoban/suspect и решения CrowdSec. +`na-fw allow add|del|list ` меняет все слои разом. Сначала собирается и проверяется +`nft -c` новый файл; не прошёл — не тронуто ничего, включая живой сет. Обновляются правило +CROWDSEC_SCOPE (его наборы — на ходу, без рестарта bouncer'а) и хэш в маркере, добавленный +адрес снимается из autoban. `na-fw` берёт тот же лок, что и `protect`, отказывает на префиксах +шире `/8` (v6 — `/16`) и предупреждает про шире `/29` (`/64`); адрес, слитый `auto-merge` с +соседним, старый nft из живого сета может не удалить — об этом прямо пишется. `na-fw unban +` снимает autoban/suspect и решения CrowdSec. #### 🟡 Локальные правила оператора переживают ре-ран @@ -111,12 +136,14 @@ autoban. `na-fw unban ` снимает autoban/suspect и решения Cro - **SSH_PORT из автодетекта больше не персистится.** Раньше он перебивал свежий детект на ре-ране: сменил порт sshd — strict открывал старый. Порт, сохранённый старой версией без пометки `explicit`, сверяется с фактом: если расходится, открываются оба порта и выводится - предупреждение. + предупреждение. В conf остаётся исходный порт без метки — сверка повторяется на каждом + прогоне, пока оператор не закрепит порт явно, а не «оба порта навсегда». #### 🟡 Детект SSH и node-агента - **SSH.** Ветка `ssh.socket` была мёртвой: читалось несуществующее свойство `ListenStream` - вместо `Listen`. Источники теперь отдают все порты, а не первый. + вместо `Listen`. Источники теперь отдают все порты, а не первый. Запасной путь через `ss` не + берёт loopback (там sshd держит X11-форвардинг сессий, 127.0.0.1:6010+). - **Сессия под `sudo`.** `sudo` вычищает `SSH_CONNECTION`, а README сам предлагает `sudo bash …`. Раньше в этом случае не было ни авто-whitelist IP админа, ни защиты порта сессии, и никто об этом не узнавал. Теперь `SSH_CONNECTION` ищется у предков процесса; не нашёлся — @@ -194,7 +221,7 @@ UDP `RcvbufErrors`, softnet drop/`time_squeeze`, `ListenOverflows` и CPU steal или `protect.conf` маркер врал (аудит флота: порты «мимо маркера» на нескольких нодах), и датчик `CONN_LIMIT` мерил не тот срез. Теперь порты берутся из живых правил (`cc4_<порт>`, `udp4_<порт>`), при отсутствии таблицы — из `protect.conf` (понимаются обе идиомы: -`: "${K:=v}"` и `: "${K=v}"` из protect 4.2), и лишь затем из маркера. Расхождение живых +`: "${K:=v}"`, как пишет save_conf, и `: "${K=v}"` после ручной правки), и лишь затем из маркера. Расхождение живых правил с `protect.conf` — ▲ с тем, что сделает ре-ран (какие порты закроет и откроет). #### 🟠 CrowdSec: «1 решение» при 25 тысячах заблокированных адресов @@ -203,7 +230,8 @@ UDP `RcvbufErrors`, softnet drop/`time_squeeze`, `ListenOverflows` и CPU steal community-блоклист: на ноде флота отчёт печатал «1» при ~25 тыс. адресов CAPI. `na-report` считал строки `"value"` в `-o json` — у одного решения таких полей несколько, выходило ×33. Теперь решения считаются по строкам `-o raw` с числовым id (заголовок и запятая в имени AS не -мешают), раздельно локальные и CAPI/списки. Diagnose показывает область действия +мешают), раздельно локальные и CAPI/списки, с `--limit 0`: по умолчанию cscli отдаёт 100 +последних алертов, и при потоке локальных CAPI выпадал из счёта. Diagnose показывает область действия блок-листов из маркера (`crowdsec_scope`; без него при решениях CAPI — ▲ «режет все порты») и источники логов из `acquis.yaml`/`acquis.d` (на флоте — только journal sshd). @@ -237,7 +265,13 @@ allowlist). Теперь берётся ЭФФЕКТИВНЫЙ allowlist — о - **Сертификаты**: добавлены хранилище Caddy (docker volume и пакетный) и `ssl_certificate` из включённых конфигов nginx. Просроченный серт раньше маскировался под «не найден» (−1 значил «нет», и −10 дней «перекрывал» следующий живой серт) — теперь отдельный флаг «найден» и ✘ при - ≤0 дней. + ≤0 дней. Но глобы цепляют и брошенное (каталог `letsencrypt/live` после смены домена, + хранилище Caddy, который больше не поднимается), поэтому серт в зоне тревоги (< 14 дн), + найденный глобом, считается, только если его кто-то обслуживает: renewal-конфиг + certbot/acme.sh, путь в conf acme.sh или Caddyfile, mount работающего контейнера, активный + пакетный Caddy. Остальные — ▲ «серт без обслуживающего сервиса» и поля + `cert_stale_count`/`cert_stale_file`, а не ✘ и не `cert_min_days`. Старая копия, у которой + есть живой серт на тот же CN, пропускается; `NA_CERT_IGNORE='<глоб> …'` исключает пути. - **json-логи контейнеров без `max-size`** — ▲ (кап из `daemon.json` получают только контейнеры, созданные после него). - **OOM/lockup за сутки** — по всем загрузкам (`_TRANSPORT=kernel` = `-k` без неявного `-b`): @@ -248,8 +282,14 @@ allowlist). Теперь берётся ЭФФЕКТИВНЫЙ allowlist — о окна нет), `cpu_steal_boot_pct`, `crowdsec_decisions_local`, `crowdsec_decisions_capi`, `crowdsec_scope`, `whitelist_drift_crowdsec`, `dnat_ports`, `dnat_guard` (−1 — ключа в маркере нет), `xanmod_update`, `cert_found`, `portscan_log_lines_24h`, -`portscan_log_share_pct`. Смысл и порядок прежних полей не менялся (`cpu_steal_pct` теперь -мерится окном 3 с, `cert_min_days` при `cert_found=true` может быть ≤0). +`portscan_log_share_pct`, `cert_stale_count`, `cert_stale_file`. Смысл и порядок прежних +полей не менялся (`cpu_steal_pct` теперь мерится окном 3 с, `cert_min_days` при +`cert_found=true` может быть ≤0). + +Полный `--json` стал тяжелее (окно steal, проходы по журналу за сутки, `cscli -a` на десятки +тысяч решений, `apt-cache`): на ноде с большим журналом и одним vCPU — десятки секунд. Для +частого опроса мониторингом — `--json --fast` (или `NA_DIAG_FAST=1`): без проходов по журналу, +`cscli -a` и `apt-cache`, steal окном 1 с; поля на местах, неизмеренные — −1/пусто. #### 🟠 `na-report`: метрики, которые значили не то, что написано @@ -277,7 +317,8 @@ xray на `127.0.0.1:10085`, высокие inbound'ы. Исходящее со руками. Теперь LISTEN-порты процессов xray/rw-core/rw-node (включая loopback) и порт node-агента, попадающие в диапазон, пишутся в `net.ipv4.ip_local_reserved_ports` вместе с резервом оператора; свои порты помнятся в `reserved-ports.na`, и порт, с которого xray ушёл, -на ре-ране снимается. xray ещё не запущен — нечего резервировать: ре-ран optimize после старта +на ре-ране снимается — в том числе из диапазона, в который ядро склеило его с соседним +портом оператора (`10085-10086`): наш порт вырезается, операторский остаётся. xray ещё не запущен — нечего резервировать: ре-ран optimize после старта ноды (diagnose подскажет). #### 🟠 RPS/XPS там, где они вредят @@ -311,6 +352,13 @@ v6-форвардинг при старте демона; `accept_ra=2` приш сбрасывается на ходу (Docker с IPv6 мог на него опереться) — оно уйдёт с ближайшим ребутом. IPv4-форвардинг (Docker bridge) остаётся. +Нода, которая маршрутизирует v6 (WireGuard, v6-relay), после ребута потеряла бы +маршрутизацию, поэтому ручка `NA_IPV6_FORWARD` (пусто — авто): если форвардинг включён и +через ноду уже прошли v6-пакеты (`Ip6OutForwDatagrams`), он остаётся, а `1` закрепляется в +`optimize.conf` (после ребута счётчик с нуля — без этого решение скакало бы). Включён, но +пакетов не было, а ставил его наш старый файл — предупреждение «после ребута будет 0». `1` — +ставить всегда, `0` — никогда. + #### 🟠 `rollback optimize` возвращал runtime только после ребута Удаление файлов не возвращает значения: `sysctl --system` перечитывает лишь чужие файлы, а @@ -319,7 +367,10 @@ THP — тоже. Теперь при ПЕРВОМ применении сним sysctl-файлов (`sysctl.orig`), маски RPS/XPS и режим THP; откат возвращает их сразу (затем `sysctl --system`, так что чужие файлы по-прежнему главнее снимка) и честно пишет, что вернулось, а что — нет (ноды, где optimize ставился до v4.2, снимка не имеют: RPS -выключается, остальное — до ребута). +выключается, остальное — до ребута). Форвардинг из снимка не возвращается: снимок снят до +Docker (штатный порядок раскатки), и `conf.all.forwarding=0` отрезал бы bridge-контейнеры +(Caddy панели, второй тенант) до рестарта Docker. `nf_conntrack_max` не опускается ниже +удвоенного текущего числа записей — иначе «table full» и дроп новых соединений. #### 🟠 Ядро XanMod: проверки до и после установки @@ -347,7 +398,15 @@ initrd, а GRUB уже выбрал новое ядро. Теперь устан забаненный адрес режется в forward (до v4.2 проходил), whitelist сильнее autoban, счётчик `c_dnat_guard` растёт; SSH — пара лишних попыток даёт только suspect, продолжение перебора — бан; именованные счётчики растут; SYNPROXY — рукопожатие завершается (до v4.2 режим вообще не - включался: проверялся несуществующий модуль). + включался: проверялся несуществующий модуль), SYN-rate режет до synproxy, забаненный не + завершает рукопожатие, whitelist сильнее бана. +- `tests/cs-scope-nft.sh` (новый) — файл правила `CROWDSEC_SCOPE=ssh` на **настоящем** nft: + в smoke-матрице (теперь с Debian 11 и Ubuntu 20.04 — nft 0.9.8 и 0.9.3, заявленные в README) + и в job `nft-behavior`. Whitelist с адресом внутри CIDR, правка живого набора как в `na-fw`, + повторная загрузка поверх живой таблицы. +- `tests/crowdsec-e2e.sh` (новый, CI-job `crowdsec-e2e`) — переключение ssh → ssh → all → ssh + на **настоящем** `crowdsec-firewall-bouncer` (packagecloud): bouncer не падает, старые и новые + решения доезжают в набор в `set-only` и обратно в его собственные правила. - `tests/apply-smoke.sh`: счётчики, `dnat_guard`, анти-амплификация, DHCPv6, `::` вне `bogon_v6`, порядок synproxy/invalid-drop и `tcp_loose=0`, `nft_sha256` = файлу, `none` держится через ре-ран, явный `SSH_PORT` персистится с меткой, автодетект — нет, устаревший @@ -355,9 +414,11 @@ initrd, а GRUB уже выбрал новое ядро. Теперь устан `WHITELIST=none`, отказ на `SAFETY_DELAY<30`/кривом `CROWDSEC_SCOPE`/токене с `;` — 42 новые проверки падают на v4.1.3. - `tests/protect-unit.sh`: лог анти-скана только в цепочках перехода; `CROWDSEC_SCOPE` - (set-only, правило только на SSH, исключение whitelist, `all` всё снимает, старый bouncer - без set-only); `na-fw` (add/del во всех слоях, мусор вместо адреса, unban); перенос банов - (остаток срока, < 1 с не переносится). + (set-only, правило только на SSH, whitelist — именованный набор, порядок stop → таблицы → + start, `all` снимает и наши таблицы, повторный `all` без перезапуска, не поднявшийся + bouncer → откат на `all`, старый bouncer без set-only); `na-fw` (add/del во всех слоях, + только v4 при пустом v6, отказ на `/0`, мусор вместо адреса, unban); перенос банов (остаток + срока, < 1 с не переносится). - `tests/common-unit.sh`: `\` в conf, метка `explicit` для `SSH_PORT`, `detect_ssh_port` (`Listen`, все `port` из `sshd -T`), `detect_node_port` (образ по digest, bridge-тенант не в счёт, два агента), `SSH_CONNECTION` у предка процесса. @@ -375,8 +436,9 @@ initrd, а GRUB уже выбрал новое ядро. Теперь устан JSON. Проверка текста теперь без цветовых escape-кодов — значок ✔/▲/✘ реально сверяется. - `tests/optimize-unit.sh` (новый): секция sysctl против эмулятора `sysctl --system` — виновник назван (и через `systemd-analyze cat-config`), незаписанный ключ, резерв портов с - сохранением резерва оператора и снятием ушедшего порта, снимок только при первом прогоне, - без v6-форвардинга; journald (`10-na-size.conf`, старое имя снято, persistent, явный + сохранением резерва оператора и снятием ушедшего порта (и из склейки ядра с портом + оператора), снимок только при первом прогоне, без v6-форвардинга и `NA_IPV6_FORWARD` (авто + по `Ip6OutForwDatagrams`, предупреждение, явный 0); journald (`10-na-size.conf`, старое имя снято, persistent, явный `Storage=` оператора); снимки RPS/THP (в т.ч. пустая маска последней очереди не выбрасывает снимок); блок отката runtime со снимками и без; проверки XanMod (Secure Boot через mokutil и efivar, `/boot`, версия ядра из Depends, initrd и его пересборка, драйвер NIC модулем и @@ -388,15 +450,17 @@ initrd, а GRUB уже выбрал новое ядро. Теперь устан - `tests/rps-unit.sh`: XPS не трогается без `NA_XPS=1`, RPS снимается при RX-очередей ≥ ядер и на 1 vCPU (к снимку или к 0), `Environment=NA_XPS` в юните, `default dev venet0`. -Каждый новый и расширенный тест падает на v4.1.3 (diagnose-unit — 90 проверок, -optimize-unit — 59, na-report-unit — 14, rps-unit — 13) и проходит на этой ветке. +Каждый новый и расширенный тест падает на v4.1.3 (до правок по ревью: diagnose-unit — 90 +проверок, optimize-unit — 59, na-report-unit — 14, rps-unit — 13) и проходит на этой ветке. ### Миграция - **`CROWDSEC_SCOPE=ssh` — новый дефолт.** Ре-ран `protect` переводит bouncer в `set-only`, и блок-листы CrowdSec начинают действовать только на SSH. Нужно прежнее поведение (на всех портах) — `CROWDSEC_SCOPE=all` при ре-ране, сохранится в conf. Ручной - `crowdsec-nft-scope.service` выключается из автозагрузки (его роль у `na-crowdsec-scope`). + `crowdsec-nft-scope.service` выключается из автозагрузки (его роль у `na-crowdsec-scope`; + `rollback protect` вернёт его). На время переключения bouncer останавливается — несколько + секунд блок-листы не действуют, затем он заново загружает решения из LAPI. - **`DNAT_GUARD=1` — новый дефолт.** Опубликованные Docker-порты начинают получать проверки источника. Если за контейнером стоит CDN или фронт, чьи адреса могли попасть в autoban, внесите их в `WHITELIST` (или `na-fw allow add`). @@ -413,8 +477,13 @@ optimize-unit — 59, na-report-unit — 14, rps-unit — 13) и проходи (очередей ≥ ядер) RPS выключается сразу. v6-форвардинг уходит с ребутом. - `na-diagnose` создаёт `diag-counters.last`; первый запуск после обновления (и после каждого ребута) дельт не даёт (`delta_window_s=-1`). -- В `optimize.conf` появляется ключ `NA_XPS`; `optimize.installed` — `tier`, `sock_max`, - `sock_def` (по ним diagnose берёт пороги буферов). +- В `optimize.conf` появляются ключи `NA_XPS` и `NA_IPV6_FORWARD`; `optimize.installed` — + `tier`, `sock_max`, `sock_def` (по ним diagnose берёт пороги буферов). +- **v6-форвардинг на ноде, которая маршрутизирует IPv6**, остаётся (авто по счётчику + форвардинга). Если форвардинг нужен, а v6-пакетов через ноду к моменту ре-рана ещё не было, + optimize предупредит — закрепи `NA_IPV6_FORWARD=1`. +- **Мониторинг, опрашивающий `na-diagnose --json` часто или с таймаутом < 30 с,** — + переведите на `--json --fast`. ## v4.1.3 — 2026-10-03 diff --git a/README.md b/README.md index 6ac6a90..df37568 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,9 @@ - **SYNPROXY** *(opt-in, done-right)* — `notrack` только host-local (`fib daddr type local`, не ломает Docker/транзит), правило до `ct state invalid drop` + `nf_conntrack_tcp_loose=0` (иначе третий ACK рукопожатия не доходил до synproxy), verify ядра/модуля, fail-loud при - недоступности. На VPN-relay обычно избыточен (syncookies + per-IP ct-лимиты уже дают + недоступности. Autoban, блок-листы и per-IP SYN-rate решаются **до** synproxy (после него + соединение уже established); `CONN_LIMIT` на этих портах не действует, а `tcp_loose=0` — на + весь хост (сессии, пережившие сброс conntrack, рвутся). На VPN-relay обычно избыточен (syncookies + per-IP ct-лимиты уже дают анти-спуф) — **default off**. Опубликованные Docker-порты в `TCP_PORTS` с ним не сочетать. - **Опубликованные Docker-порты** (`-p`, bridge, DNAT) — трафик к ним идёт через hook forward, мимо цепочки input. С v4.2 (`DNAT_GUARD=1`) новые DNAT-соединения проходят те же проверки @@ -104,7 +106,9 @@ С v4.2 блок-листы по умолчанию действуют **только на SSH** (`CROWDSEC_SCOPE=ssh`: bouncer в `set-only`, правило ставит тулкит, whitelist исключён): community-лист на всех портах отрезал мобильных абонентов за CGNAT, а CrowdSec на ноде и так разбирает только журнал sshd. - `CROWDSEC_SCOPE=all` — прежнее поведение, на всех портах. + `CROWDSEC_SCOPE=all` — прежнее поведение, на всех портах. Переключение: bouncer + останавливается, грузятся правила, bouncer стартует и проверяется; не поднялся в `set-only` — + откат на `all` с предупреждением (CrowdSec не останется без правил). Ставится из **пиннингованного APT-репо**: ключ проверяется по полному отпечатку и в keyring кладётся **ровно он** (а не всё, что приехало в ответе). Suite берётся канонический `any/any` — он же работает на Debian 13 (trixie), где у CrowdSec **нет** своего suite, а @@ -121,16 +125,18 @@ - **Персист конфига** — ре-ран без ENV не сбрасывает поднятые под ноду ручки. Пустой ENV значит «как в conf»; явно очистить список — словом `none` (`WHITELIST=none`, `UDP_PORTS=none`). - **`na-fw`** — whitelist и разбан одной командой во всех слоях сразу (живой сет, `na_filter.nft`, - `protect.conf`, CrowdSec): `na-fw allow add|del|list `, `na-fw unban `. + `protect.conf`, CrowdSec, правило `CROWDSEC_SCOPE`): `na-fw allow add|del|list `, + `na-fw unban `. Файл проверяется `nft -c` до любых изменений; префиксы шире `/8` (v6 — + `/16`) отвергаются. - **Локальные правила** — `/etc/node-accelerator/na_filter.d/table/*.nft` (наборы, свои цепочки) и `…/input/*.nft` (правила в input сразу после whitelist) вклеиваются в каждую генерацию и проверяются общим `nft -c`; ручная правка самого `na_filter.nft` ре-раном стирается. - **Баны переживают ре-ран** — записи autoban/suspect с оставшимся сроком переносятся в новую таблицу. ### 🩺 Диагностика (`scripts/diagnose.sh`) -Read-only отчёт: ядро/BBR, sysctl, лимиты, conntrack, NIC/RPS, swap/THP/governor, firewall, CrowdSec, порты, RTT — с итогом ✔/▲/✘ и рекомендациями. Плюс сенсоры **стека ноды**: контейнер `remnanode` (статус/рестарты/`SPAWN_ERROR` за час — ловит коллизию node-address в панели), **рассинхрон порта node-агента с файрволом** (агент слушает `:3000`, а strict-правила держат `:2222` → «нода недоступна» для панели; в отчёте — с командой пожарного фикса, в `--json` — `node_port_detected`/`node_port_fw`), **сроки TLS-сертификатов** (LE/acme.sh/`/opt/*/certs`; серты, снятые с renew в acme.sh, не считаются; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`), **whitelist в трёх местах** (живой сет vs `na_filter.nft` vs `protect.conf`: адрес, который переживёт ребут, но не ре-ран `protect`, называется по имени), **датчик `CONN_LIMIT` по тому же срезу, что и правило** (только входящие на сервисные порты, без loopback/whitelist — раньше на ноде с внутренним nginx он горел всегда), **PSI** (различает «не собран», «выключен в сборке ядра» и «работает»), **ретеншен journald в часах**, **замороженные дефолты в сохранённом конфиге**, **свежесть fleet-sync/blocklist**, IPv6 default-route, UDP `RcvbufErrors` (QUIC/Hysteria2), **заполненность наборов-лимитеров** (и есть ли у них timeout), **ожидание перезагрузки по факту загрузки** (`reboot_needed` сверяется с `boot_id`, а не верит вечному маркеру). +Read-only отчёт: ядро/BBR, sysctl, лимиты, conntrack, NIC/RPS, swap/THP/governor, firewall, CrowdSec, порты, RTT — с итогом ✔/▲/✘ и рекомендациями. Плюс сенсоры **стека ноды**: контейнер `remnanode` (статус/рестарты/`SPAWN_ERROR` за час — ловит коллизию node-address в панели), **рассинхрон порта node-агента с файрволом** (агент слушает `:3000`, а strict-правила держат `:2222` → «нода недоступна» для панели; в отчёте — с командой пожарного фикса, в `--json` — `node_port_detected`/`node_port_fw`), **сроки TLS-сертификатов** (LE/acme.sh/`/opt/*/certs`; серты, снятые с renew в acme.sh, не считаются; свои пути — `NA_CERT_PATHS="глоб1 глоб2"`; истекающий серт, который никто не обслуживает, — ▲ «брошенный», а не ✘; исключить — `NA_CERT_IGNORE`), **whitelist в трёх местах** (живой сет vs `na_filter.nft` vs `protect.conf`: адрес, который переживёт ребут, но не ре-ран `protect`, называется по имени), **датчик `CONN_LIMIT` по тому же срезу, что и правило** (только входящие на сервисные порты, без loopback/whitelist — раньше на ноде с внутренним nginx он горел всегда), **PSI** (различает «не собран», «выключен в сборке ядра» и «работает»), **ретеншен journald в часах**, **замороженные дефолты в сохранённом конфиге**, **свежесть fleet-sync/blocklist**, IPv6 default-route, UDP `RcvbufErrors` (QUIC/Hysteria2), **заполненность наборов-лимитеров** (и есть ли у них timeout), **ожидание перезагрузки по факту загрузки** (`reboot_needed` сверяется с `boot_id`, а не верит вечному маркеру). -С v4.2 накопительные счётчики (UDP `RcvbufErrors` v4+v6, softnet drop/`time_squeeze`, `ListenOverflows`, steal) оцениваются **по приросту с прошлого запуска**, а не «с загрузки»: каждый запуск оставляет снимок `/var/lib/node-accelerator/diag-counters.last` (единственное, что diagnose пишет; окно ≥30 с, после ребута — заново). CPU steal — окно 3 с плюс среднее с загрузки, PSI тревожит по `avg60`. Порты для сенсоров берутся из **живых правил** (`protect.conf` и маркер — запасные источники), расхождение правил с `protect.conf` называется вместе с тем, что изменит ре-ран. Новые сенсоры: **CrowdSec по источникам решений** (локальные vs CAPI/списки, область действия блок-листов, откуда читаются логи), **whitelist в четырёх слоях** (живой сет, `na_filter.nft`, `protect.conf`, эффективный allowlist CrowdSec из всех `s02-enrich/*.yaml`; дрейф в обе стороны), **порты, опубликованные через DNAT** (Docker `-p`, ручные relay) мимо `na_filter`, **публичные слушатели вне разрешённых портов** и разрешённые порты без слушателя, **MemAvailable/своп/balloon**, `nftables.service` с `flush ruleset`, ручная правка `na_filter.nft` (хэш из маркера), именованные счётчики дропов, порт xray в эфемерном диапазоне без резерва, доступное обновление XanMod, `/run/reboot-required` от стоковых ядер (info, а не ▲), json-логи контейнеров без `max-size`, OOM за сутки по всем загрузкам. Серты ищутся и в хранилище **Caddy** (docker volume и пакетный), и по `ssl_certificate` из конфигов nginx; **просроченный серт — ✘**, а не «не найден». Убраны ложные ▲: «ядро не XanMod» при `ENABLE_XANMOD=0`, уступка ротации стансе с капом, буферы ниже 32M на маленьком RAM-tier, «RPS выключен» при RX-очередей ≥ ядер, глубина volatile-журнала и журнала ≈ аптайму; лог анти-скана оценивается долей журнала за сутки. Новые поля `--json` — только в хвосте объекта: `udp_rcvbuf_errors_delta`, `softnet_drops_delta`, `time_squeeze_delta`, `listen_overflows_delta`, `delta_window_s` (−1 — окна ещё нет), `cpu_steal_boot_pct`, `crowdsec_decisions_local`, `crowdsec_decisions_capi`, `crowdsec_scope`, `whitelist_drift_crowdsec`, `dnat_ports`, `dnat_guard`, `xanmod_update`, `cert_found`, `portscan_log_lines_24h`, `portscan_log_share_pct`. +С v4.2 накопительные счётчики (UDP `RcvbufErrors` v4+v6, softnet drop/`time_squeeze`, `ListenOverflows`, steal) оцениваются **по приросту с прошлого запуска**, а не «с загрузки»: каждый запуск оставляет снимок `/var/lib/node-accelerator/diag-counters.last` (единственное, что diagnose пишет; окно ≥30 с, после ребута — заново). CPU steal — окно 3 с плюс среднее с загрузки, PSI тревожит по `avg60`. Порты для сенсоров берутся из **живых правил** (`protect.conf` и маркер — запасные источники), расхождение правил с `protect.conf` называется вместе с тем, что изменит ре-ран. Новые сенсоры: **CrowdSec по источникам решений** (локальные vs CAPI/списки, область действия блок-листов, откуда читаются логи), **whitelist в четырёх слоях** (живой сет, `na_filter.nft`, `protect.conf`, эффективный allowlist CrowdSec из всех `s02-enrich/*.yaml`; дрейф в обе стороны), **порты, опубликованные через DNAT** (Docker `-p`, ручные relay) мимо `na_filter`, **публичные слушатели вне разрешённых портов** и разрешённые порты без слушателя, **MemAvailable/своп/balloon**, `nftables.service` с `flush ruleset`, ручная правка `na_filter.nft` (хэш из маркера), именованные счётчики дропов, порт xray в эфемерном диапазоне без резерва, доступное обновление XanMod, `/run/reboot-required` от стоковых ядер (info, а не ▲), json-логи контейнеров без `max-size`, OOM за сутки по всем загрузкам. Серты ищутся и в хранилище **Caddy** (docker volume и пакетный), и по `ssl_certificate` из конфигов nginx; **просроченный серт — ✘**, а не «не найден». Убраны ложные ▲: «ядро не XanMod» при `ENABLE_XANMOD=0`, уступка ротации стансе с капом, буферы ниже 32M на маленьком RAM-tier, «RPS выключен» при RX-очередей ≥ ядер, глубина volatile-журнала и журнала ≈ аптайму; лог анти-скана оценивается долей журнала за сутки. Новые поля `--json` — только в хвосте объекта: `udp_rcvbuf_errors_delta`, `softnet_drops_delta`, `time_squeeze_delta`, `listen_overflows_delta`, `delta_window_s` (−1 — окна ещё нет), `cpu_steal_boot_pct`, `crowdsec_decisions_local`, `crowdsec_decisions_capi`, `crowdsec_scope`, `whitelist_drift_crowdsec`, `dnat_ports`, `dnat_guard`, `xanmod_update`, `cert_found`, `portscan_log_lines_24h`, `portscan_log_share_pct`, `cert_stale_count`, `cert_stale_file`. Полный `--json` на ноде с большим журналом занимает десятки секунд; для частого опроса — **`--json --fast`** (`NA_DIAG_FAST=1`): без проходов по журналу, `cscli -a` и `apt-cache`, неизмеренное — −1/пусто. После установки доступна как команда **`na-diagnose`** (`--json` для мониторинга/панели — теперь с `na_version`, `hostname`, `uptime_s`, `load1`, `mem_used_pct`, WAN rx/tx-байтами; `--retrans [--window N]` — разбор причин TCP-retransmits). @@ -199,7 +205,7 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `SSH_BAN_TIME`/`PORTSCAN_BAN_TIME` | `24h`/`1h` | сроки бана | | `ENABLE_PORTSCAN_BAN` | `1` | автобан за скан закрытых портов | | `PORTSCAN_RATE`/`PORTSCAN_BURST` | `15`/`30` | порог скана (SYN на закрытые порты/мин, per-IP) до бана — ниже порога просто дроп, без бана | -| `PORTSCAN_LOG_RATE`/`PORTSCAN_LOG_BURST` | `60`/`30` | потолок строк `[na portscan]` в минуту (с v4.2 строка пишется только при переходе адреса в suspect/бан) (до v4.1 — `5/second` ≈ 432 000 строк/сутки: на публичной ноде журнал в 300M жил меньше суток и форензика старше вчера была невозможна). Бан работает по счётчикам, не по логу; `0` — лог анти-скана не ставить. `protect` считает суточный бюджет против капа journald и предупреждает, если он больше 30% | +| `PORTSCAN_LOG_RATE`/`PORTSCAN_LOG_BURST` | `60`/`30` | потолок строк `[na portscan]` в минуту на каждое лог-правило (v4/v6 × suspect/бан; с v4.2 строка пишется только при переходе адреса в suspect/бан) (до v4.1 — `5/second` ≈ 432 000 строк/сутки: на публичной ноде журнал в 300M жил меньше суток и форензика старше вчера была невозможна). Бан работает по счётчикам, не по логу; `0` — лог анти-скана не ставить. `protect` считает теоретический суточный потолок против капа journald и предупреждает, если он больше капа | | `ENABLE_CROWDSEC` | `1` | ставить CrowdSec + bouncer | | `CROWDSEC_SCOPE` | `ssh` | где действуют блок-листы CrowdSec: `ssh` — только SSH-порт(ы), whitelist исключён (bouncer в `set-only`, правило — `na-crowdsec-scope.service`); `all` — на всех портах (как до v4.2) | | `DNAT_GUARD` | `1` | проверки источника (whitelist/autoban/блок-листы/bogon) для новых соединений к опубликованным Docker-портам (forward) | @@ -228,7 +234,7 @@ curl -fsSL "https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF | `NA_CTG_PHANTOM_MIN` / `NA_CTG_LIVE_FLOOR` | `4000` / `2` | порог conntrack-холдера / порог живых сокетов. `LIVE_FLOOR` подбирают по observe-режиму: у обычных клиентов бывает 1–2 живых сокета при заметном conntrack | | `NA_CTG_BANTIME` / `NA_CTG_INTERVAL` / `NA_CTG_COARSE_MULT` | `15m` / `20s` / `3` | срок эвикта / период проверки / во сколько раз conntrack должен превышать число живых сокетов, чтобы вообще запускать разбор | -`optimize.sh`: `ENABLE_XANMOD=1`, `XANMOD_FLAVOR=lts|main|edge|rt`, `XANMOD_PKG=...`, `REMNAWAVE_SWAP_SIZE=2G`, `TCP_ECN_MODE=2` (0/1/2), `DISABLE_TFO=0`, `CT_EST_TIMEOUT=7440` (conntrack established-timeout, сек; ↑ напр. до `14400` для idle-туннелей/мостов без частого keepalive), `QDISC=fq|fq_codel|cake` (cake — против bufferbloat на слабых аплинках; сравнивай A/B), `ENABLE_MSS_CLAMP=0` (для routed/WireGuard-нод), `SETUP_NO_ZRAM=0`, `ENABLE_LOGROTATE=1` + `NA_LOG_PATHS`/`NA_LOG_MAXSIZE=200M`/`NA_LOG_ROTATE=4`/`NA_LOG_INTERVAL=hourly` (ротация файловых логов ноды, см. ниже), `ENABLE_PSI=0` (`1` — дописать `psi=1` в `GRUB_CMDLINE_LINUX_DEFAULT`: XanMod и стоковые ядра Debian собраны с `CONFIG_PSI_DEFAULT_DISABLED=y`, без этого `/proc/pressure` нет и сенсор давления в `na-diagnose` слеп; учёт PSI не бесплатен для планировщика, поэтому opt-in; ребут), `NA_JOURNAL_MAX_USE=300M` (кап journald), `NA_REMNANODE_ENV=/opt/remnanode/.env` (откуда брать порт агента), `NA_NODE_CONTAINER=remnanode` (имя контейнера node-агента для сенсора `na-diagnose`), `NA_CERT_PATHS` (доп. сертификаты для сенсора срока). Буферы/conntrack/somaxconn — **tier-aware** (масштаб от RAM). +`optimize.sh`: `ENABLE_XANMOD=1`, `XANMOD_FLAVOR=lts|main|edge|rt`, `XANMOD_PKG=...`, `REMNAWAVE_SWAP_SIZE=2G`, `TCP_ECN_MODE=2` (0/1/2), `DISABLE_TFO=0`, `CT_EST_TIMEOUT=7440` (conntrack established-timeout, сек; ↑ напр. до `14400` для idle-туннелей/мостов без частого keepalive), `QDISC=fq|fq_codel|cake` (cake — против bufferbloat на слабых аплинках; сравнивай A/B), `ENABLE_MSS_CLAMP=0` (для routed/WireGuard-нод), `SETUP_NO_ZRAM=0`, `ENABLE_LOGROTATE=1` + `NA_LOG_PATHS`/`NA_LOG_MAXSIZE=200M`/`NA_LOG_ROTATE=4`/`NA_LOG_INTERVAL=hourly` (ротация файловых логов ноды, см. ниже), `ENABLE_PSI=0` (`1` — дописать `psi=1` в `GRUB_CMDLINE_LINUX_DEFAULT`: XanMod и стоковые ядра Debian собраны с `CONFIG_PSI_DEFAULT_DISABLED=y`, без этого `/proc/pressure` нет и сенсор давления в `na-diagnose` слеп; учёт PSI не бесплатен для планировщика, поэтому opt-in; ребут), `NA_JOURNAL_MAX_USE=300M` (кап journald), `NA_REMNANODE_ENV=/opt/remnanode/.env` (откуда брать порт агента), `NA_NODE_CONTAINER=remnanode` (имя контейнера node-агента для сенсора `na-diagnose`), `NA_CERT_PATHS` (доп. сертификаты для сенсора срока), `NA_CERT_IGNORE` (глобы брошенных сертов, которые сенсор не смотрит), `NA_IPV6_FORWARD` (пусто — авто: v6-форвардинг остаётся, если нода уже маршрутизирует IPv6; `1`/`0` — явно). Буферы/conntrack/somaxconn — **tier-aware** (масштаб от RAM). `XANMOD_PROBE=1` — проверить, что репозиторий+ключ+сборка ядра резолвятся на этой ОС, **без установки** (для CI и быстрой проверки совместимости). --- @@ -241,7 +247,7 @@ na-fw allow add 203.0.113.7 # whitelist во всех слоях сразу (de na-fw unban 198.51.100.9 # снять autoban/suspect и решения CrowdSec na-fw-top-talkers # топ источников по сервисным портам na-diagnose # 🩺 health-отчёт (read-only) -na-diagnose --json # JSON для флот-мониторинга (Zabbix/Prometheus/панель) +na-diagnose --json # JSON для флот-мониторинга (Zabbix/Prometheus/панель); частый опрос — --json --fast na-diagnose --retrans # 🔬 разбор ПРИЧИН TCP-retransmits (TX/RX, тип, хвост, CC, дропы) na-report # 🔥 форензика атак за 24ч (кто/откуда/чем/когда) na-report --json # JSON форензики; --hours N, --top N diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh index 73c96e5..b0ce899 100755 --- a/scripts/diagnose.sh +++ b/scripts/diagnose.sh @@ -12,6 +12,7 @@ # NA_NODE_CONTAINER=<имя> — контейнер node-агента (дефолт remnanode); на панели/ # CDN-origin контейнер зовётся иначе или его нет вовсе # NA_CERT_PATHS=' ' — доп. пути к fullchain для сенсора сроков TLS +# NA_CERT_IGNORE='<глоб> …' — пути сертов, которые сенсор сроков не смотрит (брошенные) set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -262,8 +263,8 @@ dynset_fill() { # ─── v4.2: хелперы сенсоров по аудиту флота ────────────────────────────────── # conf_val <файл> <КЛЮЧ> — значение из сохранённого conf модуля. save_conf пишет -# `: "${KEY:=v}"`, protect v4.2 — `: "${KEY=v}"` (пустое ENV очищает значение): понимаем -# обе идиомы, последняя строка выигрывает (как при source). +# `: "${KEY:=v}"`; руками нередко правят в `: "${KEY=v}"` — понимаем обе идиомы, +# последняя строка выигрывает (как при source). conf_val() { [[ -r "$1" ]] || return 0 sed -nE 's/^[[:space:]]*:[[:space:]]+"\$\{'"$2"':?=([^}]*)\}"[[:space:]]*$/\1/p' "$1" 2>/dev/null | tail -1 @@ -489,7 +490,9 @@ dnat_ports() { cs_decisions() { command -v cscli >/dev/null 2>&1 || return 1 local out - out="$(_to 30 cscli decisions list -a -o raw 2>/dev/null)" || return 1 + # --limit 0: по умолчанию cscli отдаёт 100 последних алертов — при потоке локальных + # CAPI-алерты выпадали, и счёт CAPI (а с ним ▲ «режет все порты») занижался + out="$(_to 15 cscli decisions list -a --limit 0 -o raw 2>/dev/null)" || return 1 # строки данных начинаются с числового id (заголовок CSV — нет); 2-я колонка — origin awk -F, '$1 ~ /^[0-9]+$/ { s = tolower($2); if (s ~ /^(capi|lists)/) c++; else l++ } END { printf "%d %d\n", l, c }' <<<"$out" @@ -613,31 +616,96 @@ nginx_cert_paths() { done < <(sed -nE "s/^[[:space:]]*ssl_certificate[[:space:]]+[\"']?([^\"'; ]+)[\"']?[[:space:]]*;.*/\1/p" "$f" 2>/dev/null) done < <(find -L /etc/nginx -type f \( -path '*/sites-enabled/*' -o \( -name '*.conf' ! -path '*/sites-available/*' \) \) 2>/dev/null) } -cert_candidates() { +cert_candidates() { # → «\t<путь>»: x — явно задан (nginx, NA_CERT_PATHS), g — найден глобом local cf # shellcheck disable=SC2086 # глобы и NA_CERT_PATHS обязаны раскрыться - for cf in $NA_CERT_GLOBS ${NA_CERT_PATHS:-}; do [[ -f "$cf" ]] && printf '%s\n' "$cf"; done - nginx_cert_paths + for cf in ${NA_CERT_PATHS:-}; do [[ -f "$cf" ]] && printf 'x\t%s\n' "$cf"; done + nginx_cert_paths | sed 's/^/x\t/' + # shellcheck disable=SC2086 + for cf in $NA_CERT_GLOBS; do [[ -f "$cf" ]] && printf 'g\t%s\n' "$cf"; done +} +# cert_served <путь> — найденный глобом серт кто-то обслуживает или продлевает (rc=0). +# Глобы цепляют и брошенное: каталог letsencrypt/live после смены домена, серт в хранилище +# Caddy, который больше не поднимается, старый /opt/<стек>/certs. Их срок — не повод для ✘ +# «клиенты получают ошибку TLS». Обслуживается = renewal-конфиг certbot/acme.sh, путь +# упомянут в conf acme.sh (--install-cert) или Caddyfile, лежит под mount работающего +# контейнера (volume Caddy, bind /opt/<стек>/certs) или это хранилище активного caddy. +CERT_MOUNTS=""; CERT_MOUNTS_DONE=0 +cert_served() { + local f="$1" d m + case "$f" in + /etc/letsencrypt/live/*) + d="${f#/etc/letsencrypt/live/}"; d="${d%%/*}" + [[ -f "/etc/letsencrypt/renewal/$d.conf" ]] && return 0 ;; + /root/.acme.sh/*) + compgen -G "$(dirname "$f")/*.conf" >/dev/null 2>&1 && return 0 ;; + esac + grep -lsF -- "$f" /root/.acme.sh/*/*.conf >/dev/null 2>&1 && return 0 + grep -sF -- "$f" /etc/caddy/Caddyfile >/dev/null 2>&1 && return 0 + if [[ "$CERT_MOUNTS_DONE" == 0 ]]; then + CERT_MOUNTS_DONE=1 + if command -v docker >/dev/null 2>&1; then + local ids; ids="$(_to 5 docker ps -q 2>/dev/null)" || ids="" + # shellcheck disable=SC2086 # список id — нарочно словами + [[ -n "$ids" ]] && CERT_MOUNTS="$(_to 5 docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' $ids 2>/dev/null)" || true + fi + fi + while IFS= read -r m; do + [[ -n "$m" && ( "$f" == "$m" || "$f" == "${m%/}"/* ) ]] && return 0 + done <<<"$CERT_MOUNTS" + [[ "$f" == /var/lib/caddy/* ]] && systemctl is-active --quiet caddy 2>/dev/null && return 0 + return 1 } # cert_scan — ближайший к истечению серт: CERT_FOUND (0|1), CERT_MIN (дни, ≤0 = истёк или # истекает в ближайшие сутки), CERT_MIN_F. Раньше −1 значил «не найдено», и просроченный # серт (−10 дней) терялся: следующий живой (30) его «перекрывал». -CERT_FOUND=0; CERT_MIN=-1; CERT_MIN_F="" +# v4.2: серт в зоне тревоги (< 14 дн), найденный глобом, считается, только если его кто-то +# обслуживает (cert_served); иначе — «брошенный»: CERT_STALE (сколько), CERT_STALE_F/_D +# (ближайший). Старая копия, у которой есть живой серт на тот же CN, пропускается молча. +# NA_CERT_IGNORE='<глоб> …' — исключить пути совсем. +CERT_FOUND=0; CERT_MIN=-1; CERT_MIN_F=""; CERT_STALE=0; CERT_STALE_F=""; CERT_STALE_D=0 cert_scan() { - CERT_FOUND=0; CERT_MIN=-1; CERT_MIN_F="" + CERT_FOUND=0; CERT_MIN=-1; CERT_MIN_F=""; CERT_STALE=0; CERT_STALE_F=""; CERT_STALE_D=0 command -v openssl >/dev/null 2>&1 || return 1 - local cf cend cends now df d + local src cf out cend cn cends now df d ig i skip + local -a P=() S=() D=() C=() + local -A OKCN=() + local -a IGN=() + read -ra IGN <<<"${NA_CERT_IGNORE:-}" now="$(date +%s)" - while IFS= read -r cf; do + while IFS=$'\t' read -r src cf; do [[ -f "$cf" ]] || continue + skip=0 + # глоб — шаблон сравнения, а не путь: раскрытие в `for` дало бы имя каталога + for ig in ${IGN[@]+"${IGN[@]}"}; do + # shellcheck disable=SC2053 # правая часть — нарочно шаблон + [[ "$cf" == $ig ]] && { skip=1; break; } + done + [[ "$skip" == 1 ]] && continue cert_retired "$cf" && continue - cend="$(openssl x509 -enddate -noout -in "$cf" 2>/dev/null | cut -d= -f2)"; [[ -n "$cend" ]] || continue + out="$(openssl x509 -noout -enddate -subject -nameopt RFC2253 -in "$cf" 2>/dev/null)" + cend="$(sed -n 's/^notAfter=//p' <<<"$out")"; [[ -n "$cend" ]] || continue + cn="$(sed -nE 's/^subject=.*CN=([^,]+).*/\1/p' <<<"$out")" cends="$(date -d "$cend" +%s 2>/dev/null)" || continue; [[ "$cends" =~ ^[0-9]+$ ]] || continue df=$(( cends - now )) if (( df >= 0 )); then d=$(( df / 86400 )); else d=$(( -((-df + 86399) / 86400) )); fi + P+=("$cf"); S+=("$src"); D+=("$d"); C+=("$cn") + [[ -n "$cn" ]] && (( d >= 14 )) && OKCN[$cn]=1 + done < <(cert_candidates | awk -F'\t' '!seen[$2]++') + for i in "${!P[@]}"; do + d="${D[$i]}"; cf="${P[$i]}" + if (( d < 14 )); then + # старая копия: на тот же CN есть живой серт — в работе он, а не эта + [[ -n "${C[$i]}" && -n "${OKCN[${C[$i]}]:-}" ]] && continue + if [[ "${S[$i]}" == g ]] && ! cert_served "$cf"; then + if [[ "$CERT_STALE" == 0 ]] || (( d < CERT_STALE_D )); then CERT_STALE_D="$d"; CERT_STALE_F="$cf"; fi + CERT_STALE=$(( CERT_STALE + 1 )) + continue + fi + fi if [[ "$CERT_FOUND" == 0 ]] || (( d < CERT_MIN )); then CERT_MIN="$d"; CERT_MIN_F="$cf"; fi CERT_FOUND=1 - done < <(cert_candidates | awk '!seen[$0]++') + done return 0 } # docker_nocap — работающие контейнеры с json-file БЕЗ max-size: их лог растёт без предела @@ -657,9 +725,9 @@ portscan_24h() { local help n tot rc help="$(_to 5 journalctl --help 2>/dev/null)" [[ "$help" == *--grep* ]] || return 1 - n="$(_to 15 journalctl -q --no-pager --since -24h _TRANSPORT=kernel -g '\[na portscan\]' 2>/dev/null | wc -l | tr -d ' ')"; rc=$? + n="$(_to 10 journalctl -q --no-pager --since -24h _TRANSPORT=kernel -g '\[na portscan\]' 2>/dev/null | wc -l | tr -d ' ')"; rc=$? [[ "$rc" -eq 124 || ! "$n" =~ ^[0-9]+$ ]] && return 1 - tot="$(_to 20 journalctl -q --no-pager --since -24h -o cat 2>/dev/null | wc -l | tr -d ' ')"; rc=$? + tot="$(_to 10 journalctl -q --no-pager --since -24h -o cat 2>/dev/null | wc -l | tr -d ' ')"; rc=$? [[ "$rc" -eq 124 || ! "$tot" =~ ^[0-9]+$ ]] && tot=-1 echo "$n $tot" } @@ -727,7 +795,7 @@ emit_json() { collapsed="$(ss -tin state established 2>/dev/null | grep -oE 'mss:[0-9]+' | awk -F: '$2>0 && $2<256{c++} END{print c+0}')" # CPU steal: окно 3 с + среднее с загрузки (секундный семпл был шумом) steal=0; stealb=0 - [[ -r /proc/stat ]] && read -r steal stealb <<<"$(cpu_steal_measure 3)" + [[ -r /proc/stat ]] && read -r steal stealb <<<"$(cpu_steal_measure "$( [[ "$NA_FAST" == 1 ]] && echo 1 || echo 3)")" [[ "$steal" =~ ^[0-9]+$ ]] || steal=0; [[ "$stealb" =~ ^[0-9]+$ ]] || stealb=0 # дельты счётчиков к прошлому запуску (diag-counters.last) delta_load @@ -826,10 +894,11 @@ emit_json() { fi # глубина журнала и объём лога анти-скана (−1 = не измерено) jsp=-1; tmpv="$(journal_span_h)" && [[ "$tmpv" =~ ^[0-9]+$ ]] && jsp="$tmpv" - psl=-1; tmpv="$(portscan_log_lines)" && [[ "$tmpv" =~ ^[0-9]+$ ]] && psl="$tmpv" + # --fast: проходы по журналу пропускаются (−1 = не измерено) — для частого опроса + psl=-1; [[ "$NA_FAST" == 1 ]] || { tmpv="$(portscan_log_lines)" && [[ "$tmpv" =~ ^[0-9]+$ ]] && psl="$tmpv"; } # за сутки по всем загрузкам + доля от всех строк журнала за сутки (−1 = не измерено) ps24=-1; psh=-1 - if tmpv="$(portscan_24h)"; then + if [[ "$NA_FAST" != 1 ]] && tmpv="$(portscan_24h)"; then read -r ps24 pst <<<"$tmpv" [[ "$pst" =~ ^[0-9]+$ ]] && (( pst > 0 )) && psh=$(( ps24 * 100 / pst )) fi @@ -877,7 +946,7 @@ emit_json() { # v4.2: CrowdSec по источникам решений (−1 = нет cscli / не ответил) и область действия csl=-1; csc=-1 - if tmpv="$(cs_decisions)"; then read -r csl csc <<<"$tmpv"; fi + if [[ "$NA_FAST" != 1 ]] && tmpv="$(cs_decisions)"; then read -r csl csc <<<"$tmpv"; fi [[ "$csl" =~ ^-?[0-9]+$ ]] || csl=-1; [[ "$csc" =~ ^-?[0-9]+$ ]] || csc=-1 csscope="$(cs_live_scope 2>/dev/null || mark_val crowdsec_scope)" # живой whitelist, не прикрытый ЭФФЕКТИВНЫМ allowlist CrowdSec (−1 = сверять не с чем): @@ -897,7 +966,7 @@ emit_json() { dnp="$(dnat_ports)" dng="$(mark_val dnat_guard)"; [[ "$dng" =~ ^[01]$ ]] || dng=-1 # доступное обновление XanMod (по кэшу apt; пусто = нет/не знаем) - xmu=""; tmpv="$(xanmod_update)" && xmu="${tmpv#* }" + xmu=""; [[ "$NA_FAST" == 1 ]] || { tmpv="$(xanmod_update)" && xmu="${tmpv#* }"; } # Каждое строковое значение — через json_escape. Раньше каждое поле полагалось на то, # что источник «и так чистый», и одного сырого перевода строки из docker хватило, @@ -945,8 +1014,15 @@ emit_json() { "$stealb" "$csl" "$csc" "$csscope" printf '"whitelist_drift_crowdsec":%s,"dnat_ports":"%s","dnat_guard":%s,"xanmod_update":"%s","cert_found":%s,' \ "$wdcs" "$dnp" "$dng" "$xmu" "$cfound" - printf '"portscan_log_lines_24h":%s,"portscan_log_share_pct":%s}\n' "$ps24" "$psh" + printf '"portscan_log_lines_24h":%s,"portscan_log_share_pct":%s,"cert_stale_count":%s,"cert_stale_file":"%s"}\n' \ + "$ps24" "$psh" "$CERT_STALE" "$(json_escape "$CERT_STALE_F")" } +# --json --fast (или NA_DIAG_FAST=1) — для частого опроса мониторингом: без проходов по +# журналу, без `cscli -a` (десятки тысяч решений), без apt-cache, steal окном 1 с. Поля +# остаются на местах, неизмеренные — −1/пусто. Полный --json на ноде с большим журналом +# и одним vCPU занимает десятки секунд. +NA_FAST=0 +[[ "${NA_DIAG_FAST:-0}" == 1 || "${2:-}" == "--fast" ]] && NA_FAST=1 if [[ "${1:-}" == "--json" ]]; then emit_json; exit 0; fi # ─── Глубокий разбор retrans (`--retrans [--window N]`) ─────────────────────── @@ -1434,8 +1510,7 @@ if nft -t list table inet na_filter >/dev/null 2>&1; then [[ -n "$FBACK" ]] && info "в $NFT_FILE, но не в живом whitelist_v4: $FBACK (вернутся после ребута/reload na-firewall)" fi if [[ -f "$CONF_DIR/protect.conf" ]]; then - # conf пишется идиомой `: "${WHITELIST:=…}"` (protect v4.2 — `=`) — наивный - # `^WHITELIST=` не матчит + # conf пишется идиомой `: "${WHITELIST:=…}"` — наивный `^WHITELIST=` не матчит CONF_WL="$(conf_val "$CONF_DIR/protect.conf" WHITELIST \ | tr ',' '\n' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?' | sed -E 's#/32$##' | sort -u)" CDRIFT_L="$(comm -23 <(printf '%s\n' "$LIVE_WL" | grep -v '^$' | sort -u) <(printf '%s\n' "$CONF_WL" | grep -v '^$' | sort -u) 2>/dev/null)" @@ -1898,6 +1973,11 @@ if command -v openssl >/dev/null 2>&1; then elif [[ "$CERT_MIN" -lt 7 ]]; then bad "TLS-серт истекает через ${CERT_MIN} дн ($CERT_MIN_F) — renewal сломан?" elif [[ "$CERT_MIN" -lt 14 ]]; then wrn "TLS-серт истекает через ${CERT_MIN} дн ($CERT_MIN_F) — проверь авто-renew" else pass "ближайший TLS-серт: ${CERT_MIN} дн до истечения ($CERT_MIN_F)"; fi + if [[ "$CERT_STALE" -gt 0 ]]; then + if [[ "$CERT_STALE_D" -lt 0 ]]; then _sd="истёк $(( -CERT_STALE_D )) дн назад"; else _sd="истекает через ${CERT_STALE_D} дн"; fi + wrn "серт без обслуживающего сервиса: $CERT_STALE_F ($_sd$( [[ "$CERT_STALE" -gt 1 ]] && echo ", всего таких: $CERT_STALE")) — не нашёл ни renewal-конфига, ни контейнера/сервиса с этим путём. Брошенный — удали или NA_CERT_IGNORE='<глоб>'; если домен обслуживается — renewal сломан" + unset _sd + fi else info "openssl не установлен — проверка сроков сертификатов пропущена" fi diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 0c92f10..f4b0848 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -266,7 +266,8 @@ detect_ssh_port() { [[ -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}' | _ports_csv)" fi - [[ -z "$p" ]] && p="$(ss -tnlp 2>/dev/null | awk '/sshd|"ssh"|ssh\.socket/{n=split($4,a,":"); print a[n]}' | _ports_csv)" + # loopback не в счёт: sshd держит там X11-форвардинг сессий (127.0.0.1:6010+) + [[ -z "$p" ]] && p="$(ss -tnlp 2>/dev/null | awk '/sshd|"ssh"|ssh\.socket/ && $4 !~ /^(127\.|\[::1\])/ {n=split($4,a,":"); print a[n]}' | _ports_csv)" [[ -z "$p" ]] && p="$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/ {print $2}' /etc/ssh/sshd_config 2>/dev/null | _ports_csv)" [[ "$p" =~ ^[0-9]+(,[0-9]+)*$ ]] || p="" echo "${p:-22}" diff --git a/scripts/na-report.sh b/scripts/na-report.sh index fcfbfb3..42cda2c 100755 --- a/scripts/na-report.sh +++ b/scripts/na-report.sh @@ -108,7 +108,7 @@ crowdsec_count() { local all="" c [[ "${1:-}" == -a ]] && all=-a command -v cscli >/dev/null 2>&1 || { [[ -n "$all" ]] && echo -1 || echo 0; return; } - c="$(cscli decisions list $all -o raw 2>/dev/null | awk -F, '$1 ~ /^[0-9]+$/ {c++} END {print c+0}')" + c="$(cscli decisions list $all --limit 0 -o raw 2>/dev/null | awk -F, '$1 ~ /^[0-9]+$/ {c++} END {print c+0}')" [[ "$c" =~ ^[0-9]+$ ]] || c=0 echo "$c" } @@ -232,20 +232,22 @@ emit_json() { # сортирует (TSV), JSON собирает bash через json_escape: имя AS приходит из whois/DNS # как есть, и экранирования одних \ и " (как было) не хватало на управляющие символы. local asn_json="" an anm acc apct - while IFS=$'\t' read -r an apct acc anm; do + # разделитель \x1f, а не таб: `IFS=$'\t' read` склеивает подряд идущие пустые поля + # (таб — «пробельный» разделитель), и при пустой стране имя AS уезжало в country + while IFS=$'\x1f' read -r an apct acc anm; do [[ -n "$an" ]] || continue asn_json+="${asn_json:+,}{\"asn\":\"$(json_str "$an")\",\"name\":\"$(json_str "$anm")\",\"country\":\"$(json_str "$acc")\",\"pct\":$apct}" done < <( for i in "${!ips[@]}"; do - ip="${ips[$i]}"; printf '%s\t%s\t%s\t%s\n' "${ASN[$ip]:-?}" "${hitsarr[$i]}" "${CC[$ip]:-}" "${ANAME[$ip]:-}" - done | awk -F'\t' ' + ip="${ips[$i]}"; printf '%s\x1f%s\x1f%s\x1f%s\n' "${ASN[$ip]:-?}" "${hitsarr[$i]}" "${CC[$ip]:-}" "${ANAME[$ip]:-}" + done | awk -F'\037' ' $1!="?"{ hit[$1]+=$2; cc[$1]=$3; nm[$1]=$4; tot+=$2 } END{ if(tot<1) exit n=0; for(a in hit){ arr[n++]=a } # простая сортировка по hit desc for(i=0;ihit[arr[i]]){t=arr[i];arr[i]=arr[j];arr[j]=t} - for(i=0;i <наши порты через запятую> — токен без наших портов (по токену на +# строку). Ядро склеивает соседние порты в диапазон: наш 10085 и операторский 10086 читаются +# как «10085-10086», и сравнение токенов целиком записывало наш порт в операторские навсегда. +_resv_minus() { + local t="$1" a b q + a="${t%-*}"; b="${t#*-}" + for q in $(tr ',' '\n' <<<"$2" | grep -E '^[0-9]+$' | sort -un || true); do + (( q < a || q > b )) && continue + (( q > a )) && { if (( q - 1 > a )); then echo "$a-$((q - 1))"; else echo "$a"; fi; } + a=$(( q + 1 )) + done + (( a > b )) && return 0 + if (( b > a )); then echo "$a-$b"; else echo "$a"; fi +} na_reserved_ports() { - local prev="" cur t p list="" + local prev="" cur t r p list="" prev="$(cat "$NA_RESV_STATE" 2>/dev/null || true)" cur="$(sysctl -n net.ipv4.ip_local_reserved_ports 2>/dev/null || true)" for t in ${cur//,/ }; do [[ "$t" =~ ^[0-9]+(-[0-9]+)?$ ]] || continue - [[ ",$prev," == *",$t,"* ]] && continue - list+="${list:+,}$t" + for r in $(_resv_minus "$t" "$prev"); do list+="${list:+,}$r"; done done NA_RESV_OURS="" for p in $(na_node_listen_ports | sort -un); do @@ -536,6 +551,29 @@ na_reserved_ports() { } na_reserved_ports +# IPv6-форвардинг. С v4.2 по умолчанию не ставим (см. комментарий в файле ниже), но до v4.2 +# его ставил наш же файл — нода, которая маршрутизирует v6 (WireGuard, v6-relay), после +# апгрейда и ребута молча теряла бы маршрутизацию. Авто: форвардинг включён и через ноду +# уже прошли v6-пакеты → оставляем и закрепляем 1 в optimize.conf (после ребута счётчик +# с нуля — без закрепления решение скакало бы). Не было пакетов, а включал его наш старый +# файл — предупреждаем: после ребута будет 0. +NA_IPV6_FORWARD="${NA_IPV6_FORWARD:-}" +if [[ -z "$NA_IPV6_FORWARD" && "$(cat /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || echo 0)" == 1 ]]; then + _v6n="$(awk '$1=="Ip6OutForwDatagrams"{print $2}' /proc/net/snmp6 2>/dev/null || true)" + if [[ "$_v6n" =~ ^[0-9]+$ ]] && (( _v6n > 0 )); then + NA_IPV6_FORWARD=1 + info "IPv6-форвардинг включён, через ноду уже прошло $_v6n v6-пакетов (WireGuard/relay?) — оставляю net.ipv6.conf.all.forwarding=1 (NA_IPV6_FORWARD=1 в optimize.conf; 0 — снять)" + elif grep -sqE '^[[:space:]]*net\.ipv6\.conf\.all\.forwarding[[:space:]]*=[[:space:]]*1' "$SYSCTL_FILE"; then + warn "IPv6-форвардинг включён — его ставила прошлая версия node-accelerator; с v4.2 не ставим (режим роутера глушит RA, на SLAAC-хостах уходит v6 default route). После ребута будет 0. Нода маршрутизирует IPv6? — NA_IPV6_FORWARD=1 ре-ран" + fi + unset _v6n +fi +[[ -z "$NA_IPV6_FORWARD" || "$NA_IPV6_FORWARD" =~ ^[01]$ ]] || { warn "NA_IPV6_FORWARD='$NA_IPV6_FORWARD' — ожидается 0|1|пусто, беру авто"; NA_IPV6_FORWARD=""; } +NA_V6FWD_LINE="# net.ipv6.conf.all.forwarding — не ставим (NA_IPV6_FORWARD=${NA_IPV6_FORWARD:-авто})" +# sysctl не понимает комментарий в строке значения — пометка отдельной строкой +[[ "$NA_IPV6_FORWARD" == 1 ]] && NA_V6FWD_LINE="# NA_IPV6_FORWARD=1: нода маршрутизирует IPv6 +net.ipv6.conf.all.forwarding = 1" + backup_file "$SYSCTL_FILE" "$BACKUP" backup_file "$SYSCTL_CT_FILE" "$BACKUP" # Пишем во временный файл рядом (не *.conf — sysctl --system его не прочтёт): снимок @@ -601,6 +639,7 @@ net.ipv4.udp_wmem_min = 16384 # не нужен вовсе, а Docker с IPv6 включает v6-форвардинг сам при старте демона. net.ipv4.ip_forward = 1 net.ipv4.conf.all.forwarding = 1 +$NA_V6FWD_LINE # --- Conntrack: timeout здесь (tunable CT_EST_TIMEOUT); ёмкость (max/buckets) — # отдельным drop-in ниже, масштабируется от RAM (99-node-accelerator-conntrack.conf), @@ -1485,7 +1524,7 @@ save_conf "$CONF_DIR/optimize.conf" \ ENABLE_XANMOD XANMOD_FLAVOR REMNAWAVE_SWAP_SIZE \ DISABLE_TFO TCP_ECN_MODE ENABLE_MSS_CLAMP SETUP_NO_ZRAM CT_EST_TIMEOUT QDISC \ ENABLE_LOGROTATE NA_LOG_PATHS NA_LOG_MAXSIZE NA_LOG_ROTATE NA_LOG_INTERVAL \ - ENABLE_PSI NA_JOURNAL_MAX_USE NA_XPS + ENABLE_PSI NA_JOURNAL_MAX_USE NA_XPS NA_IPV6_FORWARD title "ГОТОВО" ok "Оптимизатор применён." diff --git a/scripts/protect.sh b/scripts/protect.sh index 9f01cb8..4b980e0 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -73,13 +73,15 @@ load_conf "$CONF_DIR/protect.conf" # детект: сменил порт sshd — strict открывал старый). Такой сверяем с фактом: совпал — # дальше не храним; расходится — открываем ОБА и предупреждаем. NA_SSH_DETECTED="$(detect_ssh_port)" -NA_EXPLICIT_KEYS="" +NA_EXPLICIT_KEYS=""; NA_SSH_SAVE="" if [[ -n "$NA_SSH_PORT_ENV" ]] || conf_is_explicit "$CONF_DIR/protect.conf" SSH_PORT; then NA_EXPLICIT_KEYS+=" SSH_PORT" elif [[ -n "${SSH_PORT:-}" && "$SSH_PORT" != "$NA_SSH_DETECTED" ]]; then + # В conf остаётся ИСХОДНОЕ значение без метки explicit: сверка (и это предупреждение) идёт + # на каждом прогоне, пока оператор не закрепит порт, — а не «оба порта навсегда». warn "SSH_PORT=$SSH_PORT в protect.conf сохранён старой версией (автодетект), а sshd сейчас слушает $NA_SSH_DETECTED — открываю ОБА; закрепи нужный: SSH_PORT=<порт> ре-ран" + NA_SSH_SAVE="$SSH_PORT" for _p in ${NA_SSH_DETECTED//,/ }; do [[ ",$SSH_PORT," == *",$_p,"* ]] || SSH_PORT+=",$_p"; done - NA_EXPLICIT_KEYS+=" SSH_PORT" unset _p else SSH_PORT="" @@ -522,8 +524,42 @@ _cs_yaml_get() { # _cs_yaml_get <ключ верхнего уровня> | /dev/null fi } +CS_LEGACY_FLAG="$STATE_DIR/crowdsec-legacy-scope.disabled" +# Порядок переключения важен: bouncer в обычном режиме на ОСТАНОВКЕ удаляет свою таблицу, а в +# set-only при старте ищет готовую. Рестарт «после» наших таблиц убивал бы их на выходе +# старого процесса — новый не находил таблицу и не стартовал (CrowdSec не блокировал вообще +# ничего). Поэтому: stop → свои таблицы/режим → start → проверка; не поднялся — назад на all. +_cs_bouncer_up() { # _cs_bouncer_up [<таблица> <набор>] — bouncer active (и набор на месте) + local n=0 + until systemctl is-active --quiet crowdsec-firewall-bouncer 2>/dev/null; do + (( ++n > 10 )) && return 1 + sleep 1 + done + [[ -z "${1:-}" ]] || nft list set ip "$1" "$2" >/dev/null 2>&1 +} +_cs_tables() { # → «t4 t6 s4 s6» из конфига bouncer'а + local t4 t6 s4 s6 + t4="$(_cs_yaml_get ipv4 table)"; t6="$(_cs_yaml_get ipv6 table)" + s4="$(_cs_yaml_get blacklists_ipv4)"; s6="$(_cs_yaml_get blacklists_ipv6)" + echo "${t4:-crowdsec} ${t6:-crowdsec6} ${s4:-crowdsec-blacklists} ${s6:-crowdsec6-blacklists}" +} +_cs_to_all() { # bouncer снова сам ставит правила (на всех портах) + local t4 t6 _s4 _s6 + read -r t4 t6 _s4 _s6 <<<"$(_cs_tables)" + systemctl stop crowdsec-firewall-bouncer >/dev/null 2>&1 || true + sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*true[[:space:]]*$/\1 false/' "$CS_BOUNCER_YAML" && rm -f "$CS_BOUNCER_YAML.na-tmp" + if [[ -f "$CS_SCOPE_UNIT" ]]; then + systemctl disable na-crowdsec-scope.service >/dev/null 2>&1 || true + rm -f "$CS_SCOPE_UNIT" "$CS_SCOPE_NFT" + systemctl daemon-reload 2>/dev/null || true + fi + # наши таблицы (bouncer в обычном режиме создаст свои заново) + nft delete table ip "$t4" 2>/dev/null || true + nft delete table ip6 "$t6" 2>/dev/null || true + systemctl start crowdsec-firewall-bouncer >/dev/null 2>&1 || true +} crowdsec_scope_apply() { - local want="$CROWDSEC_SCOPE" t4 t6 s4 s6 wl4="" wl6="" legacy=crowdsec-nft-scope.service + local want="$CROWDSEC_SCOPE" t4 t6 s4 s6 wl4 wl6 legacy=crowdsec-nft-scope.service [[ -f "$CS_BOUNCER_YAML" ]] || { info "CrowdSec scope: конфига bouncer'а нет ($CS_BOUNCER_YAML) — пропуск"; return 0; } if [[ "$want" == ssh ]] && ! grep -qE '^[[:space:]]+set-only:' "$CS_BOUNCER_YAML"; then warn "CrowdSec scope: bouncer без опции set-only (старая версия) — блок-листы остаются на ВСЕХ портах (как CROWDSEC_SCOPE=all)" @@ -531,27 +567,27 @@ crowdsec_scope_apply() { fi backup_file "$CS_BOUNCER_YAML" "$BACKUP" if [[ "$want" == all ]]; then - sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*true[[:space:]]*$/\1 false/' "$CS_BOUNCER_YAML" && rm -f "$CS_BOUNCER_YAML.na-tmp" - if [[ -f "$CS_SCOPE_UNIT" ]]; then - systemctl disable na-crowdsec-scope.service >/dev/null 2>&1 || true - rm -f "$CS_SCOPE_UNIT" "$CS_SCOPE_NFT" - systemctl daemon-reload 2>/dev/null || true + # уже в обычном режиме и без нашего юнита — менять нечего; лишний stop/start на каждом + # ре-ране оставлял окно без блок-листов и перегружал все решения из LAPI + if grep -qE '^[[:space:]]+set-only:[[:space:]]*true[[:space:]]*$' "$CS_BOUNCER_YAML" || [[ -f "$CS_SCOPE_UNIT" ]]; then + _cs_to_all + fi + if systemctl is-enabled --quiet "$legacy" 2>/dev/null; then + warn "CrowdSec scope: найден ручной $legacy (сужает блок-листы до SSH) — CROWDSEC_SCOPE=all его не трогает; сними сам, если нужно" fi - systemctl is-enabled --quiet "$legacy" 2>/dev/null \ - && warn "CrowdSec scope: найден ручной $legacy (сужает блок-листы до SSH) — CROWDSEC_SCOPE=all его не трогает; сними сам, если нужно" - systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true + rm -f "$CS_LEGACY_FLAG" # оператор явно выбрал all — ручной юнит при откате не возвращаем + _cs_bouncer_up || warn "CrowdSec scope: bouncer не active после переключения на all — проверь: systemctl status crowdsec-firewall-bouncer" CROWDSEC_SCOPE_EFF=all info "CrowdSec: блок-листы действуют на ВСЕХ портах (CROWDSEC_SCOPE=all)" return 0 fi - t4="$(_cs_yaml_get ipv4 table)"; t4="${t4:-crowdsec}" - t6="$(_cs_yaml_get ipv6 table)"; t6="${t6:-crowdsec6}" - s4="$(_cs_yaml_get blacklists_ipv4)"; s4="${s4:-crowdsec-blacklists}" - s6="$(_cs_yaml_get blacklists_ipv6)"; s6="${s6:-crowdsec6-blacklists}" - # строки с меткой na-wl4/na-wl6 переписывает `na-fw allow` — не трогать руками - wl4=" # na-wl4 (whitelist пуст)"; wl6=" # na-wl6 (whitelist пуст)" - [[ -n "$WL4" ]] && wl4=" ip saddr { $WL4 } return # na-wl4" - [[ -n "$WL6" ]] && wl6=" ip6 saddr { $WL6 } return # na-wl6" + read -r t4 t6 s4 s6 <<<"$(_cs_tables)" + # whitelist — именованные интервальные наборы (auto-merge: IP админа внутри CIDR из + # WHITELIST не ломает загрузку); строки с меткой na-wl4/na-wl6 переписывает `na-fw allow` + wl4=" set na_wl4 { type ipv4_addr; flags interval; auto-merge; } # na-wl4" + wl6=" set na_wl6 { type ipv6_addr; flags interval; auto-merge; } # na-wl6" + [[ -n "$WL4" ]] && wl4=" set na_wl4 { type ipv4_addr; flags interval; auto-merge; elements = { $WL4 } } # na-wl4" + [[ -n "$WL6" ]] && wl6=" set na_wl6 { type ipv6_addr; flags interval; auto-merge; elements = { $WL6 } } # na-wl6" cat > "$CS_SCOPE_NFT.new" </dev/null 2>&1 || true mv -f "$CS_SCOPE_NFT.new" "$CS_SCOPE_NFT" sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*false[[:space:]]*$/\1 true/' "$CS_BOUNCER_YAML" && rm -f "$CS_BOUNCER_YAML.na-tmp" cat > "$CS_SCOPE_UNIT" </dev/null; then systemctl disable "$legacy" >/dev/null 2>&1 || true - info "CrowdSec scope: ручной $legacy выключен из автозагрузки — его роль теперь у na-crowdsec-scope.service (файл оставлен)" + mkdir -p "$STATE_DIR" 2>/dev/null && : > "$CS_LEGACY_FLAG" + info "CrowdSec scope: ручной $legacy выключен из автозагрузки — его роль теперь у na-crowdsec-scope.service (файл оставлен; rollback вернёт его)" fi systemctl daemon-reload 2>/dev/null || true systemctl enable na-crowdsec-scope.service >/dev/null 2>&1 || true nft -f "$CS_SCOPE_NFT" 2>/dev/null || warn "CrowdSec scope: nft -f $CS_SCOPE_NFT не прошёл" - # bouncer в set-only заполняет уже существующие наборы — перезапуск после таблиц - systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true + systemctl start crowdsec-firewall-bouncer >/dev/null 2>&1 || true + if ! _cs_bouncer_up "$t4" "$s4"; then + warn "CrowdSec scope: bouncer не поднялся в set-only (или нет набора $s4) — возвращаю CROWDSEC_SCOPE=all, чтобы CrowdSec не остался без правил" + _cs_to_all + CROWDSEC_SCOPE_EFF=all + return 0 + fi CROWDSEC_SCOPE_EFF=ssh ok "CrowdSec: блок-листы — только на SSH (:${SSH_EFF}); whitelist исключён; клиентские порты их не видят (CROWDSEC_SCOPE=all — как раньше)" } @@ -1132,7 +1177,10 @@ fi # fail-loud (маркер degraded + warn), БЕЗ тихой деградации; synproxy-правила не ставятся. SYNPROXY_PRE=""; SYNPROXY_IN=""; SP_MODPROBE=""; SYNPROXY_OK=0 rm -f "$STATE_DIR/.synproxy-degraded" 2>/dev/null || true -if [[ "$ENABLE_SYNPROXY" == "1" ]]; then +if [[ "$ENABLE_SYNPROXY" == "1" && -z "$TCP_PORTS" ]]; then + # пустой TCP_PORTS дал бы `tcp dport { }` — nft -c падает и прерывает весь прогон + warn "SYNPROXY запрошен, но TCP_PORTS пуст — защищать нечего, synproxy не ставлю" +elif [[ "$ENABLE_SYNPROXY" == "1" ]]; then _kmaj="$(uname -r | cut -d. -f1)"; _kmin="$(uname -r | cut -d. -f2)" [[ "$_kmaj" =~ ^[0-9]+$ ]] || _kmaj=0; [[ "$_kmin" =~ ^[0-9]+$ ]] || _kmin=0 if { [[ "$_kmaj" -gt 5 ]] || { [[ "$_kmaj" -eq 5 ]] && [[ "$_kmin" -ge 14 ]]; }; } && modprobe nft_synproxy 2>/dev/null; then @@ -1150,12 +1198,24 @@ if [[ "$ENABLE_SYNPROXY" == "1" ]]; then # synproxy, ставится ниже) третий ACK рукопожатия conntrack видит как invalid, и до # v4.2 его съедал invalid-drop раньше synproxy — новые TCP на TCP_PORTS не # устанавливались вовсе (а при loose=1 ACK шёл мимо synproxy в сокет и получал RST). - # Забаненных режем раньше: иначе synproxy дописал бы им рукопожатие, а дальше - # established прошёл бы мимо autoban. - SYNPROXY_IN=" # SYNPROXY (до invalid-drop; забаненные — раньше него) - tcp dport { ${SP_SET} } ip saddr @autoban_v4 counter name c_autoban drop - tcp dport { ${SP_SET} } ip6 saddr @autoban_v6 counter name c_autoban drop + # Всё, что должно решиться ДО рукопожатия, стоит здесь же: после synproxy соединение + # уже established, и автобан/блок-листы/per-IP лимиты ниже (они на `ct state new`) его + # не увидят. Whitelist сильнее бана (как в основной цепочке). ct count для этих + # портов не действует — у untracked SYN ещё нет состояния; SYN-rate — своим набором. + _sp_bl="" + [[ "$ENABLE_BLOCKLISTS" == "1" ]] && _sp_bl=" + tcp dport { ${SP_SET} } ip saddr @blocklist_v4 ip saddr != @whitelist_v4 counter name c_blocklist drop + tcp dport { ${SP_SET} } ip6 saddr @blocklist_v6 ip6 saddr != @whitelist_v6 counter name c_blocklist drop" + SYNPROXY_IN=" # SYNPROXY (до invalid-drop; бан/блок-листы/SYN-rate — раньше него, whitelist сильнее бана) + tcp dport { ${SP_SET} } ip saddr @autoban_v4 ip saddr != @whitelist_v4 counter name c_autoban drop + tcp dport { ${SP_SET} } ip6 saddr @autoban_v6 ip6 saddr != @whitelist_v6 counter name c_autoban drop$_sp_bl + tcp dport { ${SP_SET} } ct state untracked ip saddr != @whitelist_v4 update @spsyn4 { ip saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop + tcp dport { ${SP_SET} } ct state untracked ip6 saddr != @whitelist_v6 update @spsyn6 { ip6 saddr limit rate over ${SYN_RATE}/second burst ${SYN_BURST} packets } jump synflood_drop tcp dport { ${SP_SET} } ct state invalid,untracked synproxy mss ${SP_MSS} wscale 7 timestamp sack-perm" + unset _sp_bl + rate_set spsyn4 ipv4_addr "$SYN_RATE" "$SYN_BURST" second + rate_set spsyn6 ipv6_addr "$SYN_RATE" "$SYN_BURST" second + warn "SYNPROXY: nf_conntrack_tcp_loose=0 действует на ВЕСЬ хост — TCP-сессии, пережившие сброс conntrack или простоявшие дольше CT_EST_TIMEOUT без пакетов (SSH без keepalive), будут сброшены как invalid" ok "SYNPROXY: ядро $(uname -r) ок, mss ${SP_MSS} wscale 7 (notrack только host-local)" else warn "SYNPROXY запрошен, но недоступен (нужно ядро ≥5.14 + модуль nft_synproxy). Защита БЕЗ synproxy." @@ -1544,9 +1604,14 @@ check_journal_budget() { local cap day pct cap="$(journald_cap_bytes)" (( cap > 0 )) || return 0 - day=$(( PORTSCAN_LOG_RATE * 1440 * NA_JOURNAL_LINE_BYTES )) + # лог-правил анти-скана несколько (v4/v6 × suspect/бан) и у каждого свой лимит — это + # потолок сверху; на деле переходов в suspect/бан на порядки меньше одного в секунду + local nrules=2; [[ "${ENABLE_BANONCE:-1}" == "1" ]] && nrules=4 + day=$(( PORTSCAN_LOG_RATE * 1440 * NA_JOURNAL_LINE_BYTES * nrules )) pct=$(( day * 100 / cap )) - (( pct > 30 )) || return 0 + # с v4.2 строки пишутся только на переходах — предупреждаем, лишь если даже теоретический + # потолок съедает весь кап журнала за сутки (иначе дефолт 60/мин давал бы ложный warn) + (( pct > 100 )) || return 0 warn "журнал: лог анти-скана при PORTSCAN_LOG_RATE=$PORTSCAN_LOG_RATE даёт ~$(_hbytes "$day")/сутки при капе journald $(_hbytes "$cap") (${pct}% в сутки) — история в журнале проживёт меньше $(( 100 / pct )) суток и вытеснит логи входов/сервисов" warn "снизь PORTSCAN_LOG_RATE (0 = не логировать, на бан не влияет) или подними NA_JOURNAL_MAX_USE (optimize)" return 0 @@ -2173,6 +2238,7 @@ fam_of() { # fam_of <адрес> → 4|6; rc=1 — не адрес else return 1; fi } norm() { local a="$1"; a="${a%/32}"; [[ "$a" == *:* ]] && a="${a%/128}"; printf '%s' "$a"; } +nonempty() { grep -v '^$' || true; } # под pipefail пустой вход — не ошибка conf_list() { sed -nE 's/^: "\$\{WHITELIST:?=([^}]*)\}".*/\1/p' "$CONF" 2>/dev/null | tail -1; } conf_set() { # conf_set — переписать WHITELIST= в protect.conf (none = пусто) local v="${1:-none}" tmp @@ -2186,49 +2252,14 @@ conf_set() { # conf_set — переписать WHITELIST= в protect.c chmod 0600 "$tmp"; mv -f "$tmp" "$CONF" } file_elems() { # file_elems 4|6 — элементы whitelist_vN из na_filter.nft - sed -nE "s/.*set whitelist_v$1 \\{.*elements = \\{ ([^}]*) \\}.*/\\1/p" "$NFT_FILE" 2>/dev/null | tr -d ' ' | tr ',' '\n' | grep -v '^$' || true + sed -nE "s/.*set whitelist_v$1 \\{.*elements = \\{ ([^}]*) \\}.*/\\1/p" "$NFT_FILE" 2>/dev/null | tr -d ' ' | tr ',' '\n' | nonempty } -file_set() { # file_set 4|6 <элементы через перевод строки> - local fam="$1" list="$2" typ=ipv4_addr line tmp - [[ "$fam" == 6 ]] && typ=ipv6_addr - list="$(printf '%s\n' "$list" | grep -v '^$' | sort -u | paste -sd, - | sed 's/,/, /g')" - if [[ -n "$list" ]]; then line=" set whitelist_v$fam { type $typ; flags interval; auto-merge; elements = { $list } }" - else line=" set whitelist_v$fam { type $typ; flags interval; auto-merge; }"; fi - tmp="$(mktemp "$NFT_FILE.XXXXXX")" - awk -v fam="$fam" -v line="$line" '$0 ~ "^[[:space:]]*set whitelist_v" fam " \\{" {print line; next} {print}' "$NFT_FILE" > "$tmp" - nft -c -f "$tmp" >/dev/null 2>&1 || { rm -f "$tmp"; die "изменённый na_filter.nft не прошёл nft -c — ничего не тронуто"; } - chmod 0644 "$tmp"; mv -f "$tmp" "$NFT_FILE" -} -sync_aux() { # CrowdSec-yaml, правило scope, хэш в маркере — из итогового файла - local w4 w6 ip="" cidr="" x - w4="$(file_elems 4)"; w6="$(file_elems 6)" - for x in $w4 $w6; do - if [[ "$x" == */* ]]; then cidr+=" - \"$x\""$'\n'; else ip+=" - \"$x\""$'\n'; fi - done - if [[ -d "$(dirname "$CS_YAML")" ]]; then - if [[ -n "$ip$cidr" ]]; then - { echo "name: node-accelerator/whitelist"; echo "description: never ban admin/panel"; echo "whitelist:" - echo " reason: node-accelerator trusted" - [[ -n "$ip" ]] && { echo " ip:"; printf "%s" "$ip"; } - [[ -n "$cidr" ]] && { echo " cidr:"; printf "%s" "$cidr"; }; } > "$CS_YAML" - else rm -f "$CS_YAML"; fi - systemctl reload crowdsec >/dev/null 2>&1 || true - fi - if [[ -f "$SCOPE_NFT" ]]; then - local l4=" # na-wl4 (whitelist пуст)" l6=" # na-wl6 (whitelist пуст)" tmp - [[ -n "$w4" ]] && l4=" ip saddr { $(paste -sd, - <<<"$w4" | sed 's/,/, /g') } return # na-wl4" - [[ -n "$w6" ]] && l6=" ip6 saddr { $(paste -sd, - <<<"$w6" | sed 's/,/, /g') } return # na-wl6" - tmp="$(mktemp "$SCOPE_NFT.XXXXXX")" - awk -v l4="$l4" -v l6="$l6" '/na-wl4/{print l4; next} /na-wl6/{print l6; next} {print}' "$SCOPE_NFT" > "$tmp" - if nft -c -f "$tmp" >/dev/null 2>&1; then - mv -f "$tmp" "$SCOPE_NFT"; nft -f "$SCOPE_NFT" 2>/dev/null || true - systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true - else rm -f "$tmp"; echo "[!] правило CrowdSec scope не обновлено (nft -c)" >&2; fi - fi - if [[ -f "$STATE_DIR/protect.installed" ]]; then - local sha; sha="$(sha256sum "$NFT_FILE" | awk '{print $1}')" - sed -i.na-tmp -E "s/^nft_sha256=.*/nft_sha256=$sha/" "$STATE_DIR/protect.installed" && rm -f "$STATE_DIR/protect.installed.na-tmp" - fi +set_line() { # set_line <4|6> <имя набора> <отступ> <элементы через \n> [хвост] + local typ=ipv4_addr list + [[ "$1" == 6 ]] && typ=ipv6_addr + list="$(printf '%s\n' "$4" | nonempty | sort -u | paste -sd, - | sed 's/,/, /g')" + if [[ -n "$list" ]]; then printf '%sset %s { type %s; flags interval; auto-merge; elements = { %s } }%s' "$3" "$2" "$typ" "$list" "${5:-}" + else printf '%sset %s { type %s; flags interval; auto-merge; }%s' "$3" "$2" "$typ" "${5:-}"; fi } cmd="${1:-}"; sub="${2:-}" case "$cmd" in @@ -2236,34 +2267,95 @@ case "$cmd" in [[ -f "$NFT_FILE" ]] || die "нет $NFT_FILE — protect не ставился (или FW_MODE=skip)" case "$sub" in list) - echo "живой сет v4: $(nft list set inet na_filter whitelist_v4 2>/dev/null | sed -n '/elements = {/,/}/p' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]+)?' | paste -sd' ' -)" - echo "живой сет v6: $(nft list set inet na_filter whitelist_v6 2>/dev/null | sed -n '/elements = {/,/}/p' | grep -oE '[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){2,7}(/[0-9]+)?' | paste -sd' ' -)" + echo "живой сет v4: $(nft list set inet na_filter whitelist_v4 2>/dev/null | sed -n '/elements = {/,/}/p' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]+)?' | paste -sd' ' - || true)" + echo "живой сет v6: $(nft list set inet na_filter whitelist_v6 2>/dev/null | sed -n '/elements = {/,/}/p' | grep -oE '[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){2,7}(/[0-9]+)?' | paste -sd' ' - || true)" echo "protect.conf: $(conf_list)"; exit 0 ;; add|del) ;; *) die "использование: na-fw allow add|del|list …" ;; esac shift 2; [[ $# -gt 0 ]] || die "не указан адрес" + # один исполнитель на файрвол: параллельный прогон protect перезаписал бы файл + if command -v flock >/dev/null 2>&1; then + mkdir -p "$STATE_DIR"; exec 9>"$STATE_DIR/protect.lock" + flock -n 9 || die "идёт прогон protect (лок $STATE_DIR/protect.lock) — повтори после него" + fi cur="$(conf_list)"; [[ "$cur" == none ]] && cur="" e4="$(file_elems 4)"; e6="$(file_elems 6)" + live_add=(); live_del=() for a in "$@"; do fam="$(fam_of "$a")" || die "'$a' — не IPv4/IPv6/CIDR" a="$(norm "$a")" + if [[ "$a" == */* ]]; then + pfx="${a#*/}"; deny_lt=8; warn_lt=29 + [[ "$fam" == 6 ]] && { deny_lt=16; warn_lt=64; } + (( pfx >= deny_lt )) || die "'$a' — слишком широко для whitelist (полный обход защиты для огромного диапазона)" + (( pfx >= warn_lt )) || echo "[!] $a — это ПОЛНЫЙ обход защиты для всего диапазона; сузь до хостов, если можно" >&2 + fi if [[ "$sub" == add ]]; then [[ ",$cur," == *",$a,"* ]] || cur+="${cur:+,}$a" if [[ "$fam" == 4 ]]; then e4+=$'\n'"$a"; else e6+=$'\n'"$a"; fi - nft add element inet na_filter "whitelist_v$fam" "{ $a }" 2>/dev/null || true - nft delete element inet na_filter "autoban_v$fam" "{ $a }" 2>/dev/null || true - say "allow $a" + live_add+=("$fam $a") else cur="$(tr ',' '\n' <<<"$cur" | grep -vxF -- "$a" | paste -sd, - || true)" if [[ "$fam" == 4 ]]; then e4="$(grep -vxF -- "$a" <<<"$e4" || true)"; else e6="$(grep -vxF -- "$a" <<<"$e6" || true)"; fi - nft delete element inet na_filter "whitelist_v$fam" "{ $a }" 2>/dev/null || true - say "deny $a (снят из whitelist)" + live_del+=("$fam $a") fi done - file_set 4 "$e4"; file_set 6 "$e6" + # 1) файл автозагрузки: обе строки сразу, nft -c ДО замены — не прошло, ничего не тронуто + tmp="$(mktemp "$NFT_FILE.XXXXXX")" + l4="$(set_line 4 whitelist_v4 ' ' "$e4")"; l6="$(set_line 6 whitelist_v6 ' ' "$e6")" + awk -v l4="$l4" -v l6="$l6" '/^[[:space:]]*set whitelist_v4 \{/{print l4; next} /^[[:space:]]*set whitelist_v6 \{/{print l6; next} {print}' "$NFT_FILE" > "$tmp" + nft -c -f "$tmp" >/dev/null 2>&1 || { rm -f "$tmp"; die "изменённый na_filter.nft не прошёл nft -c — ничего не тронуто"; } + chmod 0644 "$tmp"; mv -f "$tmp" "$NFT_FILE" + # 2) живые наборы + for x in ${live_add[@]+"${live_add[@]}"}; do + nft add element inet na_filter "whitelist_v${x%% *}" "{ ${x#* } }" 2>/dev/null || true + nft delete element inet na_filter "autoban_v${x%% *}" "{ ${x#* } }" 2>/dev/null || true + say "allow ${x#* }" + done + for x in ${live_del[@]+"${live_del[@]}"}; do + nft delete element inet na_filter "whitelist_v${x%% *}" "{ ${x#* } }" 2>/dev/null \ + || echo "[!] ${x#* } не удалён из живого сета (слит с соседним интервалом?) — уйдёт при ребуте/ре-ране protect" >&2 + say "deny ${x#* } (снят из whitelist)" + done + # 3) protect.conf — ре-ран protect применит то же самое conf_set "$cur" - sync_aux + # 4) CrowdSec-парсер: из итогового файла + if [[ -d "$(dirname "$CS_YAML")" ]]; then + ip=""; cidr="" + for x in $(file_elems 4) $(file_elems 6); do + if [[ "$x" == */* ]]; then cidr+=" - \"$x\""$'\n'; else ip+=" - \"$x\""$'\n'; fi + done + if [[ -n "$ip$cidr" ]]; then + { echo "name: node-accelerator/whitelist"; echo "description: never ban admin/panel"; echo "whitelist:" + echo " reason: node-accelerator trusted" + [[ -n "$ip" ]] && { echo " ip:"; printf "%s" "$ip"; } + [[ -n "$cidr" ]] && { echo " cidr:"; printf "%s" "$cidr"; }; } > "$CS_YAML" + else rm -f "$CS_YAML"; fi + systemctl reload crowdsec >/dev/null 2>&1 || true + fi + # 5) правило CROWDSEC_SCOPE=ssh: строки-наборы в файле + живые наборы (без рестарта bouncer'а) + if [[ -f "$SCOPE_NFT" ]]; then + t4="$(awk '$1=="table" && $2=="ip" {print $3; exit}' "$SCOPE_NFT")" + t6="$(awk '$1=="table" && $2=="ip6" {print $3; exit}' "$SCOPE_NFT")" + s4l="$(set_line 4 na_wl4 ' ' "$(file_elems 4)" ' # na-wl4')" + s6l="$(set_line 6 na_wl6 ' ' "$(file_elems 6)" ' # na-wl6')" + tmp="$(mktemp "$SCOPE_NFT.XXXXXX")" + awk -v l4="$s4l" -v l6="$s6l" '/# na-wl4$/{print l4; next} /# na-wl6$/{print l6; next} {print}' "$SCOPE_NFT" > "$tmp" + mv -f "$tmp" "$SCOPE_NFT" + for f in 4 6; do + tb="$t4"; fm=ip; [[ "$f" == 6 ]] && { tb="$t6"; fm=ip6; } + [[ -n "$tb" ]] || continue + el="$(file_elems "$f" | paste -sd, - | sed 's/,/, /g')" + { echo "flush set $fm $tb na_wl$f"; [[ -n "$el" ]] && echo "add element $fm $tb na_wl$f { $el }"; } \ + | nft -f - 2>/dev/null || echo "[!] живой набор na_wl$f правила CrowdSec не обновлён — применится при ребуте" >&2 + done + fi + # 6) хэш файла в маркере: правка через na-fw — не «ручная» + if [[ -f "$STATE_DIR/protect.installed" ]]; then + sha="$(sha256sum "$NFT_FILE" | awk '{print $1}')" + sed -i.na-tmp -E "s/^nft_sha256=.*/nft_sha256=$sha/" "$STATE_DIR/protect.installed" && rm -f "$STATE_DIR/protect.installed.na-tmp" + fi say "whitelist обновлён во всех слоях (сет, na_filter.nft, protect.conf, CrowdSec)" ;; unban) shift; [[ $# -gt 0 ]] || die "не указан адрес" @@ -2341,6 +2433,7 @@ EOF # SSH_PORT пишем, только если он задан оператором — автодетект каждый прогон делает заново. for _k in $NA_NONE_KEYS; do printf -v "$_k" '%s' none; done _ssh_key=""; [[ " $NA_EXPLICIT_KEYS " == *" SSH_PORT "* ]] && _ssh_key=SSH_PORT +[[ -n "$NA_SSH_SAVE" && -z "$_ssh_key" ]] && { _ssh_key=SSH_PORT; SSH_PORT="$NA_SSH_SAVE"; } export NA_EXPLICIT_KEYS save_conf "$CONF_DIR/protect.conf" \ FW_MODE $_ssh_key TCP_PORTS UDP_PORTS NODE_PORT WHITELIST CROWDSEC_SCOPE DNAT_GUARD UDP_AMP_DROP \ diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 76d7806..4bc728f 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -72,6 +72,17 @@ rollback_optimize() { while IFS= read -r line; do k="${line%%$'\t'*}"; v="${line#*$'\t'}" [[ "$k" =~ ^[a-z0-9_.-]+$ ]] || continue + # Форвардинг НЕ возвращаем: снимок снят до Docker (штатный порядок раскатки), там + # 0, а `conf.all.forwarding=0` выключил бы его на всех интерфейсах — bridge- + # контейнеры (Caddy панели, второй тенант) потеряли бы сеть до рестарта Docker. + case "$k" in + net.ipv4.ip_forward|net.ipv4.conf.*.forwarding|net.ipv6.conf.*.forwarding) continue ;; + esac + # потолок conntrack ниже текущего числа записей — «table full», дроп новых соединений + if [[ "$k" == net.netfilter.nf_conntrack_max && "$v" =~ ^[0-9]+$ ]]; then + local cnt; cnt="$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null || echo 0)" + [[ "$cnt" =~ ^[0-9]+$ ]] && (( v < cnt * 2 )) && v=$(( cnt * 2 )) + fi p="/proc/sys/${k//.//}" [[ -w "$p" ]] || continue printf '%s\n' "$v" > "$p" 2>/dev/null && n=$((n+1)) @@ -183,19 +194,34 @@ rollback_protect() { rm -f /etc/sysctl.d/99-na-synproxy.conf sysctl -q -w net.netfilter.nf_conntrack_tcp_loose=1 2>/dev/null || true fi - # CROWDSEC_SCOPE=ssh: наш юнит/правила снимаем, bouncer возвращаем к своим правилам на - # всех портах (set-only: false) — иначе он только наполнял бы наборы, которые никто не читает + # CROWDSEC_SCOPE=ssh: наш юнит/правила снимаем. Порядок как в protect: bouncer в set-only + # таблицу на остановке не трогает — stop → режим/таблицы → start. Если protect выключал + # ручной crowdsec-nft-scope.service оператора (флаг) — возвращаем его вместе с set-only: + # откат не должен снова включить community-лист на всех портах, который оператор сузил сам. if [[ -f /etc/systemd/system/na-crowdsec-scope.service ]]; then + local _by=/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml _legacy=crowdsec-nft-scope.service + systemctl stop crowdsec-firewall-bouncer >/dev/null 2>&1 || true systemctl disable na-crowdsec-scope.service >/dev/null 2>&1 || true rm -f /etc/systemd/system/na-crowdsec-scope.service "$CONF_DIR/na-crowdsec-scope.nft" systemctl daemon-reload 2>/dev/null || true - _by=/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml - if [[ -f "$_by" ]]; then - sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*true[[:space:]]*$/\1 false/' "$_by" && rm -f "$_by.na-tmp" - systemctl restart crowdsec-firewall-bouncer >/dev/null 2>&1 || true + if [[ -f "$STATE_DIR/crowdsec-legacy-scope.disabled" ]] && systemctl cat "$_legacy" >/dev/null 2>&1; then + systemctl enable "$_legacy" >/dev/null 2>&1 || true + systemctl restart "$_legacy" >/dev/null 2>&1 || true + info "CrowdSec scope: возвращён ручной $_legacy (как было до protect)" + else + if [[ -f "$_by" ]]; then + sed -i.na-tmp -E 's/^([[:space:]]+set-only:)[[:space:]]*true[[:space:]]*$/\1 false/' "$_by" && rm -f "$_by.na-tmp" + fi + local _t + for _t in "ip $(awk '/^nftables:/{n=1} n && $1=="ipv4:"{f=1} f && $1=="table:"{print $2; exit}' "$_by" 2>/dev/null)" \ + "ip6 $(awk '/^nftables:/{n=1} n && $1=="ipv6:"{f=1} f && $1=="table:"{print $2; exit}' "$_by" 2>/dev/null)"; do + [[ "$_t" == *" " ]] && continue + nft delete table $_t 2>/dev/null || true + done + info "CrowdSec scope снят: bouncer снова ставит свои правила (на всех портах)" fi - unset _by - info "CrowdSec scope снят: bouncer снова ставит свои правила (на всех портах)" + rm -f "$STATE_DIR/crowdsec-legacy-scope.disabled" + systemctl start crowdsec-firewall-bouncer >/dev/null 2>&1 || true fi rm -f "$STATE_DIR/safety-fired.last" "$STATE_DIR/protect.lock" # nftables.service до v4.1.2 включал сам protect (boot-persist), но выключать не будем: он diff --git a/tests/apply-smoke.sh b/tests/apply-smoke.sh index ceae0aa..12b75e6 100755 --- a/tests/apply-smoke.sh +++ b/tests/apply-smoke.sh @@ -347,6 +347,10 @@ ENABLE_CROWDSEC=0 REMNAWAVE_NONINTERACTIVE=1 DRY_RUN=0 bash "$T/scripts/protect. set -e grep -q 'tcp dport { 2200, 22 } ct state new update @ssh4' "$NFTF" || { echo "[x] v4.2: устаревший SSH_PORT из conf + детект — не оба порта"; fail=1; } grep -qF 'сохранён старой версией (автодетект)' "$T/apply-v42e.log" || { echo "[x] v4.2: нет warn про устаревший SSH_PORT"; fail=1; } +# в conf остаётся исходный порт БЕЗ метки explicit — сверка (и warn) повторится на ре-ране, +# а не «оба порта навсегда» +grep -q 'SSH_PORT:=2200}' "$T/conf/protect.conf" && ! grep -qx '# explicit: SSH_PORT' "$T/conf/protect.conf" \ + || { echo "[x] v4.2: устаревший SSH_PORT закреплён (ждём :=2200 без explicit)"; fail=1; } # ── timeout набора растёт с медленным rate; NA_RATE_TO_*=0 (вечные записи) отвергается ── reset_t diff --git a/tests/crowdsec-e2e.sh b/tests/crowdsec-e2e.sh new file mode 100755 index 0000000..227b2f1 --- /dev/null +++ b/tests/crowdsec-e2e.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# crowdsec-e2e.sh — переключение CROWDSEC_SCOPE на НАСТОЯЩЕМ crowdsec-firewall-bouncer +# (root, systemd, пакеты crowdsec + crowdsec-firewall-bouncer-nftables уже стоят и запущены). +# +# Регрессия блокера v4.2: protect грузил свою таблицу и делал `restart` bouncer'а. Старый +# процесс (обычный режим) на выходе удалял таблицу — уже нашу; новый в set-only её не +# находил и не стартовал: CrowdSec не блокировал ничего, даже SSH. Юнит-тест подменяет +# systemctl и nft и этого не видит — поэтому здесь настоящий bouncer: +# ssh → bouncer active, наша цепочка на месте, решения доезжают в набор (и новые тоже); +# ssh → повтор (ре-ран protect) не роняет bouncer; +# all → bouncer снова ставит свои правила, нашей цепочки нет; +# ssh → и обратно. +# +# CI: job crowdsec-e2e. Запуск: sudo bash tests/crowdsec-e2e.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "[x] нужен root (sudo bash $0)"; exit 1; } +Y=/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml +for c in nft cscli systemctl; do command -v "$c" >/dev/null || { echo "[x] нет $c"; exit 1; }; done +[[ -f "$Y" ]] || { echo "[x] нет $Y (crowdsec-firewall-bouncer-nftables не установлен?)"; exit 1; } + +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +mkdir -p "$T/backup" +sed -n '/^# ─── Область действия блок-листов CrowdSec/,/^# ─── Сейфти-таймер/p' "$REPO_ROOT/scripts/protect.sh" | sed '$d' > "$T/scope.sh" +[[ -s "$T/scope.sh" ]] || { echo "[x] нет секции CROWDSEC_SCOPE в protect.sh"; exit 1; } + +PASS=0; FAIL=0 +check() { if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1"; else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi; } +active() { systemctl is-active --quiet crowdsec-firewall-bouncer; } +wait_for() { # wait_for <сек> <команда…> — ждать, пока команда не вернёт 0 + local n="$1"; shift + while (( n-- > 0 )); do "$@" && return 0; sleep 1; done + return 1 +} +in_table() { nft list table ip "$T4" 2>/dev/null | grep -qF "$1"; } +in_set() { nft list set ip "$T4" "$S4" 2>/dev/null | grep -qF "$1"; } +apply() { + bash -c "set -euo pipefail +. '$REPO_ROOT/scripts/lib/common.sh' +BACKUP='$T/backup'; CROWDSEC_SCOPE='$1'; SSH_NFT='22'; SSH_EFF='22'; WL4='203.0.113.7'; WL6='' +mkdir -p \"\$CONF_DIR\" \"\$STATE_DIR\" +. '$T/scope.sh' +crowdsec_scope_apply +echo \"EFF=\$CROWDSEC_SCOPE_EFF\"" 2>&1 +} +dump() { echo "---- bouncer:"; systemctl status crowdsec-firewall-bouncer --no-pager -l 2>&1 | tail -15 || true + journalctl -u crowdsec-firewall-bouncer -n 30 --no-pager 2>&1 || true; nft list tables 2>&1 || true; } + +T4="$(awk '/^nftables:/{n=1} n && $1=="ipv4:"{f=1} f && $1=="table:"{print $2; exit}' "$Y")"; T4="${T4:-crowdsec}" +S4="$(awk -F': *' '$1=="blacklists_ipv4"{gsub(/[" ]/,"",$2); print $2; exit}' "$Y")"; S4="${S4:-crowdsec-blacklists}" +echo "== bouncer $(crowdsec-firewall-bouncer -V 2>&1 | head -1 || true); таблица $T4, набор $S4" +grep -nE '^[[:space:]]+set-only:' "$Y" || echo " (в конфиге нет set-only — старый bouncer, ждём откат на all)" + +echo "== 0. исходно: bouncer в обычном режиме, решение доезжает" +wait_for 60 active || { dump; echo "[x] bouncer не active после установки"; exit 1; } +cscli decisions add --ip 198.51.100.77 --duration 2h --reason na-e2e >/dev/null +wait_for 40 in_table 198.51.100.77 && check "обычный режим: решение в таблице bouncer'а" 1 1 \ + || { check "обычный режим: решение в таблице bouncer'а" 1 0; dump; } + +for step in "1:ssh" "2:ssh" "3:all" "4:ssh"; do + n="${step%%:*}"; want="${step#*:}" + echo "== $n. CROWDSEC_SCOPE=$want" + OUT="$(apply "$want")" + check "$n: итоговая область = $want" "EFF=$want" "$(grep -oE '^EFF=[a-z]*$' <<<"$OUT" | tail -1)" + if active; then check "$n: bouncer active" 1 1; else check "$n: bouncer active" 1 0; printf '%s\n' "$OUT"; dump; fi + if [[ "$want" == ssh ]]; then + check "$n: наша цепочка na-scope-input на месте" 1 "$(nft list chain ip "$T4" na-scope-input 2>/dev/null | grep -c "@$S4")" + wait_for 40 in_set 198.51.100.77 && check "$n: старое решение доехало в набор (set-only)" 1 1 \ + || { check "$n: старое решение доехало в набор (set-only)" 1 0; dump; } + else + check "$n: нашей цепочки нет" 0 "$(nft list chain ip "$T4" na-scope-input >/dev/null 2>&1 && echo 1 || echo 0)" + wait_for 40 in_table 198.51.100.77 && check "$n: bouncer снова держит решения сам" 1 1 \ + || { check "$n: bouncer снова держит решения сам" 1 0; dump; } + fi + if [[ "$n" == 2 ]]; then + cscli decisions add --ip 198.51.100.78 --duration 2h --reason na-e2e >/dev/null + wait_for 40 in_set 198.51.100.78 && check "2: новое решение доезжает в set-only" 1 1 \ + || { check "2: новое решение доезжает в set-only" 1 0; dump; } + fi +done +apply all >/dev/null 2>&1 || true +cscli decisions delete --ip 198.51.100.77 >/dev/null 2>&1 || true +cscli decisions delete --ip 198.51.100.78 >/dev/null 2>&1 || true + +echo +echo " прогон: $PASS ok, $FAIL fail" +[[ "$FAIL" -eq 0 ]] || { echo "CROWDSEC-E2E: FAIL"; exit 1; } +echo "CROWDSEC-E2E: OK (ssh↔all на настоящем bouncer'е: не падает, решения доезжают в обе стороны)" diff --git a/tests/cs-scope-nft.sh b/tests/cs-scope-nft.sh new file mode 100755 index 0000000..6300dbd --- /dev/null +++ b/tests/cs-scope-nft.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# cs-scope-nft.sh — правило CROWDSEC_SCOPE=ssh на НАСТОЯЩЕМ nft (root или CAP_NET_ADMIN). +# +# protect-unit подменяет nft и сверяет текст. Здесь — что файл, который генерирует +# crowdsec_scope_apply, грузится nft этой ОС (0.9.3 на Ubuntu 20.04, 0.9.8 на Debian 11, +# 1.x дальше), что whitelist-набор с auto-merge принимает адрес внутри CIDR из того же +# WHITELIST, что правка живого набора так, как её делает `na-fw allow`, проходит, и что +# повторная загрузка файла (ре-ран/юнит на буте) идемпотентна. Bouncer'а здесь нет — +# systemctl стаб; переключение на настоящем bouncer'е проверяет crowdsec-e2e.sh. +# +# CI: smoke-матрица (контейнер --privileged) и job nft-behavior. Запуск: sudo bash tests/cs-scope-nft.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[[ "$(uname -s)" == Linux ]] || { echo "SKIP: нужен Linux (nftables)"; exit 0; } +[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "[x] нужен root (sudo bash $0)"; exit 1; } +command -v nft >/dev/null || { echo "[x] нет nft"; exit 1; } + +T="$(mktemp -d)" +TB4=na_t_cs; TB6=na_t_cs6 +cleanup() { nft delete table ip "$TB4" 2>/dev/null || true; nft delete table ip6 "$TB6" 2>/dev/null || true; rm -rf "$T"; } +trap cleanup EXIT +mkdir -p "$T/bin" "$T/conf" "$T/sys" "$T/backup" "$T/state" +cat > "$T/bouncer.yaml" < "$T/bin/systemctl"; printf '#!/bin/sh\nexit 0\n' > "$T/bin/sleep" +chmod +x "$T/bin"/* +# секция protect.sh как есть; юнит — в песочницу. `nft list set` после «старта bouncer'а» +# найдёт набор, который создал наш же файл: стаб systemctl отвечает «active». +sed -n '/^# ─── Область действия блок-листов CrowdSec/,/^# ─── Сейфти-таймер/p' "$REPO_ROOT/scripts/protect.sh" | sed '$d' \ + | sed -e "s#/etc/systemd/system/#$T/sys/#g" > "$T/scope.sh" +[[ -s "$T/scope.sh" ]] || { echo "[x] нет секции CROWDSEC_SCOPE в protect.sh"; exit 1; } + +PASS=0; FAIL=0 +check() { if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); echo " ok $1"; else FAIL=$((FAIL+1)); echo " FAIL $1: ожидалось [$2], получено [$3]"; fi; } +echo "== nft $(nft --version 2>/dev/null | head -1)" + +apply() { + PATH="$T/bin:$PATH" bash -c "set -euo pipefail +. '$REPO_ROOT/scripts/lib/common.sh' +CONF_DIR='$T/conf'; STATE_DIR='$T/state'; BACKUP='$T/backup'; CS_BOUNCER_YAML='$T/bouncer.yaml' +CROWDSEC_SCOPE=ssh; SSH_NFT='22, 2200'; SSH_EFF='22,2200' +# адрес внутри CIDR из того же списка: без auto-merge старый nft отказывал (conflicting intervals) +WL4='198.51.100.0/24, 198.51.100.7, 203.0.113.7'; WL6='2001:db8::/64, 2001:db8::5' +. '$T/scope.sh' +crowdsec_scope_apply +echo \"EFF=\$CROWDSEC_SCOPE_EFF\"" 2>&1 +} +OUT="$(apply)" +check "файл scope прошёл nft -c и загружен (EFF=ssh)" 1 "$(grep -c '^EFF=ssh$' <<<"$OUT")" +[[ "$OUT" == *"EFF=ssh"* ]] || printf '%s\n' "$OUT" +check "дроп по блок-листу — только на SSH-портах" 1 \ + "$(nft list chain ip "$TB4" na-scope-input 2>/dev/null | grep -cE 'tcp dport \{ 22, 2200 \} ip saddr @na-t-blacklists')" +check "v4-whitelist: CIDR в живом наборе" 1 "$(nft list set ip "$TB4" na_wl4 2>/dev/null | grep -c '198.51.100.0/24')" +check "v6-whitelist: CIDR в живом наборе" 1 "$(nft list set ip6 "$TB6" na_wl6 2>/dev/null | grep -c '2001:db8::/64')" +# bouncer добавляет решения с timeout — набор обязан это принимать +rc=0; nft add element ip "$TB4" na-t-blacklists '{ 192.0.2.10 timeout 1h }' || rc=$? +check "в набор блок-листа добавляется адрес с timeout (как это делает bouncer)" 0 "$rc" +# na-fw allow: flush + add element в живой набор (тем же текстом, что генерирует na-fw) +rc=0; printf 'flush set ip %s na_wl4\nadd element ip %s na_wl4 { 203.0.113.7, 198.51.100.0/24, 198.51.100.9 }\n' "$TB4" "$TB4" | nft -f - || rc=$? +check "правка живого набора как в na-fw allow (с адресом внутри CIDR)" 0 "$rc" +# ре-ран / юнит na-crowdsec-scope на буте: тот же файл поверх живой таблицы +rc=0; nft -f "$T/conf/na-crowdsec-scope.nft" || rc=$? +check "повторная загрузка файла поверх живой таблицы" 0 "$rc" +check "…и после неё правило на месте (одно)" 1 \ + "$(nft list chain ip "$TB4" na-scope-input 2>/dev/null | grep -c 'ip saddr @na-t-blacklists')" + +echo +echo " прогон: $PASS ok, $FAIL fail" +[[ "$FAIL" -eq 0 ]] || { echo "CS-SCOPE-NFT: FAIL"; exit 1; } +echo "CS-SCOPE-NFT: OK (файл правила CROWDSEC_SCOPE=ssh грузится этим nft, whitelist с auto-merge, правка как в na-fw)" diff --git a/tests/diagnose-unit.sh b/tests/diagnose-unit.sh index 773ae1f..cdbfbbb 100755 --- a/tests/diagnose-unit.sh +++ b/tests/diagnose-unit.sh @@ -227,6 +227,8 @@ case "$1" in inspect) case "$*" in *LogConfig*) echo "/remnanode json-file "; exit 0 ;; + # mount'ы работающих контейнеров (сенсор сертов: кто обслуживает путь) + *Mounts*) [ -f "$NFTD/docker-mounts" ] && cat "$NFTD/docker-mounts"; exit 0 ;; esac name="" for a in "$@"; do name="$a"; done @@ -245,7 +247,7 @@ case "$1" in # опубликованные порты (v4.2: сенсор DNAT мимо na_filter) и id для LogConfig case "$*" in *"{{.Ports}}"*) [ -f "$NFTD/docker-ports" ] && cat "$NFTD/docker-ports" ;; - "ps -q") [ "${NA_TEST_DK_NOCAP:-0}" = 1 ] && echo abc123 ;; + "ps -q") { [ "${NA_TEST_DK_NOCAP:-0}" = 1 ] || [ -f "$NFTD/docker-mounts" ]; } && echo abc123 ;; esac exit 0 ;; esac @@ -305,14 +307,14 @@ cat > "$BIN/cscli" <<'CS' # `decisions list -o raw` печатает CSV С ЗАГОЛОВКОМ — он и завышал счёт на 1 (#32) # `-a` — вместе с CAPI/списками: NA_TEST_CAPI решений origin=CAPI и NA_TEST_LISTS — lists case "$*" in - "decisions list -o raw"|"decisions list -a -o raw") + "decisions list -o raw"|"decisions list -a -o raw"|"decisions list --limit 0 -o raw"|"decisions list -a --limit 0 -o raw") echo "id,source,ip,reason,action,country,as,events_count,expiration,simulated,alert_id" i=0 while [ "$i" -lt "${NA_TEST_DECISIONS:-0}" ]; do i=$((i+1)) echo "$i,crowdsec,Ip:203.0.113.$i,crowdsecurity/ssh-bf,ban,,\"64500 EXAMPLE, Inc\",3,3h,false,$i" done - if [ "$*" = "decisions list -a -o raw" ]; then + if [ "$*" = "decisions list -a -o raw" ] || [ "$*" = "decisions list -a --limit 0 -o raw" ]; then j=0 while [ "$j" -lt "${NA_TEST_CAPI:-0}" ]; do j=$((j+1)); echo "$((1000+j)),CAPI,Ip:198.18.0.$j,crowdsecurity/http-probing,ban,,,0,160h,false,0" @@ -427,6 +429,12 @@ cat > "$BIN/openssl" <<'SSL' #!/bin/sh # Снятый с renew серт истекает РАНЬШЕ живого: если сенсор его учтёт — увидим ✘ (#39) for a in "$@"; do last="$a"; done +# CN: старая копия (*dup*) — тот же домен, что у живого серта; у прочих — свой +case "$last" in + *dup*|*selfsteal*) echo "subject=CN=node.example.test" ;; + *expired*) echo "subject=CN=expired.example.test" ;; + *) echo "subject=O=Example,CN=other.example.test" ;; +esac case "$last" in *retired*) echo "notAfter=RETIRED" ;; *expired*) echo "notAfter=EXPIRED" ;; @@ -1073,16 +1081,43 @@ grep_ok "…именно ▲" "▲ с rm -f "$T/run/reboot-required" "$T/run/reboot-required.pkgs" echo "== 20. сертификаты: просроченный не маскируется, Caddy и nginx находятся (v4.2) ==" -mkdir -p "$T/etc/letsencrypt/live/expired.example.test" +mkdir -p "$T/etc/letsencrypt/live/expired.example.test" "$T/etc/letsencrypt/renewal" printf 'x\n' > "$T/etc/letsencrypt/live/expired.example.test/fullchain.pem" +# с renewal-конфигом certbot серт в работе — истёк, значит renewal сломан: ✘ +printf '[renewalparams]\n' > "$T/etc/letsencrypt/renewal/expired.example.test.conf" env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20.json" 2>/dev/null || true check "json: просроченный серт: cert_min_days=-10" -10 "$(jget "$T/o20.json" cert_min_days)" check "json: cert_found=true" true "$(jget "$T/o20.json" cert_found)" +check "json: обслуживаемый — не «брошенный»" 0 "$(jget "$T/o20.json" cert_stale_count)" env TERM=dumb "$WBASH" "$DIAG" > "$T/o20.txt" 2>/dev/null || true grep_ok "текст: ✘ «ИСТЁК»" "✘ TLS-серт ИСТЁК 10 дн назад" "$T/o20.txt" +# без renewal-конфига и без сервиса с этим путём — брошенный: не ✘, а ▲ и отдельное поле +rm -f "$T/etc/letsencrypt/renewal/expired.example.test.conf" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20s.json" 2>/dev/null || true +check "json: брошенный не тянет cert_min_days вниз (живой 60)" 60 "$(jget "$T/o20s.json" cert_min_days)" +check "json: cert_stale_count=1" 1 "$(jget "$T/o20s.json" cert_stale_count)" +check "json: cert_stale_file — брошенный" "$T/etc/letsencrypt/live/expired.example.test/fullchain.pem" "$(jget "$T/o20s.json" cert_stale_file)" +env TERM=dumb "$WBASH" "$DIAG" > "$T/o20s.txt" 2>/dev/null || true +grep_not "текст: брошенный — не ✘" "TLS-серт ИСТЁК" "$T/o20s.txt" +grep_ok "текст: брошенный — ▲ с подсказкой" "▲ серт без обслуживающего сервиса: $T/etc/letsencrypt/live/expired.example.test/fullchain.pem (истёк 10 дн назад)" "$T/o20s.txt" +# путь под mount работающего контейнера — обслуживается +printf '%s\n' "$T/etc/letsencrypt" > "$NFTD/docker-mounts" +env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20m.json" 2>/dev/null || true +check "json: под mount контейнера → снова в счёте (-10)" "-10 0" "$(jget "$T/o20m.json" cert_min_days) $(jget "$T/o20m.json" cert_stale_count)" +rm -f "$NFTD/docker-mounts" +# NA_CERT_IGNORE — исключить совсем +env TERM=dumb NA_CERT_IGNORE="$T/etc/letsencrypt/live/*" "$WBASH" "$DIAG" --json > "$T/o20i.json" 2>/dev/null || true +check "json: NA_CERT_IGNORE — путь не смотрится" "60 0" "$(jget "$T/o20i.json" cert_min_days) $(jget "$T/o20i.json" cert_stale_count)" rm -rf "$T/etc/letsencrypt" +# старая копия того же домена (CN как у живого) — молча мимо, даже без renewal +mkdir -p "$T/opt/old/certs"; printf 'x\n' > "$T/opt/old/certs/fullchain-expired-dup.pem" +NA_CERT_PATHS="$T/opt/old/certs/fullchain-expired-dup.pem" +env TERM=dumb NA_CERT_PATHS="$NA_CERT_PATHS" "$WBASH" "$DIAG" --json > "$T/o20u.json" 2>/dev/null || true +check "json: истёкшая копия при живом серте на тот же CN — не тревога" "60 0" "$(jget "$T/o20u.json" cert_min_days) $(jget "$T/o20u.json" cert_stale_count)" +rm -rf "$T/opt/old"; unset NA_CERT_PATHS CADDY_D="$T/var/lib/docker/volumes/caddy_data/_data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/panel.example.test" mkdir -p "$CADDY_D"; printf 'x\n' > "$CADDY_D/panel.example.test.crt" +# 15 дн — вне зоны тревоги: обслуживание не проверяется env TERM=dumb "$WBASH" "$DIAG" --json > "$T/o20b.json" 2>/dev/null || true check "json: серт Caddy в docker volume найден (15 дн)" 15 "$(jget "$T/o20b.json" cert_min_days)" rm -rf "$T/var/lib/docker/volumes" @@ -1117,7 +1152,7 @@ d=json.load(open(sys.argv[1])); k=list(d) new=["udp_rcvbuf_errors_delta","softnet_drops_delta","time_squeeze_delta","listen_overflows_delta","delta_window_s", "cpu_steal_boot_pct","crowdsec_decisions_local","crowdsec_decisions_capi","crowdsec_scope", "whitelist_drift_crowdsec","dnat_ports","dnat_guard","xanmod_update","cert_found", - "portscan_log_lines_24h","portscan_log_share_pct"] + "portscan_log_lines_24h","portscan_log_share_pct","cert_stale_count","cert_stale_file"] i=k.index("dynset_no_timeout") print("ok" if k[i+1:]==new and k[0]=="kernel" else "keys after tail: %s" % k[i+1:]) PYK diff --git a/tests/na-report-unit.sh b/tests/na-report-unit.sh index 05eef56..9948c89 100755 --- a/tests/na-report-unit.sh +++ b/tests/na-report-unit.sh @@ -61,11 +61,11 @@ NF cat > "$BIN/cscli" <<'CS' #!/bin/sh case "$*" in - "decisions list -o raw"|"decisions list -a -o raw") + "decisions list -o raw"|"decisions list -a -o raw"|"decisions list --limit 0 -o raw"|"decisions list -a --limit 0 -o raw") echo "id,source,ip,reason,action,country,as,events_count,expiration,simulated,alert_id" echo '1,crowdsec,Ip:203.0.113.10,crowdsecurity/ssh-bf,ban,ZZ,"64500 EXAMPLE, Inc",5,3h,false,1' echo '2,cscli,Ip:203.0.113.20,manual,ban,ZZ,,0,24h,false,2' - if [ "$*" = "decisions list -a -o raw" ]; then + if [ "$*" = "decisions list -a -o raw" ] || [ "$*" = "decisions list -a --limit 0 -o raw" ]; then i=0; while [ "$i" -lt 33 ]; do i=$((i+1)); echo "$((100+i)),CAPI,Ip:198.18.0.$i,crowdsecurity/http-probing,ban,,,0,160h,false,0"; done fi ;; "decisions list -o json") @@ -85,7 +85,12 @@ n=0 for ip in $(printf '%s\n' "$in" | grep -E '^[0-9]+\.'); do n=$((n+1)) [ "$mode" = first ] && [ "$n" -gt 1 ] && break - printf '64500 | %s | 203.0.113.0/24 | ZZ | arin | 2000-01-01 | EXAMPLE\001NET\tSUB, ZZ\n' "$ip" + if [ "$mode" = nocc ]; then + # страна пустая — поле между разделителями пустое (так бывает у Cymru) + printf '64500 | %s | 203.0.113.0/24 | | arin | 2000-01-01 | EXAMPLE-NET\n' "$ip" + else + printf '64500 | %s | 203.0.113.0/24 | ZZ | arin | 2000-01-01 | EXAMPLE\001NET\tSUB, ZZ\n' "$ip" + fi done WH printf '#!/bin/sh\nexit 1\n' > "$BIN/dig" @@ -122,6 +127,10 @@ check "unique_src_5m = 2" 2 "$(jget "$T/o1.json" check "asn_enrichment = ok" ok "$(jget "$T/o1.json" asn_enrichment)" check "top_asn: имя без управляющего символа, таб не рвёт строку" "EXAMPLE" \ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["top_asn"][0]["name"].split("\t")[0][:7])' "$T/o1.json")" +# пустая страна: имя AS не должно уехать в country (`IFS=$'\t' read` склеивал пустые поля) +NA_TEST_WHOIS=nocc TERM=dumb "$WBASH" "$R" --json > "$T/o1n.json" 2>/dev/null || true +check "top_asn: при пустой стране country пуст, имя на месте" "|EXAMPLE-NET" \ + "$(python3 -c 'import json,sys; a=json.load(open(sys.argv[1]))["top_asn"][0]; print(a["country"]+"|"+a["name"])' "$T/o1n.json" 2>&1)" check "новые поля — строго в хвосте, после top_ips" ok "$(python3 - "$T/o1.json" <<'PY' import json, sys k = list(json.load(open(sys.argv[1]))) diff --git a/tests/nft-behavior.sh b/tests/nft-behavior.sh index 5d91ebb..16b5472 100755 --- a/tests/nft-behavior.sh +++ b/tests/nft-behavior.sh @@ -14,7 +14,7 @@ # а на полном наборе — общий потолок, а не открытая дверь для перебора; # 7. опубликованный (DNAT) порт: забаненный адрес режется в forward, whitelist проходит; # 8. SYNPROXY: рукопожатие на защищённом порту завершается (v4.2: synproxy до invalid-drop -# + nf_conntrack_tcp_loose=0). +# + nf_conntrack_tcp_loose=0); SYN-rate, бан и whitelist решаются ДО synproxy. # Размер набора и пол timeout ужаты ручками NA_RATE_SET_SIZE/NA_RATE_TO_*, чтобы проверить # переполнение и истечение за секунды. # @@ -58,6 +58,7 @@ cp "$F" "$T/ruleset.nft"; rm -f "$F" # вариант с SYNPROXY — отдельный ruleset (synproxy меняет путь рукопожатия на TCP_PORTS) OUT="$(TERM=dumb NA_NO_LOCK=1 DRY_RUN=1 REMNAWAVE_NONINTERACTIVE=1 ENABLE_CROWDSEC=0 \ TCP_PORTS=8443 UDP_PORTS=none NODE_PORT=none ENABLE_SYNPROXY=1 \ + SYN_RATE=$RATE SYN_BURST=$BURST NA_RATE_TO_SEC=1s NA_RATE_TO_MIN=1s \ bash "$T/scripts/protect.sh" 2>&1)" || { echo "$OUT" | tail -20; echo "[x] генерация SYNPROXY-варианта не прошла"; exit 1; } F="$(sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' <<<"$OUT" | sed -nE 's/.*Генерация nftables → (\/tmp\/na_filter\.[A-Za-z0-9]+\.nft).*/\1/p' | tail -1)" cp "$F" "$T/ruleset-sp.nft"; rm -f "$F" @@ -218,6 +219,14 @@ if modprobe nft_synproxy 2>/dev/null || [[ -d /sys/module/nft_synproxy ]]; then if ip netns exec "$SRV" nft -f "$T/ruleset-sp.nft"; then ok_ "SYNPROXY-ruleset загружен" check "соединение через synproxy устанавливается (до v4.2 — нет)" 3 "$(connects 10.77.0.13 10.77.0.1 8443 3)" + # после synproxy соединение уже established: лимиты на `ct state new` и блок-листы ниже + # его не видят — всё, что решается до рукопожатия, стоит перед synproxy (ревью v4.2) + okr="$(connects 10.77.0.15 10.77.0.1 8443 10)" + [[ "$okr" -lt 10 ]] && ok_ "SYN-rate до synproxy: из 10 быстрых прошли не все ($okr)" || fail_ "SYN-rate до synproxy не сработал: прошло $okr из 10" + ip netns exec "$SRV" nft add element inet na_filter autoban_v4 '{ 10.77.0.16 timeout 5m }' + check "забаненный не завершает рукопожатие через synproxy" 0 "$(connects 10.77.0.16 10.77.0.1 8443 1)" + ip netns exec "$SRV" nft add element inet na_filter whitelist_v4 '{ 10.77.0.16 }' + check "whitelist сильнее бана и на synproxy-порту" 1 "$(connects 10.77.0.16 10.77.0.1 8443 1)" else fail_ "SYNPROXY-ruleset не загрузился" fi diff --git a/tests/optimize-unit.sh b/tests/optimize-unit.sh index 1ee15db..904253c 100755 --- a/tests/optimize-unit.sh +++ b/tests/optimize-unit.sh @@ -141,7 +141,8 @@ awk '/^# ─── 3\. Sysctl/{f=1} /^# ─── 4\. Лимиты/{f=0} f' "$OP -e "s# /lib/sysctl\.d# $T/lib/sysctl.d#g" \ -e "s#/etc/modules-load\.d#$T/etc/modules-load.d#g" \ -e "s#/proc/sys/#$T/proc/sys/#g" \ - -e "s#/proc/meminfo#$T/proc/meminfo#g" > "$T/sysctl-section.sh" + -e "s#/proc/meminfo#$T/proc/meminfo#g" \ + -e "s#/proc/net/snmp6#$T/proc/net/snmp6#g" > "$T/sysctl-section.sh" [ -s "$T/sysctl-section.sh" ] || { echo "[x] не смог извлечь секцию 3 из optimize.sh"; exit 1; } cat > "$T/wrap-sysctl.sh" < "$T/state/reserved-ports.na" +kset net.ipv4.ip_local_reserved_ports "10086-10087,20000-20010" +"$REAL_SED" -e 's/:10086 /:10090 /' "$T/ss-t" > "$T/ss-t.new" && mv "$T/ss-t.new" "$T/ss-t" +run_sysctl +expect "rc=0" test "$rc" -eq 0 +expect "резерв: 10086 снят из склейки, операторский 10087 и наш 10090" grep -qE '^net.ipv4.ip_local_reserved_ports = 10087,10090,20000-20010$' "$F" +expect "состояние: наши порты = 10090" grep -qx 10090 "$T/state/reserved-ports.na" +# склейка внутри широкого диапазона оператора: наш порт вырезается, края остаются +printf '10090\n' > "$T/state/reserved-ports.na" +kset net.ipv4.ip_local_reserved_ports "10087,10089-10095,20000-20010" +run_sysctl +expect "резерв: из 10089-10095 вырезан только наш 10090 (и снова добавлен — xray там)" \ + grep -qE '^net.ipv4.ip_local_reserved_ports = 10087,10089,10090,10091-10095,20000-20010$' "$F" +"$REAL_SED" -e 's/:10090 /:10086 /' "$T/ss-t" > "$T/ss-t.new" && mv "$T/ss-t.new" "$T/ss-t" +printf '10086\n' > "$T/state/reserved-ports.na" +kset net.ipv4.ip_local_reserved_ports "10086,20000-20010" + +echo "== 1b3. IPv6-форвардинг: оставляем, если нода уже маршрутизирует v6 (апгрейд с ≤4.1) ==" +mkdir -p "$T/proc/net" +# прошлая версия ставила форвардинг нашим файлом; через ноду шли v6-пакеты (WireGuard) +printf 'net.ipv6.conf.all.forwarding = 1\n' >> "$F" +kset net.ipv6.conf.all.forwarding 1 +printf 'Ip6InReceives 1000\nIp6OutForwDatagrams 42\n' > "$T/proc/net/snmp6" +run_sysctl +expect "rc=0" test "$rc" -eq 0 +expect "v6 маршрутизируется → форвардинг остаётся в файле" grep -qE '^net\.ipv6\.conf\.all\.forwarding[[:space:]]+= 1$' "$F" +expect "…и сказано, почему (NA_IPV6_FORWARD=1)" grep -q 'через ноду уже прошло 42 v6-пакетов' "$T/out" +# пакетов не было, а форвардинг включал наш старый файл — не ставим, но предупреждаем +printf 'Ip6InReceives 1000\nIp6OutForwDatagrams 0\n' > "$T/proc/net/snmp6" +run_sysctl +expect_not "без v6-трафика через ноду — не ставим" grep -qE '^net\.ipv6\.conf\.all\.forwarding' "$F" +expect "…и предупреждаем: после ребута будет 0" grep -q 'После ребута будет 0' "$T/out" +# явный выбор оператора сильнее авто +printf 'Ip6InReceives 1000\nIp6OutForwDatagrams 42\n' > "$T/proc/net/snmp6" +NA_IPV6_FORWARD=0 run_sysctl +expect_not "NA_IPV6_FORWARD=0 — не ставим даже при трафике" grep -qE '^net\.ipv6\.conf\.all\.forwarding' "$F" +kset net.ipv6.conf.all.forwarding 0; rm -f "$T/proc/net/snmp6" + echo "== 1c. сверка через systemd-analyze cat-config ==" printf 'net.core.wmem_max = 4194304\n' > "$T/etc/sysctl.d/99-zz-ops.conf" printf '#\n# /etc/sysctl.conf - Configuration file for setting system variables\n' > "$T/etc/sysctl.conf" diff --git a/tests/protect-unit.sh b/tests/protect-unit.sh index 50ed0c5..3e53f65 100644 --- a/tests/protect-unit.sh +++ b/tests/protect-unit.sh @@ -327,8 +327,10 @@ nftables: chain: crowdsec6-chain priority: -10 YML -printf '#!/bin/sh\nexit 0\n' > "$CS/bin/nft" -printf '#!/bin/sh\necho "$*" >> "%s/systemctl.log"\ncase "$*" in "is-enabled --quiet crowdsec-nft-scope.service") exit 1;; esac\nexit 0\n' "$CS" > "$CS/bin/systemctl" +printf '#!/bin/sh\necho "$*" >> "%s/nft.log"\nexit 0\n' "$CS" > "$CS/bin/nft" +# NA_TEST_BOUNCER_DOWN=1 — bouncer не поднимается (set-only без таблицы): is-active ≠ 0 +printf '#!/bin/sh\necho "$*" >> "%s/systemctl.log"\ncase "$*" in "is-enabled --quiet crowdsec-nft-scope.service") exit 1;; "is-active --quiet crowdsec-firewall-bouncer") [ "${NA_TEST_BOUNCER_DOWN:-0}" = 1 ] && exit 3;; esac\nexit 0\n' "$CS" > "$CS/bin/systemctl" +printf '#!/bin/sh\nexit 0\n' > "$CS/bin/sleep" chmod +x "$CS/bin"/* sed -n '/^# ─── Область действия блок-листов CrowdSec/,/^# ─── Сейфти-таймер/p' "$PROTECT" | sed '$d' \ | sed -e "s#/etc/systemd/system/#$CS/sys/#g" > "$CS/scope.sh" @@ -347,17 +349,36 @@ crowdsec_scope_apply" 2>&1 check "ssh: bouncer переведён в set-only (обе семьи)" 2 "$(grep -c 'set-only: true' "$CS/bouncer.yaml")" check "ssh: правило дропа — только на SSH-портах" 1 \ "$(grep -c 'tcp dport { 22, 2200 } ip saddr @crowdsec-blacklists counter drop' "$CS/conf/na-crowdsec-scope.nft")" - check "ssh: whitelist исключён раньше дропа (CAPI-адрес админа не режет SSH)" 1 \ - "$(grep -c 'ip saddr { 203.0.113.7, 198.51.100.0/29 } return # na-wl4' "$CS/conf/na-crowdsec-scope.nft")" - check "ssh: пустой v6-whitelist — метка-заглушка для na-fw" 1 "$(grep -c '# na-wl6 (whitelist пуст)' "$CS/conf/na-crowdsec-scope.nft")" + check "ssh: whitelist — именованный интервальный набор (auto-merge)" 1 \ + "$(grep -c 'set na_wl4 { type ipv4_addr; flags interval; auto-merge; elements = { 203.0.113.7, 198.51.100.0/29 } } # na-wl4' "$CS/conf/na-crowdsec-scope.nft")" + check "ssh: whitelist исключён раньше дропа (CAPI-адрес админа не режет SSH)" 1 "$(grep -c 'ip saddr @na_wl4 return' "$CS/conf/na-crowdsec-scope.nft")" + check "ssh: пустой v6-whitelist — пустой набор с меткой для na-fw" 1 "$(grep -c 'set na_wl6 { type ipv6_addr; flags interval; auto-merge; } # na-wl6' "$CS/conf/na-crowdsec-scope.nft")" check "ssh: таблица/набор из конфига bouncer'а" 2 \ "$(grep -cE '^table ip crowdsec \{$|set crowdsec-blacklists \{' "$CS/conf/na-crowdsec-scope.nft")" check "ssh: юнит грузит правила ДО bouncer'а" 1 "$(grep -c 'Before=crowdsec-firewall-bouncer.service' "$CS/sys/na-crowdsec-scope.service")" - check "ssh: bouncer перезапущен (заполнит наборы)" 1 "$(grep -c 'restart crowdsec-firewall-bouncer' "$CS/systemctl.log")" - : > "$CS/systemctl.log" + # порядок: stop bouncer → свои таблицы → start (рестарт ПОСЛЕ таблиц убивал их на выходе + # старого процесса, и bouncer в set-only не стартовал — CrowdSec не блокировал ничего) + check "ssh: bouncer остановлен ДО загрузки таблиц и запущен после" "stop start" \ + "$(grep -oE '^(stop|start|restart) crowdsec-firewall-bouncer' "$CS/systemctl.log" | cut -d' ' -f1 | paste -sd' ' -)" + : > "$CS/systemctl.log"; : > "$CS/nft.log" OUT="$(cs_run all)" check "all: set-only снят" 0 "$(grep -c 'set-only: true' "$CS/bouncer.yaml")" check "all: наш юнит и правила убраны" 0 "$(ls "$CS/sys/na-crowdsec-scope.service" "$CS/conf/na-crowdsec-scope.nft" 2>/dev/null | wc -l | tr -d ' ')" + check "all: наши таблицы удалены (цепочка na-scope-input не висит)" 2 \ + "$(grep -cE '^delete table ip6? crowdsec6?$' "$CS/nft.log")" + check "all: stop → start (не restart)" "stop start" \ + "$(grep -oE '^(stop|start|restart) crowdsec-firewall-bouncer' "$CS/systemctl.log" | cut -d' ' -f1 | paste -sd' ' -)" + : > "$CS/systemctl.log" + OUT="$(cs_run all)" + check "all на обычном bouncer'е — без лишнего stop/start (окна без блок-листов)" 0 \ + "$(grep -cE '^(stop|start|restart) crowdsec-firewall-bouncer' "$CS/systemctl.log")" + # bouncer не поднялся в set-only (нет таблицы, старый процесс снёс и т.п.) — назад на all + : > "$CS/systemctl.log" + OUT="$(NA_TEST_BOUNCER_DOWN=1 cs_run ssh)" + check "ssh, bouncer не поднялся → предупреждение и откат на all" 1 \ + "$(printf '%s\n' "$OUT" | grep -c 'возвращаю CROWDSEC_SCOPE=all')" + check "…set-only снят, наш юнит убран" "0 0" \ + "$(grep -c 'set-only: true' "$CS/bouncer.yaml") $(ls "$CS/sys/na-crowdsec-scope.service" 2>/dev/null | wc -l | tr -d ' ')" sed -i.bak '/set-only/d' "$CS/bouncer.yaml"; rm -f "$CS/bouncer.yaml.bak" OUT="$(cs_run ssh)" check "старый bouncer без set-only → честный откат на all" 1 "$(printf '%s\n' "$OUT" | grep -c 'без опции set-only')" @@ -386,6 +407,13 @@ table inet na_filter { NF printf 'nft_sha256=old\n' > "$F/state/protect.installed" fw() { PATH="$F/bin:$PATH" "$WBASH" "$F/na-fw" "$@" 2>&1; } + # только v4 при пустом v6-whitelist: под pipefail grep по пустой семье ронял na-fw посередине + # (живой сет и v4-строка изменены, conf/CrowdSec/маркер — нет) + rc=0; fw allow add 198.51.100.8 >/dev/null || rc=$? + check "allow add только v4 (v6 пуст) — rc=0" 0 "$rc" + check "…и conf обновлён (не оборвалось посередине)" 1 "$(grep -c 'WHITELIST:=203.0.113.7,198.51.100.8}' "$F/etc/protect.conf")" + fw allow del 198.51.100.8 >/dev/null + check "отказ на /0" 1 "$(fw allow add 0.0.0.0/0 | grep -c 'слишком широко')" fw allow add 198.51.100.9 2001:db8::5 >/dev/null check "allow add: protect.conf дополнен" 1 "$(grep -c 'WHITELIST:=203.0.113.7,198.51.100.9,2001:db8::5}' "$F/etc/protect.conf")" check "allow add: v4 в na_filter.nft" 1 "$(grep -c 'elements = { 198.51.100.9, 203.0.113.7 }' "$F/etc/na_filter.nft")" From 5bfa5b34d39bc574633ecea1985237a933caa901 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 21:31:12 +0300 Subject: [PATCH 11/14] =?UTF-8?q?v4.2:=20=D0=BF=D0=BE=20CI=20=E2=80=94=20n?= =?UTF-8?q?a-fw=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D1=8B=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=B0=20scope=20=D0=BF=D0=BE=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BD=D0=BE=20(nft=200.9.3=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=BB=20flush+add=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B5=20=D0=B2=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=80=D0=B8=20CIDR),=20Debian=2011=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20debian-security=20=D0=B2=20=D1=81=D0=BC=D0=BE=D1=83?= =?UTF-8?q?=D0=BA=D0=B5,=20e2e=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D1=83=D0=B5=D1=82=20bouncer=20=D0=B2=20LAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu 20.04 (nft 0.9.3): `add element` всем списком падал «interval overlaps», если адрес лежит внутри CIDR из того же списка, и транзакция вместе с flush откатывалась — живой набор whitelist правила CROWDSEC_SCOPE не обновлялся. Теперь add/del по одному элементу, покрытый CIDR-ом адрес подтверждается `get element` (как в основном whitelist). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 28 +++++++++++++++++++++++++++- scripts/protect.sh | 18 +++++++++++++----- tests/crowdsec-e2e.sh | 7 +++++++ tests/cs-scope-nft.sh | 16 +++++++++++++--- tests/protect-unit.sh | 19 +++++++++++++++++++ 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1961fe..395d430 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,21 @@ jobs: | sudo tee /etc/apt/sources.list.d/crowdsec.list >/dev/null sudo apt-get update -qq sudo apt-get install -y -qq nftables crowdsec crowdsec-firewall-bouncer-nftables + - name: Bouncer зарегистрирован в LAPI и запущен + # postinst регистрирует bouncer и запускает его, только если LAPI уже отвечал; на + # раннере crowdsec поднимается позже — регистрируем сами (ключ локальный, одноразовый) + run: | + Y=/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml + sudo systemctl start crowdsec + for i in $(seq 60); do sudo cscli lapi status >/dev/null 2>&1 && break; sleep 1; done + sudo cscli lapi status + if ! sudo grep -qE '^api_key:[[:space:]]*[A-Za-z0-9]' "$Y"; then + KEY="$(sudo cscli bouncers add "na-ci-$(date +%s)" -o raw)" + sudo sed -i -E "s|^api_key:.*|api_key: ${KEY}|" "$Y" + fi + sudo systemctl enable --now crowdsec-firewall-bouncer + sleep 3; systemctl is-active crowdsec-firewall-bouncer \ + || { sudo journalctl -u crowdsec-firewall-bouncer -n 30 --no-pager; exit 1; } - name: ssh ↔ all на настоящем bouncer'е (не падает, решения доезжают) run: sudo bash tests/crowdsec-e2e.sh @@ -166,7 +181,18 @@ jobs: options: --privileged steps: - name: Install git (нужен checkout) + базовые сертификаты - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates + run: | + inst() { apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates; } + inst && exit 0 + # Debian 11 после LTS: индексы debian-security ссылаются на пакеты, которых в пуле + # уже нет (404). Для смоука хватает main — nft и прочее берём оттуда. + . /etc/os-release; [ "${VERSION_CODENAME:-}" = bullseye ] || exit 1 + echo "bullseye: debian-security недоступен — ставлю из main" + sed -i '/debian-security/d' /etc/apt/sources.list + inst && exit 0 + echo "bullseye: deb.debian.org не отдаёт — archive.debian.org" + echo 'deb http://archive.debian.org/debian bullseye main' > /etc/apt/sources.list + inst # SHA-пин: плавающий тег v4 может быть перенесён на другой коммит - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Prep container + runtime-зависимости diff --git a/scripts/protect.sh b/scripts/protect.sh index 4b980e0..42922ca 100755 --- a/scripts/protect.sh +++ b/scripts/protect.sh @@ -2343,12 +2343,20 @@ case "$cmd" in tmp="$(mktemp "$SCOPE_NFT.XXXXXX")" awk -v l4="$s4l" -v l6="$s6l" '/# na-wl4$/{print l4; next} /# na-wl6$/{print l6; next} {print}' "$SCOPE_NFT" > "$tmp" mv -f "$tmp" "$SCOPE_NFT" - for f in 4 6; do - tb="$t4"; fm=ip; [[ "$f" == 6 ]] && { tb="$t6"; fm=ip6; } + # поэлементно, как основной whitelist: на nft 0.9.x `flush` + `add` всем списком + # падает целиком («interval overlaps»), если адрес лежит внутри CIDR из того же + # списка. Адрес, уже покрытый CIDR-ом набора, и так в whitelist — это проверяет get. + for x in ${live_add[@]+"${live_add[@]}"} ${live_del[@]+"${live_del[@]}"}; do + f="${x%% *}"; a="${x#* }"; tb="$t4"; fm=ip; [[ "$f" == 6 ]] && { tb="$t6"; fm=ip6; } [[ -n "$tb" ]] || continue - el="$(file_elems "$f" | paste -sd, - | sed 's/,/, /g')" - { echo "flush set $fm $tb na_wl$f"; [[ -n "$el" ]] && echo "add element $fm $tb na_wl$f { $el }"; } \ - | nft -f - 2>/dev/null || echo "[!] живой набор na_wl$f правила CrowdSec не обновлён — применится при ребуте" >&2 + if [[ "$sub" == add ]]; then + nft add element "$fm" "$tb" "na_wl$f" "{ $a }" 2>/dev/null \ + || nft get element "$fm" "$tb" "na_wl$f" "{ $a }" >/dev/null 2>&1 \ + || echo "[!] $a не добавлен в живой набор na_wl$f правила CrowdSec — применится при ребуте/ре-ране protect" >&2 + else + nft delete element "$fm" "$tb" "na_wl$f" "{ $a }" 2>/dev/null \ + || echo "[!] $a не удалён из живого набора na_wl$f правила CrowdSec (слит с соседним интервалом?) — уйдёт при ребуте/ре-ране protect" >&2 + fi done fi # 6) хэш файла в маркере: правка через na-fw — не «ручная» diff --git a/tests/crowdsec-e2e.sh b/tests/crowdsec-e2e.sh index 227b2f1..9e429a1 100755 --- a/tests/crowdsec-e2e.sh +++ b/tests/crowdsec-e2e.sh @@ -59,6 +59,13 @@ cscli decisions add --ip 198.51.100.77 --duration 2h --reason na-e2e >/dev/null wait_for 40 in_table 198.51.100.77 && check "обычный режим: решение в таблице bouncer'а" 1 1 \ || { check "обычный режим: решение в таблице bouncer'а" 1 0; dump; } +# diagnose/na-report считают решения через `decisions list [-a] --limit 0 -o raw`: флаг должен +# приниматься этим cscli, а строки данных — начинаться с числового id (как разбирает awk) +rc=0; raw="$(cscli decisions list -a --limit 0 -o raw 2>&1)" || rc=$? +check "cscli принимает --limit 0 (-a, -o raw)" 0 "$rc" +check "…строка решения с числовым id и нашим адресом" 1 \ + "$(awk -F, '$1 ~ /^[0-9]+$/ && /198\.51\.100\.77/ {c++} END {print (c > 0)}' <<<"$raw")" + for step in "1:ssh" "2:ssh" "3:all" "4:ssh"; do n="${step%%:*}"; want="${step#*:}" echo "== $n. CROWDSEC_SCOPE=$want" diff --git a/tests/cs-scope-nft.sh b/tests/cs-scope-nft.sh index 6300dbd..62163ea 100755 --- a/tests/cs-scope-nft.sh +++ b/tests/cs-scope-nft.sh @@ -71,9 +71,19 @@ check "v6-whitelist: CIDR в живом наборе" 1 "$(nft list set ip6 "$TB # bouncer добавляет решения с timeout — набор обязан это принимать rc=0; nft add element ip "$TB4" na-t-blacklists '{ 192.0.2.10 timeout 1h }' || rc=$? check "в набор блок-листа добавляется адрес с timeout (как это делает bouncer)" 0 "$rc" -# na-fw allow: flush + add element в живой набор (тем же текстом, что генерирует na-fw) -rc=0; printf 'flush set ip %s na_wl4\nadd element ip %s na_wl4 { 203.0.113.7, 198.51.100.0/24, 198.51.100.9 }\n' "$TB4" "$TB4" | nft -f - || rc=$? -check "правка живого набора как в na-fw allow (с адресом внутри CIDR)" 0 "$rc" +# na-fw allow правит живой набор поэлементно: add, а для адреса, уже покрытого CIDR-ом +# набора, — get element (nft 0.9.3 отвергает такой add: «interval overlaps»; пачкой +# `flush` + `add` вся транзакция откатывалась, и набор не обновлялся вовсе) +covered_or_added() { # тот же порядок команд, что в na-fw + nft add element ip "$TB4" na_wl4 "{ $1 }" 2>/dev/null || nft get element ip "$TB4" na_wl4 "{ $1 }" >/dev/null 2>&1 +} +rc=0; covered_or_added 198.51.100.9 || rc=$? +check "na-fw allow: адрес внутри CIDR набора — добавлен или покрыт" 0 "$rc" +rc=0; covered_or_added 192.0.2.77 || rc=$? +check "na-fw allow: новый адрес вне CIDR — добавлен" 0 "$rc" +check "…и виден в живом наборе" 1 "$(nft list set ip "$TB4" na_wl4 2>/dev/null | grep -c '192.0.2.77')" +rc=0; nft delete element ip "$TB4" na_wl4 '{ 192.0.2.77 }' 2>/dev/null || rc=$? +check "na-fw allow del: одиночный адрес удаляется" 0 "$rc" # ре-ран / юнит na-crowdsec-scope на буте: тот же файл поверх живой таблицы rc=0; nft -f "$T/conf/na-crowdsec-scope.nft" || rc=$? check "повторная загрузка файла поверх живой таблицы" 0 "$rc" diff --git a/tests/protect-unit.sh b/tests/protect-unit.sh index 3e53f65..a8ade2f 100644 --- a/tests/protect-unit.sh +++ b/tests/protect-unit.sh @@ -414,7 +414,25 @@ NF check "…и conf обновлён (не оборвалось посередине)" 1 "$(grep -c 'WHITELIST:=203.0.113.7,198.51.100.8}' "$F/etc/protect.conf")" fw allow del 198.51.100.8 >/dev/null check "отказ на /0" 1 "$(fw allow add 0.0.0.0/0 | grep -c 'слишком широко')" + # правило CROWDSEC_SCOPE=ssh стоит — его whitelist-наборы правятся поэлементно (nft 0.9.x + # отвергал `flush` + `add` всем списком, когда адрес внутри CIDR того же списка) + cat > "$F/etc/na-crowdsec-scope.nft" <<'SCN' +table ip crowdsec { + set crowdsec-blacklists { type ipv4_addr; flags timeout; } + set na_wl4 { type ipv4_addr; flags interval; auto-merge; elements = { 203.0.113.7 } } # na-wl4 +} +table ip6 crowdsec6 { + set crowdsec6-blacklists { type ipv6_addr; flags timeout; } + set na_wl6 { type ipv6_addr; flags interval; auto-merge; } # na-wl6 +} +SCN + : > "$F/nft.log" fw allow add 198.51.100.9 2001:db8::5 >/dev/null + check "scope: строка-набор v4 в файле переписана" 1 \ + "$(grep -c 'set na_wl4 { type ipv4_addr; flags interval; auto-merge; elements = { 198.51.100.9, 203.0.113.7 } } # na-wl4' "$F/etc/na-crowdsec-scope.nft")" + check "scope: живые наборы — поэлементно, обе семьи" 2 \ + "$(grep -cE '^add element ip crowdsec na_wl4 \{ 198\.51\.100\.9 \}$|^add element ip6 crowdsec6 na_wl6 \{ 2001:db8::5 \}$' "$F/nft.log")" + check "scope: без flush всего набора" 0 "$(grep -c '^flush set' "$F/nft.log")" check "allow add: protect.conf дополнен" 1 "$(grep -c 'WHITELIST:=203.0.113.7,198.51.100.9,2001:db8::5}' "$F/etc/protect.conf")" check "allow add: v4 в na_filter.nft" 1 "$(grep -c 'elements = { 198.51.100.9, 203.0.113.7 }' "$F/etc/na_filter.nft")" check "allow add: v6 в na_filter.nft" 1 "$(grep -c 'set whitelist_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2001:db8::5 } }' "$F/etc/na_filter.nft")" @@ -426,6 +444,7 @@ NF check "allow del: убран из conf" 1 "$(grep -c 'WHITELIST:=198.51.100.9,2001:db8::5}' "$F/etc/protect.conf")" check "allow del: убран из файла" 0 "$(grep -c '203.0.113.7' "$F/etc/na_filter.nft")" check "allow del: убран из живого сета" 1 "$(grep -c 'delete element inet na_filter whitelist_v4 { 203.0.113.7 }' "$F/nft.log")" + check "allow del: убран из живого набора scope" 1 "$(grep -c '^delete element ip crowdsec na_wl4 { 203.0.113.7 }$' "$F/nft.log")" check "мусор вместо адреса — отказ" 1 "$(fw allow add '1.2.3.4;rm' | grep -c 'не IPv4/IPv6/CIDR')" fw unban 192.0.2.66 >/dev/null check "unban: autoban и suspect" 2 "$(grep -cE 'delete element inet na_filter (autoban|suspect)_v4 \{ 192.0.2.66 \}' "$F/nft.log")" From d3c56d588c2295619773aefdb36502b0709c42af Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 21:34:29 +0300 Subject: [PATCH 12/14] =?UTF-8?q?ci:=20Debian=2011=20=D0=B2=20=D1=81=D0=BC?= =?UTF-8?q?=D0=BE=D1=83=D0=BA=D0=B5=20=E2=80=94=20=D1=81=D0=BE=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20snapshot.debian.org=20(=D0=BF=D1=83=D0=BB?= =?UTF-8?q?=20debian-security=20=D0=B2=D0=BD=D0=B5=20LTS=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D1=82=20404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 395d430..fc8b991 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,17 +182,15 @@ jobs: steps: - name: Install git (нужен checkout) + базовые сертификаты run: | - inst() { apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates; } - inst && exit 0 - # Debian 11 после LTS: индексы debian-security ссылаются на пакеты, которых в пуле - # уже нет (404). Для смоука хватает main — nft и прочее берём оттуда. - . /etc/os-release; [ "${VERSION_CODENAME:-}" = bullseye ] || exit 1 - echo "bullseye: debian-security недоступен — ставлю из main" - sed -i '/debian-security/d' /etc/apt/sources.list - inst && exit 0 - echo "bullseye: deb.debian.org не отдаёт — archive.debian.org" - echo 'deb http://archive.debian.org/debian bullseye main' > /etc/apt/sources.list - inst + . /etc/os-release + if [ "${VERSION_CODENAME:-}" = bullseye ]; then + # Debian 11 вне LTS: индексы debian-security на deb.debian.org ссылаются на файлы, + # которых в пуле уже нет (404), а без security образ (пакеты из него уже стоят) + # не ставит git — «held broken packages». Берём согласованный срез snapshot. + S=20260920T000000Z + printf 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/%s bullseye main\ndeb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/%s bullseye-security main\n' "$S" "$S" > /etc/apt/sources.list + fi + apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates # SHA-пин: плавающий тег v4 может быть перенесён на другой коммит - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Prep container + runtime-зависимости From eaa7543c0d55773db2208ca37fe317029f337f3c Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 21:37:36 +0300 Subject: [PATCH 13/14] =?UTF-8?q?ci:=20Debian=2011=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B8=D0=B7=20=D1=81=D0=BC=D0=BE=D1=83=D0=BA?= =?UTF-8?q?=D0=B0=20(=D0=B2=D0=BD=D0=B5=20LTS:=20=D0=BF=D1=83=D0=BB=20debi?= =?UTF-8?q?an-security=20=D0=B1=D0=B5=D0=B7=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=D0=B7=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=BE=D0=B2,=20=D0=B2=20snapshot=20=D1=82=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5);=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B9=20nft=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20Ubuntu=2020.?= =?UTF-8?q?04=20(0.9.3);=20README/CHANGELOG=20=E2=80=94=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B3=D0=BE=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=B2=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 17 +++++------------ CHANGELOG.md | 8 +++++--- README.md | 2 +- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc8b991..28a996d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,8 +166,10 @@ jobs: fail-fast: false matrix: image: - - "debian:bullseye" # nft 0.9.8 — в README заявлен, в матрице не было - - "ubuntu:20.04" # focal, nft 0.9.3 — то же + # focal: самый старый nft из заявленных (0.9.3). Debian 11 (nft 0.9.8) вне LTS: в + # образе пакеты из debian-security, а пул security без файлов из индексов (404, и + # в snapshot тоже) — apt не ставит даже git; синтаксис покрывает focal. + - "ubuntu:20.04" - "debian:bookworm" - "debian:trixie" - "ubuntu:22.04" # jammy → подменяется на bookworm @@ -181,16 +183,7 @@ jobs: options: --privileged steps: - name: Install git (нужен checkout) + базовые сертификаты - run: | - . /etc/os-release - if [ "${VERSION_CODENAME:-}" = bullseye ]; then - # Debian 11 вне LTS: индексы debian-security на deb.debian.org ссылаются на файлы, - # которых в пуле уже нет (404), а без security образ (пакеты из него уже стоят) - # не ставит git — «held broken packages». Берём согласованный срез snapshot. - S=20260920T000000Z - printf 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/%s bullseye main\ndeb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/%s bullseye-security main\n' "$S" "$S" > /etc/apt/sources.list - fi - apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates + run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates # SHA-пин: плавающий тег v4 может быть перенесён на другой коммит - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Prep container + runtime-зависимости diff --git a/CHANGELOG.md b/CHANGELOG.md index 6941b9e..62d4781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -401,9 +401,11 @@ initrd, а GRUB уже выбрал новое ядро. Теперь устан включался: проверялся несуществующий модуль), SYN-rate режет до synproxy, забаненный не завершает рукопожатие, whitelist сильнее бана. - `tests/cs-scope-nft.sh` (новый) — файл правила `CROWDSEC_SCOPE=ssh` на **настоящем** nft: - в smoke-матрице (теперь с Debian 11 и Ubuntu 20.04 — nft 0.9.8 и 0.9.3, заявленные в README) - и в job `nft-behavior`. Whitelist с адресом внутри CIDR, правка живого набора как в `na-fw`, - повторная загрузка поверх живой таблицы. + в smoke-матрице (теперь с Ubuntu 20.04 — nft 0.9.3, самый старый из заявленных; Debian 11 + вне LTS, его образ в CI не ставит пакеты) и в job `nft-behavior`. Whitelist с адресом внутри CIDR, правка живого набора как в `na-fw`, + повторная загрузка поверх живой таблицы. Сразу нашёл: на nft 0.9.3 `na-fw` правил набор + scope `flush` + `add` всем списком, и при адресе внутри CIDR того же списка транзакция + откатывалась («interval overlaps») — теперь поэлементно. - `tests/crowdsec-e2e.sh` (новый, CI-job `crowdsec-e2e`) — переключение ssh → ssh → all → ssh на **настоящем** `crowdsec-firewall-bouncer` (packagecloud): bouncer не падает, старые и новые решения доезжают в набор в `set-only` и обратно в его собственные правила. diff --git a/README.md b/README.md index df37568..1cc3e5f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Оптимизация, диагностика и защита VPN-ноды (Remnawave / Xray / VLESS-Reality, xHTTP, Hysteria2/TUIC). Три модуля, все идемпотентны, всё откатывается одной командой. -> **Поддержка:** Debian 11/12/13, Ubuntu 20.04–26.04. Тестируется на нодах с `network_mode: host`. +> **Поддержка:** Debian 11/12/13, Ubuntu 20.04–26.04 (CI: Debian 12/13 и Ubuntu 20.04–26.04; Debian 11 вне LTS в CI не гоняется — самый старый nft, 0.9.3, покрывает Ubuntu 20.04). Тестируется на нодах с `network_mode: host`. --- From 75429f238debd898ec8c171a25b1a6da6d0f32c4 Mon Sep 17 00:00:00 2001 From: jestivald Date: Sat, 3 Oct 2026 22:40:37 +0300 Subject: [PATCH 14/14] release: sign 6 modules with minisign (v4.2) Co-Authored-By: Claude Opus 5.5 --- scripts/diagnose.sh.minisig | 8 ++++---- scripts/lib/common.sh.minisig | 8 ++++---- scripts/na-report.sh.minisig | 8 ++++---- scripts/optimize.sh.minisig | 8 ++++---- scripts/protect.sh.minisig | 8 ++++---- scripts/rollback.sh.minisig | 8 ++++---- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/scripts/diagnose.sh.minisig b/scripts/diagnose.sh.minisig index 6f1faec..2263a57 100644 --- a/scripts/diagnose.sh.minisig +++ b/scripts/diagnose.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.3 -RUQrJghT9nkdBFCEAwWrllS47lCVJwSJJthj3R6eNOithI/ixDYFLmryb/D0CNkK8GuO/neNVvzagE1zAKUASAfW73YDhYYWlg0= -trusted comment: timestamp:1791034798 file:diagnose.sh hashed -KIYk5fxLY8KqDhy9y8EDHN+61H7t2AeNtlYMxo8+7XOMh+No3+35nl6wUEBZTLjuTnPx+Wt8IcYeiUDrF5+iBw== +untrusted comment: node-accelerator v4.2 +RUQrJghT9nkdBLiRwgznnL4QLNJEsM8gRW0LoiXHGJp1Tqqtur1j26bt/2eZzlLctTMsHpvd7iO2VDhe5l+eknki5Fx/L6zZ7wY= +trusted comment: timestamp:1791056397 file:diagnose.sh hashed +GKjexVifIUvXUW1jjsLfilFdEGH8ZH0yR7qgnz2JKWhAn7+sQkPIWbVgm5QTIlJ4hcW+SlDrbBeInQzY7M7oBQ== diff --git a/scripts/lib/common.sh.minisig b/scripts/lib/common.sh.minisig index 7c7fb86..7267e68 100644 --- a/scripts/lib/common.sh.minisig +++ b/scripts/lib/common.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.3 -RUQrJghT9nkdBD+R1Pg09mULuaNOM/Lrvrs/zMFJlb0Cne3RdtH8Wra5nVKhxmzJYGWHy9PKm8BFydR+lXICiMY7Go4GCERsjw8= -trusted comment: timestamp:1791034798 file:common.sh hashed -RAnw0/8GdpQPCuOiA9rVIKjbW4S3I2PQjUFwyI6TOqJvYK6kUoBjlBeS3uQY1fTggJiDRVmv8tRDFBPZNRfpAA== +untrusted comment: node-accelerator v4.2 +RUQrJghT9nkdBO45MHO9PobGTl8YAQsi6jjgN+t565aI3txpAzE4wGDuVjy701ohxGFsniwMCFF9ISp4Xo0qFY8iCVCMjBDPnwM= +trusted comment: timestamp:1791056397 file:common.sh hashed +JgljxX1u+QjGMiCDAChZRCxDjOLYC721aTh5+Iwko9l+SwqFWCo181Nhh/k+Fr2XUCtYVRK2XKljHKrB5dnNDg== diff --git a/scripts/na-report.sh.minisig b/scripts/na-report.sh.minisig index 46b1ffa..00ef664 100644 --- a/scripts/na-report.sh.minisig +++ b/scripts/na-report.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.3 -RUQrJghT9nkdBOrdxdxfyqJUywTv/RLU7ZreVDZAtLeliMCTEsY5M0vWhsR1gF5MEbykzE8/QRKRqFN3Odck90tt1E7RC8oxow8= -trusted comment: timestamp:1791034798 file:na-report.sh hashed -sgw1l67Vrp2DhKiGbZSd+D9Vhgt+Wie37wqQKgibZyaEPgsSd3oR6i9Q75MQtcpSE2Zu+vfPGHQ4o9oVAOUKDA== +untrusted comment: node-accelerator v4.2 +RUQrJghT9nkdBJeRVwxPnPLYHAYAEuuwUDtDpFgLxAtskzBaVh2dOogIPxMVcRAjShpkUVAwHAI0TQv+DtqxEr01NbhESvJKswI= +trusted comment: timestamp:1791056397 file:na-report.sh hashed +lXJSOyX482QLHfxYHlOQvSootay902/zmxgMRlW3uQod0IliPB7Kfq6XggDNufawgkXYnjb1546JciTYRBOtAw== diff --git a/scripts/optimize.sh.minisig b/scripts/optimize.sh.minisig index 37cdece..f194aa0 100644 --- a/scripts/optimize.sh.minisig +++ b/scripts/optimize.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.3 -RUQrJghT9nkdBGAN1171KyR+ijYqhef+3TOEd0BTjvoql1JPPdzxTollSTVm6tiCHhgTfBGlf1JFIU0CLieEjhkQ/upd1HYKdw0= -trusted comment: timestamp:1791034798 file:optimize.sh hashed -OMM6gXhIgZxlzXCREfQaOAXOr8U1gnQBlyg8Z98DOZeSS+fRYCT8VcsGCnjSW/0ZlgL1XVWXVMrp4ftgD5C2AA== +untrusted comment: node-accelerator v4.2 +RUQrJghT9nkdBHOssVi0uLbUicGZiyX+rMOK2npqcXw+z+WHPvMepFLqfY8RTxCwodfIV8cJwJjaSVKk63/boMthE/uV1mo7IQo= +trusted comment: timestamp:1791056397 file:optimize.sh hashed +mvktKcbk/XvQ8/nPb55keY4sX6mBN97vPMGOrw9wrb2ZznYs+PG7AeXILmngWgc9le5oJmZZJ/7FmPngF4z3Aw== diff --git a/scripts/protect.sh.minisig b/scripts/protect.sh.minisig index 2209fc2..a4d68d2 100644 --- a/scripts/protect.sh.minisig +++ b/scripts/protect.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.3 -RUQrJghT9nkdBLcVd2ABKIp+LSZq+1DLMsMZ9hWIVAQ1f/J1D4GQmjkoRg3d1kJ5TMBWescNU8wVJsbY07vX47+HNdivQA1xrAs= -trusted comment: timestamp:1791034798 file:protect.sh hashed -HULNG7vPwzxTWefIEsV1dlDzRXI+r7ZrNkyMcDOUX3AAfSdyGvOPR4E+I58SdcueCIRv6xh4lyDDAVqq3UHRDg== +untrusted comment: node-accelerator v4.2 +RUQrJghT9nkdBCp/yrHG602/OqlkSFO2bpd90HIZn47Y9ZVDnJugUwH2GewXEh6X4kHeWxPRHaI2hfqtuzFhfzassi50S+Uqywc= +trusted comment: timestamp:1791056397 file:protect.sh hashed ++unFItwgm0LW2ymDPqd1jMu2POtBhf4AQI6pUaL/kfq5HdlUIOxqaIY3tUg58vtrr6pB3nnb+tdiHXXfJwlyCg== diff --git a/scripts/rollback.sh.minisig b/scripts/rollback.sh.minisig index 8856155..080dbb0 100644 --- a/scripts/rollback.sh.minisig +++ b/scripts/rollback.sh.minisig @@ -1,4 +1,4 @@ -untrusted comment: node-accelerator v4.1.3 -RUQrJghT9nkdBKMchSSpLL4nYdXOr18pZsDjniYKvZl4X4zQuxiH5QpwZQXWKVBsppYgz0opEDd73O2k8jGu6ecgsXIdzoVylwA= -trusted comment: timestamp:1791034798 file:rollback.sh hashed -YSbSVRZkpRpj/bBjkGA0qH2hKdsKBM8KcB+z274xNZKnWDS4Ex2gNTHKAHBg1hE+MbLaM6Cz2wqxFWOteV8mCg== +untrusted comment: node-accelerator v4.2 +RUQrJghT9nkdBOljTmNAM4ZcHaM8IUwUFW0+gDYwL2BgkBKsbu84Q5dSu62mG3BkVcpq40pE4gsnP4CMYCqXsxaztZ+0gNnxowI= +trusted comment: timestamp:1791056397 file:rollback.sh hashed +4G8hOB1wc2jzyOtH/soD7RGM2uWTWgaD7DTFI5RqrWHPz1TpBJJzUcrllZraCbVcyqenzIWo2l0yIJue7rFYCg==