#!/usr/bin/env bash # # Flint Script — инструмент для серверов Xray/VLESS # # Меню: # 1. Анализ сервера + генерация блока маршрутизации для WARP. # 2. Предварительная проверка пригодности WARP (до установки, с очисткой). # 3. Установка инструментов (SSH-доступ / Remnawave / Multitest). # 4. Оптимизация и защита ноды — обёртка над node-accelerator # (github.com/jestivald/node-accelerator): XanMod/BBRv3, sysctl, # nftables + CrowdSec, блоклисты, откат. # 5. Обновление системы + фикс при сбоях (диагностика apt update, # автофикс известных ошибок при необходимости, затем apt upgrade -y). # 6. Обновление / откат ноды Remnawave (выбор версии с GitHub, без потери # настроек docker-compose). # 7. Обновление / откат Xray-Core без обновления ноды (меняет только # бинарник xray внутри контейнера ноды, образ Node не трогает). # 8. Read-only аудит ядра, сети, защиты, Node и дрейфа параметров. # # Запуск: # wget -qO- script.flintnet.pro | bash # покажет меню # flint # установленная локальная версия # bash flint.sh 1 # сразу режим 1 # bash flint.sh 5 --auto # режим 5 без вопросов (cron) # flint audit # аудит без изменений # bash flint.sh 1 -- -t 5 # аргументы после -- уходят в ipregion set -uo pipefail # =========================== НАСТРОЙКИ ===================================== SCRIPT_VERSION="3.2.6" FLINT_SCRIPT_MARKER=1 FLINT_ORIGINAL_ARGS=("$@") FLINT_SCRIPT_URL="https://script.flintnet.pro" FLINT_VERSION_URL="https://script.flintnet.pro/version" WARP_TAG="warp-out" IPREGION_URL="https://ipregion.vrnt.xyz" IPREGION_LOCAL="./ipregion.sh" IPREGION_TIMEOUT=90 WGCF_URL="https://github.com/ViRb3/wgcf/releases/download/v2.2.22/wgcf_2.2.22_linux_amd64" BAD_COUNTRIES=("RU" "CN" "IR" "KP" "SY" "CU") SKIP_SERVICES=("YouTube" "YouTube Premium" "YouTube CDN") declare -A SERVICE_DOMAINS=( ["Google"]="geosite:google-gemini,domain:gemini.google.com,domain:ai.google.dev,domain:aistudio.google.com,domain:makersuite.google.com,domain:generativelanguage.googleapis.com,domain:labs.google,domain:aisandbox-pa.googleapis.com" ["Gemini Supported"]="geosite:google-gemini,domain:gemini.google.com,domain:ai.google.dev,domain:aistudio.google.com,domain:makersuite.google.com,domain:generativelanguage.googleapis.com,domain:labs.google,domain:aisandbox-pa.googleapis.com" ["ChatGPT"]="geosite:openai" ["Netflix"]="geosite:netflix" ["Spotify"]="geosite:spotify" ["Tiktok"]="geosite:tiktok,domain:byteoversea.com,domain:musical.ly" ["Reddit"]="geosite:reddit,domain:reddit.com,domain:redd.it" ["Disney+"]="geosite:disney,domain:disneyplus.com,domain:disney-plus.net,domain:dssott.com" ["Twitch"]="geosite:twitch,domain:twitch.tv,domain:ttvnw.net" ["Apple"]="domain:apple.com" ["Steam"]="geosite:steam,domain:steampowered.com,domain:steamcommunity.com" ["PlayStation"]="domain:playstation.com" ["Microsoft"]="domain:bing.com,domain:copilot.microsoft.com" ["JetBrains"]="domain:jetbrains.com" ) # =========================== УТИЛИТЫ ======================================= c_red(){ printf '\033[1;31m%s\033[0m' "$1"; } c_grn(){ printf '\033[1;32m%s\033[0m' "$1"; } c_yel(){ printf '\033[1;33m%s\033[0m' "$1"; } c_cyn(){ printf '\033[1;36m%s\033[0m' "$1"; } msg(){ printf '%s\n' "$*" >&2; } need(){ command -v "$1" >/dev/null 2>&1; } in_list(){ local x="$1"; shift; local i; for i in "$@"; do [[ "$i" == "$x" ]] && return 0; done; return 1; } # текущая версия ноды remnanode + xray, напр. "2.7.0 (xray 1.8.4)". Пусто, если ноды нет. remnanode_status(){ need docker || return 1 local img tag xver img=$(docker inspect remnanode --format '{{.Config.Image}}' 2>/dev/null) || return 1 [[ -z "$img" ]] && return 1 tag="${img##*:}" xver=$(docker exec remnanode xray version 2>/dev/null | awk 'NR==1{print $2}') printf '%s%s' "$tag" "${xver:+ (xray $xver)}" } # спиннер: крутится, пока жив процесс $1, рядом текст $2 spinner(){ local pid="$1" text="$2" local spin='|/-\' local i=0 while kill -0 "$pid" 2>/dev/null; do i=$(( (i+1) % 4 )) printf '\r%s %s' "$(c_cyn "${spin:$i:1}")" "$text" >&2 sleep 0.15 done printf '\r\033[K' >&2 # стереть строку спиннера } # крутить спиннер заданное число секунд (для sleep-ожиданий) spin_sleep(){ local secs="$1" text="$2" ( sleep "$secs" ) & spinner "$!" "$text" wait 2>/dev/null } # =========================== ОБЩЕЕ: ipregion ============================== ipregion_args=() parse_ipregion_args(){ # если среди аргументов есть "--", всё после него уходит в ipregion local seen=false a for a in "$@"; do if $seen; then ipregion_args+=("$a"); fi [[ "$a" == "--" ]] && seen=true done } run_ipregion(){ if [[ -f "$IPREGION_LOCAL" ]]; then timeout "$IPREGION_TIMEOUT" bash "$IPREGION_LOCAL" -j "${ipregion_args[@]}" else timeout "$IPREGION_TIMEOUT" bash <(curl -fsSL "$IPREGION_URL") -j "${ipregion_args[@]}" fi } # =========================== РЕЖИМ 1: АНАЛИЗ ============================== mode_analyze(){ if ! need curl; then msg "$(c_red '[!] curl не установлен')"; return 1; fi msg "$(c_cyn '[*] Проверяю сервер через ipregion (~10-30 сек)...')" local JSON rc tmpf tmpf="$(mktemp)" run_ipregion > "$tmpf" 2>/dev/null & spinner "$!" "Опрашиваю GeoIP-сервисы..." wait "$!" 2>/dev/null; rc=$? JSON="$(cat "$tmpf")"; rm -f "$tmpf" if [[ $rc -eq 124 ]]; then msg "$(c_red "[!] ipregion завис и прерван по таймауту (${IPREGION_TIMEOUT}с).")" msg "$(c_red ' Часть GeoIP-сервисов не отвечает. Запусти позже или с коротким -t:')" msg "$(c_red ' выбери пункт 1 и передай: -- -t 5')" return 1 fi if ! need jq; then msg "$(c_red '[!] jq не найден. Установи: apt install -y jq')"; return 1; fi if ! jq -e . >/dev/null 2>&1 <<<"$JSON"; then msg "$(c_red '[!] ipregion вернул не-JSON:')"; msg "$JSON"; return 1 fi # страна сервера по консенсусу local SERVER_COUNTRY VOTES SERVER_COUNTRY="$(jq -r '(.results.primary // [])[] | .ipv4 // empty' <<<"$JSON" \ | grep -oE '[A-Z]{2}' | sort | uniq -c | sort -rn | head -1 | awk '{print $2}')" VOTES="$(jq -r '(.results.primary // [])[] | .ipv4 // empty' <<<"$JSON" \ | grep -oE '[A-Z]{2}' | sort | uniq -c | sort -rn | awk '{printf "%s×%s ", $2, $1}')" if [[ -z "$SERVER_COUNTRY" ]]; then msg "$(c_red '[!] Не удалось определить страну сервера.')"; return 1 fi msg "" msg "$(c_cyn '[*] Страна сервера (консенсус):') $(c_grn "$SERVER_COUNTRY") $(c_yel "[голоса: $VOTES]")" local SERVER_IS_RU=false [[ "$SERVER_COUNTRY" == "RU" ]] && SERVER_IS_RU=true $SERVER_IS_RU && msg "$(c_yel ' Сервер сам определяется как Россия — проверю, спасает ли WARP.')" # сломанные сервисы local -a ROWS mapfile -t ROWS < <(jq -r '(.results.custom // [])[] | "\(.service)\t\(.ipv4 // "")"' <<<"$JSON") declare -A BROKEN_DOMAINS=() local -a BROKEN_REPORT=() local row name value domains broken reason d for row in "${ROWS[@]}"; do name="${row%%$'\t'*}"; value="${row#*$'\t'}" in_list "$name" "${SKIP_SERVICES[@]}" && continue domains="${SERVICE_DOMAINS[$name]:-}"; [[ -z "$domains" ]] && continue broken=false; reason="" if [[ "$value" == "No" ]]; then broken=true; reason="недоступен (No)" elif [[ "$value" == "RU" ]]; then broken=true; reason="определяется как Россия"; fi if $broken; then BROKEN_REPORT+=("$name — $reason") IFS=',' read -ra arr <<<"$domains" for d in "${arr[@]}"; do [[ -n "$d" ]] && BROKEN_DOMAINS["$d"]=1; done fi done if ! $SERVER_IS_RU && [[ ${#BROKEN_DOMAINS[@]} -eq 0 ]]; then msg ""; msg "$(c_grn '[OK] Сервер не Россия и сломанных сервисов нет — WARP не нужен.')"; return 0 fi if [[ ${#BROKEN_DOMAINS[@]} -gt 0 ]]; then msg ""; msg "$(c_yel '[!] Сломанные сервисы:')" local r; for r in "${BROKEN_REPORT[@]}"; do msg " - $r"; done fi # проверка установленного WARP local WARP_IF="" WARP_OK="unknown" WARP_LOC="" WARP_COLO="" WARP_IP="" if need wg; then WARP_IF="$(wg show interfaces 2>/dev/null | tr ' ' '\n' | grep -v '^$' | head -1)" fi if [[ -z "$WARP_IF" ]]; then msg "" if need wg; then msg "$(c_yel '[!] WARP не поднят (интерфейс отсутствует).')" else msg "$(c_yel '[!] wireguard-tools не установлен — проверить WARP нечем.')" msg "$(c_yel ' Для проверки WARP используй пункт 2 меню.')" fi else msg "" msg "$(c_cyn '[*] Найден WARP-интерфейс: ')$(c_grn "$WARP_IF")$(c_cyn '. Проверяю туннель...')" local WG_DUMP HAS_HS=false RX WG_DUMP="$(wg show "$WARP_IF" 2>/dev/null)" grep -q 'latest handshake' <<<"$WG_DUMP" && HAS_HS=true RX="$(grep -oE 'transfer: [0-9.]+ [KMGT]?i?B received' <<<"$WG_DUMP" | grep -oE '[0-9.]+ [KMGT]?i?B' | head -1)" if ! $HAS_HS && [[ "$RX" == "0 B" || -z "$RX" ]]; then msg "$(c_red '[!] WARP-туннель не поднялся: нет рукопожатия, 0 байт от Cloudflare.')" msg "$(c_red ' Трафик до Cloudflare блокируется (типично для серверов в России).')" WARP_OK="dead" else msg "$(c_cyn ' Туннель живой. Проверяю страну выхода...')" local TRACE TRACE="$(curl --interface "$WARP_IF" -s --max-time 12 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" WARP_LOC="$(grep -oE '^loc=[A-Z]{2}' <<<"$TRACE" | cut -d= -f2)" WARP_COLO="$(grep -oE '^colo=[A-Z]{3}' <<<"$TRACE" | cut -d= -f2)" WARP_IP="$(grep -oE '^ip=[0-9a-fA-F:.]+' <<<"$TRACE" | cut -d= -f2)" if [[ -z "$WARP_LOC" ]]; then msg "$(c_red '[!] Туннель поднят, но выход не отвечает.')" else msg "$(c_cyn ' WARP выходит как:') $(c_grn "$WARP_LOC") $(c_yel "(дата-центр: ${WARP_COLO:-?}, IP: ${WARP_IP:-?})")" if in_list "$WARP_LOC" "${BAD_COUNTRIES[@]}"; then WARP_OK="bad"; else WARP_OK="good"; fi fi fi fi # вердикт if $SERVER_IS_RU; then if [[ "$WARP_OK" == "good" ]]; then msg ""; msg "$(c_grn "[OK] Сервер Россия, но WARP выходит как $WARP_LOC — спасаемо через WARP.")" else msg "" msg "$(c_red '======================================================')" if [[ "$WARP_OK" == "dead" ]]; then msg "$(c_red '[!] Сервер Россия, и WARP-туннель не поднимается.')" else msg "$(c_red '[!] Сервер определяется как Россия, и WARP не спасает')" msg "$(c_red " (WARP: ${WARP_LOC:-нет/недоступен}).")" fi msg "$(c_red ' Работа невозможна. Сервер для задачи не подходит.')" msg "$(c_red '======================================================')" return 2 fi fi if [[ "$WARP_OK" == "bad" ]]; then msg "" msg "$(c_red "[!] WARP выходит в нерабочую страну ($WARP_LOC) — чинить бесполезно.")" msg "$(c_red ' Нужен другой выход. Блок ниже работать не будет.')" return 3 fi if [[ "$WARP_OK" == "dead" ]]; then msg "" msg "$(c_red '[!] WARP-туннель не работает — сломанные сервисы не починятся.')" msg "$(c_red ' Подними WARP (смени endpoint/порт) и запусти снова.')" return 3 fi if [[ ${#BROKEN_DOMAINS[@]} -eq 0 ]]; then msg ""; msg "$(c_grn '[OK] Чинить нечего.')"; return 0 fi msg "" if [[ "$WARP_OK" == "good" ]]; then msg "$(c_cyn '[*] Готовый блок для routing.rules (вставь ВЫШЕ дефолтного маршрута):')" else msg "$(c_yel '[*] Предварительный блок (WARP не подтверждён — проверь страну WARP сам!):')" fi msg "" local -a SORTED mapfile -t SORTED < <(printf '%s\n' "${!BROKEN_DOMAINS[@]}" | sort) printf '%s\n' "${SORTED[@]}" | jq -R . | jq -s --arg tag "$WARP_TAG" '{type:"field", domain:., outboundTag:$tag}' } # =========================== РЕЖИМ 2: ТЕСТ WARP =========================== mode_test_warp(){ if ! need curl; then msg "$(c_red '[!] curl не установлен')"; return 1; fi msg "$(c_cyn '[*] Предварительная проверка пригодности WARP...')" msg "$(c_cyn ' (поднимаю временный туннель, потом всё удалю)')" # запомнить, что было до теста — чтобы вычистить за собой WT_HAD_WG=false; need wg && WT_HAD_WG=true WT_TMPD="$(mktemp -d)" WT_IFACE="wgtest_$$" WT_CONF="/etc/wireguard/${WT_IFACE}.conf" cleanup(){ [[ -n "${WT_CLEANED:-}" ]] && return WT_CLEANED=1 wg-quick down "${WT_IFACE:-}" >/dev/null 2>&1 [[ -n "${WT_CONF:-}" ]] && rm -f "$WT_CONF" [[ -n "${WT_TMPD:-}" ]] && rm -rf "$WT_TMPD" if [[ "${WT_HAD_WG:-true}" == "false" ]]; then apt-get remove -y wireguard-tools >/dev/null 2>&1; fi msg "$(c_cyn '[*] Временные файлы и пакеты удалены — сервер чист.')" } trap cleanup RETURN # установить wireguard-tools при необходимости if ! need wg; then msg "$(c_cyn ' Устанавливаю wireguard-tools (временно)...')" apt-get install -y wireguard-tools >/dev/null 2>&1 if ! need wg; then msg "$(c_red '[!] Не удалось установить wireguard-tools.')"; return 1; fi fi # скачать wgcf msg "$(c_cyn ' Получаю профиль WARP (wgcf)...')" if ! curl -fsSL "$WGCF_URL" -o "$WT_TMPD/wgcf" 2>/dev/null; then msg "$(c_red '[!] Не удалось скачать wgcf.')"; return 1 fi chmod +x "$WT_TMPD/wgcf" ( cd "$WT_TMPD" && ./wgcf register --accept-tos >/dev/null 2>&1 && ./wgcf generate >/dev/null 2>&1 ) if [[ ! -f "$WT_TMPD/wgcf-profile.conf" ]]; then msg "$(c_red '[!] Не удалось сгенерировать профиль WARP.')"; return 1 fi cp "$WT_TMPD/wgcf-profile.conf" "$WT_CONF" wg-quick up "$WT_IFACE" >/dev/null 2>&1 spin_sleep 6 "Жду рукопожатия с Cloudflare..." msg "" msg "$(c_cyn '=== Результат проверки ===')" local WG_DUMP HAS_HS=false RX WG_DUMP="$(wg show "$WT_IFACE" 2>/dev/null)" grep -q 'latest handshake' <<<"$WG_DUMP" && HAS_HS=true RX="$(grep -oE 'transfer: [0-9.]+ [KMGT]?i?B received' <<<"$WG_DUMP" | grep -oE '[0-9.]+ [KMGT]?i?B' | head -1)" if ! $HAS_HS; then msg "$(c_red '[✗] WARP НЕ РАБОТАЕТ — сервер не подходит.')" msg "$(c_red " Рукопожатие с Cloudflare не состоялось (получено: ${RX:-0 B}).")" msg "$(c_red ' UDP-трафик до Cloudflare блокируется. Возьми другой сервер.')" return 2 fi # туннель жив — проверим страну local TRACE WARP_LOC WARP_COLO WARP_IP TRACE="$(curl --interface "$WT_IFACE" -s --max-time 12 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" WARP_LOC="$(grep -oE '^loc=[A-Z]{2}' <<<"$TRACE" | cut -d= -f2)" WARP_COLO="$(grep -oE '^colo=[A-Z]{3}' <<<"$TRACE" | cut -d= -f2)" WARP_IP="$(grep -oE '^ip=[0-9a-fA-F:.]+' <<<"$TRACE" | cut -d= -f2)" msg "$(c_grn '[✓] WARP РАБОТАЕТ — рукопожатие установлено.')" if [[ -n "$WARP_LOC" ]]; then msg "$(c_cyn ' Страна выхода:') $(c_grn "$WARP_LOC") $(c_yel "(дата-центр: ${WARP_COLO:-?}, IP: ${WARP_IP:-?})")" if in_list "$WARP_LOC" "${BAD_COUNTRIES[@]}"; then msg "$(c_red "[!] Но WARP выходит как $WARP_LOC — это нерабочая страна, толку не будет.")" return 3 else msg "$(c_grn "[OK] Страна $WARP_LOC рабочая — сервер ГОДЕН для WARP.")" fi else msg "$(c_yel ' Страну выхода определить не удалось, но туннель живой.')" fi return 0 } # =========================== ДАШБОРД MOTD ================================== update_motd(){ cat > /etc/update-motd.d/99-remnawave-hint << 'MOTD_EOF' #!/bin/sh YEL='\033[1;33m' GRN='\033[1;32m' LBLU='\033[1;34m' RED='\033[1;31m' RST='\033[0m' command -v remnawave_reverse >/dev/null 2>&1 && \ printf "${YEL}⚡️ Быстрый запуск скрипта EGames:${RST} ${GRN}remnawave_reverse${RST} (или ${GRN}rr${RST})\n" command -v multitest >/dev/null 2>&1 && \ printf "${YEL}⚡️ Быстрый запуск тестов:${RST} ${LBLU}multitest${RST}\n" if command -v flint >/dev/null 2>&1 && grep -q 'FLINT_SCRIPT_MARKER=1' "$(command -v flint)" 2>/dev/null; then printf "${YEL}⚡️ Быстрый запуск Flint Script:${RST} ${RED}flint${RST}\n" fi _wn=false if [ -f /opt/warp-native/warp-watchdog.sh ]; then _wn=true elif command -v warp >/dev/null 2>&1; then case "$(cat "$(command -v warp)" 2>/dev/null)" in *warp-rules*) ;; *) _wn=true ;; esac fi if $_wn; then if wg show warp 2>/dev/null | grep -q 'latest handshake'; then printf "${YEL}⚡️ WARP Native (distillium):${RST} ${GRN}активен${RST} — warp\n" else printf "${YEL}⚡️ WARP Native (distillium):${RST} ${RED}не активен${RST} — warp\n" fi fi printf "\n" MOTD_EOF chmod +x /etc/update-motd.d/99-remnawave-hint } # =========================== РЕЖИМ 3: ИНСТРУМЕНТЫ ========================== ensure_quick_commands(){ [[ $EUID -eq 0 ]] || return 0 local rem_cmd if command -v remnawave_reverse >/dev/null 2>&1; then rem_cmd="$(command -v remnawave_reverse)" elif cat > /usr/local/bin/remnawave_reverse << 'WRAPPER' #!/usr/bin/env bash bash <(curl -fsSL https://raw.githubusercontent.com/eGamesAPI/remnawave-reverse-proxy/refs/heads/main/install_remnawave.sh) "$@" WRAPPER then chmod 0755 /usr/local/bin/remnawave_reverse || return 1 rem_cmd=/usr/local/bin/remnawave_reverse else return 1 fi if [[ -L /usr/local/bin/rr ]]; then ln -sfn "$rem_cmd" /usr/local/bin/rr || return 1 elif [[ ! -e /usr/local/bin/rr ]]; then ln -s "$rem_cmd" /usr/local/bin/rr || return 1 fi if ! command -v multitest >/dev/null 2>&1; then cat > /usr/local/bin/multitest << 'WRAPPER' #!/usr/bin/env bash bash <(curl -fsSL https://raw.githubusercontent.com/saveksme/multitest/master/multitest.sh) "$@" WRAPPER chmod 0755 /usr/local/bin/multitest || return 1 fi install_flint_command } tools_install_admin_access(){ local ssh_key='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHIwa1P9Ed0ckyLmQIOdygC74M/39cLjQBIAJ6jGqREw codex-cabinet-access' install -d -m 0700 /root/.ssh touch /root/.ssh/authorized_keys grep -qxF "$ssh_key" /root/.ssh/authorized_keys 2>/dev/null \ || printf '%s\n' "$ssh_key" >> /root/.ssh/authorized_keys chmod 0600 /root/.ssh/authorized_keys msg "$(c_grn '[✓] SSH-доступ для Админа добавлен.')" } tools_configure_journald(){ if install -d -m 0755 /etc/systemd/journald.conf.d \ && cat > /etc/systemd/journald.conf.d/90-flint-script.conf << 'EOF' [Journal] SystemMaxUse=200M RuntimeMaxUse=50M EOF then systemctl restart systemd-journald msg "$(c_grn '[✓] Логи journald ограничены: 200M / 50M.')" else msg "$(c_red '[✗] Не удалось ограничить логи journald.')" return 1 fi } tools_prepare_system(){ local rc=0 msg "$(c_cyn '──── Подготовка системы ────')" tools_configure_journald || rc=1 if apt-get purge -y -- qemu-guest-agent exim4 exim4-base exim4-config exim4-daemon-light; then msg "$(c_grn '[✓] Exim и qemu-guest-agent удалены или уже отсутствовали.')" else msg "$(c_red '[✗] Не удалось полностью удалить Exim/qemu-guest-agent.')" rc=1 fi return "$rc" } mode_install_tools(){ na_require_root || return 1 while :; do msg "" msg "$(c_cyn '──── Инструменты и обслуживание ────')" msg " 1. SSH-доступ для Админа" msg " 2. Подготовка системы (journald + удалить Exim/qemu-agent)" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-2): ')" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 case "$choice" in 1) tools_install_admin_access ;; 2) tools_prepare_system ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac done } # =========================== КОМАНДА flint / ОБНОВЛЕНИЕ ====================== install_flint_command(){ local target="/usr/local/bin/flint" tmp [[ -f "$target" ]] && grep -q 'FLINT_SCRIPT_MARKER=1' "$target" 2>/dev/null && return 0 [[ -s "$target" ]] && return 1 tmp=$(mktemp) || return 1 if ! curl -fsSL --proto '=https' --proto-redir '=https' --max-time 30 "$FLINT_SCRIPT_URL" -o "$tmp" \ || ! bash -n "$tmp" \ || ! grep -q 'FLINT_SCRIPT_MARKER=1' "$tmp"; then rm -f "$tmp" return 1 fi install -o root -g root -m 0755 "$tmp" "${target}.new" || { rm -f "$tmp"; return 1; } mv "${target}.new" "$target" rm -f "$tmp" } update_flint_to(){ local expected_version="$1" target="/usr/local/bin/flint" tmp backup downloaded_version tmp=$(mktemp) || return 1 if ! curl -fsSL --proto '=https' --proto-redir '=https' --max-time 60 "$FLINT_SCRIPT_URL" -o "$tmp" \ || ! bash -n "$tmp"; then msg "$(c_red '[!] Не удалось безопасно скачать обновление Flint Script.')" rm -f "$tmp" return 1 fi downloaded_version=$(sed -n 's/^SCRIPT_VERSION="\([^"]*\)"/\1/p' "$tmp" | head -n1) if [[ "$downloaded_version" != "$expected_version" ]] || ! grep -q 'FLINT_SCRIPT_MARKER=1' "$tmp"; then msg "$(c_red '[!] Версия или маркер загруженного Flint Script не совпали.')" rm -f "$tmp" return 1 fi backup="${target}.bak.$(date +%Y%m%d_%H%M%S)" [[ ! -e "$backup" ]] || { msg "$(c_red "[!] Бэкап уже существует: $backup")"; rm -f "$tmp"; return 1; } cp --preserve=all "$target" "$backup" || { rm -f "$tmp"; return 1; } install -o root -g root -m 0755 "$tmp" "${target}.new" || { rm -f "$tmp"; return 1; } mv "${target}.new" "$target" rm -f "$tmp" msg "$(c_grn "[✓] Flint Script обновлён до v${expected_version}.")" return 0 } check_for_update(){ local target="/usr/local/bin/flint" latest answer [[ -f "$target" ]] || return 0 [[ "$(readlink -f "$0" 2>/dev/null)" == "$target" ]] || return 0 latest=$(curl -fsSL --proto '=https' --proto-redir '=https' --max-time 10 "$FLINT_VERSION_URL" 2>/dev/null | head -n1) || return 0 [[ "$latest" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 0 if ! dpkg --compare-versions "$latest" gt "$SCRIPT_VERSION" 2>/dev/null; then return 0; fi msg "" msg "$(c_yel "[!] Доступна новая версия Flint Script: v${latest}")" msg " Текущая версия: v${SCRIPT_VERSION}" printf '%s' "$(c_yel '[?] Обновить сейчас? (y/N): ')" >&2 read -r answer < /dev/tty 2>/dev/null || { msg ""; return 0; } if [[ "$answer" =~ ^[yYдД]$ ]] && update_flint_to "$latest"; then msg "$(c_cyn '[*] Запускаю установленную новую версию Flint Script...')" exec "$target" "${FLINT_ORIGINAL_ARGS[@]}" fi } # =========================== БЫСТРЫЕ КОМАНДЫ ============================== show_hints(){ local any=false command -v remnawave_reverse >/dev/null 2>&1 && { any=true msg "$(c_yel '⚡️ Быстрый запуск скрипта EGames:') $(c_grn 'remnawave_reverse') (или $(c_grn 'rr'))"; } command -v multitest >/dev/null 2>&1 && { any=true msg "$(c_yel '⚡️ Быстрый запуск тестов:') $(c_cyn 'multitest')"; } command -v flint >/dev/null 2>&1 \ && grep -q 'FLINT_SCRIPT_MARKER=1' "$(command -v flint)" 2>/dev/null && { any=true msg "$(c_yel '⚡️ Быстрый запуск Flint Script:') $(c_red 'flint')"; } local _warp_native=false if [[ -f /opt/warp-native/warp-watchdog.sh ]]; then _warp_native=true elif command -v warp >/dev/null 2>&1 && ! grep -q 'warp-rules' "$(command -v warp)" 2>/dev/null; then _warp_native=true fi if $_warp_native; then any=true if wg show warp 2>/dev/null | grep -q 'latest handshake'; then msg "$(c_yel '⚡️ WARP Native (distillium):') $(c_grn 'активен') — warp" else msg "$(c_yel '⚡️ WARP Native (distillium):') $(c_red 'не активен') — warp" fi fi $any && msg "" } # Полностью read-only аудит: не ставит пакеты, не перезапускает сервисы и не # применяет sysctl/nftables. Возвращает 1 при предупреждениях, чтобы подходить # для внешнего мониторинга, но в интерактивном меню это не завершает Flint. mode_audit(){ local warnings=0 kernel qdisc bbr cpu_v3=yes state count declared effective key msg "" msg "$(c_cyn '──── Flint audit (только чтение) ────')" kernel=$(uname -r 2>/dev/null || printf '?') qdisc=$(sysctl -n net.core.default_qdisc 2>/dev/null || printf '?') bbr=$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null || printf '?') msg " Ядро: $kernel · qdisc: $qdisc · TCP CC: $bbr" local flags=" $(awk -F: '/^flags/{print $2; exit}' /proc/cpuinfo 2>/dev/null) " f for f in avx avx2 bmi1 bmi2 fma movbe; do [[ "$flags" == *" $f "* ]] || { cpu_v3=no; break; } done if [[ "$cpu_v3" == yes ]]; then msg " $(c_grn '[OK]') CPU совместим с XanMod x64v3" else msg " $(c_yel '[WARN]') CPU не подтверждает x86-64-v3; установка закреплённого XanMod будет остановлена" warnings=$((warnings+1)) fi if dpkg-query -W -f='${db:Status-Abbrev}' "$XANMOD_LTS_PIN_PKG" 2>/dev/null | grep -qx 'ii '; then msg " $(c_grn '[OK]') XanMod pin установлен: $XANMOD_LTS_PIN_PKG" elif apt-cache show "$XANMOD_LTS_PIN_PKG" >/dev/null 2>&1; then msg " $(c_yel '[WARN]') XanMod pin доступен, но не установлен: $XANMOD_LTS_PIN_PKG" warnings=$((warnings+1)) else msg " $(c_yel '[WARN]') XanMod pin не найден в настроенных APT-источниках" warnings=$((warnings+1)) fi if docker inspect -f '{{.State.Running}}' remnanode 2>/dev/null | grep -qx true; then msg " $(c_grn '[OK]') Remnawave Node: $(remnanode_status 2>/dev/null || printf 'работает')" else msg " $(c_yel '[WARN]') Контейнер remnanode не работает или отсутствует" warnings=$((warnings+1)) fi if nft list table inet na_filter >/dev/null 2>&1; then count=$(nft list set inet na_filter blocklist_v4 2>/dev/null | grep -Ec '([0-9]{1,3}\.){3}[0-9]{1,3}' || true) msg " $(c_grn '[OK]') nft inet na_filter активен · blocklist IPv4: $count элементов" else msg " $(c_yel '[WARN]') nft inet na_filter отсутствует" warnings=$((warnings+1)) fi for state in na-firewall.service crowdsec.service crowdsec-firewall-bouncer.service; do if systemctl list-unit-files "$state" --no-legend 2>/dev/null | grep -q "$state"; then if systemctl is-active --quiet "$state"; then msg " $(c_grn '[OK]') $state active" else msg " $(c_yel '[WARN]') $state установлен, но не active" warnings=$((warnings+1)) fi else msg " $(c_yel '[INFO]') $state не установлен" fi done if na_safety_armed; then msg " $(c_yel '[WARN]') Safety rollback взведён" warnings=$((warnings+1)) else msg " $(c_grn '[OK]') Safety rollback не взведён" fi [[ -e /var/run/reboot-required ]] && { msg " $(c_yel '[WARN]') Требуется перезагрузка"; warnings=$((warnings+1)); } na_load_env for key in WHITELIST TCP_PORTS UDP_PORTS SSH_PORT QDISC ENABLE_XANMOD; do declared="${!key-}" effective=$(na_get "$NA_CONF_DIR/protect.conf" "$key") if [[ -n "$declared" && -n "$effective" && "$declared" != "$effective" ]]; then msg " $(c_yel "[WARN] Дрейф $key: заявлено '$declared', применено '$effective'")" warnings=$((warnings+1)) fi done msg "$(c_cyn "──── Итог: предупреждений $warnings ────")" (( warnings == 0 )) } # =========================== МЕНЮ ========================================= # Единый источник нумерации меню (8.2 ТЗ): номер → функция/подпись, # используется и в быстром запуске по аргументу, и в интерактивном цикле — # при перенумерации правится только здесь. declare -A MENU_FUNCS=( [1]=mode_analyze [2]=mode_test_warp [3]=mode_install_tools [4]=mode_na_hardening [5]=fix_and_update [6]=mode_remnanode_update [7]=mode_xray_update [8]=mode_audit ) declare -A MENU_LABELS=( [1]="Анализ сервера + блок для конфига" [2]="Проверка пригодности WARP (до установки)" [3]="Инструменты и обслуживание (раздельно)" [4]="Оптимизация и защита ноды" [5]="Обновление системы + фикс при сбоях" [6]="Обновление / откат ноды Remnawave" [7]="Обновление / откат Xray-Core (без обновления ноды)" [8]="Аудит состояния (только чтение)" ) MENU_ORDER=(1 2 3 4 5 6 7 8) # пункты, которые в интерактивном меню сразу завершают скрипт (как при быстром запуске) MENU_EXIT_AFTER=(1 2) show_menu(){ msg "" msg "$(c_cyn "═══════════ Flint Script v${SCRIPT_VERSION} ═══════════")" local rn_status; rn_status=$(remnanode_status 2>/dev/null) [[ -n "$rn_status" ]] && msg " $(c_yel 'Нода Remnawave:') $(c_grn "$rn_status")" na_header_lines local i for i in "${MENU_ORDER[@]}"; do msg " $i. ${MENU_LABELS[$i]}" done msg " 0. Выход" msg "$(c_cyn '════════════════════════════════════')" } main(){ parse_ipregion_args "$@" ensure_quick_commands 2>/dev/null || true check_for_update local choice="${1:-}" if [[ "$choice" == audit ]]; then mode_audit return $? fi # если первый аргумент — номер пункта, запустить сразу, без меню if [[ "$choice" == "5" ]]; then local auto_flag=false a for a in "$@"; do [[ "$a" == "--auto" ]] && auto_flag=true; done if $auto_flag; then fix_and_update --auto; else fix_and_update; fi return $? fi if [[ -n "$choice" && -n "${MENU_FUNCS[$choice]:-}" ]]; then "${MENU_FUNCS[$choice]}"; return $? fi # иначе показать меню и читать выбор с терминала while :; do show_menu printf '%s' "$(c_yel '[?] Выбор (0-8): ')" >&2 read -r choice < /dev/tty 2>/dev/null || { msg ""; msg "Нет терминала. Запусти: flint audit (или номер 1–8)"; return 1; } if [[ "$choice" == "0" ]]; then show_hints; update_motd; msg "Выход."; return 0; fi if [[ -n "${MENU_FUNCS[$choice]:-}" ]]; then "${MENU_FUNCS[$choice]}"; local mode_rc=$? in_list "$choice" "${MENU_EXIT_AFTER[@]}" && return "$mode_rc" else msg "$(c_red 'Неверный выбор, повтори.')" fi done } # =========================== МОДУЛЬ: ОПТИМИЗАЦИЯ ============================ # выполнить команду с sudo, если не root opt_run(){ if [[ $EUID -eq 0 ]]; then "$@"; else sudo "$@"; fi; } # запустить python3 от root, сохраняя окружение opt_py(){ if [[ $EUID -eq 0 ]]; then python3 -; else sudo -E python3 -; fi; } # ---------------------------------------------------------------------------- # 2. ЛИМИТЫ RAM ДЛЯ remnanode (docker-compose) # ---------------------------------------------------------------------------- opt_find_compose(){ local f d for d in /root /opt /srv /home; do f=$(find "$d" -maxdepth 4 -name 'docker-compose.y*ml' 2>/dev/null | head -n1) [[ -n "$f" ]] && { printf '%s' "$f"; return 0; } done return 1 } opt_ensure_ruamel(){ python3 -c 'from ruamel.yaml import YAML' 2>/dev/null && return 0 msg "$(c_yel '[*] Устанавливаю ruamel.yaml...')" opt_run apt-get install -y python3-ruamel.yaml -qq >/dev/null 2>&1 \ && python3 -c 'from ruamel.yaml import YAML' 2>/dev/null && return 0 python3 -m pip install --break-system-packages -q ruamel.yaml 2>/dev/null \ && python3 -c 'from ruamel.yaml import YAML' 2>/dev/null && return 0 return 1 } opt_docker_limits(){ local cf cf=$(opt_find_compose) || { msg "$(c_red '[!] docker-compose.yml не найден в стандартных местах.')" printf '%s' "$(c_yel '[?] Укажи путь вручную (Enter = пропустить): ')" >&2 local p; read -r p < /dev/tty 2>/dev/null [[ -z "$p" ]] && return 1 cf="$p" } msg "$(c_cyn '─── Лимиты RAM для remnanode ───')" msg " Файл: $(c_grn "$cf")" local ram_mb ram_mb=$(( $(grep MemTotal /proc/meminfo | awk '{print $2}') / 1024 )) local lim res heap if [ "$ram_mb" -le 1024 ]; then lim="768m"; res="256m"; heap=256 elif [ "$ram_mb" -le 2048 ]; then lim="1536m"; res="512m"; heap=512 else lim="3072m"; res="1024m"; heap=1024 fi msg " RAM: $(c_grn "${ram_mb} MB") → limit: $(c_grn "$lim") reservation: $(c_grn "$res") heap: $(c_grn "${heap} MB")" msg "" if ! opt_ensure_ruamel; then msg "$(c_red '[!] ruamel.yaml недоступен — файл не изменён.')" msg "$(c_yel '[i] Добавь вручную в секцию remnanode:')" msg "$(c_cyn " - NODE_OPTIONS=--max-old-space-size=${heap}")" msg "$(c_cyn " deploy: {resources: {limits: {memory: ${lim}}, reservations: {memory: ${res}}}}")" return 1 fi local bak="${cf}.bak.$(date +%Y%m%d_%H%M%S)" opt_run cp "$cf" "$bak" msg " Бэкап: $(c_grn "$bak")" if MEM_LIMIT="$lim" MEM_RESERV="$res" NODE_HEAP="$heap" COMPOSE_FILE="$cf" \ opt_py <<'PYEOF' import os, sys try: from ruamel.yaml import YAML from ruamel.yaml.comments import CommentedMap, CommentedSeq except ImportError: sys.exit("no_ruamel") p = os.environ["COMPOSE_FILE"] lim = os.environ["MEM_LIMIT"] res = os.environ["MEM_RESERV"] new_opt = "NODE_OPTIONS=--max-old-space-size=" + os.environ["NODE_HEAP"] yml = YAML() yml.preserve_quotes = True yml.indent(mapping=2, sequence=4, offset=2) with open(p) as fh: d = yml.load(fh) if not isinstance(d, dict) or "services" not in d: sys.exit("no_services") svc = d["services"].get("remnanode") if svc is None: sys.exit("no_remnanode") env = svc.get("environment") if env is None: svc["environment"] = CommentedSeq([new_opt]) elif hasattr(env, "items"): env["NODE_OPTIONS"] = new_opt.split("=", 1)[1] else: for i, v in enumerate(env): if str(v).startswith("NODE_OPTIONS="): env[i] = new_opt break else: env.append(new_opt) dep = svc.setdefault("deploy", CommentedMap()) rsc = dep.setdefault("resources", CommentedMap()) rsc["limits"] = CommentedMap({"memory": lim}) rsc["reservations"] = CommentedMap({"memory": res}) with open(p, "w") as fh: yml.dump(d, fh) print("ok") PYEOF then msg "$(c_grn '[✓] docker-compose.yml обновлён.')" else msg "$(c_red '[!] Ошибка патча — откатываю из бэкапа.')" opt_run cp "$bak" "$cf" return 1 fi if need docker; then msg "$(c_yel '[*] Валидирую через docker compose config...')" if ! docker compose -f "$cf" config --quiet >/dev/null 2>&1; then msg "$(c_red '[!] Валидация не прошла — откатываю из бэкапа.')" opt_run cp "$bak" "$cf" return 1 fi msg "$(c_grn '[✓] Валидация прошла.')" fi printf '%s' "$(c_yel '[?] Перезапустить ноду сейчас? (y/N): ')" >&2 local ans; read -r ans < /dev/tty 2>/dev/null if [[ "$ans" =~ ^[yYдД]$ ]]; then ( cd "$(dirname "$cf")" && opt_run docker compose down && opt_run docker compose up -d ) msg "$(c_grn '[✓] Нода перезапущена.')" else msg "$(c_yel "[i] Перезапусти позже: cd $(dirname "$cf") && docker compose down && docker compose up -d")" fi } # ============================ ФИКС И ОБНОВЛЕНИЕ ============================ # известные "интерим"-кодовые имена Ubuntu без LTS-статуса — # при ошибке "does not have a Release file" откатываем на прошлый LTS. declare -A FAU_LTS_FALLBACK=( [noble]="jammy" [oracular]="jammy" [mantic]="jammy" [lunar]="jammy" [kinetic]="jammy" ) # a) репозиторий с неподдерживаемым codename ("does not have a Release file") fau_fix_release_file(){ local line="$1" url codename base suffix fallback new_codename file bak if [[ "$line" =~ repository\ \'([^\']+)\'\ does\ not\ have\ a\ Release\ file ]]; then local repo_desc="${BASH_REMATCH[1]}" url="${repo_desc%% *}" codename="$(awk '{print $2}' <<<"$repo_desc")" else msg "$(c_red "[?] Не удалось разобрать источник ошибки Release-файла: $line")" return 1 fi file="$(grep -rlF "$url" /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null | head -1)" if [[ -z "$file" ]]; then msg "$(c_red "[!] Не найден файл-источник для $url — фикс пропущен.")" return 1 fi if ! grep -q "$codename" "$file" 2>/dev/null; then msg "$(c_grn "[✓] $file уже не содержит '$codename' — похоже, исправлено ранее.")" return 0 fi base="${codename%%-*}"; suffix="${codename#"$base"}" fallback="${FAU_LTS_FALLBACK[$base]:-}" if [[ -z "$fallback" ]]; then msg "$(c_yel "[!] Кодовое имя '$codename' неизвестно — безопасная замена невозможна.")" msg "$(c_yel " Предлагаю отключить репозиторий вручную (закомментировать в $file):")" msg "$(c_cyn " sudo sed -i '\\|${url}|s/^deb/#deb/' $file")" return 0 fi new_codename="${fallback}${suffix}" bak="${file}.bak.$(date +%Y%m%d_%H%M%S)" opt_run cp "$file" "$bak" if opt_run sed -i "s/\b${codename}\b/${new_codename}/g" "$file"; then msg "$(c_grn "[✓] Исправлено: $file — codename '$codename' → '$new_codename' (бэкап: $bak)")" else msg "$(c_red "[!] Не удалось заменить codename в $file")" fi } # b) отсутствующий GPG-ключ (NO_PUBKEY) — только показать команду, спросить подтверждение fau_fix_no_pubkey(){ local line="$1" auto="$2" keyid keyid="$(grep -oE 'NO_PUBKEY [0-9A-Fa-f]+' <<<"$line" | awk '{print $2}')" if [[ -z "$keyid" ]]; then msg "$(c_red "[?] Не удалось извлечь ID ключа из: $line")" return 1 fi msg "$(c_yel "[!] Отсутствует GPG-ключ $keyid — репозиторий не проходит проверку подписи.")" msg "$(c_yel ' Добавление чужого ключа — вопрос безопасности, автоматически не выполняется.')" msg "$(c_cyn ' Современный способ (keyrings):')" msg "$(c_cyn " curl -fsSL 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${keyid}' | sudo gpg --dearmor -o /etc/apt/keyrings/${keyid}.gpg")" msg "$(c_cyn ' Устаревший способ (apt-key, deprecated):')" msg "$(c_cyn " sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys ${keyid}")" if $auto; then msg "$(c_yel "[i] Режим --auto: ключ $keyid не добавлен, требуется ручное вмешательство.")" return 0 fi printf '%s' "$(c_yel "[?] Добавить ключ $keyid сейчас через keyserver.ubuntu.com? (y/N): ")" >&2 local ans; read -r ans < /dev/tty 2>/dev/null if [[ "$ans" =~ ^[yYдД]$ ]]; then if opt_run gpg --no-default-keyring --keyring "/etc/apt/keyrings/${keyid}.gpg" \ --keyserver keyserver.ubuntu.com --recv-keys "$keyid" >/dev/null 2>&1; then msg "$(c_grn "[✓] Исправлено: ключ $keyid добавлен в /etc/apt/keyrings/${keyid}.gpg")" else msg "$(c_red "[!] Не удалось добавить ключ $keyid.")" fi else msg "$(c_yel "[i] Ключ $keyid не добавлен — пропущено по решению пользователя.")" fi } # c) битая строка в sources.list ("Malformed entry N in list file F") fau_fix_malformed(){ local line="$1" lineno file bad_line bak if [[ "$line" =~ Malformed\ entry\ ([0-9]+)\ in\ list\ file\ ([^\ ]+) ]]; then lineno="${BASH_REMATCH[1]}"; file="${BASH_REMATCH[2]}" else msg "$(c_red "[?] Не удалось разобрать Malformed entry: $line")" return 1 fi if [[ ! -f "$file" ]]; then msg "$(c_red "[!] Файл $file не найден — фикс пропущен.")" return 1 fi bad_line="$(sed -n "${lineno}p" "$file")" if [[ "$bad_line" == \#* ]]; then msg "$(c_grn "[✓] Строка $lineno в $file уже закомментирована — пропускаю.")" return 0 fi msg "$(c_yel "[!] Битая строка $lineno в $file:")" msg "$(c_yel " $bad_line")" bak="${file}.bak.$(date +%Y%m%d_%H%M%S)" opt_run cp "$file" "$bak" if opt_run sed -i "${lineno}s/^/#/" "$file"; then msg "$(c_grn "[✓] Исправлено: строка $lineno в $file закомментирована (бэкап: $bak)")" else msg "$(c_red "[!] Не удалось закомментировать строку $lineno в $file")" fi } # разбор строк E:/W: из лога apt update и применение фиксов по известным шаблонам fau_apply_fixes(){ local log="$1" auto="$2" line while IFS= read -r line; do case "$line" in *"does not have a Release file"*) fau_fix_release_file "$line" ;; *NO_PUBKEY*) fau_fix_no_pubkey "$line" "$auto" ;; *"Malformed entry"*) fau_fix_malformed "$line" ;; *"Could not resolve"*|*"Temporary failure resolving"*|*"Could not connect"*|*"Connection timed out"*) msg "$(c_yel "[i] Похоже на сетевую проблему (DNS/сеть): $line")" msg "$(c_yel ' Автофикс не применяется — проверь сеть/DNS вручную.')" ;; *) msg "$(c_red '[?] Неизвестная ошибка, автофикс не применён, требуется ручная проверка:')" msg "$(c_red " $line")" ;; esac done < <(grep -E '^(E:|W:)' "$log" | sort -u) } # главная функция: диагностика apt update, автофикс известных ошибок, затем upgrade fix_and_update(){ local auto=false a for a in "$@"; do [[ "$a" == "--auto" ]] && auto=true; done msg "$(c_cyn '─── Фикс и обновление ───')" local log1 log2 log1="$(mktemp)"; log2="$(mktemp)" msg "$(c_cyn '[*] Проверяю apt update (диагностика, без сырого вывода)...')" opt_run apt-get update >"$log1" 2>&1 if ! grep -qE '^(E:|W:)' "$log1"; then msg "$(c_grn '[✓] apt update прошёл без ошибок.')" rm -f "$log1" "$log2" else msg "$(c_yel '[!] Обнаружены проблемы apt update — разбираю и применяю известные фиксы...')" fau_apply_fixes "$log1" "$auto" rm -f "$log1" msg "" msg "$(c_cyn '[*] Повторная проверка apt update...')" opt_run apt-get update >"$log2" 2>&1 if grep -qE '^(E:|W:)' "$log2"; then msg "$(c_red '[!] После фиксов остались ошибки apt update:')" grep -E '^(E:|W:)' "$log2" | while IFS= read -r line; do msg "$(c_red " $line")" done msg "$(c_red '[!] apt upgrade не запущен — исправь ошибки вручную и повтори.')" rm -f "$log2" return 1 fi rm -f "$log2" msg "$(c_grn '[✓] После фиксов apt update чист.')" fi msg "" msg "$(c_cyn '[*] Запускаю apt upgrade -y...')" xanmod_hold_installed_meta if opt_run apt-get upgrade -y; then msg "$(c_grn '[✓] Система обновлена.')" else msg "$(c_red '[!] Ошибка при apt upgrade.')" return 1 fi } # =========================== МОДУЛЬ: ОПТИМИЗАЦИЯ И ЗАЩИТА НОДЫ ============= # Flint Script сам больше не оптимизирует хост — это обёртка эксплуатации над # сторонним node-accelerator (github.com/jestivald/node-accelerator, эталон — # именно апстрим). node-accelerator запускается ОТДЕЛЬНЫМ процессом (download # install.sh во временный файл + `bash tmp модуль`), а не сорсится функциями: # у него свой `set -euo pipefail` и IFS, которые здесь ломали бы меню (8.1 ТЗ). NA_REF="${NA_REF:-1b09ed951fef89a743d13f8244b1a218de255453}" NA_REPO_URL="${NA_REPO_URL:-https://raw.githubusercontent.com/jestivald/node-accelerator/$NA_REF}" # XanMod LTS pin: 7.1.8 имеет проблемы с QXL, поэтому optimize/all ставят строго # проверенную x64v3-сборку. Не заменять meta-пакетом linux-xanmod-lts-x64v3: # он двигается по ветке и при обычном apt upgrade снова может подтянуть 7.1.8. readonly XANMOD_LTS_PIN_PKG="linux-image-6.18.42-x64v3-xanmod1" NA_CONF_DIR=/etc/node-accelerator NA_STATE_DIR=/var/lib/node-accelerator NA_LOCK_FILE=/run/lock/flint-hardening.lock NA_BLOCKLIST_STATE_DIR=/var/lib/flint-script NA_BLOCKLIST_SNAPSHOT="$NA_BLOCKLIST_STATE_DIR/blocklist_v4.lkg" NA_BLOCKLIST_SAVER=/usr/local/sbin/flint-save-blocklist NA_BLOCKLIST_RESTORER=/usr/local/sbin/flint-restore-blocklist # Пути пока сохраняют legacy-имя ради уже существующих настроек и бэкапов нод. # Их перенос — отдельная миграция данных, не часть смены команды запуска. WR_HARDEN_ENV=/etc/warp-rules/harden.env WR_BACKUP_ROOT=/var/backups/warp-rules # гео-блок — бизнес-решение донора, не хардинг; список стран вынесен в параметр NA_GEOBLOCK_COUNTRIES_DEFAULT="tw,in,bd,vn,id,ph,ir,pk,th,mm,kh,la,ng,eg,ke,tz,et,br,ve,ec" NA_MIN_CUSTOM_BLOCKLIST_CIDRS=1000 # коммит shadow-netlab/traffic-guard-lists, проверен вручную перед переносом (раздел 4.2 ТЗ) NA_SCANLIST_REF="${NA_SCANLIST_REF:-aa4c79a665007bc6df00f53c0622cd9fa8aaef81}" # Свой дефолт TCP-портов вместо унаследованного от node-accelerator "443,2087". # 80 — для HTTP-01 (Let's Encrypt), обновление серта selfsteal-домена почти # всегда идёт через него; 2087 — дефолт апстрима без привязки к Remnawave, # смысла в нём для наших нод нет, убран. Действует до первого apply тоже — # именно этот дефолт показывается в шапке и уходит в первый прогон protect. TCP_PORTS="${TCP_PORTS:-80,443}" # Известный набор доверенных IP владельца (скрипт непубличный, см. коммент к # AUTO_REMOVE_LEGACY_FIREWALL) — предлагается в na_ensure_whitelist, когда # established-автодетект панели ничего не нашёл (панель молчит в момент apply). # 93.100.162.144 — домашний ПК; 213.165.58.154 — панель Remnawave; # 213.176.77.11 — бот (в т.ч. SSH). NA_WHITELIST_DEFAULT="${NA_WHITELIST_DEFAULT:-93.100.162.144,213.165.58.154,213.176.77.11}" # Сносить legacy ufw/iptables автоматически сразу после того, как confirm # реально снял ВЗВЕДЁННЫЙ сейфти-таймер (не при холостом нажатии). Дефолт 1 — # осознанное решение владельца (скрипт непубличный, использует только он). # 0 через «Параметры» — вернуться к ручному пункту «Обслуживание» с YES. AUTO_REMOVE_LEGACY_FIREWALL="${AUTO_REMOVE_LEGACY_FIREWALL:-1}" # ключи node-accelerator, которые Flint Script передаёт через ENV (см. 6.3.4 ТЗ) NA_UPSTREAM_KEYS=(SSH_PORT TCP_PORTS UDP_PORTS NODE_PORT WHITELIST SAFETY_DELAY \ ENABLE_BLOCKLISTS BLOCK_TOR ENABLE_CTGUARD \ ENABLE_XANMOD QDISC REMNAWAVE_SWAP_SIZE NODE_PORT_WHITELIST_ONLY) # свои ключи (не из node-accelerator) — тоже хранятся в harden.env WR_OWN_KEYS=(NA_GEOBLOCK_COUNTRIES AUTO_REMOVE_LEGACY_FIREWALL) NA_ENV_KEYS=("${NA_UPSTREAM_KEYS[@]}" "${WR_OWN_KEYS[@]}") # Не даём обычному `apt upgrade` обновить уже установленный плавающий XanMod # meta-package. Точный image-пакет выше остаётся доступен для optimize/all. xanmod_hold_installed_meta(){ command -v apt-mark >/dev/null 2>&1 || return 0 local p held=0 while IFS= read -r p; do [[ "$p" =~ ^linux-xanmod-(lts-)?x64v[1-4]$ ]] || continue apt-mark hold "$p" >/dev/null 2>&1 || { msg "$(c_yel "[!] Не удалось закрепить XanMod meta-package $p — apt upgrade может обновить ядро.")" continue } msg "$(c_cyn "[*] XanMod meta-package удержан: $p (pin: $XANMOD_LTS_PIN_PKG)")" held=1 done < <(dpkg-query -W -f='${binary:Package}\n' 'linux-xanmod*' 2>/dev/null || true) [[ $held -eq 1 ]] || msg "$(c_cyn "[*] XanMod pin для новых установок: $XANMOD_LTS_PIN_PKG")" } # node-accelerator пропускает install_xanmod, если uname уже содержит xanmod. # Поэтому после optimize/all отдельно доказываем, что точный image-пакет pin'а # установлен, а не только передан через ENV. Репозиторий к этому моменту уже # настроен апстримом. Более новое проблемное ядро автоматически не удаляем. xanmod_ensure_pinned_image(){ local cpu_flags cpu_flags=" $(awk -F: '/^flags/{print $2; exit}' /proc/cpuinfo 2>/dev/null) " local required_flag for required_flag in avx avx2 bmi1 bmi2 fma movbe; do [[ "$cpu_flags" == *" $required_flag "* ]] || { msg "$(c_red "[!] CPU не поддерживает x86-64-v3 (нет $required_flag); $XANMOD_LTS_PIN_PKG не устанавливается.")" return 1 } done if dpkg-query -W -f='${db:Status-Abbrev}' "$XANMOD_LTS_PIN_PKG" 2>/dev/null | grep -qx 'ii '; then msg "$(c_grn "[✓] XanMod pin установлен: $XANMOD_LTS_PIN_PKG")" return 0 fi apt-cache show "$XANMOD_LTS_PIN_PKG" >/dev/null 2>&1 || { msg "$(c_red "[!] Точный XanMod pin недоступен в apt: $XANMOD_LTS_PIN_PKG")" return 1 } msg "$(c_cyn "[*] Устанавливаю точный XanMod pin: $XANMOD_LTS_PIN_PKG")" DEBIAN_FRONTEND=noninteractive apt-get install -y "$XANMOD_LTS_PIN_PKG" || { msg "$(c_red "[!] Не удалось установить $XANMOD_LTS_PIN_PKG")" return 1 } update-grub >/dev/null 2>&1 || true touch /var/run/reboot-required 2>/dev/null || true msg "$(c_yel "[!] $XANMOD_LTS_PIN_PKG установлен — требуется reboot.")" if dpkg-query -W -f='${binary:Package}\n' 'linux-image-7.1.8*xanmod*' 2>/dev/null | grep -q .; then msg "$(c_yel '[!] Также установлен XanMod 7.1.8: он не удалён автоматически; перед reboot проверь GRUB.')" fi } na_require_root(){ [[ $EUID -eq 0 ]] || { msg "$(c_red '[!] Нужен root: перезапусти через sudo (node-accelerator работает только от root).')"; return 1; }; } # na_get — эффективное значение node-accelerator. Формат # protect.conf/optimize.conf — не KEY=value, идиома `: "${KEY:=val}"` # (присваивает, только если KEY ещё не задан) — grep '^KEY=' её не поймёт, # нужен сорсинг в субшелле с предварительным unset (см. 1.3 ТЗ). na_get(){ local f="$1" k="$2" [[ -f "$f" ]] || return 0 # shellcheck disable=SC1090 # путь заведомо динамический (protect.conf/optimize.conf на ноде) ( unset "$k"; . "$f" 2>/dev/null; printf '%s' "${!k-}" ) } # «заявленные владельцем» параметры (то, что мы передаём в ENV при каждом apply) — # отдельно от protect.conf/optimize.conf, чтобы видеть расхождение (5.3 ТЗ) # shellcheck disable=SC1090 # путь заведомо динамический (harden.env владельца) na_load_env(){ [[ -f "$WR_HARDEN_ENV" ]] && . "$WR_HARDEN_ENV" 2>/dev/null; return 0; } na_save_env(){ install -d -m 0700 "$(dirname "$WR_HARDEN_ENV")" 2>/dev/null local tmp k v; tmp="$(mktemp)" { printf '# Flint Script — заявленные параметры node-accelerator (%s)\n' "$(date -Is)" printf '# фактически применённое: /etc/node-accelerator/{protect,optimize}.conf\n' for k in "${NA_ENV_KEYS[@]}"; do v="${!k-}" [[ -n "$v" ]] && printf '%s=%q\n' "$k" "$v" done } > "$tmp" install -m 0600 "$tmp" "$WR_HARDEN_ENV" rm -f "$tmp" } # na_set_param KEY VALUE — обновить один ключ в harden.env, не потеряв остальные na_conf_file_for_key(){ case "$1" in ENABLE_XANMOD|QDISC|REMNAWAVE_SWAP_SIZE) printf '%s' "$NA_CONF_DIR/optimize.conf" ;; *) printf '%s' "$NA_CONF_DIR/protect.conf" ;; esac } # Убрать ключ из protect.conf/optimize.conf насовсем. Нужен для очистки: пустой # ENV НЕ очищает сохранённое значение — идиома `: "${KEY:=val}"` не отличает # "пусто" от "не задано", поэтому load_conf снова подставит старое значение. # Без этого снятие последнего whitelist-IP или закрытие всех портов молча не # применится (protect.sh отработает "успешно", список останется прежним). na_conf_unset(){ local f="$1" k="$2" tmp line prefix [[ -f "$f" ]] || return 0 # shellcheck disable=SC2016 # одинарные кавычки нужны: $/{ должны остаться литералом для printf, не раскрыться в shell printf -v prefix ': "${%s:=' "$k" tmp="$(mktemp)" while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" == "$prefix"* ]] && continue printf '%s\n' "$line" done < "$f" > "$tmp" install -m 0600 "$tmp" "$f" rm -f "$tmp" } na_set_param(){ local key="$1" val="$2" na_load_env printf -v "$key" '%s' "$val" na_save_env if [[ -z "$val" ]] && in_list "$key" "${NA_UPSTREAM_KEYS[@]}"; then na_conf_unset "$(na_conf_file_for_key "$key")" "$key" msg "$(c_yel "[i] $key снят из $(na_conf_file_for_key "$key") — apply применит пустое значение, а не старое.")" fi } na_installed(){ [[ -d "$NA_CONF_DIR" ]]; } # nод-accelerator v3.9.2 уже персистит этот маркер сам (optimize.sh, $STATE_DIR/optimize.installed) — # свой маркер поверх писать не нужно, читаем готовый (см. отчёт: расхождение с 1.3/4.5 ТЗ) # Маркер reboot_needed пишет optimize.sh В МОМЕНТ установки ядра и обновляет # только при следующем прогоне optimize — сам факт ребута его не снимает. # Сверяем с реальным uname -r, иначе после ребута в XanMod ещё долго висело # бы ложное «ТРЕБУЕТСЯ РЕБУТ» (поймано на реальной ноде). na_reboot_needed(){ uname -r | grep -qi xanmod && return 1 grep -q '^reboot_needed=1' "$NA_STATE_DIR/optimize.installed" 2>/dev/null } na_safety_armed(){ systemctl is-active --quiet na-fw-safety.timer 2>/dev/null && return 0 [[ -f "$NA_STATE_DIR/na-fw-safety.pid" ]] && kill -0 "$(cat "$NA_STATE_DIR/na-fw-safety.pid" 2>/dev/null)" 2>/dev/null } na_safety_remaining_s(){ # systemd-run --on-active создаёт МОНОТОННЫЙ таймер (относительно аптайма, # не календаря) — NextElapseUSecRealtime у него всегда n/a, нужен Monotonic # + текущий монотонный момент из /proc/uptime. local next_us uptime_s now_us diff next_us="$(systemctl show na-fw-safety.timer -p NextElapseUSecMonotonic --value 2>/dev/null)" [[ "$next_us" =~ ^[0-9]+$ && "$next_us" -gt 0 ]] || return 1 uptime_s="$(awk '{print $1}' /proc/uptime 2>/dev/null)" [[ -n "$uptime_s" ]] || return 1 now_us="$(awk -v u="$uptime_s" 'BEGIN{printf "%.0f", u*1000000}')" diff=$(( next_us - now_us )) (( diff > 0 )) && echo $(( diff / 1000000 )) || echo 0 } # дешёвая шапка статуса для главного меню (6.1 ТЗ) — только если node-accelerator # вообще применялся; только nft/systemctl/чтение файла, без docker и без sleep na_header_lines(){ na_installed || return 0 local kern qdisc reb fw crowd saf secs kern="$(uname -r)"; qdisc="$(sysctl -n net.core.default_qdisc 2>/dev/null)" if na_reboot_needed; then reb="$(c_red 'ТРЕБУЕТСЯ РЕБУТ')"; else reb="ребут не требуется"; fi msg " $(c_yel 'Ядро:') $(c_grn "$kern") · qdisc ${qdisc:-?} · $reb" nft list table inet na_filter >/dev/null 2>&1 && fw="$(c_grn 'na_filter активен')" || fw="$(c_red 'na_filter отсутствует')" systemctl is-active --quiet crowdsec 2>/dev/null && crowd="$(c_grn 'CrowdSec ok')" || crowd="$(c_yel 'CrowdSec —')" if na_safety_armed; then secs="$(na_safety_remaining_s 2>/dev/null)" saf="$(c_red "авто-откат ВЗВЕДЁН${secs:+, осталось ${secs}с}")" else saf="авто-откат снят" fi msg " $(c_yel 'Защита:') $fw · $crowd · $saf" } # ── валидация ввода (whitelist/порты уходят в nft-heredoc — мусор нельзя) ── na_valid_ipcidr(){ local v="$1" [[ -n "$v" ]] || return 1 # без точки/двоеточия это не IPv4/IPv6 — иначе чистый hex ("abc", "dead") проходил # бы как «валидный» через класс символов [0-9a-fA-F:.] [[ "$v" == *.* || "$v" == *:* ]] || return 1 [[ "$v" =~ ^[0-9a-fA-F:.]+(/[0-9]{1,3})?$ ]] || return 1 if [[ "$v" == *.* && "$v" != *:* ]]; then local ip="${v%%/*}" o IFS='.' read -ra o <<<"$ip" [[ ${#o[@]} -eq 4 ]] || return 1 local n for n in "${o[@]}"; do [[ "$n" =~ ^[0-9]{1,3}$ ]] || return 1 (( 10#$n <= 255 )) || return 1 done fi return 0 } na_validate_whitelist(){ local list="$1" v [[ -n "$list" ]] || return 1 local -a items; IFS=',' read -ra items <<<"$list" for v in "${items[@]}"; do v="${v// /}"; [[ -n "$v" ]] || continue na_valid_ipcidr "$v" || return 1 done return 0 } na_validate_ports(){ local list="$1" v [[ -n "$list" ]] || return 1 local -a items; IFS=',' read -ra items <<<"$list" for v in "${items[@]}"; do [[ "$v" =~ ^[0-9]{1,5}$ ]] || return 1 (( 10#$v >= 1 && 10#$v <= 65535 )) || return 1 done return 0 } # снапшот шире, чем rollback node-accelerator (тот откатывает только своё) — # фиксирует ЧУЖИЕ правила, которые были до всего (4.4 ТЗ) na_snapshot(){ local ts dir; ts="$(date -u +%Y%m%dT%H%M%SZ)"; dir="$WR_BACKUP_ROOT/$ts" install -d -m 0700 "$dir" nft list ruleset >"$dir/nft.ruleset" 2>/dev/null || true iptables-save >"$dir/iptables.rules" 2>/dev/null || true ip6tables-save >"$dir/ip6tables.rules" 2>/dev/null || true systemctl list-unit-files 'na-*' --no-legend >"$dir/na-units.txt" 2>/dev/null || true systemctl status crowdsec crowdsec-firewall-bouncer --no-pager >"$dir/crowdsec-status.txt" 2>&1 || true [[ -f "$NA_CONF_DIR/custom-blocklist.txt" ]] && cp -a "$NA_CONF_DIR/custom-blocklist.txt" "$dir/custom-blocklist.txt" 2>/dev/null msg "$(c_grn "[✓] Снапшот: $dir")" } # Локальный health-гейт перед снятием safety rollback. Не выключает и не # перезапускает сервисы: только доказывает, что защита и рабочая нода живы. na_health_local(){ nft list table inet na_filter >/dev/null 2>&1 \ || { msg "$(c_red '[!] Health: таблица inet na_filter отсутствует.')"; return 1; } systemctl is-active --quiet na-firewall.service 2>/dev/null \ || { msg "$(c_red '[!] Health: na-firewall.service не активен.')"; return 1; } docker inspect -f '{{.State.Running}}' remnanode 2>/dev/null | grep -qx true \ || { msg "$(c_red '[!] Health: контейнер remnanode не работает.')"; return 1; } if docker inspect remnawave-nginx >/dev/null 2>&1; then docker inspect -f '{{.State.Running}}' remnawave-nginx 2>/dev/null | grep -qx true \ || { msg "$(c_red '[!] Health: контейнер remnawave-nginx не работает.')"; return 1; } fi if systemctl list-unit-files crowdsec.service --no-legend 2>/dev/null | grep -q crowdsec; then systemctl is-active --quiet crowdsec.service \ || { msg "$(c_red '[!] Health: crowdsec.service не активен.')"; return 1; } fi if systemctl list-unit-files crowdsec-firewall-bouncer.service --no-legend 2>/dev/null | grep -q crowdsec; then systemctl is-active --quiet crowdsec-firewall-bouncer.service \ || { msg "$(c_red '[!] Health: CrowdSec firewall bouncer не активен.')"; return 1; } fi msg "$(c_grn '[✓] Локальный health-check защиты и ноды пройден.')" } # apt.conf.d force-confold на время прогона — иначе dpkg виснет на вопросе о # конфигах в неинтерактивном режиме (4.6 ТЗ); снимается через trap RETURN NA_APT_CONFOLD_FILE=/etc/apt/apt.conf.d/99-flint-script-hardening na_apt_confold_on(){ printf '%s\n' 'Dpkg::Options { "--force-confdef"; "--force-confold"; }' > "$NA_APT_CONFOLD_FILE"; } na_apt_confold_off(){ rm -f "$NA_APT_CONFOLD_FILE"; } na_download_install(){ local dest="$1" curl -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 15 --max-time 60 \ "$NA_REPO_URL/install.sh" -o "$dest" \ || { msg "$(c_red "[!] Не удалось скачать install.sh из $NA_REPO_URL")"; return 1; } chmod 0700 "$dest" } # если WHITELIST пуст — спросить IP панели (иначе она рискует остаться без # admin-доступа к порту ноды при первом же protect); пусто = продолжить на свой риск # Порт node-agent — та же логика приоритетов, что у detect_node_port() в # node-accelerator: docker env NODE_PORT/APP_PORT контейнера remnanode → # сохранённый NODE_PORT в protect.conf → известные дефолты. Реальный шаблон # docker-compose eGamesAPI/remnawave-reverse-proxy кладёт в remnanode ТОЛЬКО # NODE_PORT и SECRET_KEY — никакого URL/IP панели там нет (проверено по # исходнику install_node.sh), поэтому определять порт можно, а адрес панели # из env контейнера — нельзя (см. комментарий в na_detect_panel_ip_candidates). na_detect_node_port(){ local p if command -v docker >/dev/null 2>&1 && docker inspect remnanode >/dev/null 2>&1; then p="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' remnanode 2>/dev/null \ | awk -F= '$1=="NODE_PORT"||$1=="APP_PORT"{print $2; exit}')" [[ -n "$p" ]] && { printf '%s' "$p"; return 0; } fi p="$(na_get "$NA_CONF_DIR/protect.conf" NODE_PORT)" [[ -n "$p" && "$p" != "auto" ]] && { printf '%s' "$p"; return 0; } printf '%s' "2222,3000" } # Кандидаты на IP панели: у remnanode нет env-переменной с адресом панели # (докер-шаблон Remnawave её просто не создаёт — панель САМА ходит к ноде, # а не наоборот), поэтому единственный практичный признак — established- # соединения на node-порт прямо сейчас. Тот же принцип, что у # harvest_node_port_peers в protect.sh, только своими силами: на первом # apply protect.conf ещё может не существовать. na_detect_panel_ip_candidates(){ local ports port filt="" ports="$(na_detect_node_port)" for port in ${ports//,/ }; do [[ "$port" =~ ^[0-9]+$ ]] || continue filt="${filt:+$filt or }sport = :$port" done [[ -n "$filt" ]] || return 1 ss -Hnt state established "( $filt )" 2>/dev/null \ | awk '{print $NF}' | sed -E 's/:[0-9]+$//; s/^\[//; s/\]$//' \ | sed -E 's/^::ffff:([0-9.]+)$/\1/' \ | awk 'NF && $0!="127.0.0.1" && $0!="::1"' | sort -u } na_ensure_whitelist(){ na_load_env local cur="${WHITELIST:-}" [[ -z "$cur" ]] && cur="$(na_get "$NA_CONF_DIR/protect.conf" WHITELIST)" [[ -n "$cur" ]] && return 0 msg "$(c_yel '[!] WHITELIST пуст. Без IP панели/мониторинга при первом apply есть риск не достучаться до порта ноды.')" local -a cands=() mapfile -t cands < <(na_detect_panel_ip_candidates) local ans="" if [[ ${#cands[@]} -eq 1 ]]; then msg "$(c_cyn "[*] Node-порт сейчас держит соединение с ${cands[0]} — похоже, это панель.")" printf '%s' "$(c_yel "[?] Использовать ${cands[0]} как WHITELIST? (Y/n): ")" >&2 local yn; read -r yn < /dev/tty 2>/dev/null [[ -z "$yn" || "$yn" =~ ^[yYдД]$ ]] && ans="${cands[0]}" elif [[ ${#cands[@]} -gt 1 ]]; then msg "$(c_cyn '[*] Node-порт сейчас держат несколько соединений — выбери, какое из них панель:')" local i for i in "${!cands[@]}"; do msg " $((i+1)). ${cands[$i]}"; done msg " 0. Ни одно (ввести вручную / пропустить)" printf '%s' "$(c_yel "[?] Выбор (0-${#cands[@]}): ")" >&2 local pick; read -r pick < /dev/tty 2>/dev/null if [[ "$pick" =~ ^[0-9]+$ ]] && (( pick >= 1 && pick <= ${#cands[@]} )); then ans="${cands[$((pick-1))]}" fi elif [[ -n "$NA_WHITELIST_DEFAULT" ]]; then msg "$(c_cyn '[*] Established-соединений на node-порт сейчас нет, но есть известный дефолт (домашний ПК, панель, бот).')" printf '%s' "$(c_yel "[?] Использовать $NA_WHITELIST_DEFAULT как WHITELIST? (Y/n): ")" >&2 local yn; read -r yn < /dev/tty 2>/dev/null [[ -z "$yn" || "$yn" =~ ^[yYдД]$ ]] && ans="$NA_WHITELIST_DEFAULT" fi if [[ -z "$ans" ]]; then printf '%s' "$(c_yel '[?] IP/CIDR панели через запятую (Enter — пропустить на свой риск): ')" >&2 read -r ans < /dev/tty 2>/dev/null fi if [[ -n "$ans" ]]; then if ! na_validate_whitelist "$ans"; then msg "$(c_red '[!] Некорректный формат IP/CIDR — apply отменён.')" return 1 fi WHITELIST="$ans" else msg "$(c_yel '[i] Продолжаю без WHITELIST.')" fi return 0 } # Пост-проверка после protect: na_nodeport_wl_v4 наполняется из established- # пиров node-порта В МОМЕНТ прогона (см. harvest_node_port_peers в # protect.sh) — это НЕ то же самое, что WHITELIST, и пустой сет сам по себе # не поломка (панель может быть неактивна ровно в этот момент). Проверка # честно объясняет разницу, а не молча сравнивает множества. na_check_nodeport_wl(){ nft list table inet na_filter >/dev/null 2>&1 || return 0 nft list set inet na_filter na_nodeport_wl_v4 >/dev/null 2>&1 || return 0 local elems elems="$(nft list set inet na_filter na_nodeport_wl_v4 2>/dev/null | grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' | paste -sd, -)" if [[ -z "$elems" ]]; then msg "$(c_yel '[!] na_nodeport_wl_v4 пуст. Это нормально, если панель прямо сейчас не держит соединение с node-портом — сет наполняется established-пирами в момент прогона, не копией WHITELIST. Если панель ИМЕННО СЕЙЧАС теряет доступ: «Белый список и порты → Пожарный допуск» (временно, до рестарта), либо повтори apply, пока панель активно опрашивает ноду.')" else msg "$(c_grn "[✓] na_nodeport_wl_v4: $elems")" fi } # ── применить: optimize|protect|all — отдельным процессом (8.1 ТЗ) ── # 99-zz-bbr-cake.conf / 99-zz-node-limits.conf — конфиги старых модулей # старого инструмента (уже удалённых). Сортируются ПОСЛЕ 99-node-accelerator.conf # ("n" < "z") и молча перебивают его тюнинг без единого сообщения — снести # нужно ДО первого apply optimize, иначе конфликт останется (владелец решил: # сносить сразу, а не отдельным шагом). na_cleanup_legacy_sysctl(){ local f found=0 for f in /etc/sysctl.d/99-zz-bbr-cake.conf /etc/sysctl.d/99-zz-node-limits.conf \ /etc/modules-load.d/bbr-cake.conf /etc/security/limits.d/99-node.conf; do [[ -f "$f" ]] && { rm -f "$f"; found=1; } done [[ "$found" -eq 1 ]] || return 0 msg "$(c_grn '[✓] Старые конфликтующие sysctl-конфиги удалены — не будут перебивать тюнинг node-accelerator.')" sysctl --system >/dev/null 2>&1 || true } na_apply(){ local mode="$1" na_require_root || return 1 install -d -m 0755 /run/lock exec 9>"$NA_LOCK_FILE" if ! flock -n 9; then exec 9>&- msg "$(c_red '[!] Другой hardening/apply уже выполняется. Повторный запуск остановлен.')" return 1 fi trap 'na_apt_confold_off; flock -u 9 2>/dev/null || true; exec 9>&-' RETURN if [[ "$mode" == protect || "$mode" == all ]]; then na_ensure_whitelist || return 1 fi na_snapshot [[ "$mode" == optimize || "$mode" == all ]] && na_cleanup_legacy_sysctl local tmp; tmp="$(mktemp)" na_download_install "$tmp" || { rm -f "$tmp"; return 1; } na_apt_confold_on na_load_env local -a envp=(NA_REF="$NA_REF" NA_REPO_URL="$NA_REPO_URL" NA_REQUIRE_SIG=0 \ REMNAWAVE_NONINTERACTIVE=1) if [[ "$mode" == optimize || "$mode" == all ]]; then xanmod_hold_installed_meta envp+=(XANMOD_FLAVOR=lts XANMOD_PKG="$XANMOD_LTS_PIN_PKG") msg "$(c_cyn "[*] XanMod LTS закреплён: $XANMOD_LTS_PIN_PKG (QXL-safe; 7.1.8 запрещён)")" fi local k v npwl_declared=0 for k in "${NA_UPSTREAM_KEYS[@]}"; do v="${!k-}" if [[ -n "$v" ]]; then envp+=("$k=$v") [[ "$k" == NODE_PORT_WHITELIST_ONLY ]] && npwl_declared=1 fi done # protect.sh резолвит NODE_PORT_WHITELIST_ONLY=auto в буквальную 1 и # ПЕРСИСТИТ её такой в protect.conf (см. save_conf). На повторном прогоне # load_conf подставляет уже не "auto", а "1" — скрипт больше не может # отличить это от явного intent'а оператора и отключает авто-допуск # established-пиров панели в na_nodeport_wl_* (баг апстрима, поймано на # реальной ноде: работает на первом apply, пропадает на втором). Форсируем # "auto" на каждом protect/all, если оператор сам не задал другое значение # через «Параметры». if [[ "$npwl_declared" -eq 0 && ( "$mode" == protect || "$mode" == all ) ]]; then envp+=("NODE_PORT_WHITELIST_ONLY=auto") fi msg "$(c_cyn "[*] Запускаю node-accelerator $mode ($NA_REF)...")" env "${envp[@]}" bash "$tmp" "$mode" local rc=$? if [[ $rc -eq 0 && ( "$mode" == optimize || "$mode" == all ) ]]; then xanmod_ensure_pinned_image || rc=1 fi rm -f "$tmp" if [[ $rc -eq 0 ]]; then na_save_env if [[ "$mode" == protect || "$mode" == all ]]; then na_configure_crowdsec_remnawave na_write_blocklist_updater na_check_nodeport_wl na_health_local || { msg "$(c_red '[!] Apply не подтверждён: safety rollback оставлен взведённым.')" return 1 } msg "" msg "$(c_red "[!] Сейфти-таймер взведён (${SAFETY_DELAY:-900}с). Проверь SSH и HTTPS из ОТДЕЛЬНОЙ сессии, затем пункт «Подтвердить доступ».")" fi if [[ "$mode" == optimize || "$mode" == all ]] && na_reboot_needed; then msg "$(c_yel '[!] Установлено новое ядро XanMod — нужна перезагрузка (reboot), чтобы BBRv3 заработал.')" fi else msg "$(c_red "[!] node-accelerator $mode завершился с ошибкой (код $rc).")" fi return $rc } # ── подтверждение доступа (два действия donor'а: systemd-таймер И nohup+pid, # у node-accelerator есть оба — снимать нужно оба, см. 4.1 ТЗ) ── na_confirm(){ na_require_root || return 1 if ! na_safety_armed; then msg "$(c_grn '[✓] Авто-откат не взведён — подтверждать нечего.')" return 0 fi na_health_local || { msg "$(c_red '[!] Safety rollback не снят: локальный health-check не пройден.')" return 1 } na_load_env if [[ "${AUTO_REMOVE_LEGACY_FIREWALL:-1}" == 1 ]]; then na_do_remove_legacy_firewall na_health_local || { msg "$(c_red '[!] После удаления legacy firewall health-check не пройден; safety rollback оставлен взведённым.')" return 1 } fi systemctl stop na-fw-safety.service na-fw-safety.timer 2>/dev/null || true local pidf="$NA_STATE_DIR/na-fw-safety.pid" if [[ -f "$pidf" ]]; then kill "$(cat "$pidf" 2>/dev/null)" 2>/dev/null || true rm -f "$pidf" fi if na_safety_armed; then msg "$(c_red '[!] Не удалось снять авто-откат — таймер или процесс всё ещё активны.')" return 1 fi msg "$(c_grn '[✓] Авто-откат снят. Защита активна на постоянной основе.')" if [[ "${AUTO_REMOVE_LEGACY_FIREWALL:-1}" == 1 ]]; then msg "$(c_grn '[✓] Legacy-firewall снесён автоматически после подтверждения доступа.')" fi } # ── CrowdSec: привязка к remnawave-nginx (без этого CrowdSec работает вхолостую) ── na_configure_crowdsec_remnawave(){ local nginx_container nginx_container="$(docker ps --format '{{.Names}}' 2>/dev/null | awk '$0 == "remnawave-nginx" {print; exit}')" if [[ -z "$nginx_container" ]]; then msg "$(c_yel '[i] Контейнер remnawave-nginx не найден — привязка CrowdSec к его логам пропущена.')" return 0 fi command -v cscli >/dev/null 2>&1 || { msg "$(c_yel '[i] CrowdSec ещё не установлен — привязка выполнится позже.')"; return 0; } install -d -m 0755 /etc/crowdsec/acquis.d /etc/systemd/system/crowdsec.service.d local tmp; tmp="$(mktemp)" cat > "$tmp" < "$tmp" <<'EOF' [Service] Environment=GOGC=20 Environment=GOMAXPROCS=1 EOF mv -f "$tmp" /etc/systemd/system/crowdsec.service.d/remnawave-limits.conf cscli collections install crowdsecurity/nginx >/dev/null 2>&1 local scenario for scenario in http-wordpress-scan http-magento-scan http-phpmyadmin-scan http-thinkphp-scan; do cscli scenarios remove "crowdsecurity/$scenario" >/dev/null 2>&1 || true done systemctl daemon-reload systemctl restart crowdsec.service crowdsec-firewall-bouncer.service 2>/dev/null || true msg "$(c_grn "[✓] CrowdSec привязан к логам $nginx_container (GOGC=20/GOMAXPROCS=1, коллекция nginx, шумные web-сценарии сняты).")" } # ── блоклист-апдейтер: скан/гео-списки → custom-blocklist.txt (штатная точка # расширения protect.sh) + systemd-таймер (4.2 ТЗ) ── na_write_blocklist_updater(){ na_load_env local countries="${NA_GEOBLOCK_COUNTRIES:-$NA_GEOBLOCK_COUNTRIES_DEFAULT}" local custom="$NA_CONF_DIR/custom-blocklist.txt" local updater=/usr/local/sbin/remnawave-update-scanner-lists install -d -m 0750 "$NA_CONF_DIR" local tmp; tmp="$(mktemp)" cat > "$tmp" </dev/null 2>&1; then /usr/local/sbin/na-blocklist-update 1 else systemctl start na-blocklist.service 2>/dev/null || true fi "$NA_BLOCKLIST_SAVER" || logger -t flint-blocklist 'effective blocklist snapshot was not replaced' EOF install -m 0750 "$tmp" "$updater" rm -f "$tmp" cat > "$NA_BLOCKLIST_SAVER" </dev/null 2>&1 || exit 0 nft -j list set inet na_filter blocklist_v4 >"\$WORK/set.json" python3 - "\$WORK/set.json" "\$WORK/blocklist.lkg" "\$MIN_CIDRS" <<'PY' import ipaddress, json, pathlib, sys payload = json.loads(pathlib.Path(sys.argv[1]).read_text()) minimum = int(sys.argv[3]) data = next((x['set'] for x in payload.get('nftables', []) if isinstance(x, dict) and x.get('set', {}).get('name') == 'blocklist_v4'), None) if data is None: raise SystemExit('blocklist_v4 not found') nets = [] for elem in data.get('elem', []): try: if isinstance(elem, str): nets.append(ipaddress.ip_network(elem, strict=False)) elif 'prefix' in elem: p = elem['prefix']; nets.append(ipaddress.ip_network(f"{p['addr']}/{p['len']}", strict=False)) elif 'range' in elem: a, b = map(ipaddress.ip_address, elem['range']); nets.extend(ipaddress.summarize_address_range(a, b)) except (KeyError, ValueError): pass nets = list(ipaddress.collapse_addresses(n for n in nets if n.version == 4)) if len(nets) < minimum: raise SystemExit(f'refusing snapshot: {len(nets)} < {minimum}') pathlib.Path(sys.argv[2]).write_text(''.join(f'{n}\\n' for n in nets), encoding='ascii') PY install -d -m 0700 "\$(dirname "\$SNAPSHOT")" install -m 0600 "\$WORK/blocklist.lkg" "\$SNAPSHOT.new" mv -f "\$SNAPSHOT.new" "\$SNAPSHOT" logger -t flint-blocklist "saved \$(wc -l < "\$SNAPSHOT") effective IPv4 CIDRs" EOF chmod 0750 "$NA_BLOCKLIST_SAVER" cat > "$NA_BLOCKLIST_RESTORER" </dev/null 2>&1 || exit 0 SOURCE="\$SNAPSHOT"; [[ -s "\$SOURCE" ]] || SOURCE="\$CUSTOM_LIST" [[ -s "\$SOURCE" ]] || exit 0 python3 - "\$SOURCE" "\$WORK/restore.nft" "\$MIN_CIDRS" <<'PY' import ipaddress, pathlib, sys nets = [] for raw in pathlib.Path(sys.argv[1]).read_text(errors='ignore').splitlines(): try: net = ipaddress.ip_network(raw.split('#', 1)[0].strip(), strict=False) if net.version == 4: nets.append(net) except ValueError: pass nets = list(ipaddress.collapse_addresses(nets)) if len(nets) < int(sys.argv[3]): raise SystemExit(f'refusing restore: {len(nets)} < {sys.argv[3]}') pathlib.Path(sys.argv[2]).write_text('flush set inet na_filter blocklist_v4\\nadd element inet na_filter blocklist_v4 {\\n' + ',\\n'.join(f' {n}' for n in nets) + '\\n}\\n') PY nft -f "\$WORK/restore.nft" logger -t flint-blocklist "restored last-known-good IPv4 blocklist" EOF chmod 0750 "$NA_BLOCKLIST_RESTORER" cat > /etc/systemd/system/flint-blocklist-restore.service < /etc/systemd/system/remnawave-scanner-update.service < /etc/systemd/system/remnawave-scanner-update.timer <<'EOF' [Unit] Description=Daily refresh of Flint Script scanner/geo block list [Timer] OnCalendar=*-*-* 04:00:00 UTC Persistent=true RandomizedDelaySec=20m [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable flint-blocklist-restore.service >/dev/null 2>&1 systemctl enable --now remnawave-scanner-update.timer >/dev/null 2>&1 msg "$(c_grn '[✓] Блоклист-апдейтер установлен (таймер remnawave-scanner-update, 04:00 UTC).')" } # ── whitelist / порты ── na_whitelist_add(){ na_require_root || return 1 local cur new ip_input cur="$(na_get "$NA_CONF_DIR/protect.conf" WHITELIST)" if [[ -z "$cur" ]]; then na_load_env; cur="${WHITELIST:-}"; fi msg " Текущий whitelist: ${cur:-(пусто)}" printf '%s' "$(c_yel '[?] IP/CIDR добавить (можно несколько через запятую): ')" >&2 read -r ip_input < /dev/tty 2>/dev/null [[ -z "$ip_input" ]] && { msg "$(c_yel 'Отменено.')"; return 0; } if ! na_validate_whitelist "$ip_input"; then msg "$(c_red '[!] Некорректный IP/CIDR — отменено.')"; return 1; fi # добавляем по одному адресу — проверка «уже в списке» на всей строке разом # пропускала бы дубликаты при вводе нескольких адресов через запятую new="$cur" local -a items; IFS=',' read -ra items <<<"$ip_input" local added=0 e for e in "${items[@]}"; do e="${e// /}"; [[ -n "$e" ]] || continue if [[ ",$new," == *",$e,"* ]]; then msg "$(c_yel "[i] $e уже в списке.")" else new="${new:+$new,}$e"; added=1 fi done [[ "$added" -eq 1 ]] || { msg "$(c_yel '[i] Нечего добавлять.')"; return 0; } na_set_param WHITELIST "$new" msg "$(c_grn "[✓] WHITELIST → ${new:-(пусто)}")" na_apply_prompt protect } na_whitelist_remove(){ na_require_root || return 1 local cur new e cur="$(na_get "$NA_CONF_DIR/protect.conf" WHITELIST)" if [[ -z "$cur" ]]; then na_load_env; cur="${WHITELIST:-}"; fi if [[ -z "$cur" ]]; then msg "$(c_yel '[i] Whitelist пуст.')"; return 0; fi msg " Текущий whitelist: $cur" printf '%s' "$(c_yel '[?] IP/CIDR убрать: ')" >&2 local ip; read -r ip < /dev/tty 2>/dev/null [[ -z "$ip" ]] && { msg "$(c_yel 'Отменено.')"; return 0; } if [[ ",$cur," != *",$ip,"* ]]; then msg "$(c_yel '[i] Такого адреса в списке нет.')"; return 0; fi new="" local -a items; IFS=',' read -ra items <<<"$cur" for e in "${items[@]}"; do [[ "$e" == "$ip" ]] || new="${new:+$new,}$e"; done na_set_param WHITELIST "$new" msg "$(c_grn "[✓] WHITELIST → ${new:-(пусто)}")" na_apply_prompt protect } # Единый паттерн после накопленной правки (whitelist/порты): применить сразу # или отложить до явного «1. Применить» в корневом меню — Y по умолчанию # (не ломает привычный разовый сценарий «просто открой порт»), n — правка # остаётся в harden.env без ре-рана protect (не взводит сейфти-таймер зря # на каждую мелкую правку, если их копится несколько подряд). na_apply_prompt(){ local mode="$1" printf '%s' "$(c_yel '[?] Применить сейчас? (Y/n): ')" >&2 local yn; read -r yn < /dev/tty 2>/dev/null if [[ -z "$yn" || "$yn" =~ ^[yYдД]$ ]]; then na_apply "$mode" else msg "$(c_yel '[i] Сохранено, не применено. Когда закончишь править — «1. Применить» в меню «Оптимизация и защита ноды».')" fi } # Порт из списка закрывается, отсутствующий — открывается. Без суффикса — TCP. na_ports_change(){ local tcp udp input item port proto cur new pp touched_tcp=false touched_udp=false tcp="$(na_get "$NA_CONF_DIR/protect.conf" TCP_PORTS)" udp="$(na_get "$NA_CONF_DIR/protect.conf" UDP_PORTS)" if [[ -z "$tcp" || -z "$udp" ]]; then na_load_env [[ -n "$tcp" ]] || tcp="${TCP_PORTS:-}" [[ -n "$udp" ]] || udp="${UDP_PORTS:-}" fi msg " TCP: ${tcp:-(пусто)} UDP: ${udp:-(пусто)}" printf '%s' "$(c_yel '[?] Порт (443 = TCP, 443/udp = UDP; можно через запятую): ')" >&2 read -r input < /dev/tty 2>/dev/null input="${input// /}"; input="${input,,}" [[ -n "$input" ]] || { msg "$(c_yel 'Отменено.')"; return 0; } local -a items current IFS=',' read -ra items <<<"$input" for item in "${items[@]}"; do if [[ ! "$item" =~ ^([0-9]{1,5})(/(tcp|udp))?$ ]]; then msg "$(c_red "[!] Некорректный порт: $item")" return 1 fi port="${BASH_REMATCH[1]}"; proto="${BASH_REMATCH[3]:-tcp}" if ! na_validate_ports "$port"; then msg "$(c_red "[!] Некорректный порт: $item")" return 1 fi port=$((10#$port)) if [[ "$proto" == tcp ]]; then cur="$tcp"; touched_tcp=true; else cur="$udp"; touched_udp=true; fi if [[ ",$cur," == *",$port,"* ]]; then new=""; IFS=',' read -ra current <<<"$cur" for pp in "${current[@]}"; do [[ "$pp" == "$port" ]] || new="${new:+$new,}$pp"; done msg "$(c_yel "[↔] ${proto^^} $port открыт → закрываю")" else new="${cur:+$cur,}$port" msg "$(c_grn "[↔] ${proto^^} $port закрыт → открываю")" fi if [[ "$proto" == tcp ]]; then tcp="$new"; else udp="$new"; fi done na_require_root || return 1 $touched_tcp && na_set_param TCP_PORTS "$tcp" $touched_udp && na_set_param UDP_PORTS "$udp" msg "$(c_grn "[✓] TCP → ${tcp:-(пусто)} · UDP → ${udp:-(пусто)}")" na_apply_prompt protect } # «пожарный» допуск — прямой nft add element, без ре-рана, до перезапуска na-firewall/ребута na_firewall_emergency_admit(){ na_installed || { msg "$(c_yel '[i] node-accelerator ещё не применялся.')"; return 1; } na_require_root || return 1 printf '%s' "$(c_yel '[?] IP для пожарного допуска: ')" >&2 local ip; read -r ip < /dev/tty 2>/dev/null na_valid_ipcidr "$ip" || { msg "$(c_red '[!] Некорректный IP/CIDR.')"; return 1; } msg " 1. Ко всем портам (whitelist_v4/v6) — стоит выше всех лимитов" msg " 2. Только к порту node-agent (na_nodeport_wl_*)" printf '%s' "$(c_yel '[?] Куда (1/2): ')" >&2 local w set; read -r w < /dev/tty 2>/dev/null if [[ "$w" == 2 ]]; then set=na_nodeport_wl; else set=whitelist; fi [[ "$ip" == *:* ]] && set="${set}_v6" || set="${set}_v4" if nft add element inet na_filter "$set" "{ $ip }" 2>/dev/null; then msg "$(c_grn "[✓] $ip добавлен в $set (живёт до перезапуска na-firewall/ребута — не персистится).")" else msg "$(c_red '[!] Не удалось добавить элемент — есть ли живая na_filter?')" fi } na_report_ip(){ command -v na-report >/dev/null 2>&1 || { msg "$(c_yel '[i] na-report ещё не установлен.')"; return 1; } printf '%s' "$(c_yel '[?] IP (один адрес, без /CIDR-маски): ')" >&2 local ip; read -r ip < /dev/tty 2>/dev/null if [[ "$ip" == */* ]]; then msg "$(c_red '[!] na-report --ip ожидает один адрес, а не CIDR-диапазон.')"; return 1; fi na_valid_ipcidr "$ip" || { msg "$(c_red '[!] Некорректный IP.')"; return 1; } na-report --ip "$ip" } na_menu_whitelist(){ while :; do local wl tcp udp ssh wl="$(na_get "$NA_CONF_DIR/protect.conf" WHITELIST)" tcp="$(na_get "$NA_CONF_DIR/protect.conf" TCP_PORTS)" udp="$(na_get "$NA_CONF_DIR/protect.conf" UDP_PORTS)" ssh="$(na_get "$NA_CONF_DIR/protect.conf" SSH_PORT)" msg "" if [[ -z "$wl" && -n "$NA_WHITELIST_DEFAULT" ]]; then msg " Whitelist: (пусто) $(c_yel "[дефолт при apply: $NA_WHITELIST_DEFAULT]")" else msg " Whitelist: ${wl:-(пусто)}" fi msg " TCP: ${tcp:-?} UDP: ${udp:-—} SSH: ${ssh:-авто}" msg " ─────────────────────────────────────" msg " 1. Показать кандидатов (na-fw-top-talkers)" msg " 2. Добавить IP/CIDR в whitelist" msg " 3. Убрать IP/CIDR из whitelist" msg " 4. Открыть / закрыть порт" msg " 5. Пожарный допуск (без ре-рана, до перезапуска)" msg " 6. Проверить IP (na-report --ip)" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-6): ')" >&2 local c; read -r c < /dev/tty 2>/dev/null || return 1 case "$c" in 1) if command -v na-fw-top-talkers >/dev/null 2>&1; then na-fw-top-talkers; else msg "$(c_yel '[i] Ещё не установлено.')"; fi ;; 2) na_whitelist_add ;; 3) na_whitelist_remove ;; 4) na_ports_change ;; 5) na_firewall_emergency_admit ;; 6) na_report_ip ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac done } na_menu_params(){ na_load_env msg "" msg "$(c_cyn '──── Параметры: заявлено (harden.env) → применено (node-accelerator) ────')" msg "$(printf ' %-22s %-20s %s' 'XANMOD_PKG (pin)' "$XANMOD_LTS_PIN_PKG" '(жёстко задано Flint Script)')" local k declared actual f for k in "${NA_UPSTREAM_KEYS[@]}"; do declared="${!k:-—}" f="$NA_CONF_DIR/protect.conf" case "$k" in ENABLE_XANMOD|QDISC|REMNAWAVE_SWAP_SIZE) f="$NA_CONF_DIR/optimize.conf" ;; esac actual="$(na_get "$f" "$k")"; actual="${actual:-—}" msg "$(printf ' %-22s %-20s %s' "$k" "$declared" "$actual")" done for k in "${WR_OWN_KEYS[@]}"; do declared="${!k:-—}" msg "$(printf ' %-22s %-20s %s' "$k" "$declared" "(своё, не из node-accelerator)")" done msg "" msg "$(c_cyn '[i] TCP_PORTS/UDP_PORTS — только просмотр здесь; правка через «Белый список и порты → Изменить порты».')" printf '%s' "$(c_yel '[?] Enter — назад, либо KEY=value чтобы задать (применится при следующем apply): ')" >&2 local line; read -r line < /dev/tty 2>/dev/null [[ -z "$line" ]] && return 0 if [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]]; then local kk="${BASH_REMATCH[1]}" vv="${BASH_REMATCH[2]}" if [[ "$kk" == TCP_PORTS || "$kk" == UDP_PORTS ]]; then msg "$(c_yel "[i] $kk редактируется только через «Белый список и порты → Изменить порты» — там сразу видно текущее значение и можно применить.")" elif in_list "$kk" "${NA_ENV_KEYS[@]}"; then na_require_root || return 1 na_set_param "$kk" "$vv" msg "$(c_grn "[✓] $kk=$vv сохранено в $WR_HARDEN_ENV.")" else msg "$(c_red "[!] Неизвестный ключ: $kk")" fi else msg "$(c_red '[!] Формат: KEY=value')" fi } na_menu_status(){ while :; do msg "" msg "$(c_cyn '──── Статус и диагностика ────')" msg " 1. Краткая сводка" msg " 2. na-diagnose (полный health-отчёт)" msg " 3. na-diagnose --json" msg " 4. na-fw-status (баны, блоклисты, CrowdSec)" msg " 5. na-report (форензика атак за 24ч)" msg " 6. Список снапшотов правил" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-6): ')" >&2 local c; read -r c < /dev/tty 2>/dev/null || return 1 case "$c" in 1) na_header_lines || msg "$(c_yel '[i] node-accelerator ещё не применялся.')" ;; 2) if command -v na-diagnose >/dev/null 2>&1; then na-diagnose; else msg "$(c_yel '[i] node-accelerator ещё не применялся.')"; fi ;; 3) if command -v na-diagnose >/dev/null 2>&1; then na-diagnose --json; else msg "$(c_yel '[i] node-accelerator ещё не применялся.')"; fi ;; 4) if command -v na-fw-status >/dev/null 2>&1; then na-fw-status; else msg "$(c_yel '[i] Ещё не установлено.')"; fi ;; 5) if command -v na-report >/dev/null 2>&1; then na-report; else msg "$(c_yel '[i] Ещё не установлено.')"; fi ;; 6) if [[ -d "$WR_BACKUP_ROOT" ]]; then ls -1 "$WR_BACKUP_ROOT" 2>/dev/null | while IFS= read -r l; do msg " $l"; done else msg "$(c_yel '[i] Снапшотов нет.')"; fi ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac done } # legacy ufw/iptables — по умолчанию НЕ трогаем (protect.sh намеренно живёт # рядом с чужими правилами). Флаш только INPUT (не FORWARD — там цепочки # Docker, донорский `iptables -F` без указания цепочки их вычищал, 5.1 ТЗ). # Собственно работа — без запроса подтверждения. Вызывается из двух мест: # na_confirm (автоматически, только после реального снятия сейфти-таймера) # и na_remove_legacy_firewall (вручную, после явного YES). na_do_remove_legacy_firewall(){ na_snapshot systemctl disable --now ufw.service 2>/dev/null || true apt-get purge -y ufw >/dev/null 2>&1 || true systemctl disable --now traffic-guard.service 2>/dev/null || true rm -f /usr/local/bin/traffic-guard /etc/systemd/system/traffic-guard.service systemctl daemon-reload iptables -P INPUT ACCEPT 2>/dev/null || true iptables -F INPUT 2>/dev/null || true iptables -X SCANNERS-BLOCK 2>/dev/null || true command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save || true msg "$(c_grn '[✓] Legacy-цепочка INPUT очищена (FORWARD/Docker-цепочки не трогались).')" if docker inspect remnanode >/dev/null 2>&1; then msg "$(c_cyn '[*] Проверяю исходящую связность из remnanode...')" if docker exec remnanode sh -c 'command -v curl >/dev/null 2>&1 && curl -fsS --max-time 5 https://1.1.1.1 >/dev/null' 2>/dev/null; then msg "$(c_grn '[✓] Связность из remnanode подтверждена.')" else msg "$(c_yel '[!] Не удалось подтвердить связность из remnanode автоматически (нет curl в образе или сеть режется) — проверь вручную.')" fi fi } na_remove_legacy_firewall(){ na_require_root || return 1 msg "$(c_yel '[!] Снятие legacy ufw/iptables. По умолчанию мы их не трогаем — убедись, что')" msg "$(c_yel ' na_filter уже применена и SSH-доступ подтверждён (пункт «Подтвердить доступ»).')" printf '%s' "$(c_yel '[?] Точно продолжить? Наберите YES: ')" >&2 local ans; read -r ans < /dev/tty 2>/dev/null [[ "$ans" == "YES" ]] || { msg "$(c_yel 'Отменено.')"; return 0; } na_do_remove_legacy_firewall } na_menu_maintenance(){ msg "" msg " 1. Обновить блоклисты сейчас" msg " 2. Лимиты RAM для remnanode (docker-compose)" msg " 3. Убрать legacy-фаервол (ufw/iptables)" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-3): ')" >&2 local c; read -r c < /dev/tty 2>/dev/null || return 1 case "$c" in 1) na_require_root || return 1 if [[ -x /usr/local/sbin/remnawave-update-scanner-lists ]]; then /usr/local/sbin/remnawave-update-scanner-lists && msg "$(c_grn '[✓] Блоклисты обновлены.')" else msg "$(c_yel '[i] Блоклист-апдейтер ещё не установлен — примени защиту (пункт 1 → 1 или 3).')" fi ;; 2) opt_docker_limits ;; 3) na_remove_legacy_firewall ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac } na_rollback(){ local what="$1" na_require_root || return 1 msg "$(c_yel "[!] Откат node-accelerator: $what")" printf '%s' "$(c_yel '[?] Точно? (y/N): ')" >&2 local ans; read -r ans < /dev/tty 2>/dev/null [[ "$ans" =~ ^[yYдД]$ ]] || { msg "$(c_yel 'Отменено.')"; return 0; } na_snapshot local tmp; tmp="$(mktemp)" na_download_install "$tmp" || { rm -f "$tmp"; return 1; } env NA_REF="$NA_REF" NA_REPO_URL="$NA_REPO_URL" bash "$tmp" rollback "$what" local rc=$? rm -f "$tmp" [[ $rc -eq 0 ]] && msg "$(c_grn "[✓] Откат ($what) выполнен.")" return $rc } na_menu_rollback(){ msg "" msg " 1. Откатить защиту (protect)" msg " 2. Откатить оптимизацию (optimize)" msg " 3. Откатить всё" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-3): ')" >&2 local c; read -r c < /dev/tty 2>/dev/null || return 1 case "$c" in 1) na_rollback protect ;; 2) na_rollback optimize ;; 3) na_rollback all ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac } na_menu_advanced(){ msg "" msg "$(c_cyn '──── Дополнительно ────')" msg " 1. Только оптимизация (ядро, sysctl, память, NIC)" msg " 2. Только защита (nftables + CrowdSec + блоклисты)" msg " 3. Тонкие параметры" msg " 4. Обслуживание" msg " 5. Откат" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-5): ')" >&2 local c; read -r c < /dev/tty 2>/dev/null || return 1 case "$c" in 1) na_apply optimize ;; 2) na_apply protect ;; 3) na_menu_params ;; 4) na_menu_maintenance ;; 5) na_menu_rollback ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac } # --------------------------------------------------------------------------- # ПУНКТ МЕНЮ 4: «Оптимизация и защита ноды» # --------------------------------------------------------------------------- mode_na_hardening(){ na_require_root || return 1 while :; do local kern qdisc swapsz fw crowd bouncer wl tcp udp ssh safety=false msg "" msg "$(c_cyn '──── Оптимизация и защита ноды ────')" kern="$(uname -r)"; qdisc="$(sysctl -n net.core.default_qdisc 2>/dev/null)" # REMNAWAVE_SWAP_SIZE в optimize.sh — локальная переменная внутри функции # настройки свопа, наружу в save_conf не попадает (optimize.conf её не # хранит) — читаем реальное состояние системы, а не конфиг swapsz="$(swapon --show=NAME,SIZE --noheadings 2>/dev/null | awk '{printf "%s%s", (NR>1?", ":""), $1" "$2}')" msg " Ядро: $kern · qdisc ${qdisc:-?} · swap ${swapsz:-нет}" nft list table inet na_filter >/dev/null 2>&1 && fw="активен" || fw="нет" systemctl is-active --quiet crowdsec 2>/dev/null && crowd="ok" || crowd="—" systemctl is-active --quiet crowdsec-firewall-bouncer 2>/dev/null && bouncer="ok" || bouncer="—" msg " Сеть: na_filter $fw · CrowdSec $crowd · bouncer $bouncer" wl="$(na_get "$NA_CONF_DIR/protect.conf" WHITELIST)" tcp="$(na_get "$NA_CONF_DIR/protect.conf" TCP_PORTS)" # до первого apply protect.conf ещё не существует — показываем то, что # реально уйдёт в прогон (наш дефолт/harden.env), а не голый "?" [[ -z "$tcp" ]] && { na_load_env; tcp="${TCP_PORTS:-}"; } udp="$(na_get "$NA_CONF_DIR/protect.conf" UDP_PORTS)" [[ -z "$udp" ]] && { na_load_env; udp="${UDP_PORTS:-}"; } ssh="$(na_get "$NA_CONF_DIR/protect.conf" SSH_PORT)" msg " Порты: TCP ${tcp:-?} · UDP ${udp:-авто} · SSH ${ssh:-авто}" if na_safety_armed; then safety=true local secs; secs="$(na_safety_remaining_s 2>/dev/null)" msg " $(c_red "Авто-откат: ВЗВЕДЁН${secs:+, осталось ${secs}с}")" else msg " Авто-откат: не взведён" fi msg "$(c_cyn '─────────────────────────────────────────────')" if $safety; then msg " 1. SSH и HTTPS работают — сохранить правила" msg " 2. Настроить или обновить защиту" msg " 3. Доступ, whitelist и порты" msg " 4. Проверить состояние" msg " 5. Дополнительно" else msg " 1. Настроить или обновить защиту" msg " 2. Доступ, whitelist и порты" msg " 3. Проверить состояние" msg " 4. Дополнительно" fi msg " 0. Назад" if $safety; then printf '%s' "$(c_yel '[?] Выбор (0-5): ')" >&2 else printf '%s' "$(c_yel '[?] Выбор (0-4): ')" >&2 fi local c; read -r c < /dev/tty 2>/dev/null || return 1 if $safety; then case "$c" in 1) na_confirm ;; 2) na_apply all ;; 3) na_menu_whitelist ;; 4) na_menu_status ;; 5) na_menu_advanced ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac else case "$c" in 1) na_apply all ;; 2) na_menu_whitelist ;; 3) na_menu_status ;; 4) na_menu_advanced ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac fi done } # =========================== МОДУЛЬ: ОБНОВЛЕНИЕ/ОТКАТ НОДЫ REMNAWAVE ======= # список релизов Node (по умолчанию только стабильные) rn_releases(){ local include_pre="$1" limit="$2" json json=$(curl -fsSL --max-time 10 "https://api.github.com/repos/remnawave/node/releases?per_page=30" 2>/dev/null) || return 1 need jq || { printf '%s' "$json" | grep -o '"tag_name": *"[^"]*"' | sed -E 's/.*"([^"]+)"$/\1/' | head -n "$limit"; return 0; } if [[ "$include_pre" == true ]]; then printf '%s' "$json" | jq -r '.[].tag_name' 2>/dev/null | head -n "$limit" else printf '%s' "$json" | jq -r '.[] | select(.prerelease==false) | .tag_name' 2>/dev/null | head -n "$limit" fi } rn_compose_version(){ grep -oE 'remnawave/node:[[:alnum:]_.-]+' "$1" 2>/dev/null | head -n1 | cut -d: -f2- } rn_healthy(){ local target="$1" status image health started [[ "$(docker inspect remnanode --format '{{.State.Status}}' 2>/dev/null)" == running ]] || return 1 image=$(docker inspect remnanode --format '{{.Config.Image}}' 2>/dev/null) || return 1 [[ "${image##*:}" == "$target" ]] || return 1 status=$(docker exec remnanode xray version 2>/dev/null | awk 'NR==1{print $2}') [[ -n "$status" ]] || return 1 health=$(docker inspect remnanode --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null) [[ "$health" != unhealthy ]] || return 1 started=$(docker inspect remnanode --format '{{.State.StartedAt}}' 2>/dev/null) ! docker logs --since "$started" remnanode 2>&1 | grep -qi 'fatal' } rn_install_version(){ local dir="$1" cf="$2" target="$3" [[ "$target" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]] || { msg "$(c_red '[!] Некорректная версия.')"; return 1; } local bak="${cf}.bak.$(date +%Y%m%d_%H%M%S)" out opt_run cp "$cf" "$bak" || return 1 if ! opt_run sed -i -E "s|(image:[[:space:]]*remnawave/node:)[^[:space:]\"']+|\1${target}|" "$cf" \ || [[ "$(rn_compose_version "$cf")" != "$target" ]] \ || ! (cd "$dir" && docker compose config --quiet >/dev/null 2>&1); then msg "$(c_red '[!] Не удалось изменить compose — возвращаю исходный файл.')" opt_run cp "$bak" "$cf" return 1 fi msg "$(c_cyn "[*] Скачиваю remnawave/node:${target}...")" if ! out=$(cd "$dir" && opt_run docker compose pull remnanode 2>&1) \ || ! out=$(cd "$dir" && opt_run docker compose up -d remnanode 2>&1); then msg "$(c_red '[!] Ошибка обновления — возвращаю предыдущую версию.')" msg "$out" opt_run cp "$bak" "$cf" (cd "$dir" && opt_run docker compose up -d remnanode >/dev/null 2>&1) return 1 fi spin_sleep 5 "Проверяю ноду..." if rn_healthy "$target"; then msg "$(c_grn "[✓] Установлено: remnanode $(remnanode_status)")" return 0 fi msg "$(c_red '[!] Нода не прошла проверку — автоматически возвращаю предыдущую версию.')" opt_run cp "$bak" "$cf" (cd "$dir" && opt_run docker compose up -d remnanode >/dev/null 2>&1) spin_sleep 4 "Проверяю откат..." if rn_healthy "$(rn_compose_version "$bak")"; then msg "$(c_grn '[✓] Предыдущая версия восстановлена.')" else msg "$(c_red "[!] Автооткат не подтверждён. Бэкап: $bak")" fi return 1 } rn_pick_version(){ local dir="$1" cf="$2" include_pre=false target="" while :; do local -a vers=() local v i=1 while IFS= read -r v; do [[ -n "$v" ]] && vers+=("${v#v}"); done < <(rn_releases "$include_pre" 8) [[ ${#vers[@]} -eq 0 ]] && msg "$(c_yel '[!] Не удалось получить релизы с GitHub; версию можно ввести вручную.')" msg "" for v in "${vers[@]}"; do msg " $i. $v"; i=$((i+1)); done local manual_idx=$i toggle_idx=$((i+1)) msg " $manual_idx. Ввести версию вручную" if $include_pre; then msg " $toggle_idx. Скрыть prerelease"; else msg " $toggle_idx. Показать prerelease"; fi msg " 0. Назад" printf '%s' "$(c_yel "[?] Выбор (0-$toggle_idx): ")" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 [[ "$choice" == 0 ]] && return 0 if [[ "$choice" == "$toggle_idx" ]]; then if $include_pre; then include_pre=false; else include_pre=true; fi continue elif [[ "$choice" == "$manual_idx" ]]; then printf '%s' "$(c_yel '[?] Версия (например 2.8.1): ')" >&2 read -r target < /dev/tty 2>/dev/null; target="${target#v}" elif [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice < manual_idx )); then target="${vers[$((choice-1))]}" else msg "$(c_red 'Неверный выбор, повтори.')" continue fi rn_install_version "$dir" "$cf" "$target" return $? done } rn_backups_menu(){ local dir="$1" cf="$2" local -a baks=() mapfile -t baks < <(compgen -G "${cf}.bak.*" | sort -r) [[ ${#baks[@]} -gt 0 ]] || { msg "$(c_yel '[i] Compose-бэкапов нет.')"; return 0; } msg "" msg "$(c_cyn '─── Бэкапы Node ───')" local i for i in "${!baks[@]}"; do msg " $((i+1)). $(rn_compose_version "${baks[$i]}") (${baks[$i]##*.bak.})"; done msg " 0. Назад" printf '%s' "$(c_yel "[?] Выбор (0-${#baks[@]}): ")" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 [[ "$choice" == 0 ]] && return 0 [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#baks[@]} )) \ || { msg "$(c_red 'Неверный выбор.')"; return 1; } local selected="${baks[$((choice-1))]}" target safety="${cf}.bak.$(date +%Y%m%d_%H%M%S)" target="$(rn_compose_version "$selected")" [[ -n "$target" ]] || { msg "$(c_red '[!] В бэкапе не найдена версия remnawave/node.')"; return 1; } opt_run cp "$cf" "$safety" || return 1 opt_run cp "$selected" "$cf" || return 1 if ! (cd "$dir" && docker compose config --quiet >/dev/null 2>&1) \ || ! (cd "$dir" && opt_run docker compose up -d remnanode >/dev/null 2>&1); then msg "$(c_red '[!] Бэкап не запустился — возвращаю исходный compose.')" opt_run cp "$safety" "$cf" (cd "$dir" && opt_run docker compose up -d remnanode >/dev/null 2>&1) return 1 fi spin_sleep 5 "Проверяю ноду..." if rn_healthy "$target"; then msg "$(c_grn "[✓] Восстановлена Node $target")" return 0 fi msg "$(c_red '[!] Восстановленная версия не прошла проверку — возвращаю исходную.')" opt_run cp "$safety" "$cf" (cd "$dir" && opt_run docker compose up -d remnanode >/dev/null 2>&1) return 1 } mode_remnanode_update(){ docker inspect remnanode >/dev/null 2>&1 || { msg "$(c_red '[!] Нода remnanode не найдена.')"; return 1; } local dir cf dir=$(docker inspect remnanode --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' 2>/dev/null) [[ -n "$dir" && -d "$dir" ]] || { msg "$(c_red '[!] Не нашёл рабочую директорию docker-compose ноды.')"; return 1; } cf=$(find "$dir" -maxdepth 1 -name 'docker-compose.y*ml' 2>/dev/null | head -n1) [[ -n "$cf" ]] || { msg "$(c_red "[!] docker-compose.yml не найден в $dir")"; return 1; } while :; do msg "" msg "$(c_cyn '─── Обновление / откат ноды Remnawave ───')" msg " Текущая версия: $(c_grn "$(remnanode_status)")" msg "" msg " 1. Выбрать версию Node" msg " 2. Восстановить из compose-бэкапа" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-2): ')" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 case "$choice" in 1) rn_pick_version "$dir" "$cf" ;; 2) rn_backups_menu "$dir" "$cf" ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac done } # =========================== МОДУЛЬ: ОБНОВЛЕНИЕ/ОТКАТ XRAY-CORE ============ # Меняет только бинарник xray внутри контейнера ноды, образ/версию самой # Node не трогает. Независим от режима 6. XC_NODE_MIN_COMPAT="2.8.0" # начиная с этой версии Node... XC_CORE_MIN_COMPAT="26.6.27" # ...требуется минимум эта версия Xray-Core # истина, если версия $1 >= $2 (сравнение через sort -V) xc_ver_ge(){ local a="$1" b="$2" [[ "$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -n1)" == "$a" ]] } # контейнер ноды — тот же, что использует пункт 6 xc_container(){ docker inspect remnanode >/dev/null 2>&1 && { printf 'remnanode'; return 0; } return 1 } xc_cur_version(){ docker exec "$1" xray version 2>/dev/null | awk 'NR==1{print $2}'; } xc_node_tag(){ local img img=$(docker inspect "$1" --format '{{.Config.Image}}' 2>/dev/null) || return 1 printf '%s' "${img##*:}" } xc_arch(){ docker exec "$1" uname -m 2>/dev/null; } # список тегов релизов Xray-Core (по умолчанию без пре-релизов) xc_releases(){ local include_pre="$1" limit="$2" json json=$(curl -fsSL --max-time 10 "https://api.github.com/repos/XTLS/Xray-core/releases?per_page=30" 2>/dev/null) || return 1 need jq || { printf '%s' "$json" | grep -o '"tag_name": *"[^"]*"' | sed -E 's/.*"([^"]+)"$/\1/' | head -n "$limit"; return 0; } if [[ "$include_pre" == "true" ]]; then printf '%s' "$json" | jq -r '.[].tag_name' 2>/dev/null | head -n "$limit" else printf '%s' "$json" | jq -r '.[] | select(.prerelease==false) | .tag_name' 2>/dev/null | head -n "$limit" fi } # имя asset-файла под архитектуру внутри контейнера xc_asset_name(){ case "$1" in x86_64|amd64) printf 'Xray-linux-64.zip' ;; aarch64|arm64) printf 'Xray-linux-arm64-v8a.zip' ;; armv7l|armhf) printf 'Xray-linux-arm32-v7a.zip' ;; i386|i686) printf 'Xray-linux-32.zip' ;; *) return 1 ;; esac } # URL и официальный SHA-256 asset из GitHub Release API. xc_asset_metadata(){ local tag="$1" arch="$2" asset json url digest asset=$(xc_asset_name "$arch") || return 1 json=$(curl -fsSL --max-time 10 "https://api.github.com/repos/XTLS/Xray-core/releases/tags/${tag}" 2>/dev/null) || return 1 if need jq; then url=$(printf '%s' "$json" | jq -r --arg n "$asset" '.assets[] | select(.name==$n) | .browser_download_url' 2>/dev/null) digest=$(printf '%s' "$json" | jq -r --arg n "$asset" '.assets[] | select(.name==$n) | .digest' 2>/dev/null) else msg "$(c_red '[!] Для проверки SHA-256 Xray требуется jq.')" return 1 fi digest="${digest#sha256:}" [[ -n "$url" && "$url" != null && "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 printf '%s\t%s\n' "$url" "$digest" } # список бэкапов бинарника внутри контейнера (полные пути) xc_backups(){ docker exec "$1" sh -c 'ls -1 /usr/local/bin/xray.bak-* 2>/dev/null'; } # восстановить конкретный бэкап и перезапустить контейнер xc_restore(){ local c="$1" bak="$2" msg "$(c_cyn "[*] Восстанавливаю $bak...")" if ! docker exec "$c" cp "$bak" /usr/local/bin/xray 2>/dev/null; then msg "$(c_red '[!] Не удалось восстановить бэкап.')" return 1 fi opt_run docker restart "$c" >/dev/null 2>&1 spin_sleep 4 "Перезапускаю контейнер..." local v; v=$(xc_cur_version "$c") if [[ -n "$v" ]]; then msg "$(c_grn "[✓] Xray-Core восстановлен: $v")" return 0 fi msg "$(c_red '[!] После восстановления версия не определяется.')" return 1 } # подменю: показать бэкапы внутри контейнера, дать откатиться на любой xc_backups_menu(){ local c="$1" local -a baks=() local b while IFS= read -r b; do [[ -n "$b" ]] && baks+=("$b"); done < <(xc_backups "$c") if [[ ${#baks[@]} -eq 0 ]]; then msg "$(c_yel '[!] Бэкапов Xray-Core внутри контейнера не найдено.')" return 0 fi msg "" msg "$(c_cyn '─── Бэкапы Xray-Core в контейнере ───')" local i=1 for b in "${baks[@]}"; do msg " $i. ${b##*/}"; i=$((i+1)); done msg " 0. Назад" msg "$(c_cyn '──────────────────────────────────────')" printf '%s' "$(c_yel "[?] Выбор (0-$((i-1))): ")" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 [[ "$choice" == "0" ]] && return 0 if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#baks[@]} )); then msg "$(c_red 'Неверный выбор.')" return 1 fi xc_restore "$c" "${baks[$((choice-1))]}" } # выбор версии Xray-Core с GitHub, скачивание, бэкап, установка, проверка xc_pick_and_install(){ local c="$1" node_tag="$2" arch="$3" local include_pre=false tag="" while :; do msg "" msg "$(c_cyn '[*] Получаю список релизов Xray-Core с GitHub...')" local -a vers=() local v while IFS= read -r v; do [[ -n "$v" ]] && vers+=("$v"); done < <(xc_releases "$include_pre" 8) [[ ${#vers[@]} -eq 0 ]] && msg "$(c_yel '[!] Не удалось получить список релизов с GitHub (сеть/лимит API).')" msg "" local i=1 for v in "${vers[@]}"; do msg " $i. $v"; i=$((i+1)); done local manual_idx=$i msg " $manual_idx. Ввести версию вручную" local toggle_idx=$((manual_idx + 1)) if $include_pre; then msg " $toggle_idx. Скрыть пре-релизы" else msg " $toggle_idx. Показать все версии (включая пре-релизы)" fi msg " 0. Отмена" msg "" printf '%s' "$(c_yel "[?] Выбор (0-$toggle_idx): ")" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 [[ "$choice" == "0" ]] && return 0 if [[ "$choice" == "$toggle_idx" ]]; then if $include_pre; then include_pre=false; else include_pre=true; fi continue fi if [[ "$choice" == "$manual_idx" ]]; then printf '%s' "$(c_yel '[?] Версия (например v26.6.27 или 26.6.27): ')" >&2 read -r tag < /dev/tty 2>/dev/null [[ -z "$tag" ]] && { msg "$(c_red 'Версия не указана.')"; return 1; } [[ "$tag" != v* ]] && tag="v${tag}" break fi if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice < manual_idx )); then tag="${vers[$((choice-1))]}" break fi msg "$(c_red 'Неверный выбор, повтори.')" done local target_ver="${tag#v}" # проверка совместимости Node >= 2.8.0 <-> Xray-Core >= 26.6.27 if [[ -n "$node_tag" ]] && xc_ver_ge "$node_tag" "$XC_NODE_MIN_COMPAT" \ && ! xc_ver_ge "$target_ver" "$XC_CORE_MIN_COMPAT"; then msg "" msg "$(c_red '======================================================')" msg "$(c_red "[!] Node $node_tag требует Xray-Core >= $XC_CORE_MIN_COMPAT.")" msg "$(c_red " Выбранная версия $target_ver — старше и может быть несовместима.")" msg "$(c_red ' Нода может не запуститься или работать нестабильно.')" msg "$(c_red '======================================================')" printf '%s' "$(c_yel '[?] Введите "да, я понимаю риск" чтобы продолжить: ')" >&2 local confirm; read -r confirm < /dev/tty 2>/dev/null if [[ "$confirm" != "да, я понимаю риск" ]]; then msg "$(c_yel '[i] Отменено — версия Xray-Core не изменена.')" return 0 fi fi local asset_url asset_sha IFS=$'\t' read -r asset_url asset_sha < <(xc_asset_metadata "$tag" "$arch") || { msg "$(c_red "[!] Не нашёл asset или официальный SHA-256 под архитектуру ($arch) в релизе $tag.")" return 1 } [[ -n "$asset_url" && "$asset_sha" =~ ^[0-9a-f]{64}$ ]] || { msg "$(c_red '[!] GitHub Release API не вернул пригодный SHA-256; установка отменена.')" return 1 } if ! need unzip; then msg "$(c_yel '[*] Устанавливаю unzip...')" opt_run apt-get install -y unzip -qq >/dev/null 2>&1 need unzip || { msg "$(c_red '[!] unzip недоступен и не установился — установи вручную.')"; return 1; } fi local tmpd; tmpd=$(mktemp -d) msg "$(c_cyn "[*] Скачиваю ${asset_url##*/} ($tag)...")" if ! curl -fsSL --max-time 90 "$asset_url" -o "$tmpd/xray.zip"; then msg "$(c_red '[!] Ошибка загрузки архива.')" rm -rf "$tmpd"; return 1 fi local downloaded_sha downloaded_sha=$(sha256sum "$tmpd/xray.zip" | awk '{print $1}') if [[ "$downloaded_sha" != "$asset_sha" ]]; then msg "$(c_red '[!] SHA-256 архива Xray не совпал с digest официального GitHub Release API.')" rm -rf "$tmpd"; return 1 fi msg "$(c_grn '[✓] SHA-256 архива Xray подтверждён.')" unzip -o -q "$tmpd/xray.zip" -d "$tmpd/extracted" >/dev/null 2>&1 local bin="$tmpd/extracted/xray" if [[ ! -f "$bin" ]]; then msg "$(c_red '[!] В архиве не найден бинарник xray.')" rm -rf "$tmpd"; return 1 fi chmod +x "$bin" local cur_ver; cur_ver=$(xc_cur_version "$c") local bak="/usr/local/bin/xray.bak-${cur_ver:-unknown}" if docker exec "$c" test -f "$bak" 2>/dev/null; then msg "$(c_yel "[!] Бэкап $bak уже существует — не перезаписываю.")" else if docker exec "$c" cp /usr/local/bin/xray "$bak" 2>/dev/null; then msg "$(c_grn "[✓] Бэкап текущей версии создан: $bak")" else msg "$(c_red '[!] Не удалось создать бэкап — прерываю, чтобы не потерять путь отката.')" rm -rf "$tmpd"; return 1 fi fi msg "$(c_cyn "[*] Устанавливаю Xray-Core $target_ver в контейнер...")" if ! docker cp "$bin" "$c":/usr/local/bin/xray; then msg "$(c_red '[!] docker cp не удался.')" rm -rf "$tmpd"; return 1 fi rm -rf "$tmpd" opt_run docker restart "$c" >/dev/null 2>&1 spin_sleep 4 "Перезапускаю контейнер..." local new_ver logs_ok=true new_ver=$(xc_cur_version "$c") docker logs --tail 30 "$c" 2>&1 | grep -qi 'fatal' && logs_ok=false if [[ "$new_ver" == "$target_ver" ]] && $logs_ok; then msg "$(c_grn "[✓] Xray-Core обновлён: $new_ver")" else msg "$(c_yel "[!] Проблема после обновления (версия: ${new_ver:-нет данных}, ожидалась $target_ver).")" printf '%s' "$(c_yel "[?] Обнаружена проблема. Откатить на предыдущую версию ($bak)? (y/n): ")" >&2 local ans; read -r ans < /dev/tty 2>/dev/null if [[ "$ans" =~ ^[yYдД]$ ]]; then xc_restore "$c" "$bak" else msg "$(c_yel "[i] Откат не выполнен. Бэкап остаётся: $bak")" fi fi msg "" msg "$(c_red '[!] Внимание: при следующем обновлении/пересоздании образа Node')" msg "$(c_red ' (пункт 6 этого меню или docker pull) версия Xray-Core будет')" msg "$(c_red ' перезаписана той, что зашита в новый образ Node. Если хочешь')" msg "$(c_red ' сохранить текущую версию Xray после обновления Node — повтори')" msg "$(c_red ' обновление Xray заново после пункта 6.')" } mode_xray_update(){ local c c=$(xc_container) || { msg "$(c_red '[!] Нода remnanode не найдена.')" msg "$(c_yel '[i] Сначала установи её через пункт 3 меню.')" return 1 } while :; do local cur_xray cur_img arch cur_xray=$(xc_cur_version "$c") cur_img=$(xc_node_tag "$c") arch=$(xc_arch "$c") msg "" msg "$(c_cyn '─── Обновление / откат Xray-Core (без обновления ноды) ───')" msg " Контейнер: $(c_grn "$c")" msg " Версия Xray-Core: $(c_grn "${cur_xray:-неизвестно}")" msg " Версия образа Node: $(c_grn "${cur_img:-неизвестно}")" msg " Архитектура: $(c_grn "${arch:-неизвестно}")" msg "" msg " 1. Выбрать версию Xray-Core (обновить/откатить)" msg " 2. Показать список бэкапов и откатиться" msg " 0. Отмена" msg "$(c_cyn '─────────────────────────────────────────────────────────')" printf '%s' "$(c_yel '[?] Выбор (0-2): ')" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 case "$choice" in 1) xc_pick_and_install "$c" "$cur_img" "$arch"; return $? ;; 2) xc_backups_menu "$c" ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac done } main "$@"