#!/usr/bin/env bash # # Flint Script — инструмент для серверов Xray/VLESS # # Меню: # 1. Мой мост: WARP (проверка/блок) или свой сервер-хаб (настройка/блок). # 2. Инструменты: SSH-доступ и подготовка системы. # 3. Оптимизация и защита — обёртка над node-accelerator # (github.com/jestivald/node-accelerator): XanMod/BBRv3, sysctl, # nftables + CrowdSec, блоклисты, откат. # 4. Обновление системы + исправление сбоев apt. # 5. Remnawave Node: выбор версии и восстановление. # 6. Xray-Core: выбор версии и восстановление без обновления Node. # 7. Проверка ядра, сети, защиты, Node и параметров без изменений. # # Обновление системы включает диагностику apt update, # автофикс известных ошибок при необходимости, затем apt upgrade -y). # # Запуск: # wget -qO- script.flintnet.pro | bash # покажет меню # flint # установленная локальная версия # flint warp # меню Мой мост (WARP + свой хаб) # flint bridge-setup # настроить туннель на свой хаб # flint bridge-route # блок маршрутизации для своего моста # flint bridge-outbounds # аутбаунды профилей панели (просмотр/добавить/удалить) # flint route -- -t 5 # блок маршрутизации + аргументы ipregion # flint 5 --auto # старый cron-вызов обновления системы # flint audit # проверка без изменений # multitest # встроенные тесты Flint set -uo pipefail # =========================== НАСТРОЙКИ ===================================== SCRIPT_VERSION="3.17.0" 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://script.flintnet.pro/ipregion.sh" 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") # Известные хабы для "Мой мост" → "Настроить мост на свой сервер". # Значение: "endpoint(host:port)|публичный_ключ_хаба|префикс_подсети_без_последней_цифры". # Публичный ключ WireGuard не секрет (секретный остаётся только на хабе) — # хранить его прямо в скрипте безопасно, это устраняет ручной ввод при # добавлении новой ноды к уже существующему хабу. declare -A BRIDGE_HUBS=( ["germany"]="185.194.177.212:51888|28lPcROJGumNwu5OOHEADnXzuJA8PneY86OB0QErEBg=|10.90.90" ["estonia"]="95.85.243.176:51888|DDIM1RLMiBUE7rhrK/LteDpyVTG7DxiezhnOjkHnMRI=|10.91.91" ["finland"]="213.165.59.7:51888|oSK555s7cIN4YezYun0epR8W61w+l6DP8kBD6mQaMhI=|10.92.92" ) declare -A SERVICE_DOMAINS=( ["AI-сервисы (мост)"]="geosite:google-gemini,geosite:google-deepmind,geosite:category-ai-!cn,domain:ai.google.dev,domain:aisandbox-pa.googleapis.com,domain:aistudio.google.com,domain:gemini.google.com,domain:generativelanguage.googleapis.com,domain:labs.google,domain:makersuite.google.com,domain:notebooklm.google.com,domain:alkalimojo-pa.clients6.google.com,domain:proactivebackend-pa.googleapis.com,domain:speechenvelopes-pa.googleapis.com,domain:gemini.apps.google.com,domain:content-autofill.googleapis.com,domain:googleusercontent.com,domain:apis.google.com,domain:openai.com,domain:chatgpt.com,domain:oaistatic.com,domain:oaiusercontent.com,domain:ai.com,domain:anthropic.com,domain:claude.ai,domain:claude.com,domain:clau.de,domain:claudeusercontent.com,domain:claudemcpclient.com,domain:claudemcpcontent.com,domain:x.ai,domain:grok.com,domain:perplexity.ai,domain:pplx.ai,domain:mistral.ai,domain:chat.mistral.ai,domain:le-chat.mistral.ai,domain:copilot.microsoft.com,domain:bing.com,domain:meta.ai,domain:deepseek.com,domain:chat.deepseek.com,domain:character.ai,domain:poe.com,domain:huggingface.co,domain:groq.com" ["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" command -v mtproxymax >/dev/null 2>&1 && \ printf "${YEL}⚡️ Быстрый запуск MTProxyMax:${RST} ${GRN}mtproxymax${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 local mt_tmp mt_tmp="$(mktemp)" || return 1 cat > "$mt_tmp" << 'WRAPPER' #!/usr/bin/env bash FLINT_MULTITEST_LAUNCHER=1 exec /usr/local/bin/flint multitest "$@" WRAPPER if [[ ! -f /usr/local/bin/multitest ]] || ! cmp -s "$mt_tmp" /usr/local/bin/multitest; then install -o root -g root -m 0755 "$mt_tmp" /usr/local/bin/multitest || { rm -f "$mt_tmp"; return 1; } fi rm -f "$mt_tmp" 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.sh и не через интернет), пробуем # им зарегистрировать свой bridge-ключ на хабе автоматически, без ручной # правки authorized_keys. Отсутствие файла — не ошибка, просто пропускаем. _bridge_try_enroll(){ local ssh_key="$1" hub_host="$2" local enroll_key="/root/.flint-bridge-enroll" [[ -f "$enroll_key" ]] || return 1 [[ -f "${ssh_key}.pub" ]] || return 1 local out out="$(cat "${ssh_key}.pub" \ | timeout 8 ssh -i "$enroll_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${hub_host}" 2>/dev/null || true)" [[ "$out" == *OK:* ]] } # Список уже настроенных на этой ноде мостов (по *.conf в /etc/wireguard) — # чтобы не заставлять помнить имя, данное в mode_bridge_setup. Одно найдено — # берёт его сразу; несколько — печатает нумерованный список на выбор; ноль — # понятная ошибка. Имя отдаёт через stdout, ошибка/отмена — код возврата 1. # Определяет, не является ли САМА эта нода одним из известных хабов моста — # без сетевых запросов: сверяет публичный ключ каждого локального # WireGuard-интерфейса с публичным ключом хабов из BRIDGE_HUBS. Совпадение # означает, что это и есть сервер-хаб (см. Updates про путаницу sleepy-galileo/es5: # на нём нашли клиентский пункт меню «Мой мост» вместо серверного назначения). _detect_own_hub(){ need wg || return 1 local -a ifaces mapfile -t ifaces < <(wg show interfaces 2>/dev/null) [[ ${#ifaces[@]} -eq 0 ]] && return 1 local iface pub hub_name hub_pub for iface in "${ifaces[@]}"; do pub="$(wg show "$iface" public-key 2>/dev/null)" [[ -z "$pub" || "$pub" == "(none)" ]] && continue for hub_name in "${!BRIDGE_HUBS[@]}"; do IFS='|' read -r _ hub_pub _ <<<"${BRIDGE_HUBS[$hub_name]}" if [[ "$pub" == "$hub_pub" ]]; then printf '%s' "$hub_name" return 0 fi done done return 1 } _bridge_pick_name(){ local -a NAMES=() local f for f in /etc/wireguard/*.conf; do [[ -e "$f" ]] || continue NAMES+=("$(basename "$f" .conf)") done if [[ ${#NAMES[@]} -eq 0 ]]; then msg "$(c_red '[!] На этой ноде не настроено ни одного моста — сначала пункт 1 «Настроить мост на свой сервер».')" return 1 fi if [[ ${#NAMES[@]} -eq 1 ]]; then msg "$(c_cyn "[*] Мост: ${NAMES[0]}")" printf '%s' "${NAMES[0]}" return 0 fi msg "$(c_cyn 'Настроенные мосты на этой ноде:')" local i for i in "${!NAMES[@]}"; do msg " $((i+1)). ${NAMES[$i]}"; done printf '%s' "$(c_yel '[?] Номер моста: ')" >&2 local sel; read -r sel < /dev/tty 2>/dev/null || return 1 if ! [[ "$sel" =~ ^[0-9]+$ ]]; then msg "$(c_red '[!] Не число.')"; return 1; fi local idx=$((sel-1)) if [[ $idx -lt 0 || $idx -ge ${#NAMES[@]} ]]; then msg "$(c_red '[!] Нет такого номера.')"; return 1 fi printf '%s' "${NAMES[$idx]}" } # =========================== РЕЖИМ: СВОЙ МОСТ ============================= # Клиентская часть моста на свой сервер (альтернатива Cloudflare WARP). # Скрипт публичный — на хаб по SSH не ходит и credentials не хранит: # только печатает готовый блок [Peer], владелец сам вставляет его на хабе. mode_bridge_setup(){ na_require_root || return 1 msg "" msg "$(c_cyn '──── Настройка моста на свой сервер ────')" local NAME HUB_ENDPOINT HUB_PUBKEY HUB_PREFIX local -a HKEYS=() [[ ${#BRIDGE_HUBS[@]} -gt 0 ]] && mapfile -t HKEYS < <(printf '%s\n' "${!BRIDGE_HUBS[@]}" | sort) local hchoice=0 if [[ ${#HKEYS[@]} -gt 0 ]]; then msg "$(c_cyn 'Известные хабы:')" local i for i in "${!HKEYS[@]}"; do msg " $((i+1)). ${HKEYS[$i]}"; done msg " 0. Другой (новый хаб, ввести вручную)" printf '%s' "$(c_yel "[?] Выбор (0-${#HKEYS[@]}): ")" >&2 read -r hchoice < /dev/tty 2>/dev/null || return 1 fi if [[ "$hchoice" =~ ^[0-9]+$ ]] && [[ "$hchoice" -ge 1 ]] && [[ "$hchoice" -le ${#HKEYS[@]} ]]; then NAME="${HKEYS[$((hchoice-1))]}" IFS='|' read -r HUB_ENDPOINT HUB_PUBKEY HUB_PREFIX <<<"${BRIDGE_HUBS[$NAME]}" msg "$(c_grn "[✓] Хаб: ${NAME} (${HUB_ENDPOINT})")" else msg "$(c_yel 'Нужен уже готовый сервер-хаб с поднятым WireGuard (NAT+forward на нём настраиваются отдельно, не этим скриптом).')" printf '%s' "$(c_yel '[?] Имя моста (латиницей, без пробелов, напр. germany): ')" >&2 read -r NAME < /dev/tty 2>/dev/null || return 1 NAME="$(tr -cd 'a-zA-Z0-9_' <<<"$NAME")" if [[ -z "$NAME" ]]; then msg "$(c_red '[!] Пустое имя.')"; return 1; fi printf '%s' "$(c_yel '[?] Адрес хаба host:port (напр. 185.194.177.212:51888): ')" >&2 read -r HUB_ENDPOINT < /dev/tty 2>/dev/null || return 1 printf '%s' "$(c_yel '[?] Публичный ключ хаба (WireGuard PublicKey): ')" >&2 read -r HUB_PUBKEY < /dev/tty 2>/dev/null || return 1 printf '%s' "$(c_yel '[?] Подсеть туннеля хаба без последней цифры, напр. 10.90.90: ')" >&2 read -r HUB_PREFIX < /dev/tty 2>/dev/null || return 1 if [[ -z "$HUB_ENDPOINT" || -z "$HUB_PUBKEY" || -z "$HUB_PREFIX" ]]; then msg "$(c_red '[!] Не все поля заполнены.')"; return 1 fi fi if [[ -f "/etc/wireguard/${NAME}.conf" ]]; then msg "$(c_red "[!] /etc/wireguard/${NAME}.conf уже существует — туннель под этим именем уже настроен на этой ноде.")" return 1 fi # свой IP в туннеле генерируется автоматически в подсети хаба; # уникальность на хабе проверяется в момент ручного добавления [Peer] (см. вывод в конце) local LOCAL_IP="${HUB_PREFIX}.$(( (RANDOM % 253) + 2 ))/32" if ! need wg; then msg "$(c_cyn '[*] Ставлю wireguard-tools...')" DEBIAN_FRONTEND=noninteractive apt-get update -qq if ! DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard-tools >/dev/null; then msg "$(c_red '[!] Не удалось установить wireguard-tools.')"; return 1 fi fi # своя таблица маршрутизации — не пересекается с WARP (51820) и основным default route local TABLE=51820 while [[ "$TABLE" == "51820" ]] || ip route show table "$TABLE" 2>/dev/null | grep -q .; do TABLE=$(( (RANDOM % 5000) + 51821 )) done mkdir -p /etc/wireguard umask 077 local PRIV PUB PRIV="$(wg genkey)" PUB="$(wg pubkey <<<"$PRIV")" cat > "/etc/wireguard/${NAME}.conf" </dev/null || true PostUp = iptables -t mangle -A POSTROUTING -o ${NAME} -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240 || true PostDown = iptables -t mangle -D POSTROUTING -o ${NAME} -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240 2>/dev/null || true [Peer] PublicKey = ${HUB_PUBKEY} Endpoint = ${HUB_ENDPOINT} AllowedIPs = 0.0.0.0/0 PersistentKeepalive = 25 EOF chmod 600 "/etc/wireguard/${NAME}.conf" # автоматизация (вариант 2, согласован с владельцем): свой одноразовый SSH-ключ # только для добавления пира на хабе, на хабе он ограничен forced-command'ом # (/usr/local/bin/bridge-dispatch.sh) — шелла, чтения файлов или чего-то ещё # через него получить нельзя, даже если эту ноду скомпрометируют. local HUB_HOST="${HUB_ENDPOINT%%:*}" local SSH_KEY="/etc/wireguard/${NAME}_hubssh" local AUTO_ADDED=false SSH_OUT="" if need ssh-keygen && need ssh; then if [[ ! -f "$SSH_KEY" ]]; then ssh-keygen -t ed25519 -N "" -f "$SSH_KEY" -q -C "flint-bridge-${NAME}" || true fi if [[ -f "$SSH_KEY" ]]; then chmod 600 "$SSH_KEY" if _bridge_try_enroll "$SSH_KEY" "$HUB_HOST"; then msg "$(c_grn '[✓] Ключ автоматически зарегистрирован на хабе (найден файл регистрации).')" fi SSH_OUT="$(printf '%s\n%s\n' "$PUB" "$LOCAL_IP" \ | timeout 8 ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${HUB_HOST}" 2>/dev/null || true)" [[ "$SSH_OUT" == *OK:* ]] && AUTO_ADDED=true fi fi systemctl enable --now "wg-quick@${NAME}" >/dev/null 2>&1 sleep 2 if ! systemctl is-active --quiet "wg-quick@${NAME}"; then msg "$(c_red "[!] Туннель не поднялся — проверь: systemctl status wg-quick@${NAME}")" return 1 fi local WG_DUMP HAS_HS=false WG_DUMP="$(wg show "$NAME" 2>/dev/null)" grep -q 'latest handshake' <<<"$WG_DUMP" && HAS_HS=true if ! $HAS_HS; then msg "$(c_yel '[!] Туннель поднят, но хендшейка ещё нет — проверь порт/ключ хаба и что там уже добавлен блок [Peer] ниже.')" else msg "$(c_grn '[✓] Хендшейк с хабом установлен.')" local TRACE TRACE="$(curl -4 -s --max-time 10 --interface "$NAME" "http://ip-api.com/json?fields=status,country,city,isp,org,query" 2>/dev/null)" [[ -n "$TRACE" ]] && msg "$(c_cyn ' Выход через мост:') $(c_grn "$TRACE")" fi msg "" if $AUTO_ADDED; then msg "$(c_grn "[✓] Пир добавлен на хабе автоматически (${SSH_OUT}).")" else msg "$(c_yel '[!] Автоматически добавить пира не вышло (SSH-ключ этой ноды ещё не авторизован на хабе, либо хаб недоступен).')" msg "" msg "$(c_cyn 'Вариант А — разово вставить вручную в конфиг хаба:')" msg "" msg "[Peer]" msg "PublicKey = ${PUB}" msg "AllowedIPs = ${LOCAL_IP%/*}/32" msg "" msg "$(c_cyn 'Вариант Б (рекомендуется) — один раз добавить на хабе в /root/.ssh/authorized_keys эту строку,')" msg "$(c_cyn 'дальше эта нода сможет сама добавлять/обновлять себя на этом хабе (и только это, ничего больше):')" msg "" msg "command=\"/usr/local/bin/bridge-dispatch.sh ${HUB_PREFIX}\",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding $(cat "${SSH_KEY}.pub" 2>/dev/null)" msg "" msg "$(c_cyn 'Или положи на эту ноду файл /root/.flint-bridge-enroll (регистрационный ключ хаба) и выполни:') $(c_grn "flint bridge-sync ${NAME}")" msg "$(c_cyn '— зарегистрируется сама, без ручной правки на хабе.')" fi msg "" msg "$(c_yel "Имя моста для пункта 3 «Прописать outbound» в панели: ${NAME}")" } mode_bridge_sync(){ msg "" msg "$(c_cyn '──── Досинхронизировать мост с хабом ────')" local NAME if [[ -n "${1:-}" ]]; then NAME="$1" else NAME="$(_bridge_pick_name)" || return 1 fi NAME="$(tr -cd 'a-zA-Z0-9_' <<<"$NAME")" local CONF="/etc/wireguard/${NAME}.conf" local SSH_KEY="/etc/wireguard/${NAME}_hubssh" if [[ ! -f "$CONF" ]]; then msg "$(c_red "[!] Мост '${NAME}' не настроен — сначала пункт 1 «Настроить мост на свой сервер».")"; return 1 fi if [[ ! -f "$SSH_KEY" ]]; then msg "$(c_cyn "[*] SSH-ключ автоматизации для '${NAME}' ещё не создан — создаю...")" if ! need ssh-keygen; then msg "$(c_red '[!] ssh-keygen не найден.')"; return 1 fi ssh-keygen -t ed25519 -N "" -f "$SSH_KEY" -q -C "flint-bridge-${NAME}" \ || { msg "$(c_red '[!] Не удалось создать SSH-ключ.')"; return 1; } chmod 600 "$SSH_KEY" fi local PRIV PUB LOCAL_IP HUB_HOST PRIV="$(sed -n 's/^PrivateKey = //p' "$CONF" | head -1)" LOCAL_IP="$(sed -n 's/^Address = //p' "$CONF" | head -1)" HUB_HOST="$(sed -n 's/^Endpoint = //p' "$CONF" | head -1)"; HUB_HOST="${HUB_HOST%%:*}" PUB="$(wg pubkey <<<"$PRIV" 2>/dev/null)" if [[ -z "$PUB" || -z "$LOCAL_IP" || -z "$HUB_HOST" ]]; then msg "$(c_red '[!] Не удалось прочитать параметры моста из конфига.')"; return 1 fi if _bridge_try_enroll "$SSH_KEY" "$HUB_HOST"; then msg "$(c_grn '[✓] Ключ автоматически зарегистрирован на хабе (найден файл регистрации).')" fi local SSH_OUT SSH_OUT="$(printf '%s\n%s\n' "$PUB" "$LOCAL_IP" \ | timeout 8 ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${HUB_HOST}" 2>&1 || true)" msg "$(c_cyn 'Ответ хаба:') ${SSH_OUT}" if [[ "$SSH_OUT" == *OK:* ]]; then msg "$(c_grn '[✓] Готово.')" systemctl restart "wg-quick@${NAME}" >/dev/null 2>&1 || true sleep 2 local line wg show "$NAME" 2>&1 | while IFS= read -r line; do msg " $line"; done else msg "$(c_red '[!] Не удалось — SSH-ключ этой ноды ещё не добавлен на хабе, либо хаб недоступен.')" local sync_prefix="${LOCAL_IP%.*}" msg "$(c_yel 'Открытый SSH-ключ этой ноды для добавления на хаб:')" msg "command=\"/usr/local/bin/bridge-dispatch.sh ${sync_prefix}\",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding $(cat "${SSH_KEY}.pub" 2>/dev/null)" msg "$(c_cyn 'Или положи на эту ноду файл /root/.flint-bridge-enroll и повтори эту же команду.')" fi } mode_bridge_route(){ msg "" msg "$(c_cyn '──── Блок маршрутизации для своего моста ────')" local NAME if [[ -n "${1:-}" ]]; then NAME="$1" else NAME="$(_bridge_pick_name)" || return 1 fi NAME="$(tr -cd 'a-zA-Z0-9_' <<<"$NAME")" if [[ -z "$NAME" ]]; then msg "$(c_red '[!] Пустое имя.')"; return 1; fi if ! need jq; then msg "$(c_red '[!] jq не найден. Установи: apt install -y jq')"; return 1; fi local -a KEYS mapfile -t KEYS < <(printf '%s\n' "${!SERVICE_DOMAINS[@]}" | sort) msg "" msg "$(c_cyn 'Доступные пресеты:')" local i for i in "${!KEYS[@]}"; do msg " $((i+1)). ${KEYS[$i]}"; done printf '%s' "$(c_yel '[?] Номера через запятую (напр. 1,3): ')" >&2 local sel; read -r sel < /dev/tty 2>/dev/null || return 1 declare -A CHOSEN=() local n idx d domains IFS=',' read -ra nums <<<"$sel" for n in "${nums[@]}"; do n="$(tr -d ' ' <<<"$n")" [[ "$n" =~ ^[0-9]+$ ]] || continue idx=$((n-1)) [[ $idx -ge 0 && $idx -lt ${#KEYS[@]} ]] || continue domains="${SERVICE_DOMAINS[${KEYS[$idx]}]}" IFS=',' read -ra arr <<<"$domains" for d in "${arr[@]}"; do [[ -n "$d" ]] && CHOSEN["$d"]=1; done done if [[ ${#CHOSEN[@]} -eq 0 ]]; then msg "$(c_red '[!] Ничего не выбрано.')"; return 1 fi msg "" msg "$(c_cyn '[*] Готовый блок для routing.rules (вставь ВЫШЕ дефолтного маршрута):')" msg "" local -a SORTED mapfile -t SORTED < <(printf '%s\n' "${!CHOSEN[@]}" | sort) printf '%s\n' "${SORTED[@]}" | jq -R . | jq -s --arg tag "${NAME}-out" '{type:"field", domain:., outboundTag:$tag}' msg "" msg "$(c_yel "Не забудь: outbound \"${NAME}-out\" со sockopt.interface = \"${NAME}\" должен быть в конфиге ноды в панели.")" msg "$(c_cyn 'Или, если на хабе для этой ноды настроена автоматизация панели — просто выполни:') $(c_grn "flint bridge-panel ${NAME}")" } # Через тот же ограниченный SSH-канал, что и добавление WireGuard-пира, но # отдельной операцией "outbound" — хаб сам определяет, какой профиль панели # RemnaWave править, по РЕАЛЬНОМУ IP подключения (сверяет с serverNames всех # профилей) — нода этот выбор не передаёт и не может подменить. Токен панели # на ноде никогда не появляется. mode_bridge_panel(){ msg "" msg "$(c_cyn '──── Прописать outbound и маршрутизацию в панели ────')" local NAME if [[ -n "${1:-}" ]]; then NAME="$1" else NAME="$(_bridge_pick_name)" || return 1 fi NAME="$(tr -cd 'a-zA-Z0-9_' <<<"$NAME")" local CONF="/etc/wireguard/${NAME}.conf" local SSH_KEY="/etc/wireguard/${NAME}_hubssh" if [[ ! -f "$CONF" ]]; then msg "$(c_red "[!] Мост '${NAME}' не настроен — сначала пункт 1 «Настроить мост на свой сервер».")"; return 1 fi if [[ ! -f "$SSH_KEY" ]]; then msg "$(c_yel "[i] Ключ автоматизации для '${NAME}' ещё не настроен — это разовая настройка (пункт 2 «Досинхронизировать»).")" mode_bridge_sync "$NAME" if [[ ! -f "$SSH_KEY" ]]; then msg "$(c_red '[!] Ключ так и не появился — синхронизация с хабом не прошла, смотри сообщение выше.')" return 1 fi fi if ! need jq; then msg "$(c_red '[!] jq не найден. Установи: apt install -y jq')"; return 1; fi local -a KEYS mapfile -t KEYS < <(printf '%s\n' "${!SERVICE_DOMAINS[@]}" | sort) msg "" msg "$(c_cyn 'Доступные пресеты:')" local i for i in "${!KEYS[@]}"; do msg " $((i+1)). ${KEYS[$i]}"; done printf '%s' "$(c_yel '[?] Номера через запятую (напр. 1,3): ')" >&2 local sel; read -r sel < /dev/tty 2>/dev/null || return 1 declare -A CHOSEN=() local n idx d domains IFS=',' read -ra nums <<<"$sel" for n in "${nums[@]}"; do n="$(tr -d ' ' <<<"$n")" [[ "$n" =~ ^[0-9]+$ ]] || continue idx=$((n-1)) [[ $idx -ge 0 && $idx -lt ${#KEYS[@]} ]] || continue domains="${SERVICE_DOMAINS[${KEYS[$idx]}]}" IFS=',' read -ra arr <<<"$domains" for d in "${arr[@]}"; do [[ -n "$d" ]] && CHOSEN["$d"]=1; done done if [[ ${#CHOSEN[@]} -eq 0 ]]; then msg "$(c_red '[!] Ничего не выбрано.')"; return 1 fi local -a SORTED mapfile -t SORTED < <(printf '%s\n' "${!CHOSEN[@]}" | sort) local DOMAINS_JSON DOMAINS_JSON="$(printf '%s\n' "${SORTED[@]}" | jq -R . | jq -sc .)" local HUB_HOST HUB_HOST="$(sed -n 's/^Endpoint = //p' "$CONF" | head -1)"; HUB_HOST="${HUB_HOST%%:*}" if [[ -z "$HUB_HOST" ]]; then msg "$(c_red '[!] Не удалось прочитать адрес хаба из конфига.')"; return 1 fi local SSH_OUT SSH_OUT="$(printf 'outbound\n%s\n%s\n' "$NAME" "$DOMAINS_JSON" \ | timeout 15 ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${HUB_HOST}" 2>&1 || true)" msg "$(c_cyn 'Ответ хаба:') ${SSH_OUT}" if [[ "$SSH_OUT" == *OK:* ]]; then msg "$(c_grn '[✓] Готово — outbound и маршрут прописаны в конфиг-профиле панели этой ноды.')" else msg "$(c_red '[!] Не вышло. Либо реальный IP этой ноды не совпал ни с одним профилем в панели (проверь DNS), либо панель недоступна/отклонила запрос.')" fi } # Управление аутбаундами ЛЮБОГО config-профиля панели (не только своего) через # тот же ограниченный SSH-канал на хаб — осознанно шире, чем "outbound" пункта # 6. Ключ автоматизации ноды достаточен, чтобы попросить хаб отредактировать # аутбаунды чужого профиля тоже (см. Updates/20260816_001_bridge_mode.md, # Доработка 7 — заменила собой более раннюю версию для инбаундов, владелец # указал, что мост пишется именно в outbound). Токен панели на ноде никогда # не появляется. DIRECT/BLOCK защищены от удаления — и здесь, и на хабе. mode_bridge_outbounds(){ msg "" msg "$(c_cyn '──── Аутбаунды профилей панели (через хаб) ────')" local NAME if [[ -n "${1:-}" ]]; then NAME="$1" else NAME="$(_bridge_pick_name)" || return 1 fi NAME="$(tr -cd 'a-zA-Z0-9_' <<<"$NAME")" local CONF="/etc/wireguard/${NAME}.conf" local SSH_KEY="/etc/wireguard/${NAME}_hubssh" if [[ ! -f "$CONF" ]]; then msg "$(c_red "[!] Мост '${NAME}' не настроен — сначала пункт 1 «Настроить мост на свой сервер».")"; return 1 fi if [[ ! -f "$SSH_KEY" ]]; then msg "$(c_yel "[i] Ключ автоматизации для '${NAME}' ещё не настроен — это разовая настройка (пункт 2 «Досинхронизировать»).")" mode_bridge_sync "$NAME" if [[ ! -f "$SSH_KEY" ]]; then msg "$(c_red '[!] Ключ так и не появился — синхронизация с хабом не прошла, смотри сообщение выше.')" return 1 fi fi if ! need jq; then msg "$(c_red '[!] jq не найден. Установи: apt install -y jq')"; return 1; fi local HUB_HOST HUB_HOST="$(sed -n 's/^Endpoint = //p' "$CONF" | head -1)"; HUB_HOST="${HUB_HOST%%:*}" if [[ -z "$HUB_HOST" ]]; then msg "$(c_red '[!] Не удалось прочитать адрес хаба из конфига.')"; return 1 fi msg "$(c_cyn '[*] Запрашиваю список профилей панели у хаба...')" local PROFILES_OUT PROFILES_OUT="$(printf 'list-profiles\n' \ | timeout 15 ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${HUB_HOST}" 2>/dev/null || true)" if [[ "$PROFILES_OUT" == FAIL:* || -z "$PROFILES_OUT" ]]; then msg "$(c_red "[!] Хаб не отдал список профилей: ${PROFILES_OUT:-нет ответа}")"; return 1 fi local -a P_UUID=() P_NAME=() P_COUNT=() local u n c i line while IFS=$'\t' read -r u n c; do [[ -z "$u" ]] && continue P_UUID+=("$u"); P_NAME+=("$n"); P_COUNT+=("$c") done <<<"$PROFILES_OUT" if [[ ${#P_UUID[@]} -eq 0 ]]; then msg "$(c_red '[!] Профили не найдены.')"; return 1 fi msg "" msg "$(c_cyn 'Профили панели:')" for i in "${!P_UUID[@]}"; do msg " $((i+1)). ${P_NAME[$i]} (аутбаундов: ${P_COUNT[$i]})" done printf '%s' "$(c_yel '[?] Номер профиля: ')" >&2 local psel; read -r psel < /dev/tty 2>/dev/null || return 1 if ! [[ "$psel" =~ ^[0-9]+$ ]]; then msg "$(c_red '[!] Не число.')"; return 1; fi local pidx=$((psel-1)) if [[ $pidx -lt 0 || $pidx -ge ${#P_UUID[@]} ]]; then msg "$(c_red '[!] Нет такого номера.')"; return 1 fi local PROFILE_UUID="${P_UUID[$pidx]}" PROFILE_NAME="${P_NAME[$pidx]}" msg "" msg "$(c_cyn "[*] Аутбаунды профиля '${PROFILE_NAME}':")" local OB_OUT OB_OUT="$(printf 'list-outbounds\n%s\n' "$PROFILE_UUID" \ | timeout 15 ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${HUB_HOST}" 2>/dev/null || true)" if [[ "$OB_OUT" == FAIL:* || -z "$OB_OUT" ]]; then msg "$(c_red "[!] Хаб не отдал аутбаунды: ${OB_OUT:-нет ответа}")"; return 1 fi local -a OB_TAG=() OB_PROTO=() OB_IFACE=() OB_DCOUNT=() local t pr ifc dc while IFS=$'\t' read -r t pr ifc dc; do [[ -z "$t" ]] && continue OB_TAG+=("$t"); OB_PROTO+=("$pr"); OB_IFACE+=("$ifc"); OB_DCOUNT+=("$dc") done <<<"$OB_OUT" for i in "${!OB_TAG[@]}"; do msg " $((i+1)). ${OB_TAG[$i]} ${OB_PROTO[$i]} интерфейс=${OB_IFACE[$i]} доменов_в_правиле=${OB_DCOUNT[$i]}" done msg "" msg " 1. Добавить outbound + routing-правило (пресет доменов)" msg " 2. Удалить outbound (и его routing-правило)" msg " 0. Выход" printf '%s' "$(c_yel '[?] Выбор (0-2): ')" >&2 local action; read -r action < /dev/tty 2>/dev/null || return 1 case "$action" in 1) local TAG IFACE printf '%s' "$(c_yel '[?] Тег нового outbound (латиница/цифры/_-): ')" >&2 read -r TAG < /dev/tty 2>/dev/null || return 1 TAG="$(tr -cd 'a-zA-Z0-9_-' <<<"$TAG")" if [[ -z "$TAG" ]]; then msg "$(c_red '[!] Пустой тег.')"; return 1; fi if [[ "${TAG,,}" == "direct" || "${TAG,,}" == "block" ]]; then msg "$(c_red '[!] DIRECT/BLOCK — зарезервированные системные теги, нельзя.')"; return 1 fi printf '%s' "$(c_yel '[?] Имя сетевого интерфейса, куда должен уйти трафик (напр. warp, germanyrelay): ')" >&2 read -r IFACE < /dev/tty 2>/dev/null || return 1 IFACE="$(tr -cd 'a-zA-Z0-9_.-' <<<"$IFACE")" if [[ -z "$IFACE" ]]; then msg "$(c_red '[!] Пустой интерфейс.')"; return 1; fi msg "$(c_yel 'Внимание: если профиль принадлежит ДРУГОЙ ноде — интерфейс должен существовать именно там, отсюда это не проверить.')" local -a KEYS mapfile -t KEYS < <(printf '%s\n' "${!SERVICE_DOMAINS[@]}" | sort) msg "" msg "$(c_cyn 'Доступные пресеты доменов:')" for i in "${!KEYS[@]}"; do msg " $((i+1)). ${KEYS[$i]}"; done printf '%s' "$(c_yel '[?] Номера через запятую (напр. 1,3): ')" >&2 local sel; read -r sel < /dev/tty 2>/dev/null || return 1 declare -A CHOSEN=() local nn idx dd domains IFS=',' read -ra nums <<<"$sel" for nn in "${nums[@]}"; do nn="$(tr -d ' ' <<<"$nn")" [[ "$nn" =~ ^[0-9]+$ ]] || continue idx=$((nn-1)) [[ $idx -ge 0 && $idx -lt ${#KEYS[@]} ]] || continue domains="${SERVICE_DOMAINS[${KEYS[$idx]}]}" IFS=',' read -ra arr_d <<<"$domains" for dd in "${arr_d[@]}"; do [[ -n "$dd" ]] && CHOSEN["$dd"]=1; done done if [[ ${#CHOSEN[@]} -eq 0 ]]; then msg "$(c_red '[!] Ничего не выбрано.')"; return 1 fi local -a SORTED mapfile -t SORTED < <(printf '%s\n' "${!CHOSEN[@]}" | sort) local DOMAINS_JSON DOMAINS_JSON="$(printf '%s\n' "${SORTED[@]}" | jq -R . | jq -sc .)" msg "" msg "$(c_cyn "Итоговый outbound: tag=${TAG}, interface=${IFACE}, доменов в правиле: ${#SORTED[@]}")" printf '%s' "$(c_yel '[?] Отправить на хаб и записать в панель? (y/N): ')" >&2 local conf; read -r conf < /dev/tty 2>/dev/null || return 1 if ! [[ "$conf" =~ ^[yYдД]$ ]]; then msg "$(c_cyn 'Отменено.')"; return 0; fi local ADD_OUT ADD_OUT="$(printf 'add-outbound\n%s\n%s\n%s\n%s\n' "$PROFILE_UUID" "$TAG" "$IFACE" "$DOMAINS_JSON" \ | timeout 15 ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${HUB_HOST}" 2>&1 || true)" msg "$(c_cyn 'Ответ хаба:') ${ADD_OUT}" if [[ "$ADD_OUT" == *OK:* ]]; then msg "$(c_grn '[✓] Outbound и routing-правило добавлены в конфиг-профиль панели.')" else msg "$(c_red '[!] Не вышло.')" fi ;; 2) if [[ ${#OB_TAG[@]} -eq 0 ]]; then msg "$(c_red '[!] В профиле нет аутбаундов.')"; return 1 fi printf '%s' "$(c_yel '[?] Введи ТОЧНЫЙ тег outbound для удаления: ')" >&2 local DEL_TAG; read -r DEL_TAG < /dev/tty 2>/dev/null || return 1 local found=false tt for tt in "${OB_TAG[@]}"; do [[ "$tt" == "$DEL_TAG" ]] && found=true; done if ! $found; then msg "$(c_red "[!] Такого тега нет в списке выше: '${DEL_TAG}'.")"; return 1 fi if [[ "${DEL_TAG,,}" == "direct" || "${DEL_TAG,,}" == "block" ]]; then msg "$(c_red '[!] DIRECT/BLOCK — защищённые системные outbound, хаб откажет.')"; return 1 fi msg "$(c_yel '[!] Вместе с outbound удалится и его routing-правило — трафик, который шёл через него, начнёт идти по умолчанию (обычно DIRECT).')" printf '%s' "$(c_yel "[?] Точно удалить '${DEL_TAG}' из профиля '${PROFILE_NAME}'? (y/N): ")" >&2 local conf2; read -r conf2 < /dev/tty 2>/dev/null || return 1 if ! [[ "$conf2" =~ ^[yYдД]$ ]]; then msg "$(c_cyn 'Отменено.')"; return 0; fi local DEL_OUT DEL_OUT="$(printf 'remove-outbound\n%s\n%s\n' "$PROFILE_UUID" "$DEL_TAG" \ | timeout 15 ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=6 -o BatchMode=yes "root@${HUB_HOST}" 2>&1 || true)" msg "$(c_cyn 'Ответ хаба:') ${DEL_OUT}" if [[ "$DEL_OUT" == *OK:* ]]; then msg "$(c_grn '[✓] Outbound и его routing-правило удалены из конфиг-профиля панели.')" else msg "$(c_red '[!] Не вышло.')" fi ;; 0|"") return 0 ;; *) msg "$(c_red 'Неверный выбор.')" ;; esac } mode_warp_menu(){ while :; do msg "" msg "$(c_cyn '──── Мой мост ────')" # Порядок — по рабочему пути (настроить → досинхронизировать → outbound # в панели → аутбаунды). Ручной блок маршрутизации (старый способ до # автоматизации через хаб) и проверки WARP (сам WARP снят со всех нод) # из видимого меню убраны — остались доступны через CLI: # flint bridge-route <имя> / flint warp-test / flint route. msg " 1. Настроить мост на свой сервер" msg " └ создаст WireGuard-туннель этой ноды до хаба (germany/estonia/finland)" msg " 2. Досинхронизировать мост с хабом" msg " └ повторить регистрацию ключа, если пункт 1 не смог сам достучаться до хаба" msg " 3. Прописать outbound и маршрут в панели (через хаб)" msg " └ включить мост как выходной канал для этой ноды в RemnaWave-панели" msg " 4. Аутбаунды профилей панели" msg " └ посмотреть/добавить/удалить выходные каналы в любом профиле панели (через хаб)" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-4): ')" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 case "$choice" in 1) mode_bridge_setup; return $? ;; 2) mode_bridge_sync; return $? ;; 3) mode_bridge_panel; return $? ;; 4) mode_bridge_outbounds; return $? ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac done } # =========================== КОМАНДА flint / ОБНОВЛЕНИЕ ====================== install_flint_command(){ local target="/usr/local/bin/flint" tmp dl_ok=1 [[ -f "$target" ]] && grep -q 'FLINT_SCRIPT_MARKER=1' "$target" 2>/dev/null && return 0 [[ -s "$target" ]] && return 1 tmp=$(mktemp) || return 1 if need curl; then curl -fsSL --proto '=https' --proto-redir '=https' --max-time 30 "$FLINT_SCRIPT_URL" -o "$tmp" && dl_ok=0 elif need wget; then wget -qO "$tmp" --https-only --timeout=30 "$FLINT_SCRIPT_URL" && dl_ok=0 fi if [[ $dl_ok -ne 0 ]] \ || ! 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 [[ -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}" if 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 mtproxymax >/dev/null 2>&1 && { any=true msg "$(c_yel '⚡️ Быстрый запуск MTProxyMax:') $(c_grn 'mtproxymax')"; } 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 "" } # =========================== MULTITEST FLINT =============================== # Меню и список тестов — часть Flint. Сами тестовые движки # (YABS, censorcheck и др.) остаются внешними и загружаются по HTTPS. mt_pkg_install(){ local pkg="$1" msg "$(c_cyn "[*] Устанавливаю $pkg...")" if need apt-get; then DEBIAN_FRONTEND=noninteractive apt-get update -qq \ && DEBIAN_FRONTEND=noninteractive apt-get install -y "$pkg" elif need dnf; then dnf install -y "$pkg" elif need yum; then yum install -y "$pkg" elif need apk; then apk add "$pkg" elif need pacman; then pacman -S --noconfirm "$pkg" else msg "$(c_red "[!] Не нашёл пакетный менеджер. Установи $pkg вручную.")" return 1 fi } mt_need(){ local cmd="$1" pkg="${2:-$1}" need "$cmd" && return 0 mt_pkg_install "$pkg" && need "$cmd" } mt_remote(){ local title="$1" url="$2" tmp rc shift 2 msg "" msg "$(c_cyn "──── $title ────")" mt_need curl || return 1 tmp="$(mktemp)" || return 1 if ! curl -fsSL --proto '=https' --proto-redir '=https' --max-time 60 "$url" -o "$tmp"; then msg "$(c_red '[!] Не удалось загрузить тест.')" rm -f "$tmp" return 1 fi bash "$tmp" "$@"; rc=$? rm -f "$tmp" return "$rc" } mt_geocheck(){ mt_remote "geocheck — полная проверка IP (гео, репутация, доступность сервисов)" \ "https://geocheck.ing" --runtime binary } mt_ip_region(){ mt_remote "IP Region" "$IPREGION_URL" } mt_censor_full(){ mt_remote "Censorcheck — полная проверка" \ "https://raw.githubusercontent.com/Nokola-Tesla/censorcheck/main/censorcheck.sh" } mt_censor_geoblock(){ mt_remote "Censorcheck — геоблок" \ "https://raw.githubusercontent.com/vernette/censorcheck/master/censorcheck.sh" --mode geoblock } mt_censor_dpi(){ mt_remote "Censorcheck — DPI" \ "https://raw.githubusercontent.com/vernette/censorcheck/master/censorcheck.sh" --mode dpi } mt_iperf_ru(){ mt_need iperf3 || return 1 mt_remote "iPerf3 — российские серверы" \ "https://raw.githubusercontent.com/itdoginfo/russian-iperf3-servers/main/speedtest.sh" } mt_iperf_tlab(){ mt_need iperf3 || return 1 mt_remote "iPerf3 — bench.tlab.pw" "https://bench.tlab.pw" } mt_yabs(){ mt_remote "YABS — бенчмарк сервера" "https://yabs.sh" -4 } mt_ip_check(){ mt_remote "IP Check Place — блокировки сервисами" "https://ip.check.place" -l en } mt_bench(){ mt_remote "bench.sh — параметры и скорость" "https://bench.sh" } mt_ip_quality(){ mt_remote "IPQuality" "https://check.place" -EI } mt_sysbench(){ msg ""; msg "$(c_cyn '──── sysbench CPU ────')" mt_need sysbench || return 1 sysbench cpu run --threads=1 } declare -a MT_FUNCS=( mt_geocheck mt_ip_region mt_censor_full mt_censor_geoblock mt_censor_dpi mt_iperf_ru mt_iperf_tlab mt_yabs mt_ip_check mt_bench mt_ip_quality mt_sysbench ) declare -a MT_LABELS=( "geocheck — полная проверка IP" "IP Region" "Censorcheck — полная проверка" "Censorcheck — геоблок" "Censorcheck — DPI" "iPerf3 — российские серверы" "iPerf3 — bench.tlab.pw" "YABS — бенчмарк сервера" "IP Check Place — блокировки сервисами" "bench.sh — параметры и скорость" "IPQuality" "sysbench CPU" ) mt_run_all(){ local i rc=0 total="${#MT_FUNCS[@]}" for i in "${!MT_FUNCS[@]}"; do msg ""; msg "$(c_cyn "[$((i+1))/$total] ${MT_LABELS[$i]}")" "${MT_FUNCS[$i]}" || rc=1 done msg "" [[ $rc -eq 0 ]] \ && msg "$(c_grn '[✓] Все тесты завершены.')" \ || msg "$(c_yel '[!] Тесты завершены, но часть из них вернула ошибку.')" return "$rc" } mt_pause(){ msg "" printf '%s' "$(c_yel '[i] Нажми любую клавишу для возврата в меню Multitest...')" >&2 read -r -s -n 1 < /dev/tty 2>/dev/null || true msg "" } mode_multitest(){ while :; do msg "" msg "$(c_cyn "══════════ Flint Multitest v${SCRIPT_VERSION} ══════════")" local i total run_all total="${#MT_LABELS[@]}" run_all=$((total + 1)) for i in "${!MT_LABELS[@]}"; do msg " $((i+1)). ${MT_LABELS[$i]}"; done msg " $run_all. Запустить все тесты" msg " 0. Выход" msg "$(c_cyn '══════════════════════════════════════════')" printf '%s' "$(c_yel "[?] Выбор (0-$run_all): ")" >&2 local choice; read -r choice < /dev/tty 2>/dev/null || return 1 if [[ "$choice" == 0 ]]; then return 0 elif [[ "$choice" == "$run_all" ]]; then mt_run_all; mt_pause elif [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= total )); then "${MT_FUNCS[$((choice-1))]}"; mt_pause else msg "$(c_red 'Неверный выбор, повтори.')" fi done } # Полностью 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 '──── Проверка сервера ────')" 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_warp_menu [2]=mode_install_tools [3]=mode_na_hardening [4]=fix_and_update [5]=mode_remnanode_update [6]=mode_xray_update [7]=mode_audit ) declare -A MENU_LABELS=( [1]="Мой мост" [2]="Инструменты" [3]="Оптимизация и защита (ядро, сеть, firewall)" [4]="Обновление системы" [5]="Remnawave Node" [6]="Xray-Core" [7]="Проверка сервера" ) MENU_ORDER=(1 2 3 4 5 6 7) show_menu(){ msg "" msg "$(c_cyn "═══════════ Flint Script v${SCRIPT_VERSION} ═══════════")" local own_hub; own_hub="$(_detect_own_hub 2>/dev/null)" || own_hub="" [[ -n "$own_hub" ]] && msg " $(c_yel 'Роль:') $(c_grn "эта нода — ХАБ моста «${own_hub}»") $(c_yel '(раздел «Мой мост» — для клиентских нод, не для хаба)')" 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 case "$choice" in warp) mode_warp_menu; return $? ;; warp-test) mode_test_warp; return $? ;; route) mode_analyze; return $? ;; bridge) mode_warp_menu; return $? ;; bridge-setup) mode_bridge_setup; return $? ;; bridge-route) mode_bridge_route "${2:-}"; return $? ;; bridge-sync) mode_bridge_sync "${2:-}"; return $? ;; bridge-panel) mode_bridge_panel "${2:-}"; return $? ;; bridge-outbounds) mode_bridge_outbounds "${2:-}"; return $? ;; tools) mode_install_tools; return $? ;; protect) mode_na_hardening; return $? ;; update) fix_and_update; return $? ;; node) mode_remnanode_update; return $? ;; xray) mode_xray_update; return $? ;; multitest) mode_multitest; return $? ;; esac # если первый аргумент — номер пункта, запустить сразу, без меню 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; return $?; fi fi if [[ -n "$choice" && -n "${MENU_FUNCS[$choice]:-}" ]]; then "${MENU_FUNCS[$choice]}"; return $? fi # иначе показать меню и читать выбор с терминала while :; do show_menu printf '%s' "$(c_yel '[?] Выбор (0-7): ')" >&2 read -r choice < /dev/tty 2>/dev/null || { msg ""; msg "Нет терминала. Запусти: flint audit (или warp, tools, protect, update, node, xray)"; return 1; } if [[ "$choice" == "0" ]]; then show_hints; update_motd; msg "Выход."; return 0; fi if [[ -n "${MENU_FUNCS[$choice]:-}" ]]; then "${MENU_FUNCS[$choice]}" 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). Его мало держать только в дефолте: # найденный IP панели или старый protect.conf иначе полностью заменяют дефолт. # Поэтому na_ensure_whitelist добавляет этот адрес при каждом protect/all. NA_BOT_TRUSTED_IP="213.176.77.11" NA_WHITELIST_DEFAULT="${NA_WHITELIST_DEFAULT:-93.100.162.144,213.165.58.154,$NA_BOT_TRUSTED_IP}" # Сносить 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(){ local kern qdisc reb fw crowd saf secs fw_kind 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" fw_kind="$(na_detect_firewall)" case "$fw_kind" in na_filter) fw="$(c_grn 'Flint активен')" ;; ufw) fw="$(c_grn 'UFW активен')" ;; *) fw="$(c_red 'защиты нет')" ;; esac 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)" if [[ -n "$cur" ]]; then if [[ ",$cur," != *",$NA_BOT_TRUSTED_IP,"* ]]; then cur="$cur,$NA_BOT_TRUSTED_IP" msg "$(c_cyn "[*] IP бота $NA_BOT_TRUSTED_IP автоматически добавлен в WHITELIST.")" fi WHITELIST="$cur" return 0 fi 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 [[ ",$ans," != *",$NA_BOT_TRUSTED_IP,"* ]]; then ans="${ans:+$ans,}$NA_BOT_TRUSTED_IP" msg "$(c_cyn "[*] IP бота $NA_BOT_TRUSTED_IP автоматически добавлен в WHITELIST.")" 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 } # Если TCP_PORTS ещё нигде явно не заявлен (ни в harden.env владельца, ни в # уже применённом protect.conf) — это, по сути, первый apply защиты на этой # ноде. Слепой дефолт "80,443" в такой момент может закрыть что-то, что уже # реально работает (свой нестандартный порт вроде mtproxymax или Beszel- # мониторинга — см. es4, 2026-08-18). Смотрим, что фактически открыто ПРЯМО # СЕЙЧАС (той же логикой, что и статус-строка "Открыто:" — na_open_tcp_ports # по факту активного файрвола), и предлагаем взять это как TCP_PORTS вместо # дефолта. SSH_PORT и NODE_PORT сюда не включаем — у них своя отдельная # логика (рейт-лимит и whitelist-only), TCP_PORTS ими не управляет. na_ensure_tcp_ports(){ na_load_env grep -q '^TCP_PORTS=' "$WR_HARDEN_ENV" 2>/dev/null && return 0 local saved; saved="$(na_get "$NA_CONF_DIR/protect.conf" TCP_PORTS)" [[ -n "$saved" ]] && return 0 local ssh_port node_port fw detected p out="" ssh_port="${SSH_PORT:-22}" node_port="$(na_detect_node_port)"; node_port="${node_port%%,*}" fw="$(na_detect_firewall)" case "$fw" in ufw) detected="$(na_ufw_open_tcp_ports)" ;; na_filter) detected="$(na_filter_open_tcp_ports)" ;; *) detected="$(na_real_tcp_ports)" ;; esac [[ -n "$detected" ]] || return 0 for p in $(printf '%s' "$detected" | tr ',' '\n' | sort -un); do [[ "$p" == "$ssh_port" || "$p" == "$node_port" ]] && continue out="${out:+$out,}$p" done [[ -n "$out" ]] || return 0 msg "" msg "$(c_yel "[!] TCP_PORTS ещё не задан явно. Сейчас реально открыто (кроме SSH-порта $ssh_port и порта панели $node_port): $out")" printf '%s' "$(c_yel "[?] Использовать эти порты как TCP_PORTS вместо дефолта \"$TCP_PORTS\"? (Y/n): ")" >&2 local yn=""; read -r yn < /dev/tty 2>/dev/null if [[ -z "$yn" || "$yn" =~ ^[yYдД]$ ]]; then TCP_PORTS="$out" msg "$(c_grn "[✓] TCP_PORTS = $TCP_PORTS")" else msg "$(c_yel "[i] Оставляю дефолт TCP_PORTS = $TCP_PORTS")" fi # UFW умеет ограничивать отдельный порт конкретным IP — у na_filter такого # общего механизма нет (есть только глобальный WHITELIST на всё, либо # whitelist-only для NODE_PORT). Такие правила молча перенести нельзя — # честно показываем и оставляем решение владельцу. if [[ "$fw" == ufw ]]; then local restricted restricted="$(ufw status 2>/dev/null | awk -v np="$node_port" ' /ALLOW/ && !/Anywhere/ { split($1, a, "/"); port=a[1] if (port ~ /^[0-9]+$/ && port != np) print $0 }')" if [[ -n "$restricted" ]]; then msg "" msg "$(c_yel '[!] В UFW есть порты, ограниченные конкретным IP (не панель) — у na_filter нет прямого аналога:')" msg "$restricted" msg "$(c_yel ' После применения реши сам: добавить этот IP в общий "Доверенные IP" (откроет ему всё), либо оставить порт закрытым всем.')" fi fi } # Пост-проверка после 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 na_ensure_tcp_ports 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 systemctl start na-firewall.service 2>/dev/null || { msg "$(c_red '[!] Не удалось запустить na-firewall.service; safety rollback оставлен взведённым.')" return 1 } 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 # Parser whitelist предотвращает только новые решения. Старый ban остаётся # в LAPI/bouncer и режет доверенный IP раньше na_filter, пока не истечёт. if cscli decisions list --ip "$NA_BOT_TRUSTED_IP" -o raw 2>/dev/null \ | awk 'NR > 1 { found=1 } END { exit !found }'; then if cscli decisions delete --ip "$NA_BOT_TRUSTED_IP" >/dev/null 2>&1; then msg "$(c_grn "[✓] Старое CrowdSec-решение для доверенного IP $NA_BOT_TRUSTED_IP удалено.")" else msg "$(c_yel "[!] Не удалось удалить CrowdSec-решение для $NA_BOT_TRUSTED_IP.")" fi fi 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-порты, которые сервер слушает извне (не loopback) — то, что # фактически доступно снаружи прямо сейчас, независимо от того, каким # фаерволом управляется нода (na_filter/UFW/nftables/ничем). Источник истины — # `ss`, а не конфиг Flint (который до первого apply — просто шаблон). na_real_tcp_ports(){ ss -Htln 2>/dev/null | awk '{print $4}' \ | grep -Ev '^(127\.|\[::1\])' \ | sed -E 's/.*:([0-9]+)$/\1/' | sort -un | paste -sd, - } # UDP не проверяем весь диапазон: Xray-ядро на активной ноде держит десятки # эфемерных портов для обслуживания клиентов — это шум, а не то, чем # управляет админ. Проверяем точечно только известный порт(ы) WireGuard-моста # (ListenPort из локальных конфигов /etc/wireguard/*.conf, если мост на этой # ноде вообще настроен как слушающий). na_bridge_udp_ports(){ local f port ports="" for f in /etc/wireguard/*.conf; do [[ -e "$f" ]] || continue port=$(awk -F= '/^[[:space:]]*ListenPort[[:space:]]*=/{gsub(/[[:space:]]/,"",$2); print $2; exit}' "$f") [[ -n "$port" ]] || continue ports="${ports:+$ports,}$port" done [[ -z "$ports" ]] && return 0 printf '%s' "$ports" | tr ',' '\n' | sort -un | paste -sd, - } na_bridge_udp_status(){ local ports p out="" ports="$(na_bridge_udp_ports)" [[ -z "$ports" ]] && { printf '%s' 'мост не настроен на этой ноде'; return 0; } local -a plist; IFS=',' read -ra plist <<<"$ports" for p in "${plist[@]}"; do if ss -Huln 2>/dev/null | awk '{print $4}' | grep -qE ":${p}\$"; then out="${out:+$out, }UDP $p слушает" else out="${out:+$out, }UDP $p НЕ слушает" fi done printf '%s' "$out" } # true, если у порта в UFW есть правило ALLOW, ограниченное конкретным # источником (не "Anywhere") — например панель на 2222 только с одного IP. # Такие правила через быстрый toggle не трогаем: `ufw delete allow P/proto` # не совпадает с "allow from IP to any port P" и либо тихо ничего не сделает # (ложный "закрыл"), либо в другом раскладе добавит рядом уже неограниченное # правило и случайно откроет порт всем. na_ufw_port_restricted(){ local port="$1" proto="$2" ufw status 2>/dev/null | grep -E "^${port}(/${proto})?[[:space:]]+ALLOW" | grep -qv 'Anywhere' } na_ufw_apply_toggle(){ local port="$1" proto="$2" if ufw status 2>/dev/null | grep -qE "^${port}(/${proto})?[[:space:]]+ALLOW"; then ufw delete allow "${port}/${proto}" >/dev/null 2>&1 msg "$(c_yel "[↔] ${proto^^} $port был открыт → закрыл")" else ufw allow "${port}/${proto}" >/dev/null 2>&1 msg "$(c_grn "[↔] ${proto^^} $port был закрыт → открыл")" fi } # Какой firewall реально работает на этой ноде — на одних нодах ещё стоит # штатный UFW (Flint его не трогал), на других уже применена защита самого # Flint (nftables-таблица na_filter). Действие "открыть/закрыть порт" должно # менять именно ту систему, что реально фильтрует трафик, а не спрашивать # владельца, какая у него нода. na_detect_firewall(){ if nft list table inet na_filter >/dev/null 2>&1; then printf 'na_filter' elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then printf 'ufw' else printf 'none' fi } # TCP-порты, реально разрешённые в UFW правилом ALLOW ... Anywhere — то, что # действительно доступно снаружи, а не просто слушается локально (см. # na_real_tcp_ports). Ограниченные конкретным IP правила (не "Anywhere") # сюда не попадают — они и так не открыты всем. na_ufw_open_tcp_ports(){ ufw status 2>/dev/null | awk ' /ALLOW/ && /Anywhere/ { split($1, a, "/") proto = (a[2] == "" ? "tcp" : a[2]) if (proto == "tcp" && a[1] ~ /^[0-9]+$/) print a[1] } ' | sort -un | paste -sd, - } # TCP-порты, реально разрешённые ЖИВЫМ набором правил защиты Flint # (na_filter) прямо сейчас — сверка "заявлено в protect.conf" ПРОТИВ # "реально есть в применённом nft-наборе", чтобы не показать порт открытым, # если конфиг поменяли, но ещё не нажали apply. na_filter_open_tcp_ports(){ local ssh_port tcp_ports node_port declared live p out="" ssh_port="$(na_get "$NA_CONF_DIR/protect.conf" SSH_PORT)"; ssh_port="${ssh_port:-22}" tcp_ports="$(na_get "$NA_CONF_DIR/protect.conf" TCP_PORTS)" node_port="$(na_get "$NA_CONF_DIR/protect.conf" NODE_PORT)"; node_port="${node_port:-2222}" declared="${ssh_port},${tcp_ports},${node_port}" live="$(nft list table inet na_filter 2>/dev/null | grep -oE 'tcp dport [0-9]+' | awk '{print $3}' | sort -un)" [[ -z "$live" ]] && return 0 for p in $(printf '%s' "$declared" | tr ',' '\n' | sort -un); do [[ "$p" =~ ^[0-9]+$ ]] || continue grep -qx "$p" <<<"$live" && out="${out:+$out,}$p" done printf '%s' "$out" } # Единая точка: какие TCP-порты РЕАЛЬНО открыты снаружи прямо сейчас — по # факту той защиты, что реально работает на ноде (см. na_detect_firewall), # а не просто что слушает локальный процесс. na_open_tcp_ports(){ case "$(na_detect_firewall)" in ufw) na_ufw_open_tcp_ports ;; na_filter) na_filter_open_tcp_ports ;; *) na_real_tcp_ports ;; esac } # Единая точка входа "Открыть/закрыть порт" из меню «Оптимизация и защита». na_toggle_port_real(){ case "$(na_detect_firewall)" in na_filter) na_ports_change ;; ufw) na_ufw_toggle_port ;; *) msg "$(c_yel '[i] На этой ноде сейчас вообще нет активной защиты — все порты и так открыты.')" msg "$(c_yel ' Если нужна защита — пункт «Применить защиту Flint» в этом же меню.')" ;; esac } # Мгновенное open/close в UFW. Порт 22 (SSH) через эту кнопку не трогаем — # единственная страховка от случайной потери доступа (у UFW, в отличие от # na_filter, нет авто-отката). na_ufw_toggle_port(){ na_require_root || return 1 msg " Открыто: TCP $(na_ufw_open_tcp_ports) · $(na_bridge_udp_status)" printf '%s' "$(c_yel '[?] Порт (443 = TCP, 443/udp = UDP; можно через запятую): ')" >&2 local input; read -r input < /dev/tty 2>/dev/null input="${input// /}"; input="${input,,}" [[ -n "$input" ]] || { msg "$(c_yel 'Отменено.')"; return 0; } local -a items; local item port proto IFS=',' read -ra items <<<"$input" for item in "${items[@]}"; do if [[ ! "$item" =~ ^([0-9]{1,5})(/(tcp|udp))?$ ]]; then msg "$(c_red "[!] Некорректный порт: $item")" continue fi port="${BASH_REMATCH[1]}"; proto="${BASH_REMATCH[3]:-tcp}" if ! na_validate_ports "$port"; then msg "$(c_red "[!] Некорректный порт: $item")" continue fi port=$((10#$port)) if [[ "$port" -eq 22 ]]; then msg "$(c_red '[!] Порт 22 (SSH) через это меню не трогаем — риск потерять доступ.')" continue fi if na_ufw_port_restricted "$port" "$proto"; then msg "$(c_red "[!] ${proto^^} $port в UFW ограничен конкретным IP (не «Anywhere») — через это меню не трогаем, чтобы не сломать ограничение. Правь вручную: ufw status numbered")" continue fi na_ufw_apply_toggle "$port" "$proto" done } # Порт из списка закрывается, отсутствующий — открывается. Без суффикса — 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 " Заявлено Flint (применится при apply): TCP ${tcp:-(пусто)} · UDP ${udp:-(пусто)}" msg " $(c_cyn "Реально разрешено сейчас: TCP $(na_filter_open_tcp_ports) · $(na_bridge_udp_status)")" 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. Ко всем защищённым портам" msg " 2. Только к порту Remnawave Node" 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(){ 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; } if command -v na-report >/dev/null 2>&1; then na-report --ip "$ip" else msg "$(c_yel '[i] na-report ещё не установлен.')" fi msg "" msg "$(c_cyn '== CrowdSec ==')" if command -v cscli >/dev/null 2>&1; then cscli decisions list --ip "$ip" 2>&1 else msg "$(c_yel '[i] cscli не установлен.')" fi if [[ "$ip" != *:* ]] && command -v nft >/dev/null 2>&1; then if ! nft list set ip crowdsec crowdsec-blacklists-crowdsec >/dev/null 2>&1; then msg "$(c_yel '[i] CrowdSec nft-set недоступен.')" elif nft get element ip crowdsec crowdsec-blacklists-crowdsec "{ $ip }" >/dev/null 2>&1; then msg "$(c_red "[!] nft CrowdSec: $ip активно блокируется.")" else msg "$(c_grn "[✓] nft CrowdSec: $ip не заблокирован.")" fi fi } na_menu_whitelist(){ while :; do local wl wl="$(na_get "$NA_CONF_DIR/protect.conf" WHITELIST)" msg "" if [[ -z "$wl" && -n "$NA_WHITELIST_DEFAULT" ]]; then msg " Whitelist: (пусто) $(c_yel "[дефолт при apply: $NA_WHITELIST_DEFAULT]")" else msg " Whitelist: ${wl:-(пусто)}" fi msg " ─────────────────────────────────────" msg " 1. Показать активные подключения" msg " 2. Добавить доверенный IP" msg " 3. Удалить доверенный IP" msg " 4. Временно открыть доступ" msg " 5. Проверить IP" 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) 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_firewall_emergency_admit ;; 5) 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. Полная диагностика" msg " 3. Статус защиты" msg " 4. Отчёт об атаках за 24 часа" 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_header_lines ;; 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-fw-status >/dev/null 2>&1; then na-fw-status; else msg "$(c_yel '[i] Ещё не установлено.')"; fi ;; 4) if command -v na-report >/dev/null 2>&1; then na-report; else msg "$(c_yel '[i] Ещё не установлено.')"; fi ;; 5) 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. Ограничить память Remnawave Node" msg " 3. Убрать старый файрвол (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. Откатить защиту" msg " 2. Откатить оптимизацию" 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_enable_bbr_cake(){ na_require_root || return 1 local old_cc old_qdisc new_cc new_qdisc old_cc="$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)" old_qdisc="$(sysctl -n net.core.default_qdisc 2>/dev/null)" if [[ "$old_cc" == bbr && "$old_qdisc" == cake ]]; then msg "$(c_grn '[✓] BBR + Cake уже включены.')" return 0 fi modprobe tcp_bbr 2>/dev/null || { msg "$(c_red '[!] Ядро не поддерживает BBR.')" return 1 } modprobe sch_cake 2>/dev/null || { msg "$(c_red '[!] Ядро не поддерживает Cake.')" return 1 } na_snapshot cat > /etc/sysctl.d/99-zz-flint-bbr-cake.conf <<'EOF' net.core.default_qdisc=cake net.ipv4.tcp_congestion_control=bbr EOF if ! sysctl -p /etc/sysctl.d/99-zz-flint-bbr-cake.conf >/dev/null; then msg "$(c_red '[!] Не удалось применить BBR + Cake.')" return 1 fi new_cc="$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)" new_qdisc="$(sysctl -n net.core.default_qdisc 2>/dev/null)" if [[ "$new_cc" == bbr && "$new_qdisc" == cake ]]; then msg "$(c_grn "[✓] BBR + Cake включены (было: ${old_cc:-?} / ${old_qdisc:-?}).")" else msg "$(c_red "[!] Проверка не пройдена: ${new_cc:-?} / ${new_qdisc:-?}.")" return 1 fi } na_disable_ipv6(){ na_require_root || return 1 local current new current="$(sysctl -n net.ipv6.conf.all.disable_ipv6 2>/dev/null)" if [[ "$current" == 1 ]]; then msg "$(c_grn '[✓] IPv6 уже отключён.')" return 0 fi na_snapshot cat > /etc/sysctl.d/99-zz-flint-disable-ipv6.conf <<'EOF' net.ipv6.conf.all.disable_ipv6=1 net.ipv6.conf.default.disable_ipv6=1 net.ipv6.conf.lo.disable_ipv6=1 EOF if ! sysctl -p /etc/sysctl.d/99-zz-flint-disable-ipv6.conf >/dev/null; then msg "$(c_red '[!] Не удалось отключить IPv6.')" return 1 fi new="$(sysctl -n net.ipv6.conf.all.disable_ipv6 2>/dev/null)" if [[ "$new" == 1 ]]; then msg "$(c_grn '[✓] IPv6 отключён и останется отключённым после перезагрузки.')" else msg "$(c_red '[!] IPv6 остался включён.')" return 1 fi } na_menu_advanced(){ msg "" msg "$(c_cyn '──── Дополнительно ────')" msg " 1. Только оптимизация системы" msg " 2. Только защита сервера" msg " 3. Тонкие параметры" msg " 4. Обслуживание" msg " 5. Откат изменений" msg " 6. Включить BBR + Cake" msg " 7. Отключить IPv6" msg " 0. Назад" printf '%s' "$(c_yel '[?] Выбор (0-7): ')" >&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 ;; 6) na_enable_bbr_cake ;; 7) na_disable_ipv6 ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac } # --------------------------------------------------------------------------- # ПУНКТ МЕНЮ 4: «Оптимизация и защита ноды» # --------------------------------------------------------------------------- mode_na_hardening(){ na_require_root || return 1 while :; do local kern qdisc swapsz crowd bouncer safety=false fw_kind fw_label msg "" msg "$(c_cyn '──── Оптимизация и защита ────')" fw_kind="$(na_detect_firewall)" case "$fw_kind" in na_filter) fw_label="$(c_grn 'Flint (активна)')" ;; ufw) fw_label="$(c_grn 'UFW (активен)')" ;; *) fw_label="$(c_red 'НЕ настроена — всё открыто')" ;; esac msg " Защита: $fw_label" msg " Открыто: TCP $(na_open_tcp_ports) · $(na_bridge_udp_status)" 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:-нет}" 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 " CrowdSec $crowd · bouncer $bouncer" if na_safety_armed; then safety=true local secs; secs="$(na_safety_remaining_s 2>/dev/null)" msg " $(c_red "Авто-откат: ВЗВЕДЁН${secs:+, осталось ${secs}с}")" fi msg "$(c_cyn '─────────────────────────────────────────────')" # Номера пунктов 1-4 закреплены независимо от состояния safety — только # последний пункт «Подтвердить доступ» появляется/исчезает. msg " 1. Открыть/закрыть порт" msg " 2. Доверенные IP" if $safety; then msg " 3. Применить повторно (ядро, CrowdSec, блок-листы — со снимком и авто-откатом)" else msg " 3. Применить защиту Flint (ядро, CrowdSec, блок-листы — со снимком и авто-откатом)" fi msg " 4. Проверить состояние" msg " 5. Дополнительно" if $safety; then msg " 6. Подтвердить доступ и сохранить настройки" fi msg " 0. Назад" if $safety; then printf '%s' "$(c_yel '[?] Выбор (0-6): ')" >&2 else printf '%s' "$(c_yel '[?] Выбор (0-5): ')" >&2 fi local c; read -r c < /dev/tty 2>/dev/null || return 1 case "$c" in 1) na_toggle_port_real ;; 2) na_menu_whitelist ;; 3) na_apply all ;; 4) na_menu_status ;; 5) na_menu_advanced ;; 6) if $safety; then na_confirm; else msg "$(c_red 'Неверный выбор, повтори.')"; fi ;; 0) return 0 ;; *) msg "$(c_red 'Неверный выбор, повтори.')" ;; esac 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. Выбрать версию" msg " 2. Восстановить из бэкапа" 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. Выбрать версию" 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 "$@"