#!/bin/bash # ============================================================ # Yurich Panel v5.8.3 # Стек: Caddy 2 + klzgrad/forwardproxy@naive + Hysteria 2 + WARP + Xray Modern # ОС: Ubuntu 20.04 / 22.04 / 24.04 # # Copyright (C) 2026 Yurich Panel contributors # License: PolyForm Noncommercial 1.0.0 + Commercial License # Commercial use requires written permission from the author. # # GitHub: https://github.com/ivan-yurich/naiveproxy # ============================================================ set -euo pipefail VERSION="5.8.3" LANG_UI="${NAIVEPROXY_LANG:-ru}" # ru или en — export NAIVEPROXY_LANG=en GITHUB_RAW="https://raw.githubusercontent.com/ivan-yurich/naiveproxy/main/yurich-panel.sh" GITHUB_SHA256_RAW="https://raw.githubusercontent.com/ivan-yurich/naiveproxy/main/yurich-panel.sh.sha256" GITHUB_API="https://api.github.com/repos/ivan-yurich/naiveproxy/releases/latest" PROJECT_GITHUB_URL="https://github.com/ivan-yurich/naiveproxy" PROJECT_GITHUB_SHORT="github.com/ivan-yurich/naiveproxy" PROJECT_WEBSITE_URL="${YURICH_PROJECT_WEBSITE_URL:-$PROJECT_GITHUB_URL}" PROJECT_DONATION_URL="${YURICH_DONATION_URL:-https://dzen.ru/ivanyurievich?donate=true}" ANDROID_APP_RELEASES_URL="https://github.com/ivan-yurich/Yurich-Connect-Android/releases" WINDOWS_APP_RELEASES_URL="https://github.com/ivan-yurich/yurich-connect-windows/releases" STREISAND_APP_URL="https://apps.apple.com/us/app/streisand/id6450534064" KARING_APP_URL="https://apps.apple.com/us/app/karing/id6472431552?l=ru" TELEGRAM_COMMUNITY_URL="${YURICH_TELEGRAM_COMMUNITY_URL:-https://t.me/your_channel}" TELEGRAM_BOT_URL="${YURICH_TELEGRAM_BOT_URL:-https://t.me/your_notification_bot}" TELEGRAM_ID_BOT_URL="${YURICH_TELEGRAM_ID_BOT_URL:-https://t.me/getmyid_bot}" SUPPORT_EMAIL="${YURICH_SUPPORT_EMAIL:-support@example.com}" SALES_BOT_CHANNEL_URL_DEFAULT="${YURICH_SALES_BOT_CHANNEL_URL:-https://t.me/your_channel}" SALES_BOT_PLANS_DEFAULT="1d:50,1m:250,3m:700,6m:1300,12m:2400" SALES_BOT_CURRENCY_DEFAULT="RUB" SALES_BOT_PAYMENT_TEXT_DEFAULT="Тарифы Yurich Connect: 1 день - 50 руб, 1 месяц - 250 руб, 3 месяца - 700 руб, 6 месяцев - 1300 руб, 12 месяцев - 2400 руб. После оплаты отправь скрин платежа сюда в бот. Администратор проверит оплату и включит подписку." SALES_BOT_PAYMENT_QR_PATH_DEFAULT="/etc/naiveproxy/sales-bot/payment-qr.jpg" SALES_BOT_WELCOME_ANIMATION_PATH_DEFAULT="/etc/naiveproxy/sales-bot/welcome.gif" SALES_BOT_WELCOME_IMAGE_PATH_DEFAULT="/etc/naiveproxy/sales-bot/welcome.jpg" SALES_BOT_CAPTCHA_TTL_SECONDS_DEFAULT="86400" EXPIRED_DELETE_GRACE_DAYS_DEFAULT="5" SCRIPT_PATH="/usr/local/bin/yurich-panel.sh" LEGACY_SCRIPT_PATH="/usr/local/bin/naiveproxy.sh" CADDY_VERSION_PIN="${NAIVEPROXY_CADDY_VERSION:-v2.11.4}" XCADDY_VERSION_PIN="${NAIVEPROXY_XCADDY_VERSION:-v0.4.6}" FORWARDPROXY_REF_PIN="${NAIVEPROXY_FORWARDPROXY_REF:-d62c80d3dd2c706b6b87579844d2397bddd18317}" XRAY_VERSION_PIN="${NAIVEPROXY_XRAY_VERSION:-v26.3.27}" HYSTERIA_VERSION_PIN="${NAIVEPROXY_HYSTERIA_VERSION:-app/v2.12.3}" PINGTUNNEL_DEFAULT_VERSION="master-2c83808a81b56784d639c952b70baada6601e2d7" PINGTUNNEL_VERSION_PIN="${NAIVEPROXY_PINGTUNNEL_VERSION:-$PINGTUNNEL_DEFAULT_VERSION}" PINGTUNNEL_SHA256_AMD64="5d5847a17099b9359c55a959f85a1994232cf8a089642bd632bfa64e5bdfe8af" PINGTUNNEL_SHA256_ARM64="ccf5ccb1a2b32cbfa6d85207d0d853b799e9df434ba975e67f93e2fbdf6e51d0" # ─── Цвета ─────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' GOLD='\033[0;33m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' ok() { echo -e "${GREEN}[✓]${RESET} $*"; } err() { echo -e "${RED}[✗]${RESET} $*"; } info() { echo -e "${CYAN}[i]${RESET} $*"; } warn() { echo -e "${YELLOW}[!]${RESET} $*"; } hr() { echo -e "${CYAN}──────────────────────────────────────────${RESET}"; } version_gt() { local a="${1:-0}" b="${2:-0}" [[ "$a" == "$b" ]] && return 1 [[ "$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -n1)" == "$a" ]] } # ─── Баннер при первом запуске ──────────────────────────────── show_banner() { echo -e "${BOLD}${CYAN}" echo ' ███╗ ██╗ █████╗ ██╗██╗ ██╗███████╗' echo ' ████╗ ██║██╔══██╗██║██║ ██║██╔════╝' echo ' ██╔██╗ ██║███████║██║██║ ██║█████╗ ' echo ' ██║╚██╗██║██╔══██║██║╚██╗ ██╔╝██╔══╝ ' echo ' ██║ ╚████║██║ ██║██║ ╚████╔╝ ███████╗' echo ' ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝' echo -e "${RESET}" echo -e " ${BOLD}${GOLD}██████╗ ██████╗ ██████╗ ██╗ ██╗██╗ ██╗${RESET}" echo -e " ${BOLD}${GOLD}██╔══██╗██╔══██╗██╔═══██╗╚██╗██╔╝╚██╗ ██╔╝${RESET}" echo -e " ${BOLD}${GOLD}██████╔╝██████╔╝██║ ██║ ╚███╔╝ ╚████╔╝ ${RESET}" echo -e " ${BOLD}${GOLD}██╔═══╝ ██╔══██╗██║ ██║ ██╔██╗ ╚██╔╝ ${RESET}" echo -e " ${BOLD}${GOLD}██║ ██║ ██║╚██████╔╝██╔╝ ██╗ ██║ ${RESET}" echo -e " ${BOLD}${GOLD}╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ${RESET}" echo echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo -e " ${BOLD} Yurich Panel${RESET} ${DIM}v${VERSION}${RESET}" echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo echo -e " ${YELLOW}[INFO] Обновления выходят раз в месяц${RESET}" echo -e " ${CYAN}[GH] GitHub:${RESET} ${PROJECT_GITHUB_SHORT}" [[ -n "${PROJECT_WEBSITE_URL:-}" ]] && echo -e " ${CYAN}[WEB] Сайт:${RESET} ${PROJECT_WEBSITE_URL}" [[ -n "${TELEGRAM_COMMUNITY_URL:-}" ]] && echo -e " ${CYAN}[TG] Telegram:${RESET} ${TELEGRAM_COMMUNITY_URL}" [[ -n "${PROJECT_DONATION_URL:-}" ]] && echo -e " ${BOLD}${GOLD}[DONATE] Донат:${RESET} ${PROJECT_DONATION_URL}" echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo } # ─── Пути ──────────────────────────────────────────────────── CADDY_BIN="/usr/local/bin/caddy" CADDY_SERVICE="/etc/systemd/system/caddy.service" CADDYFILE="/etc/caddy/Caddyfile" CADDY_DIR="/etc/caddy" HYSTERIA_BIN="/usr/local/bin/hysteria" HYSTERIA_SERVICE="/etc/systemd/system/hysteria.service" HYSTERIA_CONFIG="/etc/naiveproxy/hysteria.yaml" HYSTERIA_PORT_HOP_SERVICE="/etc/systemd/system/yurich-hysteria-port-hop.service" HYSTERIA_PORT_HOP_SCRIPT="/usr/local/sbin/yurich-hysteria-port-hop" HYSTERIA_PORT_HOP_PORTS_DEFAULT="20000-20100" XRAY_BIN="/usr/local/bin/xray" XRAY_SERVICE="/etc/systemd/system/xray.service" XRAY_CONFIG_DIR="/etc/xray" XRAY_CONFIG="/etc/xray/config.json" XRAY_LOGROTATE_CONF="/etc/logrotate.d/yurich-xray" XRAY_ASSETS_DIR="/usr/local/share/xray" XRAY_ZAPRET_DAT_DEFAULT="${XRAY_ASSETS_DIR}/zapret.dat" XRAY_ZAPRET_URL_DEFAULT="https://github.com/kutovoys/ru_gov_zapret/releases/latest/download/zapret.dat" WARP_PROXY_PORT_DEFAULT="40000" WEBROOT="/var/www/html" CONFIG_FILE="/etc/naiveproxy/naive.conf" CONFIG_DIR="/etc/naiveproxy" USERS_FILE="/etc/naiveproxy/users.conf" DISABLED_USERS_FILE="/etc/naiveproxy/users.disabled" CREDENTIALS_DIR="/etc/naiveproxy/credentials" CREDENTIALS_PRIVATE_KEY="${CREDENTIALS_DIR}/private.pem" CREDENTIALS_PUBLIC_KEY="${CREDENTIALS_DIR}/public.pem" USERS_SECRETS_FILE="${CREDENTIALS_DIR}/users.secrets" ACTIVE_USERS_HASH_FILE="${CREDENTIALS_DIR}/users.active.htpasswd" CREDENTIALS_LOCK_FILE="${CREDENTIALS_DIR}/credentials.lock" CREDENTIALS_MIGRATION_RECEIPT="${CREDENTIALS_DIR}/migration.receipt" HYSTERIA_AUTH_HELPER="/usr/local/libexec/yurich-hysteria-auth" HYSTERIA_TLS_DIR="${CONFIG_DIR}/hysteria-tls" HYSTERIA_TLS_CERT="${HYSTERIA_TLS_DIR}/cert.pem" HYSTERIA_TLS_KEY="${HYSTERIA_TLS_DIR}/key.pem" HYSTERIA_TLS_SYNC_SCRIPT="/usr/local/sbin/yurich-hysteria-tls-sync" HYSTERIA_TLS_SYNC_SERVICE="/etc/systemd/system/yurich-hysteria-tls-sync.service" HYSTERIA_TLS_SYNC_TIMER="/etc/systemd/system/yurich-hysteria-tls-sync.timer" CREDENTIAL_BCRYPT_COST_DEFAULT="12" XRAY_USERS_FILE="/etc/naiveproxy/xray-users.conf" XRAY_DISABLED_USERS_FILE="/etc/naiveproxy/xray-users.disabled" XRAY_COMPAT_USERS_FILE="/etc/naiveproxy/xray-compat-users.conf" SUBS_DIR="/etc/naiveproxy/subscriptions" SUBS_WEB_DIR="${WEBROOT}/s" SUBSCRIPTION_ASSETS_DIR="/etc/naiveproxy/assets" SUBSCRIPTION_LOGO_PATH_DEFAULT="${SUBSCRIPTION_ASSETS_DIR}/yurich-connect-logo.png" SUBSCRIPTION_PROJECT_HELP_QR_PATH_DEFAULT="${SUBSCRIPTION_ASSETS_DIR}/project-help-qr.jpg" SUBS_ALIASES_FILE="/etc/naiveproxy/subscription-aliases.conf" USER_META_DIR="/etc/naiveproxy/users.d" PRIVATE_PAGE_TOKEN_FILE="/etc/naiveproxy/private_page.token" PRIVATE_WEB_DIR="${WEBROOT}/p" LOG_DIR="/var/log/caddy" BACKUP_DIR="/etc/naiveproxy/backups" EXPORT_DIR="/etc/naiveproxy/exports" BRIDGE_CONFIG="/etc/naiveproxy/bridge.conf" NODES_FILE="/etc/naiveproxy/nodes.conf" NODES_KNOWN_HOSTS_FILE="/etc/naiveproxy/nodes_known_hosts" NODES_PENDING_SYNC_FILE="/etc/naiveproxy/.nodes-sync-pending" MONITOR_SCRIPT="/etc/naiveproxy/monitor.sh" SSH_HARDENING_DONE="/etc/naiveproxy/.ssh_hardened" SYSUPDATE_DONE="/etc/naiveproxy/.sysupdate_done" DEVICE_LIMIT_DEFAULT="5" DEVICE_WINDOW_HOURS_DEFAULT="24" DEVICE_CRON="/etc/cron.d/naiveproxy-device-limit" EXPIRY_NOTIFY_CRON="/etc/cron.d/naiveproxy-expiry-notify" EXPIRY_NOTIFY_LOG="/var/log/naiveproxy-expiry-notify.log" PROTOCOL_BENCHMARK_CRON="/etc/cron.d/yurich-protocol-benchmark" PROTOCOL_BENCHMARK_LOG_DEFAULT="/var/log/yurich-protocol-benchmark.csv" SALES_BOT_SERVICE="/etc/systemd/system/yurich-sales-bot.service" SALES_BOT_DIR="/etc/naiveproxy/sales-bot" SALES_BOT_ORDERS_DIR="${SALES_BOT_DIR}/orders" SALES_BOT_CAPTCHA_DIR="${SALES_BOT_DIR}/captcha" SALES_BOT_VERIFIED_DIR="${SALES_BOT_DIR}/verified" SALES_BOT_ORDER_MIN_INTERVAL_SECONDS_DEFAULT="60" SALES_BOT_MAX_PENDING_PER_CHAT_DEFAULT="2" SALES_BOT_MAX_ORDER_FILES_DEFAULT="10000" XRAY_REALITY_PORT_DEFAULT="8444" XRAY_MOBILE_ALT_PORT_DEFAULT="8445" XRAY_MOBILE_ALT_TARGET_DEFAULT="www.cloudflare.com:443" XRAY_MOBILE_ALT_SERVER_NAME_DEFAULT="www.cloudflare.com" XRAY_GITHUB_TEST_PORT_DEFAULT="8446" XRAY_GITHUB_TEST_TARGET_DEFAULT="github.com:443" XRAY_GITHUB_TEST_SERVER_NAME_DEFAULT="github.com" XRAY_MKCP_PORT_DEFAULT="8446" XRAY_VISION_PORT_DEFAULT="8447" XRAY_XHTTP_PORT_DEFAULT="8448" XRAY_WS_PORT_DEFAULT="8449" XRAY_HTTPUPGRADE_PORT_DEFAULT="8450" XRAY_CADDY_FALLBACK_PORT_DEFAULT="7443" XRAY_REALITY_PUBLIC_PORT_DEFAULT="" HAPROXY_CFG="/etc/haproxy/haproxy.cfg" HAPROXY_STATS_SOCKET="/run/haproxy-admin.sock" HAPROXY_LOG_FILE="/var/log/haproxy.log" HAPROXY_RSYSLOG_CONF="/etc/rsyslog.d/49-yurich-haproxy.conf" HAPROXY_LOGROTATE_CONF="/etc/logrotate.d/yurich-haproxy" HAPROXY_SYSCTL_CONF="/etc/sysctl.d/99-yurich-haproxy.conf" XRAY_SYSCTL_CONF="/etc/sysctl.d/99-z-yurich-vless-reality.conf" EGRESS_GAI_CONF="/etc/gai.conf" EGRESS_SYSCTL_CONF="/etc/sysctl.d/99-yurich-egress-ipv4.conf" PINGTUNNEL_DIR="/opt/yurich-pingtunnel" PINGTUNNEL_BIN="${PINGTUNNEL_DIR}/pingtunnel" PINGTUNNEL_ENV="/etc/naiveproxy/pingtunnel.env" PINGTUNNEL_SERVICE="/etc/systemd/system/yurich-pingtunnel.service" PINGTUNNEL_SYSCTL_CONF="/etc/sysctl.d/99-yurich-pingtunnel.conf" # ══════════════════════════════════════════════════════════════ # БЛОК 1: ОБНОВЛЕНИЕ СИСТЕМЫ # ══════════════════════════════════════════════════════════════ cmd_sysupdate() { hr echo -e "${BOLD} Обновление системы${RESET}" hr # Загружаем конфиг для Telegram (может вызываться до основного load_config) load_config 2>/dev/null || true info "Обновляю списки пакетов..." apt-get update -q info "Устанавливаю обновления пакетов..." DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -q -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" info "Чищу ненужные пакеты..." apt-get autoremove -y -q apt-get autoclean -q # Ставим и настраиваем автообновления безопасности info "Настраиваю автоматические обновления безопасности..." apt-get install -y -q unattended-upgrades apt-listchanges cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF' Unattended-Upgrade::Allowed-Origins { "${distro_id}:${distro_codename}"; "${distro_id}:${distro_codename}-security"; "${distro_id}ESMApps:${distro_codename}-apps-security"; "${distro_id}ESM:${distro_codename}-infra-security"; }; Unattended-Upgrade::Package-Blacklist {}; Unattended-Upgrade::DevRelease "false"; Unattended-Upgrade::AutoFixInterruptedDpkg "true"; Unattended-Upgrade::MinimalSteps "true"; Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; Unattended-Upgrade::Remove-New-Unused-Dependencies "true"; Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; Unattended-Upgrade::Automatic-Reboot-Time "03:30"; EOF cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF' APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Download-Upgradeable-Packages "1"; APT::Periodic::AutocleanInterval "7"; APT::Periodic::Unattended-Upgrade "1"; EOF systemctl enable unattended-upgrades --quiet 2>/dev/null || true systemctl restart unattended-upgrades 2>/dev/null || true # Проверяем нужен ли ребут if [[ -f /var/run/reboot-required ]]; then warn "Требуется перезагрузка сервера для применения обновлений ядра!" echo -ne "${YELLOW}Перезагрузить сейчас? [y/N]: ${RESET}" read -r ans if [[ "${ans,,}" == "y" ]]; then ok "Перезагружаю через 5 секунд..." sleep 5 reboot else warn "Не забудь перезагрузить сервер позже: reboot" fi fi # Маркер что обновление выполнено mkdir -p "$CONFIG_DIR" date '+%Y-%m-%d %H:%M:%S' > "$SYSUPDATE_DONE" ok "Система обновлена" ok "Автообновления безопасности: включены (ежедневно)" tg_send "🔄 Система обновлена 🖥 Сервер: $(hostname) 🕐 $(date '+%Y-%m-%d %H:%M:%S') 🛡 Автообновления безопасности: включены" } # ══════════════════════════════════════════════════════════════ # БЛОК 2: SSH HARDENING # ══════════════════════════════════════════════════════════════ cmd_ssh_hardening() { hr echo -e "${BOLD} SSH Hardening${RESET}" hr local sshd_config="/etc/ssh/sshd_config" local current_port ssh_dropin_backup_dir="" current_port=$(current_ssh_port) echo -e " Текущий SSH порт: ${CYAN}${current_port}${RESET}" echo # ── Шаг 1: Создание нового пользователя ────────────────── hr echo -e "${BOLD} Шаг 1: Новый sudo-пользователь${RESET}" hr local new_user="" while true; do echo -ne "${CYAN}Имя нового пользователя (Enter = пропустить): ${RESET}" read -r new_user if [[ -z "$new_user" ]]; then warn "Пропускаю создание пользователя" break fi if [[ ! "$new_user" =~ ^[a-z][a-z0-9_-]{2,31}$ ]]; then err "Только строчные буквы, цифры, _, - (3-32 символа, начинается с буквы)" continue fi if id "$new_user" &>/dev/null; then warn "Пользователь $new_user уже существует" break fi # Создаём пользователя useradd -m -s /bin/bash "$new_user" usermod -aG sudo "$new_user" # Пароль echo -ne "${CYAN}Пароль для $new_user (Enter = сгенерировать): ${RESET}" read -r user_pass if [[ -z "$user_pass" ]]; then user_pass=$(random_safe_token 20 'a-zA-Z0-9') info "Сгенерирован пароль: ${BOLD}${user_pass}${RESET}" info "СОХРАНИ ЕГО СЕЙЧАС!" fi printf "%s:%s" "${new_user}" "${user_pass}" | chpasswd ok "Пользователь ${new_user} создан с правами sudo" break done # ── Шаг 2: SSH-ключ ────────────────────────────────────── hr echo -e "${BOLD} Шаг 2: SSH-ключ (ED25519)${RESET}" hr local target_user="${new_user:-root}" local target_home target_home=$(getent passwd "$target_user" | cut -d: -f6) local ssh_dir="${target_home}/.ssh" local auth_keys="${ssh_dir}/authorized_keys" if [[ -f "$auth_keys" ]] && grep -q "ssh-" "$auth_keys" 2>/dev/null; then ok "SSH-ключ уже настроен для ${target_user}" else info "Генерирую ED25519 ключ для ${target_user}..." mkdir -p "$ssh_dir" chmod 700 "$ssh_dir" # Генерируем ключ ssh-keygen -t ed25519 -f "${ssh_dir}/id_ed25519_server" -N "" -C "naiveproxy-server-$(date +%Y%m%d)" -q cat "${ssh_dir}/id_ed25519_server.pub" >> "$auth_keys" chmod 600 "$auth_keys" [[ "$target_user" != "root" ]] && chown -R "${target_user}:${target_user}" "$ssh_dir" # Авто-сохранение ключа в /etc/naiveproxy/ mkdir -p "$CONFIG_DIR" cp "${ssh_dir}/id_ed25519_server" "${CONFIG_DIR}/ssh_private_key" cp "${ssh_dir}/id_ed25519_server.pub" "${CONFIG_DIR}/ssh_public_key" chmod 600 "${CONFIG_DIR}/ssh_private_key" ok "SSH ключ авто-сохранён: ${CONFIG_DIR}/ssh_private_key" echo echo -e "${RED}╔══════════════════════════════════════════════════════╗${RESET}" echo -e "${RED}║ ПРИВАТНЫЙ КЛЮЧ — СКОПИРУЙ И СОХРАНИ ПРЯМО СЕЙЧАС ║${RESET}" echo -e "${RED}╚══════════════════════════════════════════════════════╝${RESET}" cat "${ssh_dir}/id_ed25519_server" echo -e "${RED}══════════════════════════════════════════════════════${RESET}" echo # Команда для скачивания ключа с сервера local server_ip_tmp server_ip_tmp=$(curl -s4 --max-time 5 https://ifconfig.me 2>/dev/null || echo YOUR_IP) echo -e " ${BOLD}Скачать ключ на свой компьютер:${RESET}" echo -e " ${CYAN}# Linux/macOS:${RESET}" echo -e " scp root@${server_ip_tmp}:${CONFIG_DIR}/ssh_private_key ~/.ssh/id_naiveproxy" echo -e " chmod 600 ~/.ssh/id_naiveproxy" echo echo -e " ${CYAN}# Windows PowerShell:${RESET}" echo -e " scp root@${server_ip_tmp}:${CONFIG_DIR}/ssh_private_key \$HOME\.ssh\id_naiveproxy" echo warn "Подключение после hardening:" echo -e " ${CYAN}ssh -i ~/.ssh/id_naiveproxy -p [НОВЫЙ_ПОРТ] ${target_user}@${server_ip_tmp}${RESET}" echo echo -ne "${YELLOW}Ты сохранил/скачал ключ? [yes]: ${RESET}" read -r confirm if [[ "${confirm,,}" != "yes" && "${confirm,,}" != "y" ]]; then warn "Ключ сохранён на сервере: ${CONFIG_DIR}/ssh_private_key" warn "Скачай его позже через scp!" echo -ne "${YELLOW}Продолжить? [y/N]: ${RESET}" read -r force [[ "${force,,}" == "y" ]] || return 1 fi ok "SSH-ключ сгенерирован и добавлен в authorized_keys" fi # ── Шаг 3: Смена SSH порта ──────────────────────────────── hr echo -e "${BOLD} Шаг 3: Смена SSH порта${RESET}" hr local new_ssh_port="" echo -e " Текущий порт: ${CYAN}${current_port}${RESET}" echo -e " ${BOLD}1)${RESET} Ввести вручную" echo -e " ${BOLD}2)${RESET} Случайный (49000-65000)" echo -e " ${BOLD}0)${RESET} Оставить ${current_port}" echo -ne "${CYAN}Выбор: ${RESET}" read -r port_choice case "$port_choice" in 1) while true; do echo -ne "${CYAN}Новый SSH порт (1024-65535): ${RESET}" read -r new_ssh_port if is_valid_local_proxy_port "$new_ssh_port"; then break fi err "Неверный порт. Введи число от 1024 до 65535" done ;; 2) # Генерируем случайный порт и проверяем что он свободен while true; do new_ssh_port=$(( RANDOM % 16000 + 49000 )) if ! ss -tlnp | grep -E ":${new_ssh_port}([[:space:]]|$)" >/dev/null; then break fi done info "Случайный порт: ${BOLD}${new_ssh_port}${RESET}" ;; *) new_ssh_port="$current_port" info "Порт оставляем: $current_port" ;; esac # ── Шаг 4: Применяем sshd_config ───────────────────────── hr echo -e "${BOLD} Шаг 4: Настройка sshd_config${RESET}" hr # Бэкап — сохраняем метку времени для возможного отката local sshd_backup sshd_backup="${sshd_config}.bak.$(date +%Y%m%d_%H%M%S)" cp "$sshd_config" "$sshd_backup" ssh_dropin_backup_dir=$(mktemp -d /tmp/yurich-ssh-dropins.XXXXXX) chmod 700 "$ssh_dropin_backup_dir" for cfg in /etc/ssh/sshd_config.d/*.conf; do [[ -f "$cfg" ]] || continue cp -a "$cfg" "$ssh_dropin_backup_dir/$(basename "$cfg")" done ok "Бэкап sshd_config создан: $sshd_backup" # Безопасные значения по умолчанию: не отключаем текущие способы входа без явного согласия. local permit_root="yes" local password_auth="yes" if [[ "$target_user" != "root" ]]; then echo -ne "${YELLOW}Запретить SSH вход root? [y/N]: ${RESET}" read -r ans_disable_root [[ "${ans_disable_root,,}" == "y" ]] && permit_root="no" else warn "Hardening выполняется для root — root вход оставляю включённым, чтобы не потерять доступ." fi if [[ ! -f "$auth_keys" ]] || ! grep -q "ssh-" "$auth_keys" 2>/dev/null; then warn "Нет SSH-ключа — пароль оставляем включённым" else echo -ne "${YELLOW}Отключить SSH вход по паролю? [y/N]: ${RESET}" read -r ans_disable_pass [[ "${ans_disable_pass,,}" == "y" ]] && password_auth="no" fi # Меняем настройки # Проверяем что строка Port вообще есть if grep -qE "^#?Port " "$sshd_config"; then sed -i "s/^#*Port .*/Port ${new_ssh_port}/" "$sshd_config" else echo "Port ${new_ssh_port}" >> "$sshd_config" fi # На Ubuntu 22.04+ конфиг может быть в /etc/ssh/sshd_config.d/*.conf for cfg in /etc/ssh/sshd_config.d/*.conf; do [[ ! -f "$cfg" ]] && continue if grep -qE "^#?Port " "$cfg"; then sed -i "s/^#*Port .*/Port ${new_ssh_port}/" "$cfg" fi done # PermitRootLogin if grep -qE "^#?PermitRootLogin " "$sshd_config"; then sed -i "s/^#*PermitRootLogin .*/PermitRootLogin ${permit_root}/" "$sshd_config" else echo "PermitRootLogin ${permit_root}" >> "$sshd_config" fi # PasswordAuthentication if grep -qE "^#?PasswordAuthentication " "$sshd_config"; then sed -i "s/^#*PasswordAuthentication .*/PasswordAuthentication ${password_auth}/" "$sshd_config" else echo "PasswordAuthentication ${password_auth}" >> "$sshd_config" fi # Также в conf.d файлах (Ubuntu 22.04+) for cfg in /etc/ssh/sshd_config.d/*.conf; do [[ ! -f "$cfg" ]] && continue sed -i "s/^#*PermitRootLogin .*/PermitRootLogin ${permit_root}/" "$cfg" 2>/dev/null || true sed -i "s/^#*PasswordAuthentication .*/PasswordAuthentication ${password_auth}/" "$cfg" 2>/dev/null || true done sed -i "s/^#*PubkeyAuthentication .*/PubkeyAuthentication yes/" "$sshd_config" sed -i "s/^#*AuthorizedKeysFile .*/AuthorizedKeysFile .ssh\/authorized_keys/" "$sshd_config" sed -i "s/^#*X11Forwarding .*/X11Forwarding no/" "$sshd_config" sed -i "s/^#*MaxAuthTries .*/MaxAuthTries 3/" "$sshd_config" sed -i "s/^#*LoginGraceTime .*/LoginGraceTime 30/" "$sshd_config" sed -i "s/^#*PermitEmptyPasswords .*/PermitEmptyPasswords no/" "$sshd_config" # Добавляем если нет grep -q "^ClientAliveInterval" "$sshd_config" || echo "ClientAliveInterval 300" >> "$sshd_config" grep -q "^ClientAliveCountMax" "$sshd_config" || echo "ClientAliveCountMax 2" >> "$sshd_config" # Проверяем конфиг if ! sshd -t 2>/dev/null; then err "Ошибка в sshd_config! Откатываю из $sshd_backup..." cp "$sshd_backup" "$sshd_config" 2>/dev/null || true for cfg in "$ssh_dropin_backup_dir"/*.conf; do [[ -f "$cfg" ]] && cp -a "$cfg" "/etc/ssh/sshd_config.d/$(basename "$cfg")" 2>/dev/null || true done rm -rf -- "$ssh_dropin_backup_dir" return 1 fi # ── Шаг 5: UFW + Fail2Ban ───────────────────────────────── hr echo -e "${BOLD} Шаг 5: Firewall + Fail2Ban${RESET}" hr # Убеждаемся что UFW активен if ! ufw status | grep -q "Status: active"; then warn "UFW неактивен — включаю..." ufw --force enable >/dev/null 2>&1 || true fi # Открываем новый порт ПЕРЕД закрытием старого ufw allow "${new_ssh_port}/tcp" comment "SSH hardened" >/dev/null 2>&1 || true # Старый порт не закрываем автоматически: это частая причина lockout. if [[ "$new_ssh_port" != "$current_port" ]]; then warn "Старый SSH порт ${current_port} оставлен открытым в UFW как аварийный запас." warn "Закрой его вручную только после проверки входа на порт ${new_ssh_port}." fi ok "Новый SSH порт ${new_ssh_port} открыт в UFW" # Ubuntu 22.04+ может использовать ssh.socket. Переключаемся на ssh.service # и сразу перезапускаем SSH, до установки дополнительных пакетов. local ssh_socket_was_enabled="no" if systemctl is-enabled ssh.socket &>/dev/null; then ssh_socket_was_enabled="yes" info "Отключаю ssh.socket (Ubuntu 22.04+ override) и включаю ssh.service..." systemctl enable ssh --quiet 2>/dev/null || systemctl enable sshd --quiet 2>/dev/null || true systemctl disable --now ssh.socket --quiet 2>/dev/null || true fi if restart_ssh_service; then ok "SSH сервис перезапущен с новыми настройками" else err "Не удалось перезапустить ssh/sshd. Откатываю sshd_config..." cp "$sshd_backup" "$sshd_config" 2>/dev/null || true for cfg in "$ssh_dropin_backup_dir"/*.conf; do [[ -f "$cfg" ]] && cp -a "$cfg" "/etc/ssh/sshd_config.d/$(basename "$cfg")" 2>/dev/null || true done if [[ "$ssh_socket_was_enabled" == "yes" ]]; then systemctl enable --now ssh.socket --quiet 2>/dev/null || true fi restart_ssh_service || true rm -rf -- "$ssh_dropin_backup_dir" return 1 fi if ! setup_fail2ban "$new_ssh_port"; then rm -rf -- "$ssh_dropin_backup_dir" return 1 fi rm -rf -- "$ssh_dropin_backup_dir" # Маркер mkdir -p "$CONFIG_DIR" cat > "$SSH_HARDENING_DONE" << EOF SSH_PORT=${new_ssh_port} SSH_USER=${target_user} HARDENED_AT=$(date '+%Y-%m-%d %H:%M:%S') EOF # ── Итог ───────────────────────────────────────────────── local server_ip server_ip=$(curl -s4 --max-time 5 https://ifconfig.me 2>/dev/null || curl -s4 --max-time 5 https://api.ipify.org 2>/dev/null || echo "YOUR_IP") hr echo -e "${BOLD}${GREEN} SSH Hardening завершён!${RESET}" hr echo -e " ${BOLD}Новый SSH порт:${RESET} ${CYAN}${new_ssh_port}${RESET}" echo -e " ${BOLD}Пользователь:${RESET} ${CYAN}${target_user}${RESET}" echo -e " ${BOLD}Root вход:${RESET} $([ "$permit_root" = "no" ] && echo -e "${RED}запрещён${RESET}" || echo -e "${YELLOW}разрешён${RESET}")" echo -e " ${BOLD}Пароль вход:${RESET} $([ "$password_auth" = "no" ] && echo -e "${RED}запрещён${RESET}" || echo -e "${YELLOW}разрешён${RESET}")" echo -e " ${BOLD}Fail2Ban:${RESET} ${GREEN}активен${RESET}" echo echo -e " ${BOLD}Подключение:${RESET}" echo -e " ${CYAN}ssh -i ~/.ssh/id_naiveproxy -p ${new_ssh_port} ${target_user}@${server_ip}${RESET}" hr tg_send "🔒 SSH Hardening выполнен 🖥 Сервер: $(hostname) 🔑 Пользователь: ${target_user} 🚪 SSH порт: ${new_ssh_port} 🛡 Fail2Ban: включён 🕐 $(date '+%Y-%m-%d %H:%M:%S')" } cmd_ssh_rescue() { local rescue_conf="/etc/ssh/sshd_config.d/99-naiveproxy-rescue.conf" local rescue_script="/usr/local/sbin/yurich-disable-ssh-rescue.sh" local rescue_state="/run/yurich-ssh-rescue.state" local rescue_ttl="${NAIVEPROXY_SSH_RESCUE_TTL_SECONDS:-1800}" local rescue_unit socket_enabled=0 socket_active=0 fail2ban_active=0 ufw_22_preexisting=0 candidate hr echo -e "${BOLD}${YELLOW} SSH Rescue Mode${RESET}" hr warn "Включаю временный аварийный SSH-доступ на 22 порту." warn "После восстановления зайди по SSH и заново настрой hardening." command -v systemd-run >/dev/null 2>&1 || { err "systemd-run недоступен: безопасный автооткат SSH rescue невозможен"; return 1; } is_canonical_uint_in_range "$rescue_ttl" 300 86400 || { err "SSH rescue TTL должен быть 300-86400 секунд"; return 1; } [[ ! -e "$rescue_conf" && ! -L "$rescue_conf" ]] || { err "SSH rescue уже активен: $rescue_conf"; return 1; } mkdir -p /etc/ssh/sshd_config.d candidate=$(mktemp /etc/ssh/sshd_config.d/.99-naiveproxy-rescue.XXXXXX) cat > "$candidate" <<'EOF' Port 22 PermitRootLogin yes PasswordAuthentication yes PubkeyAuthentication yes EOF install -m 600 "$candidate" "$rescue_conf" rm -f "$candidate" if ! sshd -t; then rm -f "$rescue_conf" err "sshd_config не прошёл проверку; rescue отменён" return 1 fi systemctl is-enabled --quiet ssh.socket 2>/dev/null && socket_enabled=1 systemctl is-active --quiet ssh.socket 2>/dev/null && socket_active=1 systemctl is-active --quiet fail2ban 2>/dev/null && fail2ban_active=1 ufw show added 2>/dev/null | grep -Eq '^ufw allow 22/tcp([[:space:]]|$)' && ufw_22_preexisting=1 { printf 'SSH_SOCKET_ENABLED=%s\n' "$socket_enabled" printf 'SSH_SOCKET_ACTIVE=%s\n' "$socket_active" printf 'FAIL2BAN_ACTIVE=%s\n' "$fail2ban_active" printf 'UFW_22_PREEXISTED=%s\n' "$ufw_22_preexisting" } > "$rescue_state" chmod 600 "$rescue_state" cat > "$rescue_script" <<'EOF' #!/bin/bash set -euo pipefail state=/run/yurich-ssh-rescue.state state_value() { awk -F= -v key="$1" '$1 == key {print $2; exit}' "$state" 2>/dev/null || true; } socket_enabled=$(state_value SSH_SOCKET_ENABLED) socket_active=$(state_value SSH_SOCKET_ACTIVE) fail2ban_active=$(state_value FAIL2BAN_ACTIVE) ufw_22_preexisting=$(state_value UFW_22_PREEXISTED) rm -f /etc/ssh/sshd_config.d/99-naiveproxy-rescue.conf if [[ "$ufw_22_preexisting" != "1" ]]; then ufw --force delete allow 22/tcp >/dev/null 2>&1 || true fi if [[ "$fail2ban_active" == "1" ]]; then systemctl start fail2ban >/dev/null 2>&1 || true fi if [[ "$socket_enabled" == "1" ]]; then systemctl enable ssh.socket >/dev/null 2>&1 || true else systemctl disable ssh.socket >/dev/null 2>&1 || true fi if [[ "$socket_active" == "1" ]]; then systemctl stop ssh >/dev/null 2>&1 || systemctl stop sshd >/dev/null 2>&1 || true systemctl start ssh.socket >/dev/null 2>&1 || true elif sshd -t; then systemctl reload ssh >/dev/null 2>&1 || systemctl reload sshd >/dev/null 2>&1 || systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true fi rm -f "$state" /usr/local/sbin/yurich-disable-ssh-rescue.sh EOF chmod 700 "$rescue_script" rescue_unit="yurich-ssh-rescue-autodisable-$(date +%s)" if ! systemd-run --unit="$rescue_unit" --on-active="${rescue_ttl}s" "$rescue_script" >/dev/null 2>&1; then rm -f "$rescue_conf" "$rescue_state" "$rescue_script" err "Не удалось поставить обязательный автооткат; SSH rescue не включён" return 1 fi warn "SSH rescue автоматически отключится через ${rescue_ttl} сек." systemctl disable --now ssh.socket >/dev/null 2>&1 || true if command -v ufw >/dev/null 2>&1 && ! ufw allow 22/tcp comment "SSH rescue" >/dev/null 2>&1; then "$rescue_script" >/dev/null 2>&1 || true err "Не удалось открыть 22/tcp в UFW; rescue отменён" return 1 fi systemctl stop fail2ban >/dev/null 2>&1 || true if ! restart_ssh_service; then "$rescue_script" >/dev/null 2>&1 || true err "Не удалось перезапустить SSH; rescue отменён и прежнее состояние восстановлено" return 1 fi if ! ss -tln 2>/dev/null | grep -Eq ':(22)([[:space:]]|$)'; then "$rescue_script" >/dev/null 2>&1 || true err "SSH не начал слушать 22/tcp; rescue отменён и прежнее состояние восстановлено" return 1 fi ok "SSH rescue включён: порт 22, root/password временно разрешены" warn "Если пароль root неизвестен, задай его командой: passwd root" ss -tlnp | grep -E ':(22)([[:space:]]|$)' || true } # ─── МУЛЬТИЯЗЫЧНОСТЬ ────────────────────────────────────────── # Использование: t "Текст на русском" "English text" t() { if [[ "${LANG_UI}" == "en" ]]; then echo "$2" else echo "$1" fi } normalize_lang_ui() { if [[ -n "${NAIVEPROXY_LANG:-}" ]]; then LANG_UI="$NAIVEPROXY_LANG" fi case "${LANG_UI:-ru}" in ru|en) ;; *) LANG_UI="ru" ;; esac } # Установить язык: export NAIVEPROXY_LANG=en # Set language: export NAIVEPROXY_LANG=en # ─── Проверки ──────────────────────────────────────────────── check_root() { if [[ $EUID -ne 0 ]]; then err "Запускай от root: sudo bash $0" exit 1 fi } check_os() { if ! grep -qiE "ubuntu|debian" /etc/os-release 2>/dev/null; then warn "Скрипт тестировался на Ubuntu/Debian. Продолжаем на свой страх и риск." fi } check_installed() { [[ -f "$CADDY_BIN" && -f "$CADDYFILE" ]] } is_valid_domain() { [[ "${1:-}" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]] } domain_in_space_list_ci() { local domains="${1:-}" needle="${2:-}" domain is_valid_domain "$needle" || return 1 for domain in $domains; do [[ "${domain,,}" == "${needle,,}" ]] && return 0 done return 1 } subscription_public_domain() { local domain="${SUBSCRIPTION_DOMAIN:-${DOMAIN:-}}" is_valid_domain "$domain" || return 1 printf '%s\n' "$domain" } is_valid_host_port() { local value="${1:-}" host port [[ "$value" == *:* ]] || return 1 host="${value%:*}" port="${value##*:}" is_valid_domain "$host" || return 1 is_valid_port "$port" } public_dns_ipv4() { local domain="${1:-}" [[ -n "$domain" ]] || return 0 command -v dig >/dev/null 2>&1 || return 0 dig @1.1.1.1 +short A "$domain" +time=3 +tries=1 2>/dev/null \ | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$' \ | head -1 || true } domains_for_hosts_audit() { local dom node_domain { [[ -n "${DOMAIN:-}" ]] && printf '%s\n' "$DOMAIN" [[ -n "${SUBSCRIPTION_DOMAIN:-}" ]] && printf '%s\n' "$SUBSCRIPTION_DOMAIN" if [[ -n "${DOMAINS:-}" ]]; then local IFS=',' for dom in $DOMAINS; do printf '%s\n' "$dom" done fi if [[ -f "${NODES_FILE:-}" ]]; then while IFS='|' read -r _ _ _ _ node_domain _; do if [[ -n "$node_domain" && "$node_domain" != "-" ]]; then printf '%s\n' "$node_domain" fi done < <(grep -v '^#\|^[[:space:]]*$' "$NODES_FILE" 2>/dev/null || true) fi } | while IFS= read -r dom; do dom="${dom//[[:space:]]/}" dom="${dom%.}" dom="${dom,,}" if [[ -n "$dom" ]] && is_valid_domain "$dom"; then printf '%s\n' "$dom" fi done | awk '!seen[$0]++' } hosts_public_domain_overrides() { local hosts_file="${1:-/etc/hosts}" domain [[ -r "$hosts_file" ]] || return 0 domains_for_hosts_audit | while IFS= read -r domain; do awk -v domain="$domain" ' /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } { for (i = 2; i <= NF; i++) { entry = tolower($i) sub(/\.$/, "", entry) if (entry == domain) { print domain "|" FNR ":" $0 break } } } ' "$hosts_file" done } format_hosts_override_domains() { awk -F'|' 'NF && !seen[$1]++ { out = out sep $1; sep = ", " } END { print out }' } is_valid_proxy_user() { [[ "${1:-}" =~ ^[a-zA-Z0-9_-]{2,32}$ ]] } is_valid_proxy_pass() { [[ "${1:-}" =~ ^[a-zA-Z0-9_-]{8,64}$ ]] } current_ssh_port() { local port port=$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2; exit}' || true) is_valid_port "$port" || port="22" printf '%s\n' "$port" } restart_ssh_service() { if systemctl restart ssh 2>/dev/null; then return 0 fi if systemctl restart sshd 2>/dev/null; then return 0 fi return 1 } detect_hysteria_arch() { local arch arch=$(uname -m) case "$arch" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; armv7l|armv7*) echo "armv7" ;; i386|i686) echo "386" ;; *) err "Архитектура $arch не поддерживается Hysteria 2 автоустановкой"; return 1 ;; esac } find_caddy_cert() { local domain="${1:-${DOMAIN:-}}" [[ -z "$domain" ]] && return 1 find /root/.local/share/caddy/certificates -type f -path "*/${domain}/${domain}.crt" 2>/dev/null | head -1 } find_caddy_key() { local domain="${1:-${DOMAIN:-}}" [[ -z "$domain" ]] && return 1 find /root/.local/share/caddy/certificates -type f -path "*/${domain}/${domain}.key" 2>/dev/null | head -1 } is_valid_port() { local value="${1:-}" [[ "$value" =~ ^[1-9][0-9]{0,4}$ ]] && (( 10#$value <= 65535 )) } is_valid_local_proxy_port() { local value="${1:-}" is_valid_port "$value" && (( 10#$value >= 1024 )) } local_tcp_endpoint_ready() { local port="${1:-}" is_valid_port "$port" || return 1 command -v timeout >/dev/null 2>&1 || return 1 timeout 2 bash -c "exec 3<>/dev/tcp/127.0.0.1/${port}" >/dev/null 2>&1 } is_canonical_uint_in_range() { local value="${1:-}" min="${2:-0}" max="${3:-2147483647}" [[ "$value" =~ ^(0|[1-9][0-9]{0,9})$ ]] || return 1 (( 10#$value >= min && 10#$value <= max )) } warp_cli() { warp-cli --accept-tos "$@" 2>/dev/null || warp-cli "$@" } random_safe_token() { local len="${1:-20}" local alphabet="${2:-a-zA-Z0-9_-}" local token="" local chunk local attempts=0 if ! is_canonical_uint_in_range "$len" 8 128; then err "Некорректная длина токена: $len" return 1 fi while [[ ${#token} -lt "$len" ]]; do attempts=$((attempts + 1)) if [[ "$attempts" -gt 20 ]]; then err "Не удалось сгенерировать случайный токен через openssl" return 1 fi chunk=$(openssl rand -base64 "$((len * 3))" 2>/dev/null \ | LC_ALL=C tr -dc "$alphabet" \ | head -c "$len" || true) token="${token}${chunk}" done printf '%s\n' "${token:0:len}" } is_valid_hysteria_secret() { [[ "${1:-}" =~ ^[A-Za-z0-9_-]{8,64}$ ]] } ensure_hysteria_secrets() { if ! is_valid_hysteria_secret "${HYSTERIA_PASSWORD:-}"; then HYSTERIA_PASSWORD=$(random_safe_token 24) info "Сгенерирован новый пароль Hysteria 2" fi if ! is_valid_hysteria_secret "${HYSTERIA_OBFS_PASSWORD:-}"; then HYSTERIA_OBFS_PASSWORD=$(random_safe_token 24) info "Сгенерирован новый obfs-пароль Hysteria 2" fi if ! is_valid_hysteria_secret "${HYSTERIA_PASSWORD:-}" || ! is_valid_hysteria_secret "${HYSTERIA_OBFS_PASSWORD:-}"; then err "Не удалось подготовить безопасные пароли Hysteria 2" return 1 fi } # ─── Режим входящего 443 ───────────────────────────────────── normalize_edge_routing_mode() { case "${EDGE_ROUTING_MODE:-}" in haproxy|sni|sni-mux|sni_mux) EDGE_ROUTING_MODE="haproxy" ;; caddy|caddy-only|caddy_only|"") if [[ "${XRAY_REALITY_SNI_MUX_ENABLED:-0}" == "1" ]]; then EDGE_ROUTING_MODE="haproxy" else EDGE_ROUTING_MODE="caddy" fi ;; *) warn "Неизвестный EDGE_ROUTING_MODE=${EDGE_ROUTING_MODE}; использую caddy" EDGE_ROUTING_MODE="caddy" ;; esac } edge_routing_mode_is_haproxy() { [[ "${EDGE_ROUTING_MODE:-}" == "haproxy" || "${XRAY_REALITY_SNI_MUX_ENABLED:-0}" == "1" ]] } edge_routing_mode_label() { if edge_routing_mode_is_haproxy; then printf 'HAProxy SNI mux' else printf 'Caddy-only' fi } set_edge_routing_mode() { local mode="${1:-caddy}" case "$mode" in haproxy|sni|sni-mux|sni_mux) EDGE_ROUTING_MODE="haproxy" XRAY_REALITY_SNI_MUX_ENABLED="1" XRAY_FALLBACK_ENABLED="0" XRAY_REALITY_PUBLIC_PORT="443" XRAY_CADDY_FALLBACK_PORT="${XRAY_CADDY_FALLBACK_PORT:-$XRAY_CADDY_FALLBACK_PORT_DEFAULT}" ;; caddy|caddy-only|caddy_only) EDGE_ROUTING_MODE="caddy" XRAY_REALITY_SNI_MUX_ENABLED="0" XRAY_FALLBACK_ENABLED="0" XRAY_REALITY_PUBLIC_PORT="" ;; *) err "Неверный режим 443: $mode" return 1 ;; esac } prompt_edge_routing_mode() { local ans current mode normalize_edge_routing_mode current="${EDGE_ROUTING_MODE:-caddy}" echo echo -e "${BOLD}Режим входящего 443:${RESET}" echo -e " ${BOLD}1)${RESET} Caddy-only — Caddy держит 443, VLESS Reality на отдельном TCP-порту" echo -e " ${BOLD}2)${RESET} HAProxy SNI mux — Naive/Caddy и VLESS Reality вместе на 443" echo -ne "${YELLOW}Выбор [${current}]: ${RESET}" read -r ans case "${ans,,}" in "" ) mode="$current" ;; 1|c|caddy|caddy-only|caddy_only) mode="caddy" ;; 2|h|haproxy|sni|sni-mux|sni_mux) mode="haproxy" ;; *) err "Неверный выбор режима 443"; return 1 ;; esac set_edge_routing_mode "$mode" ok "Режим 443 выбран: $(edge_routing_mode_label)" } # ─── Конфиг ────────────────────────────────────────────────── load_config() { if [[ -f "$CONFIG_FILE" ]]; then # Безопасность: проверяем владельца и права перед source local owner perms owner=$(stat -c '%U' "$CONFIG_FILE" 2>/dev/null || echo "unknown") perms=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || echo "000") if [[ "$owner" != "root" ]]; then err "БЕЗОПАСНОСТЬ: $CONFIG_FILE принадлежит '$owner', ожидается root. Прерываю." exit 1 fi [[ "$perms" != "600" ]] && chmod 600 "$CONFIG_FILE" # shellcheck source=/dev/null # Проверяем владельца перед source local _cfg_owner _cfg_owner=$(stat -c '%U' "$CONFIG_FILE" 2>/dev/null || echo "unknown") if [[ "$_cfg_owner" == "root" ]]; then # shellcheck source=/dev/null source "$CONFIG_FILE" normalize_lang_ui normalize_edge_routing_mode else warn "CONFIG_FILE принадлежит не root — пропускаю source" fi fi normalize_lang_ui normalize_edge_routing_mode } save_config() { mkdir -p "$CONFIG_DIR" chmod 700 "$CONFIG_DIR" 2>/dev/null || true local tmp_config tmp_config=$(mktemp "${CONFIG_DIR}/.naive.conf.XXXXXX") || { err "Не удалось создать временный конфиг в $CONFIG_DIR" return 1 } chmod 600 "$tmp_config" normalize_edge_routing_mode if ! { printf 'LANG_UI=%q\n' "${LANG_UI:-ru}" printf 'DOMAIN=%q\n' "${DOMAIN:-}" printf 'DOMAINS=%q\n' "${DOMAINS:-${DOMAIN:-}}" printf '# Отдельный публичный домен страниц подписок; пусто = DOMAIN\n' printf 'SUBSCRIPTION_DOMAIN=%q\n' "${SUBSCRIPTION_DOMAIN:-${DOMAIN:-}}" printf 'EMAIL=%q\n' "${EMAIL:-}" printf '# Красивое имя сервера в подписках, например: 🇫🇮 Finland\n' printf 'PROFILE_LOCATION_LABEL=%q\n' "${PROFILE_LOCATION_LABEL:-Yurich}" printf '# Подписки: 1 = включать локальный сервер в клиентские профили, 0 = только nodes\n' printf 'SUBSCRIPTION_LOCAL_ENABLED=%q\n' "${SUBSCRIPTION_LOCAL_ENABLED:-1}" printf '# Подписки: first или last, если локальный сервер включён\n' printf 'SUBSCRIPTION_LOCAL_POSITION=%q\n' "${SUBSCRIPTION_LOCAL_POSITION:-first}" printf '# Подписки: имена nodes, где публиковать VLESS XHTTP. Примеры: all, poland2, "poland2 test2"\n' printf 'SUBSCRIPTION_XHTTP_NODE_NAMES=%q\n' "${SUBSCRIPTION_XHTTP_NODE_NAMES:-all}" printf '# 1 = nodes из SUBSCRIPTION_XHTTP_NODE_NAMES публикуют только XHTTP, без HTTPS/Turbo/Reality\n' printf 'SUBSCRIPTION_XHTTP_NODE_EXCLUSIVE=%q\n' "${SUBSCRIPTION_XHTTP_NODE_EXCLUSIVE:-0}" printf '# Nodes, используемые только как backend для relay: синхронизируются, но прямые профили не публикуются\n' printf 'SUBSCRIPTION_RELAY_ONLY_NODE_NAMES=%q\n' "${SUBSCRIPTION_RELAY_ONLY_NODE_NAMES:-}" printf '# 1 = публиковать XHTTP в основных клиентских подписках, 0 = держать XHTTP отдельно/для тестов\n' printf 'SUBSCRIPTION_XHTTP_MAIN_ENABLED=%q\n' "${SUBSCRIPTION_XHTTP_MAIN_ENABLED:-1}" printf '# Canary XHTTP: дополнительные nodes публикуются только указанным пользователям\n' printf 'SUBSCRIPTION_XHTTP_CANARY_NODE_NAMES=%q\n' "${SUBSCRIPTION_XHTTP_CANARY_NODE_NAMES:-}" printf 'SUBSCRIPTION_XHTTP_CANARY_USERS=%q\n' "${SUBSCRIPTION_XHTTP_CANARY_USERS:-}" printf '# Новый дизайн страниц подписок: all, * или список пользователей через пробел\n' printf 'SUBSCRIPTION_REMWAVE_USERS=%q\n' "${SUBSCRIPTION_REMWAVE_USERS:-all}" printf 'SUBSCRIPTION_REMWAVE_PREVIEW_USERS=%q\n' "${SUBSCRIPTION_REMWAVE_PREVIEW_USERS:-all}" printf '# Режим входящего 443: caddy = Caddy держит 443; haproxy = HAProxy SNI mux держит 443\n' printf 'EDGE_ROUTING_MODE=%q\n' "${EDGE_ROUTING_MODE:-caddy}" printf '# 1 = стандартный HTTP 407 для Chrome/Edge browser proxy; 0 = stealth 404\n' printf 'BROWSER_PROXY_COMPAT=%q\n' "${BROWSER_PROXY_COMPAT:-0}" printf '# Optional isolated browser proxy route behind HAProxy SNI mux; empty domain = disabled\n' printf 'BROWSER_PROXY_SNI_DOMAIN=%q\n' "${BROWSER_PROXY_SNI_DOMAIN:-}" printf 'BROWSER_PROXY_BACKEND_PORT=%q\n' "${BROWSER_PROXY_BACKEND_PORT:-7444}" printf '# Enable only when the local backend explicitly accepts PROXY protocol v2; default = 0\n' printf 'BROWSER_PROXY_SEND_PROXY_V2=%q\n' "${BROWSER_PROXY_SEND_PROXY_V2:-0}" printf '# Browser subscriptions: host|node-name entries separated by semicolons; empty = disabled\n' printf 'BROWSER_SUBSCRIPTION_PROFILES=%q\n' "${BROWSER_SUBSCRIPTION_PROFILES:-}" printf '# Extra Naive HTTPS profiles: host|node-name entries separated by semicolons; empty = disabled\n' printf 'NAIVE_EXTRA_PROFILES=%q\n' "${NAIVE_EXTRA_PROFILES:-}" printf '# Users allowed to receive extra Naive profiles: *, all or a space/comma-separated list\n' printf 'NAIVE_EXTRA_USERS=%q\n' "${NAIVE_EXTRA_USERS:-}" printf '# HTTPS node sources: node-name|domain|ipv4 entries separated by semicolons; empty = disabled\n' printf 'SUBSCRIPTION_HTTP_NODE_SOURCES=%q\n' "${SUBSCRIPTION_HTTP_NODE_SOURCES:-}" printf '# Subscription rebuild: prefetch remote node profiles once and publish each page atomically\n' printf 'SUBSCRIPTION_NODE_PREFETCH_ENABLED=%q\n' "${SUBSCRIPTION_NODE_PREFETCH_ENABLED:-1}" printf 'SUBSCRIPTION_ATOMIC_PUBLISH=%q\n' "${SUBSCRIPTION_ATOMIC_PUBLISH:-1}" printf '# Subscription rebuild workers: 0 = auto (1-4), explicit safe range = 1-8\n' printf 'SUBSCRIPTION_REBUILD_JOBS=%q\n' "${SUBSCRIPTION_REBUILD_JOBS:-0}" printf '# VLESS relay profiles: source-node|relay-host|relay-port|reality,xhttp entries separated by semicolons\n' printf 'VLESS_RELAY_PROFILES=%q\n' "${VLESS_RELAY_PROFILES:-}" printf '# Users allowed to receive relay profiles: *, all or a space/comma-separated list\n' printf 'VLESS_RELAY_USERS=%q\n' "${VLESS_RELAY_USERS:-}" printf '# Автомониторинг протоколов: тестовый пользователь, повторы, порог средней задержки в мс\n' printf 'PROTOCOL_BENCHMARK_USER=%q\n' "${PROTOCOL_BENCHMARK_USER:-}" printf 'PROTOCOL_BENCHMARK_ROUNDS=%q\n' "${PROTOCOL_BENCHMARK_ROUNDS:-3}" printf 'PROTOCOL_BENCHMARK_MAX_AVG_MS=%q\n' "${PROTOCOL_BENCHMARK_MAX_AVG_MS:-2500}" printf 'PROTOCOL_BENCHMARK_LOG=%q\n' "${PROTOCOL_BENCHMARK_LOG:-$PROTOCOL_BENCHMARK_LOG_DEFAULT}" printf 'PROTOCOL_BENCHMARK_MONITOR_MIN_ROUNDS=%q\n' "${PROTOCOL_BENCHMARK_MONITOR_MIN_ROUNDS:-3}" printf 'PROTOCOL_BENCHMARK_SLOW_MIN_HITS=%q\n' "${PROTOCOL_BENCHMARK_SLOW_MIN_HITS:-2}" printf 'PROTOCOL_BENCHMARK_WARN_MIN_OK_HITS=%q\n' "${PROTOCOL_BENCHMARK_WARN_MIN_OK_HITS:-2}" printf 'PROTOCOL_BENCHMARK_ALERT_REPEAT=%q\n' "${PROTOCOL_BENCHMARK_ALERT_REPEAT:-3}" printf 'PROTOCOL_BENCHMARK_HARD_ALERT_REPEAT=%q\n' "${PROTOCOL_BENCHMARK_HARD_ALERT_REPEAT:-3}" printf 'PROTOCOL_BENCHMARK_ALERT_COOLDOWN_MINUTES=%q\n' "${PROTOCOL_BENCHMARK_ALERT_COOLDOWN_MINUTES:-60}" printf 'PROTOCOL_BENCHMARK_ALERT_STATE=%q\n' "${PROTOCOL_BENCHMARK_ALERT_STATE:-$CONFIG_DIR/protocol-benchmark-alert.state}" printf 'PROTOCOL_BENCHMARK_RECOVERY_ALERT=%q\n' "${PROTOCOL_BENCHMARK_RECOVERY_ALERT:-1}" printf 'PROTOCOL_MONITOR_ALERT_COOLDOWN_MINUTES=%q\n' "${PROTOCOL_MONITOR_ALERT_COOLDOWN_MINUTES:-60}" printf 'PROTOCOL_MONITOR_ALERT_REPEAT=%q\n' "${PROTOCOL_MONITOR_ALERT_REPEAT:-3}" printf 'PROTOCOL_MONITOR_ALERT_STATE=%q\n' "${PROTOCOL_MONITOR_ALERT_STATE:-$CONFIG_DIR/protocol-health-alert.state}" printf 'PROTOCOL_MONITOR_RECOVERY_ALERT=%q\n' "${PROTOCOL_MONITOR_RECOVERY_ALERT:-1}" printf 'TG_TOKEN=%q\n' "${TG_TOKEN:-}" printf 'TG_CHAT_ID=%q\n' "${TG_CHAT_ID:-}" printf '# Доп. администраторы через запятую: id1,id2,id3\n' printf 'TG_ADMINS=%q\n' "${TG_ADMINS:-}" printf '# Отдельный Telegram бот для продаж и автовыдачи подписок\n' printf 'SALES_BOT_TOKEN=%q\n' "${SALES_BOT_TOKEN:-}" printf 'SALES_BOT_ADMIN_ID=%q\n' "${SALES_BOT_ADMIN_ID:-${TG_CHAT_ID:-}}" printf 'SALES_BOT_ADMINS=%q\n' "${SALES_BOT_ADMINS:-}" printf 'SALES_BOT_PLANS=%q\n' "${SALES_BOT_PLANS:-$SALES_BOT_PLANS_DEFAULT}" printf 'SALES_BOT_CURRENCY=%q\n' "${SALES_BOT_CURRENCY:-$SALES_BOT_CURRENCY_DEFAULT}" printf 'SALES_BOT_CHANNEL_URL=%q\n' "${SALES_BOT_CHANNEL_URL:-$SALES_BOT_CHANNEL_URL_DEFAULT}" printf 'SALES_BOT_PAYMENT_TEXT=%q\n' "${SALES_BOT_PAYMENT_TEXT:-$SALES_BOT_PAYMENT_TEXT_DEFAULT}" printf 'SALES_BOT_PAYMENT_QR_PATH=%q\n' "${SALES_BOT_PAYMENT_QR_PATH:-$SALES_BOT_PAYMENT_QR_PATH_DEFAULT}" printf 'SALES_BOT_WELCOME_ANIMATION_PATH=%q\n' "${SALES_BOT_WELCOME_ANIMATION_PATH:-$SALES_BOT_WELCOME_ANIMATION_PATH_DEFAULT}" printf 'SALES_BOT_WELCOME_IMAGE_PATH=%q\n' "${SALES_BOT_WELCOME_IMAGE_PATH:-$SALES_BOT_WELCOME_IMAGE_PATH_DEFAULT}" printf 'SALES_BOT_CAPTCHA_TTL_SECONDS=%q\n' "${SALES_BOT_CAPTCHA_TTL_SECONDS:-$SALES_BOT_CAPTCHA_TTL_SECONDS_DEFAULT}" printf 'SALES_BOT_ORDER_MIN_INTERVAL_SECONDS=%q\n' "${SALES_BOT_ORDER_MIN_INTERVAL_SECONDS:-$SALES_BOT_ORDER_MIN_INTERVAL_SECONDS_DEFAULT}" printf 'SALES_BOT_MAX_PENDING_PER_CHAT=%q\n' "${SALES_BOT_MAX_PENDING_PER_CHAT:-$SALES_BOT_MAX_PENDING_PER_CHAT_DEFAULT}" printf 'SALES_BOT_MAX_ORDER_FILES=%q\n' "${SALES_BOT_MAX_ORDER_FILES:-$SALES_BOT_MAX_ORDER_FILES_DEFAULT}" printf '# 1 = Hysteria 2 включена для роли сервера, 0 = не запускать её при синхронизации пользователей\n' printf 'HYSTERIA_ENABLED=%q\n' "${HYSTERIA_ENABLED:-1}" printf 'HYSTERIA_PORT=%q\n' "${HYSTERIA_PORT:-8443}" printf 'HYSTERIA_PASSWORD=%q\n' "${HYSTERIA_PASSWORD:-}" printf 'HYSTERIA_OBFS_PASSWORD=%q\n' "${HYSTERIA_OBFS_PASSWORD:-}" printf '# 1 = направлять только Hysteria 2 через локальный WARP SOCKS5, не трогая Caddy/Xray\n' printf 'HYSTERIA_WARP_ENABLED=%q\n' "${HYSTERIA_WARP_ENABLED:-0}" printf '# 1 = добавлять Hysteria 2 Port Hopping профиль и UDP redirect диапазона на Hysteria порт\n' printf 'HYSTERIA_PORT_HOP_ENABLED=%q\n' "${HYSTERIA_PORT_HOP_ENABLED:-0}" printf 'HYSTERIA_PORT_HOP_PORTS=%q\n' "${HYSTERIA_PORT_HOP_PORTS:-$HYSTERIA_PORT_HOP_PORTS_DEFAULT}" printf 'UNBOUND_ENABLED=%q\n' "${UNBOUND_ENABLED:-0}" printf 'UNBOUND_MODE=%q\n' "${UNBOUND_MODE:-recursive}" printf 'UNBOUND_ADBLOCK=%q\n' "${UNBOUND_ADBLOCK:-0}" printf 'UNBOUND_GATEWAY_IP=%q\n' "${UNBOUND_GATEWAY_IP:-}" printf 'UNBOUND_MANAGED_GATEWAY=%q\n' "${UNBOUND_MANAGED_GATEWAY:-0}" printf 'UNBOUND_VPN_ENABLED=%q\n' "${UNBOUND_VPN_ENABLED:-0}" printf 'UNBOUND_VPN_CIDRS=%q\n' "${UNBOUND_VPN_CIDRS:-10.0.0.0/24}" printf 'UNBOUND_FILTER_ENABLED=%q\n' "${UNBOUND_FILTER_ENABLED:-1}" printf 'UNBOUND_FILTER_URLS=%q\n' "${UNBOUND_FILTER_URLS:-$DNS_FILTER_URLS_DEFAULT}" printf 'UNBOUND_FILTER_MAX_DOMAINS=%q\n' "${UNBOUND_FILTER_MAX_DOMAINS:-$DNS_FILTER_MAX_DOMAINS_DEFAULT}" printf 'DNS_MONITOR_ALERT_REPEAT=%q\n' "${DNS_MONITOR_ALERT_REPEAT:-3}" printf 'DNS_MONITOR_ALERT_COOLDOWN_MINUTES=%q\n' "${DNS_MONITOR_ALERT_COOLDOWN_MINUTES:-180}" printf 'DNS_MONITOR_ALERT_STATE=%q\n' "${DNS_MONITOR_ALERT_STATE:-$CONFIG_DIR/dns-monitor-alert.state}" printf 'DNS_MONITOR_RECOVERY_ALERT=%q\n' "${DNS_MONITOR_RECOVERY_ALERT:-1}" printf 'WARP_PROXY_PORT=%q\n' "${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" printf 'WARP_PROXY_ENABLED=%q\n' "${WARP_PROXY_ENABLED:-0}" printf 'WARP_MODE=%q\n' "${WARP_MODE:-off}" printf 'WARP_PROTOCOL=%q\n' "${WARP_PROTOCOL:-auto}" printf 'WARP_SSH_ALLOW_CIDRS=%q\n' "${WARP_SSH_ALLOW_CIDRS:-}" printf 'XRAY_WARP_ENABLED=%q\n' "${XRAY_WARP_ENABLED:-${WARP_PROXY_ENABLED:-0}}" printf 'DEVICE_LIMIT_ENABLED=%q\n' "${DEVICE_LIMIT_ENABLED:-0}" printf 'DEVICE_LIMIT=%q\n' "${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}" printf 'DEVICE_WINDOW_HOURS=%q\n' "${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}" printf 'DEVICE_LIMIT_MODE=%q\n' "${DEVICE_LIMIT_MODE:-alert}" printf 'EXPIRED_DELETE_GRACE_DAYS=%q\n' "${EXPIRED_DELETE_GRACE_DAYS:-$EXPIRED_DELETE_GRACE_DAYS_DEFAULT}" printf 'XRAY_ENABLED=%q\n' "${XRAY_ENABLED:-0}" printf 'XRAY_FALLBACK_ENABLED=%q\n' "${XRAY_FALLBACK_ENABLED:-0}" printf '# 1 = включить VLESS Reality inbound, 0 = оставить Xray только для других явно включённых transport\n' printf 'XRAY_REALITY_ENABLED=%q\n' "${XRAY_REALITY_ENABLED:-1}" printf 'XRAY_REALITY_PORT=%q\n' "${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}" printf 'XRAY_REALITY_PUBLIC_PORT=%q\n' "${XRAY_REALITY_PUBLIC_PORT:-${XRAY_REALITY_PUBLIC_PORT_DEFAULT}}" printf 'XRAY_REALITY_SNI_MUX_ENABLED=%q\n' "${XRAY_REALITY_SNI_MUX_ENABLED:-0}" printf 'XRAY_MKCP_PORT=%q\n' "${XRAY_MKCP_PORT:-$XRAY_MKCP_PORT_DEFAULT}" printf 'XRAY_VISION_PORT=%q\n' "${XRAY_VISION_PORT:-$XRAY_VISION_PORT_DEFAULT}" printf '# 1 = включить VLESS XHTTP inbound на сервере, 0 = убрать inbound из Xray\n' printf 'XRAY_XHTTP_ENABLED=%q\n' "${XRAY_XHTTP_ENABLED:-0}" printf 'XRAY_XHTTP_PORT=%q\n' "${XRAY_XHTTP_PORT:-$XRAY_XHTTP_PORT_DEFAULT}" printf '# all/* или список пользователей, разрешённых на XHTTP inbound\n' printf 'XRAY_XHTTP_ALLOWED_USERS=%q\n' "${XRAY_XHTTP_ALLOWED_USERS:-all}" printf 'XRAY_MOBILE_ALT_ENABLED=%q\n' "${XRAY_MOBILE_ALT_ENABLED:-0}" printf 'XRAY_WS_PORT=%q\n' "${XRAY_WS_PORT:-$XRAY_WS_PORT_DEFAULT}" printf 'XRAY_HTTPUPGRADE_PORT=%q\n' "${XRAY_HTTPUPGRADE_PORT:-$XRAY_HTTPUPGRADE_PORT_DEFAULT}" printf 'XRAY_CADDY_FALLBACK_PORT=%q\n' "${XRAY_CADDY_FALLBACK_PORT:-$XRAY_CADDY_FALLBACK_PORT_DEFAULT}" printf 'XRAY_REALITY_TARGET=%q\n' "${XRAY_REALITY_TARGET:-www.microsoft.com:443}" printf 'XRAY_REALITY_SERVER_NAME=%q\n' "${XRAY_REALITY_SERVER_NAME:-www.microsoft.com}" printf '# Optional temporary Reality test profile with a separate SNI/target\n' printf 'XRAY_GITHUB_TEST_ENABLED=%q\n' "${XRAY_GITHUB_TEST_ENABLED:-0}" printf 'XRAY_GITHUB_TEST_USERS=%q\n' "${XRAY_GITHUB_TEST_USERS:-}" printf 'XRAY_GITHUB_TEST_PORT=%q\n' "${XRAY_GITHUB_TEST_PORT:-$XRAY_GITHUB_TEST_PORT_DEFAULT}" printf 'XRAY_GITHUB_TEST_TARGET=%q\n' "${XRAY_GITHUB_TEST_TARGET:-$XRAY_GITHUB_TEST_TARGET_DEFAULT}" printf 'XRAY_GITHUB_TEST_SERVER_NAME=%q\n' "${XRAY_GITHUB_TEST_SERVER_NAME:-$XRAY_GITHUB_TEST_SERVER_NAME_DEFAULT}" printf 'XRAY_REALITY_PRIVATE_KEY=%q\n' "${XRAY_REALITY_PRIVATE_KEY:-}" printf 'XRAY_REALITY_PUBLIC_KEY=%q\n' "${XRAY_REALITY_PUBLIC_KEY:-}" printf 'XRAY_REALITY_SHORT_ID=%q\n' "${XRAY_REALITY_SHORT_ID:-}" printf 'XRAY_TROJAN_PASSWORD=%q\n' "${XRAY_TROJAN_PASSWORD:-}" printf '# Legacy: zapret.dat blackhole routing disabled; kept only for safe migration/status\n' printf 'XRAY_ZAPRET_ENABLED=%q\n' "${XRAY_ZAPRET_ENABLED:-0}" printf 'XRAY_ZAPRET_DAT=%q\n' "${XRAY_ZAPRET_DAT:-$XRAY_ZAPRET_DAT_DEFAULT}" printf 'XRAY_ZAPRET_URL=%q\n' "${XRAY_ZAPRET_URL:-$XRAY_ZAPRET_URL_DEFAULT}" printf 'CADDY_VERSION_PIN=%q\n' "${CADDY_VERSION_PIN:-v2.11.4}" printf 'XCADDY_VERSION_PIN=%q\n' "${XCADDY_VERSION_PIN:-v0.4.6}" printf 'FORWARDPROXY_REF_PIN=%q\n' "${FORWARDPROXY_REF_PIN:-d62c80d3dd2c706b6b87579844d2397bddd18317}" printf 'XRAY_VERSION_PIN=%q\n' "${XRAY_VERSION_PIN:-v26.3.27}" printf 'HYSTERIA_VERSION_PIN=%q\n' "${HYSTERIA_VERSION_PIN:-app/v2.12.3}" printf 'BRIDGE_ENABLED=%q\n' "${BRIDGE_ENABLED:-0}" printf 'BRIDGE_NAME=%q\n' "${BRIDGE_NAME:-}" printf 'BRIDGE_ENTRY_PROTOCOL=%q\n' "${BRIDGE_ENTRY_PROTOCOL:-naive}" printf 'BRIDGE_EXIT_PROTOCOL=%q\n' "${BRIDGE_EXIT_PROTOCOL:-vless}" printf 'BRIDGE_EXIT_URI=%q\n' "${BRIDGE_EXIT_URI:-}" printf 'INSTALLED_AT=%q\n' "$(date '+%Y-%m-%d %H:%M:%S')" } > "$tmp_config"; then rm -f "$tmp_config" err "Не удалось записать временный конфиг" return 1 fi chown root:root "$tmp_config" 2>/dev/null || true chmod 600 "$tmp_config" if ! mv -f "$tmp_config" "$CONFIG_FILE"; then rm -f "$tmp_config" err "Не удалось обновить $CONFIG_FILE" return 1 fi } # ─── Пользователи ──────────────────────────────────────────── load_users() { if [[ ! -f "$USERS_FILE" ]]; then mkdir -p "$CONFIG_DIR" : > "$USERS_FILE" chmod 600 "$USERS_FILE" fi chown root:root "$USERS_FILE" 2>/dev/null || true chmod 600 "$USERS_FILE" 2>/dev/null || true } get_users() { grep -v '^#\|^[[:space:]]*$' "$USERS_FILE" 2>/dev/null || true } is_valid_proxy_hash() { [[ "${1:-}" =~ ^\$2[aby]\$[0-9]{2}\$[./A-Za-z0-9]{53}$ ]] } is_valid_encrypted_proxy_secret() { local value="${1:-}" [[ "${#value}" -ge 128 && "${#value}" -le 2048 ]] || return 1 [[ "$value" =~ ^[A-Za-z0-9+/]+={0,2}$ ]] } credential_bcrypt_cost() { local cost="${CREDENTIAL_BCRYPT_COST:-$CREDENTIAL_BCRYPT_COST_DEFAULT}" [[ "$cost" =~ ^(10|11|12|13|14)$ ]] || cost="$CREDENTIAL_BCRYPT_COST_DEFAULT" printf '%s\n' "$cost" } ensure_credential_tools() { command -v openssl >/dev/null 2>&1 || { err "Для credential vault нужен openssl"; return 1; } if ! command -v htpasswd >/dev/null 2>&1; then info "Устанавливаю apache2-utils для bcrypt..." DEBIAN_FRONTEND=noninteractive apt-get install -y -q apache2-utils >/dev/null 2>&1 \ || { err "Для bcrypt нужен htpasswd (пакет apache2-utils)"; return 1; } fi } ensure_credential_keypair() { ensure_credential_tools || return 1 install -d -o root -g root -m 700 "$CREDENTIALS_DIR" if [[ -s "$CREDENTIALS_PRIVATE_KEY" || -s "$CREDENTIALS_PUBLIC_KEY" ]]; then if [[ ! -s "$CREDENTIALS_PRIVATE_KEY" || ! -s "$CREDENTIALS_PUBLIC_KEY" ]]; then err "Credential keypair неполный; автоматическая замена запрещена" return 1 fi openssl pkey -in "$CREDENTIALS_PRIVATE_KEY" -check -noout >/dev/null 2>&1 || { err "Повреждён private key credential vault"; return 1; } openssl pkey -pubin -in "$CREDENTIALS_PUBLIC_KEY" -noout >/dev/null 2>&1 || { err "Повреждён public key credential vault"; return 1; } local derived_public_hash stored_public_hash derived_public_hash=$(openssl pkey -in "$CREDENTIALS_PRIVATE_KEY" -pubout -outform DER 2>/dev/null | sha256sum | awk '{print $1}') stored_public_hash=$(openssl pkey -pubin -in "$CREDENTIALS_PUBLIC_KEY" -outform DER 2>/dev/null | sha256sum | awk '{print $1}') [[ -n "$derived_public_hash" && "$derived_public_hash" == "$stored_public_hash" ]] \ || { err "Private/public key credential vault не совпадают"; return 1; } touch "$USERS_SECRETS_FILE" "$CREDENTIALS_LOCK_FILE" chmod 600 "$CREDENTIALS_PRIVATE_KEY" "$USERS_SECRETS_FILE" "$CREDENTIALS_LOCK_FILE" 2>/dev/null || true chmod 644 "$CREDENTIALS_PUBLIC_KEY" 2>/dev/null || true return 0 fi if [[ -s "$USERS_SECRETS_FILE" ]] \ || grep -Eq ':\$2[aby]\$' "$USERS_FILE" 2>/dev/null \ || grep -Eq ':\$2[aby]\$' "$DISABLED_USERS_FILE" 2>/dev/null; then err "Credential vault key отсутствует для существующего protected store; автоматическая замена запрещена" return 1 fi local private_tmp public_tmp private_tmp=$(mktemp "${CREDENTIALS_DIR}/private.pem.XXXXXX") || return 1 public_tmp=$(mktemp "${CREDENTIALS_DIR}/public.pem.XXXXXX") || { rm -f "$private_tmp"; return 1; } chmod 600 "$private_tmp" "$public_tmp" if ! openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out "$private_tmp" >/dev/null 2>&1 \ || ! openssl pkey -in "$private_tmp" -pubout -out "$public_tmp" >/dev/null 2>&1; then rm -f "$private_tmp" "$public_tmp" err "Не удалось создать RSA-OAEP keypair для credential vault" return 1 fi mv -f "$private_tmp" "$CREDENTIALS_PRIVATE_KEY" mv -f "$public_tmp" "$CREDENTIALS_PUBLIC_KEY" chown root:root "$CREDENTIALS_PRIVATE_KEY" "$CREDENTIALS_PUBLIC_KEY" 2>/dev/null || true chmod 600 "$CREDENTIALS_PRIVATE_KEY" chmod 644 "$CREDENTIALS_PUBLIC_KEY" : > "$USERS_SECRETS_FILE" chmod 600 "$USERS_SECRETS_FILE" ok "Создан локальный RSA-OAEP credential vault" } hash_proxy_password() { local user="$1" pass="$2" record hash ensure_credential_tools || return 1 is_valid_proxy_user "$user" || return 1 is_valid_proxy_pass "$pass" || return 1 record=$(printf '%s\n' "$pass" | htpasswd -niBC "$(credential_bcrypt_cost)" "$user" 2>/dev/null) || return 1 hash="${record#*:}" is_valid_proxy_hash "$hash" || return 1 printf '%s\n' "$hash" } verify_proxy_password_hash() { local user="$1" pass="$2" hash="$3" verify_file is_valid_proxy_user "$user" || return 1 is_valid_proxy_pass "$pass" || return 1 is_valid_proxy_hash "$hash" || return 1 ensure_credential_tools || return 1 verify_file=$(mktemp /tmp/yurich-credential-verify.XXXXXX) || return 1 chmod 600 "$verify_file" printf '%s:%s\n' "$user" "$hash" > "$verify_file" if printf '%s\n' "$pass" | htpasswd -vi "$verify_file" "$user" >/dev/null 2>&1; then rm -f "$verify_file" return 0 fi rm -f "$verify_file" return 1 } encrypt_proxy_password() { local pass="$1" encrypted ensure_credential_keypair || return 1 is_valid_proxy_pass "$pass" || return 1 encrypted=$(printf '%s' "$pass" \ | openssl pkeyutl -encrypt -pubin -inkey "$CREDENTIALS_PUBLIC_KEY" \ -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha256 2>/dev/null \ | base64 -w0) || return 1 is_valid_encrypted_proxy_secret "$encrypted" || return 1 printf '%s\n' "$encrypted" } decrypt_proxy_password_with_key() { local encrypted="$1" private_key="$2" pass is_valid_encrypted_proxy_secret "$encrypted" || return 1 [[ -s "$private_key" ]] || return 1 pass=$(printf '%s' "$encrypted" \ | base64 -d 2>/dev/null \ | openssl pkeyutl -decrypt -inkey "$private_key" \ -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha256 2>/dev/null) || return 1 is_valid_proxy_pass "$pass" || return 1 printf '%s\n' "$pass" } decrypt_proxy_password() { decrypt_proxy_password_with_key "$1" "$CREDENTIALS_PRIVATE_KEY" } credential_file_value() { local file="$1" lookup_user="$2" [[ -f "$file" ]] || return 1 awk -F: -v user="$lookup_user" '$1 == user {sub(/^[^:]*:/, ""); print; exit}' "$file" } get_user_hash() { local lookup_user="$1" value value=$(credential_file_value "$USERS_FILE" "$lookup_user" 2>/dev/null || true) [[ -n "$value" ]] || return 1 if is_valid_proxy_hash "$value"; then printf '%s\n' "$value" return 0 fi is_valid_proxy_pass "$value" || return 1 hash_proxy_password "$lookup_user" "$value" } get_user_pass() { local lookup_user="$1" encrypted legacy_value encrypted=$(credential_file_value "$USERS_SECRETS_FILE" "$lookup_user" 2>/dev/null || true) if [[ -n "$encrypted" ]]; then decrypt_proxy_password "$encrypted" return $? fi # Read-only compatibility for pre-v5.7 installs. New writes never use plaintext. legacy_value=$(credential_file_value "$USERS_FILE" "$lookup_user" 2>/dev/null || true) if is_valid_proxy_pass "$legacy_value" && ! is_valid_proxy_hash "$legacy_value"; then printf '%s\n' "$legacy_value" return 0 fi return 1 } get_active_user_hashes() { local user value hash while IFS=: read -r user value; do [[ -z "$user" || -z "$value" ]] && continue if ! user_is_expired "$user"; then hash=$(get_user_hash "$user" 2>/dev/null || true) [[ -n "$hash" ]] && printf '%s:%s\n' "$user" "$hash" fi done < <(get_users) } get_active_users() { local user value pass while IFS=: read -r user value; do [[ -z "$user" || -z "$value" ]] && continue if ! user_is_expired "$user"; then pass=$(get_user_pass "$user" 2>/dev/null || true) [[ -n "$pass" ]] && printf '%s:%s\n' "$user" "$pass" fi done < <(get_users) } get_active_user_pass() { local lookup_user="$1" if user_is_expired "$lookup_user"; then return 1 fi get_user_pass "$lookup_user" } active_user_count() { get_active_user_hashes | wc -l } write_active_users_hash_file() { local tmp ensure_credential_keypair || return 1 tmp=$(mktemp "${CREDENTIALS_DIR}/users.active.XXXXXX") || return 1 { echo "# Yurich Panel active bcrypt credentials" get_active_user_hashes } > "$tmp" install -o root -g root -m 600 "$tmp" "$ACTIVE_USERS_HASH_FILE" rm -f "$tmp" } credential_state_backup() { local destination="$1" mkdir -p "$destination" chmod 700 "$destination" local file name for file in "$USERS_FILE" "$DISABLED_USERS_FILE"; do name=$(basename "$file") if [[ -e "$file" ]]; then cp -a "$file" "$destination/$name"; else : > "$destination/$name.missing"; fi done if [[ -d "$CREDENTIALS_DIR" ]]; then cp -a "$CREDENTIALS_DIR" "$destination/credentials" else : > "$destination/credentials.missing" fi } credential_state_restore() { local source="$1" file name for file in "$USERS_FILE" "$DISABLED_USERS_FILE"; do name=$(basename "$file") if [[ -f "$source/$name.missing" ]]; then rm -f "$file" elif [[ -f "$source/$name" ]]; then install -o root -g root -m 600 "$source/$name" "$file" fi done rm -rf "$CREDENTIALS_DIR" if [[ -d "$source/credentials" ]]; then cp -a "$source/credentials" "$CREDENTIALS_DIR" fi } credential_set_user() { local user="$1" pass="$2" hash encrypted users_tmp secrets_tmp users_old secrets_old lock_fd is_valid_proxy_user "$user" || return 1 is_valid_proxy_pass "$pass" || return 1 ensure_credential_keypair || return 1 hash=$(hash_proxy_password "$user" "$pass") || { err "Не удалось создать bcrypt для $user"; return 1; } encrypted=$(encrypt_proxy_password "$pass") || { err "Не удалось зашифровать secret для $user"; return 1; } touch "$CREDENTIALS_LOCK_FILE" "$USERS_SECRETS_FILE" chmod 600 "$CREDENTIALS_LOCK_FILE" "$USERS_SECRETS_FILE" exec {lock_fd}>"$CREDENTIALS_LOCK_FILE" flock -x "$lock_fd" || { exec {lock_fd}>&-; return 1; } users_tmp=$(mktemp "${CONFIG_DIR}/users.conf.XXXXXX") || { flock -u "$lock_fd"; exec {lock_fd}>&-; return 1; } secrets_tmp=$(mktemp "${CREDENTIALS_DIR}/users.secrets.XXXXXX") || { rm -f "$users_tmp"; flock -u "$lock_fd"; exec {lock_fd}>&-; return 1; } users_old=$(mktemp "${CREDENTIALS_DIR}/users.old.XXXXXX") \ || { rm -f "$users_tmp" "$secrets_tmp"; flock -u "$lock_fd"; exec {lock_fd}>&-; return 1; } secrets_old=$(mktemp "${CREDENTIALS_DIR}/secrets.old.XXXXXX") \ || { rm -f "$users_tmp" "$secrets_tmp" "$users_old"; flock -u "$lock_fd"; exec {lock_fd}>&-; return 1; } cp -a "$USERS_FILE" "$users_old" cp -a "$USERS_SECRETS_FILE" "$secrets_old" awk -F: -v user="$user" '$1 != user' "$USERS_FILE" > "$users_tmp" printf '%s:%s\n' "$user" "$hash" >> "$users_tmp" awk -F: -v user="$user" '$1 != user' "$USERS_SECRETS_FILE" > "$secrets_tmp" printf '%s:%s\n' "$user" "$encrypted" >> "$secrets_tmp" if ! install -o root -g root -m 600 "$users_tmp" "$USERS_FILE" \ || ! install -o root -g root -m 600 "$secrets_tmp" "$USERS_SECRETS_FILE" \ || [[ "$(get_user_pass "$user" 2>/dev/null || true)" != "$pass" ]] \ || ! verify_proxy_password_hash "$user" "$pass" "$hash" \ || ! write_active_users_hash_file >/dev/null 2>&1; then install -o root -g root -m 600 "$users_old" "$USERS_FILE" 2>/dev/null || true install -o root -g root -m 600 "$secrets_old" "$USERS_SECRETS_FILE" 2>/dev/null || true rm -f "$users_tmp" "$secrets_tmp" "$users_old" "$secrets_old" flock -u "$lock_fd"; exec {lock_fd}>&- err "Credential transaction для $user отменена" return 1 fi rm -f "$users_tmp" "$secrets_tmp" "$users_old" "$secrets_old" flock -u "$lock_fd"; exec {lock_fd}>&- return 0 } credential_remove_user_secret() { local user="$1" tmp lock_fd [[ -f "$USERS_SECRETS_FILE" ]] || return 0 touch "$CREDENTIALS_LOCK_FILE" chmod 600 "$CREDENTIALS_LOCK_FILE" exec {lock_fd}>"$CREDENTIALS_LOCK_FILE" flock -x "$lock_fd" || { exec {lock_fd}>&-; return 1; } tmp=$(mktemp "${CREDENTIALS_DIR}/users.secrets.XXXXXX") \ || { flock -u "$lock_fd"; exec {lock_fd}>&-; return 1; } awk -F: -v user="$user" '$1 != user' "$USERS_SECRETS_FILE" > "$tmp" if ! install -o root -g root -m 600 "$tmp" "$USERS_SECRETS_FILE"; then rm -f "$tmp" flock -u "$lock_fd"; exec {lock_fd}>&- return 1 fi rm -f "$tmp" flock -u "$lock_fd"; exec {lock_fd}>&- } credential_plaintext_count() { local count=0 file user value for file in "$USERS_FILE" "$DISABLED_USERS_FILE"; do [[ -f "$file" ]] || continue while IFS=: read -r user value; do [[ -z "$user" || -z "$value" ]] && continue is_valid_proxy_hash "$value" || count=$((count + 1)) done < <(grep -v '^#\|^[[:space:]]*$' "$file" 2>/dev/null || true) done printf '%s\n' "$count" } credential_store_integrity_check() { local file user value encrypted pass local -A seen=() command -v htpasswd >/dev/null 2>&1 || { err "Credential integrity: htpasswd не найден"; return 1; } [[ -s "$CREDENTIALS_PRIVATE_KEY" && -s "$CREDENTIALS_PUBLIC_KEY" && -f "$USERS_SECRETS_FILE" ]] \ || { err "Credential integrity: vault неполный"; return 1; } local derived_public_hash stored_public_hash derived_public_hash=$(openssl pkey -in "$CREDENTIALS_PRIVATE_KEY" -pubout -outform DER 2>/dev/null | sha256sum | awk '{print $1}') stored_public_hash=$(openssl pkey -pubin -in "$CREDENTIALS_PUBLIC_KEY" -outform DER 2>/dev/null | sha256sum | awk '{print $1}') [[ -n "$derived_public_hash" && "$derived_public_hash" == "$stored_public_hash" ]] \ || { err "Credential integrity: RSA keypair не совпадает"; return 1; } for file in "$USERS_FILE" "$DISABLED_USERS_FILE"; do [[ -f "$file" ]] || continue while IFS=: read -r user value; do [[ -z "$user" || -z "$value" || "$user" == \#* ]] && continue [[ -z "${seen[$user]+x}" ]] || { err "Credential integrity: duplicate user $user"; return 1; } seen[$user]=1 is_valid_proxy_hash "$value" || continue encrypted=$(credential_file_value "$USERS_SECRETS_FILE" "$user" 2>/dev/null || true) pass=$(decrypt_proxy_password "$encrypted" 2>/dev/null || true) verify_proxy_password_hash "$user" "$pass" "$value" \ || { err "Credential integrity: bcrypt/vault mismatch for $user"; return 1; } done < <(grep -v '^#\|^[[:space:]]*$' "$file" 2>/dev/null || true) done } credential_migrate_store() ( local active_tmp disabled_tmp secrets_tmp file output user value hash encrypted pass migration_lock_fd local -A seen=() ensure_credential_keypair || return 1 exec {migration_lock_fd}>"$CREDENTIALS_LOCK_FILE" flock -x "$migration_lock_fd" || return 1 active_tmp=$(mktemp "${CONFIG_DIR}/users.conf.migrate.XXXXXX") || return 1 disabled_tmp=$(mktemp "${CONFIG_DIR}/users.disabled.migrate.XXXXXX") || { rm -f "$active_tmp"; return 1; } secrets_tmp=$(mktemp "${CREDENTIALS_DIR}/users.secrets.migrate.XXXXXX") || { rm -f "$active_tmp" "$disabled_tmp"; return 1; } : > "$active_tmp"; : > "$disabled_tmp"; : > "$secrets_tmp" for file in "$USERS_FILE" "$DISABLED_USERS_FILE"; do [[ "$file" == "$USERS_FILE" ]] && output="$active_tmp" || output="$disabled_tmp" [[ -f "$file" ]] || continue while IFS=: read -r user value; do [[ -z "$user" || -z "$value" || "$user" == \#* ]] && continue is_valid_proxy_user "$user" || { err "Миграция: некорректный пользователь $user"; rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp"; return 1; } [[ -z "${seen[$user]+x}" ]] || { err "Миграция: пользователь $user встречается дважды"; rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp"; return 1; } seen[$user]=1 encrypted=$(credential_file_value "$USERS_SECRETS_FILE" "$user" 2>/dev/null || true) if is_valid_proxy_hash "$value"; then hash="$value" is_valid_encrypted_proxy_secret "$encrypted" || { err "Миграция: для $user нет encrypted secret; сначала ротируй пароль"; rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp"; return 1; } pass=$(decrypt_proxy_password "$encrypted" 2>/dev/null || true) verify_proxy_password_hash "$user" "$pass" "$hash" || { err "Миграция: encrypted secret $user не соответствует bcrypt"; rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp"; return 1; } else pass="$value" is_valid_proxy_pass "$pass" || { err "Миграция: некорректный legacy-пароль $user"; rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp"; return 1; } hash=$(hash_proxy_password "$user" "$pass") || { rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp"; return 1; } encrypted=$(encrypt_proxy_password "$pass") || { rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp"; return 1; } fi printf '%s:%s\n' "$user" "$hash" >> "$output" printf '%s:%s\n' "$user" "$encrypted" >> "$secrets_tmp" done < <(grep -v '^#\|^[[:space:]]*$' "$file" 2>/dev/null || true) done if ! install -o root -g root -m 600 "$active_tmp" "$USERS_FILE" \ || ! install -o root -g root -m 600 "$disabled_tmp" "$DISABLED_USERS_FILE" \ || ! install -o root -g root -m 600 "$secrets_tmp" "$USERS_SECRETS_FILE"; then rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp" return 1 fi rm -f "$active_tmp" "$disabled_tmp" "$secrets_tmp" write_active_users_hash_file ) cmd_credentials_status() { load_users local total legacy encrypted=0 missing=0 user value total=$(get_users | wc -l) legacy=$(credential_plaintext_count) while IFS=: read -r user value; do [[ -z "$user" ]] && continue if is_valid_encrypted_proxy_secret "$(credential_file_value "$USERS_SECRETS_FILE" "$user" 2>/dev/null || true)"; then encrypted=$((encrypted + 1)) else missing=$((missing + 1)) fi done < <(get_users) hr echo -e "${BOLD} Credential storage${RESET}" hr echo -e " Пользователей: ${CYAN}${total}${RESET}" echo -e " Bcrypt + vault: ${GREEN}${encrypted}${RESET}" echo -e " Legacy plaintext: $([[ "$legacy" -eq 0 ]] && echo -e "${GREEN}0${RESET}" || echo -e "${RED}${legacy}${RESET}")" echo -e " Без encrypted secret: $([[ "$missing" -eq 0 ]] && echo -e "${GREEN}0${RESET}" || echo -e "${RED}${missing}${RESET}")" [[ -s "$CREDENTIALS_PRIVATE_KEY" ]] && ok "RSA-OAEP keypair: готов" || warn "RSA-OAEP keypair: ещё не создан" } cmd_credentials_migrate() { load_config load_users local legacy runtime_backup caddy_had=0 hysteria_had=0 legacy=$(credential_plaintext_count) if [[ "$legacy" -eq 0 ]]; then if [[ "$(get_users | wc -l)" -gt 0 || -s "$DISABLED_USERS_FILE" ]]; then credential_store_integrity_check || { err "Protected credential store повреждён; миграция без восстановления запрещена" return 1 } fi write_active_users_hash_file >/dev/null 2>&1 || return 1 ok "Credential store уже использует bcrypt + encrypted vault" return 0 fi runtime_backup=$(mktemp -d /run/yurich-credential-migration.XXXXXX) || return 1 chmod 700 "$runtime_backup" credential_state_backup "$runtime_backup/state" || { rm -rf "$runtime_backup"; return 1; } if [[ -f "$CADDYFILE" ]]; then cp -a "$CADDYFILE" "$runtime_backup/Caddyfile"; caddy_had=1; fi if [[ -f "$HYSTERIA_CONFIG" ]]; then cp -a "$HYSTERIA_CONFIG" "$runtime_backup/hysteria.yaml"; hysteria_had=1; fi info "Мигрирую ${legacy} legacy credentials в bcrypt + RSA-OAEP vault..." if ! credential_migrate_store || ! apply_user_access_runtime; then err "Миграция не применена; восстанавливаю прежнее состояние" credential_state_restore "$runtime_backup/state" || true [[ "$caddy_had" -eq 1 ]] && install -m 600 "$runtime_backup/Caddyfile" "$CADDYFILE" [[ "$hysteria_had" -eq 1 ]] && install -m 600 "$runtime_backup/hysteria.yaml" "$HYSTERIA_CONFIG" systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy >/dev/null 2>&1 || true systemctl restart hysteria >/dev/null 2>&1 || true rm -rf "$runtime_backup" return 1 fi if [[ "$(credential_plaintext_count)" -ne 0 ]] || grep -q 'type: userpass' "$HYSTERIA_CONFIG" 2>/dev/null; then err "Post-migration audit не пройден; выполняю rollback" credential_state_restore "$runtime_backup/state" || true [[ "$caddy_had" -eq 1 ]] && install -m 600 "$runtime_backup/Caddyfile" "$CADDYFILE" [[ "$hysteria_had" -eq 1 ]] && install -m 600 "$runtime_backup/hysteria.yaml" "$HYSTERIA_CONFIG" systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy >/dev/null 2>&1 || true systemctl restart hysteria >/dev/null 2>&1 || true rm -rf "$runtime_backup" return 1 fi { printf 'MIGRATED_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'USERS=%q\n' "$(get_users | wc -l)" printf 'BCRYPT_COST=%q\n' "$(credential_bcrypt_cost)" printf 'FORMAT=%q\n' "bcrypt+rsa-oaep-sha256" } > "$CREDENTIALS_MIGRATION_RECEIPT" chmod 600 "$CREDENTIALS_MIGRATION_RECEIPT" rm -rf "$runtime_backup" ok "Миграция завершена: plaintext credentials удалены из user store, Caddy и Hysteria config" } is_valid_user_months() { [[ "${1:-}" =~ ^([1-9]|1[0-2])$ ]] } is_valid_user_term() { [[ "${1:-}" =~ ^1d$ || "${1:-}" =~ ^([1-9]|1[0-2])m$ || "${1:-}" =~ ^([1-9]|1[0-2])$ ]] } normalize_user_term() { local term="${1:-}" case "$term" in 1d|day|день) printf '1d' ;; 1|2|3|4|5|6|7|8|9|10|11|12) printf '%sm\n' "$term" ;; 1m|2m|3m|4m|5m|6m|7m|8m|9m|10m|11m|12m) printf '%s\n' "$term" ;; *) return 1 ;; esac } user_term_label() { local term term=$(normalize_user_term "${1:-}" 2>/dev/null || true) case "$term" in 1d) printf '1 день' ;; 1m) printf '1 месяц' ;; 2m|3m|4m) printf '%s месяца' "${term%m}" ;; *m) printf '%s месяцев' "${term%m}" ;; *) printf '%s' "${1:-?}" ;; esac } user_meta_file() { local user="$1" printf '%s/%s.env\n' "$USER_META_DIR" "$user" } add_calendar_months_utc() { local base="$1" months="$2" target_month last_day day is_valid_user_months "$months" || return 1 [[ "$base" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1 [[ "$(date -u -d "$base" '+%Y-%m-%d' 2>/dev/null)" == "$base" ]] || return 1 target_month=$(date -u -d "${base:0:7}-01 +${months} months" '+%Y-%m') || return 1 last_day=$(date -u -d "${target_month}-01 +1 month -1 day" '+%d') || return 1 day=$((10#${base:8:2})) if (( day > 10#$last_day )); then day=$((10#$last_day)) fi printf '%s-%02d\n' "$target_month" "$day" } set_user_expiry_months() { local user="$1" months="$2" expires_at created_at meta_file today if ! is_valid_proxy_user "$user"; then err "Некорректный пользователь для срока: $user" return 1 fi if ! is_valid_user_months "$months"; then err "Срок пользователя: только 1-12 месяцев" return 1 fi mkdir -p "$USER_META_DIR" chmod 700 "$USER_META_DIR" created_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ') today=$(date -u '+%Y-%m-%d') expires_at=$(add_calendar_months_utc "$today" "$months" 2>/dev/null || true) if [[ ! "$expires_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then err "Не удалось рассчитать срок пользователя" return 1 fi meta_file=$(user_meta_file "$user") { printf 'USER=%q\n' "$user" printf 'CREATED_AT=%q\n' "$created_at" printf 'TERM_MONTHS=%q\n' "$months" printf 'EXPIRES_AT=%q\n' "$expires_at" } > "$meta_file" chmod 600 "$meta_file" } set_user_expiry_extend_months() { local user="$1" months="$2" current today base expires_at if ! is_valid_proxy_user "$user"; then err "Некорректный пользователь для продления: $user" return 1 fi if ! is_valid_user_months "$months"; then err "Срок пользователя: только 1-12 месяцев" return 1 fi today=$(date -u '+%Y-%m-%d') current=$(get_user_expiry "$user" 2>/dev/null || true) if [[ "$current" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ && "$current" > "$today" ]]; then base="$current" else base="$today" fi expires_at=$(add_calendar_months_utc "$base" "$months" 2>/dev/null || true) if [[ ! "$expires_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then err "Не удалось рассчитать срок продления" return 1 fi user_meta_set "$user" TERM_MONTHS "$months" >/dev/null user_meta_set "$user" EXPIRES_AT "$expires_at" >/dev/null user_meta_set "$user" UPDATED_AT "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >/dev/null } set_user_expiry_extend_term() { local user="$1" raw_term="$2" term current today base expires_at months if ! is_valid_proxy_user "$user"; then err "Некорректный пользователь для продления: $user" return 1 fi term=$(normalize_user_term "$raw_term" 2>/dev/null || true) if ! is_valid_user_term "$term"; then err "Срок пользователя: 1 день или 1-12 месяцев" return 1 fi today=$(date -u '+%Y-%m-%d') current=$(get_user_expiry "$user" 2>/dev/null || true) if [[ "$current" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ && "$current" > "$today" ]]; then base="$current" else base="$today" fi case "$term" in 1d) expires_at=$(date -u -d "${base} +1 day" '+%Y-%m-%d' 2>/dev/null || true) ;; *m) months="${term%m}" expires_at=$(add_calendar_months_utc "$base" "$months" 2>/dev/null || true) ;; esac if [[ ! "$expires_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then err "Не удалось рассчитать срок продления" return 1 fi if [[ "$term" == "1d" ]]; then user_meta_set "$user" TERM_DAYS "1" >/dev/null user_meta_set "$user" TERM_MONTHS "" >/dev/null else user_meta_set "$user" TERM_MONTHS "$months" >/dev/null user_meta_set "$user" TERM_DAYS "" >/dev/null fi user_meta_set "$user" TERM "$term" >/dev/null user_meta_set "$user" EXPIRES_AT "$expires_at" >/dev/null user_meta_set "$user" UPDATED_AT "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >/dev/null } get_user_expiry() { local user="$1" meta_file expires if ! is_valid_proxy_user "$user"; then return 1 fi meta_file=$(user_meta_file "$user") [[ -f "$meta_file" ]] || return 1 expires=$(awk -F= '$1=="EXPIRES_AT"{gsub(/'\''|"|[[:space:]]/,"",$2); print $2; exit}' "$meta_file" 2>/dev/null || true) [[ "$expires" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1 printf '%s\n' "$expires" } user_is_blocked() { local user="$1" blocked blocked=$(user_meta_get "$user" ACCESS_BLOCKED 2>/dev/null || true) [[ "$blocked" == "1" ]] } user_is_time_expired() { local user="$1" expires today expires=$(get_user_expiry "$user" 2>/dev/null || true) [[ -n "$expires" ]] || return 1 today=$(date -u '+%Y-%m-%d') [[ "$expires" < "$today" ]] } user_is_expired() { local user="$1" user_is_blocked "$user" && return 0 user_is_time_expired "$user" } user_expiry_label() { local user="$1" expires today if user_is_blocked "$user"; then printf 'приостановлен администратором' return fi expires=$(get_user_expiry "$user" 2>/dev/null || true) if [[ -z "$expires" ]]; then printf 'без срока' return fi today=$(date -u '+%Y-%m-%d') if [[ "$expires" < "$today" ]]; then printf 'истёк %s' "$expires" else printf 'до %s' "$expires" fi } user_expiry_tag() { local user="$1" expires expires=$(get_user_expiry "$user" 2>/dev/null || true) if [[ -z "$expires" ]]; then printf 'lifetime' else printf 'until-%s' "${expires//-/}" fi } user_expiry_epoch() { local user="$1" expires expires=$(get_user_expiry "$user" 2>/dev/null || true) [[ -n "$expires" ]] || return 1 date -u -d "${expires} 23:59:59" '+%s' 2>/dev/null || return 1 } user_expired_days() { local user="$1" expires today_ts expires_ts expires=$(get_user_expiry "$user" 2>/dev/null || true) [[ "$expires" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1 today_ts=$(date -u -d "$(date -u '+%Y-%m-%d')" '+%s' 2>/dev/null || return 1) expires_ts=$(date -u -d "$expires" '+%s' 2>/dev/null || return 1) if [[ "$today_ts" -le "$expires_ts" ]]; then printf '0\n' else printf '%s\n' $(( (today_ts - expires_ts) / 86400 )) fi } prompt_user_term_months() { local default_months="${1:-12}" ans is_valid_user_months "$default_months" || default_months="12" if [[ -t 0 ]]; then # The function is often called inside command substitution, so keep the # prompt on stderr but read from the active stdin. Reading /dev/tty can # hang or hide the prompt in some provider serial consoles. printf "%b" "${CYAN}Срок пользователя 1-12 месяцев [${default_months}]: ${RESET}" >&2 read -r ans || ans="" else ans="$default_months" fi ans="${ans:-$default_months}" if ! is_valid_user_months "$ans"; then err "Срок должен быть от 1 до 12 месяцев" >&2 return 1 fi printf '%s\n' "$ans" } cleanup_user_metadata() { local user="$1" is_valid_proxy_user "$user" || return 0 rm -f -- "$(user_meta_file "$user")" 2>/dev/null || true } user_meta_get() { local user="$1" key="$2" meta_file is_valid_proxy_user "$user" || return 1 [[ "$key" =~ ^[A-Z0-9_]+$ ]] || return 1 meta_file=$(user_meta_file "$user") [[ -f "$meta_file" ]] || return 1 awk -F= -v k="$key" '$1 == k { sub(/^[^=]*=/, "") gsub(/^'\''|'\''$/, "") gsub(/^"|"$/, "") print exit }' "$meta_file" 2>/dev/null || true } user_meta_set() { local user="$1" key="$2" value="$3" meta_file tmp if ! is_valid_proxy_user "$user"; then err "Некорректный пользователь: $user" return 1 fi if [[ ! "$key" =~ ^[A-Z0-9_]+$ ]]; then err "Некорректный ключ metadata: $key" return 1 fi mkdir -p "$USER_META_DIR" chmod 700 "$USER_META_DIR" meta_file=$(user_meta_file "$user") tmp=$(mktemp) if [[ -f "$meta_file" ]]; then awk -F= -v k="$key" '$1 != k {print}' "$meta_file" > "$tmp" else : > "$tmp" fi if ! grep -q '^USER=' "$tmp" 2>/dev/null; then printf 'USER=%q\n' "$user" >> "$tmp" fi if ! grep -q '^CREATED_AT=' "$tmp" 2>/dev/null; then printf 'CREATED_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> "$tmp" fi printf '%s=%q\n' "$key" "$value" >> "$tmp" install -m 600 "$tmp" "$meta_file" rm -f "$tmp" } user_meta_unset() { local user="$1" key="$2" meta_file tmp is_valid_proxy_user "$user" || return 1 [[ "$key" =~ ^[A-Z0-9_]+$ ]] || return 1 meta_file=$(user_meta_file "$user") [[ -f "$meta_file" ]] || return 0 tmp=$(mktemp) awk -F= -v k="$key" '$1 != k {print}' "$meta_file" > "$tmp" install -m 600 "$tmp" "$meta_file" rm -f "$tmp" } is_valid_tg_chat_id() { [[ "${1:-}" =~ ^-?[0-9]{5,20}$ ]] } tg_api_with_token() { local token="$1" method="$2" shift 2 [[ "$token" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] || return 1 [[ "$method" =~ ^[A-Za-z][A-Za-z0-9]*(\?[A-Za-z0-9_.%=&-]+)?$ ]] || return 1 local cfg rc cfg=$(mktemp /tmp/yurich_tg_api_XXXXXX) || return 1 chmod 600 "$cfg" 2>/dev/null || true printf 'url = "https://api.telegram.org/bot%s/%s"\n' "$token" "$method" > "$cfg" curl --config "$cfg" "$@" rc=$? rm -f "$cfg" 2>/dev/null || true return "$rc" } tg_api() { local method="$1" shift [[ -z "${TG_TOKEN:-}" ]] && return 1 tg_api_with_token "$TG_TOKEN" "$method" "$@" } list_subscription_users() { { get_users | awk -F: '{print $1}' [[ -s "$XRAY_USERS_FILE" ]] && awk -F: '{print $1}' "$XRAY_USERS_FILE" } | awk 'NF && !seen[$0]++' } days_until_expiry() { local expires="$1" exp_ts today_ts [[ "$expires" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1 exp_ts=$(date -u -d "$expires" '+%s' 2>/dev/null || true) today_ts=$(date -u -d "$(date -u '+%Y-%m-%d')" '+%s' 2>/dev/null || true) [[ -n "$exp_ts" && -n "$today_ts" ]] || return 1 printf '%s\n' $(( (exp_ts - today_ts) / 86400 )) } expiry_days_text() { local days="${1:-0}" if (( days < 0 )); then printf 'срок уже истёк' elif (( days == 0 )); then printf 'сегодня последний день' elif (( days == 1 )); then printf 'остался 1 день' elif (( days >= 2 && days <= 4 )); then printf 'осталось %s дня' "$days" else printf 'осталось %s дней' "$days" fi } tg_send_to_chat() { local chat_id="$1" message="$2" resp [[ -z "${TG_TOKEN:-}" ]] && return 1 is_valid_tg_chat_id "$chat_id" || return 1 resp=$(tg_api "sendMessage" -s --max-time 12 --retry 2 --retry-delay 2 \ -X POST \ --data-urlencode "chat_id=${chat_id}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "disable_web_page_preview=true" \ --data-urlencode "text=${message}" \ 2>/dev/null || true) echo "$resp" | grep -q '"ok":true' } sales_bot_renew_url() { local username resp if [[ -n "${SALES_BOT_PUBLIC_URL:-}" ]]; then printf '%s\n' "$SALES_BOT_PUBLIC_URL" return 0 fi if [[ -n "${SALES_BOT_USERNAME:-}" ]]; then printf 'https://t.me/%s?start=renew\n' "${SALES_BOT_USERNAME#@}" return 0 fi [[ -n "${SALES_BOT_TOKEN:-}" ]] || return 1 resp=$(tg_api_with_token "$SALES_BOT_TOKEN" "getMe" -s --max-time 12 2>/dev/null || true) username=$(printf '%s\n' "$resp" | grep -o '"username":"[^"]*"' | head -1 | cut -d'"' -f4) [[ -n "$username" ]] || return 1 printf 'https://t.me/%s?start=renew\n' "$username" } tg_send_to_chat_with_renew_button() { local chat_id="$1" message="$2" renew_url="$3" resp reply_markup [[ -z "${TG_TOKEN:-}" ]] && return 1 is_valid_tg_chat_id "$chat_id" || return 1 [[ -n "$renew_url" ]] || return 1 reply_markup=$(printf '{"inline_keyboard":[[{"text":"Продлить подписку","url":"%s"}]]}' "$renew_url") resp=$(tg_api "sendMessage" -s --max-time 12 --retry 2 --retry-delay 2 \ -X POST \ --data-urlencode "chat_id=${chat_id}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "disable_web_page_preview=true" \ --data-urlencode "text=${message}" \ --data-urlencode "reply_markup=${reply_markup}" \ 2>/dev/null || true) echo "$resp" | grep -q '"ok":true' } telegram_expiry_message() { local user="$1" expires="$2" days="$3" pretty safe_user safe_domain days_text pretty=$(profile_expiry_date_dmy "$expires") safe_user=$(html_escape_text "$user") safe_domain=$(html_escape_text "${DOMAIN:-Yurich Connect}") days_text=$(expiry_days_text "$days") cat <Yurich Connect Подписка скоро закончится. 👤 Профиль: ${safe_user} 🌐 Сервер: ${safe_domain} 📅 Активна до: ${pretty} ⏳ ${days_text} Чтобы подключение не прерывалось, продли подписку заранее. EOF } telegram_bind_message() { local user="$1" expires="$2" pretty safe_user safe_domain pretty=$(profile_expiry_date_dmy "$expires") safe_user=$(html_escape_text "$user") safe_domain=$(html_escape_text "${DOMAIN:-Yurich Connect}") cat <Yurich Connect Уведомления подключены. 👤 Профиль: ${safe_user} 🌐 Сервер: ${safe_domain} 📅 Активна до: ${pretty:-без срока} Я напомню примерно за 5 дней до окончания подписки. EOF } notify_user_expiry() { local user="$1" force="${2:-0}" expires days chat_id last_key notify_key message now_at renew_url if ! is_valid_proxy_user "$user"; then printf 'invalid_user\n' return 1 fi expires=$(get_user_expiry "$user" 2>/dev/null || true) if [[ -z "$expires" ]]; then printf 'no_expiry\n' return 0 fi days=$(days_until_expiry "$expires" 2>/dev/null || true) if [[ -z "$days" ]]; then printf 'bad_expiry\n' return 1 fi chat_id=$(user_meta_get "$user" TELEGRAM_CHAT_ID 2>/dev/null || true) if [[ -z "$chat_id" ]]; then printf 'no_contact\n' return 0 fi if ! is_valid_tg_chat_id "$chat_id"; then printf 'bad_contact\n' return 1 fi if [[ "$force" != "1" && "$days" -gt 5 ]]; then printf 'not_due\n' return 0 fi notify_key="${expires}:window5" last_key=$(user_meta_get "$user" LAST_EXPIRY_NOTIFY_KEY 2>/dev/null || true) if [[ "$force" != "1" && "$last_key" == "$notify_key" ]]; then printf 'already_sent\n' return 0 fi message=$(telegram_expiry_message "$user" "$expires" "$days") renew_url=$(sales_bot_renew_url 2>/dev/null || true) if { [[ -n "$renew_url" ]] && tg_send_to_chat_with_renew_button "$chat_id" "$message" "$renew_url"; } || tg_send_to_chat "$chat_id" "$message"; then if [[ "$force" != "1" ]]; then now_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ') user_meta_set "$user" LAST_EXPIRY_NOTIFY_KEY "$notify_key" >/dev/null 2>&1 || true user_meta_set "$user" LAST_EXPIRY_NOTIFY_AT "$now_at" >/dev/null 2>&1 || true fi printf 'sent\n' return 0 fi printf 'failed\n' return 1 } cmd_notify_bind_tg() { local user="${1:-}" chat_id="${2:-}" expires message load_config; load_users if [[ -z "$user" || -z "$chat_id" ]]; then err "Использование: notify-bind-tg USER TELEGRAM_ID" return 1 fi if ! is_valid_proxy_user "$user"; then err "Некорректный пользователь: $user" return 1 fi if ! subscription_user_exists "$user"; then err "Пользователь $user не найден" return 1 fi if ! is_valid_tg_chat_id "$chat_id"; then err "Некорректный Telegram ID" return 1 fi user_meta_set "$user" TELEGRAM_CHAT_ID "$chat_id" ok "Telegram уведомления привязаны к пользователю $user" expires=$(get_user_expiry "$user" 2>/dev/null || true) if [[ -n "${TG_TOKEN:-}" && -n "$expires" ]]; then message=$(telegram_bind_message "$user" "$expires") if tg_send_to_chat "$chat_id" "$message"; then ok "Проверочное сообщение отправлено" else warn "Не удалось отправить проверочное сообщение. Пользователь должен открыть бота и нажать Start." fi fi } cmd_notify_unbind_tg() { local user="${1:-}" load_config; load_users if [[ -z "$user" ]]; then err "Использование: notify-unbind-tg USER" return 1 fi user_meta_unset "$user" TELEGRAM_CHAT_ID ok "Telegram уведомления отключены для $user" } cmd_notify_expiry_list() { local user expires days chat status pretty load_config; load_users printf '%-24s %-12s %-10s %s\n' "USER" "EXPIRES" "DAYS" "TELEGRAM" printf '%-24s %-12s %-10s %s\n' "----" "-------" "----" "--------" while IFS= read -r user; do [[ -z "$user" ]] && continue expires=$(get_user_expiry "$user" 2>/dev/null || true) [[ -z "$expires" ]] && expires="без срока" if [[ "$expires" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then days=$(days_until_expiry "$expires" 2>/dev/null || echo "-") pretty=$(profile_expiry_date_dmy "$expires") else days="-" pretty="$expires" fi chat=$(user_meta_get "$user" TELEGRAM_CHAT_ID 2>/dev/null || true) if [[ -n "$chat" ]]; then status="ok" else status="нет id" fi printf '%-24s %-12s %-10s %s\n' "$user" "$pretty" "$days" "$status" done < <(list_subscription_users) } cmd_notify_expiry_run() { local user status total=0 sent=0 no_contact=0 skipped=0 failed=0 load_config; load_users while IFS= read -r user; do [[ -z "$user" ]] && continue total=$((total+1)) if ! status=$(notify_user_expiry "$user" 0 2>/dev/null); then status="${status:-failed}" fi status=$(printf '%s\n' "$status" | tail -n1) case "$status" in sent) sent=$((sent+1)) ;; no_contact) no_contact=$((no_contact+1)) ;; failed|bad_contact|bad_expiry|invalid_user) failed=$((failed+1)) ;; *) skipped=$((skipped+1)) ;; esac done < <(list_subscription_users) cmd_enforce_expired_users || failed=$((failed+1)) printf 'expiry notify: total=%s sent=%s no_contact=%s skipped=%s failed=%s\n' "$total" "$sent" "$no_contact" "$skipped" "$failed" [[ "$failed" -eq 0 ]] } cmd_enforce_expired_users() { local user expired=0 deleted=0 kept=0 failed=0 days grace apply_failed=0 load_config; load_users grace="${EXPIRED_DELETE_GRACE_DAYS:-$EXPIRED_DELETE_GRACE_DAYS_DEFAULT}" if ! is_canonical_uint_in_range "$grace" 0 3650; then grace="$EXPIRED_DELETE_GRACE_DAYS_DEFAULT"; fi while IFS= read -r user; do [[ -z "$user" ]] && continue if user_is_time_expired "$user"; then expired=$((expired + 1)) if ! suspend_subscription_page "$user" >/dev/null 2>&1; then warn "Не удалось приостановить публичную страницу $user" failed=$((failed + 1)) fi days=$(user_expired_days "$user" 2>/dev/null || echo 0) if [[ "$days" -ge "$grace" ]]; then if delete_subscription_user_everywhere "$user" >/dev/null 2>&1; then deleted=$((deleted + 1)) else failed=$((failed + 1)) fi else kept=$((kept + 1)) fi fi done < <(list_subscription_users) if [[ "$expired" -lt 1 && ! -f "$NODES_PENDING_SYNC_FILE" ]]; then printf 'expiry enforce: expired=0\n' return 0 fi if [[ -x "$CADDY_BIN" && -f "$CADDYFILE" ]]; then safe_apply_caddy_current >/dev/null 2>&1 || { warn "Не удалось применить Caddy после истечения подписок"; apply_failed=1; } fi sync_hysteria_users_if_active >/dev/null 2>&1 || { warn "Не удалось применить Hysteria после истечения подписок"; apply_failed=1; } if [[ -x "$XRAY_BIN" && -s "$XRAY_USERS_FILE" ]]; then cmd_xray_rebuild >/dev/null 2>&1 || { warn "Не удалось пересобрать Xray после истечения подписок"; apply_failed=1; } fi apply_subscription_aliases >/dev/null 2>&1 || { warn "Не удалось обновить alias URL после истечения подписок"; apply_failed=1; } if [[ "$(nodes_count 2>/dev/null || echo 0)" -gt 0 || -f "$NODES_PENDING_SYNC_FILE" ]]; then cmd_nodes_sync_users all >/dev/null 2>&1 || { warn "Не удалось синхронизировать истечение подписок на nodes"; apply_failed=1; } fi if [[ "$apply_failed" -ne 0 ]]; then failed=$((failed + 1)) fi printf 'expiry enforce: expired=%s kept=%s deleted=%s grace_days=%s failed=%s\n' "$expired" "$kept" "$deleted" "$grace" "$failed" [[ "$failed" -eq 0 ]] } cmd_notify_expiry_test() { local user="${1:-}" status load_config; load_users if [[ -z "$user" ]]; then err "Использование: notify-expiry-test USER" return 1 fi if ! status=$(notify_user_expiry "$user" 1 2>/dev/null); then status="${status:-failed}" fi status=$(printf '%s\n' "$status" | tail -n1) if [[ "$status" == "sent" ]]; then ok "Тестовое уведомление отправлено пользователю $user" else err "Тестовое уведомление не отправлено: $status" return 1 fi } telegram_news_message() { local body="$1" safe_body safe_domain body="${body//$'\r'/}" safe_body=$(html_escape_text "$body") safe_domain=$(html_escape_text "${DOMAIN:-Yurich Connect}") cat <Yurich Connect ${safe_body} 🌐 Сервис: ${safe_domain} 💬 По вопросам напиши Ивану Юрьевичу. EOF } cmd_notify_news_test() { local body="${1:-}" target_chat_id="${2:-${TG_CHAT_ID:-}}" message load_config if [[ -z "$body" ]]; then err "Использование: notify-news-test ТЕКСТ" return 1 fi if ! is_valid_tg_chat_id "$target_chat_id"; then err "Некорректный Telegram ID для теста" return 1 fi message=$(telegram_news_message "$body") if tg_send_to_chat "$target_chat_id" "$message"; then ok "Тестовая новость отправлена" return 0 fi err "Тестовая новость не отправлена. Проверь, что админ открыл бота и нажал Start." return 1 } cmd_notify_news_broadcast() { local body="${1:-}" user chat_id message seen_file total=0 contacts=0 sent=0 failed=0 skipped=0 duplicate=0 bad=0 load_config; load_users if [[ -z "$body" ]]; then err "Использование: notify-news ТЕКСТ" return 1 fi message=$(telegram_news_message "$body") seen_file=$(mktemp) chmod 600 "$seen_file" 2>/dev/null || true while IFS= read -r user; do [[ -z "$user" ]] && continue total=$((total + 1)) chat_id=$(user_meta_get "$user" TELEGRAM_CHAT_ID 2>/dev/null || true) if [[ -z "$chat_id" ]]; then skipped=$((skipped + 1)) continue fi if ! is_valid_tg_chat_id "$chat_id"; then bad=$((bad + 1)) continue fi if grep -Fxq -- "$chat_id" "$seen_file" 2>/dev/null; then duplicate=$((duplicate + 1)) continue fi printf '%s\n' "$chat_id" >> "$seen_file" contacts=$((contacts + 1)) if tg_send_to_chat "$chat_id" "$message"; then sent=$((sent + 1)) else failed=$((failed + 1)) fi sleep 0.25 done < <(list_subscription_users) rm -f "$seen_file" printf 'news broadcast: users=%s contacts=%s sent=%s no_contact=%s duplicate=%s bad_contact=%s failed=%s\n' \ "$total" "$contacts" "$sent" "$skipped" "$duplicate" "$bad" "$failed" [[ "$failed" -eq 0 && "$sent" -gt 0 ]] } cmd_notify_expiry_install() { local script_path="${SCRIPT_PATH:-/usr/local/bin/yurich-panel.sh}" load_config; load_users mkdir -p "$(dirname "$EXPIRY_NOTIFY_CRON")" "$(dirname "$EXPIRY_NOTIFY_LOG")" cat > "$EXPIRY_NOTIFY_CRON" <> ${EXPIRY_NOTIFY_LOG} 2>&1 EOF chmod 644 "$EXPIRY_NOTIFY_CRON" touch "$EXPIRY_NOTIFY_LOG" chmod 600 "$EXPIRY_NOTIFY_LOG" ok "Ежедневные Telegram-уведомления включены: $EXPIRY_NOTIFY_CRON" } # ─── Telegram ──────────────────────────────────────────────── tg_send() { local message="$1" [[ -z "${TG_TOKEN:-}" || -z "${TG_CHAT_ID:-}" ]] && return 0 # Используем --data-urlencode для безопасной передачи спецсимволов tg_api "sendMessage" -s --max-time 10 --retry 2 --retry-delay 3 \ -X POST \ --data-urlencode "chat_id=${TG_CHAT_ID}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "text=${message}" \ >/dev/null 2>&1 || true } tg_alert_up() { tg_send "✅ Yurich Panel запущен 🌐 Домен: ${DOMAIN:-unknown} 🕐 $(date '+%Y-%m-%d %H:%M:%S') 📡 Сервер: $(hostname)" } tg_alert_down() { tg_send "🔴 Yurich Panel упал! 🌐 Домен: ${DOMAIN:-unknown} 🕐 $(date '+%Y-%m-%d %H:%M:%S') 📡 Сервер: $(hostname) ⚠️ Требуется вмешательство!" } tg_alert_updated() { local old_ver="$1" new_ver="$2" tg_send "🔄 Caddy обновлён 📦 Было: ${old_ver} 📦 Стало: ${new_ver} 🕐 $(date '+%Y-%m-%d %H:%M:%S')" } tg_send_stats() { if ! check_installed; then tg_send "❌ Yurich Panel не установлен" return fi local status="🔴 Остановлен" systemctl is-active --quiet caddy 2>/dev/null && status="🟢 Работает" local uptime_str uptime_str=$(systemctl show caddy --property=ActiveEnterTimestamp 2>/dev/null \ | cut -d= -f2 | xargs -I{} date -d "{}" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "н/д") local caddy_ver caddy_ver=$("$CADDY_BIN" version 2>/dev/null | head -1 | awk '{print $1}' || echo "н/д") local iface rx tx iface=$(ip route | awk '/default/{print $5}' | head -1) rx=$(cat /sys/class/net/"$iface"/statistics/rx_bytes 2>/dev/null || echo 0) tx=$(cat /sys/class/net/"$iface"/statistics/tx_bytes 2>/dev/null || echo 0) rx=$(numfmt --to=iec "$rx" 2>/dev/null || echo "н/д") tx=$(numfmt --to=iec "$tx" 2>/dev/null || echo "н/д") local users_count users_count=$(get_users | wc -l) tg_send "📊 Статистика Yurich Panel 🌐 Домен: ${DOMAIN:-н/д} 📡 Статус: ${status} 🕐 Запущен: ${uptime_str} 📦 Caddy: ${caddy_ver} 👥 Пользователей: ${users_count} 📈 Трафик (с ребута): ⬇️ Входящий: ${rx} ⬆️ Исходящий: ${tx} 🖥 Сервер: $(hostname) 💾 RAM: $(free -h | awk '/Mem:/{print $3"/"$2}') 💿 Диск: $(df -h / | awk 'NR==2{print $3"/"$2" ("$5")"}') 🔐 Сертификат: $( cert_days="" cert_info=$(echo | timeout 5 openssl s_client -connect "${DOMAIN:-localhost}:443" -servername "${DOMAIN:-localhost}" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null || echo "") if [[ -n "$cert_info" ]]; then not_after=$(echo "$cert_info" | grep "notAfter" | cut -d= -f2) expire_ts=$(date -d "$not_after" +%s 2>/dev/null || echo 0) now_ts=$(date +%s) cert_days=$(( (expire_ts - now_ts) / 86400 )) echo "📅 Истекает: ${not_after}" echo "⏳ Осталось: ${cert_days} дней" else echo "N/A" fi )" } # ─── Настройка Telegram ─────────────────────────────────────── setup_telegram() { hr echo -e "${BOLD} Настройка Telegram-бота${RESET}" hr echo info "Нужен токен бота и твой chat_id." info "Создать бота: @BotFather → /newbot" info "Узнать chat_id: напиши боту @userinfobot" echo echo -ne "${CYAN}Bot Token (Enter чтобы пропустить): ${RESET}" read -rs input_token echo [[ -z "$input_token" ]] && { warn "Telegram пропущен"; return; } echo -ne "${CYAN}Chat ID: ${RESET}" read -r input_chat_id [[ -z "$input_chat_id" ]] && { warn "Telegram пропущен"; return; } if [[ ! "$input_token" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]]; then err "Bot Token имеет некорректный формат" return 1 fi if ! is_valid_tg_chat_id "$input_chat_id"; then err "Chat ID имеет некорректный формат" return 1 fi info "Проверяю токен..." local response response=$(tg_api_with_token "$input_token" "getMe" -s 2>/dev/null || echo "{}") if echo "$response" | grep -q '"ok":true'; then local bot_name bot_name=$(echo "$response" | grep -o '"username":"[^"]*"' | cut -d'"' -f4) ok "Бот найден: @${bot_name}" else err "Токен неверный или бот недоступен" return fi TG_TOKEN="$input_token" TG_CHAT_ID="$input_chat_id" save_config tg_apply_bot_menu || warn "Команды Telegram Menu можно применить позже: sudo bash yurich-panel.sh bot-menu" tg_send "🤖 Yurich Panel подключён! ✅ Telegram-уведомления настроены 📡 Сервер: $(hostname) 🕐 $(date '+%Y-%m-%d %H:%M:%S') Доступные команды в скрипте: • статус — bash yurich-panel.sh tg-stats • мониторинг — каждые 5 минут автоматически" ok "Тестовое сообщение отправлено" echo warn "Чтобы бот отвечал на /start, /menu и кнопки, нужен systemd сервис naiveproxy-bot." echo -ne "${YELLOW}Установить и запустить Telegram bot service сейчас? [Y/n]: ${RESET}" read -r bot_service_answer if [[ ! "${bot_service_answer}" =~ ^[Nn]$ ]]; then install_bot_service else warn "Бот-меню применено, но ответы на /menu заработают после: sudo bash ${SCRIPT_PATH} bot-install" fi } # ─── Watchdog (cron) ───────────────────────────────────────── install_monitor() { cat > "$MONITOR_SCRIPT" <<'MONITOR' #!/bin/bash CONFIG_FILE="/etc/naiveproxy/naive.conf" if [[ -f "$CONFIG_FILE" ]]; then _owner=$(stat -c '%U' "$CONFIG_FILE" 2>/dev/null || echo "unknown") _perms=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || echo "000") if [[ "$_owner" == "root" && "$_perms" == "600" ]]; then _config_value() { local key="$1" awk -F= -v key="$key" '$1 == key {sub(/^[^=]*=/, ""); print; exit}' "$CONFIG_FILE" 2>/dev/null } TG_TOKEN=$(_config_value TG_TOKEN) TG_CHAT_ID=$(_config_value TG_CHAT_ID) DOMAIN=$(_config_value DOMAIN) [[ "$TG_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] || TG_TOKEN="" [[ "$TG_CHAT_ID" =~ ^-?[0-9]{5,20}$ ]] || TG_CHAT_ID="" [[ "$DOMAIN" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]] || DOMAIN="unknown" unset -f _config_value fi fi tg_api() { local method="$1" shift [[ "${TG_TOKEN:-}" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] || return 1 [[ "$method" =~ ^[A-Za-z][A-Za-z0-9]*(\?[A-Za-z0-9_.%=&-]+)?$ ]] || return 1 local cfg rc cfg=$(mktemp /tmp/yurich_tg_api_XXXXXX) || return 1 chmod 600 "$cfg" 2>/dev/null || true printf 'url = "https://api.telegram.org/bot%s/%s"\n' "$TG_TOKEN" "$method" > "$cfg" curl --config "$cfg" "$@" rc=$? rm -f "$cfg" 2>/dev/null || true return "$rc" } tg_send() { local msg="$1" [[ -z "${TG_TOKEN:-}" || -z "${TG_CHAT_ID:-}" ]] && return tg_api "sendMessage" -s --max-time 10 --retry 2 \ -X POST \ --data-urlencode "chat_id=${TG_CHAT_ID}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "text=${msg}" \ >/dev/null 2>&1 || true } FLAG="/run/naiveproxy_was_down" if ! systemctl is-active --quiet caddy 2>/dev/null; then if [[ ! -f "$FLAG" ]]; then touch "$FLAG" tg_send "🔴 Yurich Panel упал! 🌐 Домен: ${DOMAIN:-unknown} 🕐 $(date '+%Y-%m-%d %H:%M:%S') 🔄 Пытаюсь перезапустить..." systemctl restart caddy 2>/dev/null || true sleep 5 if systemctl is-active --quiet caddy; then tg_send "✅ Перезапущен успешно 🕐 $(date '+%Y-%m-%d %H:%M:%S')" rm -f "$FLAG" else tg_send "❌ Перезапуск не помог! Нужно ручное вмешательство." fi fi else rm -f "$FLAG" fi MONITOR chmod +x "$MONITOR_SCRIPT" local script_path # Если запущен через bash <(curl), $0 будет /dev/fd/xx — используем фиксированный путь script_path=$(realpath "$0" 2>/dev/null || echo "") if [[ -z "$script_path" || "$script_path" == /dev/fd/* || "$script_path" == /proc/* ]]; then script_path="${SCRIPT_PATH}" # Копируем себя в постоянное место если ещё не там [[ -f "$script_path" ]] || cp "$0" "$script_path" 2>/dev/null || true [[ -f "$script_path" ]] && chmod +x "$script_path" fi # Очищаем старые naive-cron записи, добавляем новые ( crontab -l 2>/dev/null | grep -v "naiveproxy\|monitor\.sh" || true echo "*/5 * * * * /bin/bash $MONITOR_SCRIPT" echo "0 3 * * 0 /bin/bash ${script_path} update >> ${LOG_DIR}/autoupdate.log 2>&1" ) | crontab - ok "Watchdog: каждые 5 минут" ok "Автообновление Caddy: воскресенье 3:00" } # ─── Проверка домена ───────────────────────────────────────── check_domain() { local domain="$1" info "Проверяю DNS для $domain..." local server_ip domain_ip public_domain_ip hosts_overrides hosts_domains ans server_ip=$(curl -s4 --max-time 5 https://ifconfig.me 2>/dev/null || curl -s4 --max-time 5 https://api.ipify.org 2>/dev/null || curl -s4 --max-time 5 https://checkip.amazonaws.com 2>/dev/null || echo "") public_domain_ip=$(public_dns_ipv4 "$domain") domain_ip=$(getent ahostsv4 "$domain" 2>/dev/null | awk '{print $1; exit}' || echo "") [[ -n "$domain_ip" ]] || domain_ip=$(getent hosts "$domain" 2>/dev/null | awk '$1 ~ /^([0-9]{1,3}\.){3}[0-9]{1,3}$/ {print $1; exit}' || echo "") [[ -n "$domain_ip" ]] || domain_ip="$public_domain_ip" hosts_overrides=$(hosts_public_domain_overrides | awk -F'|' -v d="${domain,,}" '$1 == d {print}' | head -5 || true) if [[ -n "$hosts_overrides" ]]; then hosts_domains=$(printf '%s\n' "$hosts_overrides" | format_hosts_override_domains) warn "/etc/hosts содержит публичный домен (${hosts_domains}). Это может ломать TLS/SNI и проверки между серверами." fi if [[ -z "$domain_ip" ]]; then err "Домен $domain не резолвится. Проверь DNS." exit 1 fi if [[ -n "$public_domain_ip" ]]; then domain_ip="$public_domain_ip" fi if [[ -n "$server_ip" && "$server_ip" != "$domain_ip" ]]; then warn "IP сервера: $server_ip | IP домена: $domain_ip" warn "Не совпадают! Let's Encrypt может отказать в сертификате." echo -ne "${YELLOW}Продолжить всё равно? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || exit 1 elif [[ -z "$server_ip" ]]; then warn "Не смог определить публичный IPv4 сервера, DNS домена: $domain_ip" else ok "DNS OK: $domain → $domain_ip" fi } # ─── Зависимости ───────────────────────────────────────────── install_deps() { info "Обновляю пакеты и ставлю зависимости..." apt-get update -qq if ! apt-get install -y -qq curl wget unzip tar ufw openssl dnsutils ca-certificates python3 jq apache2-utils 2>/dev/null; then err "Не удалось установить обязательные системные зависимости" return 1 fi export PATH="/usr/local/go/bin:$PATH" local go_ver go_min="1.25.1" go_ver=$(go version 2>/dev/null | grep -oP 'go\K[\d.]+' || echo "0.0") if version_gt "$go_min" "$go_ver"; then warn "Go $go_ver не подходит для Caddy ${CADDY_VERSION_PIN:-v2.11.4}, ставлю закреплённую версию..." local arch arch=$(dpkg --print-architecture) [[ "$arch" == "arm64" ]] || arch="amd64" local go_ver_pin="1.26.5" local go_url="https://go.dev/dl/go${go_ver_pin}.linux-${arch}.tar.gz" local go_sha256_amd64="5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" local go_sha256_arm64="fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49" local expected_sha go_tmp [[ "$arch" == "arm64" ]] && expected_sha="$go_sha256_arm64" || expected_sha="$go_sha256_amd64" go_tmp=$(mktemp /tmp/go_XXXXXX.tar.gz) if ! wget -q --tries=3 --timeout=30 "$go_url" -O "$go_tmp"; then rm -f "$go_tmp" err "Не удалось скачать Go: $go_url" exit 1 fi local actual_sha actual_sha=$(sha256sum "$go_tmp" | awk '{print $1}') if [[ "$actual_sha" != "$expected_sha" ]]; then err "SHA256 Go не совпадает! Возможная атака на цепочку поставок. Прерываю." rm -f "$go_tmp" exit 1 fi ok "SHA256 Go подтверждён" rm -rf /usr/local/go tar -C /usr/local -xzf "$go_tmp" rm -f "$go_tmp" printf 'export PATH="/usr/local/go/bin:$PATH"\n' > /etc/profile.d/go.sh fi export PATH="/usr/local/go/bin:$PATH" ok "Зависимости готовы" } # ─── Сборка Caddy ──────────────────────────────────────────── build_caddy() ( info "Собираю Caddy с forwardproxy (naive)..." info "Занимает 5-15 минут, не прерывай..." local output_bin="${1:-$CADDY_BIN}" local caddy_ver="${CADDY_VERSION_PIN:-v2.11.4}" local fp_ref="${FORWARDPROXY_REF_PIN:-d62c80d3dd2c706b6b87579844d2397bddd18317}" [[ "$caddy_ver" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { err "Некорректный Caddy pin: $caddy_ver" return 1 } [[ "$fp_ref" =~ ^[a-fA-F0-9]{40}$ ]] || { err "Forwardproxy должен быть закреплён полным commit SHA" return 1 } export PATH="/usr/local/go/bin:$PATH" export GOPATH="/root/go" export GOCACHE="/root/.cache/go-build" # Ставим git если нет command -v git &>/dev/null || apt-get install -y -q git info "Ставлю xcaddy ${XCADDY_VERSION_PIN:-v0.4.6}..." go install "github.com/caddyserver/xcaddy/cmd/xcaddy@${XCADDY_VERSION_PIN:-v0.4.6}" # Клонируем naive ветку напрямую — единственный надёжный способ local fp_dir fp_dir=$(mktemp -d /tmp/naiveproxy_forwardproxy_XXXXXX) trap 'rm -rf "${fp_dir:-}" 2>/dev/null' EXIT info "Клонирую klzgrad/forwardproxy@${fp_ref}..." if ! git clone -b naive --depth 1 https://github.com/klzgrad/forwardproxy.git "$fp_dir" 2>/dev/null; then err "Не удалось клонировать forwardproxy. Проверь интернет." return 1 fi local current_fp_commit current_fp_commit=$(git -C "$fp_dir" rev-parse HEAD 2>/dev/null || true) if [[ "$current_fp_commit" == "$fp_ref" ]]; then git -C "$fp_dir" checkout --detach HEAD >/dev/null 2>&1 || true elif ! git -C "$fp_dir" fetch --depth 1 origin "$fp_ref" >/dev/null 2>&1 \ || ! git -C "$fp_dir" checkout --detach FETCH_HEAD >/dev/null 2>&1; then err "Не удалось закрепить forwardproxy на ${fp_ref}" return 1 fi current_fp_commit=$(git -C "$fp_dir" rev-parse HEAD 2>/dev/null || true) [[ "$current_fp_commit" == "$fp_ref" ]] || { err "Forwardproxy commit mismatch: ожидался $fp_ref, получен ${current_fp_commit:-unknown}" return 1 } info "Forwardproxy ref: ${current_fp_commit:0:12}" info "Caddy release pin: $caddy_ver" # Сборка из неизменяемого commit SHA сохраняет происхождение модуля в build metadata. "$GOPATH/bin/xcaddy" build "$caddy_ver" \ --with "github.com/caddyserver/forwardproxy=github.com/klzgrad/forwardproxy@${fp_ref}" \ --output "$output_bin" chmod +x "$output_bin" local built_ver built_ver=$("$output_bin" version 2>/dev/null | head -1 || true) if [[ "$built_ver" != *"${caddy_ver#v}"* ]]; then err "Собран неожиданный Caddy: ${built_ver:-unknown}, ожидался $caddy_ver" rm -f "$output_bin" return 1 fi if ! "$output_bin" list-modules 2>/dev/null | grep -Fxq 'http.handlers.forward_proxy'; then err "В собранном Caddy отсутствует http.handlers.forward_proxy" rm -f "$output_bin" return 1 fi # Проверяем наличие naive padding в бинарнике if command -v strings &>/dev/null; then local _pc _pc=$(strings "$output_bin" 2>/dev/null | grep -cE "^(Padding|SetPadding|WithPadding)$" || true) _pc="${_pc//[^0-9]/}"; _pc="${_pc:-0}" if [[ "${_pc}" -ge 2 ]]; then ok "Naive padding модуль подтверждён ✓" else warn "Padding не найден — возможна проблема совместимости" fi fi ok "Caddy собран: $built_ver" ) # Browser extensions require a standards-compliant proxy auth challenge. # Keep stealth 404 as the default for existing public installations. caddy_proxy_auth_error_block() { if [[ "${BROWSER_PROXY_COMPAT:-0}" == "1" ]]; then cat <<'EOF' handle_errors { @proxy_auth_error expression {http.error.status_code} == 401 handle @proxy_auth_error { log_append yurich_auth_failure "1" header -WWW-Authenticate header Proxy-Authenticate "Basic realm=\"Yurich Connect Browser\"" respond "" 407 } } EOF else cat <<'EOF' handle_errors { @proxy_auth_error expression {http.error.status_code} == 401 handle @proxy_auth_error { log_append yurich_auth_failure "1" header -WWW-Authenticate respond "Not Found" 404 } } EOF fi } # ── Мультидомен: генерация Caddyfile ───────────────────────── write_caddyfile_multi() { mkdir -p "$CADDY_DIR" "$WEBROOT" "$LOG_DIR" if [[ "${XRAY_FALLBACK_ENABLED:-0}" == "1" ]]; then warn "Xray fallback hub включён: мультидомен Caddy отключён, Caddy будет локальным fallback." write_caddyfile return $? fi install_camouflage_page # Caddy core basic_auth принимает bcrypt; plaintext в Caddyfile не попадает. local auth_blocks="" local auth_count=0 while IFS=: read -r u p; do [[ -z "$u" ]] && continue if ! is_valid_proxy_user "$u" || ! is_valid_proxy_hash "$p"; then err "Некорректная bcrypt-запись пользователя в $USERS_FILE: $u" return 1 fi auth_blocks+=" ${u} ${p}"$'\n' auth_count=$((auth_count+1)) done < <(get_active_user_hashes) if [[ "$auth_count" -lt 1 ]]; then local disabled_pass disabled_hash disabled_pass=$(random_safe_token 32) disabled_hash=$(hash_proxy_password "__disabled__" "$disabled_pass") || return 1 auth_blocks+=" __disabled__ ${disabled_hash}"$'\n' auth_count=1 warn "Нет активных пользователей. Caddy proxy закрыт placeholder-auth." fi local caddy_upstream="" if [[ "${WARP_PROXY_ENABLED:-0}" == "1" ]]; then caddy_upstream=" upstream socks5://127.0.0.1:${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}"$'\n' fi local caddy_protocols="h1 h2" xhttp_block="" caddy_runtime_sampling="" local proxy_auth_error_block proxy_auth_error_block=$(caddy_proxy_auth_error_block) if [[ "${HYSTERIA_PORT:-8443}" == "443" ]]; then caddy_protocols="h1 h2" fi if [[ "${XRAY_XHTTP_ENABLED:-0}" == "1" ]]; then xhttp_block=$(cat < "$CADDYFILE" <> "$CADDYFILE" </dev/null 2>&1 || true "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1 || { err "Сгенерированный Caddyfile не прошёл validate" "$CADDY_BIN" validate --config "$CADDYFILE" || true return 1 } fi local dom_count dom_count=$(echo "$domains_csv" | tr ',' '\n' | grep -c '[a-z]' || echo 1) ok "Caddyfile обновлён (доменов: ${dom_count}, активных пользователей: $(active_user_count))" } # ─── КАМУФЛЯЖНАЯ СТРАНИЦА ──────────────────────────────────────── install_camouflage_page() { mkdir -p "$WEBROOT" cat > "$WEBROOT/index.html" << 'CAMOUFLAGE_EOF' Yurich Panel — Linux & Infrastructure Notes
TECHNICAL NOTES

Linux, Networking
& Infrastructure

Practical notes on server administration, open source tooling, and building reliable systems. No fluff — just working code and real-world configs.

47articles
published
12kmonthly
readers
3ywriting
about Linux
100%self-hosted
infrastructure
RECENT POSTS

UFW Deep Dive: Rules, Logging and Common Mistakes

UFW is friendly but hides complexity. I cover rule ordering, default policies, logging levels, and the three mistakes that get people locked out of their own servers.

9 min
CAMOUFLAGE_EOF chmod 644 "$WEBROOT/index.html" ok "Камуфляжная страница установлена → $WEBROOT/index.html" } # ─── Caddyfile ─────────────────────────────────────────────── write_caddyfile() { mkdir -p "$CADDY_DIR" "$WEBROOT" "$LOG_DIR" install_camouflage_page if ! is_valid_domain "${DOMAIN:-}"; then err "Неверный домен в конфиге: ${DOMAIN:-}" return 1 fi # Caddy core basic_auth принимает bcrypt; plaintext в Caddyfile не попадает. local auth_blocks="" local auth_count=0 while IFS=: read -r u p; do [[ -z "$u" ]] && continue if ! is_valid_proxy_user "$u" || ! is_valid_proxy_hash "$p"; then err "Некорректная bcrypt-запись пользователя в $USERS_FILE: $u" return 1 fi auth_blocks+=" ${u} ${p}"$'\n' auth_count=$((auth_count+1)) done < <(get_active_user_hashes) if [[ "$auth_count" -lt 1 ]]; then local disabled_pass disabled_hash disabled_pass=$(random_safe_token 32) disabled_hash=$(hash_proxy_password "__disabled__" "$disabled_pass") || return 1 auth_blocks+=" __disabled__ ${disabled_hash}"$'\n' auth_count=1 warn "Нет активных пользователей. Caddy proxy закрыт placeholder-auth." fi local caddy_upstream="" if [[ "${WARP_PROXY_ENABLED:-0}" == "1" ]]; then caddy_upstream=" upstream socks5://127.0.0.1:${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}"$'\n' fi local caddy_protocols="h1 h2" xhttp_block="" caddy_runtime_sampling="" caddy_bind_line="" local proxy_auth_error_block proxy_auth_error_block=$(caddy_proxy_auth_error_block) local caddy_server_port="443" if [[ "${HYSTERIA_PORT:-8443}" == "443" ]]; then caddy_protocols="h1 h2" fi if [[ "${XRAY_XHTTP_ENABLED:-0}" == "1" ]]; then xhttp_block=$(cat < "$CADDYFILE" </dev/null 2>&1 || true "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1 || { err "Сгенерированный Caddyfile не прошёл validate" "$CADDY_BIN" validate --config "$CADDYFILE" || true return 1 } fi ok "Caddyfile обновлён (активных пользователей: $(active_user_count))" } rewrite_caddyfile_current() { if [[ "${DOMAINS:-}" == *,* ]]; then write_caddyfile_multi else write_caddyfile fi } # ─── systemd ───────────────────────────────────────────────── write_service() { cat > "$CADDY_SERVICE" <<'EOF' [Unit] Description=Yurich Panel Caddy Documentation=https://caddyserver.com/docs/ After=network.target network-online.target Requires=network-online.target StartLimitBurst=5 StartLimitIntervalSec=60 [Service] Type=notify User=root Group=root ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force TimeoutStopSec=5s LimitNOFILE=1048576 LimitNPROC=512 PrivateTmp=true ProtectSystem=full NoNewPrivileges=true CapabilityBoundingSet=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true ProtectControlGroups=true RestrictSUIDSGID=true LockPersonality=true RestrictRealtime=true # Авто-перезапуск при сбое Restart=on-failure RestartSec=5s [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable caddy --quiet ok "systemd сервис Caddy настроен (Restart=on-failure)" } # ─── UFW ───────────────────────────────────────────────────── setup_firewall() { command -v ufw &>/dev/null || apt-get install -y -q ufw info "Настраиваю UFW..." local ssh_port ssh_port=$(current_ssh_port) if ! is_valid_port "$ssh_port"; then ssh_port="22" fi # SSH открываем ДО включения default deny. Иначе новая SSH-сессия может не зайти, # даже если SSH Hardening был пропущен. ufw allow "${ssh_port}/tcp" comment "SSH access" >/dev/null 2>&1 || true # Включаем UFW если не активен if ! ufw status | grep -q "Status: active"; then # Дефолтная политика: блокируем всё входящее ufw default deny incoming >/dev/null 2>&1 || true ufw default allow outgoing >/dev/null 2>&1 || true ufw --force enable >/dev/null 2>&1 || true ok "UFW включён (дефолт: блокировать всё входящее)" fi # Базовые порты Yurich Panel ufw delete allow 80/tcp >/dev/null 2>&1 || true ufw limit 80/tcp comment "Yurich Panel ACME limited" >/dev/null 2>&1 \ || ufw allow 80/tcp comment "Yurich Panel ACME" >/dev/null 2>&1 \ || true ufw allow 443/tcp comment "Yurich Panel HTTPS" >/dev/null 2>&1 || true ufw allow 443/udp comment "Yurich Panel HTTP3" >/dev/null 2>&1 || true # Блокируем типичные порты для сканеров for port in 3306 5432 6379 27017 8080 8888 9200; do ufw deny "${port}/tcp" comment "Block scanners" >/dev/null 2>&1 || true done ok "UFW: открыт SSH порт ${ssh_port}/tcp" ok "UFW: открыты 80, 443/tcp, 443/udp" ok "UFW: заблокированы порты БД и типичные цели сканеров" ok "UFW: limit на 80/tcp для снижения шума сканеров" } setup_fail2ban() { local ssh_port="${1:-}" is_valid_port "$ssh_port" || ssh_port=$(current_ssh_port) is_valid_port "$ssh_port" || ssh_port="22" info "Настраиваю Fail2Ban..." apt-get update -qq 2>/dev/null || true apt-get install -y -q fail2ban mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d "$LOG_DIR" touch "$LOG_DIR/naive.log" "$LOG_DIR/access.log" 2>/dev/null || true if [[ -f /etc/fail2ban/jail.local ]] && grep -Eq "Глобальные настройки|SSH DDoS|Рецидивисты" /etc/fail2ban/jail.local; then cp -a /etc/fail2ban/jail.local "/etc/fail2ban/jail.local.bak.$(date '+%Y%m%d-%H%M%S')" 2>/dev/null || true rm -f /etc/fail2ban/jail.local fi cat > /etc/fail2ban/action.d/yurich-ufw.conf <<'EOF' [Definition] actionstart = actionstop = actioncheck = actionban = ufw insert 1 deny from to any actionunban = ufw delete deny from to any EOF cat > /etc/fail2ban/filter.d/yurich-caddy-auth.conf <<'EOF' [Definition] failregex = ^.*"remote_ip":"".*"status":(?:401|407).* ^.*"client_ip":"".*"status":(?:401|407).* ^.*"remote_ip":"".*"yurich_auth_failure":"1".* ^.*"yurich_auth_failure":"1".*"remote_ip":"".* ^.*"client_ip":"".*"yurich_auth_failure":"1".* ^.*"yurich_auth_failure":"1".*"client_ip":"".* ignoreregex = EOF cat > /etc/fail2ban/jail.d/yurich-panel.local </dev/null 2>&1; then err "Fail2Ban config не прошёл проверку:" fail2ban-client -t || true return 1 fi systemctl enable fail2ban --quiet systemctl restart fail2ban ok "Fail2Ban настроен:" ok " SSH брутфорс: 3 попытки → бан 7 дней" ok " SSH DDoS: 10 попыток за 1 мин → бан 7 дней" ok " Caddy/Yurich Proxy auth: 8 ошибок → бан 24 часа" ok " Рецидивисты: → бан 30 дней" } # ─── BBR ───────────────────────────────────────────────────── enable_bbr() { local current current=$(sysctl net.ipv4.tcp_congestion_control 2>/dev/null | awk '{print $3}') [[ "$current" == "bbr" ]] && { ok "BBR уже включён"; return; } echo -ne "${YELLOW}Включить TCP BBR для ускорения? [y/N]: ${RESET}" read -r ans if [[ "${ans,,}" == "y" ]]; then cat > /etc/sysctl.d/99-bbr.conf <<'EOF' net.core.default_qdisc=fq net.ipv4.tcp_congestion_control=bbr EOF sysctl -p /etc/sysctl.d/99-bbr.conf >/dev/null 2>&1 ok "BBR включён" fi } # ─── Бэкап ─────────────────────────────────────────────────── backup_config() { [[ -f "$CADDYFILE" ]] || return mkdir -p "$BACKUP_DIR" local ts; ts=$(date +%Y%m%d_%H%M%S) cp "$CADDYFILE" "$BACKUP_DIR/Caddyfile.$ts" ok "Бэкап → $BACKUP_DIR/Caddyfile.$ts" } validate_enabled_configs() { local failed=0 if [[ -x "$CADDY_BIN" && -f "$CADDYFILE" ]]; then if "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1; then ok "Caddyfile валиден" else err "Caddyfile не прошёл validate" "$CADDY_BIN" validate --config "$CADDYFILE" || true failed=1 fi else warn "Caddy validate пропущен: бинарник или Caddyfile не найден" fi if [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then if "$XRAY_BIN" run -test -config "$XRAY_CONFIG" >/dev/null 2>&1; then ok "Xray config валиден" else err "Xray config не прошёл проверку" "$XRAY_BIN" run -test -config "$XRAY_CONFIG" || true failed=1 fi fi if command -v unbound-checkconf >/dev/null 2>&1 && command -v unbound >/dev/null 2>&1; then if unbound-checkconf >/dev/null 2>&1; then ok "Unbound config валиден" else err "Unbound config не прошёл проверку" unbound-checkconf || true failed=1 fi fi if [[ -x "$HYSTERIA_BIN" && -f "$HYSTERIA_CONFIG" ]]; then if validate_hysteria_config "$HYSTERIA_CONFIG" >/dev/null 2>&1; then ok "Hysteria config валиден" else err "Hysteria config не прошёл проверку" validate_hysteria_config "$HYSTERIA_CONFIG" || true failed=1 fi fi return "$failed" } safe_apply_caddy_current() { load_config load_users mkdir -p "$BACKUP_DIR" local ts backup_file ts=$(date +%Y%m%d_%H%M%S) backup_file="$BACKUP_DIR/Caddyfile.safe-apply.${ts}" [[ -f "$CADDYFILE" ]] && cp "$CADDYFILE" "$backup_file" if ! rewrite_caddyfile_current; then err "Не удалось сгенерировать Caddyfile" [[ -f "$backup_file" ]] && cp "$backup_file" "$CADDYFILE" return 1 fi if [[ -x "$CADDY_BIN" ]] && ! "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1; then err "Новый Caddyfile невалиден, откатываю" [[ -f "$backup_file" ]] && cp "$backup_file" "$CADDYFILE" "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1 || true return 1 fi if ! systemctl reload caddy 2>/dev/null; then warn "Reload Caddy не прошёл, пробую restart" if ! systemctl restart caddy 2>/dev/null; then err "Restart Caddy не прошёл, откатываю Caddyfile" [[ -f "$backup_file" ]] && cp "$backup_file" "$CADDYFILE" systemctl restart caddy 2>/dev/null || true return 1 fi fi ok "Safe apply Caddy выполнен" [[ -f "$backup_file" ]] && info "Rollback backup: $backup_file" } cmd_safe_apply() { hr echo -e "${BOLD} Safe apply${RESET}" hr validate_enabled_configs || return 1 safe_apply_caddy_current } health_line() { local name="$1" status="$2" detail="${3:-}" case "$status" in ok) ok "$name${detail:+ — $detail}" ;; warn) warn "$name${detail:+ — $detail}" ;; fail) err "$name${detail:+ — $detail}" ;; esac } ufw_has_port_rule() { local rule="$1" command -v ufw >/dev/null 2>&1 || return 1 ufw status 2>/dev/null | grep -Eiq "(^|[[:space:]])${rule//\//\\/}([[:space:]]|[[:space:]].*ALLOW|.*ALLOW|.*LIMIT)" } cmd_health_check() { load_config 2>/dev/null || true load_users 2>/dev/null || true hr echo -e "${BOLD} Health-check Yurich Panel${RESET}" hr [[ -x "$CADDY_BIN" ]] && health_line "Caddy binary" ok "$("$CADDY_BIN" version 2>/dev/null | head -1)" || health_line "Caddy binary" fail "не найден" systemctl is-active --quiet caddy 2>/dev/null && health_line "Caddy service" ok "active" || health_line "Caddy service" warn "не active" [[ -f "$CADDYFILE" ]] && "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1 && health_line "Caddyfile" ok "valid" || health_line "Caddyfile" warn "нет validate" ss -tulpn 2>/dev/null | grep -E ':443([[:space:]]|$)' >/dev/null && health_line "Port 443" ok "listening" || health_line "Port 443" warn "не слушается" if edge_routing_mode_is_haproxy; then systemctl is-active --quiet haproxy 2>/dev/null && health_line "HAProxy service" ok "active" || health_line "HAProxy service" warn "не active" [[ -S "$HAPROXY_STATS_SOCKET" ]] && health_line "HAProxy stats socket" ok "$HAPROXY_STATS_SOCKET" || health_line "HAProxy stats socket" warn "не найден" [[ -f "$HAPROXY_CFG" ]] && haproxy -c -f "$HAPROXY_CFG" >/dev/null 2>&1 && health_line "HAProxy config" ok "valid" || health_line "HAProxy config" warn "нет validate" fi local hosts_overrides hosts_domains hosts_overrides=$(hosts_public_domain_overrides | head -5 || true) if [[ -n "$hosts_overrides" ]]; then hosts_domains=$(printf '%s\n' "$hosts_overrides" | format_hosts_override_domains) health_line "Public domain /etc/hosts" warn "${hosts_domains}: убери публичные домены из /etc/hosts" else health_line "Public domain /etc/hosts" ok "clean" fi if command -v ufw >/dev/null 2>&1; then if ufw status 2>/dev/null | grep -q "Status: active"; then health_line "UFW" ok "active" else health_line "UFW" warn "не active" fi local ssh_port ssh_port=$(current_ssh_port) is_valid_port "$ssh_port" || ssh_port="22" ufw_has_port_rule "${ssh_port}/tcp" && health_line "UFW SSH ${ssh_port}/tcp" ok "open" || health_line "UFW SSH ${ssh_port}/tcp" warn "нет правила" ufw_has_port_rule "80/tcp" && health_line "UFW 80/tcp" ok "open/limit" || health_line "UFW 80/tcp" warn "нет правила" ufw_has_port_rule "443/tcp" && health_line "UFW 443/tcp" ok "open" || health_line "UFW 443/tcp" warn "нет правила" ufw_has_port_rule "443/udp" && health_line "UFW 443/udp" ok "open" || health_line "UFW 443/udp" warn "нет правила" if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then ufw_has_port_rule "${HYSTERIA_PORT:-8443}/udp" && health_line "UFW Hysteria ${HYSTERIA_PORT:-8443}/udp" ok "open" || health_line "UFW Hysteria ${HYSTERIA_PORT:-8443}/udp" warn "нет правила" fi if [[ -x "$XRAY_BIN" || -f "$XRAY_CONFIG" ]]; then if [[ "${XRAY_REALITY_SNI_MUX_ENABLED:-0}" == "1" ]]; then health_line "UFW Xray REALITY" ok "через HAProxy 443, backend local" else ufw_has_port_rule "${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}/tcp" && health_line "UFW Xray REALITY" ok "open" || health_line "UFW Xray REALITY" warn "нет правила" fi fi if [[ "${UNBOUND_VPN_ENABLED:-0}" == "1" ]]; then ufw_has_port_rule "53/udp" && ufw_has_port_rule "53/tcp" && health_line "UFW DNS 53" ok "VPN-only rules present" || health_line "UFW DNS 53" warn "проверь VPN CIDR rules" fi else health_line "UFW" warn "не установлен" fi if command -v fail2ban-client >/dev/null 2>&1; then if systemctl is-active --quiet fail2ban 2>/dev/null; then health_line "Fail2Ban" ok "active" fail2ban-client status sshd >/dev/null 2>&1 && health_line "Fail2Ban sshd" ok "jail active" || health_line "Fail2Ban sshd" warn "jail не найден" fail2ban-client status yurich-caddy-auth >/dev/null 2>&1 && health_line "Fail2Ban Caddy auth" ok "jail active" || health_line "Fail2Ban Caddy auth" warn "jail не найден" else health_line "Fail2Ban" warn "не active" fi else health_line "Fail2Ban" warn "не установлен" fi if command -v unbound >/dev/null 2>&1; then systemctl is-active --quiet unbound 2>/dev/null && health_line "DNS (Unbound)" ok "active" || health_line "DNS (Unbound)" warn "не active" command -v dig >/dev/null 2>&1 && dig @127.0.0.1 google.com +time=3 +tries=1 >/dev/null 2>&1 && health_line "DNS test" ok "127.0.0.1 отвечает" || health_line "DNS test" warn "dig не прошёл" else health_line "DNS (Unbound)" warn "не установлен" fi if systemctl cat naiveproxy-bot >/dev/null 2>&1; then systemctl is-active --quiet naiveproxy-bot 2>/dev/null && health_line "Telegram bot service" ok "active" || health_line "Telegram bot service" warn "не active" else health_line "Telegram bot service" warn "не установлен" fi if command -v warp-cli >/dev/null 2>&1; then local warp_health_label="${WARP_MODE:-off}" [[ "${WARP_PROXY_ENABLED:-0}" == "1" ]] && warp_health_label="proxy" [[ "${HYSTERIA_WARP_ENABLED:-0}" == "1" ]] && warp_health_label="proxy для Hysteria" if warp-cli --accept-tos status >/dev/null 2>&1 || warp-cli status >/dev/null 2>&1; then health_line "WARP" ok "$warp_health_label" else health_line "WARP" warn "warp-cli без статуса" fi else health_line "WARP" warn "не установлен" fi if [[ -x "$XRAY_BIN" || -f "$XRAY_CONFIG" ]]; then [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]] && "$XRAY_BIN" run -test -config "$XRAY_CONFIG" >/dev/null 2>&1 && health_line "Xray config" ok "valid" || health_line "Xray config" fail "ошибка" systemctl is-active --quiet xray 2>/dev/null && health_line "Xray service" ok "active" || health_line "Xray service" warn "не active" else health_line "Xray" warn "не установлен" fi if [[ -x "$HYSTERIA_BIN" || -f "$HYSTERIA_CONFIG" ]]; then [[ -f "$HYSTERIA_CONFIG" ]] && health_line "Hysteria config" ok "$HYSTERIA_CONFIG" || health_line "Hysteria config" warn "не найден" systemctl is-active --quiet hysteria 2>/dev/null && health_line "Hysteria service" ok "active" || health_line "Hysteria service" warn "не active" else health_line "Hysteria 2" warn "не установлен" fi hr } ensure_haproxy_packages() { if command -v haproxy >/dev/null 2>&1 && command -v rsyslogd >/dev/null 2>&1; then return 0 fi info "Устанавливаю HAProxy/rsyslog..." apt-get update -qq >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive apt-get install -y -q haproxy rsyslog >/dev/null } haproxy_optimal_nbthread() { local cpu_count cpu_count=$(nproc 2>/dev/null || echo 1) [[ "$cpu_count" =~ ^[0-9]+$ ]] || cpu_count=1 (( cpu_count > 4 )) && cpu_count=4 if (( cpu_count > 1 )); then printf '%s\n' "$cpu_count" fi } apply_haproxy_kernel_tuning() { cat > "$HAPROXY_SYSCTL_CONF" <<'EOF' # Yurich Panel HAProxy TCP passthrough tuning. # Keeps the TLS SNI mux responsive during connection bursts. net.core.somaxconn = 8192 net.core.default_qdisc = fq net.ipv4.tcp_max_syn_backlog = 8192 net.ipv4.ip_local_port_range = 10240 65535 net.ipv4.tcp_tw_reuse = 2 net.ipv4.tcp_congestion_control = bbr net.ipv4.tcp_slow_start_after_idle = 0 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216 net.core.rmem_default = 262144 net.core.wmem_default = 262144 net.ipv4.tcp_rmem = 4096 87380 16777216 net.ipv4.tcp_wmem = 4096 65536 16777216 net.ipv4.udp_rmem_min = 8192 net.ipv4.udp_wmem_min = 8192 net.ipv4.tcp_mtu_probing = 1 net.ipv4.tcp_keepalive_time = 60 net.ipv4.tcp_keepalive_intvl = 15 net.ipv4.tcp_keepalive_probes = 4 EOF chmod 644 "$HAPROXY_SYSCTL_CONF" sysctl --system >/dev/null 2>&1 || sysctl -p "$HAPROXY_SYSCTL_CONF" >/dev/null 2>&1 || true } apply_vless_tcp_tuning() { cat > "$XRAY_SYSCTL_CONF" <<'EOF' # Yurich Panel VLESS Reality TCP stability tuning. # Conservative values for mobile networks, CGNAT and long-lived TCP tunnels. net.core.default_qdisc = fq net.core.somaxconn = 8192 net.ipv4.tcp_congestion_control = bbr net.ipv4.tcp_max_syn_backlog = 8192 net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_slow_start_after_idle = 0 net.ipv4.tcp_mtu_probing = 1 net.ipv4.tcp_keepalive_time = 60 net.ipv4.tcp_keepalive_intvl = 15 net.ipv4.tcp_keepalive_probes = 4 EOF chmod 644 "$XRAY_SYSCTL_CONF" sysctl --system >/dev/null 2>&1 || sysctl -p "$XRAY_SYSCTL_CONF" >/dev/null 2>&1 || true ok "VLESS TCP tuning применён: $XRAY_SYSCTL_CONF" } cmd_egress_prefer_ipv4() { load_config 2>/dev/null || true hr echo -e "${BOLD} Force IPv4 egress${RESET}" hr local backup_dir svc failed=0 backup_dir="${BACKUP_DIR}/egress-ipv4-before-$(date '+%Y%m%d_%H%M%S')" install -d -m 700 "$backup_dir" [[ -f "$EGRESS_GAI_CONF" ]] && cp -a "$EGRESS_GAI_CONF" "$backup_dir/" 2>/dev/null || true [[ -f "$EGRESS_SYSCTL_CONF" ]] && cp -a "$EGRESS_SYSCTL_CONF" "$backup_dir/" 2>/dev/null || true info "Backup: $backup_dir" touch "$EGRESS_GAI_CONF" sed -i '/^[#[:space:]]*precedence[[:space:]][[:space:]]*::ffff:0:0\/96[[:space:]][[:space:]]*100[[:space:]]*$/d' "$EGRESS_GAI_CONF" cat >> "$EGRESS_GAI_CONF" <<'EOF' # Yurich Panel: prefer IPv4 for outbound proxy egress. # Keeps geo/IP checks consistent when provider IPv6 geolocation differs. precedence ::ffff:0:0/96 100 EOF chmod 644 "$EGRESS_GAI_CONF" ok "IPv4 preference записан в $EGRESS_GAI_CONF" cat > "$EGRESS_SYSCTL_CONF" <<'EOF' # Yurich Panel: force IPv4 egress for proxy services. # Use `yurich-panel.sh egress-dualstack` to revert. net.ipv6.conf.all.disable_ipv6 = 1 net.ipv6.conf.default.disable_ipv6 = 1 EOF chmod 644 "$EGRESS_SYSCTL_CONF" sysctl -p "$EGRESS_SYSCTL_CONF" >/dev/null 2>&1 || true for path in /proc/sys/net/ipv6/conf/*/disable_ipv6; do [[ -w "$path" ]] && printf '1' > "$path" 2>/dev/null || true done ok "IPv6 отключён для egress-теста: $EGRESS_SYSCTL_CONF" for svc in caddy xray hysteria; do if systemctl is-active --quiet "$svc" 2>/dev/null; then info "Перезапускаю $svc для применения resolver policy..." if systemctl restart "$svc"; then ok "$svc restarted" else err "$svc restart failed" journalctl -u "$svc" -n 50 --no-pager 2>/dev/null || true failed=1 fi fi done if command -v getent >/dev/null 2>&1; then info "Проверка порядка адресов для ifconfig.co:" getent ahosts ifconfig.co 2>/dev/null | sed -n '1,6p' || true fi [[ "$failed" -eq 0 ]] } cmd_egress_dualstack() { load_config 2>/dev/null || true hr echo -e "${BOLD} Restore dual-stack egress${RESET}" hr local backup_dir svc failed=0 backup_dir="${BACKUP_DIR}/egress-dualstack-before-$(date '+%Y%m%d_%H%M%S')" install -d -m 700 "$backup_dir" [[ -f "$EGRESS_GAI_CONF" ]] && cp -a "$EGRESS_GAI_CONF" "$backup_dir/" 2>/dev/null || true [[ -f "$EGRESS_SYSCTL_CONF" ]] && cp -a "$EGRESS_SYSCTL_CONF" "$backup_dir/" 2>/dev/null || true info "Backup: $backup_dir" rm -f "$EGRESS_SYSCTL_CONF" sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true for path in /proc/sys/net/ipv6/conf/*/disable_ipv6; do [[ -w "$path" ]] && printf '0' > "$path" 2>/dev/null || true done if [[ -f "$EGRESS_GAI_CONF" ]]; then sed -i '/^# Yurich Panel: prefer IPv4 for outbound proxy egress\.$/,+2d' "$EGRESS_GAI_CONF" fi ok "Dual-stack egress восстановлен" for svc in caddy xray hysteria; do if systemctl is-active --quiet "$svc" 2>/dev/null; then info "Перезапускаю $svc..." if systemctl restart "$svc"; then ok "$svc restarted" else err "$svc restart failed" failed=1 fi fi done [[ "$failed" -eq 0 ]] } detect_pingtunnel_arch() { local arch arch=$(uname -m) case "$arch" in x86_64|amd64) printf 'amd64\n' ;; i386|i686) printf '386\n' ;; aarch64|arm64) printf 'arm64\n' ;; armv7l|armv6l|armv7*) printf 'arm\n' ;; riscv64) printf 'riscv64\n' ;; ppc64) printf 'ppc64\n' ;; ppc64le) printf 'ppc64le\n' ;; *) err "Архитектура $arch не поддерживается PingTunnel автоустановкой"; return 1 ;; esac } validate_zip_archive_safe() { local archive="$1" max_entries="${2:-10000}" max_unpacked_bytes="${3:-536870912}" [[ -f "$archive" && ! -L "$archive" ]] || return 1 command -v python3 >/dev/null 2>&1 || { err "Для безопасной проверки ZIP нужен python3"; return 1; } python3 - "$archive" "$max_entries" "$max_unpacked_bytes" <<'PY' import pathlib import stat import sys import zipfile archive = pathlib.Path(sys.argv[1]) max_entries = int(sys.argv[2]) max_unpacked = int(sys.argv[3]) total = 0 try: with zipfile.ZipFile(archive) as zf: infos = zf.infolist() if not infos or len(infos) > max_entries: raise ValueError(f"unsafe ZIP entry count: {len(infos)}") for info in infos: name = info.filename path = pathlib.PurePosixPath(name) if not name or "\\" in name or path.is_absolute() or ".." in path.parts: raise ValueError(f"unsafe ZIP path: {name!r}") mode = (info.external_attr >> 16) & 0xFFFF kind = stat.S_IFMT(mode) if kind not in (0, stat.S_IFREG, stat.S_IFDIR): raise ValueError(f"unsafe ZIP entry type: {name!r}") if info.flag_bits & 0x1: raise ValueError(f"encrypted ZIP entry is not supported: {name!r}") total += info.file_size if total > max_unpacked: raise ValueError(f"ZIP unpacked size exceeds {max_unpacked} bytes") except (OSError, ValueError, zipfile.BadZipFile) as exc: print(f"ZIP validation failed: {exc}", file=sys.stderr) raise SystemExit(1) PY } random_pingtunnel_key() { local raw raw=$(od -An -N4 -tu4 /dev/urandom 2>/dev/null | tr -d '[:space:]') [[ "$raw" =~ ^[0-9]+$ ]] || raw=$((RANDOM * RANDOM + 1)) printf '%s\n' "$((raw % 2147483646 + 1))" } write_pingtunnel_env() { local force="${1:-0}" key secret encrypt install -d -m 700 "$CONFIG_DIR" if [[ "$force" != "1" && -f "$PINGTUNNEL_ENV" ]]; then return 0 fi key=$(random_pingtunnel_key) secret=$(openssl rand -base64 32 | tr -d '\n') encrypt="chacha20" umask 077 cat > "$PINGTUNNEL_ENV" < и NAIVEPROXY_PINGTUNNEL_SHA256=" return 1 fi url=$(curl -fsSL https://api.github.com/repos/esrrhs/pingtunnel/releases/latest \ | awk -v asset="pingtunnel_linux_${arch}.zip" ' $0 ~ "\"name\": \"" asset "\"" {found=1} found && /browser_download_url/ { gsub(/.*"browser_download_url": "|".*/, "") print exit }') else url="https://github.com/esrrhs/pingtunnel/releases/download/${version}/pingtunnel_linux_${arch}.zip" fi [[ -n "${url:-}" ]] || { err "Не найден download URL для PingTunnel (${version}, ${arch})"; return 1; } [[ "$url" == "https://github.com/esrrhs/pingtunnel/releases/download/"*"/pingtunnel_linux_${arch}.zip" ]] || { err "PingTunnel download URL не прошёл проверку источника" return 1 } tmp=$(mktemp -d) zip="${tmp}/pingtunnel.zip" install -d -m 755 "$PINGTUNNEL_DIR" info "Скачиваю PingTunnel ${version}: ${url}" curl -fsSL -o "$zip" -- "$url" || { rm -rf "$tmp"; err "Не удалось скачать PingTunnel"; return 1; } archive_size=$(stat -c '%s' "$zip" 2>/dev/null || echo 0) if [[ ! "$archive_size" =~ ^[0-9]+$ || "$archive_size" -gt 268435456 ]]; then rm -rf "$tmp" err "PingTunnel archive превышает лимит 256 MiB" return 1 fi expected_sha="${NAIVEPROXY_PINGTUNNEL_SHA256:-}" if [[ -z "$expected_sha" && "$version" == "$PINGTUNNEL_DEFAULT_VERSION" ]]; then case "$arch" in amd64) expected_sha="$PINGTUNNEL_SHA256_AMD64" ;; arm64) expected_sha="$PINGTUNNEL_SHA256_ARM64" ;; esac fi if [[ -n "$expected_sha" ]]; then [[ "$expected_sha" =~ ^[a-fA-F0-9]{64}$ ]] || { rm -rf "$tmp"; err "Некорректный PingTunnel SHA256"; return 1; } expected_sha="${expected_sha,,}" actual_sha=$(sha256sum "$zip" | awk '{print $1}') if [[ "$actual_sha" != "$expected_sha" ]]; then rm -rf "$tmp" err "PingTunnel SHA256 mismatch: expected=${expected_sha}, actual=${actual_sha}" return 1 fi ok "PingTunnel SHA256 проверен" elif [[ "${YURICH_ALLOW_UNVERIFIED_DOWNLOADS:-0}" == "1" ]]; then warn "PingTunnel SHA256 не задан, продолжаю из-за YURICH_ALLOW_UNVERIFIED_DOWNLOADS=1" else rm -rf "$tmp" err "Нет SHA256 для PingTunnel ${version}/${arch}" err "Задай NAIVEPROXY_PINGTUNNEL_SHA256= или YURICH_ALLOW_UNVERIFIED_DOWNLOADS=1" return 1 fi if ! validate_zip_archive_safe "$zip" 10000 536870912; then rm -rf "$tmp" err "PingTunnel ZIP не прошёл безопасную проверку" return 1 fi unzip -q "$zip" -d "$tmp/unpack" || { rm -rf "$tmp"; err "Не удалось распаковать PingTunnel"; return 1; } extracted=$(find "$tmp/unpack" -type f -name 'pingtunnel' -perm /111 2>/dev/null | head -1) [[ -n "$extracted" ]] || extracted=$(find "$tmp/unpack" -type f -name 'pingtunnel' 2>/dev/null | head -1) [[ -n "$extracted" ]] || { rm -rf "$tmp"; err "Бинарник pingtunnel не найден в архиве"; return 1; } chmod 755 "$extracted" help_output=$(timeout 10s "$extracted" -h 2>&1 || true) [[ -n "$help_output" ]] || { rm -rf "$tmp"; err "Скачанный PingTunnel binary не запускается"; return 1; } staging_bin=$(mktemp "${PINGTUNNEL_BIN}.install.XXXXXX") || { rm -rf "$tmp"; return 1; } if ! install -m 755 "$extracted" "$staging_bin" || ! mv -f -- "$staging_bin" "$PINGTUNNEL_BIN"; then rm -f "$staging_bin" rm -rf "$tmp" err "Не удалось атомарно установить PingTunnel" return 1 fi rm -rf "$tmp" ok "PingTunnel установлен: $PINGTUNNEL_BIN" } wait_apt_locks() { local i for i in $(seq 1 60); do if fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ || fuser /var/lib/dpkg/lock >/dev/null 2>&1 \ || fuser /var/cache/apt/archives/lock >/dev/null 2>&1; then info "Жду apt/dpkg lock (${i}/60)..." sleep 5 else return 0 fi done err "apt/dpkg lock не освободился" return 1 } write_pingtunnel_service() { cat > "$PINGTUNNEL_SERVICE" < "$PINGTUNNEL_SYSCTL_CONF" <<'EOF' # Yurich Panel PingTunnel server mode. # Prevents the kernel ICMP echo handler from racing with PingTunnel raw socket replies. net.ipv4.icmp_echo_ignore_all = 1 EOF chmod 644 "$PINGTUNNEL_SYSCTL_CONF" sysctl -p "$PINGTUNNEL_SYSCTL_CONF" >/dev/null 2>&1 || true } cmd_pingtunnel_install() { load_config 2>/dev/null || true hr echo -e "${BOLD} PingTunnel ICMP fallback install${RESET}" hr wait_apt_locks || return 1 apt-get update wait_apt_locks || return 1 DEBIAN_FRONTEND=noninteractive apt-get install -y curl unzip ca-certificates openssl >/dev/null download_pingtunnel_binary || return 1 write_pingtunnel_env 0 || return 1 write_pingtunnel_service || return 1 systemctl daemon-reload systemctl enable --now yurich-pingtunnel sleep 2 systemctl is-active --quiet yurich-pingtunnel || { journalctl -u yurich-pingtunnel -n 80 --no-pager; return 1; } ok "PingTunnel active" cmd_pingtunnel_status } cmd_pingtunnel_status() { hr echo -e "${BOLD} PingTunnel status${RESET}" hr [[ -x "$PINGTUNNEL_BIN" ]] && "$PINGTUNNEL_BIN" -h 2>&1 | head -1 || warn "PingTunnel binary не найден" if systemctl is-active --quiet yurich-pingtunnel 2>/dev/null; then ok "Service active: yurich-pingtunnel" else warn "Service не active: yurich-pingtunnel" fi systemctl show yurich-pingtunnel \ -p ActiveState -p SubState -p MainPID -p ExecMainStatus -p NRestarts \ --no-pager 2>/dev/null || true if [[ -f "$PINGTUNNEL_ENV" ]]; then awk -F= ' $1=="PINGTUNNEL_KEY" {print "key_id="$2} $1=="PINGTUNNEL_ENCRYPT" {print "encrypt="$2} $1=="PINGTUNNEL_ENCRYPT_KEY" {print "encrypt_key=***stored in /etc/naiveproxy/pingtunnel.env***"} ' "$PINGTUNNEL_ENV" fi journalctl -u yurich-pingtunnel -n 12 --no-pager 2>/dev/null \ | sed -E 's/(-encrypt-key )[A-Za-z0-9+\/=._-]+/\1***REDACTED***/g' || true sysctl net.ipv4.icmp_echo_ignore_all 2>/dev/null || true } cmd_pingtunnel_config() { if [[ ! -f "$PINGTUNNEL_ENV" ]]; then err "PingTunnel env не найден: $PINGTUNNEL_ENV" return 1 fi if [[ "${1:-}" == "--show-secrets" || "${YURICH_SHOW_SECRETS:-0}" == "1" ]]; then cat "$PINGTUNNEL_ENV" else sed -E \ -e 's/^(PINGTUNNEL_KEY=).+/\1***REDACTED***/' \ -e 's/^(PINGTUNNEL_ENCRYPT_KEY=).+/\1***REDACTED***/' \ "$PINGTUNNEL_ENV" warn "Секреты скрыты. Для полного вывода: pingtunnel-config --show-secrets или YURICH_SHOW_SECRETS=1" fi } write_pingtunnel_subscription_file() { local out_file="$1" domain="$2" user="$3" key encrypt encrypt_key [[ -n "$out_file" && -n "$domain" && -f "$PINGTUNNEL_ENV" ]] || return 1 if [[ -L "$PINGTUNNEL_ENV" || "$(stat -c '%u' "$PINGTUNNEL_ENV" 2>/dev/null || echo -1)" != "0" ]]; then err "PingTunnel env должен быть обычным root-owned файлом: $PINGTUNNEL_ENV" return 1 fi chmod 600 "$PINGTUNNEL_ENV" 2>/dev/null || return 1 # shellcheck source=/dev/null source "$PINGTUNNEL_ENV" key="${PINGTUNNEL_KEY:-}" encrypt="${PINGTUNNEL_ENCRYPT:-chacha20}" encrypt_key="${PINGTUNNEL_ENCRYPT_KEY:-}" [[ -n "$key" && -n "$encrypt_key" ]] || return 1 cat > "$out_file" </dev/null || true rm -f "$PINGTUNNEL_SERVICE" "$PINGTUNNEL_SYSCTL_CONF" sysctl -w net.ipv4.icmp_echo_ignore_all=0 >/dev/null 2>&1 || true systemctl daemon-reload ok "PingTunnel service удалён. Конфиг и ключи оставлены: $PINGTUNNEL_ENV" } write_haproxy_logging_config() { cat > "$HAPROXY_RSYSLOG_CONF" <<'EOF' local0.* /var/log/haproxy.log & stop EOF chmod 644 "$HAPROXY_RSYSLOG_CONF" touch "$HAPROXY_LOG_FILE" chmod 640 "$HAPROXY_LOG_FILE" 2>/dev/null || true if id syslog >/dev/null 2>&1; then chown syslog:adm "$HAPROXY_LOG_FILE" 2>/dev/null || chown syslog:syslog "$HAPROXY_LOG_FILE" 2>/dev/null || true fi if [[ -f /etc/logrotate.d/haproxy ]] && grep -Eq '(^|[[:space:]])/var/log/haproxy\.log([[:space:]]|\{|$)' /etc/logrotate.d/haproxy; then rm -f "$HAPROXY_LOGROTATE_CONF" 2>/dev/null || true else cat > "$HAPROXY_LOGROTATE_CONF" </dev/null 2>&1 || systemctl reload rsyslog >/dev/null 2>&1 || true endscript } EOF chmod 644 "$HAPROXY_LOGROTATE_CONF" fi systemctl enable rsyslog --quiet 2>/dev/null || true systemctl restart rsyslog 2>/dev/null || systemctl reload rsyslog 2>/dev/null || true } write_haproxy_sni_mux_config() { load_config if ! is_valid_domain "${DOMAIN:-}"; then err "Домен не настроен или некорректен: ${DOMAIN:-}" return 1 fi local domain="${DOMAIN}" local subscription_domain caddy_sni_domains dom subscription_domain=$(subscription_public_domain) || { err "Некорректный SUBSCRIPTION_DOMAIN: ${SUBSCRIPTION_DOMAIN:-}" return 1 } caddy_sni_domains="$domain" if [[ "$subscription_domain" != "$domain" ]]; then caddy_sni_domains+=" ${subscription_domain}" fi if [[ -n "${DOMAINS:-}" ]]; then while IFS= read -r dom; do dom="${dom//[[:space:]]/}" [[ -z "$dom" ]] && continue if ! is_valid_domain "$dom"; then err "Некорректный домен для HAProxy SNI mux: $dom" return 1 fi if ! domain_in_space_list_ci "$caddy_sni_domains" "$dom"; then caddy_sni_domains+=" ${dom}" fi done < <(printf '%s\n' "$DOMAINS" | tr ',' '\n') fi local caddy_port="${XRAY_CADDY_FALLBACK_PORT:-$XRAY_CADDY_FALLBACK_PORT_DEFAULT}" local reality_port="${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}" local reality_sni="${XRAY_REALITY_SERVER_NAME:-www.microsoft.com}" local mobile_alt_port="${XRAY_MOBILE_ALT_PORT:-$XRAY_MOBILE_ALT_PORT_DEFAULT}" local mobile_alt_sni="${XRAY_MOBILE_ALT_SERVER_NAME:-$XRAY_MOBILE_ALT_SERVER_NAME_DEFAULT}" local github_test_port="${XRAY_GITHUB_TEST_PORT:-$XRAY_GITHUB_TEST_PORT_DEFAULT}" local github_test_sni="${XRAY_GITHUB_TEST_SERVER_NAME:-$XRAY_GITHUB_TEST_SERVER_NAME_DEFAULT}" local browser_sni_domain="${BROWSER_PROXY_SNI_DOMAIN:-}" local browser_backend_port="${BROWSER_PROXY_BACKEND_PORT:-7444}" local browser_send_proxy_v2="${BROWSER_PROXY_SEND_PROXY_V2:-0}" local backup_file="" nbthread_value="" nbthread_block="" mobile_alt_acl="" mobile_alt_backend="" github_test_acl="" github_test_backend="" local browser_acl="" browser_backend="" browser_server_args="" local default_backend_name="caddy_tls" reality_backend="" if [[ "${XRAY_REALITY_ENABLED:-1}" == "1" && "${XRAY_MOBILE_ALT_ENABLED:-0}" == "1" && -s "$XRAY_COMPAT_USERS_FILE" ]]; then mobile_alt_acl=" use_backend xray_reality_mobile_alt if { req.ssl_sni -i ${mobile_alt_sni} }"$'\n' mobile_alt_backend=$'\n'"backend xray_reality_mobile_alt"$'\n'" mode tcp"$'\n'" server xray_mobile_alt 127.0.0.1:${mobile_alt_port} check inter 2s fall 3 rise 2"$'\n' fi if [[ "${XRAY_REALITY_ENABLED:-1}" == "1" && "${XRAY_GITHUB_TEST_ENABLED:-0}" == "1" ]]; then if ! is_valid_domain "$github_test_sni"; then err "Некорректный XRAY_GITHUB_TEST_SERVER_NAME: $github_test_sni" return 1 fi github_test_acl=" use_backend xray_reality_github_test if { req.ssl_sni -i ${github_test_sni} }"$'\n' github_test_backend=$'\n'"backend xray_reality_github_test"$'\n'" mode tcp"$'\n'" option tcp-check"$'\n'" server xray_github 127.0.0.1:${github_test_port} check inter 2s fall 3 rise 2"$'\n' fi if [[ -n "$browser_sni_domain" ]]; then if ! is_valid_domain "$browser_sni_domain"; then err "Некорректный BROWSER_PROXY_SNI_DOMAIN: $browser_sni_domain" return 1 fi if domain_in_space_list_ci "$caddy_sni_domains" "$browser_sni_domain"; then err "BROWSER_PROXY_SNI_DOMAIN конфликтует с основным Caddy SNI: $browser_sni_domain" return 1 fi if [[ "${XRAY_REALITY_ENABLED:-1}" == "1" && "${browser_sni_domain,,}" == "${reality_sni,,}" ]]; then err "BROWSER_PROXY_SNI_DOMAIN конфликтует с REALITY SNI: $browser_sni_domain" return 1 fi if [[ "${XRAY_MOBILE_ALT_ENABLED:-0}" == "1" && "${browser_sni_domain,,}" == "${mobile_alt_sni,,}" ]]; then err "BROWSER_PROXY_SNI_DOMAIN конфликтует с Mobile Alt SNI: $browser_sni_domain" return 1 fi if [[ "${XRAY_GITHUB_TEST_ENABLED:-0}" == "1" && "${browser_sni_domain,,}" == "${github_test_sni,,}" ]]; then err "BROWSER_PROXY_SNI_DOMAIN конфликтует с GitHub Test SNI: $browser_sni_domain" return 1 fi if ! is_valid_local_proxy_port "$browser_backend_port"; then err "Некорректный BROWSER_PROXY_BACKEND_PORT: $browser_backend_port" return 1 fi if [[ ! "$browser_send_proxy_v2" =~ ^[01]$ ]]; then err "BROWSER_PROXY_SEND_PROXY_V2 должен быть 0 или 1" return 1 fi if ! local_tcp_endpoint_ready "$browser_backend_port"; then err "Browser proxy backend не отвечает на 127.0.0.1:${browser_backend_port}" warn "Сначала запусти совместимый локальный backend или очисти BROWSER_PROXY_SNI_DOMAIN" return 1 fi [[ "$browser_send_proxy_v2" == "1" ]] && browser_server_args=" send-proxy-v2" browser_acl=" use_backend browser_proxy_tls if { req.ssl_sni -i ${browser_sni_domain} }"$'\n' browser_backend=$'\n'"backend browser_proxy_tls"$'\n'" mode tcp"$'\n'" server browser_proxy 127.0.0.1:${browser_backend_port} check inter 2s fall 3 rise 2${browser_server_args}"$'\n' fi if [[ "${XRAY_REALITY_ENABLED:-1}" == "1" ]]; then default_backend_name="xray_reality" reality_backend=$'\n'"backend xray_reality"$'\n'" mode tcp"$'\n'" server xray 127.0.0.1:${reality_port} check inter 2s fall 3 rise 2"$'\n' fi mkdir -p /etc/haproxy "$BACKUP_DIR" if [[ -f "$HAPROXY_CFG" ]]; then backup_file="${BACKUP_DIR}/haproxy-before-sni-mux-$(date '+%Y%m%d_%H%M%S').cfg" cp -p "$HAPROXY_CFG" "$backup_file" fi apply_haproxy_kernel_tuning nbthread_value=$(haproxy_optimal_nbthread) if [[ -n "$nbthread_value" ]]; then nbthread_block=" nbthread ${nbthread_value}"$'\n' fi cat > "$HAPROXY_CFG" </dev/null 2>&1 || return 1 HAPROXY_SOCKET="$HAPROXY_STATS_SOCKET" HAPROXY_COMMAND="$command_text" python3 - <<'PY' import os, socket, sys path = os.environ.get("HAPROXY_SOCKET", "") command = os.environ.get("HAPROXY_COMMAND", "show info").strip() + "\n" try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(3) sock.connect(path) sock.sendall(command.encode("utf-8")) chunks = [] while True: try: data = sock.recv(65536) except socket.timeout: break if not data: break chunks.append(data) sock.close() sys.stdout.write(b"".join(chunks).decode("utf-8", "replace")) except Exception as exc: sys.stderr.write(str(exc) + "\n") sys.exit(1) PY } haproxy_recent_sni_summary() { [[ -f "$HAPROXY_LOG_FILE" ]] || return 0 tail -n 2000 "$HAPROXY_LOG_FILE" 2>/dev/null \ | awk ' /sni=/ { be="-"; sni="-"; for (i=1; i<=NF; i++) { if ($i ~ /^backend=/) { be=substr($i,9) } if ($i ~ /^sni=/) { sni=substr($i,5) } } if (sni == "") sni="-"; count[be " " sni]++; } END { for (k in count) print count[k] " " k; }' \ | sort -rn 2>/dev/null \ | head -8 \ | awk '{c=$1; $1=""; sub(/^ /,""); printf " %sx %s\n", c, $0}' } haproxy_stats_text() { if ! command -v haproxy >/dev/null 2>&1; then echo "HAProxy: не установлен" return 1 fi if ! systemctl is-active --quiet haproxy 2>/dev/null; then echo "HAProxy: service не active" return 1 fi if [[ ! -S "$HAPROXY_STATS_SOCKET" ]]; then echo "HAProxy: stats socket не найден: $HAPROXY_STATS_SOCKET" return 1 fi local info_file stat_file info_file=$(mktemp) stat_file=$(mktemp) if ! haproxy_socket_cmd "show info" > "$info_file" 2>/dev/null; then rm -f "$info_file" "$stat_file" echo "HAProxy: не удалось прочитать show info" return 1 fi if ! haproxy_socket_cmd "show stat" > "$stat_file" 2>/dev/null; then rm -f "$info_file" "$stat_file" echo "HAProxy: не удалось прочитать show stat" return 1 fi python3 - "$info_file" "$stat_file" "${BROWSER_PROXY_SNI_DOMAIN:+1}" <<'PY' import csv import sys info_path, stat_path = sys.argv[1], sys.argv[2] require_browser = len(sys.argv) > 3 and sys.argv[3] == "1" info = {} with open(info_path, "r", encoding="utf-8", errors="replace") as fh: for line in fh: if ":" in line: key, value = line.split(":", 1) info[key.strip()] = value.strip() rows = [] with open(stat_path, "r", encoding="utf-8", errors="replace", newline="") as fh: raw = fh.read().splitlines() if raw: raw[0] = raw[0].lstrip("# ") rows = list(csv.DictReader(raw)) def pick(px, sv=None): for row in rows: if row.get("pxname") == px and (sv is None or row.get("svname") == sv): return row return {} def val(row, key, default="0"): value = (row.get(key) or default).strip() return value or default def human_bytes(value): try: n = float(value or 0) except Exception: n = 0.0 units = ["B", "KiB", "MiB", "GiB", "TiB"] for unit in units: if n < 1024 or unit == units[-1]: return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}B" n /= 1024 frontend = pick("yurich_tls_443", "FRONTEND") caddy = pick("caddy_tls", "caddy") xray = pick("xray_reality", "xray") caddy_backend = pick("caddy_tls", "BACKEND") xray_backend = pick("xray_reality", "BACKEND") browser = pick("browser_proxy_tls", "browser_proxy") browser_backend = pick("browser_proxy_tls", "BACKEND") print("HAProxy: active") print(f"Uptime: {info.get('Uptime', 'n/a')}") print(f"Connections: {info.get('CurrConns', '0')} / {info.get('Maxconn', 'n/a')} max") print(f"Frontend 443: current={val(frontend, 'scur')} total={val(frontend, 'stot')} in={human_bytes(val(frontend, 'bin'))} out={human_bytes(val(frontend, 'bout'))}") print("Routes:") print(f" Naive/Caddy: status={val(caddy, 'status', 'n/a')} current={val(caddy_backend, 'scur')} total={val(caddy_backend, 'stot')} in={human_bytes(val(caddy_backend, 'bin'))} out={human_bytes(val(caddy_backend, 'bout'))}") print(f" Reality/Xray: status={val(xray, 'status', 'n/a')} current={val(xray_backend, 'scur')} total={val(xray_backend, 'stot')} in={human_bytes(val(xray_backend, 'bin'))} out={human_bytes(val(xray_backend, 'bout'))}") if require_browser: print(f" Browser proxy: status={val(browser, 'status', 'n/a')} current={val(browser_backend, 'scur')} total={val(browser_backend, 'stot')} in={human_bytes(val(browser_backend, 'bin'))} out={human_bytes(val(browser_backend, 'bout'))}") PY rm -f "$info_file" "$stat_file" local recent recent=$(haproxy_recent_sni_summary || true) if [[ -n "$recent" ]]; then echo "Recent SNI routes:" printf '%s\n' "$recent" else echo "Recent SNI routes: пока нет данных в $HAPROXY_LOG_FILE" fi } haproxy_backends_healthy() { load_config 2>/dev/null || true command -v haproxy >/dev/null 2>&1 || { echo "HAProxy binary not found"; return 1; } systemctl is-active --quiet haproxy 2>/dev/null || { echo "HAProxy service is not active"; return 1; } [[ -S "$HAPROXY_STATS_SOCKET" ]] || { echo "HAProxy stats socket not found: $HAPROXY_STATS_SOCKET"; return 1; } local stat_file rc=0 require_reality="${XRAY_REALITY_ENABLED:-1}" require_browser="0" [[ -n "${BROWSER_PROXY_SNI_DOMAIN:-}" ]] && require_browser="1" stat_file=$(mktemp) if ! haproxy_socket_cmd "show stat" > "$stat_file" 2>/dev/null; then rm -f "$stat_file" echo "HAProxy show stat failed" return 1 fi python3 - "$stat_file" "$require_reality" "$require_browser" <<'PY' import csv import sys path = sys.argv[1] require_reality = sys.argv[2] == "1" require_browser = sys.argv[3] == "1" raw = open(path, "r", encoding="utf-8", errors="replace").read().splitlines() if not raw: print("HAProxy stats are empty") sys.exit(1) raw[0] = raw[0].lstrip("# ") rows = list(csv.DictReader(raw)) def status(px, sv): for row in rows: if row.get("pxname") == px and row.get("svname") == sv: return (row.get("status") or "").strip() return "" backends = [("caddy_tls", "caddy", "Naive/Caddy")] if require_reality: backends.append(("xray_reality", "xray", "Reality/Xray")) if require_browser: backends.append(("browser_proxy_tls", "browser_proxy", "Browser proxy")) failed = [] for px, sv, label in backends: st = status(px, sv) if st != "UP": failed.append(f"{label}: {st or 'missing'}") if failed: print("\n".join(failed)) sys.exit(1) print("HAProxy backends UP: " + ", ".join(label for _, _, label in backends)) PY rc=$? rm -f "$stat_file" return "$rc" } cmd_haproxy_status() { load_config 2>/dev/null || true hr echo -e "${BOLD} HAProxy SNI mux status${RESET}" hr local hap_ver hap_ver=$(haproxy -v 2>/dev/null | head -1 || true) [[ -n "$hap_ver" ]] && echo "$hap_ver" || warn "haproxy не установлен" echo haproxy_stats_text || true echo echo -e " Config: ${CYAN}${HAPROXY_CFG}${RESET}" echo -e " Socket: ${CYAN}${HAPROXY_STATS_SOCKET}${RESET}" echo -e " Log: ${CYAN}${HAPROXY_LOG_FILE}${RESET}" hr } cmd_haproxy_tg() { load_config 2>/dev/null || true local tmp safe_text tmp=$(mktemp /tmp/yurich-haproxy-tg-XXXXXX.out) cmd_haproxy_status > "$tmp" 2>&1 || true safe_text=$(html_escape_text "$(tail -n 45 "$tmp")") tg_send "📊 Yurich Connect HAProxy 📡 Сервер: $(hostname) 🕐 $(date '+%Y-%m-%d %H:%M:%S')
${safe_text}
" cat "$tmp" rm -f "$tmp" ok "HAProxy stats отправлены в Telegram" } cmd_haproxy_logs() { hr echo -e "${BOLD} HAProxy SNI logs${RESET}" hr if [[ -f "$HAPROXY_LOG_FILE" ]]; then tail -n 80 "$HAPROXY_LOG_FILE" 2>/dev/null | sed -E 's/client=[^ ]+/client=hidden/g' else warn "Лог не найден: $HAPROXY_LOG_FILE" journalctl -u haproxy -n 80 --no-pager 2>/dev/null || true fi hr } edge_routing_snapshot_file() { local snapshot="$1" label="$2" path="$3" if [[ -e "$path" || -L "$path" ]]; then cp -a -- "$path" "$snapshot/$label" else : > "$snapshot/$label.absent" fi } edge_routing_snapshot() { local snapshot unit enabled active snapshot=$(mktemp -d /tmp/yurich-edge-routing.XXXXXX) || return 1 chmod 700 "$snapshot" edge_routing_snapshot_file "$snapshot" naive.conf "$CONFIG_FILE" || { rm -rf -- "$snapshot"; return 1; } edge_routing_snapshot_file "$snapshot" Caddyfile "$CADDYFILE" || { rm -rf -- "$snapshot"; return 1; } edge_routing_snapshot_file "$snapshot" xray.json "$XRAY_CONFIG" || { rm -rf -- "$snapshot"; return 1; } edge_routing_snapshot_file "$snapshot" haproxy.cfg "$HAPROXY_CFG" || { rm -rf -- "$snapshot"; return 1; } edge_routing_snapshot_file "$snapshot" haproxy.rsyslog "$HAPROXY_RSYSLOG_CONF" || { rm -rf -- "$snapshot"; return 1; } edge_routing_snapshot_file "$snapshot" haproxy.logrotate "$HAPROXY_LOGROTATE_CONF" || { rm -rf -- "$snapshot"; return 1; } edge_routing_snapshot_file "$snapshot" haproxy.sysctl "$HAPROXY_SYSCTL_CONF" || { rm -rf -- "$snapshot"; return 1; } : > "$snapshot/services.state" for unit in caddy.service haproxy.service xray.service; do enabled="no"; active="no" systemctl is-enabled --quiet "$unit" 2>/dev/null && enabled="yes" systemctl is-active --quiet "$unit" 2>/dev/null && active="yes" printf '%s|%s|%s\n' "$unit" "$enabled" "$active" >> "$snapshot/services.state" done printf '%s\n' "$snapshot" } edge_routing_restore_file() { local snapshot="$1" label="$2" path="$3" if [[ -f "$snapshot/$label.absent" ]]; then rm -f -- "$path" elif [[ -e "$snapshot/$label" || -L "$snapshot/$label" ]]; then mkdir -p "$(dirname "$path")" cp -a -- "$snapshot/$label" "$path" fi } edge_routing_restore() { local snapshot="$1" unit enabled active failed=0 [[ -n "$snapshot" && "$snapshot" == /tmp/yurich-edge-routing.* && -d "$snapshot" ]] || return 1 edge_routing_restore_file "$snapshot" naive.conf "$CONFIG_FILE" || failed=1 edge_routing_restore_file "$snapshot" Caddyfile "$CADDYFILE" || failed=1 edge_routing_restore_file "$snapshot" xray.json "$XRAY_CONFIG" || failed=1 edge_routing_restore_file "$snapshot" haproxy.cfg "$HAPROXY_CFG" || failed=1 edge_routing_restore_file "$snapshot" haproxy.rsyslog "$HAPROXY_RSYSLOG_CONF" || failed=1 edge_routing_restore_file "$snapshot" haproxy.logrotate "$HAPROXY_LOGROTATE_CONF" || failed=1 edge_routing_restore_file "$snapshot" haproxy.sysctl "$HAPROXY_SYSCTL_CONF" || failed=1 systemctl daemon-reload >/dev/null 2>&1 || true while IFS='|' read -r unit enabled active; do [[ -n "$unit" ]] || continue if [[ "$enabled" == "yes" ]]; then systemctl enable "$unit" >/dev/null 2>&1 || failed=1 else systemctl disable "$unit" >/dev/null 2>&1 || true fi if [[ "$active" == "yes" ]]; then systemctl restart "$unit" >/dev/null 2>&1 || failed=1 else systemctl stop "$unit" >/dev/null 2>&1 || true fi done < "$snapshot/services.state" rm -rf -- "$snapshot" [[ "$failed" -eq 0 ]] } edge_routing_discard_snapshot() { local snapshot="$1" [[ -n "$snapshot" && "$snapshot" == /tmp/yurich-edge-routing.* ]] && rm -rf -- "$snapshot" } edge_routing_transition_failed() { local snapshot="$1" message="$2" err "$message" if edge_routing_restore "$snapshot"; then warn "Предыдущий режим 443 восстановлен" else err "Автоматический rollback режима 443 выполнен не полностью. Проверь Caddy/HAProxy/Xray вручную." fi return 1 } apply_haproxy_sni_mux_runtime() { local routing_snapshot load_config check_installed || { err "Сначала установи Yurich Panel"; return 1; } if ! is_valid_domain "${DOMAIN:-}"; then err "Домен не настроен или некорректен: ${DOMAIN:-}" return 1 fi ensure_haproxy_packages || return 1 routing_snapshot=$(edge_routing_snapshot) || { err "Не удалось создать rollback-снимок режима 443"; return 1; } write_haproxy_logging_config || { edge_routing_transition_failed "$routing_snapshot" "Не удалось настроить HAProxy logging"; return 1; } set_edge_routing_mode "haproxy" || { edge_routing_transition_failed "$routing_snapshot" "Не удалось выбрать HAProxy mode"; return 1; } save_config || { edge_routing_transition_failed "$routing_snapshot" "Не удалось сохранить HAProxy mode"; return 1; } info "Пересобираю Caddy для внутреннего TLS порта ${XRAY_CADDY_FALLBACK_PORT}" rewrite_caddyfile_current || { edge_routing_transition_failed "$routing_snapshot" "Caddy fallback-конфиг не собран"; return 1; } if ! systemctl reload caddy 2>/dev/null && ! systemctl restart caddy; then err "Caddy не применил fallback-конфиг для HAProxy" edge_routing_transition_failed "$routing_snapshot" "Caddy не применил fallback-конфиг для HAProxy" return 1 fi if [[ -x "$XRAY_BIN" && -s "$XRAY_USERS_FILE" ]]; then info "Проверяю Xray Reality на TCP/${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}" write_xray_config || { edge_routing_transition_failed "$routing_snapshot" "Xray config не собран"; return 1; } write_xray_service systemctl restart xray || { edge_routing_transition_failed "$routing_snapshot" "Xray не перезапустился"; return 1; } else warn "Xray не установлен или нет пользователей. HAProxy применится, но Reality backend будет DOWN до настройки Xray." fi write_haproxy_sni_mux_config || { edge_routing_transition_failed "$routing_snapshot" "HAProxy config не собран"; return 1; } systemctl enable haproxy --quiet || { edge_routing_transition_failed "$routing_snapshot" "HAProxy не включён"; return 1; } if ! systemctl reload haproxy 2>/dev/null && ! systemctl restart haproxy; then edge_routing_transition_failed "$routing_snapshot" "HAProxy не применил конфигурацию" return 1 fi sleep 1 if ! systemctl is-active --quiet haproxy; then err "HAProxy не запустился" journalctl -u haproxy -n 50 --no-pager edge_routing_transition_failed "$routing_snapshot" "HAProxy не запустился" return 1 fi apply_xray_reality_firewall edge_routing_discard_snapshot "$routing_snapshot" ok "HAProxy SNI mux применён: ${DOMAIN}:443 -> Caddy, default SNI -> Xray Reality" } apply_caddy_only_runtime() { local routing_snapshot load_config check_installed || { err "Сначала установи Yurich Panel"; return 1; } routing_snapshot=$(edge_routing_snapshot) || { err "Не удалось создать rollback-снимок режима 443"; return 1; } set_edge_routing_mode "caddy" || { edge_routing_transition_failed "$routing_snapshot" "Не удалось выбрать Caddy-only mode"; return 1; } save_config || { edge_routing_transition_failed "$routing_snapshot" "Не удалось сохранить Caddy-only mode"; return 1; } info "Останавливаю HAProxy, чтобы освободить TCP/443 для Caddy" systemctl disable --now haproxy >/dev/null 2>&1 || true systemctl stop haproxy >/dev/null 2>&1 || true pkill -x haproxy >/dev/null 2>&1 || true info "Пересобираю Caddyfile для прямого Caddy-only режима на 443" rewrite_caddyfile_current || { edge_routing_transition_failed "$routing_snapshot" "Caddy-only конфиг не собран"; return 1; } if ! systemctl reload caddy 2>/dev/null && ! systemctl restart caddy; then err "Caddy не применил Caddy-only конфиг" edge_routing_transition_failed "$routing_snapshot" "Caddy не применил Caddy-only конфиг" return 1 fi if [[ -x "$XRAY_BIN" && -s "$XRAY_USERS_FILE" ]]; then info "Пересобираю Xray Reality для прямого TCP-порта ${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}" write_xray_config || { edge_routing_transition_failed "$routing_snapshot" "Xray config не собран"; return 1; } write_xray_service systemctl restart xray || { edge_routing_transition_failed "$routing_snapshot" "Xray не перезапустился"; return 1; } fi apply_xray_reality_firewall edge_routing_discard_snapshot "$routing_snapshot" ok "Caddy-only применён: Caddy держит 443, Reality использует отдельный TCP-порт" } refresh_haproxy_if_enabled() { load_config edge_routing_mode_is_haproxy || return 0 command -v haproxy >/dev/null 2>&1 || return 0 write_haproxy_sni_mux_config || return 1 systemctl reload haproxy 2>/dev/null || systemctl restart haproxy } cmd_edge_routing_mode() { load_config local mode="${1:-}" if [[ -z "$mode" ]]; then prompt_edge_routing_mode || return 1 mode="${EDGE_ROUTING_MODE:-caddy}" else set_edge_routing_mode "$mode" || return 1 fi if [[ "$mode" == "haproxy" ]]; then apply_haproxy_sni_mux_runtime || return 1 cmd_haproxy_status else apply_caddy_only_runtime || return 1 cmd_health_check fi } cmd_haproxy_apply() { load_config check_installed || { err "Сначала установи Yurich Panel"; return 1; } if ! is_valid_domain "${DOMAIN:-}"; then err "Домен не настроен или некорректен: ${DOMAIN:-}" return 1 fi hr echo -e "${BOLD} Применение HAProxy SNI mux${RESET}" hr apply_haproxy_sni_mux_runtime || return 1 cmd_haproxy_status } cmd_haproxy_menu() { while true; do load_config hr echo -e "${BOLD} Routing mode / HAProxy SNI mux${RESET}" hr echo -e " Текущий режим: ${CYAN}$(edge_routing_mode_label)${RESET}" echo -e " ${BOLD}1)${RESET} Статус и маршруты" echo -e " ${BOLD}2)${RESET} Применить / пересобрать SNI mux" echo -e " ${BOLD}3)${RESET} Последние SNI-логи" echo -e " ${BOLD}4)${RESET} Переключить на Caddy-only" echo -e " ${BOLD}0)${RESET} Назад" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_haproxy_status ;; 2) cmd_haproxy_apply ;; 3) cmd_haproxy_logs ;; 4) cmd_edge_routing_mode caddy ;; 0|"") return ;; *) warn "Неверный выбор" ;; esac echo -ne "${DIM}Enter для продолжения...${RESET}"; read -r _ done } cmd_security_audit() { load_config 2>/dev/null || true local failed=0 warn_count=0 value owner perms tcp_ports udp_service_ports service service_hardening_failed hr echo -e "${BOLD} Security audit${RESET}" hr if command -v ufw >/dev/null 2>&1; then if ufw status 2>/dev/null | grep -qi '^Status: active'; then ok "UFW active" else err "UFW не active" failed=$((failed + 1)) fi else warn "UFW не установлен" warn_count=$((warn_count + 1)) fi if command -v sshd >/dev/null 2>&1; then value=$(sshd -T 2>/dev/null | awk '/^passwordauthentication /{print $2; exit}' || true) [[ "$value" == "no" ]] && ok "SSH password login disabled" || { err "SSH passwordauthentication=${value:-unknown}"; failed=$((failed + 1)); } value=$(sshd -T 2>/dev/null | awk '/^permitrootlogin /{print $2; exit}' || true) case "$value" in no|prohibit-password|without-password|forced-commands-only) ok "SSH root login restricted: ${value}" ;; *) err "SSH permitrootlogin=${value:-unknown}"; failed=$((failed + 1)) ;; esac else warn "sshd не найден" warn_count=$((warn_count + 1)) fi if systemctl is-active --quiet fail2ban 2>/dev/null; then ok "Fail2Ban active" else warn "Fail2Ban не active" warn_count=$((warn_count + 1)) fi if systemctl is-active --quiet crowdsec 2>/dev/null; then ok "CrowdSec active" else warn "CrowdSec не active" warn_count=$((warn_count + 1)) fi for service in auditd apparmor unattended-upgrades; do if systemctl is-active --quiet "$service" 2>/dev/null; then ok "${service}: active" else warn "${service}: не active" warn_count=$((warn_count + 1)) fi done for service in caddy xray hysteria haproxy unbound; do [[ "$(systemctl show -p LoadState --value "${service}.service" 2>/dev/null || true)" == "loaded" ]] || continue service_hardening_failed=0 [[ "$(systemctl show -p NoNewPrivileges --value "${service}.service" 2>/dev/null || true)" == "yes" ]] || service_hardening_failed=1 [[ "$(systemctl show -p PrivateTmp --value "${service}.service" 2>/dev/null || true)" == "yes" ]] || service_hardening_failed=1 value=$(systemctl show -p ProtectSystem --value "${service}.service" 2>/dev/null || true) [[ "$value" == "full" || "$value" == "strict" ]] || service_hardening_failed=1 if [[ "$service_hardening_failed" -eq 0 ]]; then ok "${service}: systemd hardening enabled" else warn "${service}: systemd hardening incomplete" warn_count=$((warn_count + 1)) fi done if [[ -f "$CONFIG_FILE" ]]; then owner=$(stat -c '%U' "$CONFIG_FILE" 2>/dev/null || echo unknown) perms=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || echo 000) [[ "$owner" == "root" && "$perms" == "600" ]] && ok "$CONFIG_FILE permissions OK" || { err "$CONFIG_FILE owner=$owner perms=$perms"; failed=$((failed + 1)); } else err "$CONFIG_FILE не найден" failed=$((failed + 1)) fi tcp_ports=$(ss -H -ltnp 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\1/' | sort -n | uniq | tr '\n' ' ' || true) udp_service_ports=$(ss -H -lunp 2>/dev/null | awk '$0 !~ /xray/ {print $4}' | sed -E 's/.*:([0-9]+)$/\1/' | sort -n | uniq | tr '\n' ' ' || true) echo "TCP listen ports: ${tcp_ports:-n/a}" echo "UDP service ports: ${udp_service_ports:-n/a}" if ss -H -ltnp 2>/dev/null | awk ' { port=$4 sub(/.*:/, "", port) if (port ~ /^(23|25|3306|5432|6379|9200)$/) found=1 } END { exit(found ? 0 : 1) } '; then err "Найдены потенциально лишние публичные порты" failed=$((failed + 1)) else ok "Критичных лишних портов не видно" fi if ss -H -lntu 2>/dev/null | awk ' { endpoint=$5 port=endpoint sub(/.*:/, "", port) host=endpoint sub(/:[^:]*$/, "", host) gsub(/^\[|\]$/, "", host) if (port == "53" && host !~ /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|::1$)/) found=1 } END { exit(found ? 0 : 1) } '; then err "DNS/53 слушает wildcard или публичный адрес" failed=$((failed + 1)) else ok "DNS/53 доступен только на loopback/private адресах" fi if [[ -f "${WEBROOT}/robots.txt" ]] && grep -qi 'Disallow: /s/' "${WEBROOT}/robots.txt" 2>/dev/null; then ok "Subscription pages закрыты от индексации robots.txt" else warn "robots.txt не закрывает /s/" warn_count=$((warn_count + 1)) fi local legacy_credentials legacy_credentials=$(credential_plaintext_count) if [[ "$legacy_credentials" -eq 0 ]]; then ok "Proxy user store: plaintext credentials не найдены" else warn "Proxy user store: ${legacy_credentials} legacy plaintext записей; запусти credentials-migrate" warn_count=$((warn_count + 1)) fi if [[ -s "$CREDENTIALS_PRIVATE_KEY" ]]; then [[ "$(stat -c '%a' "$CREDENTIALS_DIR" 2>/dev/null || echo 0)" == "700" ]] \ && ok "Credential vault directory: mode 700" \ || { err "Credential vault directory имеет небезопасные права"; failed=$((failed + 1)); } [[ "$(stat -c '%a' "$CREDENTIALS_PRIVATE_KEY" 2>/dev/null || echo 0)" == "600" ]] \ && ok "Credential vault private key: mode 600" \ || { err "Credential vault private key имеет небезопасные права"; failed=$((failed + 1)); } [[ -s "$USERS_SECRETS_FILE" ]] \ && ok "Encrypted credential vault: доступен" \ || { err "Encrypted credential vault отсутствует"; failed=$((failed + 1)); } if [[ "$legacy_credentials" -eq 0 ]]; then credential_store_integrity_check \ && ok "Credential vault integrity: bcrypt/keypair/ciphertext OK" \ || failed=$((failed + 1)) fi elif [[ "$(get_users | wc -l)" -gt 0 && "$legacy_credentials" -eq 0 ]]; then err "Credential vault key отсутствует при bcrypt users.conf" failed=$((failed + 1)) fi if grep -Eq '^[[:space:]]+basic_auth[[:space:]]+[A-Za-z0-9_-]{2,32}[[:space:]]+[A-Za-z0-9_-]{8,64}[[:space:]]*$' "$CADDYFILE" 2>/dev/null; then err "Caddyfile содержит legacy plaintext proxy auth" failed=$((failed + 1)) else ok "Caddy auth config: bcrypt/no plaintext" fi if grep -q 'type: userpass' "$HYSTERIA_CONFIG" 2>/dev/null; then warn "Hysteria использует legacy userpass; запусти credentials-migrate" warn_count=$((warn_count + 1)) elif [[ -f "$HYSTERIA_CONFIG" ]]; then grep -q 'type: command' "$HYSTERIA_CONFIG" \ && ok "Hysteria auth: bcrypt verifier" \ || { warn "Hysteria auth backend не распознан"; warn_count=$((warn_count + 1)); } fi if [[ -f "$HYSTERIA_CONFIG" ]]; then if hysteria_tls_layout_needs_repair; then err "Hysteria TLS sandbox несовместим с текущим cert path; запусти hysteria-repair" failed=$((failed + 1)) else ok "Hysteria TLS: изолирован, mode 700/600, sync timer enabled" fi fi [[ -d "$BACKUP_DIR" ]] && ok "Backup dir exists: $BACKUP_DIR" || { warn "Backup dir отсутствует: $BACKUP_DIR"; warn_count=$((warn_count + 1)); } if find "$BACKUP_DIR" -maxdepth 1 -type f -name 'naiveproxy-full-*.tar.gz.enc' -mtime -7 -print -quit 2>/dev/null | grep -q .; then ok "Recent encrypted backup: найден" else warn "Recent encrypted backup младше 7 дней не найден" warn_count=$((warn_count + 1)) fi if find "$SUBS_WEB_DIR" -maxdepth 1 -type d -name '.*.build.*' -mmin +60 -print -quit 2>/dev/null | grep -q .; then warn "Найдены старые staging-каталоги подписок" warn_count=$((warn_count + 1)) else ok "Subscription staging: старых каталогов нет" fi [[ -f "$PROTOCOL_BENCHMARK_CRON" ]] && ok "Protocol benchmark cron installed" || warn "Protocol benchmark cron не установлен" [[ -f "$EXPIRY_NOTIFY_CRON" ]] && ok "Expiry notify cron installed" || warn "Expiry notify cron не установлен" hr echo "security audit: failed=${failed} warnings=${warn_count}" [[ "$failed" -eq 0 ]] } cmd_backup_encrypted() { load_config 2>/dev/null || true command -v openssl >/dev/null 2>&1 || { err "openssl не найден"; return 1; } command -v tar >/dev/null 2>&1 || { err "tar не найден"; return 1; } mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" local pass pass_file ts backup_file backup_tmp item includes=() if [[ -n "${NAIVEPROXY_BACKUP_PASSPHRASE:-}" ]]; then pass="$NAIVEPROXY_BACKUP_PASSPHRASE" else echo -ne "${CYAN}Пароль для encrypted backup: ${RESET}" read -r -s pass echo fi [[ -n "$pass" ]] || { err "Пароль пустой"; return 1; } for item in "$CONFIG_DIR" "$CADDYFILE" "$CADDY_SERVICE" "$XRAY_CONFIG_DIR" "$XRAY_SERVICE" "$HYSTERIA_SERVICE" "$SUBS_WEB_DIR" "$PRIVATE_WEB_DIR"; do [[ -e "$item" ]] && includes+=("$item") done [[ "${#includes[@]}" -gt 0 ]] || { err "Нечего бэкапить"; return 1; } pass_file=$(mktemp /tmp/naiveproxy_backup_pass_XXXXXX) chmod 600 "$pass_file" printf '%s' "$pass" > "$pass_file" ts=$(date +%Y%m%d_%H%M%S) backup_file="$BACKUP_DIR/naiveproxy-full-${ts}.tar.gz.enc" backup_tmp=$(mktemp "/var/tmp/naiveproxy-full-${ts}.XXXXXX.tar.gz.enc") || { rm -f "$pass_file"; return 1; } chmod 600 "$backup_tmp" if tar --exclude="${BACKUP_DIR#/}" --exclude="${EXPORT_DIR#/}" -czf - "${includes[@]}" 2>/dev/null \ | openssl enc -aes-256-cbc -pbkdf2 -salt -out "$backup_tmp" -pass "file:$pass_file" \ && install -o root -g root -m 600 "$backup_tmp" "$backup_file"; then rm -f "$backup_tmp" ok "Encrypted backup создан: $backup_file" else rm -f "$backup_tmp" "$backup_file" 2>/dev/null || true err "Encrypted backup не создан" rm -f "$pass_file" return 1 fi rm -f "$pass_file" } cmd_export_state() { mkdir -p "$EXPORT_DIR" chmod 700 "$EXPORT_DIR" local ts out items=() ts=$(date +%Y%m%d_%H%M%S) out="$EXPORT_DIR/naiveproxy-state-${ts}.tar.gz" for item in naive.conf users.conf credentials users.d subscriptions subscription-aliases.conf xray-users.conf xray-compat-users.conf xray-users.disabled users.disabled bridge.conf nodes.conf; do [[ -e "$CONFIG_DIR/$item" ]] && items+=("$item") done [[ "${#items[@]}" -gt 0 ]] || { err "Нет данных для export"; return 1; } tar -C "$CONFIG_DIR" -czf "$out" "${items[@]}" chmod 600 "$out" ok "Export создан: $out" } import_shell_word_is_safe() { local word="${1-}" inner char local i escaped=0 [[ -z "$word" || "$word" == "''" ]] && return 0 if [[ "${word:0:2}" == "\$'" && "${word: -1}" == "'" && ${#word} -ge 3 ]]; then inner="${word:2:${#word}-3}" for ((i = 0; i < ${#inner}; i++)); do char="${inner:i:1}" if [[ "$escaped" -eq 1 ]]; then escaped=0 continue fi if [[ "$char" == '\\' ]]; then escaped=1 elif [[ "$char" == "'" ]]; then return 1 fi done [[ "$escaped" -eq 0 ]] return fi for ((i = 0; i < ${#word}; i++)); do char="${word:i:1}" if [[ "$escaped" -eq 1 ]]; then escaped=0 continue fi if [[ "$char" == '\\' ]]; then escaped=1 continue fi case "$char" in [[:space:]]|'$'|'`'|"'"|'"'|';'|'&'|'|'|'('|')'|'<'|'>') return 1 ;; esac done [[ "$escaped" -eq 0 ]] } is_valid_import_chat_id_list() { local raw="${1:-}" item count=0 local -a items=() [[ -z "$raw" ]] && return 0 raw="${raw// /}" [[ "$raw" != ,* && "$raw" != *, && "$raw" != *,,* ]] || return 1 IFS=',' read -r -a items <<< "$raw" for item in "${items[@]}"; do count=$((count + 1)) (( count <= 32 )) || return 1 [[ "$item" =~ ^[0-9]{5,20}$ ]] || return 1 done (( count > 0 )) } is_valid_sales_plans_value() { local raw="${1:-}" item term price normalized count=0 local -A seen_terms=() local -a items=() [[ -n "$raw" ]] || return 1 [[ "$raw" != ,* && "$raw" != *, && "$raw" != *,,* ]] || return 1 IFS=',' read -r -a items <<< "$raw" for item in "${items[@]}"; do count=$((count + 1)) (( count <= 24 )) || return 1 [[ "$item" =~ ^([^:]+):([^:]+)$ ]] || return 1 term="${BASH_REMATCH[1]}" price="${BASH_REMATCH[2]}" normalized=$(normalize_user_term "$term" 2>/dev/null || true) [[ -n "$normalized" && -z "${seen_terms[$normalized]+x}" ]] || return 1 is_canonical_uint_in_range "$price" 1 1000000000 || return 1 seen_terms[$normalized]=1 done (( count > 0 )) } sanitize_imported_shell_data_file() { local source_file="$1" output_file="$2" mode="${3:-naive}" local max_size=1048576 file_size allowed_keys line key value line_no=0 safe_tmp import_value dom local -a imported_keys=() local -A seen_keys=() [[ -f "$source_file" && ! -L "$source_file" ]] || { err "Импортируемый data-файл не является обычным файлом: $source_file" return 1 } file_size=$(stat -c '%s' "$source_file" 2>/dev/null || echo 0) [[ "$file_size" =~ ^[0-9]+$ && "$file_size" -le "$max_size" ]] || { err "Импортируемый data-файл слишком большой: $source_file" return 1 } case "$mode" in naive) allowed_keys=$(declare -f save_config | sed -nE "s/.*printf '([A-Z][A-Z0-9_]*)=%q\\\\n'.*/\\1/p") ;; bridge) allowed_keys=$'BRIDGE_ENABLED\nBRIDGE_NAME\nBRIDGE_ENTRY_PROTOCOL\nBRIDGE_EXIT_PROTOCOL\nBRIDGE_EXIT_URI\nUPDATED_AT' ;; *) err "Неизвестный тип import data-файла: $mode" return 1 ;; esac [[ -n "$allowed_keys" ]] || { err "Не удалось построить allowlist import-конфига"; return 1; } safe_tmp="${output_file}.tmp" : > "$safe_tmp" chmod 600 "$safe_tmp" while IFS= read -r line || [[ -n "$line" ]]; do line_no=$((line_no + 1)) line="${line%$'\r'}" [[ ${#line} -le 65536 ]] || { err "Слишком длинная строка ${line_no} в $source_file"; rm -f "$safe_tmp"; return 1; } [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then err "Небезопасная строка ${line_no} в import-конфиге" rm -f "$safe_tmp" return 1 fi key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" if ! grep -Fxq -- "$key" <<< "$allowed_keys"; then warn "Import: неизвестный ключ $key пропущен" continue fi if [[ -n "${seen_keys[$key]+x}" ]]; then err "Import: ключ $key указан несколько раз" rm -f "$safe_tmp" return 1 fi if ! import_shell_word_is_safe "$value"; then err "Import: небезопасное значение ключа $key" rm -f "$safe_tmp" return 1 fi seen_keys[$key]=1 imported_keys+=("$key") printf '%s=%s\n' "$key" "$value" >> "$safe_tmp" done < "$source_file" [[ -s "$safe_tmp" ]] || { err "Import data-файл не содержит поддерживаемых ключей"; rm -f "$safe_tmp"; return 1; } mv -f "$safe_tmp" "$output_file" chmod 600 "$output_file" if ! ( local bool_key port_key range_spec min_value max_value cidr_item hop_start hop_end local -a cidr_items=() for key in "${imported_keys[@]}"; do unset "$key"; done # Файл уже прошёл строгую проверку shell-слов и allowlist ключей. # shellcheck source=/dev/null source "$output_file" for key in "${imported_keys[@]}"; do import_value="${!key-}" if [[ "$import_value" == *$'\n'* || "$import_value" == *$'\r'* || "$import_value" == *$'\t'* ]]; then err "Import: управляющие символы запрещены в $key" exit 1 fi done if [[ "$mode" == "naive" ]]; then for bool_key in \ SUBSCRIPTION_LOCAL_ENABLED SUBSCRIPTION_XHTTP_NODE_EXCLUSIVE SUBSCRIPTION_XHTTP_MAIN_ENABLED \ SUBSCRIPTION_NODE_PREFETCH_ENABLED SUBSCRIPTION_ATOMIC_PUBLISH \ BROWSER_PROXY_COMPAT BROWSER_PROXY_SEND_PROXY_V2 \ PROTOCOL_BENCHMARK_RECOVERY_ALERT PROTOCOL_MONITOR_RECOVERY_ALERT DNS_MONITOR_RECOVERY_ALERT HYSTERIA_ENABLED \ HYSTERIA_WARP_ENABLED HYSTERIA_PORT_HOP_ENABLED UNBOUND_ENABLED UNBOUND_ADBLOCK \ UNBOUND_MANAGED_GATEWAY UNBOUND_VPN_ENABLED UNBOUND_FILTER_ENABLED WARP_PROXY_ENABLED \ XRAY_WARP_ENABLED DEVICE_LIMIT_ENABLED XRAY_ENABLED XRAY_FALLBACK_ENABLED \ XRAY_REALITY_ENABLED XRAY_REALITY_SNI_MUX_ENABLED XRAY_XHTTP_ENABLED \ XRAY_MOBILE_ALT_ENABLED XRAY_GITHUB_TEST_ENABLED XRAY_ZAPRET_ENABLED BRIDGE_ENABLED; do [[ -n "${seen_keys[$bool_key]+x}" ]] || continue import_value="${!bool_key}" [[ "$import_value" =~ ^[01]$ ]] || { err "Import: $bool_key должен быть 0 или 1"; exit 1; } done for port_key in HYSTERIA_PORT WARP_PROXY_PORT BROWSER_PROXY_BACKEND_PORT XRAY_REALITY_PORT XRAY_REALITY_PUBLIC_PORT \ XRAY_MKCP_PORT XRAY_VISION_PORT XRAY_XHTTP_PORT XRAY_WS_PORT XRAY_HTTPUPGRADE_PORT \ XRAY_CADDY_FALLBACK_PORT XRAY_GITHUB_TEST_PORT; do [[ -n "${seen_keys[$port_key]+x}" ]] || continue import_value="${!port_key}" is_valid_port "$import_value" || { err "Import: некорректный порт $port_key"; exit 1; } done for range_spec in \ PROTOCOL_BENCHMARK_ROUNDS:1:20 PROTOCOL_BENCHMARK_MAX_AVG_MS:100:600000 \ PROTOCOL_BENCHMARK_MONITOR_MIN_ROUNDS:1:20 PROTOCOL_BENCHMARK_SLOW_MIN_HITS:1:20 \ PROTOCOL_BENCHMARK_WARN_MIN_OK_HITS:1:20 PROTOCOL_BENCHMARK_ALERT_REPEAT:1:100 \ PROTOCOL_BENCHMARK_HARD_ALERT_REPEAT:1:100 PROTOCOL_BENCHMARK_ALERT_COOLDOWN_MINUTES:1:10080 \ PROTOCOL_MONITOR_ALERT_REPEAT:1:100 PROTOCOL_MONITOR_ALERT_COOLDOWN_MINUTES:1:10080 \ DNS_MONITOR_ALERT_REPEAT:1:100 DNS_MONITOR_ALERT_COOLDOWN_MINUTES:1:10080 \ SALES_BOT_CAPTCHA_TTL_SECONDS:300:2592000 SALES_BOT_ORDER_MIN_INTERVAL_SECONDS:10:3600 \ SALES_BOT_MAX_PENDING_PER_CHAT:1:10 SALES_BOT_MAX_ORDER_FILES:100:50000 \ UNBOUND_FILTER_MAX_DOMAINS:1000:500000 DEVICE_LIMIT:1:50 DEVICE_WINDOW_HOURS:1:168 \ EXPIRED_DELETE_GRACE_DAYS:0:3650; do IFS=':' read -r key min_value max_value <<< "$range_spec" [[ -n "${seen_keys[$key]+x}" ]] || continue import_value="${!key}" is_canonical_uint_in_range "$import_value" "$min_value" "$max_value" \ || { err "Import: $key вне безопасного диапазона $min_value..$max_value"; exit 1; } done if [[ -n "${seen_keys[LANG_UI]+x}" ]]; then [[ "${LANG_UI}" == "ru" || "${LANG_UI}" == "en" ]] || { err "Import: некорректный LANG_UI"; exit 1; } fi if [[ -n "${seen_keys[SUBSCRIPTION_LOCAL_POSITION]+x}" ]]; then [[ "${SUBSCRIPTION_LOCAL_POSITION}" == "first" || "${SUBSCRIPTION_LOCAL_POSITION}" == "last" ]] || { err "Import: некорректный SUBSCRIPTION_LOCAL_POSITION"; exit 1; } fi if [[ -n "${seen_keys[EDGE_ROUTING_MODE]+x}" ]]; then [[ "${EDGE_ROUTING_MODE}" == "caddy" || "${EDGE_ROUTING_MODE}" == "haproxy" ]] || { err "Import: некорректный EDGE_ROUTING_MODE"; exit 1; } fi if [[ -n "${seen_keys[UNBOUND_MODE]+x}" ]]; then [[ "${UNBOUND_MODE}" == "recursive" || "${UNBOUND_MODE}" == "forward" ]] || { err "Import: некорректный UNBOUND_MODE"; exit 1; } fi if [[ -n "${seen_keys[WARP_MODE]+x}" ]]; then case "${WARP_MODE}" in off|proxy|warp|warp+doh) ;; *) err "Import: некорректный WARP_MODE"; exit 1 ;; esac fi if [[ -n "${seen_keys[WARP_PROTOCOL]+x}" ]]; then case "${WARP_PROTOCOL}" in auto|MASQUE|WireGuard) ;; *) err "Import: некорректный WARP_PROTOCOL"; exit 1 ;; esac fi if [[ -n "${seen_keys[DEVICE_LIMIT_MODE]+x}" ]]; then case "${DEVICE_LIMIT_MODE}" in alert|lock-user) ;; *) err "Import: некорректный DEVICE_LIMIT_MODE"; exit 1 ;; esac fi if [[ -n "${seen_keys[PROTOCOL_BENCHMARK_USER]+x}" && -n "${PROTOCOL_BENCHMARK_USER}" ]]; then is_valid_proxy_user "${PROTOCOL_BENCHMARK_USER}" || { err "Import: некорректный PROTOCOL_BENCHMARK_USER"; exit 1; } fi if [[ -n "${seen_keys[TG_ADMINS]+x}" ]]; then is_valid_import_chat_id_list "${TG_ADMINS}" || { err "Import: некорректный TG_ADMINS"; exit 1; } fi if [[ -n "${seen_keys[SALES_BOT_ADMINS]+x}" ]]; then is_valid_import_chat_id_list "${SALES_BOT_ADMINS}" || { err "Import: некорректный SALES_BOT_ADMINS"; exit 1; } fi if [[ -n "${seen_keys[SALES_BOT_PLANS]+x}" ]]; then is_valid_sales_plans_value "${SALES_BOT_PLANS}" || { err "Import: некорректный SALES_BOT_PLANS"; exit 1; } fi if [[ -n "${seen_keys[SALES_BOT_CURRENCY]+x}" ]]; then [[ "${SALES_BOT_CURRENCY}" =~ ^[A-Z]{3,8}$ ]] || { err "Import: некорректный SALES_BOT_CURRENCY"; exit 1; } fi if [[ -n "${seen_keys[HYSTERIA_PORT_HOP_PORTS]+x}" ]]; then [[ "${HYSTERIA_PORT_HOP_PORTS}" =~ ^([1-9][0-9]{0,4})-([1-9][0-9]{0,4})$ ]] || { err "Import: некорректный HYSTERIA_PORT_HOP_PORTS"; exit 1; } hop_start="${BASH_REMATCH[1]}"; hop_end="${BASH_REMATCH[2]}" is_valid_local_proxy_port "$hop_start" && is_valid_local_proxy_port "$hop_end" \ && (( 10#$hop_start < 10#$hop_end && 10#$hop_end - 10#$hop_start <= 1000 )) \ || { err "Import: небезопасный диапазон Hysteria port hopping"; exit 1; } fi if [[ -n "${seen_keys[UNBOUND_GATEWAY_IP]+x}" && -n "${UNBOUND_GATEWAY_IP}" ]]; then is_private_vpn_ipv4 "${UNBOUND_GATEWAY_IP}" || { err "Import: UNBOUND_GATEWAY_IP должен быть private/CGNAT адресом"; exit 1; } fi if [[ -n "${seen_keys[UNBOUND_VPN_CIDRS]+x}" ]]; then normalize_cidr_list "${UNBOUND_VPN_CIDRS}" >/dev/null || { err "Import: некорректный UNBOUND_VPN_CIDRS"; exit 1; } fi if [[ -n "${seen_keys[WARP_SSH_ALLOW_CIDRS]+x}" && -n "${WARP_SSH_ALLOW_CIDRS}" ]]; then read -r -a cidr_items <<< "${WARP_SSH_ALLOW_CIDRS//,/ }" (( ${#cidr_items[@]} <= 32 )) || { err "Import: слишком много WARP SSH CIDR"; exit 1; } for cidr_item in "${cidr_items[@]}"; do is_valid_cidr4 "$cidr_item" || { err "Import: некорректный WARP SSH CIDR"; exit 1; } is_canonical_uint_in_range "${cidr_item#*/}" 8 32 || { err "Import: WARP SSH CIDR шире /8 запрещён"; exit 1; } done fi if [[ -n "${seen_keys[SUBSCRIPTION_HTTP_NODE_SOURCES]+x}" ]]; then subscription_http_node_sources_is_valid "${SUBSCRIPTION_HTTP_NODE_SOURCES}" \ || { err "Import: некорректный SUBSCRIPTION_HTTP_NODE_SOURCES"; exit 1; } fi if [[ -n "${DOMAIN:-}" ]] && ! is_valid_domain "$DOMAIN"; then err "Import: некорректный DOMAIN"; exit 1; fi if [[ -n "${SUBSCRIPTION_DOMAIN:-}" ]] && ! is_valid_domain "$SUBSCRIPTION_DOMAIN"; then err "Import: некорректный SUBSCRIPTION_DOMAIN"; exit 1; fi if [[ -n "${BROWSER_PROXY_SNI_DOMAIN:-}" ]] && ! is_valid_domain "$BROWSER_PROXY_SNI_DOMAIN"; then err "Import: некорректный BROWSER_PROXY_SNI_DOMAIN"; exit 1; fi if [[ -n "${DOMAINS:-}" ]]; then while IFS= read -r dom; do dom="${dom//[[:space:]]/}" [[ -z "$dom" ]] && continue is_valid_domain "$dom" || { err "Import: некорректный домен $dom"; exit 1; } done < <(printf '%s\n' "$DOMAINS" | tr ',' '\n') fi [[ -z "${TG_TOKEN:-}" || "$TG_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] || { err "Import: некорректный TG_TOKEN"; exit 1; } [[ -z "${SALES_BOT_TOKEN:-}" || "$SALES_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] || { err "Import: некорректный SALES_BOT_TOKEN"; exit 1; } [[ -z "${TG_CHAT_ID:-}" || "$TG_CHAT_ID" =~ ^-?[0-9]{5,20}$ ]] || { err "Import: некорректный TG_CHAT_ID"; exit 1; } [[ -z "${SALES_BOT_ADMIN_ID:-}" || "$SALES_BOT_ADMIN_ID" =~ ^-?[0-9]{5,20}$ ]] || { err "Import: некорректный SALES_BOT_ADMIN_ID"; exit 1; } [[ -z "${CADDY_VERSION_PIN:-}" || "$CADDY_VERSION_PIN" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { err "Import: некорректный CADDY_VERSION_PIN"; exit 1; } [[ -z "${XCADDY_VERSION_PIN:-}" || "$XCADDY_VERSION_PIN" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { err "Import: некорректный XCADDY_VERSION_PIN"; exit 1; } [[ -z "${FORWARDPROXY_REF_PIN:-}" || "$FORWARDPROXY_REF_PIN" =~ ^[a-fA-F0-9]{40}$ ]] || { err "Import: FORWARDPROXY_REF_PIN должен быть полным commit SHA"; exit 1; } [[ -z "${XRAY_VERSION_PIN:-}" || "$XRAY_VERSION_PIN" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { err "Import: некорректный XRAY_VERSION_PIN"; exit 1; } [[ -z "${HYSTERIA_VERSION_PIN:-}" || "$HYSTERIA_VERSION_PIN" =~ ^app/v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { err "Import: некорректный HYSTERIA_VERSION_PIN"; exit 1; } [[ -z "${PROTOCOL_BENCHMARK_LOG:-}" || "$PROTOCOL_BENCHMARK_LOG" == "$PROTOCOL_BENCHMARK_LOG_DEFAULT" ]] || { err "Import: PROTOCOL_BENCHMARK_LOG должен использовать управляемый путь"; exit 1; } [[ -z "${SUBSCRIPTION_REBUILD_JOBS:-}" ]] || is_canonical_uint_in_range "$SUBSCRIPTION_REBUILD_JOBS" 0 8 || { err "Import: SUBSCRIPTION_REBUILD_JOBS должен быть от 0 до 8"; exit 1; } [[ -z "${PROTOCOL_BENCHMARK_ALERT_STATE:-}" || "$PROTOCOL_BENCHMARK_ALERT_STATE" == "$CONFIG_DIR/protocol-benchmark-alert.state" ]] || { err "Import: некорректный PROTOCOL_BENCHMARK_ALERT_STATE"; exit 1; } [[ -z "${PROTOCOL_MONITOR_ALERT_STATE:-}" || "$PROTOCOL_MONITOR_ALERT_STATE" == "$CONFIG_DIR/protocol-health-alert.state" ]] || { err "Import: некорректный PROTOCOL_MONITOR_ALERT_STATE"; exit 1; } import_path_is_within "${SALES_BOT_PAYMENT_QR_PATH:-$SALES_BOT_PAYMENT_QR_PATH_DEFAULT}" "$SALES_BOT_DIR" || { err "Import: QR оплаты должен находиться в $SALES_BOT_DIR"; exit 1; } import_path_is_within "${SALES_BOT_WELCOME_ANIMATION_PATH:-$SALES_BOT_WELCOME_ANIMATION_PATH_DEFAULT}" "$SALES_BOT_DIR" || { err "Import: welcome animation должна находиться в $SALES_BOT_DIR"; exit 1; } import_path_is_within "${SALES_BOT_WELCOME_IMAGE_PATH:-$SALES_BOT_WELCOME_IMAGE_PATH_DEFAULT}" "$SALES_BOT_DIR" || { err "Import: welcome image должна находиться в $SALES_BOT_DIR"; exit 1; } [[ -z "${XRAY_ZAPRET_DAT:-}" || "$XRAY_ZAPRET_DAT" == "$XRAY_ZAPRET_DAT_DEFAULT" ]] || { err "Import: XRAY_ZAPRET_DAT должен использовать управляемый путь"; exit 1; } import_https_url_list_is_safe "${XRAY_ZAPRET_URL:-}" || { err "Import: XRAY_ZAPRET_URL должен быть безопасным HTTPS URL"; exit 1; } import_https_url_list_is_safe "${UNBOUND_FILTER_URLS:-}" || { err "Import: UNBOUND_FILTER_URLS содержит небезопасный URL"; exit 1; } else [[ "${BRIDGE_ENABLED:-0}" =~ ^[01]$ ]] || { err "Import: некорректный BRIDGE_ENABLED"; exit 1; } [[ -z "${BRIDGE_EXIT_URI:-}" || "$BRIDGE_EXIT_URI" =~ ^(vless|trojan|hysteria2|hy2|socks|socks5|http|https):// ]] || { err "Import: некорректный BRIDGE_EXIT_URI"; exit 1; } fi ); then rm -f "$output_file" return 1 fi } import_path_is_within() { local path="$1" base="$2" normalized normalized_base [[ "$path" == /* && "$base" == /* ]] || return 1 normalized=$(readlink -m -- "$path" 2>/dev/null) || return 1 normalized_base=$(readlink -m -- "$base" 2>/dev/null) || return 1 [[ "$normalized" == "$normalized_base" || "$normalized" == "$normalized_base"/* ]] } import_https_url_list_is_safe() { local raw="${1:-}" url count=0 remainder authority host port [[ -z "$raw" ]] && return 0 for url in $raw; do count=$((count + 1)) (( count <= 16 )) || return 1 [[ "$url" == https://* && "$url" != *"@"* && "$url" != *\\* ]] || return 1 [[ "$url" != *$'\r'* && "$url" != *$'\n'* && "$url" != *$'\t'* ]] || return 1 remainder="${url#https://}" [[ "$remainder" == */* ]] || return 1 authority="${remainder%%/*}" [[ -n "$authority" ]] || return 1 if [[ "$authority" == *:* ]]; then host="${authority%:*}" port="${authority##*:}" is_valid_port "$port" || return 1 else host="$authority" fi is_valid_domain "$host" || return 1 done } sanitize_imported_colon_file() { local source_file="$1" output_file="$2" mode="$3" local line record user secret extra count=0 tmp [[ -f "$source_file" && ! -L "$source_file" ]] || return 1 [[ "$(stat -c '%s' "$source_file" 2>/dev/null || echo 0)" -le 1048576 ]] || { err "Import: слишком большой credentials-файл"; return 1; } tmp="${output_file}.tmp" : > "$tmp" chmod 600 "$tmp" while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue count=$((count + 1)) (( count <= 10000 )) || { err "Import: слишком много credentials-записей"; rm -f "$tmp"; return 1; } record="${line%%$'\t'#*}" record="${record%% #*}" IFS=':' read -r user secret extra <<< "$record" if ! is_valid_proxy_user "$user" || [[ -n "$extra" ]]; then err "Import: некорректная credentials-запись для $user" rm -f "$tmp" return 1 fi case "$mode" in proxy) { is_valid_proxy_pass "$secret" || is_valid_proxy_hash "$secret"; } || { err "Import: некорректный proxy credential для $user"; rm -f "$tmp"; return 1; } ;; xray) is_valid_xray_uuid "$secret" || { err "Import: некорректный Xray UUID для $user"; rm -f "$tmp"; return 1; } ;; *) rm -f "$tmp"; return 1 ;; esac printf '%s:%s\n' "$user" "$secret" >> "$tmp" done < "$source_file" install -m 600 "$tmp" "$output_file" rm -f "$tmp" } sanitize_imported_nodes_file() { local source_file="$1" output_file="$2" line name count=0 tmp local -A seen=() [[ -f "$source_file" && ! -L "$source_file" ]] || return 1 [[ "$(stat -c '%s' "$source_file" 2>/dev/null || echo 0)" -le 1048576 ]] || { err "Import: nodes.conf слишком большой"; return 1; } tmp="${output_file}.tmp" { echo "# Yurich Panel nodes" echo "# format: name|host|ssh_port|ssh_user|domain|role|weight|enabled" } > "$tmp" while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue count=$((count + 1)) (( count <= 128 )) || { err "Import: слишком много nodes (максимум 128)"; rm -f "$tmp"; return 1; } nodes_validate_line "$line" || { err "Import: некорректная node-запись"; rm -f "$tmp"; return 1; } name="${line%%|*}" [[ -z "${seen[$name]+x}" ]] || { err "Import: node $name указана несколько раз"; rm -f "$tmp"; return 1; } seen[$name]=1 printf '%s\n' "$line" >> "$tmp" done < "$source_file" install -m 600 "$tmp" "$output_file" rm -f "$tmp" } sanitize_imported_aliases_file() { local source_file="$1" output_file="$2" line alias target user extra count=0 tmp local -A seen=() [[ -f "$source_file" && ! -L "$source_file" ]] || return 1 [[ "$(stat -c '%s' "$source_file" 2>/dev/null || echo 0)" -le 1048576 ]] || { err "Import: aliases-файл слишком большой"; return 1; } tmp="${output_file}.tmp" : > "$tmp" while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue read -r alias target user extra <<< "$line" [[ "$alias" =~ ^[A-Fa-f0-9]{32,64}$ && "$target" =~ ^[A-Fa-f0-9]{32,64}$ && "$alias" != "$target" && -z "$extra" ]] || { err "Import: некорректный subscription alias"; rm -f "$tmp"; return 1; } [[ -z "$user" ]] || is_valid_proxy_user "$user" || { err "Import: некорректный пользователь alias"; rm -f "$tmp"; return 1; } count=$((count + 1)) (( count <= 4096 )) || { err "Import: слишком много aliases"; rm -f "$tmp"; return 1; } [[ -z "${seen[$alias]+x}" ]] || { err "Import: alias $alias указан несколько раз"; rm -f "$tmp"; return 1; } seen[$alias]=1 printf '%s %s%s\n' "$alias" "$target" "${user:+ $user}" >> "$tmp" done < "$source_file" install -m 600 "$tmp" "$output_file" rm -f "$tmp" } sanitize_imported_credentials_dir() { local source_dir="$1" output_dir="$2" file name private_key public_key secrets_file active_file tmp line user encrypted extra pass count=0 local -A seen=() [[ -d "$source_dir" && ! -L "$source_dir" ]] || return 1 while IFS= read -r file; do name=$(basename "$file") case "$name" in private.pem|public.pem|users.secrets|users.active.htpasswd|credentials.lock|migration.receipt) ;; *) err "Import: неизвестный файл credential vault: $name"; return 1 ;; esac [[ -f "$file" && ! -L "$file" ]] || { err "Import: credential vault содержит не обычный файл"; return 1; } [[ "$(stat -c '%s' "$file" 2>/dev/null || echo 0)" -le 4194304 ]] || { err "Import: credential-файл слишком большой: $name"; return 1; } done < <(find "$source_dir" -mindepth 1 -maxdepth 1 -print) private_key="$source_dir/private.pem" public_key="$source_dir/public.pem" secrets_file="$source_dir/users.secrets" active_file="$source_dir/users.active.htpasswd" [[ -s "$private_key" && -s "$public_key" && -f "$secrets_file" ]] || { err "Import: credential vault неполный"; return 1; } openssl pkey -in "$private_key" -check -noout >/dev/null 2>&1 || { err "Import: private key vault повреждён"; return 1; } openssl pkey -pubin -in "$public_key" -noout >/dev/null 2>&1 || { err "Import: public key vault повреждён"; return 1; } local derived_hash public_hash derived_hash=$(openssl pkey -in "$private_key" -pubout -outform DER 2>/dev/null | sha256sum | awk '{print $1}') public_hash=$(openssl pkey -pubin -in "$public_key" -outform DER 2>/dev/null | sha256sum | awk '{print $1}') [[ -n "$derived_hash" && "$derived_hash" == "$public_hash" ]] || { err "Import: private/public key vault не совпадают"; return 1; } mkdir -p "$output_dir" chmod 700 "$output_dir" tmp="$output_dir/users.secrets.tmp" : > "$tmp" while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue IFS=: read -r user encrypted extra <<< "$line" is_valid_proxy_user "$user" && is_valid_encrypted_proxy_secret "$encrypted" && [[ -z "$extra" ]] \ || { err "Import: некорректная encrypted credential-запись"; rm -f "$tmp"; return 1; } [[ -z "${seen[$user]+x}" ]] || { err "Import: duplicate encrypted credential $user"; rm -f "$tmp"; return 1; } pass=$(decrypt_proxy_password_with_key "$encrypted" "$private_key" 2>/dev/null || true) is_valid_proxy_pass "$pass" || { err "Import: encrypted credential $user не расшифровывается"; rm -f "$tmp"; return 1; } seen[$user]=1 count=$((count + 1)); (( count <= 10000 )) || { rm -f "$tmp"; return 1; } printf '%s:%s\n' "$user" "$encrypted" >> "$tmp" done < "$secrets_file" install -m 600 "$private_key" "$output_dir/private.pem" install -m 644 "$public_key" "$output_dir/public.pem" install -m 600 "$tmp" "$output_dir/users.secrets" rm -f "$tmp" if [[ -f "$active_file" ]]; then sanitize_imported_colon_file "$active_file" "$output_dir/users.active.htpasswd" proxy || return 1 fi : > "$output_dir/credentials.lock" chmod 600 "$output_dir/credentials.lock" } validate_imported_user_vault() { local users_file="$1" credentials_dir="$2" user hash encrypted pass [[ -f "$users_file" && -d "$credentials_dir" ]] || return 0 while IFS=: read -r user hash; do [[ -z "$user" || "$user" == \#* ]] && continue encrypted=$(credential_file_value "$credentials_dir/users.secrets" "$user" 2>/dev/null || true) if is_valid_proxy_hash "$hash"; then pass=$(decrypt_proxy_password_with_key "$encrypted" "$credentials_dir/private.pem" 2>/dev/null || true) if ! verify_proxy_password_hash "$user" "$pass" "$hash"; then err "Import: vault credential $user не соответствует bcrypt в $(basename "$users_file")" return 1 fi elif [[ -n "$encrypted" ]]; then pass=$(decrypt_proxy_password_with_key "$encrypted" "$credentials_dir/private.pem" 2>/dev/null || true) if [[ "$pass" != "$hash" ]]; then err "Import: legacy credential $user конфликтует с encrypted vault" return 1 fi else continue fi if [[ -z "$pass" ]]; then err "Import: vault credential $user не соответствует bcrypt в $(basename "$users_file")" return 1 fi done < "$users_file" } sanitize_imported_token_file() { local source_file="$1" output_file="$2" token [[ -f "$source_file" && ! -L "$source_file" ]] || return 1 [[ "$(stat -c '%s' "$source_file" 2>/dev/null || echo 0)" -le 256 ]] || { err "Import: token-файл слишком большой"; return 1; } token=$(tr -d '\r\n' < "$source_file") [[ "$token" =~ ^[A-Fa-f0-9]{48}$ ]] || { err "Import: некорректный subscription token"; return 1; } printf '%s\n' "$token" > "${output_file}.tmp" install -m 600 "${output_file}.tmp" "$output_file" rm -f "${output_file}.tmp" } sanitize_imported_user_meta_file() { local source_file="$1" output_file="$2" expected_user="$3" line key value count=0 [[ -f "$source_file" && ! -L "$source_file" ]] || return 1 [[ "$(stat -c '%s' "$source_file" 2>/dev/null || echo 0)" -le 65536 ]] || { err "Import: metadata-файл слишком большой"; return 1; } printf 'USER=%q\n' "$expected_user" > "${output_file}.tmp" while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue [[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || { err "Import: некорректная metadata-строка"; rm -f "${output_file}.tmp"; return 1; } key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" [[ "$key" == "USER" ]] && continue import_shell_word_is_safe "$value" || { err "Import: небезопасное metadata-значение"; rm -f "${output_file}.tmp"; return 1; } count=$((count + 1)) (( count <= 128 )) || { err "Import: слишком много metadata-полей"; rm -f "${output_file}.tmp"; return 1; } printf '%s=%s\n' "$key" "$value" >> "${output_file}.tmp" done < "$source_file" install -m 600 "${output_file}.tmp" "$output_file" rm -f "${output_file}.tmp" } cmd_import_state() { local archive="${1:-}" name import_work_dir import_archive payload archive_size archive_stats entry_count unpacked_size safe_naive safe_bridge safe_nodes safe_aliases safe_credentials state_file local existing_state=0 imported_has_bcrypt=0 [[ -n "$archive" ]] || { echo -ne "${CYAN}Путь к export .tar.gz: ${RESET}"; read -r archive; } [[ -f "$archive" ]] || { err "Файл не найден: $archive"; return 1; } archive_size=$(stat -c '%s' "$archive" 2>/dev/null || echo 0) [[ "$archive_size" =~ ^[0-9]+$ && "$archive_size" -le 52428800 ]] || { err "Import-архив слишком большой (лимит 50 MiB)" return 1 } import_work_dir=$(mktemp -d /tmp/naiveproxy_import_XXXXXX) import_archive="${import_work_dir}/state.tar.gz" payload="${import_work_dir}/payload" mkdir -m 700 "$payload" trap '[[ -n "${import_work_dir:-}" && "$import_work_dir" == /tmp/naiveproxy_import_* ]] && rm -rf -- "$import_work_dir" 2>/dev/null; trap - RETURN' RETURN if ! install -m 600 -- "$archive" "$import_archive"; then err "Не удалось создать защищённую копию import-архива" return 1 fi archive_size=$(stat -c '%s' "$import_archive" 2>/dev/null || echo 0) [[ "$archive_size" =~ ^[0-9]+$ && "$archive_size" -le 52428800 ]] || { err "Защищённая копия import-архива превышает лимит"; return 1; } if ! archive_stats=$(timeout 30s tar --numeric-owner -tvzf "$import_archive" 2>/dev/null | awk '{count++; if ($3 ~ /^[0-9]+$/) total += $3} END {print count+0, total+0}'); then err "Не удалось безопасно прочитать import-архив" return 1 fi read -r entry_count unpacked_size <<< "$archive_stats" [[ "$entry_count" =~ ^[0-9]+$ && "$entry_count" -le 10000 ]] || { err "В import-архиве слишком много записей"; return 1; } [[ "$unpacked_size" =~ ^[0-9]+$ && "$unpacked_size" -le 209715200 ]] || { err "Распакованный import-архив превышает 200 MiB"; return 1; } if ! timeout 30s tar -tzf "$import_archive" 2>/dev/null \ | awk 'BEGIN { bad=0 } /(^\/|(^|\/)\.\.(\/|$))/ { bad=1 } END { exit bad ? 1 : 0 }'; then err "Архив содержит небезопасные пути или не читается" return 1 fi if ! timeout 30s tar -tzf "$import_archive" 2>/dev/null \ | awk '/^(naive\.conf|users\.conf|users\.disabled|xray-users\.conf|xray-compat-users\.conf|xray-users\.disabled|bridge\.conf|nodes\.conf|subscription-aliases\.conf|credentials\/|credentials\/(private\.pem|public\.pem|users\.secrets|users\.active\.htpasswd|credentials\.lock|migration\.receipt)|users\.d\/|users\.d\/[A-Za-z0-9_-]+\.env|subscriptions\/|subscriptions\/[A-Za-z0-9_-]+\.token)$/ { next } { bad=1 } END { exit bad ? 1 : 0 }'; then err "Архив содержит неизвестные файлы. Импорт остановлен." return 1 fi if tar -tvzf "$import_archive" | awk '{ t=substr($1,1,1); if (t != "-" && t != "d") bad=1 } END { exit bad ? 0 : 1 }'; then err "Архив содержит symlink/hardlink/special-файлы. Импорт остановлен." return 1 fi if ! tar --no-same-owner --no-same-permissions -xzf "$import_archive" -C "$payload"; then err "Не удалось распаковать import-архив" return 1 fi if [[ -f "$payload/naive.conf" ]]; then safe_naive="${import_work_dir}/naive.conf.safe" sanitize_imported_shell_data_file "$payload/naive.conf" "$safe_naive" naive || return 1 fi if [[ -f "$payload/bridge.conf" ]]; then safe_bridge="${import_work_dir}/bridge.conf.safe" sanitize_imported_shell_data_file "$payload/bridge.conf" "$safe_bridge" bridge || return 1 fi if [[ -f "$payload/nodes.conf" ]]; then safe_nodes="${import_work_dir}/nodes.conf.safe" sanitize_imported_nodes_file "$payload/nodes.conf" "$safe_nodes" || return 1 fi if [[ -f "$payload/subscription-aliases.conf" ]]; then safe_aliases="${import_work_dir}/subscription-aliases.conf.safe" sanitize_imported_aliases_file "$payload/subscription-aliases.conf" "$safe_aliases" || return 1 fi if [[ -d "$payload/credentials" ]]; then safe_credentials="${import_work_dir}/credentials.safe" sanitize_imported_credentials_dir "$payload/credentials" "$safe_credentials" || return 1 fi for name in users.conf users.disabled; do [[ -f "$payload/$name" ]] || continue sanitize_imported_colon_file "$payload/$name" "${import_work_dir}/${name}.safe" proxy || return 1 done if [[ -n "${safe_credentials:-}" && ! -f "${import_work_dir}/users.conf.safe" ]]; then err "Import: credential vault без users.conf запрещён" return 1 fi for name in users.conf users.disabled; do if [[ -f "${import_work_dir}/${name}.safe" ]] \ && awk -F: 'NF >= 2 && $1 !~ /^#/ && $2 ~ /^\$2[aby]\$/ {found=1} END {exit found ? 0 : 1}' "${import_work_dir}/${name}.safe"; then imported_has_bcrypt=1 fi done if [[ "$imported_has_bcrypt" -eq 1 && -z "${safe_credentials:-}" ]]; then err "Import: bcrypt users.conf требует каталог credentials с encrypted vault" return 1 fi if [[ -n "${safe_credentials:-}" ]]; then if [[ -f "${import_work_dir}/users.conf.safe" ]]; then validate_imported_user_vault "${import_work_dir}/users.conf.safe" "$safe_credentials" || return 1 fi if [[ -f "${import_work_dir}/users.disabled.safe" ]]; then validate_imported_user_vault "${import_work_dir}/users.disabled.safe" "$safe_credentials" || return 1 fi fi for name in xray-users.conf xray-compat-users.conf xray-users.disabled; do [[ -f "$payload/$name" ]] || continue sanitize_imported_colon_file "$payload/$name" "${import_work_dir}/${name}.safe" xray || return 1 done echo -ne "${YELLOW}Импорт перезапишет users/subscriptions/config. Продолжить? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return 0 for state_file in naive.conf users.conf credentials users.d subscriptions subscription-aliases.conf xray-users.conf xray-compat-users.conf xray-users.disabled users.disabled bridge.conf nodes.conf; do [[ -e "$CONFIG_DIR/$state_file" ]] && { existing_state=1; break; } done if [[ "$existing_state" -eq 1 ]]; then info "Делаю обязательный export текущего состояния перед import..." if ! cmd_export_state >/dev/null 2>&1; then err "Pre-import export не создан. Импорт отменён без изменений." return 1 fi else info "Текущее состояние пустое, pre-import export не требуется" fi mkdir -p "$CONFIG_DIR" chmod 700 "$CONFIG_DIR" for name in naive.conf users.conf users.disabled xray-users.conf xray-compat-users.conf xray-users.disabled bridge.conf nodes.conf subscription-aliases.conf; do case "$name" in naive.conf) [[ -n "${safe_naive:-}" && -f "$safe_naive" ]] && install -m 600 "$safe_naive" "$CONFIG_DIR/$name" ;; bridge.conf) [[ -n "${safe_bridge:-}" && -f "$safe_bridge" ]] && install -m 600 "$safe_bridge" "$CONFIG_DIR/$name" ;; nodes.conf) [[ -n "${safe_nodes:-}" && -f "$safe_nodes" ]] && install -m 600 "$safe_nodes" "$CONFIG_DIR/$name" ;; subscription-aliases.conf) [[ -n "${safe_aliases:-}" && -f "$safe_aliases" ]] && install -m 600 "$safe_aliases" "$CONFIG_DIR/$name" ;; users.conf|users.disabled|xray-users.conf|xray-compat-users.conf|xray-users.disabled) [[ -f "${import_work_dir}/${name}.safe" ]] && install -m 600 "${import_work_dir}/${name}.safe" "$CONFIG_DIR/$name" ;; *) [[ -f "$payload/$name" ]] && install -m 600 "$payload/$name" "$CONFIG_DIR/$name" ;; esac done if [[ -n "${safe_credentials:-}" && -d "$safe_credentials" ]]; then rm -rf "$CREDENTIALS_DIR" cp -a "$safe_credentials" "$CREDENTIALS_DIR" chown -R root:root "$CREDENTIALS_DIR" 2>/dev/null || true chmod 700 "$CREDENTIALS_DIR" chmod 600 "$CREDENTIALS_PRIVATE_KEY" "$USERS_SECRETS_FILE" "$ACTIVE_USERS_HASH_FILE" "$CREDENTIALS_LOCK_FILE" 2>/dev/null || true chmod 644 "$CREDENTIALS_PUBLIC_KEY" 2>/dev/null || true elif [[ -f "${import_work_dir}/users.conf.safe" ]]; then # A legacy import must not inherit encrypted secrets from the previous install. rm -rf "$CREDENTIALS_DIR" fi if [[ -d "$payload/users.d" ]]; then mkdir -p "$USER_META_DIR" chmod 700 "$USER_META_DIR" find "$payload/users.d" -maxdepth 1 -type f -name '*.env' -print0 2>/dev/null \ | while IFS= read -r -d '' meta_file; do name=$(basename "$meta_file" .env) is_valid_proxy_user "$name" || { err "Import: некорректное имя metadata-файла"; exit 1; } sanitize_imported_user_meta_file "$meta_file" "$USER_META_DIR/${name}.env" "$name" || exit 1 done fi if [[ -d "$payload/subscriptions" ]]; then mkdir -p "$SUBS_DIR" chmod 700 "$SUBS_DIR" find "$payload/subscriptions" -maxdepth 1 -type f -name '*.token' -print0 2>/dev/null \ | while IFS= read -r -d '' token_file; do name=$(basename "$token_file" .token) is_valid_proxy_user "$name" || { err "Import: некорректное имя token-файла"; exit 1; } sanitize_imported_token_file "$token_file" "$SUBS_DIR/${name}.token" || exit 1 done fi ok "Import завершён" warn "После импорта запусти: sudo bash yurich-panel.sh safe-apply" } cmd_bridge_configure() { load_config 2>/dev/null || true hr echo -e "${BOLD} Bridge builder${RESET}" hr echo -e " Схема: мобилка → этот VPS (${DOMAIN:-domain}) → второй VPS" echo -ne "${CYAN}Название bridge [ru-to-eu]: ${RESET}" read -r BRIDGE_NAME BRIDGE_NAME="${BRIDGE_NAME:-ru-to-eu}" echo -ne "${CYAN}Входной протокол на этом VPS [naive]: ${RESET}" read -r BRIDGE_ENTRY_PROTOCOL BRIDGE_ENTRY_PROTOCOL="${BRIDGE_ENTRY_PROTOCOL:-naive}" echo -ne "${CYAN}Выходной протокол второго VPS [vless]: ${RESET}" read -r BRIDGE_EXIT_PROTOCOL BRIDGE_EXIT_PROTOCOL="${BRIDGE_EXIT_PROTOCOL:-vless}" echo -ne "${CYAN}URI второго VPS (vless://, trojan://, hysteria2://, socks://, http://): ${RESET}" read -r BRIDGE_EXIT_URI if [[ ! "$BRIDGE_EXIT_URI" =~ ^(vless|trojan|hysteria2|hy2|socks|socks5|http|https):// ]]; then err "Нужен корректный URI второго VPS" return 1 fi BRIDGE_ENABLED="1" save_config install -d -m 700 "$CONFIG_DIR" { printf 'BRIDGE_ENABLED=%q\n' "$BRIDGE_ENABLED" printf 'BRIDGE_NAME=%q\n' "$BRIDGE_NAME" printf 'BRIDGE_ENTRY_PROTOCOL=%q\n' "$BRIDGE_ENTRY_PROTOCOL" printf 'BRIDGE_EXIT_PROTOCOL=%q\n' "$BRIDGE_EXIT_PROTOCOL" printf 'BRIDGE_EXIT_URI=%q\n' "$BRIDGE_EXIT_URI" printf 'UPDATED_AT=%q\n' "$(date '+%Y-%m-%d %H:%M:%S')" } > "$BRIDGE_CONFIG" chmod 600 "$BRIDGE_CONFIG" ok "Bridge profile сохранён: $BRIDGE_CONFIG" cmd_bridge_show } cmd_bridge_show() { load_config 2>/dev/null || true if [[ -f "$BRIDGE_CONFIG" ]]; then local bridge_owner bridge_owner=$(stat -c '%U' "$BRIDGE_CONFIG" 2>/dev/null || echo unknown) if [[ "$bridge_owner" == "root" ]]; then chmod 600 "$BRIDGE_CONFIG" 2>/dev/null || true # shellcheck source=/dev/null source "$BRIDGE_CONFIG" 2>/dev/null || true else warn "$BRIDGE_CONFIG принадлежит не root — не загружаю" fi fi hr echo -e "${BOLD} Bridge profile${RESET}" hr if [[ "${BRIDGE_ENABLED:-0}" != "1" ]]; then warn "Bridge не настроен" return 0 fi echo -e " Name: ${CYAN}${BRIDGE_NAME:-bridge}${RESET}" echo -e " Entry: ${CYAN}${BRIDGE_ENTRY_PROTOCOL:-naive}${RESET} на этом VPS" echo -e " Exit: ${CYAN}${BRIDGE_EXIT_PROTOCOL:-vless}${RESET} на втором VPS" echo -e " URI: ${DIM}${BRIDGE_EXIT_URI:-не задан}${RESET}" echo warn "Caddy/Naive сам по себе не умеет upstream chaining. Для полного bridge используй Xray/sing-box outbound на первом VPS." echo -e "${CYAN} Следующий шаг:${RESET} включить Xray на этом VPS и добавить outbound на второй VPS по URI выше." hr } cmd_bridge_remove() { rm -f "$BRIDGE_CONFIG" 2>/dev/null || true BRIDGE_ENABLED="0" BRIDGE_EXIT_URI="" save_config ok "Bridge profile удалён" } cmd_bridge_menu() { while true; do hr echo -e "${BOLD} Bridge builder${RESET}" hr echo -e " ${BOLD}1)${RESET} Создать / изменить bridge profile" echo -e " ${BOLD}2)${RESET} Показать bridge profile" echo -e " ${BOLD}3)${RESET} Удалить bridge profile" echo -e " ${BOLD}0)${RESET} Назад" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_bridge_configure ;; 2) cmd_bridge_show ;; 3) cmd_bridge_remove ;; 0) break ;; *) warn "Неверный выбор" ;; esac echo -ne "${YELLOW}Enter для продолжения...${RESET}" read -r done } # ─── MULTI-SERVER NODES ─────────────────────────────────────── is_valid_node_name() { [[ "${1:-}" =~ ^[a-zA-Z0-9_-]{2,32}$ ]] } is_valid_ssh_user() { [[ "${1:-}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}$ ]] } is_valid_node_host() { local host="${1:-}" [[ -n "$host" && "$host" != *"|"* ]] || return 1 if is_valid_domain "$host"; then return 0 fi [[ "$host" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 local IFS=. part for part in $host; do [[ "$part" =~ ^[0-9]{1,3}$ ]] || return 1 [[ "$part" == "0" || "$part" != 0* ]] || return 1 (( 10#$part <= 255 )) || return 1 done } is_valid_node_role() { case "${1:-}" in edge|bridge|exit|backup) return 0 ;; *) return 1 ;; esac } is_valid_node_weight() { local value="${1:-}" [[ "$value" =~ ^[1-9][0-9]{0,2}$ ]] && (( 10#$value <= 999 )) } nodes_ensure_file() { mkdir -p "$CONFIG_DIR" if [[ ! -f "$NODES_FILE" ]]; then { echo "# Yurich Panel nodes" echo "# format: name|host|ssh_port|ssh_user|domain|role|weight|enabled" } > "$NODES_FILE" chmod 600 "$NODES_FILE" fi } nodes_list_lines() { local line [[ -f "$NODES_FILE" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue if nodes_validate_line "$line"; then printf '%s\n' "$line" else warn "Пропускаю некорректную node-запись в $NODES_FILE" fi done < "$NODES_FILE" } nodes_count() { nodes_list_lines | wc -l } nodes_get_line() { local lookup="${1:-}" local line is_valid_node_name "$lookup" || return 1 line=$(nodes_list_lines | awk -F'|' -v name="$lookup" '$1 == name && !found {print; found=1}') [[ -n "$line" ]] || return 1 nodes_validate_line "$line" || return 1 printf '%s\n' "$line" } nodes_validate_line() { local line="$1" local name host port ssh_user node_domain role weight enabled IFS='|' read -r name host port ssh_user node_domain role weight enabled <<< "$line" is_valid_node_name "$name" || return 1 is_valid_node_host "$host" || return 1 is_valid_port "$port" || return 1 is_valid_ssh_user "$ssh_user" || return 1 if [[ -n "$node_domain" && "$node_domain" != "-" ]]; then is_valid_domain "$node_domain" || return 1 fi is_valid_node_role "$role" || return 1 is_valid_node_weight "$weight" || return 1 [[ "$enabled" == "1" || "$enabled" == "0" ]] || return 1 } nodes_upsert_line() { local line="$1" local name tmp nodes_validate_line "$line" || { err "Некорректная запись node"; return 1; } name="${line%%|*}" nodes_ensure_file tmp=$(mktemp) awk -F'|' -v name="$name" 'BEGIN{OFS=FS} /^#/ || /^[[:space:]]*$/ {print; next} $1 != name {print}' "$NODES_FILE" > "$tmp" printf '%s\n' "$line" >> "$tmp" install -m 600 "$tmp" "$NODES_FILE" rm -f "$tmp" } nodes_remove_line() { local name="${1:-}" tmp is_valid_node_name "$name" || { err "Некорректное имя node"; return 1; } nodes_ensure_file tmp=$(mktemp) awk -F'|' -v name="$name" 'BEGIN{OFS=FS} /^#/ || /^[[:space:]]*$/ {print; next} $1 != name {print}' "$NODES_FILE" > "$tmp" install -m 600 "$tmp" "$NODES_FILE" rm -f "$tmp" } nodes_prompt_name() { local provided="${1:-}" name if [[ -n "$provided" ]]; then is_valid_node_name "$provided" || return 1 printf '%s\n' "$provided" return 0 fi cmd_nodes_list >&2 echo -ne "${CYAN}Node name: ${RESET}" >&2 read -r name is_valid_node_name "$name" || return 1 printf '%s\n' "$name" } nodes_known_host_lookup_names() { local host="$1" port="$2" printf '%s\n' "$host" [[ "$port" == "22" ]] || printf '[%s]:%s\n' "$host" "$port" } nodes_known_host_is_trusted() { local host="$1" port="$2" lookup [[ -f "$NODES_KNOWN_HOSTS_FILE" ]] || return 1 while IFS= read -r lookup; do ssh-keygen -F "$lookup" -f "$NODES_KNOWN_HOSTS_FILE" >/dev/null 2>&1 && return 0 done < <(nodes_known_host_lookup_names "$host" "$port") return 1 } nodes_import_existing_host_key() { local host="$1" port="$2" default_file="${HOME:-/root}/.ssh/known_hosts" lookup tmp [[ -f "$default_file" ]] || return 1 tmp=$(mktemp) while IFS= read -r lookup; do ssh-keygen -F "$lookup" -f "$default_file" 2>/dev/null | grep -v '^#' >> "$tmp" || true done < <(nodes_known_host_lookup_names "$host" "$port") if [[ -s "$tmp" ]]; then mkdir -p "$(dirname "$NODES_KNOWN_HOSTS_FILE")" touch "$NODES_KNOWN_HOSTS_FILE" chmod 600 "$NODES_KNOWN_HOSTS_FILE" cat "$tmp" >> "$NODES_KNOWN_HOSTS_FILE" sort -u "$NODES_KNOWN_HOSTS_FILE" -o "$NODES_KNOWN_HOSTS_FILE" rm -f "$tmp" return 0 fi rm -f "$tmp" return 1 } nodes_ensure_host_key() { local line="$1" node_name node_host node_port node_user node_domain node_role node_weight node_enabled tmp ans nodes_validate_line "$line" || { err "Небезопасная node-запись"; return 1; } IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled <<< "$line" mkdir -p "$(dirname "$NODES_KNOWN_HOSTS_FILE")" touch "$NODES_KNOWN_HOSTS_FILE" chmod 600 "$NODES_KNOWN_HOSTS_FILE" nodes_known_host_is_trusted "$node_host" "$node_port" && return 0 if nodes_import_existing_host_key "$node_host" "$node_port" && nodes_known_host_is_trusted "$node_host" "$node_port"; then ok "SSH host key для ${node_name} импортирован из существующего known_hosts" return 0 fi [[ -t 0 ]] || { err "SSH host key для ${node_name} не доверен. Запусти интерактивно: nodes-test ${node_name}"; return 1; } command -v ssh-keyscan >/dev/null 2>&1 || { err "Не найден ssh-keyscan"; return 1; } tmp=$(mktemp) if ! ssh-keyscan -T 8 -p "$node_port" "$node_host" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then rm -f "$tmp" err "Не удалось получить SSH host key: ${node_host}:${node_port}" return 1 fi echo -e "${YELLOW}Сверь fingerprint ${node_name} с панелью провайдера:${RESET}" ssh-keygen -lf "$tmp" || { rm -f "$tmp"; return 1; } echo -ne "${YELLOW}Если fingerprint совпадает, введи TRUST: ${RESET}" read -r ans if [[ "$ans" != "TRUST" ]]; then rm -f "$tmp" err "Host key не добавлен" return 1 fi cat "$tmp" >> "$NODES_KNOWN_HOSTS_FILE" sort -u "$NODES_KNOWN_HOSTS_FILE" -o "$NODES_KNOWN_HOSTS_FILE" chmod 600 "$NODES_KNOWN_HOSTS_FILE" rm -f "$tmp" ok "SSH host key закреплён: ${node_name}" } nodes_ssh() { local line="$1" remote_cmd="$2" local node_name node_host node_port node_user node_domain node_role node_weight node_enabled local ssh_timeout="${NODES_SSH_TIMEOUT_SECONDS:-600}" nodes_validate_line "$line" || { err "Небезопасная node-запись"; return 1; } nodes_ensure_host_key "$line" || return 1 IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled <<< "$line" if command -v timeout >/dev/null 2>&1; then timeout "${ssh_timeout}s" ssh \ -n \ -o BatchMode=yes \ -o ConnectTimeout=8 \ -o ServerAliveInterval=5 \ -o ServerAliveCountMax=2 \ -o StrictHostKeyChecking=yes \ -o UserKnownHostsFile="$NODES_KNOWN_HOSTS_FILE" \ -p "$node_port" \ "${node_user}@${node_host}" \ "$remote_cmd" return $? fi ssh \ -n \ -o BatchMode=yes \ -o ConnectTimeout=8 \ -o ServerAliveInterval=5 \ -o ServerAliveCountMax=2 \ -o StrictHostKeyChecking=yes \ -o UserKnownHostsFile="$NODES_KNOWN_HOSTS_FILE" \ -p "$node_port" \ "${node_user}@${node_host}" \ "$remote_cmd" } nodes_scp_to() { local line="$1" src="$2" dest="$3" local node_name node_host node_port node_user node_domain node_role node_weight node_enabled nodes_validate_line "$line" || { err "Небезопасная node-запись"; return 1; } nodes_ensure_host_key "$line" || return 1 IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled <<< "$line" scp \ -P "$node_port" \ -o BatchMode=yes \ -o ConnectTimeout=8 \ -o StrictHostKeyChecking=yes \ -o UserKnownHostsFile="$NODES_KNOWN_HOSTS_FILE" \ -- "$src" "${node_user}@${node_host}:${dest}" } nodes_remote_sudo_prefix() { local ssh_user="${1:-root}" if [[ "$ssh_user" == "root" ]]; then printf '' else printf 'sudo -n ' fi } nodes_local_script_source() { if [[ -r "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then printf '%s\n' "${BASH_SOURCE[0]}" return 0 fi if [[ -r "$SCRIPT_PATH" ]]; then printf '%s\n' "$SCRIPT_PATH" return 0 fi err "Не найден локальный файл скрипта для отправки на node" return 1 } cmd_nodes_list() { nodes_ensure_file hr echo -e "${BOLD} Multi-server nodes${RESET}" hr if [[ "$(nodes_count)" -eq 0 ]]; then warn "Ноды не добавлены" echo -e " Файл: ${NODES_FILE}" return 0 fi printf ' %-14s %-22s %-7s %-10s %-24s %-8s %-6s %s\n' "NAME" "HOST" "SSH" "USER" "DOMAIN" "ROLE" "WEIGHT" "ON" nodes_list_lines | while IFS='|' read -r name host port ssh_user node_domain role weight enabled; do [[ "$enabled" == "1" ]] && enabled="yes" || enabled="no" [[ -z "$node_domain" || "$node_domain" == "-" ]] && node_domain="-" printf ' %-14s %-22s %-7s %-10s %-24s %-8s %-6s %s\n' "$name" "$host" "$port" "$ssh_user" "$node_domain" "$role" "$weight" "$enabled" done } cmd_nodes_add() { nodes_ensure_file local name host port ssh_user node_domain role weight enabled ans line hr echo -e "${BOLD} Добавить / изменить node${RESET}" hr echo -ne "${CYAN}Node name [eu-1]: ${RESET}" read -r name name="${name:-eu-1}" echo -ne "${CYAN}SSH host/IP: ${RESET}" read -r host echo -ne "${CYAN}SSH port [22]: ${RESET}" read -r port port="${port:-22}" echo -ne "${CYAN}SSH user [root]: ${RESET}" read -r ssh_user ssh_user="${ssh_user:-root}" echo -ne "${CYAN}Публичный домен node для ссылок (Enter = host если это домен): ${RESET}" read -r node_domain if [[ -z "$node_domain" && "$host" =~ [a-zA-Z] ]]; then node_domain="$host" fi [[ -z "$node_domain" ]] && node_domain="-" echo -ne "${CYAN}Role [edge/bridge/exit/backup, Enter=edge]: ${RESET}" read -r role role="${role:-edge}" echo -ne "${CYAN}Weight [100]: ${RESET}" read -r weight weight="${weight:-100}" echo -ne "${CYAN}Включить node в подписки? [Y/n]: ${RESET}" read -r ans [[ "${ans,,}" == "n" ]] && enabled="0" || enabled="1" if ! is_valid_node_name "$name"; then err "Node name: 2-32 символа A-Z a-z 0-9 _ -"; return 1; fi if ! is_valid_node_host "$host"; then err "Некорректный host/IP"; return 1; fi if ! is_valid_port "$port"; then err "Некорректный SSH порт"; return 1; fi if ! is_valid_ssh_user "$ssh_user"; then err "Некорректный SSH user"; return 1; fi if [[ "$node_domain" != "-" ]] && ! is_valid_domain "$node_domain"; then err "Некорректный домен node"; return 1; fi if ! is_valid_node_role "$role"; then err "Role должен быть edge, bridge, exit или backup"; return 1; fi if ! is_valid_node_weight "$weight"; then err "Weight: 1-999"; return 1; fi line="${name}|${host}|${port}|${ssh_user}|${node_domain}|${role}|${weight}|${enabled}" nodes_upsert_line "$line" ok "Node сохранена: ${name}" warn "Для управления node нужен SSH key login и, если user не root, NOPASSWD sudo." } cmd_nodes_remove() { load_config local name node_line node_domain configured entry host label new_profiles="" browser_changed=0 local -a browser_entries=() name=$(nodes_prompt_name "${1:-}") || { err "Node не выбрана"; return 1; } node_line=$(nodes_get_line "$name") || { err "Node не найдена: $name"; return 1; } IFS='|' read -r _ _ _ _ node_domain _ _ _ <<< "$node_line" configured="${BROWSER_SUBSCRIPTION_PROFILES:-}" if [[ -n "$configured" ]]; then IFS=';' read -r -a browser_entries <<< "$configured" for entry in "${browser_entries[@]}"; do [[ "$entry" == *"|"* ]] || continue host="${entry%%|*}" label="${entry#*|}" host=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') label=$(printf '%s' "$label" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') if [[ "$label" == "$name" || ( "$node_domain" != "-" && "$host" == "${node_domain,,}" ) ]]; then browser_changed=1 continue fi new_profiles+="${new_profiles:+;}${entry}" done fi nodes_remove_line "$name" || return 1 if [[ "$browser_changed" -eq 1 ]]; then BROWSER_SUBSCRIPTION_PROFILES="$new_profiles" if ! save_config; then nodes_upsert_line "$node_line" >/dev/null 2>&1 || true err "Не удалось обновить browser-профили; запись node восстановлена" return 1 fi ok "Browser-профили node удалены из конфигурации" fi ok "Node удалена из реестра: $name" warn "Запусти nodes-subscriptions, чтобы убрать node из опубликованных страниц" } cmd_nodes_test() { local name="${1:-}" line failed=0 explicit_target=0 nodes_ensure_file if [[ -n "$name" && "$name" != "all" ]]; then explicit_target=1 line=$(nodes_get_line "$name") || { err "Node не найдена: $name"; return 1; } set -- "$line" else mapfile -t _nodes_to_check < <(nodes_list_lines) if [[ "${#_nodes_to_check[@]}" -eq 0 ]]; then warn "Ноды не добавлены" return 0 fi set -- "${_nodes_to_check[@]}" fi for line in "$@"; do local node_name node_host node_port node_user node_domain node_role node_weight node_enabled IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled <<< "$line" if [[ "$node_enabled" != "1" && "$explicit_target" != "1" ]]; then info "Node ${node_name} выключена, пропускаю" continue fi hr echo -e "${BOLD} Node status: ${node_name}${RESET} (${node_user}@${node_host}:${node_port})" hr if nodes_ssh "$line" 'printf "host=%s\n" "$(hostname)"; printf "time=%s\n" "$(date "+%Y-%m-%d %H:%M:%S")"; uptime; printf "caddy="; systemctl is-active caddy 2>/dev/null || true; printf "xray="; systemctl is-active xray 2>/dev/null || true; printf "hysteria="; systemctl is-active hysteria 2>/dev/null || true; printf "unbound="; systemctl is-active unbound 2>/dev/null || true; ss -tulpn 2>/dev/null | grep -E ":(443|8443|8444)([[:space:]]|$)" || true'; then ok "SSH/status OK: ${node_name}" else err "SSH/status failed: ${node_name}" failed=1 fi done return "$failed" } cmd_nodes_deploy_script() { local name line src remote_tmp node_name node_host node_port node_user node_domain node_role node_weight node_enabled sudo_cmd name=$(nodes_prompt_name "${1:-}") || { err "Node не выбрана"; return 1; } line=$(nodes_get_line "$name") || { err "Node не найдена: $name"; return 1; } IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled <<< "$line" src=$(nodes_local_script_source) || return 1 remote_tmp="/tmp/yurich-panel-node-${RANDOM}-$$.sh" sudo_cmd=$(nodes_remote_sudo_prefix "$node_user") info "Отправляю текущий скрипт на ${node_name}..." nodes_scp_to "$line" "$src" "$remote_tmp" || return 1 nodes_ssh "$line" "${sudo_cmd}install -m 755 ${remote_tmp} ${SCRIPT_PATH} && ${sudo_cmd}install -m 755 ${remote_tmp} ${LEGACY_SCRIPT_PATH} && ${sudo_cmd}rm -f ${remote_tmp} && ${sudo_cmd}bash ${SCRIPT_PATH} version" || return 1 ok "Скрипт установлен/обновлён на node: ${node_name}" echo -ne "${CYAN}Запустить интерактивную установку Yurich Panel на node сейчас? [y/N]: ${RESET}" read -r ans if [[ "${ans,,}" == "y" ]]; then nodes_ensure_host_key "$line" || return 1 ssh -tt \ -o BatchMode=yes \ -o ConnectTimeout=8 \ -o StrictHostKeyChecking=yes \ -o UserKnownHostsFile="$NODES_KNOWN_HOSTS_FILE" \ -p "$node_port" \ "${node_user}@${node_host}" \ "${sudo_cmd}bash ${SCRIPT_PATH} install" fi } cmd_nodes_sync_users() { local target="${1:-}" archive remote_tmp failed=0 lines=() items=() load_config load_users nodes_ensure_file [[ -f "$USERS_FILE" ]] || { err "users.conf не найден, нечего синхронизировать"; return 1; } ensure_credential_keypair || return 1 write_active_users_hash_file || return 1 for item in users.conf credentials users.d subscriptions subscription-aliases.conf xray-users.conf xray-compat-users.conf xray-users.disabled users.disabled; do [[ -e "$CONFIG_DIR/$item" ]] && items+=("$item") done [[ "${#items[@]}" -gt 0 ]] || { err "Нет файлов состояния для sync"; return 1; } archive=$(mktemp /tmp/yurich-panel-node-sync-XXXXXX.tar.gz) tar -C "$CONFIG_DIR" -czf "$archive" "${items[@]}" if [[ -n "$target" && "$target" != "all" ]]; then local selected selected=$(nodes_get_line "$target") || { rm -f "$archive"; err "Node не найдена: $target"; return 1; } lines+=("$selected") else mapfile -t lines < <(nodes_list_lines) fi if [[ "${#lines[@]}" -eq 0 ]]; then rm -f "$archive" rm -f "$NODES_PENDING_SYNC_FILE" warn "Ноды не добавлены" return 0 fi for line in "${lines[@]}"; do local node_name node_host node_port node_user node_domain node_role node_weight node_enabled sudo_cmd IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled <<< "$line" [[ "$node_enabled" == "1" ]] || { warn "Node ${node_name} выключена, пропускаю"; continue; } remote_tmp="/tmp/yurich-panel-node-sync-${RANDOM}-$$.tar.gz" sudo_cmd=$(nodes_remote_sudo_prefix "$node_user") info "Синхронизирую пользователей на ${node_name}..." if ! nodes_scp_to "$line" "$archive" "$remote_tmp"; then err "Не удалось отправить архив на ${node_name}" failed=1 continue fi if nodes_ssh "$line" "trap 'rm -f ${remote_tmp}' EXIT; ${sudo_cmd}chmod 600 ${remote_tmp} || exit 16; ${sudo_cmd}mkdir -p ${CONFIG_DIR} ${BACKUP_DIR} ${USER_META_DIR} ${SUBS_DIR} || exit 17; ${sudo_cmd}tar -tzf ${remote_tmp} >/dev/null || exit 18; ${sudo_cmd}tar -C ${CONFIG_DIR} -czf ${BACKUP_DIR}/node-sync-before-\$(date +%Y%m%d_%H%M%S).tar.gz users.conf credentials users.d subscriptions subscription-aliases.conf xray-users.conf xray-compat-users.conf xray-users.disabled users.disabled 2>/dev/null || true; ${sudo_cmd}find ${BACKUP_DIR} -maxdepth 1 -type f -name 'node-sync-before-*.tar.gz' -exec chmod 600 {} +; ${sudo_cmd}tar -C ${CONFIG_DIR} -xzf ${remote_tmp} || exit 19; ${sudo_cmd}chmod 700 ${CONFIG_DIR} ${CREDENTIALS_DIR} ${USER_META_DIR} ${SUBS_DIR} 2>/dev/null || true; ${sudo_cmd}chmod 600 ${USERS_FILE} ${DISABLED_USERS_FILE} ${CREDENTIALS_PRIVATE_KEY} ${USERS_SECRETS_FILE} ${ACTIVE_USERS_HASH_FILE} ${CREDENTIALS_LOCK_FILE} ${XRAY_USERS_FILE} ${XRAY_COMPAT_USERS_FILE} ${XRAY_DISABLED_USERS_FILE} ${SUBS_ALIASES_FILE} ${USER_META_DIR}/*.env ${SUBS_DIR}/*.token 2>/dev/null || true; ${sudo_cmd}chmod 644 ${CREDENTIALS_PUBLIC_KEY} 2>/dev/null || true; sync_status=0; if ${sudo_cmd}test -x ${SCRIPT_PATH}; then if ${sudo_cmd}test -x ${CADDY_BIN} && ${sudo_cmd}test -f ${CADDYFILE}; then ${sudo_cmd}bash ${SCRIPT_PATH} safe-apply || sync_status=20; fi; if ${sudo_cmd}test -x ${HYSTERIA_BIN} || ${sudo_cmd}test -f ${HYSTERIA_CONFIG}; then ${sudo_cmd}bash ${SCRIPT_PATH} hysteria-sync >/dev/null 2>&1 || sync_status=21; fi; if ${sudo_cmd}test -x ${XRAY_BIN} || ${sudo_cmd}test -f ${XRAY_CONFIG}; then ${sudo_cmd}bash ${SCRIPT_PATH} xray-rebuild >/dev/null 2>&1 || sync_status=22; fi; ${sudo_cmd}bash ${SCRIPT_PATH} nodes-subscriptions >/dev/null 2>&1 || sync_status=23; else sync_status=24; fi; exit \$sync_status"; then ok "Users synced: ${node_name}" else err "Sync failed: ${node_name}" failed=1 fi done rm -f "$archive" if [[ "$failed" -eq 0 ]]; then rm -f "$NODES_PENDING_SYNC_FILE" else touch "$NODES_PENDING_SYNC_FILE" chmod 600 "$NODES_PENDING_SYNC_FILE" fi return "$failed" } node_links_for_user() { local user="$1" pass="$2" expiry_tag="${3:-active}" [[ -n "$pass" ]] || return 0 [[ -f "$NODES_FILE" ]] || return 0 nodes_list_lines | while IFS='|' read -r name host port ssh_user node_domain role weight enabled; do [[ "$enabled" == "1" ]] || continue [[ "${role:-edge}" == "edge" ]] || continue [[ -n "$node_domain" && "$node_domain" != "-" ]] || continue is_valid_domain "$node_domain" || continue subscription_node_core_enabled "$name" || continue printf 'naive+https://%s:%s@%s:443#%s\n' "$user" "$pass" "$node_domain" \ "$(uri_fragment_encode "$(pretty_profile_name "$user" "HTTPS" "$name")")" done } naive_extra_links_for_user() { local user="$1" pass="$2" expiry_tag="${3:-active}" configured entry host node local -a entries=() local -A seen_hosts=() is_valid_proxy_user "$user" || return 1 subscription_name_in_list "$user" "${NAIVE_EXTRA_USERS:-}" || return 0 configured="${NAIVE_EXTRA_PROFILES:-}" [[ -n "$configured" && -n "$pass" ]] || return 0 is_valid_proxy_pass "$pass" || return 1 IFS=';' read -r -a entries <<< "$configured" for entry in "${entries[@]}"; do entry=$(printf '%s' "$entry" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') [[ -n "$entry" ]] || continue [[ "$entry" == *"|"* ]] || return 1 host="${entry%%|*}" node="${entry#*|}" host=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') node=$(printf '%s' "$node" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') is_valid_domain "$host" || return 1 [[ "$node" =~ ^[A-Za-z0-9_-]{1,32}$ ]] || return 1 [[ -z "${seen_hosts[$host]+x}" ]] || continue seen_hosts["$host"]=1 printf 'naive+https://%s:%s@%s:443#%s\n' "$user" "$pass" "$host" \ "$(uri_fragment_encode "$(pretty_profile_name "$user" "HTTPS" "$node")")" done } subscription_http_node_sources_is_valid() { local configured="${1:-}" entry node domain ip extra count=0 local -a entries=() [[ -z "$configured" ]] && return 0 IFS=';' read -r -a entries <<< "$configured" for entry in "${entries[@]}"; do entry=$(printf '%s' "$entry" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') [[ -n "$entry" ]] || continue count=$((count + 1)) (( count <= 16 )) || return 1 IFS='|' read -r node domain ip extra <<< "$entry" [[ -z "$extra" ]] || return 1 is_valid_node_name "$node" || return 1 is_valid_domain "$domain" || return 1 is_valid_ipv4 "$ip" || return 1 done (( count > 0 )) } subscription_http_node_links_for_user() { local user="$1" configured entry node domain ip extra token token_file tmp candidate authority endpoint host protocol accepted local -a entries=() is_valid_proxy_user "$user" || return 1 configured="${SUBSCRIPTION_HTTP_NODE_SOURCES:-}" [[ -n "$configured" ]] || return 0 subscription_http_node_sources_is_valid "$configured" || return 1 token_file="${SUBS_DIR}/${user}.token" token=$(cat "$token_file" 2>/dev/null || true) [[ "$token" =~ ^[a-fA-F0-9]{32,64}$ ]] || return 1 IFS=';' read -r -a entries <<< "$configured" for entry in "${entries[@]}"; do entry=$(printf '%s' "$entry" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') [[ -n "$entry" ]] || continue IFS='|' read -r node domain ip extra <<< "$entry" tmp=$(mktemp /tmp/yurich-http-node-links-XXXXXX) || return 1 chmod 600 "$tmp" if ! curl --proto '=https' --tlsv1.2 -fsS \ --connect-timeout 5 --max-time 15 --max-filesize 1048576 \ --resolve "${domain}:443:${ip}" \ "https://${domain}/s/${token}/links.txt" -o "$tmp" 2>/dev/null; then rm -f "$tmp" return 1 fi accepted=0 while IFS= read -r candidate || [[ -n "$candidate" ]]; do [[ -n "$candidate" && "$candidate" != \#* ]] || continue protocol_line_allowed "$candidate" || continue authority="${candidate#*://}" authority="${authority%%[/?#]*}" endpoint="${authority##*@}" host="${endpoint%:*}" [[ "${host,,}" == "${domain,,}" ]] || continue case "$candidate" in naive+https://*) protocol="HTTPS" ;; hy2://*|hysteria2://*) protocol="Turbo" ;; vless://*type=xhttp*) protocol="XHTTP" ;; vless://*security=reality*) protocol="Reality" ;; *) continue ;; esac uri_with_profile_name "$candidate" "$(pretty_profile_name "$user" "$protocol" "$node")" accepted=$((accepted + 1)) done < "$tmp" rm -f "$tmp" (( accepted > 0 )) || return 1 done } browser_subscription_node_is_enabled() { local node_name="$1" browser_host="${2:-}" line _name _host _port _user _domain _role _weight enabled [[ -n "$node_name" ]] || return 1 subscription_node_relay_only "$node_name" && return 1 # Backward compatibility: custom browser labels that are not registered nodes # remain valid. Registered nodes always obey their enabled flag. if ! line=$(nodes_get_line "$node_name" 2>/dev/null); then if is_valid_domain "$browser_host"; then while IFS='|' read -r _name _host _port _user _domain _role _weight enabled; do if [[ "${_domain,,}" == "${browser_host,,}" ]]; then [[ "$enabled" == "1" ]] return fi done < <(nodes_list_lines) fi return 0 fi IFS='|' read -r _name _host _port _user _domain _role _weight enabled <<< "$line" [[ "$enabled" == "1" ]] } browser_subscription_links_for_user() { local user="$1" pass="$2" configured entry host node emit_profiles=1 local -a entries=() local -A seen_hosts=() is_valid_proxy_user "$user" || return 1 configured="${BROWSER_SUBSCRIPTION_PROFILES:-}" [[ -n "$configured" ]] || return 0 if [[ -z "$pass" ]]; then emit_profiles=0 else is_valid_proxy_pass "$pass" || return 1 fi IFS=';' read -r -a entries <<< "$configured" for entry in "${entries[@]}"; do entry=$(printf '%s' "$entry" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') [[ -n "$entry" ]] || continue [[ "$entry" == *"|"* ]] || return 1 host="${entry%%|*}" node="${entry#*|}" host=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') node=$(printf '%s' "$node" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') is_valid_domain "$host" || return 1 [[ "$node" =~ ^[A-Za-z0-9_-]{1,32}$ ]] || return 1 browser_subscription_node_is_enabled "$node" "$host" || continue [[ -z "${seen_hosts[$host]+x}" ]] || continue seen_hosts["$host"]=1 if [[ "$emit_profiles" == "1" ]]; then uri_with_profile_name \ "https://${user}:${pass}@${host}:443" \ "$(pretty_profile_name "$user" "Browser HTTPS" "$node")" fi done } node_xhttp_link_for_user() { local user="$1" node_name="$2" node_domain="$3" uuid [[ -n "$user" && -n "$node_domain" ]] || return 0 is_valid_domain "$node_domain" || return 0 uuid=$(get_xray_user_uuid "$user" 2>/dev/null || true) [[ -n "$uuid" ]] || return 0 printf 'vless://%s@%s:443?encryption=none&security=tls&type=xhttp&host=%s&path=%%2Fxhttp&mode=packet-up&sni=%s&fp=chrome&alpn=h2#%s\n' \ "$uuid" "$node_domain" "$node_domain" "$node_domain" \ "$(uri_fragment_encode "$(pretty_profile_name "$user" "XHTTP" "$node_name")")" } subscription_name_in_list() { local needle="$1" raw_list="$2" item [[ -n "$needle" && -n "$raw_list" ]] || return 1 raw_list="${raw_list//,/ }" for item in $raw_list; do [[ "$item" == "*" || "$item" == "all" || "$item" == "$needle" ]] && return 0 done return 1 } subscription_node_xhttp_enabled() { local node_name="$1" xhttp_nodes="${SUBSCRIPTION_XHTTP_NODE_NAMES:-all}" subscription_name_in_list "$node_name" "$xhttp_nodes" } subscription_user_xhttp_enabled() { local user="$1" node_name="$2" local canary_users="${SUBSCRIPTION_XHTTP_CANARY_USERS:-}" subscription_node_xhttp_enabled "$node_name" && return 0 subscription_name_in_list "$node_name" "${SUBSCRIPTION_XHTTP_CANARY_NODE_NAMES:-}" || return 1 [[ "$canary_users" == "*" ]] && return 0 subscription_name_in_list "$user" "$canary_users" } subscription_node_core_enabled() { local node_name="$1" subscription_node_relay_only "$node_name" && return 1 if [[ "${SUBSCRIPTION_XHTTP_NODE_EXCLUSIVE:-0}" == "1" ]] && subscription_node_xhttp_enabled "$node_name"; then return 1 fi return 0 } subscription_node_relay_only() { local node_name="$1" subscription_name_in_list "$node_name" "${SUBSCRIPTION_RELAY_ONLY_NODE_NAMES:-}" } cmd_node_subscription_export() { load_config load_users local selected_user="${1:-}" user hy2_uri uuid reality_link reality_public_port local hysteria_active=0 xray_active=0 if [[ -n "$selected_user" ]]; then is_valid_proxy_user "$selected_user" || return 1 subscription_user_exists "$selected_user" || return 1 fi systemctl is-active --quiet hysteria 2>/dev/null && hysteria_active=1 systemctl is-active --quiet xray 2>/dev/null && xray_active=1 reality_public_port=$(xray_reality_public_port) while IFS= read -r user; do [[ -n "$user" ]] || continue [[ -z "$selected_user" || "$user" == "$selected_user" ]] || continue user_is_expired "$user" && continue if [[ "$hysteria_active" -eq 1 && -n "${HYSTERIA_OBFS_PASSWORD:-}" ]]; then hy2_uri=$(hysteria_uri_for_user "$user" 2>/dev/null || true) if [[ -n "$hy2_uri" ]]; then hy2_uri=$(uri_with_profile_name "$hy2_uri" "$(pretty_profile_name "$user" "Turbo")") printf '%s\t%s\n' "$user" "$hy2_uri" fi fi if [[ "$xray_active" -eq 1 && -n "${XRAY_REALITY_PUBLIC_KEY:-}" && -n "${XRAY_REALITY_SHORT_ID:-}" ]]; then uuid=$(get_xray_user_uuid "$user" 2>/dev/null || true) if [[ -n "$uuid" ]]; then reality_link=$(uri_with_profile_name \ "vless://${uuid}@${DOMAIN}:${reality_public_port}?encryption=none&security=reality&type=tcp&flow=xtls-rprx-vision&sni=${XRAY_REALITY_SERVER_NAME:-www.microsoft.com}&fp=chrome&pbk=${XRAY_REALITY_PUBLIC_KEY}&sid=${XRAY_REALITY_SHORT_ID}&spx=%2F" \ "$(pretty_profile_name "$user" "Reality")") printf '%s\t%s\n' "$user" "$reality_link" fi fi done < <(list_subscription_users) } subscription_node_cache_ready() { local node_name="$1" cache_dir="${SUBSCRIPTION_NODE_LINK_CACHE_DIR:-}" [[ -n "$cache_dir" && -d "$cache_dir" && -f "$cache_dir/.ready.${node_name}" ]] } subscription_node_cached_links_for_user() { local user="$1" node_name="$2" cache_dir="${SUBSCRIPTION_NODE_LINK_CACHE_DIR:-}" cache_file is_valid_proxy_user "$user" || return 1 is_valid_node_name "$node_name" || return 1 cache_file="${cache_dir}/${user}.links" [[ -f "$cache_file" ]] || return 0 awk -F '\t' -v node="$node_name" '$1 == node {print substr($0, index($0, "\t") + 1)}' "$cache_file" } subscription_prepare_node_link_cache() { local cache_dir="$1" line name host port ssh_user node_domain role weight enabled local sudo_cmd remote_script remote_b64 remote_cmd output cache_user candidate ready=0 [[ -d "$cache_dir" ]] || return 1 while IFS='|' read -r name host port ssh_user node_domain role weight enabled; do [[ "$enabled" == "1" ]] || continue [[ -n "$node_domain" && "$node_domain" != "-" ]] || continue is_valid_domain "$node_domain" || continue subscription_node_core_enabled "$name" || continue line="${name}|${host}|${port}|${ssh_user}|${node_domain}|${role}|${weight}|${enabled}" sudo_cmd=$(nodes_remote_sudo_prefix "$ssh_user") remote_script=$(cat <<'EOS' set -euo pipefail if [[ -x /usr/local/bin/yurich-panel.sh ]]; then if grep -q 'node-subscription-export)' /usr/local/bin/yurich-panel.sh 2>/dev/null; then /usr/local/bin/yurich-panel.sh node-subscription-export exit 0 fi /usr/local/bin/yurich-panel.sh nodes-subscriptions >/dev/null 2>&1 fi shopt -s nullglob for token_file in /etc/naiveproxy/subscriptions/*.token; do user=${token_file##*/} user=${user%.token} [[ "$user" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$ ]] || continue token=$(tr -dc 'a-fA-F0-9' < "$token_file" | head -c 64) [[ "$token" =~ ^[a-fA-F0-9]{32,64}$ ]] || continue links="/var/www/html/s/${token}/links.txt" [[ -s "$links" ]] || continue if systemctl is-active --quiet hysteria 2>/dev/null; then awk -v user="$user" '/^(hy2:\/\/|hysteria2:\/\/)/ && $0 !~ /[Hh]op/ {printf "%s\t%s\n", user, $0}' "$links" fi if systemctl is-active --quiet xray 2>/dev/null; then awk -v user="$user" '/^vless:\/\// && /security=reality/ && /type=tcp/ {printf "%s\t%s\n", user, $0}' "$links" fi done EOS ) remote_b64=$(printf '%s' "$remote_script" | base64 -w0) remote_cmd="${sudo_cmd}bash -c 'printf %s ${remote_b64} | base64 -d | bash'" if ! output=$(nodes_ssh "$line" "$remote_cmd" 2>/dev/null); then warn "Node cache недоступен: ${name}; будет использован точечный SSH fallback" continue fi while IFS=$'\t' read -r cache_user candidate; do is_valid_proxy_user "$cache_user" || continue [[ "$candidate" == *"@${node_domain}:"* ]] || continue protocol_line_allowed "$candidate" || continue printf '%s\t%s\n' "$name" "$candidate" >> "${cache_dir}/${cache_user}.links" chmod 600 "${cache_dir}/${cache_user}.links" done <<< "$output" : > "$cache_dir/.ready.${name}" chmod 600 "$cache_dir/.ready.${name}" ready=$((ready + 1)) done < <(nodes_list_lines) [[ "$ready" -gt 0 ]] } node_app_links_for_user() { local user="$1" [[ -n "$user" ]] || return 0 [[ -f "$NODES_FILE" ]] || return 0 nodes_list_lines | while IFS='|' read -r name host port ssh_user node_domain role weight enabled; do [[ "$enabled" == "1" ]] || continue [[ -n "$node_domain" && "$node_domain" != "-" ]] || continue is_valid_domain "$node_domain" || continue local line sudo_cmd remote_cmd line="${name}|${host}|${port}|${ssh_user}|${node_domain}|${role}|${weight}|${enabled}" if subscription_node_relay_only "$name"; then continue fi if ! subscription_node_core_enabled "$name"; then if [[ "${SUBSCRIPTION_XHTTP_MAIN_ENABLED:-1}" == "1" ]]; then subscription_user_xhttp_enabled "$user" "$name" && node_xhttp_link_for_user "$user" "$name" "$node_domain" fi continue fi if subscription_node_cache_ready "$name"; then subscription_node_cached_links_for_user "$user" "$name" if [[ "${SUBSCRIPTION_XHTTP_MAIN_ENABLED:-1}" == "1" ]]; then subscription_user_xhttp_enabled "$user" "$name" && node_xhttp_link_for_user "$user" "$name" "$node_domain" fi continue fi sudo_cmd=$(nodes_remote_sudo_prefix "$ssh_user") remote_cmd="${sudo_cmd}bash -lc 'u=\"${user}\"; token_file=\"/etc/naiveproxy/subscriptions/\${u}.token\"; token=\$(cat \"\$token_file\" 2>/dev/null || true); links=\"/var/www/html/s/\${token}/links.txt\"; if { [ -z \"\$token\" ] || [ ! -s \"\$links\" ]; } && [ -x /usr/local/bin/yurich-panel.sh ]; then bash /usr/local/bin/yurich-panel.sh nodes-subscriptions >/dev/null 2>&1 || true; token=\$(cat \"\$token_file\" 2>/dev/null || true); links=\"/var/www/html/s/\${token}/links.txt\"; fi; if [ -s \"\$links\" ]; then if systemctl is-active --quiet hysteria 2>/dev/null; then awk '\''/^(hy2:\\/\\/|hysteria2:\\/\\/)/ && \$0 !~ /[Hh]op/ {print}'\'' \"\$links\" || true; fi; if systemctl is-active --quiet xray 2>/dev/null; then awk '\''/^vless:\\/\\// && \$0 ~ /security=reality/ && \$0 ~ /type=tcp/ && \$0 ~ /^vless:\\/\\/[^@]+@[^/?#]+:[0-9]+\\?/ {print}'\'' \"\$links\" || true; fi; fi'" local output attempt fetched=0 for ((attempt = 1; attempt <= 3; attempt++)); do if output=$(nodes_ssh "$line" "$remote_cmd" 2>/dev/null); then fetched=1 [[ -n "$output" ]] && printf '%s\n' "$output" | awk -v domain="$node_domain" 'index($0, "@" domain ":")' if [[ "${SUBSCRIPTION_XHTTP_MAIN_ENABLED:-1}" == "1" ]]; then subscription_user_xhttp_enabled "$user" "$name" && node_xhttp_link_for_user "$user" "$name" "$node_domain" fi break fi sleep 2 done if (( fetched == 0 )); then err "SSH node ${name} недоступен при сборке подписки ${user}" return 1 fi done } node_relay_source_link_for_user() { local user="$1" line="$2" node_domain="$3" transport="$4" local node_name node_host node_port node_user node_role node_weight node_enabled local sudo_cmd remote_cmd output quoted_user awk_filter is_valid_proxy_user "$user" || return 1 nodes_validate_line "$line" || return 1 is_valid_domain "$node_domain" || return 1 case "$transport" in reality) awk_filter='/^vless:\/\// && /security=reality/ && /type=tcp/ {print; exit}' ;; xhttp) awk_filter='/^vless:\/\// && /security=tls/ && /type=xhttp/ {print; exit}' ;; *) return 1 ;; esac IFS='|' read -r node_name node_host node_port node_user _ node_role node_weight node_enabled <<< "$line" sudo_cmd=$(nodes_remote_sudo_prefix "$node_user") printf -v quoted_user '%q' "$user" remote_cmd="${sudo_cmd}bash -lc 'u=${quoted_user}; token_file=\"/etc/naiveproxy/subscriptions/\${u}.token\"; token=\$(tr -dc \"a-fA-F0-9\" < \"\$token_file\" 2>/dev/null | head -c 64); links=\"/var/www/html/s/\${token}/links.txt\"; [ -s \"\$links\" ] || exit 3; awk '\''${awk_filter}'\'' \"\$links\"'" output=$(nodes_ssh "$line" "$remote_cmd") || return 1 printf '%s\n' "$output" | awk -v authority="@${node_domain}:443?" 'index($0, authority) {print; exit}' } subscription_vless_relay_links_for_user() { local user="$1" source_links="$2" configured entry source_node relay_host relay_port transports local node_line node_host node_port node_user source_domain node_role node_weight node_enabled local transport source_link relay_link protocol_label local -a entries=() local -A seen=() [[ -n "$user" ]] || return 0 subscription_name_in_list "$user" "${VLESS_RELAY_USERS:-}" || return 0 configured="${VLESS_RELAY_PROFILES:-}" [[ -n "$configured" ]] || return 0 IFS=';' read -r -a entries <<< "$configured" for entry in "${entries[@]}"; do entry=$(printf '%s' "$entry" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') [[ -n "$entry" ]] || continue IFS='|' read -r source_node relay_host relay_port transports <<< "$entry" [[ "$source_node" =~ ^[A-Za-z0-9_-]{1,32}$ ]] || return 1 is_valid_domain "$relay_host" || return 1 [[ "$relay_port" =~ ^[0-9]{2,5}$ ]] && ((relay_port >= 1024 && relay_port <= 65535)) || return 1 if [[ "$source_node" == "local" ]]; then node_line="" source_domain="$DOMAIN" else node_line=$(nodes_get_line "$source_node" 2>/dev/null) || return 1 IFS='|' read -r _ node_host node_port node_user source_domain node_role node_weight node_enabled <<< "$node_line" fi is_valid_domain "$source_domain" || return 1 transports="${transports//,/ }" for transport in $transports; do case "$transport" in reality) source_link=$(printf '%s\n' "$source_links" | awk -v authority="@${source_domain}:443?" ' /^vless:\/\// && index($0, authority) && /security=reality/ && /type=tcp/ {print; exit} ') protocol_label="Reality Relay" ;; xhttp) source_link=$(printf '%s\n' "$source_links" | awk -v authority="@${source_domain}:443?" ' /^vless:\/\// && index($0, authority) && /security=tls/ && /type=xhttp/ {print; exit} ') protocol_label="XHTTP Relay" ;; *) return 1 ;; esac if [[ -z "$source_link" && "$source_node" != "local" ]]; then source_link=$(node_relay_source_link_for_user "$user" "$node_line" "$source_domain" "$transport") || return 1 fi [[ -n "$source_link" ]] || return 1 relay_link="${source_link%%#*}" relay_link="${relay_link/@${source_domain}:443/@${relay_host}:${relay_port}}" if [[ "$source_node" == "local" ]]; then relay_link=$(uri_with_profile_name "$relay_link" "$(pretty_profile_name "$user" "$protocol_label")") else relay_link=$(uri_with_profile_name "$relay_link" "$(pretty_profile_name "$user" "$protocol_label" "$source_node")") fi [[ -z "${seen[$relay_link]+x}" ]] || continue seen["$relay_link"]=1 printf '%s\n' "$relay_link" done done } subscription_filter_published_links() { awk ' /Reality GitHub TEST/ { next } /spx=%2Fgithub-test-/ { next } NF && !seen[$0]++ { print } ' } subscription_rebuild_worker_count() { local requested="${SUBSCRIPTION_REBUILD_JOBS:-0}" cpu_count if is_canonical_uint_in_range "$requested" 1 8; then printf '%s\n' "$requested" return 0 fi cpu_count=$(nproc 2>/dev/null || echo 1) [[ "$cpu_count" =~ ^[0-9]+$ && "$cpu_count" -gt 0 ]] || cpu_count=1 (( cpu_count > 4 )) && cpu_count=4 printf '%s\n' "$cpu_count" } subscription_generate_pages_parallel() { local jobs="$1" log_dir="$2" users_file="$3" local user log_file pid failed=0 seq=0 local -a worker_pids=() worker_users=() worker_logs=() is_canonical_uint_in_range "$jobs" 1 8 || return 1 [[ -d "$log_dir" && -f "$users_file" ]] || return 1 while IFS= read -r user; do [[ -n "$user" ]] || continue seq=$((seq + 1)) if ! is_valid_proxy_user "$user"; then printf 'FAIL\tinvalid-entry-%s\n' "$seq" failed=1 continue fi log_file="${log_dir}/${seq}-${user}.log" ( SUBSCRIPTION_REBUILD_WORKER=1 generate_subscription_page "$user" ) >"$log_file" 2>&1 & pid=$! worker_pids+=("$pid") worker_users+=("$user") worker_logs+=("$log_file") if (( ${#worker_pids[@]} >= jobs )); then if wait "${worker_pids[0]}"; then printf 'OK\t%s\n' "${worker_users[0]}" rm -f -- "${worker_logs[0]}" else printf 'FAIL\t%s\n' "${worker_users[0]}" failed=1 fi worker_pids=("${worker_pids[@]:1}") worker_users=("${worker_users[@]:1}") worker_logs=("${worker_logs[@]:1}") fi done < "$users_file" while (( ${#worker_pids[@]} > 0 )); do if wait "${worker_pids[0]}"; then printf 'OK\t%s\n' "${worker_users[0]}" rm -f -- "${worker_logs[0]}" else printf 'FAIL\t%s\n' "${worker_users[0]}" failed=1 fi worker_pids=("${worker_pids[@]:1}") worker_users=("${worker_users[@]:1}") worker_logs=("${worker_logs[@]:1}") done return "$failed" } cmd_nodes_rebuild_subscriptions() { load_config load_users local lock_fd cache_dir="" build_log_dir="" active_users_file="" results_file="" local failed=0 failed_users="" jobs status user expected=0 result_count=0 install -d -m 755 /run/lock exec {lock_fd}>/run/lock/yurich-subscription-rebuild.lock if ! flock -n "$lock_fd"; then err "Другая пересборка подписок уже выполняется" return 75 fi cleanup_orphan_subscription_pages ensure_web_privacy_files subscription_build_traffic_cache >/dev/null 2>&1 || true ensure_qrencode_for_subscriptions >/dev/null 2>&1 || warn "qrencode недоступен; QR-коды будут пропущены" local count=0 if [[ "${SUBSCRIPTION_NODE_PREFETCH_ENABLED:-1}" == "1" && "$(nodes_count 2>/dev/null || echo 0)" -gt 0 ]]; then cache_dir=$(mktemp -d /run/yurich-subscription-cache.XXXXXX) chmod 700 "$cache_dir" if subscription_prepare_node_link_cache "$cache_dir"; then SUBSCRIPTION_NODE_LINK_CACHE_DIR="$cache_dir" else warn "Пакетный cache нод не собран полностью; включён совместимый SSH fallback" fi fi active_users_file=$(mktemp /tmp/yurich-subscription-users.XXXXXX) || return 1 results_file=$(mktemp /tmp/yurich-subscription-results.XXXXXX) || { rm -f -- "$active_users_file"; return 1; } build_log_dir=$(mktemp -d /tmp/yurich-subscription-logs.XXXXXX) || { rm -f -- "$active_users_file" "$results_file"; return 1; } chmod 600 "$active_users_file" "$results_file" chmod 700 "$build_log_dir" while IFS= read -r user; do [[ -z "$user" ]] && continue if user_is_expired "$user"; then cleanup_subscription_page "$user" continue fi printf '%s\n' "$user" >> "$active_users_file" done < <(list_subscription_users) jobs=$(subscription_rebuild_worker_count) info "Пересборка подписок: workers=${jobs}" subscription_generate_pages_parallel "$jobs" "$build_log_dir" "$active_users_file" > "$results_file" || true while IFS=$'\t' read -r status user; do [[ -n "$user" ]] || continue result_count=$((result_count + 1)) if [[ "$status" == "OK" ]]; then count=$((count + 1)) else failed=$((failed + 1)) failed_users+=" ${user}" fi done < "$results_file" expected=$(awk 'NF{c++} END{print c+0}' "$active_users_file") if [[ "$result_count" -ne "$expected" ]]; then failed=$((failed + 1)) failed_users+=" internal-worker-result" fi find "$build_log_dir" -xdev -mindepth 1 -type f -delete 2>/dev/null || true rmdir "$build_log_dir" 2>/dev/null || true rm -f -- "$active_users_file" "$results_file" unset SUBSCRIPTION_NODE_LINK_CACHE_DIR if [[ -n "$cache_dir" && -d "$cache_dir" ]]; then find "$cache_dir" -mindepth 1 -maxdepth 1 -type f -delete rmdir "$cache_dir" 2>/dev/null || true fi if [[ "$failed" -gt 0 ]]; then err "Не пересобраны подписки:${failed_users}" err "Успешно: ${count}, ошибок: ${failed}; прежние страницы сохранены" return 1 fi apply_subscription_aliases >/dev/null 2>&1 || { err "Не удалось обновить alias URL"; return 1; } ok "Пересобраны страницы подписки: ${count}" } cmd_subscription_rebuild_clean() { load_config load_users local forbidden="${1:-}" failed=0 item matches cmd_nodes_rebuild_subscriptions || failed=1 cleanup_orphan_subscription_pages if [[ -n "$forbidden" ]]; then forbidden="${forbidden//,/ }" for item in $forbidden; do [[ -z "$item" ]] && continue matches=$(grep -R -n --exclude='*.png' -- "$item" "$SUBS_WEB_DIR" 2>/dev/null | head -n 20 || true) if [[ -n "$matches" ]]; then printf '%s\n' "$matches" err "Запрещённый домен всё ещё найден в подписках: $item" failed=1 else ok "Запрещённый домен не найден: $item" fi done fi cmd_protocol_validate || failed=1 [[ "$failed" -eq 0 ]] } protocol_line_allowed() { local line="${1:-}" [[ -z "$line" || "$line" == \#* ]] && return 0 case "$line" in naive+https://*) return 0 ;; hy2://*|hysteria2://*) [[ "$line" != *"Hop"* && "$line" != *"Hysteria2%20Hop"* ]] return ;; vless://*) if [[ "$line" == *"type=xhttp"* ]]; then [[ "$line" == *"security=tls"* && "$line" == *"path=%2Fxhttp"* && "$line" == *"mode=packet-up"* && "$line" == *"alpn=h2"* && "$line" =~ ^vless://[^@]+@[^/?#]+:[0-9]{1,5}\? ]] return fi [[ "$line" == *"security=reality"* && "$line" == *"type=tcp"* && "$line" =~ ^vless://[^@]+@[^/?#]+:[0-9]{1,5}\? ]] return ;; esac return 1 } protocol_line_forbidden() { local line="${1:-}" [[ -z "$line" || "$line" == \#* ]] && return 1 [[ "$line" == *"type=ws"* ]] && return 0 [[ "$line" == *"type=httpupgrade"* ]] && return 0 [[ "$line" == *"type=kcp"* ]] && return 0 [[ "$line" == *"security=tls"* && "$line" != *"type=xhttp"* ]] && return 0 [[ "$line" == *"VLESS%20Vision"* ]] && return 0 [[ "$line" == *"VLESS%20WebSocket"* ]] && return 0 [[ "$line" == *"VLESS%20HTTPUpgrade"* ]] && return 0 [[ "$line" == *"VLESS%20mKCP"* ]] && return 0 [[ "$line" == *"Hysteria2%20Hop"* || "$line" == *"Hop%20"* ]] && return 0 [[ "$line" == vless://* && ! "$line" =~ ^vless://[^@]+@[^/?#]+:[0-9]{1,5}\? ]] && return 0 return 1 } cmd_protocol_validate() { load_config load_users local failed=0 checked=0 user token file line count bad files files="links.txt hiddify.txt streisand.txt happ.txt nekobox.txt v2rayng.txt" while IFS= read -r user; do [[ -z "$user" ]] && continue if user_is_expired "$user"; then printf 'SKIP %-24s expired\n' "$user" continue fi token=$(cat "${SUBS_DIR}/${user}.token" 2>/dev/null || true) if [[ -z "$token" ]]; then printf 'FAIL %-24s no_token\n' "$user" failed=$((failed + 1)) continue fi for file in $files; do local path="${SUBS_WEB_DIR}/${token}/${file}" if [[ ! -s "$path" ]]; then printf 'FAIL %-24s %s missing\n' "$user" "$file" failed=$((failed + 1)) continue fi count=0 bad=0 while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "$line" || "$line" == \#* ]] && continue count=$((count + 1)) if protocol_line_forbidden "$line" || ! protocol_line_allowed "$line"; then bad=$((bad + 1)) fi done < "$path" checked=$((checked + 1)) if [[ "$bad" -gt 0 ]]; then printf 'FAIL %-24s %s lines=%s bad=%s\n' "$user" "$file" "$count" "$bad" failed=$((failed + 1)) else printf 'OK %-24s %s lines=%s\n' "$user" "$file" "$count" fi done done < <(list_subscription_users) printf 'protocol validate: checked=%s failed=%s\n' "$checked" "$failed" [[ "$failed" -eq 0 ]] } cmd_protocol_benchmark() { load_config load_users local user="${1:-}" rounds="${2:-1}" token links_file tmp_py if [[ -z "$user" ]]; then echo -ne "${CYAN}Пользователь: ${RESET}" read -r user fi if ! is_valid_proxy_user "$user" || ! subscription_user_exists "$user"; then err "Пользователь не найден: ${user:-empty}" return 1 fi is_canonical_uint_in_range "$rounds" 1 5 || rounds=1 generate_subscription_page "$user" >/dev/null 2>&1 || true token=$(cat "${SUBS_DIR}/${user}.token" 2>/dev/null || true) links_file="${SUBS_WEB_DIR}/${token}/links.txt" if [[ -z "$token" || ! -s "$links_file" ]]; then err "links.txt не найден для пользователя: $user" return 1 fi tmp_py=$(mktemp /tmp/yurich-protocol-benchmark-XXXXXX.py) cat > "$tmp_py" <<'PY' import csv import datetime as dt import json import os import re import socket import subprocess import sys import tempfile import time from pathlib import Path from urllib.parse import parse_qs, unquote, urlsplit links_path = Path(sys.argv[1]) rounds = int(sys.argv[2]) lines = [x.strip() for x in links_path.read_text(encoding="utf-8", errors="replace").splitlines() if x.strip() and not x.startswith("#")] csv_path = os.environ.get("YURICH_BENCHMARK_CSV", "").strip() csv_user = os.environ.get("YURICH_BENCHMARK_USER", "").strip() try: slow_threshold_ms = int(os.environ.get("YURICH_BENCHMARK_MAX_AVG_MS", "2500")) except Exception: slow_threshold_ms = 2500 if slow_threshold_ms < 1: slow_threshold_ms = 2500 profiles = [] for line in lines: u = urlsplit(line) qs = parse_qs(u.query, keep_blank_values=True) profiles.append({ "scheme": u.scheme.lower(), "host": u.hostname or "", "port": u.port or 443, "name": unquote(u.fragment or ""), "username": unquote(u.username or ""), "password": unquote(u.password or ""), "qs": {k: v[0] for k, v in qs.items()}, }) def wait_port(port, timeout=8.0): end = time.time() + timeout while time.time() < end: try: with socket.create_connection(("127.0.0.1", port), timeout=0.2): return True except Exception: time.sleep(0.1) return False def allocate_local_port(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] def stop_process(proc): if not proc: return try: proc.terminate() proc.wait(timeout=2) except Exception: try: proc.kill() proc.wait(timeout=1) except Exception: pass def safe_client_log(log_path): try: text = Path(log_path).read_text(encoding="utf-8", errors="replace") except Exception: return "client log unavailable" text = re.sub(r"(?i)(hy2|hysteria2)://[^@\s]+@", r"\1://[redacted]@", text) text = re.sub(r"(?i)\b(auth|password|token)\b(\s*[:=]\s*)\S+", r"\1\2[redacted]", text) lines = [line.strip() for line in text.splitlines() if line.strip()] return " | ".join(lines[-3:])[-240:] if lines else "client exited without log" def start_local_client(profile, socks_port, log_path): scheme = profile["scheme"] if scheme in ("hy2", "hysteria2"): qs = profile["qs"] auth = profile["username"] + ((":" + profile["password"]) if profile["password"] else "") auth = auth or qs.get("auth", "") yaml_string = lambda value: json.dumps(str(value), ensure_ascii=False) server_address = f"{profile['host']}:{profile['port']}" socks_address = f"127.0.0.1:{socks_port}" text = ( f"server: {yaml_string(server_address)}\n" f"auth: {yaml_string(auth)}\n" "tls:\n" f" sni: {yaml_string(qs.get('sni', profile['host']))}\n" " insecure: false\n" "obfs:\n" f" type: {yaml_string(qs.get('obfs', 'salamander'))}\n" " salamander:\n" f" password: {yaml_string(qs.get('obfs-password', ''))}\n" "socks5:\n" f" listen: {yaml_string(socks_address)}\n" ) suffix = ".yaml" command = lambda cfg: ["hysteria", "client", "-c", cfg] elif scheme == "vless": qs = profile["qs"] user = {"id": profile["username"], "encryption": "none"} network = qs.get("type", "tcp") security = qs.get("security", "reality") if qs.get("flow") and network != "xhttp": user["flow"] = qs.get("flow") stream_settings = { "network": network, "security": security, } if network == "xhttp": alpn = [x for x in qs.get("alpn", "h2").split(",") if x] stream_settings["tlsSettings"] = { "serverName": qs.get("sni", profile["host"]), "fingerprint": qs.get("fp", "chrome"), "alpn": alpn or ["h2"], "allowInsecure": False, } stream_settings["xhttpSettings"] = { "path": qs.get("path", "/xhttp"), "mode": qs.get("mode", "packet-up"), "host": qs.get("host", profile["host"]), } else: stream_settings["security"] = "reality" stream_settings["realitySettings"] = { "serverName": qs.get("sni", ""), "fingerprint": qs.get("fp", "chrome"), "publicKey": qs.get("pbk", ""), "shortId": qs.get("sid", ""), "spiderX": qs.get("spx", "/"), } text = json.dumps({ "log": {"loglevel": "warning"}, "inbounds": [{"listen": "127.0.0.1", "port": socks_port, "protocol": "socks", "settings": {"udp": True}}], "outbounds": [{ "protocol": "vless", "settings": {"vnext": [{"address": profile["host"], "port": profile["port"], "users": [user]}]}, "streamSettings": stream_settings, }], }) suffix = ".json" command = lambda cfg: ["xray", "run", "-config", cfg] else: raise ValueError(f"unsupported scheme {scheme}") fd, cfg = tempfile.mkstemp(prefix=f"{scheme}_benchmark_", suffix=suffix) os.close(fd) Path(cfg).write_text(text, encoding="utf-8") log_handle = open(log_path, "w", encoding="utf-8") try: proc = subprocess.Popen(command(cfg), stdout=log_handle, stderr=subprocess.STDOUT) except Exception: log_handle.close() os.remove(cfg) raise return proc, cfg, log_handle def run_curl(cmd, timeout=18): try: r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout) text = (r.stdout or "").strip() ok = r.returncode == 0 and "code=200" in text total = None for item in text.split(): if item.startswith("total="): try: total = float(item.split("=", 1)[1]) except Exception: pass return ok, total, (r.stderr or "").strip()[-120:] except Exception as exc: return False, None, repr(exc)[-120:] def curl_socks(port): return run_curl([ "curl", "-sS", "--max-time", "15", "--socks5-hostname", f"127.0.0.1:{port}", "-o", "/dev/null", "-w", "code=%{http_code} total=%{time_total}", "https://www.cloudflare.com/cdn-cgi/trace", ]) def curl_naive(profile): auth = profile["username"] + (( ":" + profile["password"]) if profile["password"] else "") return run_curl([ "curl", "-k", "--proxy-insecure", "-sS", "--max-time", "15", "--proxy", f"https://{profile['host']}:{profile['port']}", "--proxy-user", auth, "-o", "/dev/null", "-w", "code=%{http_code} total=%{time_total}", "https://www.cloudflare.com/cdn-cgi/trace", ]) def percentile(values, pct): if not values: return None ordered = sorted(values) if len(ordered) == 1: return ordered[0] pos = (len(ordered) - 1) * pct lower = int(pos) upper = min(lower + 1, len(ordered) - 1) weight = pos - lower return ordered[lower] * (1 - weight) + ordered[upper] * weight def fmt_seconds(value): return f"{value:.3f}s" if value is not None else "-" def bench_profile(index, profile): scheme = profile["scheme"] proc = None cfg = None log_handle = None log_path = None try: if scheme == "naive+https": values = [] errors = [] for _ in range(rounds): ok, total, err = curl_naive(profile) if ok and total is not None: values.append(total) elif err: errors.append(err) return values, errors startup_attempts = 2 if scheme in ("hy2", "hysteria2") else 1 startup_errors = [] socks_port = None for attempt in range(1, startup_attempts + 1): socks_port = allocate_local_port() log_path = f"/tmp/yurich-benchmark-{os.getpid()}-{index}-{attempt}.log" try: proc, cfg, log_handle = start_local_client(profile, socks_port, log_path) except Exception as exc: startup_errors.append(f"client start failed ({attempt}/{startup_attempts}): {exc!r}"[-240:]) else: if wait_port(socks_port): break stop_process(proc) proc = None if log_handle: log_handle.close() log_handle = None startup_errors.append( f"local socks not ready ({attempt}/{startup_attempts}): {safe_client_log(log_path)}" ) if cfg: try: os.remove(cfg) except Exception: pass cfg = None if log_path: try: os.remove(log_path) except Exception: pass log_path = None if attempt < startup_attempts: time.sleep(0.5) else: return [], [startup_errors[-1] if startup_errors else "local socks not ready"] values = [] errors = [] for _ in range(rounds): ok, total, err = curl_socks(socks_port) if ok and total is not None: values.append(total) elif err: errors.append(err) return values, errors finally: stop_process(proc) if log_handle: try: log_handle.close() except Exception: pass if cfg: try: os.remove(cfg) except Exception: pass if log_path: try: os.remove(log_path) except Exception: pass print(f"{'STATUS':<6} {'PROTO':<12} {'HOST':<22} {'OK':<5} {'BEST':>8} {'AVG':>8} {'MEDIAN':>8} {'P95':>8} {'WORST':>8} {'SLOW':>7} NAME") print("-" * 128) failed = 0 csv_rows = [] now_iso = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() try: load1 = round(os.getloadavg()[0], 2) except (AttributeError, OSError): load1 = "" try: meminfo = {} for item in Path('/proc/meminfo').read_text().splitlines(): key, value = item.split(':', 1) meminfo[key] = int(value.strip().split()[0]) mem_total = meminfo.get('MemTotal', 0) mem_available = meminfo.get('MemAvailable', 0) mem_used_pct = round((mem_total - mem_available) * 100 / mem_total, 1) if mem_total else "" except (OSError, ValueError): mem_used_pct = "" for index, profile in enumerate(profiles): values, errors = bench_profile(index, profile) ok_count = len(values) status = "OK" if ok_count == rounds else ("WARN" if ok_count else "FAIL") if status == "FAIL": failed += 1 best_value = min(values) if values else None avg_value = (sum(values) / len(values)) if values else None median_value = percentile(values, 0.50) if values else None p95_value = percentile(values, 0.95) if values else None worst_value = max(values) if values else None jitter_value = (sum(abs(values[i] - values[i - 1]) for i in range(1, len(values))) / (len(values) - 1)) if len(values) > 1 else 0 if values else None loss_pct = round((rounds - ok_count) * 100 / rounds, 1) slow_count = sum(1 for value in values if int(value * 1000) > slow_threshold_ms) slow_label = f"slow={slow_count}/{ok_count}" if values else f"slow=0/{rounds}" best = fmt_seconds(best_value) avg = fmt_seconds(avg_value) median = fmt_seconds(median_value) p95 = fmt_seconds(p95_value) worst = fmt_seconds(worst_value) name = profile["name"][:48] print(f"{status:<6} {profile['scheme']:<12} {profile['host']:<22} {ok_count}/{rounds:<3} {best:>8} {avg:>8} {median:>8} {p95:>8} {worst:>8} {slow_label:>7} {name}") csv_rows.append({ "ts": now_iso, "user": csv_user, "status": status, "scheme": profile["scheme"], "host": profile["host"], "ok": ok_count, "rounds": rounds, "best_ms": int(best_value * 1000) if best_value is not None else "", "avg_ms": int(avg_value * 1000) if avg_value is not None else "", "median_ms": int(median_value * 1000) if median_value is not None else "", "p95_ms": int(p95_value * 1000) if p95_value is not None else "", "worst_ms": int(worst_value * 1000) if worst_value is not None else "", "jitter_ms": int(jitter_value * 1000) if jitter_value is not None else "", "loss_pct": loss_pct, "load1": load1, "mem_used_pct": mem_used_pct, "slow_threshold_ms": slow_threshold_ms, "slow_count": slow_count if values else "", "slow_total": ok_count if values else "", "name": profile["name"], }) if not values and errors: print(f" error: {errors[-1]}") if csv_path and csv_rows: path = Path(csv_path) path.parent.mkdir(parents=True, exist_ok=True) fieldnames = list(csv_rows[0].keys()) new_file = not path.exists() or path.stat().st_size == 0 if not new_file: try: with path.open("r", newline="", encoding="utf-8", errors="replace") as f: header = next(csv.reader(f), []) if header != fieldnames: backup = path.with_name(f"{path.name}.{dt.datetime.now().strftime('%Y%m%d%H%M%S')}.bak") path.replace(backup) new_file = True except Exception: new_file = True with path.open("a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if new_file: writer.writeheader() writer.writerows(csv_rows) print("-" * 128) print(f"protocol benchmark: checked={len(profiles)} failed={failed} rounds={rounds}") sys.exit(0 if failed == 0 else 1) PY echo -e "${BOLD}Protocol benchmark:${RESET} user=${user} rounds=${rounds}" local rc=0 python3 "$tmp_py" "$links_file" "$rounds" || rc=$? rm -f "$tmp_py" 2>/dev/null || true return "$rc" } protocol_benchmark_default_user() { local configured="${PROTOCOL_BENCHMARK_USER:-}" first_user if [[ -n "$configured" ]] && is_valid_proxy_user "$configured" && subscription_user_exists "$configured"; then printf '%s\n' "$configured" return 0 fi first_user=$(list_subscription_users | head -n1 || true) [[ -n "$first_user" ]] || return 1 printf '%s\n' "$first_user" } monitor_issue_fingerprint() { sed -E \ -e $'s/\033\\[[0-9;]*[[:alpha:]]//g' \ -e 's/(avg=)[0-9]+ms/\1/g' \ -e 's/[0-9]+([.][0-9]+)?ms//g' \ -e 's/[0-9]+([.][0-9]+)?s//g' \ -e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:]+([+-][0-9:]+|Z)?//g' \ -e '/^[[:space:]]*$/d' \ | LC_ALL=C sort \ | sha256sum \ | awk '{print $1}' } cmd_protocol_benchmark_monitor() { load_config load_users if [[ -e "${YURICH_MAINTENANCE_FILE:-/run/yurich-maintenance}" ]]; then info "Protocol benchmark пропущен: включён maintenance mode" return 0 fi local user="${1:-}" rounds="${2:-${PROTOCOL_BENCHMARK_ROUNDS:-3}}" max_ms="${PROTOCOL_BENCHMARK_MAX_AVG_MS:-2500}" local min_rounds="${PROTOCOL_BENCHMARK_MONITOR_MIN_ROUNDS:-3}" slow_min_hits="${PROTOCOL_BENCHMARK_SLOW_MIN_HITS:-2}" local warn_min_ok="${PROTOCOL_BENCHMARK_WARN_MIN_OK_HITS:-2}" local alert_repeat="${PROTOCOL_BENCHMARK_ALERT_REPEAT:-3}" local hard_alert_repeat="${PROTOCOL_BENCHMARK_HARD_ALERT_REPEAT:-3}" local alert_cooldown_minutes="${PROTOCOL_BENCHMARK_ALERT_COOLDOWN_MINUTES:-60}" local alert_state="${PROTOCOL_BENCHMARK_ALERT_STATE:-$CONFIG_DIR/protocol-benchmark-alert.state}" local recovery_alert="${PROTOCOL_BENCHMARK_RECOVERY_ALERT:-1}" local log_file="${PROTOCOL_BENCHMARK_LOG:-$PROTOCOL_BENCHMARK_LOG_DEFAULT}" tmp rc=0 issues safe_issues safe_tail safe_alert_note local alert_class prev_class="" prev_count=0 prev_sent=0 prev_fingerprint="" prev_recovered=0 issue_count=1 now_ts cooldown_sec should_alert=0 hard_alert=0 last_sent=0 local fingerprint state_line state_a state_b state_c state_d state_e state_f issue_changed=0 if [[ -z "$user" ]]; then user=$(protocol_benchmark_default_user) || { err "Нет пользователя для benchmark monitor"; return 1; } fi is_canonical_uint_in_range "$rounds" 1 5 || rounds=1 is_canonical_uint_in_range "$min_rounds" 1 5 || min_rounds=3 (( rounds < min_rounds )) && rounds="$min_rounds" is_canonical_uint_in_range "$max_ms" 100 600000 || max_ms=2500 is_canonical_uint_in_range "$slow_min_hits" 1 5 || slow_min_hits=2 (( slow_min_hits > rounds )) && slow_min_hits="$rounds" is_canonical_uint_in_range "$warn_min_ok" 1 5 || warn_min_ok=2 (( warn_min_ok > rounds )) && warn_min_ok="$rounds" is_canonical_uint_in_range "$alert_repeat" 1 10 || alert_repeat=3 is_canonical_uint_in_range "$hard_alert_repeat" 1 10 || hard_alert_repeat=3 is_canonical_uint_in_range "$alert_cooldown_minutes" 5 1440 || alert_cooldown_minutes=60 tmp=$(mktemp /tmp/yurich-protocol-benchmark-monitor-XXXXXX.out) if YURICH_BENCHMARK_CSV="$log_file" YURICH_BENCHMARK_USER="$user" YURICH_BENCHMARK_MAX_AVG_MS="$max_ms" cmd_protocol_benchmark "$user" "$rounds" > "$tmp" 2>&1; then rc=0 else rc=$? fi cat "$tmp" issues=$(awk -v max="$max_ms" -v min_hits="$slow_min_hits" -v warn_min_ok="$warn_min_ok" ' /^(OK|WARN|FAIL)[[:space:]]/ { status=$1; proto=$2; host=$3; avg=$6; gsub(/s/, "", avg) ok_seen=0; ok_total=0 if ($4 ~ /^[0-9]+\/[0-9]+$/) { split($4, ok_parts, "/") ok_seen=ok_parts[1] + 0 ok_total=ok_parts[2] + 0 } ms=(avg == "-" ? 0 : int(avg * 1000)) slow_seen=-1; slow_total=0 for (i=1; i<=NF; i++) { if ($i ~ /^slow=[0-9]+\/[0-9]+$/) { split(substr($i, 6), parts, "/") slow_seen=parts[1] + 0 slow_total=parts[2] + 0 } } if (status == "FAIL" || (status == "WARN" && ok_seen < warn_min_ok)) { printf "%s %s %s ok=%s/%s\n", status, proto, host, ok_seen, ok_total } else if (slow_seen >= min_hits) { printf "SLOW %s %s slow=%s/%s threshold=%sms avg=%sms\n", proto, host, slow_seen, slow_total, max, ms } else if (slow_seen < 0 && ms > max) { printf "SLOW %s %s avg=%sms\n", proto, host, ms } } ' "$tmp" || true) if [[ "$rc" -ne 0 || -n "$issues" ]]; then [[ "$rc" -ne 0 ]] && hard_alert=1 if grep -q '^FAIL[[:space:]]' <<<"${issues:-}"; then hard_alert=1 fi alert_class="SOFT" [[ "$hard_alert" -eq 1 ]] && alert_class="HARD" fingerprint=$(printf 'rc=%s\n%s\n' "$rc" "${issues:-benchmark failed}" | monitor_issue_fingerprint) if [[ -s "$alert_state" ]]; then state_line=$(head -n1 "$alert_state" 2>/dev/null || true) IFS='|' read -r state_a state_b state_c state_d state_e state_f <<< "$state_line" || true if [[ "$state_a" == "PROTOBM1" ]]; then prev_class="$state_b" prev_count="$state_c" prev_sent="$state_d" prev_fingerprint="$state_e" prev_recovered="$state_f" else prev_class="$state_a" prev_count="$state_b" prev_sent="$state_c" prev_fingerprint="" prev_recovered=0 fi fi is_canonical_uint_in_range "$prev_count" 0 1000000000 || prev_count=0 is_canonical_uint_in_range "$prev_sent" 0 9999999999 || prev_sent=0 is_canonical_uint_in_range "$prev_recovered" 0 9999999999 || prev_recovered=0 if [[ "$prev_fingerprint" != "$fingerprint" ]]; then issue_changed=1 fi if [[ "$prev_class" == "$alert_class" && "$prev_fingerprint" == "$fingerprint" ]]; then issue_count=$((prev_count + 1)) else issue_count=1 fi now_ts=$(date +%s) cooldown_sec=$((alert_cooldown_minutes * 60)) if [[ "$hard_alert" -eq 1 ]]; then if (( issue_count >= hard_alert_repeat && (prev_sent == 0 || now_ts - prev_sent >= cooldown_sec) )); then should_alert=1 fi elif (( issue_count >= alert_repeat && (prev_sent == 0 || now_ts - prev_sent >= cooldown_sec) )); then should_alert=1 fi last_sent="$prev_sent" if [[ "$should_alert" -eq 1 ]]; then last_sent="$now_ts" fi mkdir -p "$CONFIG_DIR" 2>/dev/null || true { printf 'PROTOBM1|%s|%s|%s|%s|0\n' "$alert_class" "$issue_count" "$last_sent" "$fingerprint" > "$alert_state" && chmod 600 "$alert_state"; } 2>/dev/null || true if [[ "$should_alert" -ne 1 ]]; then if [[ "$hard_alert" -eq 1 ]]; then info "Benchmark alert suppressed: hard repeat=${issue_count}/${hard_alert_repeat}, same_issue=$((1 - issue_changed)), cooldown=${alert_cooldown_minutes}m" else info "Benchmark alert suppressed: soft repeat=${issue_count}/${alert_repeat}, same_issue=$((1 - issue_changed)), cooldown=${alert_cooldown_minutes}m" fi rm -f "$tmp" return "$rc" fi safe_issues=$(html_escape_text "${issues:-benchmark failed}") safe_tail=$(html_escape_text "$(tail -n 18 "$tmp")") safe_alert_note=$(html_escape_text "Антиспам: ${alert_class}, повтор ${issue_count}, cooldown ${alert_cooldown_minutes} мин.") tg_send "⚠️ Yurich Connect protocol benchmark 📡 Сервер: $(hostname) 👤 Пользователь: $(html_escape_text "$user") 📈 Порог: ${max_ms} ms, ${slow_min_hits}/${rounds} медленных попыток 🔕 ${safe_alert_note} 🕐 $(date '+%Y-%m-%d %H:%M:%S') Проблемы:
${safe_issues}
Последний тест:
${safe_tail}
" else if [[ -s "$alert_state" && "$recovery_alert" == "1" ]]; then state_line=$(head -n1 "$alert_state" 2>/dev/null || true) IFS='|' read -r state_a state_b state_c state_d state_e state_f <<< "$state_line" || true if [[ "$state_a" == "PROTOBM1" ]]; then prev_class="$state_b"; prev_count="$state_c"; prev_sent="$state_d" else prev_class="$state_a"; prev_count="$state_b"; prev_sent="$state_c" fi is_canonical_uint_in_range "$prev_sent" 0 9999999999 || prev_sent=0 if (( prev_sent > 0 )); then tg_send "✅ Yurich Connect protocol benchmark восстановлен 📡 Сервер: $(hostname) 👤 Пользователь: $(html_escape_text "$user") 🕐 $(date '+%Y-%m-%d %H:%M:%S')
Проблемы больше не повторяются. Последний тест прошёл без FAIL/SLOW по заданным порогам.
" fi fi rm -f "$alert_state" 2>/dev/null || true fi rm -f "$tmp" return "$rc" } cmd_protocol_benchmark_history() { load_config 2>/dev/null || true local log_file="${PROTOCOL_BENCHMARK_LOG:-$PROTOCOL_BENCHMARK_LOG_DEFAULT}" rows="${1:-30}" is_canonical_uint_in_range "$rows" 1 200 || rows=30 if [[ ! -s "$log_file" ]]; then warn "История benchmark пока пуста: $log_file" return 0 fi python3 - "$log_file" "$rows" <<'PY' import csv import sys from collections import defaultdict path, limit = sys.argv[1], int(sys.argv[2]) with open(path, "r", encoding="utf-8", errors="replace") as f: rows = list(csv.DictReader(f)) rows = rows[-limit:] print(f"{'TIME':<20} {'STATUS':<6} {'PROTO':<12} {'HOST':<22} {'AVG':>7} {'JITTER':>8} {'LOSS':>7} {'P95':>7} {'SLOW':>9} NAME") print("-" * 132) for r in rows: ts = (r.get("ts") or "")[:19].replace("T", " ") avg = (r.get("avg_ms") or "-") avg = f"{avg}ms" if avg != "-" else "-" jitter = (r.get("jitter_ms") or "-") jitter = f"{jitter}ms" if jitter != "-" else "-" loss = (r.get("loss_pct") or "-") loss = f"{loss}%" if loss != "-" else "-" p95 = (r.get("p95_ms") or "-") p95 = f"{p95}ms" if p95 != "-" else "-" slow_count = r.get("slow_count") or "" slow_total = r.get("slow_total") or r.get("ok") or r.get("rounds") or "" slow = f"{slow_count}/{slow_total}" if slow_count and slow_total else "-" print(f"{ts:<20} {r.get('status',''):<6} {r.get('scheme',''):<12} {r.get('host',''):<22} {avg:>7} {jitter:>8} {loss:>7} {p95:>7} {slow:>9} {(r.get('name') or '')[:38]}") stats = defaultdict(lambda: {"total": 0, "fail": 0, "avg_sum": 0, "avg_count": 0, "jitter_sum": 0, "jitter_count": 0, "loss_sum": 0}) for r in rows: key = (r.get("scheme", ""), r.get("host", "")) item = stats[key] item["total"] += 1 if r.get("status") == "FAIL": item["fail"] += 1 try: item["avg_sum"] += int(r.get("avg_ms") or 0) item["avg_count"] += 1 except Exception: pass try: item["jitter_sum"] += int(r.get("jitter_ms") or 0) item["jitter_count"] += 1 except Exception: pass try: item["loss_sum"] += float(r.get("loss_pct") or 0) except Exception: pass if stats: print("-" * 132) print("Summary:") for (scheme, host), item in sorted(stats.items(), key=lambda x: (x[1]["fail"], (x[1]["avg_sum"] / max(x[1]["avg_count"], 1)))): avg = int(item["avg_sum"] / max(item["avg_count"], 1)) if item["avg_count"] else 0 jitter = int(item["jitter_sum"] / max(item["jitter_count"], 1)) if item["jitter_count"] else 0 loss = round(item["loss_sum"] / max(item["total"], 1), 1) print(f"{scheme:<12} {host:<22} fail={item['fail']}/{item['total']} avg={avg}ms jitter={jitter}ms loss={loss}%") PY } cmd_protocol_stability_report() { load_config 2>/dev/null || true local log_file="${PROTOCOL_BENCHMARK_LOG:-$PROTOCOL_BENCHMARK_LOG_DEFAULT}" hours="${1:-24}" is_canonical_uint_in_range "$hours" 1 168 || hours=24 if [[ ! -s "$log_file" ]]; then warn "История benchmark пока пуста: $log_file" return 0 fi python3 - "$log_file" "$hours" <<'PY' import csv import datetime as dt import statistics import sys from collections import defaultdict path, hours = sys.argv[1], int(sys.argv[2]) cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours) groups = defaultdict(list) load_values = [] mem_values = [] with open(path, "r", encoding="utf-8", errors="replace") as f: for row in csv.DictReader(f): try: stamp = dt.datetime.fromisoformat((row.get("ts") or "").replace("Z", "+00:00")) if stamp.tzinfo is None: stamp = stamp.replace(tzinfo=dt.timezone.utc) except ValueError: continue if stamp < cutoff: continue groups[(row.get("scheme", ""), row.get("host", ""))].append(row) try: load_values.append(float(row.get("load1") or "")) except ValueError: pass try: mem_values.append(float(row.get("mem_used_pct") or "")) except ValueError: pass def nums(rows, key): result = [] for row in rows: try: result.append(float(row.get(key) or "")) except ValueError: pass return result print(f"Yurich stability report: last {hours}h") print(f"{'PROTO':<12} {'HOST':<24} {'RUNS':>5} {'FAIL':>6} {'WARN':>6} {'AVG':>8} {'JITTER':>9} {'LOSS':>8} {'P95 MAX':>9}") print("-" * 100) for (scheme, host), rows in sorted(groups.items()): avg = nums(rows, "avg_ms") jitter = nums(rows, "jitter_ms") loss = nums(rows, "loss_pct") p95 = nums(rows, "p95_ms") fails = sum(1 for row in rows if row.get("status") == "FAIL") warns = sum(1 for row in rows if row.get("status") == "WARN") fmt = lambda values, suffix: f"{statistics.fmean(values):.0f}{suffix}" if values else "-" p95_max = f"{max(p95):.0f}ms" if p95 else "-" print(f"{scheme:<12} {host:<24} {len(rows):>5} {fails:>6} {warns:>6} {fmt(avg, 'ms'):>8} {fmt(jitter, 'ms'):>9} {fmt(loss, '%'):>8} {p95_max:>9}") print("-" * 100) print(f"rows={sum(map(len, groups.values()))} max_load1={max(load_values) if load_values else '-'} max_mem={str(max(mem_values)) + '%' if mem_values else '-'}") PY } normalize_monitor_noise_settings() { local spec key minimum current for spec in \ PROTOCOL_BENCHMARK_ALERT_REPEAT:3 \ PROTOCOL_BENCHMARK_HARD_ALERT_REPEAT:3 \ PROTOCOL_BENCHMARK_ALERT_COOLDOWN_MINUTES:60 \ PROTOCOL_MONITOR_ALERT_REPEAT:3 \ PROTOCOL_MONITOR_ALERT_COOLDOWN_MINUTES:60 \ DNS_MONITOR_ALERT_REPEAT:3 \ DNS_MONITOR_ALERT_COOLDOWN_MINUTES:180; do key="${spec%%:*}" minimum="${spec##*:}" current="${!key:-}" if ! [[ "$current" =~ ^[0-9]+$ ]] || (( 10#$current < minimum )); then printf -v "$key" '%s' "$minimum" fi done } cmd_protocol_benchmark_install() { load_config load_users normalize_monitor_noise_settings local user="${1:-${PROTOCOL_BENCHMARK_USER:-}}" rounds="${2:-${PROTOCOL_BENCHMARK_ROUNDS:-3}}" script_path="${SCRIPT_PATH:-/usr/local/bin/yurich-panel.sh}" if [[ -z "$user" ]]; then user=$(protocol_benchmark_default_user) || { err "Нет пользователя для benchmark monitor"; return 1; } fi if ! is_valid_proxy_user "$user" || ! subscription_user_exists "$user"; then err "Пользователь не найден: $user" return 1 fi is_canonical_uint_in_range "$rounds" 1 5 || rounds=1 (( rounds < 3 )) && rounds=3 PROTOCOL_BENCHMARK_USER="$user" PROTOCOL_BENCHMARK_ROUNDS="$rounds" PROTOCOL_BENCHMARK_LOG="${PROTOCOL_BENCHMARK_LOG:-$PROTOCOL_BENCHMARK_LOG_DEFAULT}" save_config cat > "$PROTOCOL_BENCHMARK_CRON" <> /var/log/yurich-protocol-benchmark-monitor.log 2>&1 EOF chmod 644 "$PROTOCOL_BENCHMARK_CRON" touch /var/log/yurich-protocol-benchmark-monitor.log "$PROTOCOL_BENCHMARK_LOG" chmod 600 /var/log/yurich-protocol-benchmark-monitor.log "$PROTOCOL_BENCHMARK_LOG" ok "Benchmark monitor включён: $PROTOCOL_BENCHMARK_CRON" ok "История: $PROTOCOL_BENCHMARK_LOG" } cmd_protocol_health() { load_config load_users local failed=0 reality_target="${XRAY_REALITY_TARGET:-www.microsoft.com:443}" systemctl is-active --quiet caddy 2>/dev/null && ok "HTTPS/Caddy active" || { err "HTTPS/Caddy не active"; failed=$((failed + 1)); } if [[ -x "$CADDY_BIN" && -f "$CADDYFILE" ]]; then "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1 && ok "Caddyfile valid" || { err "Caddyfile invalid"; failed=$((failed + 1)); } fi if [[ "${HYSTERIA_ENABLED:-1}" != "1" ]]; then if systemctl is-active --quiet hysteria 2>/dev/null; then err "Turbo/Hysteria активна при отключённой роли" failed=$((failed + 1)) else ok "Turbo/Hysteria штатно отключена для роли сервера" fi elif [[ -x "$HYSTERIA_BIN" && -f "$HYSTERIA_CONFIG" ]]; then validate_hysteria_config "$HYSTERIA_CONFIG" >/dev/null 2>&1 && ok "Turbo/Hysteria config valid" || { err "Turbo/Hysteria config invalid"; failed=$((failed + 1)); } systemctl is-active --quiet hysteria 2>/dev/null && ok "Turbo/Hysteria active" || { err "Turbo/Hysteria не active"; failed=$((failed + 1)); } else warn "Turbo/Hysteria не установлен" fi if [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then "$XRAY_BIN" run -test -config "$XRAY_CONFIG" >/dev/null 2>&1 && ok "Reality/Xray config valid" || { err "Reality/Xray config invalid"; failed=$((failed + 1)); } systemctl is-active --quiet xray 2>/dev/null && ok "Reality/Xray active" || { err "Reality/Xray не active"; failed=$((failed + 1)); } if grep -Eq 'vless-(vision|ws|httpupgrade|mkcp)|"network": "(ws|httpupgrade|kcp)"' "$XRAY_CONFIG" 2>/dev/null; then err "Reality/Xray содержит удалённые транспорты" failed=$((failed + 1)) else ok "Reality/Xray transport set allowed" fi if [[ "${XRAY_REALITY_ENABLED:-1}" != "1" ]]; then ok "Xray role: Reality disabled" elif test_reality_target_tls "$reality_target" >/dev/null 2>&1; then ok "Reality target reachable: $reality_target" else warn "Reality target не прошёл TLS test: $reality_target" fi else warn "Reality/Xray не установлен" fi if edge_routing_mode_is_haproxy; then systemctl is-active --quiet haproxy 2>/dev/null && ok "HAProxy active" || { err "HAProxy не active"; failed=$((failed + 1)); } [[ -f "$HAPROXY_CFG" ]] && haproxy -c -f "$HAPROXY_CFG" >/dev/null 2>&1 && ok "HAProxy config valid" || { err "HAProxy config invalid"; failed=$((failed + 1)); } if haproxy_backends_healthy >/tmp/yurich_haproxy_backend_health.out 2>&1; then ok "$(cat /tmp/yurich_haproxy_backend_health.out)" else err "HAProxy backend problem" cat /tmp/yurich_haproxy_backend_health.out 2>/dev/null || true failed=$((failed + 1)) fi rm -f /tmp/yurich_haproxy_backend_health.out fi if [[ "${HYSTERIA_WARP_ENABLED:-0}" == "1" || "${WARP_PROXY_ENABLED:-0}" == "1" || "${WARP_MODE:-off}" != "off" ]]; then if cmd_warp_test >/tmp/yurich_warp_health.out 2>&1; then ok "WARP health OK" else err "WARP health failed" cat /tmp/yurich_warp_health.out 2>/dev/null || true failed=$((failed + 1)) fi rm -f /tmp/yurich_warp_health.out fi if cmd_protocol_validate >/tmp/yurich_protocol_validate.out 2>&1; then ok "Подписки содержат только HTTPS/Turbo/Reality/XHTTP" else err "Подписки содержат ошибки" cat /tmp/yurich_protocol_validate.out 2>/dev/null || true failed=$((failed + 1)) fi rm -f /tmp/yurich_protocol_validate.out [[ "$failed" -eq 0 ]] } cmd_protocol_monitor() { load_config if [[ -e "${YURICH_MAINTENANCE_FILE:-/run/yurich-maintenance}" ]]; then info "Protocol monitor пропущен: включён maintenance mode" return 0 fi local tmp rc alert_state="${PROTOCOL_MONITOR_ALERT_STATE:-$CONFIG_DIR/protocol-health-alert.state}" local alert_cooldown_minutes="${PROTOCOL_MONITOR_ALERT_COOLDOWN_MINUTES:-60}" recovery_alert="${PROTOCOL_MONITOR_RECOVERY_ALERT:-1}" local alert_repeat="${PROTOCOL_MONITOR_ALERT_REPEAT:-3}" local cooldown_sec now_ts fingerprint state_line state_a state_b state_c state_d state_e local prev_count=0 prev_sent=0 prev_fingerprint="" issue_count=1 issue_changed=0 should_alert=0 last_sent=0 is_canonical_uint_in_range "$alert_cooldown_minutes" 5 1440 || alert_cooldown_minutes=60 is_canonical_uint_in_range "$alert_repeat" 1 10 || alert_repeat=3 tmp=$(mktemp) if cmd_protocol_health > "$tmp" 2>&1; then rc=0 else rc=$? fi if [[ "$rc" -ne 0 ]]; then fingerprint=$(monitor_issue_fingerprint < "$tmp") if [[ -s "$alert_state" ]]; then state_line=$(head -n1 "$alert_state" 2>/dev/null || true) IFS='|' read -r state_a state_b state_c state_d state_e <<< "$state_line" || true if [[ "$state_a" == "PROTOHEALTH1" ]]; then prev_count="$state_b" prev_sent="$state_c" prev_fingerprint="$state_d" else prev_count="$state_b" prev_sent="$state_c" prev_fingerprint="" fi fi is_canonical_uint_in_range "$prev_count" 0 1000000000 || prev_count=0 is_canonical_uint_in_range "$prev_sent" 0 9999999999 || prev_sent=0 if [[ "$prev_fingerprint" != "$fingerprint" ]]; then issue_changed=1 fi if [[ "$prev_fingerprint" == "$fingerprint" ]]; then issue_count=$((prev_count + 1)) else issue_count=1 fi now_ts=$(date +%s) cooldown_sec=$((alert_cooldown_minutes * 60)) if (( issue_count >= alert_repeat && (prev_sent == 0 || now_ts - prev_sent >= cooldown_sec) )); then should_alert=1 fi last_sent="$prev_sent" [[ "$should_alert" -eq 1 ]] && last_sent="$now_ts" mkdir -p "$CONFIG_DIR" 2>/dev/null || true { printf 'PROTOHEALTH1|%s|%s|%s|0\n' "$issue_count" "$last_sent" "$fingerprint" > "$alert_state" && chmod 600 "$alert_state"; } 2>/dev/null || true if [[ "$should_alert" -eq 1 ]]; then tg_send "⚠️ Yurich Connect protocol health 📡 Сервер: $(hostname) 🌐 Домен: ${DOMAIN:-unknown} 🔕 Антиспам: повтор ${issue_count}, cooldown ${alert_cooldown_minutes} мин. 🕐 $(date '+%Y-%m-%d %H:%M:%S')
$(tail -n 30 "$tmp" | sed 's/&/\&/g; s//\>/g')
" else info "Protocol health alert suppressed: repeat=${issue_count}/${alert_repeat}, same_issue=$((1 - issue_changed)), cooldown=${alert_cooldown_minutes}m" fi else if [[ -s "$alert_state" && "$recovery_alert" == "1" ]]; then state_line=$(head -n1 "$alert_state" 2>/dev/null || true) IFS='|' read -r state_a state_b state_c state_d state_e <<< "$state_line" || true if [[ "$state_a" == "PROTOHEALTH1" ]]; then prev_sent="$state_c" else prev_sent="$state_c" fi is_canonical_uint_in_range "$prev_sent" 0 9999999999 || prev_sent=0 if (( prev_sent > 0 )); then tg_send "✅ Yurich Connect protocol health восстановлен 📡 Сервер: $(hostname) 🌐 Домен: ${DOMAIN:-unknown} 🕐 $(date '+%Y-%m-%d %H:%M:%S')
Health-check снова проходит без ошибок.
" fi fi rm -f "$alert_state" 2>/dev/null || true fi cat "$tmp" rm -f "$tmp" return "$rc" } cmd_protocol_monitor_install() { load_config normalize_monitor_noise_settings save_config local script_path="${SCRIPT_PATH:-/usr/local/bin/yurich-panel.sh}" cron_file="/etc/cron.d/yurich-protocol-monitor" log_file="/var/log/yurich-protocol-monitor.log" cat > "$cron_file" <> ${log_file} 2>&1 EOF chmod 644 "$cron_file" touch "$log_file" chmod 600 "$log_file" ok "Protocol monitor включён: $cron_file" } cmd_nodes_menu() { while true; do hr echo -e "${BOLD} Multi-server nodes${RESET}" hr echo -e " ${BOLD}1)${RESET} Список серверов" echo -e " ${BOLD}2)${RESET} Добавить / изменить сервер" echo -e " ${BOLD}3)${RESET} Проверить SSH/status" echo -e " ${BOLD}4)${RESET} Установить / обновить скрипт на node" echo -e " ${BOLD}5)${RESET} Синхронизировать пользователей на node" echo -e " ${BOLD}6)${RESET} Пересобрать подписки с node-ссылками" echo -e " ${BOLD}7)${RESET} Удалить сервер из реестра" echo -e " ${BOLD}0)${RESET} Назад" hr echo -e " Registry: ${NODES_FILE} | Nodes: $(nodes_count)" echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_nodes_list ;; 2) cmd_nodes_add ;; 3) echo -ne "${CYAN}Node name (Enter = all): ${RESET}"; read -r n; cmd_nodes_test "${n:-all}" ;; 4) cmd_nodes_deploy_script ;; 5) echo -ne "${CYAN}Node name (Enter = all): ${RESET}"; read -r n; cmd_nodes_sync_users "${n:-all}" ;; 6) cmd_nodes_rebuild_subscriptions ;; 7) cmd_nodes_remove ;; 0) break ;; *) warn "Неверный выбор" ;; esac echo -ne "${YELLOW}Enter для продолжения...${RESET}" read -r done } cmd_production_tools_menu() { while true; do hr echo -e "${BOLD} Production tools${RESET}" hr echo -e " ${BOLD}1)${RESET} Health-check всего стека" echo -e " ${BOLD}2)${RESET} Safe apply configs" echo -e " ${BOLD}3)${RESET} Encrypted backup /etc/naiveproxy" echo -e " ${BOLD}4)${RESET} Export users + subscriptions" echo -e " ${BOLD}5)${RESET} Import users + subscriptions" echo -e " ${BOLD}6)${RESET} Bridge builder" echo -e " ${BOLD}7)${RESET} Setup / refresh Fail2Ban" echo -e " ${BOLD}8)${RESET} Отчёт стабильности за 24 часа" echo -e " ${BOLD}0)${RESET} Назад" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_health_check ;; 2) cmd_safe_apply ;; 3) cmd_backup_encrypted ;; 4) cmd_export_state ;; 5) cmd_import_state ;; 6) cmd_bridge_menu ;; 7) setup_fail2ban "$(current_ssh_port)" ;; 8) cmd_protocol_stability_report 24 ;; 0) break ;; *) warn "Неверный выбор" ;; esac echo -ne "${YELLOW}Enter для продолжения...${RESET}" read -r done } # ─── Клиентский конфиг ─────────────────────────────────────── yurich_dns_client_ip() { if [[ "${UNBOUND_VPN_ENABLED:-0}" == "1" && -n "${UNBOUND_GATEWAY_IP:-}" ]]; then printf '%s\n' "$UNBOUND_GATEWAY_IP" fi } uri_fragment_encode() { local value="${1:-}" if command -v python3 >/dev/null 2>&1; then VALUE="$value" python3 - <<'PY' import os import urllib.parse print(urllib.parse.quote(os.environ.get("VALUE", "").strip(), safe="-._~")) PY else printf '%s' "$value" | tr ' ' '-' | sed 's/[^A-Za-z0-9._~-]/-/g' fi } uri_query_encode() { local value="${1:-}" if command -v python3 >/dev/null 2>&1; then VALUE="$value" python3 - <<'PY' import os import urllib.parse print(urllib.parse.quote(os.environ.get("VALUE", "").strip(), safe="-._~")) PY else printf '%s' "$value" | sed 's/%/%25/g; s/:/%3A/g; s/ /%20/g; s/#/%23/g; s/&/%26/g; s/?/%3F/g; s/=/%3D/g' fi } profile_location_label() { local label="${PROFILE_LOCATION_LABEL:-Yurich}" label="${label//$'\r'/ }" label="${label//$'\n'/ }" label="${label//$'\t'/ }" label=$(printf '%s' "$label" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//') [[ -z "$label" ]] && label="Yurich" printf '%s\n' "$label" } profile_flag_for_label() { local label="${1:-}" lowered lowered=$(printf '%s' "$label" | tr '[:upper:]' '[:lower:]') case "$lowered" in fin|*finland*|*helsinki*|*suomi*) printf '🇫🇮' ;; swe|*sweden*|*stockholm*) printf '🇸🇪' ;; estonia|ee|*estonia*|*tallinn*) printf '🇪🇪' ;; *germany*|*deutschland*|*berlin*|*frankfurt*) printf '🇩🇪' ;; *netherlands*|*holland*|*amsterdam*) printf '🇳🇱' ;; *united\ states*|*america*|*california*|*fremont*|*new\ york*) printf '🇺🇸' ;; *united\ kingdom*|*london*|*britain*) printf '🇬🇧' ;; *france*|*paris*) printf '🇫🇷' ;; *poland*|*warsaw*) printf '🇵🇱' ;; *russia*|*moscow*) printf '🇷🇺' ;; *) printf '🌐' ;; esac } node_location_label() { local node="${1:-}" lowered lowered=$(printf '%s' "$node" | tr '[:upper:]' '[:lower:]') case "$lowered" in germany|de|main) printf 'Germany' ;; germany2|germany-2|de2) printf 'Germany 2' ;; fin|finland|fi) printf 'Finland' ;; finland2|finland-2|helsinki|fi2) printf 'Finland 2' ;; swe|sweden|finland3|finland-3) printf 'SWE' ;; estonia|ee|tallinn) printf 'Estonia' ;; netherlands|nl|holland|amsterdam) printf 'Netherlands' ;; usa|us|america|california|fremont) printf 'USA California' ;; poland|pl|warsaw) printf 'Poland' ;; poland2|pl2|usa2|test) printf 'Poland 2' ;; france|fr|paris) printf 'France' ;; uk|gb|united-kingdom|united_kingdom|great-britain|britain|london) printf 'United Kingdom' ;; uk2|gb2|united-kingdom2|united_kingdom2) printf 'United Kingdom 2' ;; *) printf '%s' "$node" ;; esac } profile_expiry_date_dmy() { local ymd="${1:-}" if [[ "$ymd" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then printf '%s.%s.%s' "${BASH_REMATCH[3]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[1]}" else printf '%s' "$ymd" fi } profile_expiry_label_for_name() { local user="$1" expires today formatted expires=$(get_user_expiry "$user" 2>/dev/null || true) if [[ -z "$expires" ]]; then printf 'без срока' return fi formatted=$(profile_expiry_date_dmy "$expires") today=$(date -u '+%Y-%m-%d') if [[ "$expires" < "$today" ]]; then printf 'истёк %s' "$formatted" else printf 'до %s' "$formatted" fi } pretty_profile_name() { local user="$1" protocol="$2" node="${3:-}" label flag expiry if [[ -n "$node" ]]; then label=$(node_location_label "$node") flag=$(profile_flag_for_label "$label" "") else label=$(profile_location_label) flag=$(profile_flag_for_label "$label") fi expiry=$(profile_expiry_label_for_name "$user") printf '%s %s • %s • %s • %s\n' "$flag" "$label" "$expiry" "$protocol" "$user" } happ_location_code() { local label="${1:-}" lowered lowered=$(printf '%s' "$label" | tr '[:upper:]' '[:lower:]') case "$lowered" in *swe*|*stockholm*) printf 'SWE' ;; *estonia*|*tallinn*) printf 'EE' ;; *finland\ 2*|*finland2*|*fi2*) printf 'FI2' ;; *poland*|*warsaw*|*polska*) printf 'PL' ;; *finland*|*helsinki*|*suomi*) printf 'FI' ;; *germany*|*deutschland*|*berlin*|*frankfurt*) printf 'DE' ;; *united-kingdom*|*united\ kingdom*|*great\ britain*|*london*) printf 'GB' ;; *france*|*paris*) printf 'FR' ;; *netherlands*|*holland*|*amsterdam*) printf 'NL' ;; *usa*|*united\ states*|*america*|*california*|*fremont*) printf 'US' ;; *) printf 'VPN' ;; esac } happ_profile_name() { local protocol="$1" node="${2:-}" label code if [[ -n "$node" ]]; then label=$(node_location_label "$node") else label=$(profile_location_label) fi code=$(happ_location_code "$label") printf '%s %s\n' "$code" "$protocol" } happ_compat_links() { if ! command -v python3 >/dev/null 2>&1; then cat return 0 fi local tmp_input tmp_input=$(mktemp /tmp/yurich_happ_links_XXXXXX.txt) || return 1 cat > "$tmp_input" python3 - "$tmp_input" <<'PY' import sys import urllib.parse def loc_code(host, label): text = f"{host} {label}".lower() if "sweden" in text or "stockholm" in text or "swe •" in text: return "SWE" if "estonia" in text or "tallinn" in text: return "EE" if "finland 2" in text or "finland2" in text or "fi2" in text: return "FI2" if "poland" in text or "warsaw" in text or "polska" in text: return "PL" if "usa" in text or "america" in text or "california" in text: return "US" if "russia" in text or "russian" in text or "moscow" in text or "ru " in text: return "RU" if "finland" in text or "helsinki" in text or "suomi" in text: return "FI" if "germany" in text or "deutschland" in text or "frankfurt" in text: return "DE" if "united-kingdom" in text or "united kingdom" in text or "great britain" in text or "london" in text: return "GB" if "france" in text or "paris" in text: return "FR" if "netherlands" in text or "amsterdam" in text: return "NL" return "VPN" def loc_label(host, label): text = f"{host} {label}".lower() if "sweden" in text or "stockholm" in text or "swe •" in text: return "🇸🇪 SWE" if "estonia" in text or "tallinn" in text: return "🇪🇪 Estonia" if "finland 2" in text or "finland2" in text or "fi2" in text: return "🇫🇮 Finland 2" if "poland 2" in text or "poland2" in text or "pl2" in text: return "🇵🇱 Poland 2" if "poland" in text or "warsaw" in text or "polska" in text: return "🇵🇱 Poland" if "usa" in text or "america" in text or "california" in text: return "🇺🇸 USA California" if "russia" in text or "russian" in text or "moscow" in text or "ru " in text: return "🇷🇺 Russia" if "finland" in text or "helsinki" in text or "suomi" in text: return "🇫🇮 Finland" if "germany" in text or "deutschland" in text or "frankfurt" in text: return "🇩🇪 Germany" if "united-kingdom" in text or "united kingdom" in text or "great britain" in text or "london" in text: return "🇬🇧 United Kingdom" if "france" in text or "paris" in text: return "🇫🇷 France" if "netherlands" in text or "amsterdam" in text: return "🇳🇱 Netherlands" return "🌐 VPN" def clean_label(host, old_label, proto): return urllib.parse.quote(f"{loc_label(host, old_label)} • {proto}", safe="-._~") def one(line): raw = line.strip() if not raw: return "" split = urllib.parse.urlsplit(raw) old_label = urllib.parse.unquote(split.fragment or "") scheme = split.scheme.lower() if scheme in ("hy2", "hysteria2"): query = urllib.parse.parse_qs(split.query, keep_blank_values=True) auth = "" if "auth" in query and query["auth"]: auth = query["auth"][0] elif split.username: auth = urllib.parse.unquote(split.username) if split.password is not None: auth += ":" + urllib.parse.unquote(split.password) host = split.hostname or "" port = split.port or 443 sni = (query.get("sni") or [host])[0] obfs = (query.get("obfs") or ["salamander"])[0] obfs_password = (query.get("obfs-password") or query.get("obfs_password") or [""])[0] pairs = [ ("auth", auth), ("sni", sni), ("obfs", obfs), ("obfs-password", obfs_password), ] for key, values in query.items(): if key in {"auth", "sni", "obfs", "obfs-password", "obfs_password"}: continue for value in values: pairs.append((key, value)) q = urllib.parse.urlencode(pairs) return f"hy2://{host}:{port}/?{q}#{clean_label(host, old_label, 'Turbo')}" if scheme == "vless": host = split.hostname or "" port_num = split.port or 443 port = f":{port_num}" if split.port else "" user = split.username or "" netloc = f"{user}@{host}{port}" if user else f"{host}{port}" return urllib.parse.urlunsplit((split.scheme, netloc, split.path, split.query, clean_label(host, old_label, f"Reality {port_num}"))) return raw with open(sys.argv[1], "r", encoding="utf-8", errors="replace") as fh: for line in fh: out = one(line) if out: print(out) PY rm -f "$tmp_input" 2>/dev/null || true } hiddify_compat_links() { local force_xray_core="${1:-0}" if ! command -v python3 >/dev/null 2>&1; then cat return 0 fi local tmp_input tmp_input=$(mktemp /tmp/yurich_hiddify_links_XXXXXX.txt) || return 1 cat > "$tmp_input" python3 - "$tmp_input" "$force_xray_core" <<'PY' import sys import urllib.parse force_xray_core = len(sys.argv) > 2 and sys.argv[2] == "1" def loc_code(host, label): text = f"{host} {label}".lower() if "sweden" in text or "stockholm" in text or "swe •" in text: return "SWE" if "estonia" in text or "tallinn" in text: return "EE" if "finland 2" in text or "finland2" in text or "fi2" in text: return "FI2" if "poland" in text or "warsaw" in text or "polska" in text: return "PL" if "usa" in text or "america" in text or "california" in text: return "US" if "russia" in text or "russian" in text or "moscow" in text or "ru " in text: return "RU" if "finland" in text or "helsinki" in text or "suomi" in text: return "FI" if "germany" in text or "deutschland" in text or "frankfurt" in text: return "DE" if "united-kingdom" in text or "united kingdom" in text or "great britain" in text or "london" in text: return "GB" if "france" in text or "paris" in text: return "FR" if "netherlands" in text or "amsterdam" in text: return "NL" return "VPN" def loc_label(host, label): text = f"{host} {label}".lower() if "sweden" in text or "stockholm" in text or "swe •" in text: return "🇸🇪 SWE" if "estonia" in text or "tallinn" in text: return "🇪🇪 Estonia" if "finland 2" in text or "finland2" in text or "fi2" in text: return "🇫🇮 Finland 2" if "poland 2" in text or "poland2" in text or "pl2" in text: return "🇵🇱 Poland 2" if "poland" in text or "warsaw" in text or "polska" in text: return "🇵🇱 Poland" if "usa" in text or "america" in text or "california" in text: return "🇺🇸 USA California" if "russia" in text or "russian" in text or "moscow" in text or "ru " in text: return "🇷🇺 Russia" if "finland" in text or "helsinki" in text or "suomi" in text: return "🇫🇮 Finland" if "germany" in text or "deutschland" in text or "frankfurt" in text: return "🇩🇪 Germany" if "united-kingdom" in text or "united kingdom" in text or "great britain" in text or "london" in text: return "🇬🇧 United Kingdom" if "france" in text or "paris" in text: return "🇫🇷 France" if "netherlands" in text or "amsterdam" in text: return "🇳🇱 Netherlands" return "🌐 VPN" def clean_label(host, old_label, proto): return urllib.parse.quote(f"{loc_label(host, old_label)} • {proto}", safe="-._~") def first(query, key, default=""): values = query.get(key) return values[0] if values else default def one(line): raw = line.strip() if not raw: return "" split = urllib.parse.urlsplit(raw) old_label = urllib.parse.unquote(split.fragment or "") scheme = split.scheme.lower() if scheme in ("hy2", "hysteria2"): query = urllib.parse.parse_qs(split.query, keep_blank_values=True) auth = first(query, "auth") if not auth and split.username: auth = urllib.parse.unquote(split.username) if split.password is not None: auth += ":" + urllib.parse.unquote(split.password) host = split.hostname or "" port = split.port or 443 sni = first(query, "sni", host) obfs = first(query, "obfs", "salamander") obfs_password = first(query, "obfs-password") or first(query, "obfs_password") pairs = [ ("sni", sni), ("obfs", obfs), ("obfs-password", obfs_password), ] for key, values in query.items(): if key in {"auth", "sni", "obfs", "obfs-password", "obfs_password"}: continue for value in values: pairs.append((key, value)) q = urllib.parse.urlencode([(k, v) for k, v in pairs if v != ""]) userinfo = urllib.parse.quote(auth, safe="") + "@" if auth else "" return f"hysteria2://{userinfo}{host}:{port}/?{q}#{clean_label(host, old_label, 'Turbo')}" if scheme == "vless": host = split.hostname or "" port_num = split.port or 443 port = f":{port_num}" if split.port else "" user = split.username or "" netloc = f"{user}@{host}{port}" if user else f"{host}{port}" drop = {"headerType", "packetEncoding"} pairs = [(k, v) for k, v in urllib.parse.parse_qsl(split.query, keep_blank_values=True) if k not in drop] query_map = urllib.parse.parse_qs(split.query, keep_blank_values=True) proto = "XHTTP" if first(query_map, "type").lower() == "xhttp" else "Reality" if proto == "XHTTP" and force_xray_core: pairs = [(k, v) for k, v in pairs if k.lower() != "core"] pairs.append(("core", "xray")) query = urllib.parse.urlencode(pairs) return urllib.parse.urlunsplit((split.scheme, netloc, split.path, query, clean_label(host, old_label, proto))) return raw with open(sys.argv[1], "r", encoding="utf-8", errors="replace") as fh: for line in fh: out = one(line) if out: print(out) PY rm -f "$tmp_input" 2>/dev/null || true } subscription_profile_name() { local protocol="$1" node="${2:-}" label label=$(profile_location_label) if [[ -n "$node" ]]; then printf '%s %s %s\n' "$label" "$node" "$protocol" else printf '%s %s\n' "$label" "$protocol" fi } uri_with_profile_name() { local uri="$1" name="$2" [[ -n "$uri" ]] || return 0 printf '%s#%s\n' "${uri%%#*}" "$(uri_fragment_encode "$name")" } xray_reality_public_port() { local public_port="${XRAY_REALITY_PUBLIC_PORT:-}" if [[ -z "$public_port" && "${XRAY_REALITY_SNI_MUX_ENABLED:-0}" == "1" ]]; then public_port="443" fi if [[ -z "$public_port" ]]; then public_port="${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}" fi if is_valid_port "$public_port"; then printf '%s\n' "$public_port" else printf '%s\n' "${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}" fi } yurich_proxy_uri() { local user="$1" pass="$2" tag="${3:-}" printf 'yurich://proxy?transport=naive&server=%s&port=443&username=%s&password=%s' "$DOMAIN" "$user" "$pass" [[ -n "$tag" ]] && printf '#%s' "$(uri_fragment_encode "$tag")" printf '\n' } singbox_naive_tun_json() { local user="$1" pass="$2" dns_ip dns_ip=$(yurich_dns_client_ip) if [[ -n "$dns_ip" ]]; then cat </dev/null; then err "Пользователь $selected_user не найден" return 1 fi first_user="$selected_user" first_pass=$(get_user_pass "$selected_user") else first_user=$(get_users | head -1 | cut -d: -f1) first_pass=$(get_user_pass "$first_user" 2>/dev/null || true) fi if [[ -z "${first_user:-}" ]]; then warn "Нет пользователей. Добавь через меню → Пользователи." return fi echo -e "${CYAN} Стек сервера:${RESET}" echo -e " Yurich Proxy (Naive-compatible transport) + Caddy 2 + klzgrad/forwardproxy@naive" echo -e " Пользователь: ${BOLD}${first_user}${RESET} | срок: ${CYAN}$(user_expiry_label "$first_user")${RESET}" echo echo -e "${YELLOW} Важно для приложений:${RESET}" echo -e " Yurich Proxy сейчас использует совместимый transport ${BOLD}naive${RESET}." echo -e " В обычных приложениях выбирай тип ${BOLD}NaiveProxy / naive${RESET}, а не VLESS/Trojan/Shadowsocks." echo -e " Фирменная ссылка ${BOLD}yurich://...${RESET} — задел под будущий Yurich-клиент." echo -e " Если в приложении нет native naive support, используй HTTPS proxy fallback ниже." echo -e " Для телефона включай VPN/TUN mode, иначе не весь трафик пойдёт через прокси." echo echo -e "${CYAN} Yurich link (для будущего Yurich-клиента):${RESET}" echo -e " $(yurich_proxy_uri "$first_user" "$first_pass" "${first_user}-yurich")" echo echo -e "${CYAN} Совместимый URI (naive, для текущих клиентов):${RESET}" echo -e " naive+https://${first_user}:${first_pass}@${DOMAIN}:443" echo echo -e "${CYAN} JSON (naive-client):${RESET}" cat </dev/null || true) hy2_auth=$(hysteria_user_auth "$first_user" 2>/dev/null || true) if [[ -n "$hy2_uri" && -n "$hy2_auth" ]]; then echo echo -e "${CYAN} Hysteria 2 (персональный UDP/QUIC):${RESET}" echo -e " ${hy2_uri}" echo echo -e "${CYAN} JSON (sing-box outbound, Hysteria 2):${RESET}" cat </dev/null || true) [[ -n "$p" ]] || { warn "Encrypted secret для $u недоступен"; continue; } echo -e " [USER] ${BOLD}$u${RESET} : Yurich $(yurich_proxy_uri "$u" "$p" "${u}-yurich")" echo -e " native: naive+https://${u}:${p}@${DOMAIN}:443" done < <(get_users) fi # QR код для быстрого подключения echo info "QR код для быстрого подключения с телефона:" local uri="naive+https://${first_user}:${first_pass}@${DOMAIN}:443" if command -v qrencode &>/dev/null; then echo qrencode -t ANSIUTF8 "$uri" echo else info "Устанавливаю qrencode для QR кода..." apt-get install -y -q qrencode 2>/dev/null && echo && qrencode -t ANSIUTF8 "$uri" && echo || warn "qrencode недоступен — установи вручную: apt install qrencode" fi ok "QR содержит совместимый naive+https URI для NekoBox / Shadowrocket / Hiddify" hr } # ─── HYSTERIA 2 ─────────────────────────────────────────────── install_hysteria_bin() { local arch asset url tmp_bin tmp_hash expected actual version_output expected_version staging_bin="" arch=$(detect_hysteria_arch) || return 1 asset="hysteria-linux-${arch}" [[ "$HYSTERIA_VERSION_PIN" == "latest" || "$HYSTERIA_VERSION_PIN" =~ ^(app/)?v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$ ]] || { err "Некорректный Hysteria version pin: $HYSTERIA_VERSION_PIN" return 1 } if [[ -x "$HYSTERIA_BIN" ]]; then if "$HYSTERIA_BIN" version >/dev/null 2>&1; then ok "Hysteria 2 уже установлен: $("$HYSTERIA_BIN" version 2>/dev/null | head -1 || echo "$HYSTERIA_BIN")" return 0 fi warn "Найден Hysteria 2 binary, но он не запускается. Переустанавливаю." fi if [[ "${HYSTERIA_VERSION_PIN:-latest}" == "latest" ]]; then url="https://github.com/apernet/hysteria/releases/latest/download/${asset}" else url="https://github.com/apernet/hysteria/releases/download/${HYSTERIA_VERSION_PIN}/${asset}" fi tmp_bin=$(mktemp /tmp/hysteria_XXXXXX) tmp_hash=$(mktemp /tmp/hysteria_hashes_XXXXXX) trap 'rm -f "${tmp_bin:-}" "${tmp_hash:-}" 2>/dev/null; trap - RETURN' RETURN info "Скачиваю Hysteria 2 (${asset})..." if ! curl -fsSL --retry 3 --connect-timeout 15 --max-time 180 -o "$tmp_bin" -- "$url"; then err "Не удалось скачать Hysteria 2: $url" return 1 fi if curl -fsSL --connect-timeout 10 --max-time 30 \ "${url%/${asset}}/hashes.txt" -o "$tmp_hash" 2>/dev/null; then expected=$(grep -F "$asset" "$tmp_hash" | awk '{for(i=1;i<=NF;i++) if($i ~ /^[a-fA-F0-9]{64}$/){print tolower($i); exit}}' | head -1) if [[ -n "$expected" ]]; then actual=$(sha256sum "$tmp_bin" | awk '{print $1}') if [[ "$actual" != "$expected" ]]; then err "SHA256 Hysteria 2 не совпадает. Установка остановлена." return 1 fi ok "SHA256 Hysteria 2 подтверждён" else if [[ "${YURICH_ALLOW_UNVERIFIED_DOWNLOADS:-0}" == "1" ]]; then warn "Не нашёл SHA256 для ${asset} в hashes.txt, продолжаю из-за YURICH_ALLOW_UNVERIFIED_DOWNLOADS=1" else err "Не нашёл SHA256 для ${asset} в hashes.txt. Установка остановлена." return 1 fi fi else if [[ "${YURICH_ALLOW_UNVERIFIED_DOWNLOADS:-0}" == "1" ]]; then warn "Не удалось скачать hashes.txt, продолжаю из-за YURICH_ALLOW_UNVERIFIED_DOWNLOADS=1" else err "Не удалось скачать hashes.txt. Установка Hysteria 2 остановлена." return 1 fi fi chmod 755 "$tmp_bin" version_output=$("$tmp_bin" version 2>&1) || { err "Скачанный Hysteria 2 binary не запускается" return 1 } if [[ "${HYSTERIA_VERSION_PIN:-latest}" != "latest" ]]; then expected_version="${HYSTERIA_VERSION_PIN#app/}" [[ "$version_output" == *"$expected_version"* ]] || { err "Версия Hysteria 2 не совпадает: ожидалась $expected_version" return 1 } fi staging_bin=$(mktemp "${HYSTERIA_BIN}.install.XXXXXX") || return 1 if ! install -m 755 "$tmp_bin" "$staging_bin" || ! mv -f -- "$staging_bin" "$HYSTERIA_BIN"; then rm -f "$staging_bin" err "Не удалось атомарно установить Hysteria 2" return 1 fi ok "Hysteria 2 установлен: $(printf '%s\n' "$version_output" | head -1)" info "Hysteria release pin: ${HYSTERIA_VERSION_PIN:-latest}" } install_hysteria_auth_helper() { ensure_credential_tools || return 1 write_active_users_hash_file || return 1 install -d -o root -g root -m 755 "$(dirname "$HYSTERIA_AUTH_HELPER")" cat > "$HYSTERIA_AUTH_HELPER" </dev/null 2>&1; then printf '%s\n' "\$user" exit 0 fi exit 1 EOF chown root:root "$HYSTERIA_AUTH_HELPER" 2>/dev/null || true chmod 700 "$HYSTERIA_AUTH_HELPER" } install_hysteria_tls_sync() { local source_cert source_key source_cert=$(find_caddy_cert "${DOMAIN:-}" || true) source_key=$(find_caddy_key "${DOMAIN:-}" || true) if [[ -z "$source_cert" || -z "$source_key" ]]; then err "Не нашёл TLS сертификат Caddy для ${DOMAIN:-не задан}" err "Сначала дождись выпуска TLS: sudo bash yurich-panel.sh cert" return 1 fi install -d -o root -g root -m 700 "$HYSTERIA_TLS_DIR" install -d -o root -g root -m 755 "$(dirname "$HYSTERIA_TLS_SYNC_SCRIPT")" cat > "$HYSTERIA_TLS_SYNC_SCRIPT" <&2; exit 1; } # shellcheck disable=SC1090 source "\$CONFIG_FILE" domain="\${DOMAIN:-}" [[ "\$domain" =~ ^[A-Za-z0-9.-]{1,253}\$ && "\$domain" == *.* && "\$domain" != .* && "\$domain" != *..* ]] \ || { echo "Hysteria TLS sync: invalid DOMAIN" >&2; exit 1; } source_cert=\$(find "\$SOURCE_ROOT" -xdev -type f -path "*/\${domain}/\${domain}.crt" -print -quit 2>/dev/null || true) source_key=\$(find "\$SOURCE_ROOT" -xdev -type f -path "*/\${domain}/\${domain}.key" -print -quit 2>/dev/null || true) [[ -n "\$source_cert" && -n "\$source_key" && -r "\$source_cert" && -r "\$source_key" ]] \ || { echo "Hysteria TLS sync: Caddy certificate is unavailable for \$domain" >&2; exit 1; } install -d -o root -g root -m 700 "\$TLS_DIR" tmp_dir=\$(mktemp -d "\${TLS_DIR}/.sync.XXXXXX") trap 'rm -rf -- "\$tmp_dir"' EXIT install -o root -g root -m 600 "\$source_cert" "\$tmp_dir/cert.pem" install -o root -g root -m 600 "\$source_key" "\$tmp_dir/key.pem" openssl x509 -in "\$tmp_dir/cert.pem" -noout >/dev/null openssl pkey -in "\$tmp_dir/key.pem" -noout >/dev/null openssl x509 -in "\$tmp_dir/cert.pem" -checkend 86400 -noout >/dev/null \ || { echo "Hysteria TLS sync: certificate expires in less than 24 hours" >&2; exit 1; } cert_pub=\$(openssl x509 -in "\$tmp_dir/cert.pem" -pubkey -noout \ | openssl pkey -pubin -outform DER 2>/dev/null \ | sha256sum | awk '{print \$1}') key_pub=\$(openssl pkey -in "\$tmp_dir/key.pem" -pubout -outform DER 2>/dev/null \ | sha256sum | awk '{print \$1}') [[ -n "\$cert_pub" && "\$cert_pub" == "\$key_pub" ]] \ || { echo "Hysteria TLS sync: certificate and private key do not match" >&2; exit 1; } changed=0 if [[ ! -s "\$CERT_DEST" || ! -s "\$KEY_DEST" ]] \ || ! cmp -s "\$tmp_dir/cert.pem" "\$CERT_DEST" \ || ! cmp -s "\$tmp_dir/key.pem" "\$KEY_DEST"; then mv -f -- "\$tmp_dir/cert.pem" "\$CERT_DEST" mv -f -- "\$tmp_dir/key.pem" "\$KEY_DEST" chown root:root "\$CERT_DEST" "\$KEY_DEST" chmod 600 "\$CERT_DEST" "\$KEY_DEST" changed=1 fi if [[ "\$changed" -eq 1 ]]; then echo "Hysteria TLS sync: certificate updated for \$domain" if [[ "\$MODE" == "--restart-if-changed" ]] && systemctl is-active --quiet hysteria.service; then systemctl restart hysteria.service fi fi EOF chown root:root "$HYSTERIA_TLS_SYNC_SCRIPT" chmod 700 "$HYSTERIA_TLS_SYNC_SCRIPT" cat > "$HYSTERIA_TLS_SYNC_SERVICE" < "$HYSTERIA_TLS_SYNC_TIMER" </dev/null 2>&1 || { err "Не удалось включить автоматическую синхронизацию TLS Hysteria 2" return 1 } ok "Hysteria TLS изолирован от /root и синхронизируется автоматически" } write_hysteria_config() { load_config load_users local hy_warp_enabled=0 hysteria_config_backup="" ensure_hysteria_secrets || return 1 install_hysteria_auth_helper || return 1 install_hysteria_tls_sync || return 1 save_config if [[ "${HYSTERIA_WARP_ENABLED:-0}" == "1" || "${WARP_PROXY_ENABLED:-0}" == "1" ]]; then hy_warp_enabled=1 fi mkdir -p "$CONFIG_DIR" if [[ -f "$HYSTERIA_CONFIG" ]]; then hysteria_config_backup=$(mktemp /tmp/yurich_hysteria_backup_XXXXXX.yaml) cp "$HYSTERIA_CONFIG" "$hysteria_config_backup" 2>/dev/null || hysteria_config_backup="" fi cat > "$HYSTERIA_CONFIG" < Caddy NaiveProxy, UDP/${HYSTERIA_PORT:-8443} -> Hysteria 2 listen: :${HYSTERIA_PORT:-8443} tls: cert: ${HYSTERIA_TLS_CERT} key: ${HYSTERIA_TLS_KEY} sniGuard: strict auth: type: command command: ${HYSTERIA_AUTH_HELPER} EOF cat >> "$HYSTERIA_CONFIG" <> "$HYSTERIA_CONFIG" </dev/null || true warn "Рабочий Hysteria config восстановлен из бэкапа" else rm -f "$HYSTERIA_CONFIG" 2>/dev/null || true warn "Новый нерабочий Hysteria config удалён" fi return 1 fi [[ -n "$hysteria_config_backup" ]] && rm -f "$hysteria_config_backup" 2>/dev/null || true ok "Hysteria 2 конфиг записан: $HYSTERIA_CONFIG" if [[ "$hy_warp_enabled" == "1" ]]; then ok "Hysteria 2 outbound направлен через WARP SOCKS5: 127.0.0.1:${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" fi } write_hysteria_service() { local capability_block if is_valid_port "${HYSTERIA_PORT:-8443}" && (( 10#${HYSTERIA_PORT:-8443} < 1024 )); then capability_block=$'CapabilityBoundingSet=CAP_NET_BIND_SERVICE\nAmbientCapabilities=CAP_NET_BIND_SERVICE' else capability_block=$'CapabilityBoundingSet=\nAmbientCapabilities=' fi cat > "$HYSTERIA_SERVICE" </dev/null || return 0 grep -Fq " key: ${HYSTERIA_TLS_KEY}" "$HYSTERIA_CONFIG" 2>/dev/null || return 0 [[ -s "$HYSTERIA_TLS_CERT" && -s "$HYSTERIA_TLS_KEY" ]] || return 0 [[ -f "$HYSTERIA_SERVICE" ]] || return 0 grep -Fq "ReadOnlyPaths=${HYSTERIA_TLS_DIR}" "$HYSTERIA_SERVICE" 2>/dev/null || return 0 [[ -x "$HYSTERIA_TLS_SYNC_SCRIPT" && -f "$HYSTERIA_TLS_SYNC_TIMER" ]] || return 0 systemctl is-enabled --quiet yurich-hysteria-tls-sync.timer 2>/dev/null || return 0 return 1 } rewrite_hysteria_tls_paths_only() { local staging [[ -s "$HYSTERIA_CONFIG" ]] || { err "Hysteria config отсутствует"; return 1; } install_hysteria_tls_sync || return 1 staging=$(mktemp "${CONFIG_DIR}/.hysteria-tls-migrate.XXXXXX") || return 1 if ! sed -E \ -e "s#^([[:space:]]*cert:).*#\\1 ${HYSTERIA_TLS_CERT}#" \ -e "s#^([[:space:]]*key:).*#\\1 ${HYSTERIA_TLS_KEY}#" \ "$HYSTERIA_CONFIG" > "$staging"; then rm -f "$staging" return 1 fi if ! grep -Fq " cert: ${HYSTERIA_TLS_CERT}" "$staging" \ || ! grep -Fq " key: ${HYSTERIA_TLS_KEY}" "$staging" \ || grep -Eq '^[[:space:]]*(cert|key):[[:space:]]*/(root|run)/' "$staging"; then rm -f "$staging" err "Не удалось безопасно переписать TLS-пути Hysteria" return 1 fi install -o root -g root -m 600 "$staging" "$HYSTERIA_CONFIG" rm -f "$staging" } restore_hysteria_repair_snapshot() { local snapshot="$1" was_active="$2" was_enabled="$3" timer_was_enabled="$4" id path systemctl stop hysteria.service >/dev/null 2>&1 || true systemctl disable --now yurich-hysteria-tls-sync.timer >/dev/null 2>&1 || true while IFS='|' read -r id path; do [[ -n "$id" && -n "$path" ]] || continue rm -f -- "$path" 2>/dev/null || true if [[ -f "$snapshot/files/$id" ]]; then install -d -m 755 "$(dirname "$path")" cp -a -- "$snapshot/files/$id" "$path" fi done < "$snapshot/manifest" rm -rf -- "$HYSTERIA_TLS_DIR" 2>/dev/null || true [[ -d "$snapshot/hysteria-tls" ]] && cp -a -- "$snapshot/hysteria-tls" "$HYSTERIA_TLS_DIR" systemctl daemon-reload >/dev/null 2>&1 || true if [[ "$was_enabled" == "1" ]]; then systemctl enable hysteria.service >/dev/null 2>&1 || true else systemctl disable hysteria.service >/dev/null 2>&1 || true fi if [[ "$timer_was_enabled" == "1" ]]; then systemctl enable --now yurich-hysteria-tls-sync.timer >/dev/null 2>&1 || true fi if [[ "$was_active" == "1" ]]; then systemctl restart hysteria.service >/dev/null 2>&1 || true fi } cmd_hysteria_repair() { load_config load_users if [[ ! -f "$HYSTERIA_CONFIG" && ! -x "$HYSTERIA_BIN" ]]; then info "Hysteria 2 не установлен — исправление не требуется" return 0 fi [[ -n "${DOMAIN:-}" ]] || { err "DOMAIN не задан"; return 1; } local snapshot was_active=0 was_enabled=0 timer_was_enabled=0 should_run=0 id path snapshot=$(mktemp -d /run/yurich-hysteria-repair.XXXXXX) || return 1 chmod 700 "$snapshot" install -d -m 700 "$snapshot/files" printf '%s|%s\n' \ config "$HYSTERIA_CONFIG" \ service "$HYSTERIA_SERVICE" \ sync-script "$HYSTERIA_TLS_SYNC_SCRIPT" \ sync-service "$HYSTERIA_TLS_SYNC_SERVICE" \ sync-timer "$HYSTERIA_TLS_SYNC_TIMER" > "$snapshot/manifest" while IFS='|' read -r id path; do [[ -f "$path" ]] && cp -a -- "$path" "$snapshot/files/$id" done < "$snapshot/manifest" [[ -d "$HYSTERIA_TLS_DIR" ]] && cp -a -- "$HYSTERIA_TLS_DIR" "$snapshot/hysteria-tls" systemctl is-active --quiet hysteria.service 2>/dev/null && was_active=1 systemctl is-enabled --quiet hysteria.service 2>/dev/null && was_enabled=1 systemctl is-enabled --quiet yurich-hysteria-tls-sync.timer 2>/dev/null && timer_was_enabled=1 if [[ "$was_active" == "1" || "$was_enabled" == "1" || "${HYSTERIA_ENABLED:-0}" == "1" ]]; then should_run=1 fi info "Исправляю доступ Hysteria 2 к TLS без раскрытия каталога /root..." if [[ "$(credential_plaintext_count)" -eq 0 ]] && ! grep -q 'type: userpass' "$HYSTERIA_CONFIG" 2>/dev/null; then write_hysteria_config || { restore_hysteria_repair_snapshot "$snapshot" "$was_active" "$was_enabled" "$timer_was_enabled" rm -rf -- "$snapshot" return 1 } else warn "Legacy credentials сохранены без изменения; после ремонта запусти credentials-migrate" rewrite_hysteria_tls_paths_only || { restore_hysteria_repair_snapshot "$snapshot" "$was_active" "$was_enabled" "$timer_was_enabled" rm -rf -- "$snapshot" return 1 } fi if ! write_hysteria_service \ || ! systemd-analyze verify "$HYSTERIA_SERVICE" "$HYSTERIA_TLS_SYNC_SERVICE" "$HYSTERIA_TLS_SYNC_TIMER" >/dev/null 2>&1; then err "Проверка systemd Hysteria не пройдена; выполняю rollback" restore_hysteria_repair_snapshot "$snapshot" "$was_active" "$was_enabled" "$timer_was_enabled" rm -rf -- "$snapshot" return 1 fi if [[ "$should_run" == "1" ]]; then if ! systemctl restart hysteria.service || ! sleep 2 || ! systemctl is-active --quiet hysteria.service; then err "Hysteria 2 не запустился после исправления; выполняю rollback" journalctl -u hysteria.service -n 20 --no-pager 2>/dev/null || true restore_hysteria_repair_snapshot "$snapshot" "$was_active" "$was_enabled" "$timer_was_enabled" rm -rf -- "$snapshot" return 1 fi else systemctl stop hysteria.service >/dev/null 2>&1 || true fi systemctl reset-failed hysteria.service >/dev/null 2>&1 || true rm -rf -- "$snapshot" ok "Hysteria TLS исправлен: закрытая копия + автоматическая синхронизация" } cmd_post_update() { load_config 2>/dev/null || true load_users 2>/dev/null || true if hysteria_tls_layout_needs_repair; then info "Обнаружена старая TLS-схема Hysteria 2; запускаю безопасную миграцию" cmd_hysteria_repair || return 1 fi } run_startup_migrations() { load_config 2>/dev/null || true load_users 2>/dev/null || true if hysteria_tls_layout_needs_repair; then warn "Обнаружена старая TLS-схема Hysteria 2" if ! cmd_hysteria_repair; then warn "Автомиграция Hysteria отложена. Запусти: sudo bash yurich-panel.sh hysteria-repair" return 1 fi fi } hysteria_user_auth() { local selected_user="${1:-}" selected_pass first_user first_pass load_users if [[ -n "$selected_user" ]]; then if ! is_valid_proxy_user "$selected_user" || ! selected_pass=$(get_active_user_pass "$selected_user" 2>/dev/null); then err "Пользователь $selected_user не найден для Hysteria" return 1 fi printf '%s:%s\n' "$selected_user" "$selected_pass" return 0 fi first_user=$(get_active_users | head -1 | cut -d: -f1) first_pass=$(get_active_users | head -1 | cut -d: -f2) if [[ -n "$first_user" && -n "$first_pass" ]]; then printf '%s:%s\n' "$first_user" "$first_pass" return 0 fi err "Нет активных пользователей для Hysteria" return 1 } hysteria_uri_for_user() { local selected_user="${1:-}" server_ports="${2:-${HYSTERIA_PORT:-8443}}" auth auth=$(hysteria_user_auth "$selected_user") || return 1 [[ -z "$auth" ]] && return 1 printf 'hy2://%s@%s:%s/?sni=%s&obfs=salamander&obfs-password=%s' \ "$auth" "${DOMAIN}" "$server_ports" "${DOMAIN}" "${HYSTERIA_OBFS_PASSWORD}" if [[ -n "$selected_user" ]]; then printf '#%s-hy2' "$selected_user" fi printf '\n' } hysteria_happ_uri_for_user() { local selected_user="${1:-}" server_ports="${2:-${HYSTERIA_PORT:-8443}}" auth auth=$(hysteria_user_auth "$selected_user") || return 1 [[ -z "$auth" ]] && return 1 printf 'hy2://%s:%s/?auth=%s&sni=%s&obfs=salamander&obfs-password=%s\n' \ "${DOMAIN}" "$server_ports" "$(uri_query_encode "$auth")" "$(uri_query_encode "$DOMAIN")" "$(uri_query_encode "$HYSTERIA_OBFS_PASSWORD")" } validate_hysteria_config() { local config="${1:-$HYSTERIA_CONFIG}" test_config test_log test_port rc line if [[ ! -s "$config" ]]; then err "Hysteria config не найден или пустой: $config" return 1 fi grep -q '^listen:' "$config" || { err "Hysteria config: нет listen"; return 1; } grep -q '^tls:' "$config" || { err "Hysteria config: нет tls"; return 1; } grep -Fq " cert: ${HYSTERIA_TLS_CERT}" "$config" || { err "Hysteria config: TLS cert не из изолированного каталога"; return 1; } grep -Fq " key: ${HYSTERIA_TLS_KEY}" "$config" || { err "Hysteria config: TLS key не из изолированного каталога"; return 1; } [[ -s "$HYSTERIA_TLS_CERT" && -s "$HYSTERIA_TLS_KEY" ]] || { err "Hysteria config: изолированные TLS-файлы отсутствуют"; return 1; } [[ "$(stat -c '%a' "$HYSTERIA_TLS_DIR" 2>/dev/null || echo 0)" == "700" ]] || { err "Hysteria TLS directory: ожидается mode 700"; return 1; } [[ "$(stat -c '%a' "$HYSTERIA_TLS_CERT" 2>/dev/null || echo 0)" == "600" ]] || { err "Hysteria TLS cert: ожидается mode 600"; return 1; } [[ "$(stat -c '%a' "$HYSTERIA_TLS_KEY" 2>/dev/null || echo 0)" == "600" ]] || { err "Hysteria TLS key: ожидается mode 600"; return 1; } grep -q '^auth:' "$config" || { err "Hysteria config: нет auth"; return 1; } grep -q 'type: command' "$config" || { err "Hysteria config: auth не command"; return 1; } grep -Fq "command: ${HYSTERIA_AUTH_HELPER}" "$config" || { err "Hysteria config: неверный auth helper"; return 1; } [[ -x "$HYSTERIA_AUTH_HELPER" ]] || { err "Hysteria auth helper не исполняемый"; return 1; } grep -q '^obfs:' "$config" || { err "Hysteria config: нет obfs"; return 1; } if [[ ! -x "$HYSTERIA_BIN" ]]; then warn "Hysteria binary не найден, выполняю только структурную проверку config" return 0 fi command -v timeout >/dev/null 2>&1 || { warn "timeout не найден, выполняю только структурную проверку Hysteria config" return 0 } test_config=$(mktemp /tmp/yurich_hysteria_check_XXXXXX.yaml) || return 1 test_log=$(mktemp /tmp/yurich_hysteria_check_XXXXXX.log) || { rm -f "$test_config" 2>/dev/null || true return 1 } for _ in {1..25}; do test_port=$((40000 + RANDOM % 20000)) if ! ss -lun 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]${test_port}$"; then break fi test_port="" done if [[ -z "$test_port" ]]; then rm -f "$test_config" "$test_log" 2>/dev/null || true err "Не удалось подобрать временный UDP порт для проверки Hysteria" return 1 fi sed -E "0,/^listen:/s#^listen:.*#listen: 127.0.0.1:${test_port}#" "$config" > "$test_config" if timeout 3s "$HYSTERIA_BIN" server -c "$test_config" >"$test_log" 2>&1; then rc=0 else rc=$? fi case "$rc" in 0|124) rm -f "$test_config" "$test_log" 2>/dev/null || true return 0 ;; *) err "Hysteria config не прошёл runtime-проверку" sed -n '1,20p' "$test_log" 2>/dev/null || true rm -f "$test_config" "$test_log" 2>/dev/null || true return 1 ;; esac } hysteria_port_hop_range() { local range="${HYSTERIA_PORT_HOP_PORTS:-$HYSTERIA_PORT_HOP_PORTS_DEFAULT}" start end [[ "$range" =~ ^[0-9]{2,5}-[0-9]{2,5}$ ]] || range="$HYSTERIA_PORT_HOP_PORTS_DEFAULT" start="${range%-*}" end="${range#*-}" if ! is_valid_local_proxy_port "$start" || ! is_valid_port "$end" || (( 10#$start >= 10#$end )); then range="$HYSTERIA_PORT_HOP_PORTS_DEFAULT" fi printf '%s\n' "$range" } hysteria_port_hop_enabled() { [[ "${HYSTERIA_PORT_HOP_ENABLED:-0}" == "1" ]] || return 1 hysteria_port_hop_range >/dev/null } hysteria_port_hop_server_ports() { local main_port="${HYSTERIA_PORT:-8443}" hop_range is_valid_port "$main_port" || main_port="8443" hop_range=$(hysteria_port_hop_range) printf '%s,%s\n' "$main_port" "$hop_range" } hysteria_port_hop_uri_for_user() { local selected_user="${1:-}" server_ports hysteria_port_hop_enabled || return 1 server_ports=$(hysteria_port_hop_server_ports) hysteria_uri_for_user "$selected_user" "$server_ports" } remove_hysteria_port_hop_rules() { if [[ -x "$HYSTERIA_PORT_HOP_SCRIPT" ]]; then "$HYSTERIA_PORT_HOP_SCRIPT" stop >/dev/null 2>&1 || true fi systemctl disable --now yurich-hysteria-port-hop >/dev/null 2>&1 || true } apply_hysteria_port_hop() { load_config if ! hysteria_port_hop_enabled; then remove_hysteria_port_hop_rules return 0 fi local range start end target ufw_range range=$(hysteria_port_hop_range) start="${range%-*}" end="${range#*-}" target="${HYSTERIA_PORT:-8443}" ufw_range="${start}:${end}" if ! command -v iptables >/dev/null 2>&1; then err "iptables не найден: Hysteria 2 port hopping не сможет поставить UDP redirect" err "Установи iptables или выключи port hopping" return 1 fi mkdir -p "$(dirname "$HYSTERIA_PORT_HOP_SCRIPT")" cat > "$HYSTERIA_PORT_HOP_SCRIPT" </dev/null 2>&1; } public_ipv4_list() { ip -o -4 addr show scope global 2>/dev/null \\ | awk '{split(\$4,a,"/"); print a[1]}' \\ | grep -Ev '^(10\\.|127\\.|169\\.254\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|192\\.168\\.)' || true } add_rule() { have_iptables || return 0 iptables -t nat -C PREROUTING -p udp --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null \\ || iptables -t nat -A PREROUTING -p udp --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null || true local ip for ip in \$(public_ipv4_list); do iptables -t nat -C OUTPUT -p udp -d "\${ip}" --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null \\ || iptables -t nat -A OUTPUT -p udp -d "\${ip}" --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null || true done } del_rule() { have_iptables || return 0 while iptables -t nat -C PREROUTING -p udp --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null; do iptables -t nat -D PREROUTING -p udp --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null || break done local ip for ip in \$(public_ipv4_list); do while iptables -t nat -C OUTPUT -p udp -d "\${ip}" --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null; do iptables -t nat -D OUTPUT -p udp -d "\${ip}" --dport "\${RANGE}" -m comment --comment "\${COMMENT}" -j REDIRECT --to-ports "\${TARGET}" 2>/dev/null || break done done } case "\${ACTION}" in start|reload|restart) del_rule add_rule ;; stop) del_rule ;; *) echo "usage: \$0 {start|stop|reload}" >&2 exit 2 ;; esac EOF chmod 755 "$HYSTERIA_PORT_HOP_SCRIPT" cat > "$HYSTERIA_PORT_HOP_SERVICE" </dev/null 2>&1; then ufw allow "${ufw_range}/udp" comment "Hysteria2 Port Hopping" >/dev/null 2>&1 || true fi systemctl enable yurich-hysteria-port-hop >/dev/null 2>&1 || true systemctl restart yurich-hysteria-port-hop >/dev/null 2>&1 || "$HYSTERIA_PORT_HOP_SCRIPT" start if ! iptables -t nat -C PREROUTING -p udp --dport "${ufw_range}" -m comment --comment "Yurich Hysteria2 port hopping" -j REDIRECT --to-ports "${target}" 2>/dev/null; then err "Hysteria 2 port hopping rule не применился: UDP/${range} -> UDP/${target}" journalctl -u yurich-hysteria-port-hop -n 20 --no-pager 2>/dev/null || true return 1 fi ok "Hysteria 2 port hopping: UDP/${range} -> UDP/${target}" } sync_hysteria_users_if_active() { load_config if [[ "${HYSTERIA_ENABLED:-1}" != "1" ]]; then systemctl stop hysteria 2>/dev/null || true if [[ -f "$HYSTERIA_CONFIG" ]] && grep -q 'type: userpass' "$HYSTERIA_CONFIG" 2>/dev/null; then info "Очищаю legacy Hysteria userpass в неактивной конфигурации..." write_hysteria_config || return 1 systemctl stop hysteria 2>/dev/null || true fi return 0 fi if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then info "Обновляю Hysteria 2 auth по текущему bcrypt store..." if write_hysteria_config && apply_hysteria_port_hop && systemctl restart hysteria 2>/dev/null; then ok "Hysteria 2 обновлён для пользователей" return 0 fi warn "Hysteria 2 не удалось обновить автоматически. Проверь: sudo bash yurich-panel.sh hysteria-status" return 1 fi return 0 } print_hysteria_client_config() { load_config local selected_user="${1:-}" if [[ -z "${DOMAIN:-}" || -z "${HYSTERIA_OBFS_PASSWORD:-}" ]]; then warn "Hysteria 2 ещё не настроен. Запусти: sudo bash yurich-panel.sh hysteria" return 1 fi local hy2_uri hy2_auth auth_label hy2_auth=$(hysteria_user_auth "$selected_user") || return 1 if [[ -z "$hy2_auth" ]]; then warn "Нет Hysteria auth. Добавь пользователя или переустанови Hysteria 2." return 1 fi hy2_uri=$(hysteria_uri_for_user "$selected_user") || return 1 if [[ "$hy2_auth" == *:* ]]; then auth_label="userpass (${hy2_auth%%:*})" else auth_label="общий password" fi hr echo -e "${BOLD}${GREEN} Клиентский конфиг Hysteria 2${RESET}" hr echo -e "${CYAN} Стек:${RESET} Hysteria 2 / QUIC / UDP ${HYSTERIA_PORT:-8443}" echo -e "${CYAN} Auth:${RESET} ${auth_label}" echo -e "${YELLOW} Не конфликтует с NaiveProxy:${RESET} Caddy остаётся на TCP/443, Hysteria 2 на UDP/${HYSTERIA_PORT:-8443}" echo echo -e "${CYAN} URI:${RESET}" echo -e " ${hy2_uri}" echo echo -e "${CYAN} sing-box outbound:${RESET}" cat </dev/null; then qrencode -t ANSIUTF8 "$hy2_uri" else info "Для QR установи: apt install qrencode" fi hr } choose_hysteria_port() { local requested_port="${1:-}" current_port="${HYSTERIA_PORT:-8443}" choice custom_port next_port ans if [[ -n "$requested_port" ]]; then if ! is_valid_port "$requested_port"; then err "Неверный порт: $requested_port" return 1 fi next_port="$requested_port" if [[ "$next_port" != "$current_port" ]] && ss -ulpn 2>/dev/null | grep -E ":${next_port}([[:space:]]|$)" >/dev/null; then warn "UDP порт ${next_port} уже слушается. Продолжаю, потому что это неинтерактивный режим." fi HYSTERIA_PORT="$next_port" ok "Hysteria 2 порт: UDP/${HYSTERIA_PORT}" return 0 fi echo -e "${CYAN}Порт Hysteria 2:${RESET}" echo -e " ${BOLD}1)${RESET} По умолчанию UDP/8443 ${DIM}(рекомендуется)${RESET}" echo -e " ${BOLD}2)${RESET} Указать вручную" echo -ne "${CYAN}Выбор [1/2] (Enter = 1): ${RESET}" read -r choice case "$choice" in 1|"") next_port="8443" ;; 2|m|M|manual|Manual|ручной) echo -ne "${CYAN}UDP порт Hysteria 2 [${current_port}]: ${RESET}" read -r custom_port next_port="${custom_port:-$current_port}" ;; *) err "Неверный выбор" return 1 ;; esac if ! is_valid_port "$next_port"; then err "Неверный порт: $next_port" return 1 fi if [[ "$next_port" == "443" ]]; then warn "UDP/443 может конфликтовать с Caddy HTTP/3. Рекомендую 8443." echo -ne "${YELLOW}Оставить UDP/443? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || next_port="8443" fi if [[ "$next_port" != "$current_port" ]] && ss -ulpn 2>/dev/null | grep -E ":${next_port}([[:space:]]|$)" >/dev/null; then warn "UDP порт ${next_port} уже слушается. Проверь: ss -ulpn | grep ':${next_port}'" echo -ne "${YELLOW}Продолжить всё равно? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return 1 fi HYSTERIA_PORT="$next_port" ok "Hysteria 2 порт: UDP/${HYSTERIA_PORT}" } cmd_hysteria_install() { load_config if [[ -z "${DOMAIN:-}" ]]; then err "Домен не настроен. Сначала установи Yurich Panel." return 1 fi hr echo -e "${BOLD} Установка Hysteria 2${RESET}" hr echo -e " NaiveProxy остаётся: ${CYAN}TCP/443${RESET}" echo -e " Hysteria 2 будет: ${CYAN}UDP/${HYSTERIA_PORT:-8443}${RESET} или ручной порт" echo choose_hysteria_port || return 1 HYSTERIA_ENABLED="1" ensure_hysteria_secrets || return 1 save_config install_hysteria_bin || return 1 write_hysteria_config || return 1 write_hysteria_service || return 1 ufw allow "${HYSTERIA_PORT}/udp" comment "Hysteria2 QUIC" >/dev/null 2>&1 || true apply_hysteria_port_hop || return 1 save_config info "Запускаю Hysteria 2..." if systemctl restart hysteria && sleep 2 && systemctl is-active --quiet hysteria; then ok "Hysteria 2 запущен на UDP/${HYSTERIA_PORT}" print_hysteria_client_config else err "Hysteria 2 не запустился. Лог:" journalctl -u hysteria -n 30 --no-pager return 1 fi } cmd_hysteria_change_port() { load_config local requested_port="${1:-}" local old_port="${HYSTERIA_PORT:-8443}" hr echo -e "${BOLD} Смена UDP порта Hysteria 2${RESET}" hr echo -e " Текущий порт: ${CYAN}UDP/${old_port}${RESET}" echo choose_hysteria_port "$requested_port" || return 1 if [[ "$old_port" == "${HYSTERIA_PORT}" ]]; then ok "Порт не изменился" save_config return 0 fi save_config if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then write_hysteria_config || return 1 ufw delete allow "${old_port}/udp" >/dev/null 2>&1 || true ufw allow "${HYSTERIA_PORT}/udp" comment "Hysteria2 QUIC" >/dev/null 2>&1 || true apply_hysteria_port_hop || return 1 if systemctl restart hysteria && sleep 2 && systemctl is-active --quiet hysteria; then ok "Hysteria 2 перезапущен на UDP/${HYSTERIA_PORT}" else err "Hysteria 2 не запустился после смены порта. Лог:" journalctl -u hysteria -n 30 --no-pager return 1 fi fi } cmd_hysteria_hop_enable() { load_config local range="${1:-${HYSTERIA_PORT_HOP_PORTS:-$HYSTERIA_PORT_HOP_PORTS_DEFAULT}}" HYSTERIA_PORT_HOP_PORTS="$range" HYSTERIA_PORT_HOP_ENABLED="1" HYSTERIA_PORT_HOP_PORTS="$(hysteria_port_hop_range)" save_config apply_hysteria_port_hop || return 1 if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then write_hysteria_config || return 1 systemctl restart hysteria || return 1 sleep 1 systemctl is-active --quiet hysteria || return 1 fi ok "Hysteria 2 Port Hopping включён: UDP/$(hysteria_port_hop_range) -> UDP/${HYSTERIA_PORT:-8443}" } cmd_hysteria_hop_disable() { load_config local hop_range hop_ufw_range hop_range=$(hysteria_port_hop_range) hop_ufw_range="${hop_range/-/:}" HYSTERIA_PORT_HOP_ENABLED="0" save_config remove_hysteria_port_hop_rules if command -v ufw >/dev/null 2>&1; then ufw delete allow "${hop_ufw_range}/udp" >/dev/null 2>&1 || true fi ok "Hysteria 2 Port Hopping выключен" } cmd_hysteria_status() { load_config local hop_range hop_ufw_range hop_range=$(hysteria_port_hop_range) hop_ufw_range="${hop_range/-/:}" hr echo -e "${BOLD} Hysteria 2 статус${RESET}" hr if [[ -x "$HYSTERIA_BIN" ]]; then ok "Бинарь: $("$HYSTERIA_BIN" version 2>/dev/null | head -1 || echo "$HYSTERIA_BIN")" else warn "Бинарь не установлен: $HYSTERIA_BIN" fi [[ -f "$HYSTERIA_CONFIG" ]] && ok "Конфиг: $HYSTERIA_CONFIG" || warn "Конфиг не найден" if [[ -f "$HYSTERIA_CONFIG" ]]; then if hysteria_tls_layout_needs_repair; then warn "TLS: старая/неполная схема — запусти hysteria-repair" else ok "TLS: изолированная копия + systemd timer" fi if grep -q 'type: command' "$HYSTERIA_CONFIG"; then ok "Auth: bcrypt verifier ($(get_active_user_hashes | wc -l) пользователей)" else warn "Auth: legacy/plaintext — запусти credentials-migrate" fi if grep -q 'name: warp-socks5' "$HYSTERIA_CONFIG"; then ok "Outbound: WARP SOCKS5 127.0.0.1:${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" else ok "Outbound: direct VPS" fi if hysteria_port_hop_enabled; then ok "Port hopping: UDP/$(hysteria_port_hop_range) -> UDP/${HYSTERIA_PORT:-8443}" else warn "Port hopping: выключен" fi fi systemctl is-active --quiet hysteria 2>/dev/null && ok "Сервис: работает" || warn "Сервис: не работает" ss -ulpn 2>/dev/null | grep -E ":${HYSTERIA_PORT:-8443}([[:space:]]|$)" || warn "UDP/${HYSTERIA_PORT:-8443} не слушается" ufw status 2>/dev/null | grep -E "${HYSTERIA_PORT:-8443}/udp|${hop_ufw_range}/udp|Status" || true hr } cmd_hysteria_logs() { echo -e "${BOLD}Лог Hysteria 2 (Ctrl+C для выхода):${RESET}" journalctl -u hysteria -n 50 -f } cmd_hysteria_remove() { echo -ne "${RED}Удалить Hysteria 2? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return load_config systemctl stop hysteria 2>/dev/null || true systemctl disable hysteria 2>/dev/null || true systemctl disable --now yurich-hysteria-tls-sync.timer >/dev/null 2>&1 || true remove_hysteria_port_hop_rules rm -f "$HYSTERIA_SERVICE" "$HYSTERIA_BIN" "$HYSTERIA_CONFIG" \ "$HYSTERIA_TLS_SYNC_SCRIPT" "$HYSTERIA_TLS_SYNC_SERVICE" "$HYSTERIA_TLS_SYNC_TIMER" rm -rf -- "$HYSTERIA_TLS_DIR" ufw delete allow "${HYSTERIA_PORT:-8443}/udp" >/dev/null 2>&1 || true HYSTERIA_PORT="" HYSTERIA_PASSWORD="" HYSTERIA_OBFS_PASSWORD="" HYSTERIA_ENABLED="0" HYSTERIA_PORT_HOP_ENABLED="0" save_config systemctl daemon-reload ok "Hysteria 2 удалён" } cmd_hysteria_warp_enable() { load_config local port="${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" hr echo -e "${BOLD} Hysteria 2 через WARP proxy${RESET}" hr info "Проверяю WARP local proxy: 127.0.0.1:${port}" if ! test_warp_proxy "$port"; then err "WARP proxy не готов. Сначала включи: sudo bash yurich-panel.sh warp-proxy" return 1 fi HYSTERIA_WARP_ENABLED="1" save_config write_hysteria_config || return 1 if systemctl restart hysteria && sleep 2 && systemctl is-active --quiet hysteria; then ok "Hysteria 2 теперь направляет исходящий трафик через WARP proxy" else err "Hysteria 2 не запустился после включения WARP. Лог:" journalctl -u hysteria -n 30 --no-pager return 1 fi } cmd_hysteria_warp_disable() { load_config hr echo -e "${BOLD} Отключение WARP только для Hysteria 2${RESET}" hr HYSTERIA_WARP_ENABLED="0" save_config write_hysteria_config || return 1 if systemctl restart hysteria && sleep 2 && systemctl is-active --quiet hysteria; then ok "Hysteria 2 снова работает без отдельного WARP outbound" else err "Hysteria 2 не запустился после отключения WARP. Лог:" journalctl -u hysteria -n 30 --no-pager return 1 fi } cmd_hysteria_menu() { load_config while true; do hr echo -e "${BOLD} Hysteria 2${RESET}" hr echo -e " Текущий порт: ${CYAN}UDP/${HYSTERIA_PORT:-8443}${RESET}" echo echo -e " ${BOLD}1)${RESET} Установить / переустановить" echo -e " ${BOLD}2)${RESET} Клиентский конфиг + QR" echo -e " ${BOLD}3)${RESET} Статус" echo -e " ${BOLD}4)${RESET} Логи" echo -e " ${BOLD}5)${RESET} Удалить Hysteria 2" echo -e " ${BOLD}6)${RESET} Изменить UDP порт" echo -e " ${BOLD}7)${RESET} Включить Port Hopping" echo -e " ${BOLD}8)${RESET} Выключить Port Hopping" echo -e " ${BOLD}9)${RESET} Включить WARP только для Hysteria 2" echo -e " ${BOLD}10)${RESET} Выключить WARP только для Hysteria 2" echo -e " ${BOLD}11)${RESET} Исправить TLS / systemd с rollback" echo -e " ${BOLD}0)${RESET} Назад" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_hysteria_install ;; 2) echo -ne "${CYAN}Пользователь (Enter = первый/общий): ${RESET}" read -r hy_user print_hysteria_client_config "$hy_user" ;; 3) cmd_hysteria_status ;; 4) cmd_hysteria_logs ;; 5) cmd_hysteria_remove ;; 6) cmd_hysteria_change_port ;; 7) echo -ne "${CYAN}Диапазон UDP [${HYSTERIA_PORT_HOP_PORTS:-$HYSTERIA_PORT_HOP_PORTS_DEFAULT}]: ${RESET}" read -r hop_range cmd_hysteria_hop_enable "${hop_range:-${HYSTERIA_PORT_HOP_PORTS:-$HYSTERIA_PORT_HOP_PORTS_DEFAULT}}" ;; 8) cmd_hysteria_hop_disable ;; 9) cmd_hysteria_warp_enable ;; 10) cmd_hysteria_warp_disable ;; 11) cmd_hysteria_repair ;; 0) return ;; *) err "Неверный выбор" ;; esac echo -ne "${DIM}Enter для продолжения...${RESET}"; read -r _ done } cmd_hysteria_sync_cli() { load_config load_users if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then sync_hysteria_users_if_active else warn "Hysteria 2 не установлен — sync пропущен" fi } # ─── XRAY MODERN: VLESS / TROJAN / REALITY / FALLBACK ───────── detect_xray_arch() { local arch arch=$(uname -m) case "$arch" in x86_64|amd64) echo "64" ;; aarch64|arm64) echo "arm64-v8a" ;; armv7l|armv7*) echo "arm32-v7a" ;; i386|i686) echo "32" ;; *) err "Архитектура $arch не поддерживается Xray автоустановкой"; return 1 ;; esac } install_xray_bin() { if [[ -x "$XRAY_BIN" ]]; then ok "Xray уже установлен: $("$XRAY_BIN" version 2>/dev/null | head -1 || echo "$XRAY_BIN")" return 0 fi local arch url tmp tmp_dgst zip_dir expected actual version_output expected_version staging_bin="" archive_size arch=$(detect_xray_arch) || return 1 [[ "$XRAY_VERSION_PIN" == "latest" || "$XRAY_VERSION_PIN" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$ ]] || { err "Некорректный Xray version pin: $XRAY_VERSION_PIN" return 1 } if [[ "${XRAY_VERSION_PIN:-latest}" == "latest" ]]; then url="https://github.com/XTLS/Xray-core/releases/latest/download/Xray-linux-${arch}.zip" else url="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION_PIN}/Xray-linux-${arch}.zip" fi tmp=$(mktemp /tmp/xray_XXXXXX.zip) tmp_dgst=$(mktemp /tmp/xray_dgst_XXXXXX) zip_dir=$(mktemp -d /tmp/xray_unzip_XXXXXX) trap 'rm -f "${tmp:-}" "${tmp_dgst:-}" 2>/dev/null; rm -rf "${zip_dir:-}" 2>/dev/null; trap - RETURN' RETURN info "Скачиваю Xray-core (${arch})..." if ! curl -fsSL --retry 3 --connect-timeout 15 --max-time 180 -o "$tmp" -- "$url"; then err "Не удалось скачать Xray: $url" return 1 fi archive_size=$(stat -c '%s' "$tmp" 2>/dev/null || echo 0) [[ "$archive_size" =~ ^[0-9]+$ && "$archive_size" -le 268435456 ]] || { err "Xray archive превышает лимит 256 MiB" return 1 } info "Xray release pin: ${XRAY_VERSION_PIN:-latest}" command -v unzip &>/dev/null || apt-get install -y -q unzip if curl -fsSL --connect-timeout 10 --max-time 30 "${url}.dgst" -o "$tmp_dgst" 2>/dev/null; then expected=$(awk '{for(i=1;i<=NF;i++) if($i ~ /^[a-fA-F0-9]{64}$/){print tolower($i); exit}}' "$tmp_dgst" | head -1) if [[ -n "$expected" ]]; then actual=$(sha256sum "$tmp" | awk '{print $1}') if [[ "$actual" != "$expected" ]]; then err "SHA256 Xray не совпадает. Установка остановлена." return 1 fi ok "SHA256 Xray подтверждён" elif [[ "${YURICH_ALLOW_UNVERIFIED_DOWNLOADS:-0}" == "1" ]]; then warn "Не нашёл SHA256 в ${url}.dgst, продолжаю из-за YURICH_ALLOW_UNVERIFIED_DOWNLOADS=1" else err "Не нашёл SHA256 в ${url}.dgst. Установка Xray остановлена." return 1 fi elif [[ "${YURICH_ALLOW_UNVERIFIED_DOWNLOADS:-0}" == "1" ]]; then warn "Не удалось скачать ${url}.dgst, продолжаю из-за YURICH_ALLOW_UNVERIFIED_DOWNLOADS=1" else err "Не удалось скачать ${url}.dgst. Установка Xray остановлена." return 1 fi if ! validate_zip_archive_safe "$tmp" 10000 536870912; then err "Xray ZIP не прошёл безопасную проверку" return 1 fi if ! unzip -Z1 "$tmp" 2>/dev/null \ | awk '$0 == "xray" { found=1 } END { exit(found ? 0 : 1) }'; then err "Xray zip не содержит ожидаемый бинарник xray" return 1 fi unzip -q "$tmp" -d "$zip_dir" chmod 755 "$zip_dir/xray" version_output=$("$zip_dir/xray" version 2>&1) || { err "Скачанный Xray binary не запускается" return 1 } if [[ "${XRAY_VERSION_PIN:-latest}" != "latest" ]]; then expected_version="${XRAY_VERSION_PIN#v}" [[ "$version_output" == *"$expected_version"* ]] || { err "Версия Xray не совпадает: ожидалась ${XRAY_VERSION_PIN}" return 1 } fi staging_bin=$(mktemp "${XRAY_BIN}.install.XXXXXX") || return 1 if ! install -m 755 "$zip_dir/xray" "$staging_bin" || ! mv -f -- "$staging_bin" "$XRAY_BIN"; then rm -f "$staging_bin" err "Не удалось атомарно установить Xray" return 1 fi mkdir -p /usr/local/share/xray [[ -f "$zip_dir/geoip.dat" ]] && install -m 644 "$zip_dir/geoip.dat" /usr/local/share/xray/geoip.dat [[ -f "$zip_dir/geosite.dat" ]] && install -m 644 "$zip_dir/geosite.dat" /usr/local/share/xray/geosite.dat ok "Xray установлен: $(printf '%s\n' "$version_output" | head -1)" } load_xray_users() { mkdir -p "$CONFIG_DIR" local default_user default_user="${1:-xray}" if [[ ! -s "$XRAY_USERS_FILE" ]]; then xray_ensure_user "$default_user" >/dev/null || return 1 elif is_valid_proxy_user "$default_user"; then xray_ensure_user "$default_user" >/dev/null || return 1 fi } is_valid_xray_uuid() { [[ "${1:-}" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] } get_xray_user_uuid() { local lookup_user="$1" local uuid [[ -f "$XRAY_USERS_FILE" ]] || return 1 uuid=$(awk -F: -v user="$lookup_user" '$1 == user {print $2; exit}' "$XRAY_USERS_FILE") is_valid_xray_uuid "$uuid" || return 1 printf '%s\n' "$uuid" } get_xray_compat_user_uuid() { local lookup_user="$1" local uuid [[ -f "$XRAY_COMPAT_USERS_FILE" ]] || return 1 uuid=$(awk -F: -v user="$lookup_user" '$1 == user {print $2; exit}' "$XRAY_COMPAT_USERS_FILE") is_valid_xray_uuid "$uuid" || return 1 printf '%s\n' "$uuid" } xray_generate_uuid() { local uuid raw uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || true) if ! is_valid_xray_uuid "$uuid" && [[ -x "$XRAY_BIN" ]]; then uuid=$("$XRAY_BIN" uuid 2>/dev/null | head -1 || true) fi if ! is_valid_xray_uuid "$uuid"; then raw=$(openssl rand -hex 16) uuid="${raw:0:8}-${raw:8:4}-${raw:12:4}-${raw:16:4}-${raw:20:12}" fi printf '%s\n' "$uuid" } xray_ensure_user() { local user="$1" local uuid if ! is_valid_proxy_user "$user"; then err "Логин Xray: 2-32 символа, только A-Z a-z 0-9 _ -" return 1 fi mkdir -p "$CONFIG_DIR" touch "$XRAY_USERS_FILE" chmod 600 "$XRAY_USERS_FILE" if uuid=$(get_xray_user_uuid "$user" 2>/dev/null); then printf '%s\n' "$uuid" return 0 fi uuid=$(xray_generate_uuid) printf '%s:%s\n' "$user" "$uuid" >> "$XRAY_USERS_FILE" chmod 600 "$XRAY_USERS_FILE" printf '%s\n' "$uuid" } xray_ensure_compat_user() { local user="$1" local uuid if ! is_valid_proxy_user "$user"; then err "Логин Xray: 2-32 символа, только A-Z a-z 0-9 _ -" return 1 fi mkdir -p "$CONFIG_DIR" touch "$XRAY_COMPAT_USERS_FILE" chmod 600 "$XRAY_COMPAT_USERS_FILE" if uuid=$(get_xray_compat_user_uuid "$user" 2>/dev/null); then printf '%s\n' "$uuid" return 0 fi uuid=$(xray_generate_uuid) printf '%s:%s\n' "$user" "$uuid" >> "$XRAY_COMPAT_USERS_FILE" chmod 600 "$XRAY_COMPAT_USERS_FILE" printf '%s\n' "$uuid" } xray_active_user_count() { local count=0 user uuid while IFS=: read -r user uuid; do [[ -z "$user" || -z "$uuid" ]] && continue if ! user_is_expired "$user"; then count=$((count + 1)) fi done < <(grep -v '^#\|^[[:space:]]*$' "$XRAY_USERS_FILE" 2>/dev/null || true) printf '%s\n' "$count" } xray_clients_json() { local first=1 user uuid while IFS=: read -r user uuid; do [[ -z "$user" || -z "$uuid" ]] && continue user_is_expired "$user" && continue if [[ "$first" -eq 0 ]]; then printf ',\n'; fi first=0 printf ' { "id": "%s", "email": "%s", "flow": "xtls-rprx-vision" }' "$uuid" "$user" done < "$XRAY_USERS_FILE" } xray_clients_json_no_flow() { local allowed_users="${1:-all}" first=1 user uuid while IFS=: read -r user uuid; do [[ -z "$user" || -z "$uuid" ]] && continue user_is_expired "$user" && continue subscription_name_in_list "$user" "$allowed_users" || continue if [[ "$first" -eq 0 ]]; then printf ',\n'; fi first=0 printf ' { "id": "%s", "email": "%s" }' "$uuid" "$user" done < "$XRAY_USERS_FILE" } xray_clients_json_reality() { local first=1 user uuid while IFS=: read -r user uuid; do [[ -z "$user" || -z "$uuid" || "$user" == \#* ]] && continue user_is_expired "$user" && continue if [[ "$first" -eq 0 ]]; then printf ',\n'; fi first=0 printf ' { "id": "%s", "email": "%s", "flow": "xtls-rprx-vision" }' "$uuid" "$user" done < "$XRAY_USERS_FILE" [[ -f "$XRAY_COMPAT_USERS_FILE" ]] || return 0 while IFS=: read -r user uuid; do [[ -z "$user" || -z "$uuid" || "$user" == \#* ]] && continue is_valid_xray_uuid "$uuid" || continue user_is_expired "$user" && continue if [[ "$first" -eq 0 ]]; then printf ',\n'; fi first=0 printf ' { "id": "%s", "email": "%s-compat" }' "$uuid" "$user" done < "$XRAY_COMPAT_USERS_FILE" } xray_clients_json_compat_only() { local first=1 user uuid [[ -f "$XRAY_COMPAT_USERS_FILE" ]] || return 0 while IFS=: read -r user uuid; do [[ -z "$user" || -z "$uuid" || "$user" == \#* ]] && continue is_valid_xray_uuid "$uuid" || continue user_is_expired "$user" && continue if [[ "$first" -eq 0 ]]; then printf ',\n'; fi first=0 printf ' { "id": "%s", "email": "%s-mobile-alt" }' "$uuid" "$user" done < "$XRAY_COMPAT_USERS_FILE" } xray_reality_key_valid() { local key="${1:-}" decoded_size [[ "$key" =~ ^[A-Za-z0-9_-]{43}$ ]] || return 1 command -v openssl >/dev/null 2>&1 || return 1 decoded_size=$(printf '%s=' "$key" \ | tr '_-' '/+' \ | openssl base64 -d -A 2>/dev/null \ | wc -c \ | tr -d '[:space:]') [[ "$decoded_size" == "32" ]] } xray_extract_reality_key() { local kind="$1" output="$2" value="" case "$kind" in private) value=$(printf '%s\n' "$output" | awk -F':' ' { label=tolower($1); if (label ~ /private/) { value=$0; sub(/^[^:]*:[[:space:]]*/, "", value); gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); print value; exit; } } ') ;; public) value=$(printf '%s\n' "$output" | awk -F':' ' { label=tolower($1); if (label ~ /public/ || label ~ /password/) { value=$0; sub(/^[^:]*:[[:space:]]*/, "", value); gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); print value; exit; } } ') ;; esac if xray_reality_key_valid "$value"; then printf '%s\n' "$value" return 0 fi # Last-resort parser for compact outputs where labels changed. local -a tokens mapfile -t tokens < <(printf '%s\n' "$output" | grep -Eo '[A-Za-z0-9_-]{30,100}' || true) if [[ "$kind" == "private" && "${#tokens[@]}" -ge 1 ]] && xray_reality_key_valid "${tokens[0]}"; then printf '%s\n' "${tokens[0]}" return 0 fi if [[ "$kind" == "public" && "${#tokens[@]}" -ge 2 ]] && xray_reality_key_valid "${tokens[1]}"; then printf '%s\n' "${tokens[1]}" return 0 fi return 1 } xray_derive_reality_public_key() { local private_key="$1" public_key xray_reality_key_valid "$private_key" || return 1 command -v openssl >/dev/null 2>&1 || return 1 # RFC 8410 PKCS#8 wrapper lets OpenSSL derive the public X25519 key # without exposing the stored private key in /proc//cmdline. public_key=$( { printf '%b' '\x30\x2e\x02\x01\x00\x30\x05\x06\x03\x2b\x65\x6e\x04\x22\x04\x20' printf '%s=' "$private_key" | tr '_-' '/+' | openssl base64 -d -A } \ | openssl pkey -inform DER -pubout -outform DER 2>/dev/null \ | tail -c 32 \ | openssl base64 -A \ | tr '/+' '_-' \ | tr -d '=' ) || return 1 xray_reality_key_valid "$public_key" || return 1 printf '%s\n' "$public_key" } ensure_xray_reality_keys() { XRAY_REALITY_SHORT_ID="${XRAY_REALITY_SHORT_ID:-$(openssl rand -hex 8)}" local derived_public="" if xray_reality_key_valid "${XRAY_REALITY_PRIVATE_KEY:-}"; then derived_public=$(xray_derive_reality_public_key "$XRAY_REALITY_PRIVATE_KEY" 2>/dev/null || true) if ! xray_reality_key_valid "$derived_public"; then err "Не смог вычислить публичный REALITY ключ из сохранённого приватного ключа" return 1 fi if [[ "${XRAY_REALITY_PUBLIC_KEY:-}" != "$derived_public" ]]; then warn "Публичный REALITY ключ не соответствовал приватному и был безопасно восстановлен" XRAY_REALITY_PUBLIC_KEY="$derived_public" fi else local key_out key_out=$("$XRAY_BIN" x25519 2>&1 || true) XRAY_REALITY_PRIVATE_KEY=$(xray_extract_reality_key private "$key_out" 2>/dev/null || true) XRAY_REALITY_PUBLIC_KEY=$(xray_extract_reality_key public "$key_out" 2>/dev/null || true) if ! xray_reality_key_valid "${XRAY_REALITY_PRIVATE_KEY:-}" || ! xray_reality_key_valid "${XRAY_REALITY_PUBLIC_KEY:-}"; then warn "xray x25519 вывел неожиданный формат; ключевой материал скрыт" fi fi if ! xray_reality_key_valid "${XRAY_REALITY_PRIVATE_KEY:-}" || ! xray_reality_key_valid "${XRAY_REALITY_PUBLIC_KEY:-}"; then err "Не смог сгенерировать REALITY ключи: xray x25519" warn "Проверь вручную: ${XRAY_BIN} x25519" return 1 fi derived_public=$(xray_derive_reality_public_key "$XRAY_REALITY_PRIVATE_KEY" 2>/dev/null || true) if [[ -z "$derived_public" || "$XRAY_REALITY_PUBLIC_KEY" != "$derived_public" ]]; then err "REALITY keypair не прошла проверку соответствия" return 1 fi } xray_reality_target_presets() { cat <<'EOF' RU|ya.ru:443|Yandex short RU|www.yandex.ru:443|Yandex RU|www.ozon.ru:443|Ozon RU|www.wildberries.ru:443|Wildberries RU|www.avito.ru:443|Avito RU|www.rbc.ru:443|RBC RU|www.drom.ru:443|Drom RU|www.vk.com:443|VK GLOBAL|www.microsoft.com:443|Microsoft GLOBAL|www.apple.com:443|Apple GLOBAL|www.cloudflare.com:443|Cloudflare GLOBAL|www.ubuntu.com:443|Ubuntu GLOBAL|www.debian.org:443|Debian GLOBAL|www.mozilla.org:443|Mozilla GLOBAL|www.python.org:443|Python GLOBAL|www.wikipedia.org:443|Wikipedia EOF } normalize_reality_target() { local raw="${1:-}" host port raw="${raw#https://}" raw="${raw#http://}" raw="${raw%%/*}" raw="${raw//[[:space:]]/}" [[ -n "$raw" ]] || return 1 if [[ "$raw" == *:* ]]; then host="${raw%%:*}" port="${raw##*:}" else host="$raw" port="443" fi is_valid_domain "$host" || return 1 is_valid_port "$port" || return 1 printf '%s:%s\n' "$host" "$port" } test_reality_target_tls() { local target="$1" host port out target=$(normalize_reality_target "$target") || return 2 host="${target%%:*}" port="${target##*:}" command -v openssl >/dev/null 2>&1 || return 3 out=$(echo | timeout 8 openssl s_client \ -connect "${host}:${port}" \ -servername "$host" \ -alpn h2,http/1.1 \ -brief 2>&1 || true) if printf '%s\n' "$out" | grep -Eq 'CONNECTION ESTABLISHED|Protocol version|Ciphersuite'; then return 0 fi return 1 } prompt_xray_reality_target() { local current="${XRAY_REALITY_TARGET:-www.microsoft.com:443}" choice target label group desc i ans current=$(normalize_reality_target "$current" 2>/dev/null || printf 'www.microsoft.com:443') echo echo -e "${BOLD}REALITY target presets${RESET}" echo -e "${DIM}Это кандидаты для TLS/SNI target. Доступность меняется, поэтому скрипт проверит выбранный домен с сервера.${RESET}" echo -e "${DIM}Enter = оставить текущий: ${current}${RESET}" echo i=1 while IFS='|' read -r group target desc; do printf ' %2d) [%s] %-28s %s\n' "$i" "$group" "$target" "$desc" i=$((i + 1)) done < <(xray_reality_target_presets) echo " c) свой домен:порт" echo echo -ne "${CYAN}REALITY target choice [Enter = ${current}]: ${RESET}" read -r choice if [[ -z "$choice" ]]; then target="$current" elif [[ "${choice,,}" == "c" || "${choice,,}" == "custom" ]]; then echo -ne "${CYAN}Custom REALITY target [domain:443]: ${RESET}" read -r target elif [[ "$choice" =~ ^[0-9]+$ ]]; then target=$(xray_reality_target_presets | awk -F'|' -v n="$choice" 'NR==n {print $2; exit}') if [[ -z "$target" ]]; then warn "Нет такого номера, оставляю текущий target: $current" target="$current" fi else target="$choice" fi target=$(normalize_reality_target "$target" 2>/dev/null || true) if [[ -z "$target" ]]; then err "REALITY target должен быть domain:port, например www.microsoft.com:443" return 1 fi info "Проверяю TLS/SNI для ${target}..." if test_reality_target_tls "$target"; then ok "REALITY target отвечает по TLS: ${target}" else warn "TLS-проверка ${target} не прошла с этого сервера." warn "Это не всегда значит, что target плохой, но лучше выбрать другой кандидат." echo -ne "${YELLOW}Использовать всё равно? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return 1 fi XRAY_REALITY_TARGET="$target" XRAY_REALITY_SERVER_NAME="${target%%:*}" ok "REALITY target выбран: ${XRAY_REALITY_TARGET}" } write_xray_logrotate() { cat > "$XRAY_LOGROTATE_CONF" <<'EOF' /var/log/xray/access.log /var/log/xray/error.log { daily rotate 14 size 50M missingok notifempty compress delaycompress copytruncate create 0600 root root } EOF chmod 644 "$XRAY_LOGROTATE_CONF" } write_xray_service() { cat > "$XRAY_SERVICE" </dev/null 2>&1; then curl -fsSL --connect-timeout 10 --max-time 60 -o "$tmp" -- "$url" 2>/dev/null || true elif command -v wget >/dev/null 2>&1; then wget -q -O "$tmp" -- "$url" 2>/dev/null || true fi if [[ -s "$tmp" ]]; then mv -f "$tmp" "$dat" chmod 644 "$dat" 2>/dev/null || true ok "Xray zapret.dat готов: $dat" return 0 fi rm -f "$tmp" warn "zapret.dat не скачался, Xray routing будет собран без zapret-правила" return 1 } xray_local_dns_ready() { [[ "${UNBOUND_ENABLED:-0}" == "1" ]] || return 1 command -v systemctl >/dev/null 2>&1 || return 1 systemctl is-active --quiet unbound 2>/dev/null || return 1 local_tcp_endpoint_ready 53 } write_xray_config() { load_config local seed_user="${1:-}" if [[ -n "$seed_user" ]]; then load_xray_users "$seed_user" elif [[ ! -s "$XRAY_USERS_FILE" ]]; then load_xray_users "xray" fi ensure_xray_reality_keys || return 1 local cert key fallback_enabled fallback_port reality_enabled reality_port reality_listen xhttp_port mobile_alt_port mobile_alt_target mobile_alt_sni github_test_port github_test_target github_test_sni trojan_pass reality_target reality_sni zapret_enabled xray_config_backup xray_warp_enabled xray_filtered_config xhttp_clients_json fallback_enabled="${XRAY_FALLBACK_ENABLED:-0}" fallback_port="${XRAY_CADDY_FALLBACK_PORT:-$XRAY_CADDY_FALLBACK_PORT_DEFAULT}" reality_enabled="${XRAY_REALITY_ENABLED:-1}" reality_port="${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}" xhttp_port="${XRAY_XHTTP_PORT:-$XRAY_XHTTP_PORT_DEFAULT}" mobile_alt_port="${XRAY_MOBILE_ALT_PORT:-$XRAY_MOBILE_ALT_PORT_DEFAULT}" github_test_port="${XRAY_GITHUB_TEST_PORT:-$XRAY_GITHUB_TEST_PORT_DEFAULT}" reality_listen="0.0.0.0" if [[ "${XRAY_REALITY_SNI_MUX_ENABLED:-0}" == "1" ]]; then reality_listen="127.0.0.1" fi trojan_pass="${XRAY_TROJAN_PASSWORD:-$(random_safe_token 24)}" XRAY_TROJAN_PASSWORD="$trojan_pass" reality_target="${XRAY_REALITY_TARGET:-www.microsoft.com:443}" reality_sni="${XRAY_REALITY_SERVER_NAME:-www.microsoft.com}" mobile_alt_target="${XRAY_MOBILE_ALT_TARGET:-$XRAY_MOBILE_ALT_TARGET_DEFAULT}" mobile_alt_sni="${XRAY_MOBILE_ALT_SERVER_NAME:-$XRAY_MOBILE_ALT_SERVER_NAME_DEFAULT}" github_test_target="${XRAY_GITHUB_TEST_TARGET:-$XRAY_GITHUB_TEST_TARGET_DEFAULT}" github_test_sni="${XRAY_GITHUB_TEST_SERVER_NAME:-$XRAY_GITHUB_TEST_SERVER_NAME_DEFAULT}" zapret_enabled="0" xray_warp_enabled="${XRAY_WARP_ENABLED:-${WARP_PROXY_ENABLED:-0}}" [[ "$xray_warp_enabled" == "1" ]] || xray_warp_enabled="0" if [[ "$reality_enabled" != "1" && "${XRAY_XHTTP_ENABLED:-0}" != "1" && "$fallback_enabled" != "1" ]]; then err "Xray не имеет ни одного включённого inbound: Reality, XHTTP и fallback отключены" return 1 fi xhttp_clients_json="" if [[ "${XRAY_XHTTP_ENABLED:-0}" == "1" ]]; then xhttp_clients_json=$(xray_clients_json_no_flow "${XRAY_XHTTP_ALLOWED_USERS:-all}") if [[ -z "$xhttp_clients_json" ]]; then err "XHTTP allowlist не содержит активных Xray-пользователей: ${XRAY_XHTTP_ALLOWED_USERS:-all}" return 1 fi fi if [[ "${XRAY_GITHUB_TEST_ENABLED:-0}" == "1" ]]; then if ! is_valid_domain "$github_test_sni"; then err "Некорректный XRAY_GITHUB_TEST_SERVER_NAME: $github_test_sni" return 1 fi if ! is_valid_host_port "$github_test_target"; then err "Некорректный XRAY_GITHUB_TEST_TARGET: $github_test_target" return 1 fi fi cert="" key="" if [[ "$fallback_enabled" == "1" ]]; then cert=$(find_caddy_cert "${DOMAIN:-}") || true key=$(find_caddy_key "${DOMAIN:-}") || true if [[ -z "$cert" || -z "$key" ]]; then err "Для Xray TLS/fallback нужен TLS сертификат Caddy. Сначала запусти Yurich Panel и дождись сертификата." return 1 fi fi mkdir -p "$XRAY_CONFIG_DIR" /var/log/xray xray_config_backup="" if [[ -s "$XRAY_CONFIG" ]]; then xray_config_backup=$(mktemp) cp -p "$XRAY_CONFIG" "$xray_config_backup" 2>/dev/null || xray_config_backup="" fi if [[ "${XRAY_ZAPRET_ENABLED:-0}" == "1" ]]; then warn "Xray zapret blackhole routing отключён: этот режим ломал Instagram/Facebook. Флаг будет сброшен." XRAY_ZAPRET_ENABLED="0" fi cat > "$XRAY_CONFIG" <> "$XRAY_CONFIG" <<'EOF' "dns": { "servers": [ { "address": "127.0.0.1", "port": 53, "queryStrategy": "UseIPv4", "skipFallback": true } ], "queryStrategy": "UseIPv4", "disableFallback": true }, EOF elif [[ "${UNBOUND_ENABLED:-0}" == "1" ]]; then warn "Unbound включён в конфиге, но 127.0.0.1:53 сейчас недоступен; Xray использует системный DNS" fi cat >> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" <> "$XRAY_CONFIG" < "$xray_filtered_config"; then rm -f "$xray_filtered_config" err "Не удалось применить роль Xray без Reality" if [[ -n "$xray_config_backup" && -s "$xray_config_backup" ]]; then mv -f "$xray_config_backup" "$XRAY_CONFIG" else rm -f "$XRAY_CONFIG" fi return 1 fi mv -f "$xray_filtered_config" "$XRAY_CONFIG" fi chmod 600 "$XRAY_CONFIG" if ! "$XRAY_BIN" run -test -config "$XRAY_CONFIG" >/dev/null 2>&1; then err "Xray config не прошёл проверку" "$XRAY_BIN" run -test -config "$XRAY_CONFIG" || true if [[ -n "$xray_config_backup" && -s "$xray_config_backup" ]]; then mv -f "$xray_config_backup" "$XRAY_CONFIG" chmod 600 "$XRAY_CONFIG" 2>/dev/null || true warn "Рабочий Xray config восстановлен из бэкапа" else rm -f "$XRAY_CONFIG" 2>/dev/null || true warn "Новый нерабочий Xray config удалён" fi return 1 fi [[ -n "$xray_config_backup" ]] && rm -f "$xray_config_backup" 2>/dev/null || true save_config ok "Xray config создан: $XRAY_CONFIG" if [[ "$xray_warp_enabled" == "1" ]]; then ok "Xray outbound направлен через WARP proxy: 127.0.0.1:${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" else ok "Xray outbound: direct VPS" fi } print_xray_client_config() { load_config [[ -s "$XRAY_USERS_FILE" ]] || load_xray_users "xray" local user="${1:-}" local uuid if [[ -z "$user" ]]; then while IFS=: read -r candidate _; do [[ -z "$candidate" ]] && continue if ! user_is_expired "$candidate"; then user="$candidate" break fi done < "$XRAY_USERS_FILE" fi if user_is_expired "$user"; then err "Xray пользователь $user истёк: $(user_expiry_label "$user")" return 1 fi uuid=$(awk -F: -v u="$user" '$1 == u {print $2; exit}' "$XRAY_USERS_FILE") [[ -z "$uuid" ]] && { err "Xray пользователь $user не найден"; return 1; } reality_public_port=$(xray_reality_public_port) hr echo -e "${BOLD}${GREEN} Xray Modern client config${RESET}" hr echo -e " User: ${BOLD}${user}${RESET}" echo echo -e "${CYAN} VLESS REALITY TCP:${RESET}" echo " vless://${uuid}@${DOMAIN}:${reality_public_port}?encryption=none&security=reality&type=tcp&flow=xtls-rprx-vision&sni=${XRAY_REALITY_SERVER_NAME:-www.microsoft.com}&fp=chrome&pbk=${XRAY_REALITY_PUBLIC_KEY:-PUBLIC_KEY}&sid=${XRAY_REALITY_SHORT_ID:-SHORT_ID}&spx=%2F#${user}-reality" if [[ -n "$(yurich_dns_client_ip)" ]]; then echo echo -e "${CYAN} DNS (Unbound) для full TUN/sing-box:${RESET}" echo -e " DNS server: ${GREEN}tcp://$(yurich_dns_client_ip):53${RESET}" echo -e " detour: ${GREEN}xray-out${RESET} (или тег твоего Xray outbound в клиенте)" fi hr } # ─── ПОДПИСКИ И ПЕРСОНАЛЬНЫЕ WEB-СТРАНИЦЫ ───────────────────── html_escape_text() { printf '%s' "${1:-}" | sed 's/&/\&/g; s//\>/g; s/"/\"/g' } uri_fragment_decode() { local value="${1:-}" if command -v python3 >/dev/null 2>&1; then VALUE="$value" python3 - <<'PY' import os import urllib.parse print(urllib.parse.unquote(os.environ.get("VALUE", "")).strip()) PY else printf '%s' "$value" | sed 's/%20/ /g; s/%E2%80%A2/•/g' fi } subscription_human_bytes() { local bytes="${1:-0}" bytes="${bytes//[^0-9]/}" bytes="${bytes:-0}" if command -v numfmt >/dev/null 2>&1; then numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || printf '%s B\n' "$bytes" else printf '%s B\n' "$bytes" fi } subscription_traffic_cache_path() { printf '%s\n' "${SUBSCRIPTION_TRAFFIC_CACHE_FILE:-/tmp/yurich-subscription-traffic-cache.tsv}" } subscription_traffic_cache_fresh() { local cache="$1" max_age now mtime age max_age="${SUBSCRIPTION_TRAFFIC_REFRESH_SECONDS:-300}" is_canonical_uint_in_range "$max_age" 1 86400 || max_age=300 [[ -s "$cache" ]] || return 1 now=$(date +%s 2>/dev/null || echo 0) mtime=$(stat -c %Y "$cache" 2>/dev/null || echo 0) [[ "$now" =~ ^[0-9]+$ && "$mtime" =~ ^[0-9]+$ ]] || return 1 age=$((now - mtime)) [[ "$age" -ge 0 && "$age" -le "$max_age" ]] } subscription_build_traffic_cache() { local cache tmp logs=() cache=$(subscription_traffic_cache_path) subscription_traffic_cache_fresh "$cache" && return 0 mapfile -t logs < <(device_log_files) [[ "${#logs[@]}" -gt 0 ]] || return 1 tmp=$(mktemp) if command -v python3 >/dev/null 2>&1; then python3 - "$tmp" "${logs[@]}" <<'PY' import gzip import json import sys from collections import defaultdict out_path = sys.argv[1] paths = sys.argv[2:] totals = defaultdict(int) def open_log(path): if path.endswith(".gz"): return gzip.open(path, "rt", encoding="utf-8", errors="ignore") return open(path, "rt", encoding="utf-8", errors="ignore") for path in paths: try: fh = open_log(path) except OSError: continue with fh: for line in fh: line = line.strip() if not line or '"user_id"' not in line: continue try: row = json.loads(line) except Exception: continue user = str(row.get("user_id") or "") if not user: continue total = 0 for key in ("bytes_read", "size"): value = row.get(key, 0) if isinstance(value, (int, float)) and value > 0: total += int(value) if total > 0: totals[user] += total with open(out_path, "w", encoding="utf-8") as out: for user in sorted(totals): out.write(f"{user}\t{totals[user]}\n") PY else awk ' /"user_id":/ { user=""; br=0; sz=0; tmp=$0; sub(/^.*"user_id":"/, "", tmp); sub(/".*$/, "", tmp); user=tmp; if (user == "") next; tmp=$0; if (tmp ~ /"bytes_read":[0-9]+/) { sub(/^.*"bytes_read":/, "", tmp); sub(/[^0-9].*$/, "", tmp); br=tmp+0; } tmp=$0; if (tmp ~ /"size":[0-9]+/) { sub(/^.*"size":/, "", tmp); sub(/[^0-9].*$/, "", tmp); sz=tmp+0; } if (br + sz > 0) totals[user] += br + sz; } END { for (u in totals) print u "\t" totals[u]; } ' "${logs[@]}" > "$tmp" fi install -m 600 "$tmp" "$cache" 2>/dev/null || cp "$tmp" "$cache" rm -f "$tmp" [[ -s "$cache" ]] } subscription_refresh_traffic_meta() { local user="$1" cache bytes is_valid_proxy_user "$user" || return 1 subscription_build_traffic_cache || return 1 cache=$(subscription_traffic_cache_path) bytes=$(awk -F'\t' -v u="$user" '$1 == u {print $2; exit}' "$cache" 2>/dev/null || true) [[ "$bytes" =~ ^[0-9]+$ ]] || return 1 user_meta_set "$user" TRAFFIC_USED_BYTES "$bytes" >/dev/null 2>&1 || return 1 user_meta_set "$user" TRAFFIC_UPDATED_AT "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >/dev/null 2>&1 || true } subscription_traffic_summary() { local user="$1" used_bytes user_used updated_at used_bytes=$(user_meta_get "$user" TRAFFIC_USED_BYTES 2>/dev/null || true) if [[ "$used_bytes" =~ ^[0-9]+$ && "$used_bytes" -gt 0 ]]; then user_used=$(subscription_human_bytes "$used_bytes") updated_at=$(user_meta_get "$user" TRAFFIC_UPDATED_AT 2>/dev/null || true) if [[ -n "$updated_at" ]]; then printf 'По доступным логам Naive HTTPS: %s. Обновлено: %s.' "$user_used" "$updated_at" else printf 'По доступным логам Naive HTTPS: %s.' "$user_used" fi else printf 'По доступным логам Naive HTTPS активность пока не найдена. VLESS/Turbo требуют отдельного stats-сборщика.' fi } subscription_remnawave_page_enabled() { local user="${1:-}" list="${SUBSCRIPTION_REMWAVE_USERS:-all}" item [[ -n "$user" ]] || return 1 for item in $list; do case "$item" in "*"|"all"|"$user") return 0 ;; esac done return 1 } subscription_remnawave_preview_page_enabled() { local user="${1:-}" list="${SUBSCRIPTION_REMWAVE_PREVIEW_USERS:-all}" item [[ -n "$user" ]] || return 1 for item in $list; do case "$item" in "*"|"all"|"$user") return 0 ;; esac done return 1 } subscription_premium_v2_page_enabled() { local user="${1:-}" list="${SUBSCRIPTION_PREMIUM_V2_USERS:-all}" item [[ -n "$user" ]] || return 1 for item in $list; do case "$item" in "*"|"all"|"$user") return 0 ;; esac done return 1 } subscription_profile_protocol() { local uri="${1:-}" case "$uri" in naive+https://*) printf 'HTTPS' ;; hy2://*|hysteria2://*) printf 'Turbo' ;; vless://*) if [[ "$uri" == *"security=reality"* ]]; then printf 'Reality' elif [[ "$uri" == *"type=xhttp"* ]]; then printf 'VLESS XHTTP' elif [[ "$uri" == *"type=kcp"* ]]; then printf 'VLESS mKCP' elif [[ "$uri" == *"flow=xtls-rprx-vision"* ]]; then printf 'Reality' else printf 'VLESS' fi ;; yurich://*) printf 'Yurich Connect' ;; *) printf 'Профиль' ;; esac } subscription_profile_host() { local uri="${1:-}" rest host rest="${uri#*://}" rest="${rest#*@}" host="${rest%%/*}" host="${host%%\?*}" host="${host%%#*}" [[ -z "$host" || "$host" == "$uri" ]] && host="${DOMAIN:-сервер}" printf '%s\n' "$host" } subscription_profile_name_from_uri() { local uri="${1:-}" fragment protocol host if [[ "$uri" == *"#"* ]]; then fragment="${uri##*#}" uri_fragment_decode "$fragment" return fi protocol=$(subscription_profile_protocol "$uri") host=$(subscription_profile_host "$uri") printf '%s • %s\n' "$host" "$protocol" } subscription_active_locations_label() { local links="${1:-}" line name location host text while IFS= read -r line; do [[ -z "$line" ]] && continue name=$(subscription_profile_name_from_uri "$line") host=$(subscription_profile_host "$line") location="${name%% • *}" [[ -z "$location" || "$location" == "$name" ]] && continue text="$(printf '%s %s' "$host" "$location" | tr '[:upper:]' '[:lower:]')" case "$text" in *swe*|*stockholm*) location="🇸🇪 SWE" ;; *estonia*|*tallinn*) location="🇪🇪 Estonia" ;; *finland\ 2*|*finland2*|*fi2*) location="🇫🇮 Finland 2" ;; *poland*|*warsaw*|*polska*) location="🇵🇱 Poland" ;; *finland*|*helsinki*|*suomi*) location="🇫🇮 Finland" ;; *germany*|*deutschland*|*frankfurt*) location="🇩🇪 Germany" ;; *united-kingdom*|*united\ kingdom*|*great\ britain*|*london*) location="🇬🇧 United Kingdom" ;; *netherlands*|*amsterdam*) location="🇳🇱 Netherlands" ;; esac printf '%s\n' "$location" done <<< "$links" | awk 'NF && !seen[$0]++ { out = out ? out " / " $0 : $0 } END { print out }' } subscription_profile_cards_html() { local links="${1:-}" line protocol host name safe_line safe_protocol safe_host safe_name count=0 while IFS= read -r line; do [[ -z "$line" ]] && continue count=$((count + 1)) protocol=$(subscription_profile_protocol "$line") host=$(subscription_profile_host "$line") name=$(subscription_profile_name_from_uri "$line") safe_line=$(html_escape_text "$line") safe_protocol=$(html_escape_text "$protocol") safe_host=$(html_escape_text "$host") safe_name=$(html_escape_text "$name") cat <
${safe_protocol} ${safe_host}
${safe_name}
EOF done <<< "$links" if [[ "$count" -eq 0 ]]; then cat <<'EOF'
Активные профили не настроены
EOF fi } subscription_recommendations_html() { local links="${1:-}" log_file="${PROTOCOL_BENCHMARK_LOG:-$PROTOCOL_BENCHMARK_LOG_DEFAULT}" if command -v python3 >/dev/null 2>&1 && [[ -s "$log_file" ]]; then LINKS="$links" LOG_FILE="$log_file" python3 - <<'PY' import csv import html import os from urllib.parse import unquote, urlsplit links = [x.strip() for x in os.environ.get("LINKS", "").splitlines() if x.strip()] log_file = os.environ.get("LOG_FILE", "") profiles = [] for line in links: u = urlsplit(line) profiles.append({ "scheme": u.scheme.lower(), "host": u.hostname or "", "name": unquote(u.fragment or f"{u.hostname or ''} {u.scheme}"), }) rows = [] try: with open(log_file, "r", encoding="utf-8", errors="replace") as f: rows = list(csv.DictReader(f))[-500:] except Exception: rows = [] scores = {} for r in rows: key = (r.get("scheme", ""), r.get("host", "")) item = scores.setdefault(key, {"ok": 0, "fail": 0, "avg": []}) if r.get("status") == "OK": item["ok"] += 1 try: item["avg"].append(int(r.get("avg_ms") or 0)) except Exception: pass elif r.get("status") == "FAIL": item["fail"] += 1 def avg_for(p): item = scores.get((p["scheme"], p["host"]), {}) vals = item.get("avg") or [] return int(sum(vals) / len(vals)) if vals else None def proto_label(scheme): if scheme.startswith("naive"): return "HTTPS" if scheme in ("hy2", "hysteria2"): return "Turbo" if scheme == "vless": return "Reality" return scheme ranked = [] for p in profiles: avg = avg_for(p) if avg is not None: item = scores.get((p["scheme"], p["host"]), {}) ranked.append((avg, item.get("fail", 0), p)) cards = [] if ranked: best = sorted(ranked, key=lambda x: (x[0], x[1]))[0] cards.append(("Рекомендуемый сейчас", best[2], f"avg {best[0]} ms")) turbo = [x for x in ranked if x[2]["scheme"] in ("hy2", "hysteria2")] if turbo: t = sorted(turbo, key=lambda x: (x[0], x[1]))[0] cards.append(("Самый быстрый Turbo", t[2], f"avg {t[0]} ms")) https = [p for p in profiles if p["scheme"].startswith("naive")] if https: p = https[0] avg = avg_for(p) cards.append(("Резервный совместимый", p, f"avg {avg} ms" if avg is not None else "HTTPS fallback")) seen = set() out = [] for title, p, metric in cards: key = (title, p["scheme"], p["host"]) if key in seen: continue seen.add(key) out.append(f'''
{html.escape(title)}
{html.escape(p["name"])}
{html.escape(proto_label(p["scheme"]))} / {html.escape(p["host"])} / {html.escape(metric)}
''') if not out: out.append('''
Рейтинг появится после теста
Запусти benchmark на сервере
sudo yurich-panel.sh protocol-benchmark-monitor
''') print("\n".join(out)) PY else cat <<'EOF'
Рейтинг появится после теста
Запусти benchmark на сервере
sudo yurich-panel.sh protocol-benchmark-monitor
EOF fi } subscription_all_recommendations_html() { local links="${1:-}" log_file="${PROTOCOL_BENCHMARK_LOG:-$PROTOCOL_BENCHMARK_LOG_DEFAULT}" if command -v python3 >/dev/null 2>&1; then LINKS="$links" LOG_FILE="$log_file" python3 - <<'PY' import csv import html import os from urllib.parse import unquote, urlsplit links = [x.strip() for x in os.environ.get("LINKS", "").splitlines() if x.strip()] log_file = os.environ.get("LOG_FILE", "") rows = [] try: if log_file and os.path.exists(log_file): with open(log_file, "r", encoding="utf-8", errors="replace") as f: rows = list(csv.DictReader(f))[-700:] except Exception: rows = [] scores = {} for r in rows: key = (r.get("scheme", ""), r.get("host", "")) item = scores.setdefault(key, {"ok": 0, "fail": 0, "avg": []}) if r.get("status") == "OK": item["ok"] += 1 try: item["avg"].append(int(r.get("avg_ms") or 0)) except Exception: pass elif r.get("status") == "FAIL": item["fail"] += 1 def proto_label(scheme: str) -> str: if scheme.startswith("naive"): return "HTTPS" if scheme in ("hy2", "hysteria2"): return "Turbo" if scheme == "vless": return "Reality" return scheme or "Профиль" def display_host(u): host = u.hostname or "" try: port = u.port except ValueError: port = None if port: return f"{host}:{port}" return host out = [] seen = set() for line in links: try: u = urlsplit(line) except Exception: continue scheme = (u.scheme or "").lower() host = u.hostname or "" key = (scheme, host, u.fragment, u.netloc) if key in seen: continue seen.add(key) proto = proto_label(scheme) name = unquote(u.fragment or f"{display_host(u)} • {proto}") score = scores.get((scheme, host), {}) vals = score.get("avg") or [] if vals: metric = f"avg {int(sum(vals) / len(vals))} ms" if score.get("fail", 0): metric += f" / warn {score.get('fail', 0)}" else: metric = "ожидает benchmark" out.append(f'''
{html.escape(proto)}
{html.escape(name)}
{html.escape(display_host(u))} / {html.escape(metric)}
''') if not out: out.append('''
Профили не найдены
Активные подключения пока не настроены
Проверь подписку и список серверов
''') print("\n".join(out)) PY return fi local line protocol host name safe_line safe_protocol safe_host safe_name count=0 while IFS= read -r line; do [[ -z "$line" ]] && continue count=$((count + 1)) protocol=$(subscription_profile_protocol "$line") host=$(subscription_profile_host "$line") name=$(subscription_profile_name_from_uri "$line") safe_line=$(html_escape_text "$line") safe_protocol=$(html_escape_text "$protocol") safe_host=$(html_escape_text "$host") safe_name=$(html_escape_text "$name") cat <
${safe_protocol}
${safe_name}
${safe_host} / ожидает benchmark
EOF done <<< "$links" if [[ "$count" -eq 0 ]]; then cat <<'EOF'
Профили не найдены
Активные подключения пока не настроены
Проверь подписку и список серверов
EOF fi } ensure_qrencode_for_subscriptions() { command -v qrencode >/dev/null 2>&1 && return 0 if [[ "${EUID:-$(id -u)}" -eq 0 ]] && command -v apt-get >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive apt-get install -y -q qrencode >/dev/null 2>&1 || true fi command -v qrencode >/dev/null 2>&1 } write_subscription_qr() { local value="${1:-}" output="${2:-}" previous_output="${3:-}" local value_hash="" previous_hash="" output_meta tmp_qr tmp_meta [[ -n "$value" && -n "$output" ]] || return 1 output_meta="${output}.sha256" if command -v sha256sum >/dev/null 2>&1; then value_hash=$(printf '%s' "$value" | sha256sum | awk '{print $1}') fi if [[ -n "$value_hash" && -n "$previous_output" && -f "$previous_output" && ! -L "$previous_output" \ && -f "${previous_output}.sha256" && ! -L "${previous_output}.sha256" ]]; then previous_hash=$(tr -dc 'a-fA-F0-9' < "${previous_output}.sha256" | head -c 64) if [[ "${previous_hash,,}" == "${value_hash,,}" ]]; then if [[ "$(readlink -m -- "$previous_output")" != "$(readlink -m -- "$output")" ]]; then install -m 644 -- "$previous_output" "$output" || return 1 fi printf '%s\n' "$value_hash" > "$output_meta" chmod 600 "$output_meta" 2>/dev/null || true chmod 644 "$output" 2>/dev/null || true return 0 fi fi ensure_qrencode_for_subscriptions || return 1 tmp_qr=$(mktemp "${output}.tmp.XXXXXX") || return 1 if ! qrencode -o "$tmp_qr" -s 8 -m 2 -- "$value" 2>/dev/null; then rm -f -- "$tmp_qr" return 1 fi mv -f -- "$tmp_qr" "$output" chmod 644 "$output" 2>/dev/null || true if [[ -n "$value_hash" ]]; then tmp_meta=$(mktemp "${output_meta}.tmp.XXXXXX") || return 1 printf '%s\n' "$value_hash" > "$tmp_meta" chmod 600 "$tmp_meta" 2>/dev/null || true mv -f -- "$tmp_meta" "$output_meta" else rm -f -- "$output_meta" fi } subscription_qr_cards_html() { local label image_file url hint image_name safe_label safe_image safe_url safe_hint count=0 while IFS='|' read -r label image_file url hint; do [[ -n "$label" && -n "$image_file" && -n "$url" ]] || continue [[ -s "$image_file" ]] || continue count=$((count + 1)) image_name="${image_file##*/}" safe_label=$(html_escape_text "$label") safe_image=$(html_escape_text "$image_name") safe_url=$(html_escape_text "$url") safe_hint=$(html_escape_text "$hint") cat <
QR ${safe_label}
${safe_label}
${safe_hint}
EOF done if [[ "$count" -eq 0 ]]; then cat <<'EOF'
QR-коды пока недоступны. Используй кнопки копирования выше.
EOF fi } ensure_web_privacy_files() { mkdir -p "$WEBROOT" "$SUBS_WEB_DIR" "$PRIVATE_WEB_DIR" cat > "${WEBROOT}/robots.txt" <<'EOF' User-agent: * Disallow: /s/ Disallow: /p/ EOF chmod 644 "${WEBROOT}/robots.txt" cat > "${SUBS_WEB_DIR}/index.html" <<'EOF' 404404 EOF cat > "${PRIVATE_WEB_DIR}/index.html" <<'EOF' 404404 EOF chmod 644 "${SUBS_WEB_DIR}/index.html" "${PRIVATE_WEB_DIR}/index.html" } get_or_create_token_file() { local token_file="$1" local token="" mkdir -p "$(dirname "$token_file")" chmod 700 "$(dirname "$token_file")" 2>/dev/null || true if [[ -s "$token_file" ]]; then token=$(tr -dc 'a-fA-F0-9' < "$token_file" | head -c 48 || true) fi if [[ ! "$token" =~ ^[a-fA-F0-9]{32,64}$ ]]; then token=$(openssl rand -hex 24) printf '%s\n' "$token" > "$token_file" chmod 600 "$token_file" fi printf '%s\n' "$token" } reset_token_file() { local token_file="$1" mkdir -p "$(dirname "$token_file")" chmod 700 "$(dirname "$token_file")" 2>/dev/null || true openssl rand -hex 24 > "$token_file" chmod 600 "$token_file" } remove_web_token_dir() { local base_dir="$1" local token="$2" if [[ ! "$token" =~ ^[a-fA-F0-9]{32,64}$ ]]; then return 0 fi case "$base_dir" in "$SUBS_WEB_DIR"|"$PRIVATE_WEB_DIR") ;; *) warn "Отказываюсь удалять неожиданный web path: ${base_dir}" return 1 ;; esac if [[ -z "${WEBROOT:-}" || "$base_dir" != "${WEBROOT}/"* ]]; then warn "Отказываюсь удалять path вне WEBROOT: ${base_dir}" return 1 fi rm -rf -- "${base_dir:?}/${token:?}" 2>/dev/null || true } cleanup_subscription_page() { local user="$1" local token_file old_token if ! is_valid_proxy_user "$user"; then return 0 fi token_file="${SUBS_DIR}/${user}.token" old_token="" if [[ -s "$token_file" ]]; then old_token=$(tr -dc 'a-fA-F0-9' < "$token_file" | head -c 48 || true) fi if [[ "$old_token" =~ ^[a-fA-F0-9]{32,64}$ ]]; then remove_web_token_dir "$SUBS_WEB_DIR" "$old_token" fi rm -f -- "$token_file" 2>/dev/null || true } suspend_subscription_page() { local user="$1" token_file token page_dir file safe_user is_valid_proxy_user "$user" || return 1 token_file="${SUBS_DIR}/${user}.token" [[ -s "$token_file" ]] || return 0 token=$(tr -d '\r\n' < "$token_file") [[ "$token" =~ ^[A-Fa-f0-9]{48}$ ]] || return 1 page_dir="${SUBS_WEB_DIR}/${token}" remove_web_token_dir "$SUBS_WEB_DIR" "$token" || return 1 mkdir -p "$page_dir" chmod 755 "$SUBS_WEB_DIR" "$page_dir" for file in links.txt hiddify.txt nekobox.txt v2rayng.txt karing.txt streisand.txt happ.txt browser.txt mobile-test.txt pingtunnel.txt; do : > "$page_dir/$file" chmod 644 "$page_dir/$file" done safe_user=$(html_escape_text "$user") cat > "$page_dir/index.html" < Подписка приостановлена

Подписка приостановлена

Срок подписки ${safe_user} закончился. Профили временно отозваны.

После продления эта же ссылка снова станет активной.

EOF chmod 644 "$page_dir/index.html" } subscription_user_exists() { local user="$1" get_user_hash "$user" >/dev/null 2>&1 && return 0 [[ -n "$(get_xray_user_uuid "$user" 2>/dev/null || true)" ]] && return 0 return 1 } remove_user_from_colon_file() { local file="$1" user="$2" tmp [[ -f "$file" ]] || return 1 if ! awk -F: -v user="$user" '$1 == user {found=1} END {exit found ? 0 : 1}' "$file"; then return 1 fi tmp=$(mktemp) awk -F: -v user="$user" '$1 != user' "$file" > "$tmp" && cat "$tmp" > "$file" rm -f "$tmp" chmod 600 "$file" 2>/dev/null || true return 0 } remove_subscription_aliases_for_user() { local user="$1" token="${2:-}" tmp alias target mapped_user rest [[ -f "$SUBS_ALIASES_FILE" ]] || return 0 is_valid_proxy_user "$user" || return 1 tmp=$(mktemp) while read -r alias target mapped_user rest; do if [[ "$alias" =~ ^[A-Fa-f0-9]{32,64}$ && "$target" =~ ^[A-Fa-f0-9]{32,64}$ ]]; then if [[ "$mapped_user" == "$user" || -n "$token" && ( "$alias" == "$token" || "$target" == "$token" ) ]]; then remove_web_token_dir "$SUBS_WEB_DIR" "$alias" || true continue fi fi printf '%s %s%s%s\n' "$alias" "$target" "${mapped_user:+ $mapped_user}" "${rest:+ $rest}" >> "$tmp" done < "$SUBS_ALIASES_FILE" install -m 600 "$tmp" "$SUBS_ALIASES_FILE" rm -f "$tmp" } cleanup_orphan_subscription_pages() { load_config load_users mkdir -p "$SUBS_DIR" "$SUBS_WEB_DIR" local build_dir build_name for build_dir in "$SUBS_WEB_DIR"/.*.build.*; do [[ -d "$build_dir" && ! -L "$build_dir" ]] || continue build_name=$(basename "$build_dir") [[ "$build_name" =~ ^\.[a-fA-F0-9]{32,64}\.build\.[A-Za-z0-9._-]+$ ]] || continue if [[ -n "$(find "$build_dir" -maxdepth 0 -mmin +60 -print -quit 2>/dev/null)" ]]; then subscription_discard_build_dir "$build_dir" fi done local token_file user token shopt -s nullglob for token_file in "$SUBS_DIR"/*.token; do user=$(basename "$token_file" .token) if ! subscription_user_exists "$user"; then cleanup_subscription_page "$user" fi done shopt -u nullglob local keep_file primary_tokens dir name alias target mapped_user keep_file=$(mktemp) primary_tokens=$(mktemp) shopt -s nullglob for token_file in "$SUBS_DIR"/*.token; do token=$(tr -dc 'a-fA-F0-9' < "$token_file" | head -c 48 || true) if [[ "$token" =~ ^[a-fA-F0-9]{32,64}$ ]]; then printf '%s\n' "$token" >> "$keep_file" printf '%s\n' "$token" >> "$primary_tokens" fi done if [[ -f "$SUBS_ALIASES_FILE" ]]; then while read -r alias target mapped_user _; do [[ "$alias" =~ ^[A-Fa-f0-9]{32,64}$ && "$target" =~ ^[A-Fa-f0-9]{32,64}$ ]] || continue if grep -Fxq "$target" "$primary_tokens" 2>/dev/null; then printf '%s\n' "$alias" >> "$keep_file" else remove_web_token_dir "$SUBS_WEB_DIR" "$alias" || true fi done < "$SUBS_ALIASES_FILE" fi for dir in "$SUBS_WEB_DIR"/*; do [[ -d "$dir" ]] || continue name=$(basename "$dir") [[ "$name" =~ ^[a-fA-F0-9]{32,64}$ ]] || continue if ! grep -qx "$name" "$keep_file" 2>/dev/null; then remove_web_token_dir "$SUBS_WEB_DIR" "$name" fi done shopt -u nullglob rm -f "$keep_file" "$primary_tokens" } apply_subscription_aliases() { load_config mkdir -p "$SUBS_WEB_DIR" [[ -f "$SUBS_ALIASES_FILE" ]] || return 0 local alias target user alias_dir target_dir primary_tokens token_file primary_tokens=$(mktemp) shopt -s nullglob for token_file in "$SUBS_DIR"/*.token; do tr -dc 'a-fA-F0-9\n' < "$token_file" | head -n1 >> "$primary_tokens" 2>/dev/null || true done shopt -u nullglob while read -r alias target user _; do [[ "$alias" =~ ^[A-Fa-f0-9]{32,64}$ && "$target" =~ ^[A-Fa-f0-9]{32,64}$ ]] || continue [[ "$alias" == "$target" ]] && continue target_dir="${SUBS_WEB_DIR}/${target}" alias_dir="${SUBS_WEB_DIR}/${alias}" if [[ ! -d "$target_dir" ]]; then remove_web_token_dir "$SUBS_WEB_DIR" "$alias" || true continue fi if grep -qx "$alias" "$primary_tokens" 2>/dev/null; then continue fi if [[ -e "$alias_dir" || -L "$alias_dir" ]]; then remove_web_token_dir "$SUBS_WEB_DIR" "$alias" || continue fi mkdir -p "$alias_dir" || continue cp -a "$target_dir/." "$alias_dir/" 2>/dev/null || { rm -rf "$alias_dir"; continue; } chmod 755 "$SUBS_WEB_DIR" "$alias_dir" 2>/dev/null || true chmod 644 "$alias_dir"/* 2>/dev/null || true done < <(awk 'NF >= 2 && $1 !~ /^#/ {print $0}' "$SUBS_ALIASES_FILE" 2>/dev/null || true) rm -f "$primary_tokens" } delete_subscription_user_everywhere() { load_config load_users local target="$1" has_naive=0 has_xray=0 found=0 naive_changed=0 xray_changed=0 apply_failed=0 old_token="" if ! is_valid_proxy_user "$target"; then err "Некорректный логин" return 1 fi get_user_hash "$target" >/dev/null 2>&1 && { has_naive=1; found=1; } [[ -n "$(get_xray_user_uuid "$target" 2>/dev/null || true)" ]] && { has_xray=1; found=1; } [[ -n "$(get_xray_compat_user_uuid "$target" 2>/dev/null || true)" ]] && { has_xray=1; found=1; } awk -F: -v user="$target" '$1 == user {found=1} END {exit found ? 0 : 1}' "$DISABLED_USERS_FILE" 2>/dev/null && found=1 awk -F: -v user="$target" '$1 == user {found=1} END {exit found ? 0 : 1}' "$XRAY_DISABLED_USERS_FILE" 2>/dev/null && found=1 if [[ "$found" -eq 0 ]]; then cleanup_subscription_page "$target" cleanup_user_metadata "$target" err "Пользователь $target не найден, возможные остатки подписки очищены" return 1 fi if [[ "$has_naive" -eq 1 ]] && ! user_is_expired "$target" && [[ "$(active_user_count)" -le 1 ]]; then err "Нельзя удалить последнего активного Naive пользователя — иначе прокси может остаться без auth." return 1 fi if [[ "$has_xray" -eq 1 ]] && ! user_is_expired "$target" && [[ "$(xray_active_user_count)" -le 1 ]]; then err "Нельзя удалить последнего активного Xray пользователя — иначе Xray может пересоздать дефолтный профиль." return 1 fi backup_config if [[ -s "${SUBS_DIR}/${target}.token" ]]; then old_token=$(tr -d '\r\n' < "${SUBS_DIR}/${target}.token") [[ "$old_token" =~ ^[A-Fa-f0-9]{48}$ ]] || old_token="" fi remove_user_from_colon_file "$USERS_FILE" "$target" && naive_changed=1 remove_user_from_colon_file "$DISABLED_USERS_FILE" "$target" || true credential_remove_user_secret "$target" || { warn "Не удалось удалить encrypted secret $target"; apply_failed=1; } write_active_users_hash_file >/dev/null 2>&1 || true remove_user_from_colon_file "$XRAY_USERS_FILE" "$target" && xray_changed=1 remove_user_from_colon_file "$XRAY_COMPAT_USERS_FILE" "$target" && xray_changed=1 remove_user_from_colon_file "$XRAY_DISABLED_USERS_FILE" "$target" || true cleanup_user_metadata "$target" remove_subscription_aliases_for_user "$target" "$old_token" || { warn "Не удалось удалить alias URL пользователя $target"; apply_failed=1; } cleanup_subscription_page "$target" cleanup_orphan_subscription_pages if [[ "$naive_changed" -eq 1 ]]; then if rewrite_caddyfile_current; then systemctl reload caddy 2>/dev/null || systemctl restart caddy || apply_failed=1 else apply_failed=1 fi sync_hysteria_users_if_active >/dev/null 2>&1 || apply_failed=1 fi if [[ "$xray_changed" -eq 1 && -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then if write_xray_config >/dev/null; then systemctl restart xray || apply_failed=1 else apply_failed=1 fi fi cmd_nodes_rebuild_subscriptions >/dev/null 2>&1 || apply_failed=1 if [[ "$(nodes_count 2>/dev/null || echo 0)" -gt 0 ]]; then cmd_nodes_sync_users all >/dev/null 2>&1 || { warn "Не удалось синхронизировать удаление $target на все nodes"; apply_failed=1; } fi ok "Пользователь $target удалён из Naive/Xray/disabled/metainfo" ok "Подписка $target и публичные ссылки удалены" if [[ "$apply_failed" -ne 0 ]]; then err "Пользователь удалён локально, но часть runtime/node-операций требует повтора" return 1 fi } subscription_discard_build_dir() { local build_dir="$1" python3 - "$SUBS_WEB_DIR" "$build_dir" <<'PY' import shutil import sys from pathlib import Path root = Path(sys.argv[1]).resolve() target = Path(sys.argv[2]) if target.parent.resolve() != root or not target.name.startswith('.') or '.build.' not in target.name: raise SystemExit('unsafe subscription build path') if target.is_symlink() or (target.exists() and not target.is_dir()): target.unlink(missing_ok=True) elif target.is_dir(): shutil.rmtree(target) PY } subscription_validate_build_dir() { local build_dir="$1" file line local required_files="index.html links.txt hiddify.txt streisand.txt happ.txt nekobox.txt karing.txt v2rayng.txt" [[ -d "$build_dir" && ! -L "$build_dir" ]] || return 1 for file in $required_files; do [[ -f "$build_dir/$file" && ! -L "$build_dir/$file" ]] || return 1 done for file in index.html links.txt karing.txt; do [[ -s "$build_dir/$file" ]] || return 1 done for file in links.txt hiddify.txt streisand.txt happ.txt nekobox.txt karing.txt v2rayng.txt; do while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "$line" || "$line" == \#* ]] && continue protocol_line_forbidden "$line" && return 1 protocol_line_allowed "$line" || return 1 done < "$build_dir/$file" done if [[ -e "$build_dir/browser.txt" ]]; then [[ -s "$build_dir/browser.txt" && ! -L "$build_dir/browser.txt" ]] || return 1 while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^https://[^/@:]+:[^/@]+@[^/:?#]+:443(/.*)?(#.*)?$ ]] || return 1 done < "$build_dir/browser.txt" fi } subscription_publish_page_dir() { local build_dir="$1" final_dir="$2" python3 - "$SUBS_WEB_DIR" "$build_dir" "$final_dir" <<'PY' import ctypes import os import re import shutil import sys from pathlib import Path root = Path(sys.argv[1]).resolve() build = Path(sys.argv[2]) final = Path(sys.argv[3]) token_re = re.compile(r'^[a-fA-F0-9]{32,64}$') if build.parent.resolve() != root or final.parent.resolve() != root: raise SystemExit('subscription publish path escaped web root') if not token_re.fullmatch(final.name): raise SystemExit('invalid subscription token directory') if not build.name.startswith(f'.{final.name}.build.') or not build.is_dir() or build.is_symlink(): raise SystemExit('invalid subscription staging directory') def remove_path(path: Path) -> None: if path.parent.resolve() != root or not path.name.startswith('.'): raise RuntimeError('refusing unsafe cleanup path') if path.is_symlink() or (path.exists() and not path.is_dir()): path.unlink(missing_ok=True) elif path.is_dir(): shutil.rmtree(path) if final.exists() or final.is_symlink(): exchanged = False try: libc = ctypes.CDLL(None, use_errno=True) renameat2 = libc.renameat2 renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] renameat2.restype = ctypes.c_int if renameat2(-100, os.fsencode(build), -100, os.fsencode(final), 2) != 0: errno_value = ctypes.get_errno() raise OSError(errno_value, os.strerror(errno_value)) exchanged = True except (AttributeError, OSError, TypeError): rollback = root / f'.{final.name}.rollback.{os.getpid()}' remove_path(rollback) os.rename(final, rollback) try: os.rename(build, final) except Exception: os.rename(rollback, final) raise remove_path(rollback) if exchanged: remove_path(build) else: os.rename(build, final) os.chmod(final, 0o755) PY } subscription_finalize_generated_page() { local build_dir="$1" final_dir="$2" user="$3" [[ -n "$build_dir" ]] || return 0 if ! subscription_validate_build_dir "$build_dir"; then subscription_discard_build_dir "$build_dir" err "Subscription staging validation failed: $user" return 1 fi if ! subscription_publish_page_dir "$build_dir" "$final_dir"; then subscription_discard_build_dir "$build_dir" 2>/dev/null || true err "Atomic subscription publish failed: $user" return 1 fi if [[ -e "$build_dir" || -L "$build_dir" ]]; then if ! subscription_discard_build_dir "$build_dir"; then err "Atomic subscription cleanup failed: $user" return 1 fi fi } generate_subscription_page() { if [[ "${SUBSCRIPTION_REBUILD_WORKER:-0}" != "1" ]]; then load_config load_users fi local user="$1" if ! is_valid_proxy_user "$user"; then err "Некорректный логин" return 1 fi if ! subscription_user_exists "$user"; then err "Пользователь $user не найден в Naive/Xray" return 1 fi if user_is_expired "$user"; then err "Подписка пользователя $user истекла: $(user_expiry_label "$user")" return 1 fi if ! is_valid_domain "${DOMAIN:-}"; then err "Домен не настроен или некорректен" return 1 fi local subscription_domain subscription_domain=$(subscription_public_domain) || { err "Некорректный SUBSCRIPTION_DOMAIN: ${SUBSCRIPTION_DOMAIN:-}" return 1 } if [[ "${SUBSCRIPTION_REBUILD_WORKER:-0}" != "1" ]]; then ensure_web_privacy_files fi local token token_file page_dir final_page_dir build_page_dir="" links_file hiddify_file streisand_file happ_file browser_file nekobox_file karing_file v2rayng_file pingtunnel_file naive_pass naive_uri hy2_uri expiry_label expiry_tag node_links naive_extra_links node_app_links remote_http_links remote_http_app_links relay_source_links relay_links active_links app_links happ_links browser_links v2rayng_links token_file="${SUBS_DIR}/${user}.token" token=$(get_or_create_token_file "$token_file") final_page_dir="${SUBS_WEB_DIR}/${token}" if [[ "${SUBSCRIPTION_ATOMIC_PUBLISH:-1}" == "1" ]]; then build_page_dir=$(mktemp -d "${SUBS_WEB_DIR}/.${token}.build.XXXXXX") || return 1 chmod 700 "$build_page_dir" page_dir="$build_page_dir" else page_dir="$final_page_dir" fi links_file="${page_dir}/links.txt" hiddify_file="${page_dir}/hiddify.txt" streisand_file="${page_dir}/streisand.txt" happ_file="${page_dir}/happ.txt" browser_file="${page_dir}/browser.txt" nekobox_file="${page_dir}/nekobox.txt" karing_file="${page_dir}/karing.txt" v2rayng_file="${page_dir}/v2rayng.txt" pingtunnel_file="${page_dir}/pingtunnel.txt" mkdir -p "$page_dir" [[ -n "$build_page_dir" ]] || chmod 755 "$page_dir" expiry_label=$(user_expiry_label "$user") expiry_tag=$(user_expiry_tag "$user") naive_pass=$(get_active_user_pass "$user" 2>/dev/null || true) naive_uri="" if [[ -n "$naive_pass" ]]; then naive_uri=$(uri_with_profile_name "naive+https://${user}:${naive_pass}@${DOMAIN}:443" "$(pretty_profile_name "$user" "HTTPS")") fi browser_links="" if [[ -n "${BROWSER_SUBSCRIPTION_PROFILES:-}" ]]; then if ! browser_links=$(browser_subscription_links_for_user "$user" "$naive_pass"); then err "Некорректный BROWSER_SUBSCRIPTION_PROFILES" [[ -z "$build_page_dir" ]] || subscription_discard_build_dir "$build_page_dir" return 1 fi fi hy2_uri="" if [[ -n "$naive_pass" && -n "${HYSTERIA_OBFS_PASSWORD:-}" && ( -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ) ]]; then hy2_uri=$(hysteria_uri_for_user "$user" 2>/dev/null || true) if [[ -n "$hy2_uri" ]]; then hy2_uri=$(uri_with_profile_name "$hy2_uri" "$(pretty_profile_name "$user" "Turbo")") fi fi local uuid reality_link github_test_link reality_links reality_public_port reality_public_label github_test_users_norm github_test_sni local_xhttp_link reality_public_port=$(xray_reality_public_port) uuid=$(get_xray_user_uuid "$user" 2>/dev/null || true) reality_link="" github_test_link="" reality_links="" if [[ -n "$uuid" ]]; then reality_public_label="Reality" reality_link=$(uri_with_profile_name "vless://${uuid}@${DOMAIN}:${reality_public_port}?encryption=none&security=reality&type=tcp&flow=xtls-rprx-vision&sni=${XRAY_REALITY_SERVER_NAME:-www.microsoft.com}&fp=chrome&pbk=${XRAY_REALITY_PUBLIC_KEY:-PUBLIC_KEY}&sid=${XRAY_REALITY_SHORT_ID:-SHORT_ID}&spx=%2F" "$(pretty_profile_name "$user" "$reality_public_label")") if [[ "${XRAY_GITHUB_TEST_ENABLED:-0}" == "1" ]]; then github_test_users_norm=",${XRAY_GITHUB_TEST_USERS//[[:space:]]/}," if [[ -z "${XRAY_GITHUB_TEST_USERS:-}" || "$github_test_users_norm" == *",$user,"* ]]; then github_test_sni="${XRAY_GITHUB_TEST_SERVER_NAME:-$XRAY_GITHUB_TEST_SERVER_NAME_DEFAULT}" github_test_link=$(uri_with_profile_name "vless://${uuid}@${DOMAIN}:${reality_public_port}?encryption=none&security=reality&type=tcp&flow=xtls-rprx-vision&sni=${github_test_sni}&fp=chrome&pbk=${XRAY_REALITY_PUBLIC_KEY:-PUBLIC_KEY}&sid=${XRAY_REALITY_SHORT_ID:-SHORT_ID}&spx=%2Fgithub-test-${user}" "$(pretty_profile_name "$user" "Reality GitHub TEST")") fi fi fi reality_links=$({ [[ -n "$reality_link" ]] && printf '%s\n' "$reality_link" [[ -n "$github_test_link" ]] && printf '%s\n' "$github_test_link" } | awk 'NF') node_links=$(node_links_for_user "$user" "$naive_pass" "$expiry_tag" 2>/dev/null || true) if ! naive_extra_links=$(naive_extra_links_for_user "$user" "$naive_pass" "$expiry_tag"); then err "Некорректный NAIVE_EXTRA_PROFILES" [[ -z "$build_page_dir" ]] || subscription_discard_build_dir "$build_page_dir" return 1 fi if ! node_app_links=$(node_app_links_for_user "$user"); then err "Не удалось получить профили node для подписки $user" [[ -z "$build_page_dir" ]] || subscription_discard_build_dir "$build_page_dir" return 1 fi if ! remote_http_links=$(subscription_http_node_links_for_user "$user" 2>/dev/null); then err "Недоступен или пустой HTTPS-источник node для подписки $user" [[ -z "$build_page_dir" ]] || subscription_discard_build_dir "$build_page_dir" return 1 fi remote_http_app_links=$(printf '%s\n' "$remote_http_links" | awk 'NF && /^(hy2:\/\/|hysteria2:\/\/|vless:\/\/)/') relay_source_links=$({ [[ -n "$reality_links" ]] && printf '%s\n' "$reality_links" [[ -n "$node_app_links" ]] && printf '%s\n' "$node_app_links" } | awk 'NF') if ! relay_links=$(subscription_vless_relay_links_for_user "$user" "$relay_source_links"); then err "Некорректный VLESS_RELAY_PROFILES или недоступна исходная node" [[ -z "$build_page_dir" ]] || subscription_discard_build_dir "$build_page_dir" return 1 fi local_xhttp_link="" if [[ "${XRAY_XHTTP_ENABLED:-0}" == "1" && -n "$uuid" ]]; then local_xhttp_link=$(node_xhttp_link_for_user "$user" "" "$DOMAIN") || return 1 fi local subscription_local_enabled subscription_local_position local_links local_app_links subscription_local_enabled="${SUBSCRIPTION_LOCAL_ENABLED:-1}" [[ "$subscription_local_enabled" == "0" ]] || subscription_local_enabled="1" subscription_local_position="${SUBSCRIPTION_LOCAL_POSITION:-first}" case "$subscription_local_position" in first|last) ;; *) subscription_local_position="first" ;; esac local_links=$({ [[ -n "$naive_uri" ]] && printf '%s\n' "$naive_uri" [[ -n "$hy2_uri" ]] && printf '%s\n' "$hy2_uri" [[ -n "$reality_links" ]] && printf '%s\n' "$reality_links" if [[ -n "$local_xhttp_link" ]]; then printf '%s\n' "$local_xhttp_link"; fi } | awk 'NF') local_app_links=$({ [[ -n "$hy2_uri" ]] && printf '%s\n' "$hy2_uri" [[ -n "$reality_links" ]] && printf '%s\n' "$reality_links" if [[ -n "$local_xhttp_link" ]]; then printf '%s\n' "$local_xhttp_link"; fi } | awk 'NF') if [[ "$subscription_local_enabled" == "0" ]]; then active_links=$({ [[ -n "$node_links" ]] && printf '%s\n' "$node_links" [[ -n "$naive_extra_links" ]] && printf '%s\n' "$naive_extra_links" [[ -n "$node_app_links" ]] && printf '%s\n' "$node_app_links" [[ -n "$remote_http_links" ]] && printf '%s\n' "$remote_http_links" [[ -n "$relay_links" ]] && printf '%s\n' "$relay_links" } | awk 'NF') app_links=$({ [[ -n "$node_app_links" ]] && printf '%s\n' "$node_app_links" [[ -n "$remote_http_app_links" ]] && printf '%s\n' "$remote_http_app_links" [[ -n "$relay_links" ]] && printf '%s\n' "$relay_links" } | awk 'NF') elif [[ "$subscription_local_position" == "last" ]]; then active_links=$({ [[ -n "$node_links" ]] && printf '%s\n' "$node_links" [[ -n "$naive_extra_links" ]] && printf '%s\n' "$naive_extra_links" [[ -n "$node_app_links" ]] && printf '%s\n' "$node_app_links" [[ -n "$remote_http_links" ]] && printf '%s\n' "$remote_http_links" [[ -n "$relay_links" ]] && printf '%s\n' "$relay_links" [[ -n "$local_links" ]] && printf '%s\n' "$local_links" } | awk 'NF') app_links=$({ [[ -n "$node_app_links" ]] && printf '%s\n' "$node_app_links" [[ -n "$remote_http_app_links" ]] && printf '%s\n' "$remote_http_app_links" [[ -n "$relay_links" ]] && printf '%s\n' "$relay_links" [[ -n "$local_app_links" ]] && printf '%s\n' "$local_app_links" } | awk 'NF') else active_links=$({ [[ -n "$local_links" ]] && printf '%s\n' "$local_links" [[ -n "$node_links" ]] && printf '%s\n' "$node_links" [[ -n "$naive_extra_links" ]] && printf '%s\n' "$naive_extra_links" [[ -n "$node_app_links" ]] && printf '%s\n' "$node_app_links" [[ -n "$remote_http_links" ]] && printf '%s\n' "$remote_http_links" [[ -n "$relay_links" ]] && printf '%s\n' "$relay_links" } | awk 'NF') app_links=$({ [[ -n "$local_app_links" ]] && printf '%s\n' "$local_app_links" [[ -n "$node_app_links" ]] && printf '%s\n' "$node_app_links" [[ -n "$remote_http_app_links" ]] && printf '%s\n' "$remote_http_app_links" [[ -n "$relay_links" ]] && printf '%s\n' "$relay_links" } | awk 'NF') fi active_links=$(printf '%s\n' "$active_links" | subscription_filter_published_links) app_links=$(printf '%s\n' "$app_links" | subscription_filter_published_links) v2rayng_links=$(printf '%s\n' "$app_links" | awk 'NF && /^vless:\/\// && ((/security=reality/ && /type=tcp/) || (/security=tls/ && /type=xhttp/)) { if (!seen[$0]++) print }') happ_links=$(printf '%s\n' "$v2rayng_links" | awk '!/type=xhttp/' | happ_compat_links) printf '%s\n' "$active_links" > "$links_file" printf '%s\n' "$active_links" > "$karing_file" printf '%s\n' "$app_links" > "$nekobox_file" printf '%s\n' "$v2rayng_links" > "$v2rayng_file" { printf '#routing-enable: 0\n' printf '#server-address-resolve-enable: 1\n' printf '#server-address-resolve-dns-domain: https://common.dot.dns.yandex.net/dns-query\n' printf '#server-address-resolve-dns-ip: 77.88.8.8\n' printf '#ping-type proxy\n' printf '#check-url-via-proxy: https://cp.cloudflare.com/generate_204\n' printf '#subscription-ping-onopen-enabled: 1\n' printf '#subscription-auto-update-enable: 1\n' printf '%s\n' "$happ_links" } > "$happ_file" if [[ -n "$browser_links" ]]; then printf '%s\n' "$browser_links" > "$browser_file" chmod 644 "$browser_file" else rm -f "$browser_file" fi rm -f "${page_dir}/qr-happ.png" "${page_dir}/mobile-test.txt" "${page_dir}/qr-mobile-test.png" 2>/dev/null || true chmod 644 "$links_file" "$happ_file" "$nekobox_file" "$karing_file" "$v2rayng_file" if [[ -f "$PINGTUNNEL_ENV" && -x "$PINGTUNNEL_BIN" ]]; then write_pingtunnel_subscription_file "$pingtunnel_file" "$DOMAIN" "$user" || rm -f "$pingtunnel_file" else rm -f "$pingtunnel_file" fi local sub_url links_url hiddify_url streisand_url nekobox_url v2rayng_url pingtunnel_url hiddify_open_url hiddify_expire_epoch hiddify_used_bytes hiddify_used_human hiddify_links streisand_links title display_profile_label active_locations safe_active_locations safe_user safe_domain safe_expiry_label safe_days_left safe_hiddify_used_human local safe_android_url safe_windows_url safe_streisand_url safe_karing_url safe_telegram_url safe_donation_url safe_tg_bot_url safe_tg_id_bot_url local traffic_summary safe_traffic_summary profile_cards_html profile_count qr_cards_html recommendations_html recommendations_all_html local subscription_logo_source subscription_logo_name subscription_logo_html subscription_header_logo_html local project_help_qr_source project_help_qr_name project_help_qr_html local qr_links_png qr_hiddify_png qr_streisand_png qr_happ_png qr_nekobox_png qr_v2rayng_png sub_url="https://${subscription_domain}/s/${token}/" links_url="${sub_url}links.txt" hiddify_url="${sub_url}hiddify.txt" streisand_url="${sub_url}streisand.txt" nekobox_url="${sub_url}nekobox.txt" v2rayng_url="${sub_url}v2rayng.txt" pingtunnel_url="${sub_url}pingtunnel.txt" hiddify_open_url="hiddify://import/${hiddify_url}#$(uri_fragment_encode "Yurich Connect ${user}")" hiddify_expire_epoch=$(user_expiry_epoch "$user" 2>/dev/null || true) subscription_refresh_traffic_meta "$user" >/dev/null 2>&1 || true hiddify_used_bytes=$(user_meta_get "$user" TRAFFIC_USED_BYTES 2>/dev/null || true) [[ "$hiddify_used_bytes" =~ ^[0-9]+$ ]] || hiddify_used_bytes="0" hiddify_used_human=$(subscription_human_bytes "$hiddify_used_bytes") hiddify_links=$(printf '%s\n' "$app_links" | awk 'NF && !seen[$0]++' | hiddify_compat_links 1) streisand_links=$(printf '%s\n' "$app_links" | awk 'NF && !seen[$0]++' | hiddify_compat_links) { printf '#profile-title: Yurich Connect %s\n' "$user" printf '#profile-update-interval: 12\n' if [[ -n "$hiddify_expire_epoch" ]]; then printf '#subscription-userinfo: upload=0; download=%s; total=0; expire=%s\n' "$hiddify_used_bytes" "$hiddify_expire_epoch" fi printf '#support-url: %s\n' "$TELEGRAM_COMMUNITY_URL" printf '#profile-web-page-url: %s\n' "$sub_url" printf '%s\n' "$hiddify_links" } > "$hiddify_file" { printf '#profile-title: Yurich Connect %s iOS\n' "$user" printf '#profile-update-interval: 12\n' if [[ -n "$hiddify_expire_epoch" ]]; then printf '#subscription-userinfo: upload=0; download=%s; total=0; expire=%s\n' "$hiddify_used_bytes" "$hiddify_expire_epoch" fi printf '#support-url: %s\n' "$TELEGRAM_COMMUNITY_URL" printf '#profile-web-page-url: %s\n' "$sub_url" printf '%s\n' "$streisand_links" } > "$streisand_file" chmod 644 "$hiddify_file" "$streisand_file" qr_links_png="${page_dir}/qr-links.png" qr_hiddify_png="${page_dir}/qr-hiddify.png" qr_streisand_png="${page_dir}/qr-streisand.png" qr_happ_png="${page_dir}/qr-happ.png" qr_nekobox_png="${page_dir}/qr-nekobox.png" qr_v2rayng_png="${page_dir}/qr-v2rayng.png" write_subscription_qr "$links_url" "$qr_links_png" "${final_page_dir}/qr-links.png" || rm -f "$qr_links_png" write_subscription_qr "$hiddify_url" "$qr_hiddify_png" "${final_page_dir}/qr-hiddify.png" || rm -f "$qr_hiddify_png" write_subscription_qr "$streisand_url" "$qr_streisand_png" "${final_page_dir}/qr-streisand.png" || rm -f "$qr_streisand_png" rm -f "$qr_happ_png" write_subscription_qr "$nekobox_url" "$qr_nekobox_png" "${final_page_dir}/qr-nekobox.png" || rm -f "$qr_nekobox_png" write_subscription_qr "$v2rayng_url" "$qr_v2rayng_png" "${final_page_dir}/qr-v2rayng.png" || rm -f "$qr_v2rayng_png" subscription_logo_source="${SUBSCRIPTION_LOGO_PATH:-$SUBSCRIPTION_LOGO_PATH_DEFAULT}" subscription_logo_name="yurich-connect-logo.png" if [[ -s "$subscription_logo_source" ]]; then cp -f "$subscription_logo_source" "${page_dir}/${subscription_logo_name}" 2>/dev/null || true chmod 644 "${page_dir}/${subscription_logo_name}" 2>/dev/null || true fi if [[ -s "${page_dir}/${subscription_logo_name}" ]]; then subscription_header_logo_html="\"Yurich" subscription_logo_html="\"Yurich" else subscription_header_logo_html="YC" subscription_logo_html="YC" fi project_help_qr_source="${SUBSCRIPTION_PROJECT_HELP_QR_PATH:-$SUBSCRIPTION_PROJECT_HELP_QR_PATH_DEFAULT}" project_help_qr_name="project-help-qr.jpg" if [[ -s "$project_help_qr_source" ]]; then cp -f "$project_help_qr_source" "${page_dir}/${project_help_qr_name}" 2>/dev/null || true chmod 644 "${page_dir}/${project_help_qr_name}" 2>/dev/null || true fi if [[ -s "${page_dir}/${project_help_qr_name}" ]]; then project_help_qr_html="
\"QRQR для помощи проекту
" else project_help_qr_html="" fi title="Yurich Connect: подписка ${user}" safe_user=$(html_escape_text "$user") safe_domain=$(html_escape_text "$subscription_domain") safe_expiry_label=$(html_escape_text "$expiry_label") if [[ -n "$(get_user_expiry "$user" 2>/dev/null || true)" ]]; then safe_days_left=$(html_escape_text "$(days_until_expiry "$(get_user_expiry "$user")" 2>/dev/null || printf '0')") else safe_days_left="∞" fi safe_hiddify_used_human=$(html_escape_text "$hiddify_used_human") display_profile_label=$(subscription_active_locations_label "$active_links") [[ -n "$display_profile_label" ]] || display_profile_label=$(profile_location_label) safe_android_url=$(html_escape_text "$ANDROID_APP_RELEASES_URL") safe_windows_url=$(html_escape_text "$WINDOWS_APP_RELEASES_URL") safe_streisand_url=$(html_escape_text "$STREISAND_APP_URL") safe_karing_url=$(html_escape_text "$KARING_APP_URL") safe_telegram_url=$(html_escape_text "$TELEGRAM_COMMUNITY_URL") safe_donation_url=$(html_escape_text "$PROJECT_DONATION_URL") safe_tg_bot_url=$(html_escape_text "$TELEGRAM_BOT_URL") safe_tg_id_bot_url=$(html_escape_text "$TELEGRAM_ID_BOT_URL") traffic_summary=$(subscription_traffic_summary "$user") safe_traffic_summary=$(html_escape_text "$traffic_summary") active_locations=$(subscription_active_locations_label "$active_links") [[ -n "$active_locations" ]] || active_locations="$display_profile_label" safe_active_locations=$(html_escape_text "$active_locations") profile_cards_html=$(subscription_profile_cards_html "$active_links") recommendations_html=$(subscription_recommendations_html "$active_links") recommendations_all_html=$(subscription_all_recommendations_html "$active_links") profile_count=$(printf '%s\n' "$active_links" | awk 'NF{c++} END{print c+0}') qr_cards_html=$(printf '%s\n' \ "Основная|${qr_links_png}|${links_url}|Для Yurich Connect, v2rayN и универсального импорта" \ "Hiddify|${qr_hiddify_png}|${hiddify_url}|Сканируй внутри Hiddify: это обычный URL подписки" \ "Streisand iOS|${qr_streisand_png}|${streisand_url}|Отдельный iOS-набор для Streisand и совместимых клиентов" \ "NekoBox|${qr_nekobox_png}|${nekobox_url}|Для NekoBox и совместимых клиентов" \ "v2rayNG|${qr_v2rayng_png}|${v2rayng_url}|Только VLESS Reality через TCP/${reality_public_port}" | subscription_qr_cards_html) local safe_links_url safe_hiddify_url safe_streisand_sub_url safe_hiddify_open_url safe_nekobox_url safe_v2rayng_url safe_pingtunnel_url pingtunnel_panel_html wireguard_app_cards_html wireguard_qr_cards_html safe_links_url=$(html_escape_text "$links_url") safe_hiddify_url=$(html_escape_text "$hiddify_url") safe_streisand_sub_url=$(html_escape_text "$streisand_url") safe_hiddify_open_url=$(html_escape_text "$hiddify_open_url") safe_nekobox_url=$(html_escape_text "$nekobox_url") safe_v2rayng_url=$(html_escape_text "$v2rayng_url") safe_pingtunnel_url=$(html_escape_text "$pingtunnel_url") pingtunnel_panel_html="" if [[ -s "$pingtunnel_file" ]]; then pingtunnel_panel_html=$(cat <

PingTunnel / ICMP fallback

Ручной резервный туннель через ICMP. Он не импортируется напрямую в Hiddify, v2rayNG, NekoBox или Streisand: сначала запускается локальный SOCKS5-клиент, потом приложение или браузер направляется на 127.0.0.1:10888.

1. СкачатьСкачай PingTunnel под свою ОС и запусти от администратора/root.
2. ЗапуститьОткрой файл pingtunnel.txt и выполни команду клиента.
3. ПроверитьПроверь выход через SOCKS5: 127.0.0.1:10888.
Открыть pingtunnel.txt EOF ) fi wireguard_app_cards_html="" wireguard_qr_cards_html="" if [[ -d "${CONFIG_DIR}/wireguard" ]]; then while IFS= read -r wg_conf; do [[ -s "$wg_conf" ]] || continue wg_base=$(basename "$wg_conf") wg_label="${wg_base%.conf}" wg_label="${wg_label#${user}-}" wg_label="${wg_label//-/ }" wg_page_conf="${page_dir}/${wg_base}" wg_page_qr="${page_dir}/qr-${wg_base%.conf}.png" cp -f "$wg_conf" "$wg_page_conf" 2>/dev/null || true chmod 644 "$wg_page_conf" 2>/dev/null || true if [[ -s "${wg_conf%.conf}.png" ]]; then cp -f "${wg_conf%.conf}.png" "$wg_page_qr" 2>/dev/null || true chmod 644 "$wg_page_qr" 2>/dev/null || true else write_subscription_qr "${sub_url}${wg_base}" "$wg_page_qr" "${final_page_dir}/qr-${wg_base%.conf}.png" || rm -f "$wg_page_qr" fi safe_wg_label=$(html_escape_text "$wg_label") safe_wg_conf_url=$(html_escape_text "${sub_url}${wg_base}") safe_wg_qr=$(html_escape_text "$(basename "$wg_page_qr")") wireguard_app_cards_html="${wireguard_app_cards_html}
WireGuard ${safe_wg_label}
Тестовый профиль WireGuard. Подходит для официального приложения WireGuard на Android, iOS, Windows и macOS.
Скачать .conf
" if [[ -s "$wg_page_qr" ]]; then wireguard_qr_cards_html="${wireguard_qr_cards_html}
\"QR
WireGuard ${safe_wg_label}
Сканируй в официальном WireGuard.
Открыть .conf
" fi done < <(find "${CONFIG_DIR}/wireguard" -maxdepth 1 -type f -name "${user}-*.conf" 2>/dev/null | sort) fi if subscription_premium_v2_page_enabled "$user"; then cat > "${page_dir}/index.html" < ${title}
Yurich ConnectЛичная страница подписки
Telegram
Профиль ${safe_user} • активна

Yurich Connect VPN

Современная страница подключения для телефона и компьютера: одна подписка, быстрый импорт в приложения, QR-коды и рекомендации по рабочим локациям.

HTTPS Turbo Reality ${safe_active_locations}
Использовано трафика${safe_hiddify_used_human}

${safe_traffic_summary}

Дней осталось${safe_days_left}

${safe_expiry_label}

Активные профили${profile_count}

${safe_active_locations}

Подключение

Выбери приложение и добавь подписку без ручной настройки.
iPhone
Streisand или Karing для iOS.
NekoBox / v2rayNG
Отдельные совместимые файлы для ручного импорта.
${wireguard_app_cards_html}

Рекомендации по подключению

Все активные серверы и протоколы. Если один профиль временно медленный, попробуй соседний.
${recommendations_all_html}

QR-коды

Сканируй внутри приложения. Для Hiddify лучше использовать QR hiddify.txt.
${qr_cards_html} ${wireguard_qr_cards_html}
${pingtunnel_panel_html}

Помощь проекту

Собираем помощь для развития Yurich Connect и разработки приложений для Android и iPhone. Поддержка помогает быстрее выпускать обновления, улучшать стабильность серверов и делать подключение проще.

${project_help_qr_html} Поддержать проект

Уведомления о подписке

Чтобы получать напоминания об окончании подписки и новости, отправь разработчику свой Telegram ID.

1. Получи IDОткрой @getmyid_bot и нажми Start.
2. Отправь IDОтправь цифры Telegram ID вместе с именем профиля: ${safe_user}.
3. Включи ботаОткрой бота уведомлений и нажми Start.
Отдельные профили для ручного копирования
${profile_cards_html}
EOF chmod 644 "${page_dir}/index.html" subscription_finalize_generated_page "$build_page_dir" "$final_page_dir" "$user" || return 1 build_page_dir="" printf '%s\n' "$sub_url" return 0 fi if subscription_remnawave_preview_page_enabled "$user"; then cat > "${page_dir}/index.html" < ${title}
${subscription_header_logo_html}Подписка
TG

Установка

Установи приложение и добавь подписку одной кнопкой.
Android / iOS / Windows
Hiddify
Установи приложение
Скачай приложение для Android или Windows. Для iPhone используй Streisand или Karing.
Добавь подписку
Основная ссылка подходит для Yurich Connect, Hiddify, NekoBox, v2rayNG и совместимых клиентов.
Импорт по QR
Для Hiddify сканируй QR с обычным URL подписки, чтобы не создавался пустой профиль.
hiddify.txt

Рекомендации

Все доступные подключения по активным локациям. Начинай с Turbo для скорости, HTTPS для совместимости, Reality для стабильного TCP-подключения.
${recommendations_all_html}

QR-коды

Отдельные QR для разных приложений и сценариев импорта.
${qr_cards_html}
${pingtunnel_panel_html}

Помощь проекту

Собираем помощь для развития проекта Yurich Connect и разработки приложений для Android и iPhone. Каждый вклад помогает быстрее выпускать обновления, улучшать стабильность подключений и делать приложение удобнее.

${project_help_qr_html} Поддержать проект

Telegram-уведомления

Чтобы получать напоминания об окончании подписки и важные новости, отправь разработчику свой Telegram ID.
1. Получи IDОткрой @getmyid_bot и нажми Start.
2. Отправь IDОтправь цифры Telegram ID вместе с именем профиля: ${safe_user}.
3. Включи ботаОткрой бота уведомлений и нажми Start.
EOF chmod 644 "${page_dir}/index.html" subscription_finalize_generated_page "$build_page_dir" "$final_page_dir" "$user" || return 1 build_page_dir="" printf '%s\n' "$sub_url" return 0 fi if subscription_remnawave_page_enabled "$user"; then cat > "${page_dir}/index.html" < ${title}
Y
Yurich ConnectSubscription
Android Windows
Private access

Подписка ${safe_user}

Единая страница для установки приложения, импорта подписки и копирования отдельных профилей. Ссылка скрыта от индексации, но доступна всем, у кого есть этот URL.

HTTPS Turbo Reality ${safe_active_locations}
Трафик${safe_hiddify_used_human}

${safe_traffic_summary}

Локации${profile_count}

Активные профили по доступным серверам.

Обновлено$(date '+%H:%M')

$(date '+%Y-%m-%d') по времени сервера.

Installation

Выбери клиент и добавь подписку одной кнопкой.
Streisand iOS
Подписка для iPhone и iPad.
NekoBox
Отдельная совместимая подписка.
v2rayNG
Только VLESS Reality через TCP/${reality_public_port}.

Рекомендации

Что лучше пробовать первым на текущих серверах.
${recommendations_html}

QR-коды

Сканируй QR внутри клиента или копируй ссылку.
${qr_cards_html}
${pingtunnel_panel_html}

Telegram-уведомления

Чтобы получать напоминания об окончании подписки и важные новости, отправь разработчику свой Telegram ID.

1. Получи IDОткрой @getmyid_bot и нажми Start.
2. Отправь IDОтправь цифры Telegram ID вместе с именем профиля: ${safe_user}.
3. Включи ботаОткрой бота уведомлений и нажми Start.
Получить Telegram ID Открыть бота уведомлений

Активные профили

Каждый профиль можно скопировать отдельно.
${profile_cards_html}
EOF chmod 644 "${page_dir}/index.html" subscription_finalize_generated_page "$build_page_dir" "$final_page_dir" "$user" || return 1 build_page_dir="" printf '%s\n' "$sub_url" return 0 fi cat > "${page_dir}/index.html" < ${title}
Yurich Connect
Подписка ${safe_user}
Личная ссылка Yurich Connect.
Не публикуй её в открытом доступе.
HTTPS Turbo Reality
Срок подписки
${safe_days_left} дней
${safe_expiry_label}
Трафик клиента
${safe_hiddify_used_human}
${safe_traffic_summary} Счетчик персональный, общий трафик сервера здесь не показывается.
Активные профили
${profile_count}
HTTPS, Turbo и Reality по доступным локациям.

Рекомендации по стабильности

Рейтинг строится из последних серверных benchmark-проверок. Если сеть провайдера режет один протокол, пробуй резервный.
${recommendations_html}

Быстрый импорт

Выбери приложение или скопируй URL подписки.
Streisand
Приложение для iPhone и iPad. Импортируй iOS-подписку или открой App Store.
App Store
NekoBox
Отдельная ссылка для NekoBox и похожих клиентов.
v2rayNG
Отдельная подписка только с VLESS Reality через TCP/${reality_public_port}.

QR-коды

QR-коды содержат обычные URL подписок для популярных клиентов.
${qr_cards_html}
${pingtunnel_panel_html}

Telegram-уведомления

Чтобы получать напоминания об окончании подписки и важные новости, отправь разработчику свой Telegram ID.

1. Получи IDОткрой бота @getmyid_bot и нажми Start.
2. Отправь IDСкопируй цифры Telegram ID и отправь их разработчику вместе с именем профиля: ${safe_user}.
3. Включи уведомленияОткрой бота уведомлений и нажми Start, чтобы бот мог присылать сообщения.
Получить Telegram ID Открыть бота уведомлений

Активные профили

Каждый профиль можно скопировать отдельно.
${profile_cards_html}
EOF chmod 644 "${page_dir}/index.html" subscription_finalize_generated_page "$build_page_dir" "$final_page_dir" "$user" || return 1 build_page_dir="" printf '%s\n' "$sub_url" } cmd_subscription_user() { local user="${1:-}" if [[ -z "$user" ]]; then echo -ne "${CYAN}Пользователь: ${RESET}" read -r user fi local url url=$(generate_subscription_page "$user") || return 1 ok "Страница подписки создана:" echo " ${url}" echo " links.txt: ${url}links.txt" echo " v2rayng.txt: ${url}v2rayng.txt" } cmd_subscription_reset() { load_config local user="${1:-}" if [[ -z "$user" ]]; then echo -ne "${CYAN}Пользователь: ${RESET}" read -r user fi if ! is_valid_proxy_user "$user"; then err "Некорректный логин" return 1 fi local old_token="" if [[ -s "${SUBS_DIR}/${user}.token" ]]; then old_token=$(tr -dc 'a-fA-F0-9' < "${SUBS_DIR}/${user}.token" | head -c 48 || true) fi remove_subscription_aliases_for_user "$user" "$old_token" || return 1 reset_token_file "${SUBS_DIR}/${user}.token" if [[ "$old_token" =~ ^[a-fA-F0-9]{32,64}$ ]]; then remove_web_token_dir "$SUBS_WEB_DIR" "$old_token" fi cmd_subscription_user "$user" } install_private_camouflage_page() { load_config if ! is_valid_domain "${DOMAIN:-}"; then err "Домен не настроен или некорректен" return 1 fi ensure_web_privacy_files local mode="${1:-}" local old_token="" if [[ "$mode" == "reset" && -s "$PRIVATE_PAGE_TOKEN_FILE" ]]; then old_token=$(tr -dc 'a-fA-F0-9' < "$PRIVATE_PAGE_TOKEN_FILE" | head -c 48 || true) fi [[ "$mode" == "reset" ]] && reset_token_file "$PRIVATE_PAGE_TOKEN_FILE" if [[ "$old_token" =~ ^[a-fA-F0-9]{32,64}$ ]]; then remove_web_token_dir "$PRIVATE_WEB_DIR" "$old_token" fi local token page_dir url token=$(get_or_create_token_file "$PRIVATE_PAGE_TOKEN_FILE") page_dir="${PRIVATE_WEB_DIR}/${token}" url="https://${DOMAIN}/p/${token}/" mkdir -p "$page_dir" chmod 755 "$page_dir" cat > "${page_dir}/index.html" < Yurich Panel Lab
Private technical notebook
Yurich Panel Lab

Личная страница для заметок по инфраструктуре, релизам и тестовым окружениям. Публичная часть сайта остаётся обычным техническим блогом, эта страница живёт только по секретному адресу.

Рабочие заметки

План на неделю: проверить резервные копии, обновить список зависимостей, прогнать диагностику TLS/HTTP3, сверить правила firewall и журнал systemd.

Последняя проверка: $(date '+%Y-%m-%d %H:%M')

Статус

Docsonline
Build notessynced
Monitoringactive
EOF chmod 644 "${page_dir}/index.html" ok "Персональная фейковая страница создана:" echo " ${url}" } cleanup_xray_legacy_transports() { if command -v ufw >/dev/null 2>&1; then ufw delete allow "${XRAY_VISION_PORT:-$XRAY_VISION_PORT_DEFAULT}/tcp" >/dev/null 2>&1 || true ufw delete allow "${XRAY_WS_PORT:-$XRAY_WS_PORT_DEFAULT}/tcp" >/dev/null 2>&1 || true ufw delete allow "${XRAY_HTTPUPGRADE_PORT:-$XRAY_HTTPUPGRADE_PORT_DEFAULT}/tcp" >/dev/null 2>&1 || true ufw delete allow "${XRAY_MKCP_PORT:-$XRAY_MKCP_PORT_DEFAULT}/udp" >/dev/null 2>&1 || true ufw delete allow "${XRAY_XHTTP_PORT:-$XRAY_XHTTP_PORT_DEFAULT}/tcp" >/dev/null 2>&1 || true fi } apply_xray_reality_firewall() { command -v ufw >/dev/null 2>&1 || return 0 if [[ "${XRAY_REALITY_SNI_MUX_ENABLED:-0}" == "1" ]]; then ufw allow 443/tcp comment "HAProxy TLS SNI mux" >/dev/null 2>&1 || true ufw delete allow "${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}/tcp" >/dev/null 2>&1 || true else ufw allow "${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}/tcp" comment "Xray REALITY" >/dev/null 2>&1 || true fi } cmd_xray_install() { load_config check_installed || { err "Сначала установи Yurich Panel и получи TLS сертификат"; return 1; } hr echo -e "${BOLD} Xray Modern transports${RESET}" hr normalize_edge_routing_mode if edge_routing_mode_is_haproxy; then warn "Режим 443: HAProxy SNI mux. Reality будет доступен на ${DOMAIN}:443 через HAProxy." XRAY_FALLBACK_ENABLED="0" XRAY_REALITY_SNI_MUX_ENABLED="1" XRAY_REALITY_PUBLIC_PORT="443" else warn "Режим 443: Caddy-only. Caddy остаётся на 443, Reality будет на отдельном TCP-порту ${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}." warn "Legacy fallback hub переключает 443 с Caddy на Xray. Используй только если точно нужен старый режим." echo -ne "${YELLOW}Включить legacy Xray fallback hub на 443? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] && XRAY_FALLBACK_ENABLED="1" || XRAY_FALLBACK_ENABLED="0" fi echo -ne "${CYAN}Xray пользователь [xray]: ${RESET}" read -r xuser xuser="${xuser:-xray}" if ! is_valid_proxy_user "$xuser"; then err "Логин: 2-32 символа, только A-Z a-z 0-9 _ -" return 1 fi local x_months x_months=$(prompt_user_term_months 12) || return 1 set_user_expiry_months "$xuser" "$x_months" || true prompt_xray_reality_target || return 1 # write_xray_config reloads naive.conf, so persist the selected target first. save_config install_xray_bin || return 1 write_xray_config "$xuser" || return 1 write_xray_service if [[ "${XRAY_FALLBACK_ENABLED:-0}" == "1" ]]; then rewrite_caddyfile_current || return 1 systemctl restart caddy fi apply_xray_reality_firewall cleanup_xray_legacy_transports systemctl restart xray XRAY_ENABLED="1" save_config refresh_haproxy_if_enabled || return 1 ok "Xray запущен" local sub_url sub_url=$(generate_subscription_page "$xuser" 2>/dev/null || true) if [[ -n "$sub_url" ]]; then ok "Страница подписки Xray пользователя создана: $sub_url" echo -e " links.txt: ${sub_url}links.txt" fi print_xray_client_config "$xuser" } provision_xray_user() { load_config local xuser="$1" local backup_file="" existed=0 uuid if ! is_valid_proxy_user "$xuser"; then err "Логин Xray: 2-32 символа, только A-Z a-z 0-9 _ -" return 1 fi if [[ ! -x "$XRAY_BIN" ]]; then err "Xray не установлен. Открой меню 23 → 1." return 1 fi if ! is_valid_domain "${DOMAIN:-}"; then err "Домен не настроен или некорректен" return 1 fi if get_xray_user_uuid "$xuser" >/dev/null 2>&1; then existed=1 fi if [[ -f "$XRAY_USERS_FILE" ]]; then backup_file=$(mktemp) cp "$XRAY_USERS_FILE" "$backup_file" fi uuid=$(xray_ensure_user "$xuser") || return 1 if [[ "$existed" -eq 1 ]]; then ok "Xray пользователь уже существует: ${xuser}" else ok "Xray пользователь создан: ${xuser} (${uuid})" fi if ! write_xray_config "$xuser"; then if [[ -n "$backup_file" && -f "$backup_file" ]]; then mv "$backup_file" "$XRAY_USERS_FILE" chmod 600 "$XRAY_USERS_FILE" else rm -f "$XRAY_USERS_FILE" fi err "Xray config не прошёл проверку, пользователь ${xuser} не применён" return 1 fi rm -f "$backup_file" 2>/dev/null || true write_xray_service if [[ "${XRAY_FALLBACK_ENABLED:-0}" == "1" ]]; then rewrite_caddyfile_current || return 1 systemctl reload caddy 2>/dev/null || systemctl restart caddy 2>/dev/null || true fi apply_xray_reality_firewall cleanup_xray_legacy_transports systemctl restart xray || return 1 XRAY_ENABLED="1" save_config refresh_haproxy_if_enabled || return 1 } cmd_xray_add_user() { load_config local xuser="${1:-}" local months_arg="${2:-}" if [[ -z "$xuser" ]]; then echo -ne "${CYAN}Новый Xray пользователь: ${RESET}" read -r xuser fi local x_months if is_valid_user_months "$months_arg"; then x_months="$months_arg" elif [[ -t 0 ]]; then x_months=$(prompt_user_term_months 12) || return 1 else x_months="12" fi if ! provision_xray_user "$xuser"; then return 1 fi set_user_expiry_months "$xuser" "$x_months" || true local sub_url sub_url=$(generate_subscription_page "$xuser" 2>/dev/null || true) if [[ -n "$sub_url" ]]; then ok "Личная страница подписки создана:" echo " ${sub_url}" echo " links.txt: ${sub_url}links.txt" else warn "Страница подписки не создана автоматически. Проверь: sudo bash yurich-panel.sh subscription ${xuser}" fi print_xray_client_config "$xuser" } cmd_xray_add_compat_user() { load_config local xuser="${1:-}" local backup_file="" existed=0 uuid sub_url old_mobile_alt_enabled old_mobile_alt_enabled="${XRAY_MOBILE_ALT_ENABLED:-0}" if [[ -z "$xuser" ]]; then echo -ne "${CYAN}Xray Reality TEST пользователь: ${RESET}" read -r xuser fi if ! is_valid_proxy_user "$xuser"; then err "Логин Xray: 2-32 символа, только A-Z a-z 0-9 _ -" return 1 fi if [[ ! -x "$XRAY_BIN" ]]; then err "Xray не установлен. Открой меню 23 → 1." return 1 fi if ! get_xray_user_uuid "$xuser" >/dev/null 2>&1; then err "Основной Xray Reality пользователь не найден: ${xuser}" return 1 fi if get_xray_compat_user_uuid "$xuser" >/dev/null 2>&1; then existed=1 fi if [[ -f "$XRAY_COMPAT_USERS_FILE" ]]; then backup_file=$(mktemp) cp "$XRAY_COMPAT_USERS_FILE" "$backup_file" fi uuid=$(xray_ensure_compat_user "$xuser") || return 1 if [[ "$existed" -eq 1 ]]; then ok "Reality TEST пользователь уже существует: ${xuser}" else ok "Reality TEST пользователь создан: ${xuser} (${uuid})" fi XRAY_MOBILE_ALT_ENABLED="1" if ! write_xray_config; then XRAY_MOBILE_ALT_ENABLED="$old_mobile_alt_enabled" if [[ -n "$backup_file" && -f "$backup_file" ]]; then mv "$backup_file" "$XRAY_COMPAT_USERS_FILE" chmod 600 "$XRAY_COMPAT_USERS_FILE" else rm -f "$XRAY_COMPAT_USERS_FILE" fi err "Xray config не прошёл проверку, Reality TEST для ${xuser} не применён" return 1 fi rm -f "$backup_file" 2>/dev/null || true write_xray_service systemctl restart xray || return 1 save_config refresh_haproxy_if_enabled || return 1 sub_url=$(generate_subscription_page "$xuser" 2>/dev/null || true) [[ -n "$sub_url" ]] && ok "Подписка обновлена: ${sub_url}" } cmd_xray_rebuild() { load_config if [[ ! -x "$XRAY_BIN" ]]; then warn "Xray не установлен — rebuild пропущен" return 0 fi if [[ ! -s "$XRAY_USERS_FILE" ]]; then warn "xray-users.conf пустой — rebuild пропущен" return 0 fi write_xray_config || return 1 write_xray_service if [[ "${XRAY_FALLBACK_ENABLED:-0}" == "1" ]]; then rewrite_caddyfile_current || return 1 systemctl reload caddy 2>/dev/null || systemctl restart caddy 2>/dev/null || true fi apply_xray_reality_firewall cleanup_xray_legacy_transports systemctl restart xray || return 1 XRAY_ENABLED="1" save_config refresh_haproxy_if_enabled || return 1 ok "Xray config пересобран" } cmd_xray_status() { load_config hr echo -e "${BOLD} Xray статус${RESET}" hr [[ -x "$XRAY_BIN" ]] && "$XRAY_BIN" version | head -1 || warn "Xray не установлен" systemctl status xray --no-pager -l 2>/dev/null || true ss -tulpn | grep -E ":(443|${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT})([[:space:]]|$)" || true [[ -f "$XRAY_CONFIG" ]] && "$XRAY_BIN" run -test -config "$XRAY_CONFIG" || true hr } cmd_vless_tune() { load_config 2>/dev/null || true hr echo -e "${BOLD} VLESS Reality stability tuning${RESET}" hr local backup_dir backup_dir="${BACKUP_DIR}/vless-tune-before-$(date '+%Y%m%d_%H%M%S')" install -d -m 700 "$backup_dir" for item in "$XRAY_CONFIG" "$XRAY_SERVICE" "$HAPROXY_CFG" "$HAPROXY_SYSCTL_CONF" "$XRAY_SYSCTL_CONF"; do [[ -e "$item" ]] && cp -a "$item" "$backup_dir/" 2>/dev/null || true done info "Backup: $backup_dir" apply_vless_tcp_tuning if [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then info "Пересобираю Xray с policy/sockopt tuning..." write_xray_config || return 1 write_xray_service if ! "$XRAY_BIN" run -test -config "$XRAY_CONFIG" >/dev/null 2>&1; then err "Xray config не прошёл проверку после tuning" "$XRAY_BIN" run -test -config "$XRAY_CONFIG" || true return 1 fi systemctl daemon-reload systemctl restart xray || { journalctl -u xray -n 60 --no-pager; return 1; } systemctl is-active --quiet xray || { journalctl -u xray -n 60 --no-pager; return 1; } ok "Xray перезапущен с VLESS tuning" else warn "Xray не установлен или config отсутствует — применён только Linux TCP tuning" fi if edge_routing_mode_is_haproxy; then info "Обновляю HAProxy TCP keepalive/SNI mux..." ensure_haproxy_packages || return 1 write_haproxy_logging_config write_haproxy_sni_mux_config || return 1 systemctl enable haproxy --quiet systemctl reload haproxy 2>/dev/null || systemctl restart haproxy systemctl is-active --quiet haproxy || { journalctl -u haproxy -n 60 --no-pager; return 1; } sleep 3 if ! haproxy_backends_healthy >/tmp/yurich-haproxy-health.out 2>&1; then warn "HAProxy запущен, но health-check требует внимания:" sed -n '1,80p' /tmp/yurich-haproxy-health.out || true else ok "HAProxy backend health OK" fi else info "HAProxy режим не включён — VLESS работает напрямую через Xray/Caddy-only порт" fi ok "VLESS tuning завершён" } cmd_xray_reality_target() { load_config prompt_xray_reality_target || return 1 save_config if [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then write_xray_config || return 1 systemctl restart xray || return 1 ok "Xray config пересобран с REALITY target: ${XRAY_REALITY_TARGET}" else ok "REALITY target сохранён: ${XRAY_REALITY_TARGET}" warn "Xray ещё не установлен. Target применится при установке Xray." fi } cmd_xray_zapret() { load_config local action="${1:-enable}" local dat="${XRAY_ZAPRET_DAT:-$XRAY_ZAPRET_DAT_DEFAULT}" case "${action,,}" in enable|on|"") XRAY_ZAPRET_ENABLED="0" save_config warn "Xray zapret больше не включает block-routing: он ломал Instagram/Facebook. Оставил режим выключенным." ;; update|refresh) rm -f "$dat" 2>/dev/null || true XRAY_ZAPRET_ENABLED="0" save_config XRAY_ZAPRET_ENABLED=1 ensure_xray_zapret_assets || return 1 ;; disable|off) XRAY_ZAPRET_ENABLED="0" save_config ;; status) echo -e " Enabled: ${CYAN}0${RESET} (blackhole routing disabled)" echo -e " File: ${CYAN}${dat}${RESET}" [[ -s "$dat" ]] && ls -lh "$dat" || warn "zapret.dat не найден" return 0 ;; *) err "Используй: xray-zapret [update|disable|status]. enable оставлен только для совместимости и не включает блокировку." return 1 ;; esac if [[ -x "$XRAY_BIN" && -s "$XRAY_USERS_FILE" ]]; then if ! cmd_xray_rebuild; then if [[ "${action,,}" == "enable" || "${action,,}" == "on" || -z "${action:-}" || "${action,,}" == "update" || "${action,,}" == "refresh" ]]; then XRAY_ZAPRET_ENABLED="0" save_config warn "zapret.dat отключён на этом сервере: Xray не прошёл тест с текущими ресурсами" cmd_xray_rebuild || true fi return 1 fi else warn "Xray ещё не установлен или нет пользователей. Настройка сохранена и применится при следующей сборке Xray." fi } cmd_xray_logs() { echo -e "${BOLD}Лог Xray (Ctrl+C для выхода):${RESET}" journalctl -u xray -n 80 -f } cmd_xray_remove() { echo -ne "${RED}Удалить Xray и вернуть Caddy на 443? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return systemctl disable --now xray >/dev/null 2>&1 || true rm -f "$XRAY_SERVICE" "$XRAY_CONFIG" "$XRAY_BIN" ufw delete allow "${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}/tcp" >/dev/null 2>&1 || true ufw delete allow "${XRAY_MKCP_PORT:-$XRAY_MKCP_PORT_DEFAULT}/udp" >/dev/null 2>&1 || true ufw delete allow 8447/tcp >/dev/null 2>&1 || true ufw delete allow "${XRAY_XHTTP_PORT:-$XRAY_XHTTP_PORT_DEFAULT}/tcp" >/dev/null 2>&1 || true XRAY_ENABLED="0" XRAY_FALLBACK_ENABLED="0" save_config systemctl daemon-reload rewrite_caddyfile_current || true systemctl restart caddy 2>/dev/null || true ok "Xray удалён из конфигурации, Caddy возвращён на 443" } cmd_xray_menu() { while true; do load_config hr echo -e "${BOLD} Xray Modern transports & fallback${RESET}" hr echo -e " ${BOLD}1)${RESET} Установить / пересобрать Xray config" echo -e " ${BOLD}2)${RESET} Показать клиентские ссылки" echo -e " ${BOLD}3)${RESET} Статус" echo -e " ${BOLD}4)${RESET} Логи" echo -e " ${BOLD}5)${RESET} Удалить Xray / вернуть Caddy" echo -e " ${BOLD}6)${RESET} Создать Xray пользователя + подписка" echo -e " ${BOLD}7)${RESET} REALITY target presets / test" echo -e " ${BOLD}8)${RESET} Zapret RU GOV routing" echo -e " ${BOLD}0)${RESET} Назад" hr echo -e " Fallback 443: ${CYAN}${XRAY_FALLBACK_ENABLED:-0}${RESET} | REALITY: ${CYAN}${XRAY_REALITY_PORT:-$XRAY_REALITY_PORT_DEFAULT}${RESET} | Target: ${CYAN}${XRAY_REALITY_TARGET:-www.microsoft.com:443}${RESET} | Zapret: ${CYAN}${XRAY_ZAPRET_ENABLED:-0}${RESET}" echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_xray_install ;; 2) echo -ne "${CYAN}Пользователь [первый]: ${RESET}"; read -r u print_xray_client_config "$u" ;; 3) cmd_xray_status ;; 4) cmd_xray_logs ;; 5) cmd_xray_remove ;; 6) cmd_xray_add_user ;; 7) cmd_xray_reality_target ;; 8) echo -ne "${CYAN}Действие [enable/update/disable/status]: ${RESET}"; read -r zact cmd_xray_zapret "${zact:-enable}" ;; 0) return ;; *) warn "Неверный выбор" ;; esac echo -ne "${DIM}Enter для продолжения...${RESET}"; read -r _ done } # ─── CLOUDFLARE WARP PROXY MODE ─────────────────────────────── install_warp_client() { if command -v warp-cli &>/dev/null; then ok "cloudflare-warp уже установлен: $(warp-cli --version 2>/dev/null | head -1 || echo warp-cli)" return 0 fi local codename codename=$(lsb_release -cs 2>/dev/null || true) if [[ -z "$codename" && -f /etc/os-release ]]; then codename=$(awk -F= '$1=="VERSION_CODENAME"{gsub(/"/,"",$2); print $2; exit}' /etc/os-release 2>/dev/null || true) fi if [[ -z "$codename" ]]; then err "Не смог определить codename дистрибутива для репозитория Cloudflare WARP" return 1 fi case "$codename" in focal|jammy|noble|bullseye|bookworm|trixie) ;; *) warn "Cloudflare официально поддерживает не все релизы. Codename: $codename" echo -ne "${YELLOW}Продолжить установку WARP repo для ${codename}? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return 1 ;; esac info "Добавляю официальный репозиторий Cloudflare WARP..." apt-get update -qq apt-get install -y -q curl ca-certificates gnupg lsb-release mkdir -p /usr/share/keyrings /etc/apt/sources.list.d curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \ | gpg --yes --dearmor --output /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ ${codename} main" \ > /etc/apt/sources.list.d/cloudflare-client.list info "Устанавливаю cloudflare-warp..." apt-get update -qq apt-get install -y -q cloudflare-warp ok "cloudflare-warp установлен" } warp_registration_new() { if warp_cli registration show >/dev/null 2>&1 || warp_cli account >/dev/null 2>&1; then ok "WARP регистрация уже есть" return 0 fi info "Регистрирую WARP клиент..." if warp_cli registration new >/dev/null 2>&1 || warp_cli register >/dev/null 2>&1; then ok "WARP клиент зарегистрирован" return 0 fi err "Не удалось зарегистрировать WARP. Попробуй вручную: warp-cli registration new" return 1 } warp_set_proxy_port() { local port="$1" if warp_cli proxy port "$port" >/dev/null 2>&1; then return 0 fi if warp_cli set-proxy-port "$port" >/dev/null 2>&1; then return 0 fi if [[ "$port" == "$WARP_PROXY_PORT_DEFAULT" ]]; then warn "Не смог явно выставить порт, оставляю дефолт WARP proxy: ${WARP_PROXY_PORT_DEFAULT}" return 0 fi err "Эта версия warp-cli не дала сменить proxy port. Используй ${WARP_PROXY_PORT_DEFAULT} или проверь: warp-cli --help" return 1 } warp_set_proxy_mode() { # Современный WARP local proxy mode требует MASQUE; если команда недоступна, не считаем это фатальным. warp_cli tunnel protocol set MASQUE >/dev/null 2>&1 || true if warp_cli mode proxy >/dev/null 2>&1; then return 0 fi if warp_cli set-mode proxy >/dev/null 2>&1; then return 0 fi err "Не удалось включить WARP proxy mode. Проверь: warp-cli mode --help" return 1 } warp_set_mode() { local mode="$1" if warp_cli mode "$mode" >/dev/null 2>&1; then return 0 fi if warp_cli set-mode "$mode" >/dev/null 2>&1; then return 0 fi err "Не удалось переключить WARP mode: ${mode}. Проверь: warp-cli mode --help" return 1 } warp_set_tunnel_protocol() { local proto="$1" case "$proto" in MASQUE|WireGuard) ;; auto|"") return 0 ;; *) err "Протокол WARP должен быть auto, MASQUE или WireGuard" return 1 ;; esac if warp_cli tunnel protocol set "$proto" >/dev/null 2>&1; then WARP_PROTOCOL="$proto" ok "WARP protocol: ${proto}" return 0 fi warn "Не удалось выставить WARP protocol=${proto}. Проверь: warp-cli tunnel protocol set ${proto}" return 1 } warp_wait_connected() { local i status for i in {1..30}; do status=$(warp_cli status 2>/dev/null || true) if echo "$status" | grep -Eiq "connected|соедин"; then return 0 fi sleep 1 done warn "WARP не подтвердил Connected за 30 секунд" warp_cli status 2>/dev/null || true return 1 } warp_current_ssh_client_ip() { local ip="" if [[ -n "${SSH_CLIENT:-}" ]]; then ip=$(awk '{print $1}' <<< "$SSH_CLIENT") elif [[ -n "${SSH_CONNECTION:-}" ]]; then ip=$(awk '{print $1}' <<< "$SSH_CONNECTION") fi if [[ -z "$ip" ]]; then ip=$(who -m 2>/dev/null | sed -n 's/.*(\([^)]*\)).*/\1/p' | head -1 || true) fi printf '%s\n' "$ip" } warp_route_cidr_for_ip() { local ip="$1" [[ -z "$ip" ]] && return 1 if [[ "$ip" == *:* ]]; then [[ "$ip" =~ ^[0-9A-Fa-f:]+$ ]] || return 1 printf '%s/128\n' "$ip" else is_valid_ipv4 "$ip" || return 1 printf '%s/32\n' "$ip" fi } warp_add_excluded_route() { local cidr="$1" [[ -z "$cidr" ]] && return 1 if warp_cli tunnel ip add-range "$cidr" >/dev/null 2>&1; then ok "WARP exclude route добавлен: ${cidr}" return 0 fi if warp_cli tunnel ip add "$cidr" >/dev/null 2>&1; then ok "WARP exclude route добавлен: ${cidr}" return 0 fi if warp_cli add-excluded-route "$cidr" >/dev/null 2>&1; then ok "WARP exclude route добавлен: ${cidr}" return 0 fi if warp_cli tunnel exclude-routes "$cidr" >/dev/null 2>&1; then ok "WARP exclude route добавлен: ${cidr}" return 0 fi warn "Не удалось автоматически добавить WARP exclude route: ${cidr}" warn "Если SSH отвалится, используй консоль провайдера: sudo bash yurich-panel.sh warp-disable" return 1 } warp_prepare_ssh_safety() { local ssh_ip ssh_cidr public_ip public_cidr route ssh_ip=$(warp_current_ssh_client_ip) if [[ -n "$ssh_ip" ]]; then ssh_cidr=$(warp_route_cidr_for_ip "$ssh_ip" || true) [[ -n "$ssh_cidr" ]] && warp_add_excluded_route "$ssh_cidr" || true else warn "Не смог определить IP текущей SSH-сессии через SSH_CLIENT/SSH_CONNECTION" fi for route in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16; do warp_add_excluded_route "$route" >/dev/null 2>&1 || true done public_ip=$(curl -4 -fsSL --connect-timeout 5 --max-time 8 https://api.ipify.org 2>/dev/null || true) if is_valid_ipv4 "$public_ip"; then public_cidr="${public_ip}/32" warp_add_excluded_route "$public_cidr" >/dev/null 2>&1 || true fi warp_apply_saved_ssh_excludes } normalize_warp_ssh_cidrs() { local raw="$1" item out="" normalized local -a cidrs raw="${raw// /}" IFS=',' read -ra cidrs <<< "$raw" for item in "${cidrs[@]}"; do [[ -z "$item" ]] && continue if [[ "$item" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then normalized="${item}/32" else normalized="$item" fi if ! is_valid_cidr4 "$normalized"; then err "Некорректный SSH allow CIDR: $item" return 1 fi if [[ "${normalized#*/}" == "0" ]]; then err "Нельзя добавлять /0 в WARP SSH allowlist" return 1 fi case ",$out," in *,"$normalized",*) ;; *) out="${out:+$out,}${normalized}" ;; esac done printf '%s\n' "$out" } warp_merge_ssh_allow_cidrs() { local old="$1" extra="$2" merged merged=$(normalize_warp_ssh_cidrs "${old:+$old,}${extra}") || return 1 printf '%s\n' "$merged" } warp_apply_saved_ssh_excludes() { local cidr local -a _warp_saved_cidrs [[ -z "${WARP_SSH_ALLOW_CIDRS:-}" ]] && return 0 IFS=',' read -ra _warp_saved_cidrs <<< "${WARP_SSH_ALLOW_CIDRS}" for cidr in "${_warp_saved_cidrs[@]}"; do [[ -z "$cidr" ]] && continue warp_add_excluded_route "$cidr" >/dev/null 2>&1 || true done } cmd_warp_ssh_allow() { load_config local current_ip current_cidr extra normalized merged current_ip=$(warp_current_ssh_client_ip) [[ -n "$current_ip" ]] && current_cidr=$(warp_route_cidr_for_ip "$current_ip" || true) hr echo -e "${BOLD} WARP full tunnel SSH allowlist${RESET}" hr echo -e " Сейчас сохранено: ${CYAN}${WARP_SSH_ALLOW_CIDRS:-нет}${RESET}" [[ -n "${current_cidr:-}" ]] && echo -e " Текущая SSH-сессия: ${CYAN}${current_cidr}${RESET}" echo warn "Добавь IP/CIDR, с которых будешь заходить по SSH: домашний, мобильный, офисный." warn "Если IP динамический и заранее неизвестен, full tunnel всё равно может отрезать SSH." echo -ne "${CYAN}CIDR/IP через запятую [${current_cidr:-пусто}]: ${RESET}" read -r extra extra="${extra:-${current_cidr:-}}" normalized=$(normalize_warp_ssh_cidrs "$extra") || return 1 [[ -z "$normalized" ]] && { warn "Ничего не добавлено"; return 0; } merged=$(warp_merge_ssh_allow_cidrs "${WARP_SSH_ALLOW_CIDRS:-}" "$normalized") || return 1 WARP_SSH_ALLOW_CIDRS="$merged" save_config if command -v warp-cli >/dev/null 2>&1; then warp_apply_saved_ssh_excludes fi ok "WARP SSH allowlist обновлён: ${WARP_SSH_ALLOW_CIDRS}" } warp_arm_rollback() { local marker="/run/naiveproxy-warp-full-pending" unit_name unit_name="naiveproxy-warp-rollback-$(date +%s)" echo "$(date '+%F %T')" > "$marker" 2>/dev/null || true if command -v systemd-run >/dev/null 2>&1; then if ! systemd-run --unit="$unit_name" --on-active=2m \ /bin/bash -lc 'if [[ -f /run/naiveproxy-warp-full-pending ]]; then warp-cli disconnect >/dev/null 2>&1 || true; rm -f /run/naiveproxy-warp-full-pending; logger -t naiveproxy "WARP full tunnel rollback: disconnected after missing confirmation"; fi' \ >/dev/null 2>&1; then rm -f "$marker" err "Не удалось запланировать аварийный rollback WARP. Full tunnel не включён." return 1 fi warn "Аварийный rollback WARP включён на 2 минуты. Подтверди доступ после подключения." else rm -f "$marker" err "systemd-run не найден — безопасно включить WARP full tunnel нельзя" return 1 fi } warp_disarm_rollback() { rm -f /run/naiveproxy-warp-full-pending 2>/dev/null || true } warp_confirm_access_or_keep_rollback() { echo warn "Проверка безопасности: если SSH/панель живы, нажми Enter в течение 90 секунд." warn "Если доступ уже пропал, ничего не нажимай — через 2 минуты WARP отключится rollback-сервисом." if read -r -t 90 _; then warp_disarm_rollback ok "Доступ подтверждён, rollback снят" return 0 fi warn "Подтверждения нет. Rollback оставлен активным, WARP будет отключён автоматически." return 1 } warp_wait_proxy_ready() { local port="${1:-${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}}" local i status settings for i in {1..20}; do status=$(warp_cli status 2>/dev/null || true) settings=$(warp_cli settings 2>/dev/null || true) if ss -tlnp 2>/dev/null | grep -Eq "(127\.0\.0\.1|\*):${port}[[:space:]]|:${port}[[:space:]]"; then if echo "$settings" | grep -Eiq "WarpProxy|proxy"; then return 0 fi fi if echo "$status" | grep -Eiq "connected|соедин"; then ss -tlnp 2>/dev/null | grep -Eq "(127\.0\.0\.1|\*):${port}[[:space:]]|:${port}[[:space:]]" && return 0 fi sleep 1 done warn "WARP proxy порт ${port} не подтвердился за 20 секунд" warp_cli status 2>/dev/null || true warp_cli settings 2>/dev/null | sed -n '1,30p' || true return 1 } test_warp_proxy() { local port="${1:-${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}}" local trace direct_trace tmp direct_ip proxy_ip proxy_warp method tmp=$(mktemp /tmp/warp_trace_XXXXXX) trap 'rm -f "${tmp:-}" 2>/dev/null; trap - RETURN' RETURN direct_trace=$(curl -fsSL --connect-timeout 5 --max-time 9 \ https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null || true) direct_ip=$(printf '%s\n' "$direct_trace" | awk -F= '$1=="ip"{print $2; exit}') if curl -fsSL --connect-timeout 5 --max-time 9 -x "http://127.0.0.1:${port}" \ https://www.cloudflare.com/cdn-cgi/trace -o "$tmp" 2>/dev/null; then trace=$(cat "$tmp") method="HTTP" elif curl -fsSL --connect-timeout 5 --max-time 9 --socks5-hostname "127.0.0.1:${port}" \ https://www.cloudflare.com/cdn-cgi/trace -o "$tmp" 2>/dev/null; then trace=$(cat "$tmp") method="SOCKS5" else err "Не удалось пройти через WARP proxy 127.0.0.1:${port}" warn "Проверь: warp-cli status; warp-cli settings; ss -tlnp | grep ${port}" return 1 fi proxy_ip=$(printf '%s\n' "$trace" | awk -F= '$1=="ip"{print $2; exit}') proxy_warp=$(printf '%s\n' "$trace" | awk -F= '$1=="warp"{print $2; exit}') [[ -n "$direct_ip" ]] && echo " direct_ip=${direct_ip}" echo " proxy_method=${method}" echo "$trace" | sed -n 's/^ip=/ ip=/p; s/^colo=/ colo=/p; s/^warp=/ warp=/p; s/^gateway=/ gateway=/p' if [[ "$proxy_warp" == "on" ]]; then ok "WARP proxy mode работает" if [[ -n "$direct_ip" && "$direct_ip" == "$proxy_ip" ]]; then warn "IP direct и proxy совпали. Для некоторых VPS это возможно, но проверь повторно." fi return 0 else warn "Proxy отвечает, но trace не показал warp=on." warn "Без -x/--proxy общий IP VPS не изменится: WARP local proxy работает только для явно направленного трафика." warn "Если нужен выход через WARP для Naive/Xray/Hysteria, включи WARP proxy и дай скрипту пересобрать Caddy/Xray/Hysteria." warn "Для bridge на второй VPS всё равно нужен Xray/sing-box outbound: Caddy/Naive не заменяет полноценный chain-router." return 1 fi } test_warp_full() { local trace direct_ip warp_state colo gateway tmp tmp=$(mktemp /tmp/warp_full_trace_XXXXXX) trap 'rm -f "${tmp:-}" 2>/dev/null; trap - RETURN' RETURN if ! curl -fsSL --connect-timeout 8 --max-time 15 \ https://www.cloudflare.com/cdn-cgi/trace -o "$tmp" 2>/dev/null; then err "Не удалось проверить full-tunnel WARP через прямой curl" warn "Проверь: warp-cli status; warp-cli settings; journalctl -u warp-svc -n 30" return 1 fi trace=$(cat "$tmp") direct_ip=$(printf '%s\n' "$trace" | awk -F= '$1=="ip"{print $2; exit}') warp_state=$(printf '%s\n' "$trace" | awk -F= '$1=="warp"{print $2; exit}') colo=$(printf '%s\n' "$trace" | awk -F= '$1=="colo"{print $2; exit}') gateway=$(printf '%s\n' "$trace" | awk -F= '$1=="gateway"{print $2; exit}') echo " mode=full-tunnel" echo " ip=${direct_ip:-unknown}" echo " colo=${colo:-unknown}" echo " warp=${warp_state:-unknown}" echo " gateway=${gateway:-unknown}" if [[ "$warp_state" == "on" ]]; then ok "Full-tunnel WARP работает: весь исходящий трафик сервера идёт через WARP" return 0 fi warn "Full-tunnel включён не полностью: trace не показал warp=on" return 1 } refresh_services_after_warp_change() { load_config if [[ -f "$CADDYFILE" && -n "${DOMAIN:-}" && -s "$USERS_FILE" ]]; then info "Перегенерирую Caddyfile под текущий WARP mode..." if rewrite_caddyfile_current; then systemctl reload caddy 2>/dev/null || systemctl restart caddy 2>/dev/null || true else warn "Caddyfile не удалось пересобрать после смены WARP" fi fi if [[ "${XRAY_ENABLED:-0}" == "1" && -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then info "Перегенерирую Xray config под текущий WARP mode..." if write_xray_config && systemctl restart xray; then ok "Xray перезапущен" else warn "Xray не удалось пересобрать автоматически. Запусти: sudo bash yurich-panel.sh xray-install" fi fi if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then info "Перегенерирую Hysteria 2 config под текущий WARP mode..." if write_hysteria_config && apply_hysteria_port_hop && systemctl restart hysteria; then ok "Hysteria 2 перезапущена" else warn "Hysteria 2 не удалось пересобрать автоматически. Запусти: sudo bash yurich-panel.sh hysteria-install" fi fi } cmd_warp_install() { load_config hr echo -e "${BOLD} Cloudflare WARP proxy mode${RESET}" hr warn "Это local proxy mode: слушает 127.0.0.1 и не меняет общий маршрут VPS." warn "Внешние порты в UFW не открываются, SSH не должен пропасть." echo echo -ne "${CYAN}Локальный порт WARP proxy [${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}]: ${RESET}" read -r ans_port WARP_PROXY_PORT="${ans_port:-${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}}" if ! is_valid_local_proxy_port "$WARP_PROXY_PORT"; then err "Порт должен быть числом 1024-65535" return 1 fi if ss -tlnp 2>/dev/null | grep -E "127\\.0\\.0\\.1:${WARP_PROXY_PORT}([[:space:]]|$)" >/dev/null; then warn "127.0.0.1:${WARP_PROXY_PORT} уже слушается" echo -ne "${YELLOW}Продолжить всё равно? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return 1 fi install_warp_client || return 1 systemctl enable --now warp-svc >/dev/null 2>&1 || systemctl enable --now cloudflare-warp >/dev/null 2>&1 || true warp_registration_new || return 1 warp_set_proxy_mode || return 1 warp_set_proxy_port "$WARP_PROXY_PORT" || return 1 info "Подключаю WARP..." warp_cli connect >/dev/null 2>&1 || true warp_wait_proxy_ready "$WARP_PROXY_PORT" || true if test_warp_proxy "$WARP_PROXY_PORT"; then WARP_PROXY_ENABLED="1" WARP_MODE="proxy" WARP_PROTOCOL="MASQUE" else WARP_PROXY_ENABLED="0" WARP_MODE="off" save_config return 1 fi save_config refresh_services_after_warp_change cmd_warp_status } cmd_warp_full_install() { load_config hr echo -e "${BOLD} Cloudflare WARP full tunnel${RESET}" hr warn "Full tunnel меняет исходящий маршрут всего VPS через WARP." warn "Входящие порты Caddy/SSH обычно остаются входящими, но на VPS проверяй через консоль провайдера." warn "Если что-то пошло не так: sudo bash yurich-panel.sh warp-disable" echo echo -ne "${CYAN}DNS тоже через WARP DoH? [Y/n]: ${RESET}" read -r dns_ans local full_mode="warp+doh" [[ "${dns_ans,,}" == "n" ]] && full_mode="warp" echo -ne "${CYAN}Протокол [auto/MASQUE/WireGuard] (Enter = auto/RU): ${RESET}" read -r proto_ans proto_ans="${proto_ans:-auto}" case "$proto_ans" in auto|MASQUE|WireGuard) ;; masque) proto_ans="MASQUE" ;; wireguard|wg) proto_ans="WireGuard" ;; *) err "Неверный протокол"; return 1 ;; esac local current_ssh_ip current_ssh_cidr extra_ssh_cidrs normalized_extra current_ssh_ip=$(warp_current_ssh_client_ip) [[ -n "$current_ssh_ip" ]] && current_ssh_cidr=$(warp_route_cidr_for_ip "$current_ssh_ip" || true) echo echo -e "${CYAN}SSH safety:${RESET} текущий IP будет исключён из WARP автоматически: ${current_ssh_cidr:-не определён}" echo -ne "${CYAN}Доп. SSH IP/CIDR через запятую (дом/моб), Enter = пропустить: ${RESET}" read -r extra_ssh_cidrs if [[ -n "$extra_ssh_cidrs" ]]; then normalized_extra=$(normalize_warp_ssh_cidrs "$extra_ssh_cidrs") || return 1 WARP_SSH_ALLOW_CIDRS=$(warp_merge_ssh_allow_cidrs "${WARP_SSH_ALLOW_CIDRS:-}" "$normalized_extra") || return 1 save_config fi install_warp_client || return 1 systemctl enable --now warp-svc >/dev/null 2>&1 || systemctl enable --now cloudflare-warp >/dev/null 2>&1 || true warp_registration_new || return 1 warp_prepare_ssh_safety warp_arm_rollback || return 1 local tried="" proto if [[ "$proto_ans" == "auto" ]]; then for proto in MASQUE WireGuard; do info "Пробую WARP full tunnel: ${full_mode}, protocol=${proto}" warp_set_tunnel_protocol "$proto" || true warp_set_mode "$full_mode" || return 1 warp_cli connect >/dev/null 2>&1 || true warp_wait_connected || true if test_warp_full; then WARP_MODE="$full_mode" WARP_PROTOCOL="$proto" WARP_PROXY_ENABLED="0" tried="ok" break fi warn "protocol=${proto} не подтвердил warp=on" done [[ "$tried" == "ok" ]] || { err "Full-tunnel WARP не заработал ни на MASQUE, ни на WireGuard"; warp_cli disconnect >/dev/null 2>&1 || true; warp_disarm_rollback; return 1; } else warp_set_tunnel_protocol "$proto_ans" || return 1 warp_set_mode "$full_mode" || return 1 warp_cli connect >/dev/null 2>&1 || true warp_wait_connected || true test_warp_full || { warp_cli disconnect >/dev/null 2>&1 || true; warp_disarm_rollback; return 1; } WARP_MODE="$full_mode" WARP_PROTOCOL="$proto_ans" WARP_PROXY_ENABLED="0" fi if ! warp_confirm_access_or_keep_rollback; then WARP_MODE="off" WARP_PROXY_ENABLED="0" save_config refresh_services_after_warp_change return 1 fi save_config refresh_services_after_warp_change } print_warp_proxy_config() { load_config local port="${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" hr echo -e "${BOLD}${GREEN} WARP modes config${RESET}" hr echo -e " ${BOLD}Текущий режим:${RESET} ${WARP_MODE:-off}" echo -e " ${BOLD}Текущий протокол:${RESET} ${WARP_PROTOCOL:-auto}" echo echo -e " ${BOLD}SOCKS5:${RESET} socks5h://127.0.0.1:${port}" echo -e " ${BOLD}HTTP:${RESET} http://127.0.0.1:${port}" echo echo -e " ${YELLOW}Важно:${RESET} WARP local proxy подходит для приложений с SOCKS5/HTTP proxy." echo -e " У Cloudflare Local Proxy есть ограничения для долгих запросов." echo -e " В proxy mode Caddy/Naive добавляет upstream socks5://127.0.0.1:${port}." echo -e " Xray и Hysteria после пересборки используют WARP SOCKS5 outbound." echo -e " Full tunnel: весь исходящий трафик VPS через WARP, проверка без -x." echo echo -e "${CYAN} Проверка:${RESET}" echo -e " curl -x http://127.0.0.1:${port} https://www.cloudflare.com/cdn-cgi/trace" echo -e " curl --socks5-hostname 127.0.0.1:${port} https://www.cloudflare.com/cdn-cgi/trace" echo -e " curl https://www.cloudflare.com/cdn-cgi/trace" echo echo -e "${CYAN} Для временного использования в shell:${RESET}" echo -e " export ALL_PROXY=socks5h://127.0.0.1:${port}" echo -e " export HTTPS_PROXY=http://127.0.0.1:${port}" hr } cmd_warp_status() { load_config local port="${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" hr echo -e "${BOLD} WARP статус${RESET}" hr echo -e " Config mode: ${CYAN}${WARP_MODE:-off}${RESET}" echo -e " Protocol: ${CYAN}${WARP_PROTOCOL:-auto}${RESET}" echo -e " SSH allow: ${CYAN}${WARP_SSH_ALLOW_CIDRS:-нет}${RESET}" if command -v warp-cli &>/dev/null; then ok "warp-cli: $(warp-cli --version 2>/dev/null | head -1 || echo установлен)" warp-cli status 2>/dev/null || true warp-cli settings 2>/dev/null | sed -n '1,20p' || true else warn "cloudflare-warp не установлен" fi if [[ "${WARP_MODE:-off}" == "warp" || "${WARP_MODE:-off}" == "warp+doh" ]]; then info "Full tunnel активен: local proxy порт ${port} может не слушаться, это нормально." else ss -tlnp 2>/dev/null | grep -E "(127\.0\.0\.1|\*):${port}[[:space:]]|:${port}[[:space:]]" || warn "Локальный порт ${port} не слушается" fi hr } cmd_warp_test() { load_config if [[ "${WARP_MODE:-}" == "warp" || "${WARP_MODE:-}" == "warp+doh" ]]; then test_warp_full else test_warp_proxy "${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" fi } cmd_warp_test_full() { load_config test_warp_full } cmd_warp_health() { load_config local failed=0 port="${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" hr echo -e "${BOLD} WARP health${RESET}" hr echo -e " Mode: ${CYAN}${WARP_MODE:-off}${RESET}" echo -e " Protocol: ${CYAN}${WARP_PROTOCOL:-auto}${RESET}" if command -v warp-cli >/dev/null 2>&1; then warp-cli --accept-tos status 2>/dev/null || warp-cli status 2>/dev/null || true else warn "warp-cli не установлен" failed=$((failed + 1)) fi if [[ "${WARP_MODE:-off}" == "warp" || "${WARP_MODE:-off}" == "warp+doh" ]]; then test_warp_full || failed=$((failed + 1)) else ss -tlnp 2>/dev/null | grep -qE "(127\.0\.0\.1|\*):${port}[[:space:]]|:${port}[[:space:]]" || { err "WARP proxy порт ${port} не слушается"; failed=$((failed + 1)); } test_warp_proxy "$port" || failed=$((failed + 1)) fi [[ "$failed" -eq 0 ]] } cmd_warp_protocol() { load_config echo -e "${BOLD} WARP tunnel protocol${RESET}" echo -e " 1) auto" echo -e " 2) MASQUE" echo -e " 3) WireGuard" echo -ne "${CYAN}Выбор [1-3]: ${RESET}" read -r choice case "$choice" in 1|"") WARP_PROTOCOL="auto"; ok "Protocol: auto" ;; 2) warp_set_tunnel_protocol MASQUE || return 1 ;; 3) warp_set_tunnel_protocol WireGuard || return 1 ;; *) warn "Неверный выбор"; return 1 ;; esac save_config } cmd_warp_logs() { echo -e "${BOLD}Лог WARP (Ctrl+C для выхода):${RESET}" journalctl -u warp-svc -n 80 -f 2>/dev/null || journalctl -u cloudflare-warp -n 80 -f } cmd_warp_disable() { load_config warp_disarm_rollback warp_cli disconnect >/dev/null 2>&1 || true WARP_PROXY_ENABLED="0" WARP_MODE="off" save_config refresh_services_after_warp_change ok "WARP отключён. Пакет оставлен установленным." } cmd_warp_remove() { echo -ne "${RED}Удалить cloudflare-warp полностью? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return load_config warp_cli disconnect >/dev/null 2>&1 || true systemctl stop warp-svc cloudflare-warp >/dev/null 2>&1 || true apt-get purge -y -q cloudflare-warp || true rm -f /etc/apt/sources.list.d/cloudflare-client.list WARP_PROXY_ENABLED="0" WARP_PROXY_PORT="$WARP_PROXY_PORT_DEFAULT" WARP_MODE="off" WARP_PROTOCOL="auto" save_config refresh_services_after_warp_change ok "cloudflare-warp удалён" } cmd_warp_menu() { while true; do load_config hr echo -e "${BOLD} Cloudflare WARP modes${RESET}" hr echo -e " ${BOLD}1)${RESET} Включить local proxy mode (127.0.0.1)" echo -e " ${BOLD}2)${RESET} Включить full tunnel для всего VPS" echo -e " ${BOLD}3)${RESET} Выбрать протокол (auto/MASQUE/WireGuard)" echo -e " ${BOLD}4)${RESET} Показать config" echo -e " ${BOLD}5)${RESET} Статус" echo -e " ${BOLD}6)${RESET} Проверить local proxy" echo -e " ${BOLD}7)${RESET} Проверить full tunnel" echo -e " ${BOLD}8)${RESET} Логи" echo -e " ${BOLD}9)${RESET} Отключить WARP" echo -e " ${BOLD}10)${RESET} Удалить WARP" echo -e " ${BOLD}11)${RESET} SSH allowlist для full tunnel" echo -e " ${BOLD}0)${RESET} Назад" echo echo -e " Mode: ${CYAN}${WARP_MODE:-off}${RESET} | Protocol: ${CYAN}${WARP_PROTOCOL:-auto}${RESET} | Proxy: ${CYAN}127.0.0.1:${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}${RESET}" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_warp_install ;; 2) cmd_warp_full_install ;; 3) cmd_warp_protocol ;; 4) print_warp_proxy_config ;; 5) cmd_warp_status ;; 6) test_warp_proxy "${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" ;; 7) cmd_warp_test_full ;; 8) cmd_warp_logs ;; 9) cmd_warp_disable ;; 10) cmd_warp_remove ;; 11) cmd_warp_ssh_allow ;; 0) return ;; *) warn "Неверный выбор" ;; esac echo -ne "${DIM}Enter для продолжения...${RESET}"; read -r _ done } # ─── Ввод параметров ───────────────────────────────────────── prompt_params() { echo echo -e "${BOLD}Настройка Yurich Panel:${RESET}" echo while true; do echo -ne "${CYAN}Домен (например, proxy.example.com): ${RESET}" read -r DOMAIN if is_valid_domain "$DOMAIN"; then break fi err "Неверный формат домена. Только буквы, цифры, дефис, точка." done while true; do echo -ne "${CYAN}Email для TLS (Let's Encrypt): ${RESET}" read -r EMAIL [[ "$EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]] && break err "Введи корректный email" done echo -ne "${CYAN}Имя сервера в подписках (Enter = Yurich, пример: Finland): ${RESET}" read -r PROFILE_LOCATION_LABEL PROFILE_LOCATION_LABEL="${PROFILE_LOCATION_LABEL:-Yurich}" while true; do echo -ne "${CYAN}Логин первого пользователя (Enter = naive): ${RESET}" read -r first_user first_user="${first_user:-naive}" is_valid_proxy_user "$first_user" && break err "Логин: 2-32 символа, только A-Z a-z 0-9 _ -" done while true; do echo -ne "${CYAN}Пароль (Enter = случайный): ${RESET}" read -rs first_pass echo if [[ -z "$first_pass" ]]; then first_pass=$(random_safe_token 20) info "Сгенерирован пароль: $first_pass" fi is_valid_proxy_pass "$first_pass" && break err "Пароль: 8-64 символа, только A-Z a-z 0-9 _ -" done local first_months first_months=$(prompt_user_term_months 12) || return 1 load_users ensure_credential_keypair || return 1 : > "$USERS_FILE" : > "$USERS_SECRETS_FILE" chmod 600 "$USERS_FILE" "$USERS_SECRETS_FILE" credential_set_user "$first_user" "$first_pass" || return 1 set_user_expiry_months "$first_user" "$first_months" || true } # ─── УПРАВЛЕНИЕ ДОМЕНАМИ ───────────────────────────────────── cmd_domains() { load_config while true; do hr echo -e "${BOLD} Управление доменами${RESET}" hr local current_domains="${DOMAINS:-${DOMAIN:-}}" echo -e " ${BOLD}Текущие домены:${RESET}" local i=1 IFS=',' read -ra dlist <<< "$current_domains" for d in "${dlist[@]}"; do d="${d// /}" [[ -n "$d" ]] && echo -e " ${i}. ${CYAN}${d}${RESET}" && i=$((i+1)) done echo echo -e " ${BOLD}1)${RESET} Добавить домен" echo -e " ${BOLD}2)${RESET} Удалить домен" echo -e " ${BOLD}0)${RESET} Назад" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice; echo case "$choice" in 1) echo -ne "${CYAN}Новый домен: ${RESET}" read -r new_dom if [[ ! "$new_dom" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ ]]; then err "Неверный формат домена" continue fi check_domain "$new_dom" if [[ -z "$DOMAINS" ]]; then DOMAINS="${DOMAIN:-}" fi DOMAINS="${DOMAINS},${new_dom}" # Убираем ведущую запятую DOMAINS="${DOMAINS#,}" save_config backup_config write_caddyfile_multi systemctl reload caddy 2>/dev/null || systemctl restart caddy ok "Домен $new_dom добавлен" tg_send "🌐 Добавлен домен: ${new_dom}" ;; 2) # Защита: считаем сколько доменов local _dom_total _dom_total=$(echo "$current_domains" | tr ',' '\n' | grep -c '\S' || echo 0) if [[ ${_dom_total} -le 1 ]]; then err "Нельзя удалить последний домен!" err "Сервер перестанет работать без домена." err "Сначала добавь новый домен (вариант 1), потом удаляй старый." echo -ne "${YELLOW}Enter для продолжения...${RESET}"; read -r continue fi echo -ne "${CYAN}Номер домена для удаления: ${RESET}" read -r del_idx local new_domains="" local j=1 IFS=',' read -ra dlist2 <<< "$current_domains" for d in "${dlist2[@]}"; do d="${d// /}" [[ -z "$d" ]] && continue if [[ "$j" != "$del_idx" ]]; then new_domains="${new_domains},${d}" fi j=$((j+1)) done DOMAINS="${new_domains#,}" DOMAIN=$(echo "$DOMAINS" | cut -d',' -f1) save_config backup_config write_caddyfile_multi systemctl reload caddy 2>/dev/null || systemctl restart caddy ok "Домен удалён" ;; 0) break ;; *) warn "Неверный выбор" ;; esac echo -ne "${YELLOW}Enter для продолжения...${RESET}"; read -r done } # ─── УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ ──────────────────────────────── restore_user_credentials_backup() { local backup_dir="$1" if [[ -d "$backup_dir/credential-state" ]]; then credential_state_restore "$backup_dir/credential-state" else cp -a "$backup_dir/users.conf" "$USERS_FILE" fi if [[ -f "$backup_dir/xray-users.conf" ]]; then cp -a "$backup_dir/xray-users.conf" "$XRAY_USERS_FILE" else rm -f "$XRAY_USERS_FILE" fi if [[ -f "$backup_dir/xray-compat-users.conf" ]]; then cp -a "$backup_dir/xray-compat-users.conf" "$XRAY_COMPAT_USERS_FILE" else rm -f "$XRAY_COMPAT_USERS_FILE" fi } rotate_user_credentials() { local target="$1" new_pass="$2" backup_dir tmp new_uuid changed_xray=0 is_valid_proxy_user "$target" || return 1 is_valid_proxy_pass "$new_pass" || return 1 get_user_hash "$target" >/dev/null 2>&1 || { err "Пользователь $target не найден"; return 1; } if [[ -n "${BROWSER_SUBSCRIPTION_PROFILES:-}" ]] \ && ! browser_subscription_links_for_user "$target" "$new_pass" >/dev/null; then err "Ротация отменена: некорректный BROWSER_SUBSCRIPTION_PROFILES" return 1 fi if ! subscription_vless_relay_links_for_user "$target" "" >/dev/null; then err "Ротация отменена: некорректный VLESS_RELAY_PROFILES" return 1 fi backup_dir=$(mktemp -d /tmp/yurich-rotate-user.XXXXXX) chmod 700 "$backup_dir" credential_state_backup "$backup_dir/credential-state" [[ -f "$XRAY_USERS_FILE" ]] && cp -a "$XRAY_USERS_FILE" "$backup_dir/xray-users.conf" [[ -f "$XRAY_COMPAT_USERS_FILE" ]] && cp -a "$XRAY_COMPAT_USERS_FILE" "$backup_dir/xray-compat-users.conf" if ! credential_set_user "$target" "$new_pass"; then restore_user_credentials_backup "$backup_dir" rm -rf -- "$backup_dir" return 1 fi if [[ -n "$(get_xray_user_uuid "$target" 2>/dev/null || true)" ]]; then if ! new_uuid=$(xray_generate_uuid); then restore_user_credentials_backup "$backup_dir" rm -rf -- "$backup_dir" err "Не удалось создать новый VLESS UUID; изменения отменены" return 1 fi tmp=$(mktemp) awk -F: -v OFS=: -v user="$target" -v uuid="$new_uuid" '$1 == user {$2=uuid} {print}' "$XRAY_USERS_FILE" > "$tmp" install -m 600 "$tmp" "$XRAY_USERS_FILE" rm -f "$tmp" changed_xray=1 fi if [[ -n "$(get_xray_compat_user_uuid "$target" 2>/dev/null || true)" ]]; then if ! new_uuid=$(xray_generate_uuid); then restore_user_credentials_backup "$backup_dir" rm -rf -- "$backup_dir" err "Не удалось создать новый VLESS Compat UUID; изменения отменены" return 1 fi tmp=$(mktemp) awk -F: -v OFS=: -v user="$target" -v uuid="$new_uuid" '$1 == user {$2=uuid} {print}' "$XRAY_COMPAT_USERS_FILE" > "$tmp" install -m 600 "$tmp" "$XRAY_COMPAT_USERS_FILE" rm -f "$tmp" changed_xray=1 fi if ! apply_user_access_runtime; then restore_user_credentials_backup "$backup_dir" apply_user_access_runtime >/dev/null 2>&1 || true rm -rf -- "$backup_dir" err "Ротация не применена; прежние credentials восстановлены" return 1 fi rm -rf -- "$backup_dir" generate_subscription_page "$target" >/dev/null 2>&1 || { err "Credentials обновлены, но страницу подписки не удалось пересобрать"; return 1; } cmd_nodes_rebuild_subscriptions >/dev/null 2>&1 || true if [[ "$(nodes_count 2>/dev/null || echo 0)" -gt 0 ]] && ! cmd_nodes_sync_users all >/dev/null 2>&1; then err "Credentials обновлены локально; синхронизация nodes поставлена в повтор" return 1 fi if [[ "$changed_xray" -eq 1 ]]; then ok "Naive/Hysteria password обновлён, VLESS UUID ротирован" else ok "Naive/Hysteria password обновлён" fi warn "На устройствах нужно обновить подписку, старые профили больше не действуют" } cmd_users_list_machine() { local user state expires days chat_id credential_state subscription_state local stored_hash encrypted_secret token token_file page_dir while IFS= read -r user; do is_valid_proxy_user "$user" || continue state="lifetime" expires=$(get_user_expiry "$user" 2>/dev/null || true) days="" if user_is_blocked "$user"; then state="blocked" elif [[ -n "$expires" ]]; then days=$(days_until_expiry "$expires" 2>/dev/null || true) if [[ "$days" =~ ^-?[0-9]+$ ]]; then if (( days < 0 )); then state="expired" elif (( days <= 5 )); then state="expiring" else state="active" fi else expires="" days="" fi fi chat_id=$(user_meta_get "$user" TELEGRAM_CHAT_ID 2>/dev/null || true) if is_valid_tg_chat_id "$chat_id"; then chat_id="yes" else chat_id="no" fi credential_state="xray-only" if get_user_hash "$user" >/dev/null 2>&1; then stored_hash=$(credential_file_value "$USERS_FILE" "$user" 2>/dev/null || true) encrypted_secret=$(credential_file_value "$USERS_SECRETS_FILE" "$user" 2>/dev/null || true) if is_valid_proxy_hash "$stored_hash" && is_valid_encrypted_proxy_secret "$encrypted_secret"; then credential_state="protected" else credential_state="legacy" fi fi subscription_state="no" token_file="${SUBS_DIR}/${user}.token" token=$(tr -d '\r\n' < "$token_file" 2>/dev/null || true) if [[ "$token" =~ ^[A-Fa-f0-9]{48}$ ]]; then page_dir="${SUBS_WEB_DIR}/${token}" if [[ -s "$page_dir/index.html" ]]; then subscription_state="yes" fi fi printf 'USER|%s|%s|%s|%s|%s|%s|%s\n' \ "$user" "$state" "$expires" "$days" "$chat_id" "$credential_state" "$subscription_state" done < <(list_subscription_users) } cmd_users() { load_users while true; do hr echo -e "${BOLD} Управление пользователями${RESET}" hr echo -e " ${BOLD}1)${RESET} Список пользователей" echo -e " ${BOLD}2)${RESET} Добавить пользователя" echo -e " ${BOLD}3)${RESET} Удалить пользователя" echo -e " ${BOLD}4)${RESET} Ротировать пароль и VLESS UUID" echo -e " ${BOLD}5)${RESET} Показать ссылку пользователя" echo -e " ${BOLD}6)${RESET} Статус защищённого хранения" echo -e " ${BOLD}7)${RESET} Мигрировать legacy-пароли в bcrypt + vault" echo -e " ${BOLD}0)${RESET} Назад" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice; echo case "$choice" in 1) local count=0 while IFS=: read -r u _; do count=$((count+1)) local store_label="${GREEN}bcrypt+vault${RESET}" if ! is_valid_proxy_hash "$(credential_file_value "$USERS_FILE" "$u" 2>/dev/null || true)" \ || ! is_valid_encrypted_proxy_secret "$(credential_file_value "$USERS_SECRETS_FILE" "$u" 2>/dev/null || true)"; then store_label="${YELLOW}legacy/needs migration${RESET}" fi echo -e " ${count}. ${BOLD}${u}${RESET} ${store_label} ${DIM}срок: $(user_expiry_label "$u")${RESET}" done < <(get_users) [[ $count -eq 0 ]] && warn "Нет пользователей" echo -e " Итого: $count" ;; 2) echo -ne "${CYAN}Новый логин: ${RESET}"; read -r new_user [[ -z "$new_user" ]] && { err "Логин не может быть пустым"; continue; } # Валидация: только буквы, цифры, дефис, подчёркивание (защита от sed-инъекции) if ! is_valid_proxy_user "$new_user"; then err "Логин: 2-32 символа, только A-Z a-z 0-9 _ -" continue fi if get_user_hash "$new_user" >/dev/null; then err "Пользователь $new_user уже существует" continue fi echo -ne "${CYAN}Пароль (Enter = случайный): ${RESET}"; read -rs new_pass; echo if [[ -z "$new_pass" ]]; then new_pass=$(random_safe_token 20) info "Сгенерирован пароль: $new_pass" fi if ! is_valid_proxy_pass "$new_pass"; then err "Пароль: 8-64 символа, только A-Z a-z 0-9 _ -" continue fi local new_months new_months=$(prompt_user_term_months 12) || continue local users_backup users_backup=$(mktemp -d /run/yurich-add-user.XXXXXX) credential_state_backup "$users_backup/credential-state" if ! credential_set_user "$new_user" "$new_pass"; then rm -rf "$users_backup" err "Пользователь $new_user не создан" continue fi set_user_expiry_months "$new_user" "$new_months" || true backup_config if ! rewrite_caddyfile_current; then credential_state_restore "$users_backup/credential-state" rm -rf "$users_backup" cleanup_user_metadata "$new_user" err "Caddyfile не собрался, пользователь $new_user отменён" continue fi if ! systemctl reload caddy 2>/dev/null && ! systemctl restart caddy 2>/dev/null; then credential_state_restore "$users_backup/credential-state" rm -rf "$users_backup" cleanup_user_metadata "$new_user" rewrite_caddyfile_current >/dev/null 2>&1 || true err "Caddy не перезагрузился, пользователь $new_user отменён" continue fi rm -rf "$users_backup" ok "Пользователь $new_user добавлен" local hy_added=0 if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then if sync_hysteria_users_if_active; then hy_added=1 fi fi local xray_added=0 if [[ "${XRAY_ENABLED:-0}" == "1" && -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then echo -ne "${CYAN}Создать Xray/VLESS конфиги для ${new_user} тоже? [Y/n]: ${RESET}" read -r add_xray_ans if [[ "${add_xray_ans,,}" != "n" ]]; then if provision_xray_user "$new_user"; then xray_added=1 ok "Xray/VLESS конфиги для ${new_user} готовы" else warn "Naive пользователь создан, но Xray профиль не применён" fi fi fi local sub_url sub_url=$(generate_subscription_page "$new_user" 2>/dev/null || true) if [[ -n "$sub_url" ]]; then ok "Страница подписки создана: $sub_url" echo -e " links.txt: ${sub_url}links.txt" else warn "Страница подписки не создана автоматически. Проверь: sudo bash yurich-panel.sh subscription ${new_user}" fi print_client_config "$new_user" if [[ "$hy_added" -eq 1 ]]; then print_hysteria_client_config "$new_user" fi if [[ "$xray_added" -eq 1 ]]; then print_xray_client_config "$new_user" fi tg_send "👤 Новый пользователь Yurich Panel 🔑 Логин: ${new_user} 🕐 $(date '+%Y-%m-%d %H:%M:%S')" ;; 3) echo -ne "${CYAN}Логин для удаления: ${RESET}"; read -r del_user if ! delete_subscription_user_everywhere "$del_user"; then warn "Удаление $del_user не выполнено" continue fi tg_send "🗑 Пользователь удалён полностью: ${del_user} 🕐 $(date '+%Y-%m-%d %H:%M:%S')" ;; 4) echo -ne "${CYAN}Логин: ${RESET}"; read -r chg_user if ! is_valid_proxy_user "$chg_user" || ! get_user_hash "$chg_user" >/dev/null; then err "Пользователь $chg_user не найден"; continue fi echo -ne "${CYAN}Новый пароль (Enter = случайный): ${RESET}"; read -rs chg_pass; echo if [[ -z "$chg_pass" ]]; then chg_pass=$(random_safe_token 20) info "Сгенерирован пароль: $chg_pass" fi if ! is_valid_proxy_pass "$chg_pass"; then err "Пароль: 8-64 символа, только A-Z a-z 0-9 _ -"; continue fi backup_config rotate_user_credentials "$chg_user" "$chg_pass" || { warn "Ротация $chg_user не завершена"; continue; } ;; 5) echo -ne "${CYAN}Логин: ${RESET}"; read -r show_user print_client_config "$show_user" ;; 6) cmd_credentials_status ;; 7) cmd_credentials_migrate ;; 0) break ;; *) warn "Неверный выбор" ;; esac echo -ne "${YELLOW}Enter для продолжения...${RESET}"; read -r done } # ─── ЛИМИТ УСТРОЙСТВ / АНТИ-ШАРИНГ ──────────────────────────── device_log_files() { local f for f in "${LOG_DIR}/naive.log" "${LOG_DIR}"/naive.log.* "${LOG_DIR}"/naive_*.log "${LOG_DIR}"/naive_*.log.*; do [[ -f "$f" ]] && printf '%s\n' "$f" done } xray_log_files() { local f for f in /var/log/xray/access.log /var/log/xray/access.log.*; do [[ -f "$f" ]] && printf '%s\n' "$f" done } device_usage_report() { local window="${1:-${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}}" local since since_text pair_file since=$(date -d "${window} hours ago" +%s 2>/dev/null || echo 0) since_text=$(date -d "${window} hours ago" '+%Y/%m/%d %H:%M:%S' 2>/dev/null || echo "") [[ "$since" =~ ^[0-9]+$ ]] || since=0 pair_file=$(mktemp) trap 'rm -f "${pair_file:-}" 2>/dev/null; trap - RETURN' RETURN local logs=() mapfile -t logs < <(device_log_files) if [[ "${#logs[@]}" -gt 0 ]]; then awk -v since="$since" ' { ts=0; user=""; ip=""; if ($0 !~ /"user_id":/ || $0 !~ /"remote_ip":/) next; tmp=$0; sub(/^.*"ts":/, "", tmp); sub(/[,}].*$/, "", tmp); ts=tmp+0; if (ts < since) next; tmp=$0; sub(/^.*"user_id":"/, "", tmp); sub(/".*$/, "", tmp); user=tmp; tmp=$0; sub(/^.*"remote_ip":"/, "", tmp); sub(/".*$/, "", tmp); ip=tmp; if (user != "" && ip != "") print user "\t" ip; } ' "${logs[@]}" >> "$pair_file" fi local xlogs=() mapfile -t xlogs < <(xray_log_files) if [[ "${#xlogs[@]}" -gt 0 && -f "$XRAY_USERS_FILE" ]]; then awk -v since_text="$since_text" -v users_file="$XRAY_USERS_FILE" ' BEGIN { while ((getline line < users_file) > 0) { split(line, a, ":"); if (a[1] != "") users[a[1]]=1; } } { if (since_text != "" && substr($0, 1, 19) < since_text) next; user=""; ip=""; for (u in users) { if ($0 ~ ("email[:= ]+" u "([^A-Za-z0-9_-]|$)") || $0 ~ ("\\[" u "\\]") || $0 ~ (" " u "([^A-Za-z0-9_-]|$)")) { user=u; break; } } if (user == "") next; if (match($0, /(tcp|udp):\[[0-9A-Fa-f:]+\]:[0-9]+/)) { ip=substr($0, RSTART, RLENGTH); sub(/^(tcp|udp):\[/, "", ip); sub(/\]:[0-9]+$/, "", ip); } else if (match($0, /(tcp|udp):[0-9][0-9.]*:[0-9]+/)) { ip=substr($0, RSTART, RLENGTH); sub(/^(tcp|udp):/, "", ip); sub(/:[0-9]+$/, "", ip); } else if (match($0, / [0-9][0-9.]*:[0-9]+ accepted/)) { ip=substr($0, RSTART + 1, RLENGTH - 10); sub(/:[0-9]+$/, "", ip); } if (ip != "") print user "\t" ip; } ' "${xlogs[@]}" >> "$pair_file" fi [[ -s "$pair_file" ]] || return 1 awk -F'\t' ' { if ($1 != "" && $2 != "") seen[$1 SUBSEP $2]=1; } END { for (k in seen) { split(k, a, SUBSEP); counts[a[1]]++; ips[a[1]]=ips[a[1]] " " a[2]; } for (u in counts) { out=ips[u]; sub(/^ /, "", out); print u "\t" counts[u] "\t" out; } } ' "$pair_file" | sort -k2,2nr -k1,1 } write_device_cron() { if [[ "${DEVICE_LIMIT_ENABLED:-0}" == "1" ]]; then cat > "$DEVICE_CRON" </dev/null 2>&1 EOF chmod 644 "$DEVICE_CRON" ok "Автопроверка устройств включена: каждые 15 минут" else rm -f "$DEVICE_CRON" ok "Автопроверка устройств отключена" fi } apply_user_access_runtime() { local failed=0 load_config load_users if [[ -x "$CADDY_BIN" && -f "$CADDYFILE" ]]; then safe_apply_caddy_current >/dev/null 2>&1 || failed=1 fi if [[ -x "$HYSTERIA_BIN" || -f "$HYSTERIA_CONFIG" ]]; then sync_hysteria_users_if_active >/dev/null 2>&1 || failed=1 fi if [[ -x "$XRAY_BIN" || -f "$XRAY_CONFIG" ]]; then cmd_xray_rebuild >/dev/null 2>&1 || failed=1 fi [[ "$failed" -eq 0 ]] } device_disable_user() { local target="$1" has_user=0 if ! is_valid_proxy_user "$target"; then err "Некорректный логин" return 1 fi subscription_user_exists "$target" && has_user=1 [[ "$has_user" -eq 1 ]] || { err "Пользователь $target не найден"; return 1; } if user_is_blocked "$target"; then warn "Пользователь $target уже приостановлен" return 0 fi user_meta_set "$target" ACCESS_BLOCKED "1" || return 1 user_meta_set "$target" BLOCKED_REASON "device-limit/admin" || return 1 user_meta_set "$target" BLOCKED_AT "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" || return 1 if ! apply_user_access_runtime; then user_meta_unset "$target" ACCESS_BLOCKED >/dev/null 2>&1 || true user_meta_unset "$target" BLOCKED_REASON >/dev/null 2>&1 || true user_meta_unset "$target" BLOCKED_AT >/dev/null 2>&1 || true apply_user_access_runtime >/dev/null 2>&1 || true err "Runtime не применил блокировку; пользователь $target оставлен активным" return 1 fi if ! suspend_subscription_page "$target"; then err "Доступ заблокирован, но страницу подписки не удалось заменить" return 1 fi cmd_nodes_rebuild_subscriptions >/dev/null 2>&1 || true if [[ "$(nodes_count 2>/dev/null || echo 0)" -gt 0 ]] && ! cmd_nodes_sync_users all >/dev/null 2>&1; then err "Пользователь заблокирован локально; синхронизация nodes поставлена в повтор" return 1 fi ok "Все протоколы и страница подписки $target приостановлены" } device_enable_user() { local target="$1" if ! is_valid_proxy_user "$target"; then err "Некорректный логин" return 1 fi if user_is_blocked "$target"; then user_meta_unset "$target" ACCESS_BLOCKED || return 1 user_meta_unset "$target" BLOCKED_REASON >/dev/null 2>&1 || true user_meta_unset "$target" BLOCKED_AT >/dev/null 2>&1 || true if ! apply_user_access_runtime; then user_meta_set "$target" ACCESS_BLOCKED "1" >/dev/null 2>&1 || true user_meta_set "$target" BLOCKED_REASON "rollback-after-enable-failure" >/dev/null 2>&1 || true apply_user_access_runtime >/dev/null 2>&1 || true suspend_subscription_page "$target" >/dev/null 2>&1 || true err "Runtime не применил разблокировку; пользователь оставлен заблокированным" return 1 fi if user_is_time_expired "$target"; then suspend_subscription_page "$target" >/dev/null 2>&1 || true warn "Блокировка снята, но срок подписки истёк: $(user_expiry_label "$target")" else generate_subscription_page "$target" >/dev/null 2>&1 || { err "Не удалось восстановить страницу подписки"; return 1; } fi cmd_nodes_rebuild_subscriptions >/dev/null 2>&1 || true if [[ "$(nodes_count 2>/dev/null || echo 0)" -gt 0 ]] && ! cmd_nodes_sync_users all >/dev/null 2>&1; then err "Локальная разблокировка выполнена; синхронизация nodes поставлена в повтор" return 1 fi ok "Все протоколы и страница подписки $target разблокированы" return 0 fi local restored=0 if get_user_hash "$target" >/dev/null; then warn "Пользователь $target уже активен" restored=1 fi local line credential tmp credential_backup restored_pass if [[ -f "$DISABLED_USERS_FILE" ]]; then line=$(awk -F: -v user="$target" '$1 == user {print; exit}' "$DISABLED_USERS_FILE") if [[ -n "$line" ]]; then credential="${line#*:}" if is_valid_proxy_hash "$credential" || is_valid_proxy_pass "$credential"; then if is_valid_proxy_hash "$credential" \ && ! is_valid_encrypted_proxy_secret "$(credential_file_value "$USERS_SECRETS_FILE" "$target" 2>/dev/null || true)"; then warn "Encrypted secret отключенного пользователя $target отсутствует; сначала ротируй пароль" return 1 fi credential_backup=$(mktemp -d /run/yurich-enable-user.XXXXXX) || return 1 chmod 700 "$credential_backup" credential_state_backup "$credential_backup/state" || { rm -rf "$credential_backup"; return 1; } if is_valid_proxy_hash "$credential"; then restored_pass=$(get_user_pass "$target" 2>/dev/null || true) if ! verify_proxy_password_hash "$target" "$restored_pass" "$credential" \ || ! credential_set_user "$target" "$restored_pass"; then credential_state_restore "$credential_backup/state" >/dev/null 2>&1 || true rm -rf "$credential_backup" err "Не удалось проверить bcrypt/vault для $target" return 1 fi elif ! credential_set_user "$target" "$credential"; then credential_state_restore "$credential_backup/state" >/dev/null 2>&1 || true rm -rf "$credential_backup" err "Не удалось мигрировать legacy credential $target при включении" return 1 fi tmp=$(mktemp) if ! awk -F: -v user="$target" '$1 != user' "$DISABLED_USERS_FILE" > "$tmp" \ || ! install -m 600 "$tmp" "$DISABLED_USERS_FILE"; then rm -f "$tmp" credential_state_restore "$credential_backup/state" >/dev/null 2>&1 || true rm -rf "$credential_backup" err "Не удалось включить $target; credential store восстановлен" return 1 fi rm -f "$tmp" chmod 600 "$USERS_FILE" "$DISABLED_USERS_FILE" if ! write_active_users_hash_file >/dev/null 2>&1; then credential_state_restore "$credential_backup/state" >/dev/null 2>&1 || true rm -rf "$credential_backup" err "Не удалось обновить bcrypt runtime; credential store восстановлен" return 1 fi rm -rf "$credential_backup" rewrite_caddyfile_current systemctl reload caddy 2>/dev/null || systemctl restart caddy 2>/dev/null || true restored=1 ok "Naive пользователь $target снова активен" else warn "Naive credential отключенного пользователя повреждён" fi fi fi if [[ -f "$XRAY_DISABLED_USERS_FILE" ]]; then line=$(awk -F: -v user="$target" '$1 == user {print; exit}' "$XRAY_DISABLED_USERS_FILE") if [[ -n "$line" ]]; then local uuid xray_tmp uuid=$(printf '%s\n' "$line" | cut -d: -f2 | awk '{print $1}') if is_valid_xray_uuid "$uuid"; then printf '%s:%s\n' "$target" "$uuid" >> "$XRAY_USERS_FILE" xray_tmp=$(mktemp) awk -F: -v user="$target" '$1 != user' "$XRAY_DISABLED_USERS_FILE" > "$xray_tmp" && mv "$xray_tmp" "$XRAY_DISABLED_USERS_FILE" chmod 600 "$XRAY_USERS_FILE" "$XRAY_DISABLED_USERS_FILE" if [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then write_xray_config >/dev/null 2>&1 && systemctl restart xray 2>/dev/null || warn "Xray config не пересобран после возврата $target" fi restored=1 ok "Xray пользователь $target снова активен" else warn "Xray UUID отключенного пользователя повреждён" fi fi fi [[ "$restored" -eq 1 ]] || { err "Пользователь $target не найден в отключенных"; return 1; } apply_user_access_runtime || { err "Legacy credentials восстановлены, но runtime применён не полностью"; return 1; } if user_is_time_expired "$target"; then warn "Пользователь $target восстановлен, но срок истёк: $(user_expiry_label "$target")" else generate_subscription_page "$target" >/dev/null 2>&1 || { err "Не удалось восстановить страницу подписки"; return 1; } fi cmd_nodes_rebuild_subscriptions >/dev/null 2>&1 || true if [[ "$(nodes_count 2>/dev/null || echo 0)" -gt 0 ]] && ! cmd_nodes_sync_users all >/dev/null 2>&1; then err "Legacy user восстановлен локально; синхронизация nodes поставлена в повтор" return 1 fi } cmd_devices_scan() { load_config load_users local limit="${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}" local window="${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}" local mode="${DEVICE_LIMIT_MODE:-alert}" local enabled="${DEVICE_LIMIT_ENABLED:-0}" is_canonical_uint_in_range "$limit" 1 50 || limit="$DEVICE_LIMIT_DEFAULT" is_canonical_uint_in_range "$window" 1 168 || window="$DEVICE_WINDOW_HOURS_DEFAULT" [[ "$mode" == "lock-user" ]] || mode="alert" local report_file report_file=$(mktemp) trap 'rm -f "${report_file:-}" 2>/dev/null; trap - RETURN' RETURN if ! device_usage_report "$window" > "$report_file"; then warn "Логи Yurich Panel не найдены: ${LOG_DIR}/naive.log" return 1 fi hr echo -e "${BOLD} Лимит устройств подписка${RESET}" hr echo -e " Лимит: ${CYAN}${limit}${RESET} уникальных IP за ${CYAN}${window}${RESET} ч" echo -e " Режим: ${CYAN}${mode}${RESET} | Авто: ${CYAN}${enabled}${RESET}" echo printf ' %-24s %-8s %s\n' "Пользователь" "IP" "Адреса" printf ' %-24s %-8s %s\n' "------------" "--" "------" local user count ips exceeded=0 if [[ ! -s "$report_file" ]]; then warn "В окне ${window} ч нет CONNECT-записей с user_id" return 0 fi while IFS=$'\t' read -r user count ips; do printf ' %-24s %-8s %s\n' "$user" "$count" "$ips" if [[ "$count" =~ ^[0-9]+$ ]] && [[ "$count" -gt "$limit" ]]; then exceeded=$((exceeded+1)) warn "Превышение: $user использует $count IP при лимите $limit" tg_send "⚠️ подписка: превышен лимит устройств 👤 Пользователь: ${user} 📱 IP за ${window} ч: ${count} / лимит ${limit} 🔒 Режим: ${mode}" if [[ "$enabled" == "1" && "$mode" == "lock-user" ]]; then device_disable_user "$user" || true fi fi done < "$report_file" echo if [[ "$exceeded" -eq 0 ]]; then ok "Превышений нет" elif [[ "$enabled" != "1" ]]; then warn "Автолимит выключен. Это только отчёт." fi } cmd_devices_config() { load_config hr echo -e "${BOLD} Настройка лимита устройств${RESET}" hr echo -e " Текущий лимит: ${CYAN}${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}${RESET}" echo -e " Окно анализа: ${CYAN}${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}${RESET} ч" echo -e " Режим: ${CYAN}${DEVICE_LIMIT_MODE:-alert}${RESET}" echo local ans echo -ne "${CYAN}Лимит IP на пользователя [${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}]: ${RESET}" read -r ans DEVICE_LIMIT="${ans:-${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}}" if ! is_canonical_uint_in_range "$DEVICE_LIMIT" 1 50; then err "Лимит должен быть числом 1-50" return 1 fi echo -ne "${CYAN}Окно анализа, часов [${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}]: ${RESET}" read -r ans DEVICE_WINDOW_HOURS="${ans:-${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}}" if ! is_canonical_uint_in_range "$DEVICE_WINDOW_HOURS" 1 168; then err "Окно должно быть числом 1-168 часов" return 1 fi echo -e "${CYAN}Режим:${RESET}" echo -e " 1) alert — только предупреждать" echo -e " 2) lock-user — отключать пользователя при превышении" echo -ne "${CYAN}Выбор [1]: ${RESET}" read -r ans case "${ans:-1}" in 1) DEVICE_LIMIT_MODE="alert" ;; 2) DEVICE_LIMIT_MODE="lock-user" ;; *) err "Неверный режим"; return 1 ;; esac DEVICE_LIMIT_ENABLED="1" save_config write_device_cron ok "Лимит устройств включён" } cmd_devices_disable() { load_config DEVICE_LIMIT_ENABLED="0" save_config write_device_cron } cmd_devices_unlock_all() { load_config load_users local users_file restored=0 user tmp tmp=$(mktemp) for users_file in "$DISABLED_USERS_FILE" "$XRAY_DISABLED_USERS_FILE"; do [[ -s "$users_file" ]] || continue awk -F: 'NF && $1 !~ /^#/ {print $1}' "$users_file" >> "$tmp" done if [[ ! -s "$tmp" ]]; then rm -f "$tmp" ok "Отключенных пользователей нет" return 0 fi sort -u "$tmp" | while IFS= read -r user; do [[ -n "$user" ]] || continue device_enable_user "$user" && restored=$((restored + 1)) || true done rm -f "$tmp" sync_hysteria_users_if_active >/dev/null 2>&1 || true cmd_nodes_rebuild_subscriptions >/dev/null 2>&1 || true ok "Восстановление отключенных пользователей завершено" } cmd_devices_menu() { while true; do load_config hr echo -e "${BOLD} Лимит устройств / анти-шаринг${RESET}" hr echo -e " ${BOLD}1)${RESET} Отчёт и проверка сейчас" echo -e " ${BOLD}2)${RESET} Настроить и включить" echo -e " ${BOLD}3)${RESET} Отключить автолимит" echo -e " ${BOLD}4)${RESET} Отключить пользователя вручную" echo -e " ${BOLD}5)${RESET} Вернуть отключенного пользователя" echo -e " ${BOLD}6)${RESET} Список отключенных" echo -e " ${BOLD}0)${RESET} Назад" hr echo -e " Авто: ${CYAN}${DEVICE_LIMIT_ENABLED:-0}${RESET} | Лимит: ${CYAN}${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}${RESET} | Окно: ${CYAN}${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}ч${RESET} | Режим: ${CYAN}${DEVICE_LIMIT_MODE:-alert}${RESET}" echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) cmd_devices_scan ;; 2) cmd_devices_config ;; 3) cmd_devices_disable ;; 4) echo -ne "${CYAN}Логин: ${RESET}"; read -r u device_disable_user "$u" ;; 5) echo -ne "${CYAN}Логин: ${RESET}"; read -r u device_enable_user "$u" ;; 6) local shown=0 if [[ -s "$DISABLED_USERS_FILE" ]]; then echo -e " ${BOLD}Naive:${RESET}" awk -F: '{print " • " $1}' "$DISABLED_USERS_FILE" shown=1 fi if [[ -s "$XRAY_DISABLED_USERS_FILE" ]]; then echo -e " ${BOLD}Xray:${RESET}" awk -F: '{print " • " $1}' "$XRAY_DISABLED_USERS_FILE" shown=1 fi [[ "$shown" -eq 0 ]] && warn "Отключенных пользователей нет" ;; 0) return ;; *) warn "Неверный выбор" ;; esac echo -ne "${DIM}Enter для продолжения...${RESET}"; read -r _ done } # ─── МОНИТОРИНГ ────────────────────────────────────────────── cmd_monitor() { hr echo -e "${BOLD} Мониторинг и статистика${RESET}" hr if systemctl is-active --quiet caddy 2>/dev/null; then echo -e " Caddy: ${GREEN}● работает${RESET}" else echo -e " Caddy: ${RED}● остановлен${RESET}" fi local uptime_str uptime_str=$(systemctl show caddy --property=ActiveEnterTimestamp 2>/dev/null \ | cut -d= -f2 | xargs -I{} date -d "{}" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "н/д") echo -e " Запущен: $uptime_str" echo -e " Caddy: $("$CADDY_BIN" version 2>/dev/null | head -1 | awk '{print $1}' || echo н/д)" echo -e " Юзеров: $(get_users | wc -l)" local iface iface=$(ip route | awk '/default/{print $5}' | head -1) if [[ -n "$iface" ]]; then local rx tx rx=$(cat /sys/class/net/"$iface"/statistics/rx_bytes 2>/dev/null || echo 0) tx=$(cat /sys/class/net/"$iface"/statistics/tx_bytes 2>/dev/null || echo 0) echo echo -e " ${BOLD}Трафик ($iface, с ребута):${RESET}" echo -e " ⬇ Входящий: $(numfmt --to=iec "$rx" 2>/dev/null || echo $rx)" echo -e " TX: $(numfmt --to=iec "$tx" 2>/dev/null || echo $tx)" fi echo echo -e " ${BOLD}Ресурсы:${RESET}" echo -e " RAM: $(free -h | awk '/Mem:/{print $3" / "$2}')" echo -e " Диск: $(df -h / | awk 'NR==2{print $3" / "$2" ("$5")"}')" echo -e " Uptime: $(uptime -p)" if edge_routing_mode_is_haproxy; then echo echo -e " ${BOLD}HAProxy SNI mux:${RESET}" haproxy_stats_text | sed 's/^/ /' || true fi load_config if [[ -n "${DOMAIN:-}" ]]; then echo info "Проверяю доступность https://${DOMAIN}..." local http_code http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "https://${DOMAIN}" 2>/dev/null || echo "000") if [[ "$http_code" =~ ^[23] ]]; then ok "https://${DOMAIN} доступен (HTTP $http_code)" else warn "https://${DOMAIN} — HTTP $http_code" fi check_cert "${DOMAIN:-}" fi echo echo -ne "${YELLOW}Отправить статистику в Telegram? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] && tg_send_stats && ok "Отправлено в Telegram" hr } # ─── УСТАНОВКА ─────────────────────────────────────────────── cmd_install() { hr echo -e "${BOLD} Установка Yurich Panel v${VERSION}${RESET}" hr if check_installed; then warn "Yurich Panel уже установлен." echo -ne "${YELLOW}Переустановить? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return fi # ── Шаг 0: Обновление системы ──────────────────────────── if [[ ! -f "$SYSUPDATE_DONE" ]]; then echo -ne "${YELLOW}Обновить систему перед установкой? [Y/n]: ${RESET}" read -r ans [[ "${ans,,}" != "n" ]] && cmd_sysupdate else info "Система уже обновлялась: $(cat "$SYSUPDATE_DONE")" fi # ── Шаг 1: SSH Hardening ───────────────────────────────── if [[ ! -f "$SSH_HARDENING_DONE" ]]; then warn "SSH Hardening может сменить порт и правила входа. По умолчанию пропускаю, чтобы не потерять SSH." echo -ne "${YELLOW}Выполнить SSH Hardening сейчас? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] && cmd_ssh_hardening else info "SSH уже настроен: $(grep SSH_PORT "$SSH_HARDENING_DONE" | cut -d= -f2)" fi prompt_params prompt_edge_routing_mode || return 1 check_domain "$DOMAIN" install_deps build_caddy write_caddyfile write_service setup_firewall if systemctl is-active --quiet fail2ban 2>/dev/null; then setup_fail2ban "$(current_ssh_port)" || warn "Fail2Ban уже установлен, но автонастройка не прошла" else echo -ne "${YELLOW}Включить Fail2Ban защиту SSH + Caddy auth? [Y/n]: ${RESET}" read -r ans [[ "${ans,,}" != "n" ]] && setup_fail2ban "$(current_ssh_port)" fi enable_bbr echo echo -ne "${YELLOW}Настроить Telegram-уведомления? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] && setup_telegram save_config install_monitor cmd_notify_expiry_install info "Запускаю Caddy..." systemctl restart caddy sleep 3 if systemctl is-active --quiet caddy; then ok "Caddy запущен успешно" tg_alert_up else err "Caddy не запустился. Лог:" journalctl -u caddy -n 30 --no-pager exit 1 fi if edge_routing_mode_is_haproxy; then apply_haproxy_sni_mux_runtime || exit 1 fi print_client_config cmd_health_check } # ─── ПРОВЕРКА СЕРТИФИКАТА ───────────────────────────────────── check_cert() { local domain="${1:-${DOMAIN:-}}" [[ -z "$domain" ]] && return echo echo -e " ${BOLD}TLS Сертификат:${RESET}" local cert_info cert_info=$(echo | timeout 5 openssl s_client -connect "${domain}:443" -servername "$domain" 2>/dev/null | openssl x509 -noout -dates -issuer -subject 2>/dev/null || echo "") if [[ -z "$cert_info" ]]; then warn "Не удалось получить данные сертификата" return fi local not_after issuer not_after=$(echo "$cert_info" | grep "notAfter" | cut -d= -f2) issuer=$(echo "$cert_info" | grep "issuer" | grep -oP "O=\K[^,]+" || echo "н/д") # Считаем дней до истечения local expire_ts now_ts days_left expire_ts=$(date -d "$not_after" +%s 2>/dev/null || echo 0) now_ts=$(date +%s) days_left=$(( (expire_ts - now_ts) / 86400 )) # Цвет в зависимости от срока local days_color if [[ $days_left -gt 30 ]]; then days_color="${GREEN}" elif [[ $days_left -gt 7 ]]; then days_color="${YELLOW}" else days_color="${RED}" fi echo -e " Домен: ${CYAN}${domain}${RESET}" echo -e " Истекает: ${not_after}" echo -e " Осталось: ${days_color}${days_left} дней${RESET}" echo -e " Выдан: ${issuer}" if [[ $days_left -le 7 ]]; then err "СЕРТИФИКАТ ИСТЕКАЕТ МЕНЕЕ ЧЕМ ЧЕРЕЗ 7 ДНЕЙ!" tg_send "⚠️ Сертификат истекает! 🌐 Домен: ${domain} 📅 Осталось: ${days_left} дней 🕐 $(date '+%Y-%m-%d %H:%M:%S') 🔧 Caddy обновит автоматически — проверь что он запущен!" elif [[ $days_left -le 30 ]]; then warn "Сертификат истекает через ${days_left} дней — Caddy обновит автоматически" else ok "Сертификат действителен ещё ${days_left} дней" fi } # ══════════════════════════════════════════════════════════════ # SELF-UPDATE СКРИПТА # ══════════════════════════════════════════════════════════════ cmd_self_update() { hr echo -e "${BOLD} Обновление скрипта Yurich Panel${RESET}" hr info "Текущая версия: ${BOLD}v${VERSION}${RESET}" info "Проверяю последнюю версию на GitHub..." # Источник обновления — raw main, поэтому именно его VERSION является авторитетным. # Release API используется только для диагностики рассинхронизации. local latest_ver api_ver raw_ver api_ver=$(curl -s --max-time 10 "$GITHUB_API" 2>/dev/null | grep '"tag_name"' | grep -oP '"\K[^"]+' | head -1 | tr -d 'v' || echo "") raw_ver=$(curl -s --max-time 10 "$GITHUB_RAW" 2>/dev/null | grep '^VERSION=' | grep -oP '"\K[^"]+' || echo "") latest_ver="$raw_ver" if [[ -z "$latest_ver" ]]; then err "Не удалось получить VERSION из raw main. Обновление остановлено." return 1 fi if [[ -n "$api_ver" && "$api_ver" != "$raw_ver" ]]; then warn "GitHub Release v${api_ver} и raw main v${raw_ver} различаются; устанавливаю проверенный raw main v${raw_ver}" fi info "Последняя версия: ${BOLD}v${latest_ver}${RESET}" if [[ "$latest_ver" == "$VERSION" ]]; then ok "У тебя уже последняя версия v${VERSION}" return 0 fi echo echo -e " ${YELLOW}Доступно обновление: v${VERSION} → v${latest_ver}${RESET}" echo -ne "${CYAN}Обновить? [Y/n]: ${RESET}" read -r ans [[ "${ans,,}" == "n" ]] && return 0 # Скачиваем новую версию во временный файл local tmp_script tmp_sha expected_sha actual_sha downloaded_ver staging_script="" tmp_script=$(mktemp /tmp/naiveproxy_update_XXXXXX.sh) tmp_sha=$(mktemp /tmp/naiveproxy_update_sha_XXXXXX) # Cleanup при любом выходе из функции trap 'rm -f "${tmp_script:-}" "${tmp_sha:-}" "${staging_script:-}" 2>/dev/null; trap - RETURN' RETURN info "Скачиваю v${latest_ver}..." if ! curl -fsSL --max-time 60 "$GITHUB_RAW" -o "$tmp_script" 2>/dev/null; then err "Ошибка загрузки скрипта" rm -f "$tmp_script" return 1 fi info "Проверяю SHA256 релиза..." if curl -fsSL --max-time 20 "$GITHUB_SHA256_RAW" -o "$tmp_sha" 2>/dev/null; then expected_sha=$(awk '{print $1; exit}' "$tmp_sha" | tr -d '\r\n') actual_sha=$(sha256sum "$tmp_script" | awk '{print $1}') if [[ ! "$expected_sha" =~ ^[a-fA-F0-9]{64}$ ]]; then warn "Файл SHA256 найден, но формат некорректный" [[ "${NAIVEPROXY_REQUIRE_SHA:-1}" == "1" ]] && return 1 elif [[ "$actual_sha" != "$expected_sha" ]]; then err "SHA256 не совпадает. Обновление остановлено." echo " expected: $expected_sha" echo " actual: $actual_sha" return 1 else ok "SHA256 релиза подтверждён" fi else warn "SHA256 файл релиза не найден. Для аварийного обхода: NAIVEPROXY_REQUIRE_SHA=0" [[ "${NAIVEPROXY_REQUIRE_SHA:-1}" == "1" ]] && return 1 fi # Проверяем что скачали валидный bash скрипт if ! bash -n "$tmp_script" 2>/dev/null; then err "Скачанный скрипт содержит ошибки синтаксиса! Отменяю обновление." rm -f "$tmp_script" return 1 fi # Проверяем что это действительно наш скрипт; второй маркер нужен только для совместимости старых self-update. if ! grep -Eq "Yurich Panel|NaiveProxy Manager" "$tmp_script" 2>/dev/null; then err "Скачанный файл не является Yurich Panel. Отменяю." rm -f "$tmp_script" return 1 fi downloaded_ver=$(sed -nE 's/^VERSION="([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$tmp_script" | head -1) if [[ -z "$downloaded_ver" || "$downloaded_ver" != "$latest_ver" ]]; then err "VERSION скачанного файла не совпадает с объявленной версией" echo " announced: $latest_ver" echo " downloaded: ${downloaded_ver:-unknown}" return 1 fi # Определяем куда установлен скрипт local current_script current_script=$(realpath "$0" 2>/dev/null || echo "") if [[ -z "$current_script" || "$current_script" == /dev/fd/* || "$current_script" == /proc/* ]]; then current_script="$SCRIPT_PATH" fi [[ -f "$current_script" ]] || { err "Текущий скрипт не найден: $current_script"; return 1; } # Бэкап обязателен, а staging создаётся в том же каталоге для атомарного rename. local backup_path="${current_script}.v${VERSION}.bak" current_dir installed_ver current_dir=$(dirname "$current_script") staging_script=$(mktemp "${current_dir}/.yurich-panel.update.XXXXXX") || { err "Не удалось создать staging-файл рядом с $current_script" return 1 } if ! cp -p -- "$current_script" "$backup_path"; then err "Не удалось создать обязательный бэкап: $backup_path" return 1 fi ok "Бэкап текущей версии: $backup_path" if ! install -m 755 "$tmp_script" "$staging_script" || ! mv -f -- "$staging_script" "$current_script"; then err "Не удалось атомарно установить обновление" install -m 755 "$backup_path" "$current_script" 2>/dev/null || true return 1 fi staging_script="" installed_ver=$(bash "$current_script" version 2>/dev/null | sed -nE 's/^Yurich Panel v([0-9]+\.[0-9]+\.[0-9]+)$/\1/p' | head -1) if [[ "$installed_ver" != "$downloaded_ver" ]]; then err "Проверка установленного скрипта не пройдена. Выполняю rollback." install -m 755 "$backup_path" "$current_script" return 1 fi info "Запускаю безопасные post-update миграции..." if ! bash "$current_script" post-update "$VERSION"; then err "Post-update миграция не пройдена. Возвращаю предыдущую версию скрипта." install -m 755 "$backup_path" "$current_script" return 1 fi # Обновляем основной путь и legacy alias, чтобы старые установки не ломались. if [[ "$current_script" != "$SCRIPT_PATH" ]]; then install -m 755 "$current_script" "$SCRIPT_PATH" 2>/dev/null || warn "Не удалось синхронизировать $SCRIPT_PATH" fi if [[ -n "${LEGACY_SCRIPT_PATH:-}" && "$current_script" != "$LEGACY_SCRIPT_PATH" ]]; then install -m 755 "$current_script" "$LEGACY_SCRIPT_PATH" 2>/dev/null || warn "Не удалось синхронизировать $LEGACY_SCRIPT_PATH" fi ok "Скрипт обновлён: v${VERSION} → v${latest_ver}" tg_send "🔄 Yurich Panel обновлён 📦 Было: v${VERSION} 📦 Стало: v${latest_ver} 🕐 $(date '+%Y-%m-%d %H:%M:%S')" echo info "Перезапускаю обновлённый скрипт..." sleep 1 local restart_script="$current_script" if [[ -n "${LEGACY_SCRIPT_PATH:-}" && "$current_script" == "$LEGACY_SCRIPT_PATH" ]]; then restart_script="$SCRIPT_PATH" fi exec bash "$restart_script" } # ── Тихая проверка обновлений при запуске ───────────────────── check_update_available() { # Запускаем в фоне чтобы не тормозить старт ( local latest_ver latest_ver=$(curl -s --max-time 5 "$GITHUB_RAW" 2>/dev/null | grep '^VERSION=' | grep -oP '"\K[^"]+' || echo "") if [[ -n "$latest_ver" && "$latest_ver" != "$VERSION" ]]; then echo -e "\n ${YELLOW}[UPDATE] Доступно обновление скрипта: v${VERSION} → v${latest_ver}${RESET}" echo -e " ${YELLOW} Меню → 14) Обновить скрипт${RESET}\n" fi ) & } # ─── ДИАГНОСТИКА СИСТЕМЫ ────────────────────────────────────── cmd_diagnose_fix() { load_config 2>/dev/null || true load_users 2>/dev/null || true hr echo -e "${BOLD} [FIX] Автофикс Yurich Panel${RESET}" hr local changed=0 mkdir -p "$CONFIG_DIR" "$CADDY_DIR" "$LOG_DIR" "$WEBROOT" 2>/dev/null || true ensure_web_privacy_files 2>/dev/null || true [[ -f "$CONFIG_FILE" ]] && chown root:root "$CONFIG_FILE" 2>/dev/null || true [[ -f "$CONFIG_FILE" ]] && chmod 600 "$CONFIG_FILE" 2>/dev/null || true [[ -f "$USERS_FILE" ]] && chown root:root "$USERS_FILE" 2>/dev/null || true [[ -f "$USERS_FILE" ]] && chmod 600 "$USERS_FILE" 2>/dev/null || true [[ -f "$XRAY_USERS_FILE" ]] && chmod 600 "$XRAY_USERS_FILE" 2>/dev/null || true [[ -f "$XRAY_COMPAT_USERS_FILE" ]] && chmod 600 "$XRAY_COMPAT_USERS_FILE" 2>/dev/null || true if [[ -n "${DOMAIN:-}" && -f "$USERS_FILE" && "$(active_user_count)" -gt 0 ]]; then info "Перегенерирую Caddyfile по текущему режиму..." if rewrite_caddyfile_current; then changed=1 else warn "Caddyfile не удалось перегенерировать" fi else warn "Caddyfile не генерирую: не задан DOMAIN или нет активных пользователей" fi if [[ -x "$CADDY_BIN" && -f "$CADDYFILE" ]]; then if "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1; then if systemctl is-active caddy >/dev/null 2>&1; then systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy >/dev/null 2>&1 || true else systemctl restart caddy >/dev/null 2>&1 || true fi ok "Caddy validate/start/reload выполнен" else err "Caddyfile всё ещё невалиден: caddy validate --config $CADDYFILE" fi else warn "Caddy binary или Caddyfile отсутствует. Если модуль forward_proxy пропал — запусти: sudo bash yurich-panel.sh update" fi if hysteria_tls_layout_needs_repair; then info "Исправляю TLS sandbox Hysteria 2..." if cmd_hysteria_repair; then changed=1 else warn "Автофикс Hysteria не выполнен; рабочее состояние восстановлено" fi fi if command -v ufw >/dev/null 2>&1; then setup_firewall || true [[ -n "${HYSTERIA_PORT:-}" ]] && ufw allow "${HYSTERIA_PORT}/udp" comment "Hysteria2 QUIC" >/dev/null 2>&1 || true apply_hysteria_port_hop || true if [[ -x "$XRAY_BIN" || -f "$XRAY_CONFIG" ]]; then apply_xray_reality_firewall cleanup_xray_legacy_transports fi ok "UFW правила проверены/обновлены" else apt-get update -qq && apt-get install -y -q ufw && setup_firewall || warn "Не удалось установить/настроить UFW" fi if [[ "${DEVICE_LIMIT_ENABLED:-0}" == "1" ]]; then write_device_cron fi if [[ ! -f "$EXPIRY_NOTIFY_CRON" ]]; then cmd_notify_expiry_install || warn "Не удалось установить ежедневный expiry enforcement" fi if command -v fail2ban-client >/dev/null 2>&1; then systemctl restart fail2ban >/dev/null 2>&1 || true fi if [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then if "$XRAY_BIN" run -test -config "$XRAY_CONFIG" >/dev/null 2>&1; then systemctl restart xray >/dev/null 2>&1 || true ok "Xray config валиден, сервис перезапущен" else warn "Xray config невалиден. Попробуй: sudo bash yurich-panel.sh xray-install" fi fi if [[ "${WARP_PROXY_ENABLED:-0}" == "1" ]] && command -v warp-cli >/dev/null 2>&1; then systemctl enable --now warp-svc >/dev/null 2>&1 || systemctl enable --now cloudflare-warp >/dev/null 2>&1 || true warp_cli connect >/dev/null 2>&1 || true fi systemctl daemon-reload >/dev/null 2>&1 || true ok "Автофикс завершён" [[ "$changed" -eq 1 ]] && info "После автофикса можно проверить: sudo bash yurich-panel.sh diagnose" } cmd_diagnose() { if [[ "${1:-}" == "--fix" || "${1:-}" == "fix" ]]; then cmd_diagnose_fix return fi load_config 2>/dev/null || true local pass=0 warn=0 fail=0 local report="" # Хелперы вывода — используем pass+=1 вместо pass=$((pass+1)) из-за set -e _ok() { echo -e " ${GREEN}[OK] $1${RESET}"; report+="[OK] $1\n"; pass=$((pass+1)); } _warn() { echo -e " ${YELLOW}[WARN] $1${RESET}"; report+="[WARN] $1\n"; warn=$((warn+1)); } _fail() { echo -e " ${RED}[FAIL] $1${RESET}"; report+="[FAIL] $1\n"; fail=$((fail+1)); } _info() { echo -e " ${CYAN}[INFO] $1${RESET}"; } _sep() { echo -e " ${DIM}──────────────────────────────────────${RESET}"; } hr echo -e "${BOLD} [DIAG] Диагностика Yurich Panel v${VERSION}${RESET}" echo -e " $(date '+%Y-%m-%d %H:%M:%S') · $(hostname)" hr echo # ── БЛОК 1: CADDY ───────────────────────────────────────── echo -e " ${BOLD}[1/7] Caddy${RESET}" _sep # Caddy существует if [[ -f "${CADDY_BIN}" ]]; then local caddy_ver caddy_ver=$("${CADDY_BIN}" version 2>/dev/null | head -1 || echo "неизвестно") _ok "Caddy найден: ${caddy_ver}" else _fail "Caddy не найден: ${CADDY_BIN}" fi # Caddy запущен if systemctl is-active caddy &>/dev/null; then local uptime_caddy uptime_caddy=$(systemctl show caddy --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "н/д") _ok "Caddy запущен (с ${uptime_caddy})" else _fail "Caddy НЕ запущен! Запусти: systemctl start caddy" fi # Naive padding в бинарнике if ! command -v strings &>/dev/null; then info "Устанавливаю binutils для проверки padding..." apt-get install -y -q binutils 2>/dev/null || true fi if command -v strings &>/dev/null && [[ -f "${CADDY_BIN}" ]]; then # Проверяем naive padding по нескольким признакам local _pad_count _pad_count=$(strings "${CADDY_BIN}" 2>/dev/null | grep -cE "^(Padding|SetPadding|WithPadding|writePadding|PaddingLength)$" || true) _pad_count="${_pad_count//[^0-9]/}" _pad_count="${_pad_count:-0}" if [[ "${_pad_count}" -ge 2 ]]; then _ok "Naive padding модуль подтверждён (${_pad_count} символов)" else _fail "Naive padding НЕ найден в Caddy — пересобери: sudo bash yurich-panel.sh update" fi else _warn "strings недоступен — установи: apt install binutils" fi # forward_proxy модуль if "${CADDY_BIN}" list-modules 2>/dev/null | grep "forward_proxy" >/dev/null; then _ok "Модуль forward_proxy загружен" else _fail "Модуль forward_proxy НЕ найден" fi echo # ── БЛОК 2: CADDYFILE ───────────────────────────────────── echo -e " ${BOLD}[2/7] Конфигурация${RESET}" _sep if [[ -f "${CADDYFILE}" ]]; then _ok "Caddyfile найден: ${CADDYFILE}" # Naive CONNECT uses the target host in :authority, so the proxy block needs :443 catch-all. if grep -q "^:443," "${CADDYFILE}"; then _ok "Caddyfile: Naive forward proxy catch-all ':443, domain'" elif edge_routing_mode_is_haproxy && grep -q "^:${XRAY_CADDY_FALLBACK_PORT:-$XRAY_CADDY_FALLBACK_PORT_DEFAULT}," "${CADDYFILE}"; then _ok "Caddyfile: HAProxy fallback port ${XRAY_CADDY_FALLBACK_PORT:-$XRAY_CADDY_FALLBACK_PORT_DEFAULT}" elif grep -qE "^http://127\\.0\\.0\\.1:[0-9]+" "${CADDYFILE}"; then _ok "Caddyfile: Xray fallback mode" elif grep -qE "^[[:alnum:].-]+:443[[:space:]]*\\{" "${CADDYFILE}"; then _warn "Caddyfile: 'domain:443' может ломать Naive CONNECT — перегенерируй через safe-apply" else _warn "Caddyfile: не распознал site label" fi local hosts_overrides hosts_domains hosts_overrides=$(hosts_public_domain_overrides | head -5 || true) if [[ -n "$hosts_overrides" ]]; then hosts_domains=$(printf '%s\n' "$hosts_overrides" | format_hosts_override_domains) _warn "/etc/hosts подменяет публичные домены: ${hosts_domains}" else _ok "/etc/hosts не подменяет публичные домены" fi # order forward_proxy if grep -q "order forward_proxy before file_server" "${CADDYFILE}"; then _ok "Caddyfile: order forward_proxy — OK" else _warn "Caddyfile: отсутствует 'order forward_proxy before file_server'" fi # probe_resistance if grep -q "probe_resistance" "${CADDYFILE}"; then _ok "probe_resistance включён" else _warn "probe_resistance отключён — сервер видим для сканеров" fi # Пользователи local user_count=0 [[ -f "${USERS_FILE}" ]] && user_count=$(get_users | wc -l) if [[ ${user_count} -gt 0 ]]; then _ok "Пользователей: ${user_count}" else _fail "Нет пользователей! Добавь: sudo bash yurich-panel.sh users" fi # Caddy validate if "${CADDY_BIN}" validate --config "${CADDYFILE}" &>/dev/null; then _ok "Caddyfile валиден (caddy validate passed)" else _fail "Ошибка в Caddyfile! Проверь: caddy validate --config ${CADDYFILE}" fi else _fail "Caddyfile не найден: ${CADDYFILE}" fi echo # ── БЛОК 3: TLS / СЕТЬ ──────────────────────────────────── echo -e " ${BOLD}[3/7] TLS и сеть${RESET}" _sep local domain="${DOMAIN:-}" if [[ -z "${domain}" ]]; then _warn "Домен не настроен — пропускаю сетевые проверки" else _info "Домен: ${domain}" # DNS → IP local dns_ip server_ip dns_ip=$(dig +short "${domain}" 2>/dev/null | grep -E '^[0-9]+\.' | head -1 || echo "") server_ip=$(curl -s4 --max-time 5 https://ifconfig.me 2>/dev/null || curl -s4 --max-time 5 https://api.ipify.org 2>/dev/null || echo "") if [[ -z "${dns_ip}" ]]; then _fail "DNS не резолвится для ${domain}" elif [[ "${dns_ip}" == "${server_ip}" ]]; then _ok "DNS: ${domain} → ${dns_ip} (совпадает с IP сервера)" else _fail "DNS: ${domain} → ${dns_ip} НЕ совпадает с IP сервера ${server_ip}" fi # Порт 80 if ss -tlnp | grep -E ":80([[:space:]]|$)" >/dev/null; then _ok "Порт 80 слушается (ACME)" else _warn "Порт 80 не слушается — Let's Encrypt может не работать" fi # Порт 443 if ss -tlnp | grep -E ":443([[:space:]]|$)" >/dev/null; then _ok "Порт 443 слушается" else _fail "Порт 443 не слушается!" fi # ALPN h2 local alpn alpn=$(echo | timeout 8 openssl s_client \ -connect "${domain}:443" \ -alpn h2 \ -servername "${domain}" \ 2>/dev/null | grep -a "ALPN protocol" | awk '{print $3}') alpn="${alpn//[^a-z0-9]/}" # убираем лишние символы if [[ "${alpn}" == "h2" ]]; then _ok "ALPN: h2 ✓ (HTTP/2 работает)" else _warn "ALPN: не h2 (получено: '${alpn}') — возможно сервер за NAT или firewall" fi # TLS сертификат local cert_days=0 local cert_info cert_info=$(echo | timeout 8 openssl s_client \ -connect "${domain}:443" \ -servername "${domain}" \ 2>/dev/null | openssl x509 -noout -dates 2>/dev/null || echo "") if [[ -n "${cert_info}" ]]; then local not_after expire_ts now_ts not_after=$(echo "${cert_info}" | grep "notAfter" | cut -d= -f2) expire_ts=$(date -d "${not_after}" +%s 2>/dev/null || echo 0) now_ts=$(date +%s) cert_days=$(( (expire_ts - now_ts) / 86400 )) if [[ ${cert_days} -gt 30 ]]; then _ok "TLS сертификат действителен ещё ${cert_days} дней" elif [[ ${cert_days} -gt 7 ]]; then _warn "TLS сертификат истекает через ${cert_days} дней" else _fail "TLS сертификат истекает через ${cert_days} дней! Срочно!" fi else _fail "Не удалось получить TLS сертификат для ${domain}" fi fi echo # ── БЛОК 4: FIREWALL ────────────────────────────────────── echo -e " ${BOLD}[4/7] Firewall${RESET}" _sep # UFW if command -v ufw &>/dev/null; then if ufw status | grep -q "Status: active"; then _ok "UFW активен" # Проверяем нужные порты for port in "80/tcp" "443/tcp" "443/udp"; do if ufw status | grep -q "${port}"; then _ok "UFW: порт ${port} открыт" else _warn "UFW: порт ${port} НЕ открыт" fi done else _fail "UFW неактивен! Включи: ufw enable" fi else _warn "UFW не установлен" fi # Fail2Ban if systemctl is-active fail2ban &>/dev/null; then local banned_count banned_count=$(fail2ban-client status sshd 2>/dev/null | grep "Currently banned" | awk '{print $NF}' || echo "0") _ok "Fail2Ban активен (сейчас забанено SSH: ${banned_count})" else _warn "Fail2Ban не запущен — SSH не защищён от брутфорса" fi # DNS / Unbound if command -v unbound &>/dev/null; then if systemctl is-active --quiet unbound 2>/dev/null; then _ok "DNS (Unbound): $(unbound_mode_label), gateway=${UNBOUND_GATEWAY_IP:-127.0.0.1}, vpn=${UNBOUND_VPN_ENABLED:-0}" else _warn "DNS (Unbound) установлен, но сервис не активен" fi if [[ -f "${DNS_CONF:-/etc/unbound/unbound.conf.d/yurich-dns.conf}" ]] && unbound-checkconf "${DNS_CONF:-/etc/unbound/unbound.conf.d/yurich-dns.conf}" >/dev/null 2>&1; then _ok "Unbound config валиден" else _warn "Unbound config не найден или содержит ошибку" fi if command -v dig >/dev/null 2>&1 && dig "@127.0.0.1" google.com +short +time=2 +tries=1 2>/dev/null | grep -Eq '^[0-9a-fA-F:.]+$'; then _ok "DNS (Unbound) отвечает на 127.0.0.1:53" else _warn "DNS (Unbound) test не прошёл" fi else _info "DNS (Unbound) не установлен (меню → 17)" fi # WARP modes — proxy/full-tunnel if command -v warp-cli &>/dev/null; then local warp_port warp_trace warp_mode warp_port="${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" warp_mode="${WARP_MODE:-off}" if [[ "$warp_mode" == "warp" || "$warp_mode" == "warp+doh" ]]; then warp_trace=$(curl -fsSL --connect-timeout 5 --max-time 12 \ https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null || true) if echo "$warp_trace" | grep -q '^warp=on'; then _ok "WARP full tunnel: warp=on (${warp_mode}, ${WARP_PROTOCOL:-auto})" elif [[ -n "$warp_trace" ]]; then _warn "WARP full tunnel не подтвердил warp=on" else _warn "WARP full tunnel test не прошёл" fi else if ss -tlnp 2>/dev/null | grep -Eq "(127\.0\.0\.1|\*):${warp_port}[[:space:]]|:${warp_port}[[:space:]]"; then _ok "WARP proxy слушает локально: 127.0.0.1:${warp_port}" else _warn "WARP установлен, но локальный proxy порт ${warp_port} не слушается" fi warp_trace=$(curl -fsSL --connect-timeout 5 --max-time 9 -x "http://127.0.0.1:${warp_port}" \ https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null || \ curl -fsSL --connect-timeout 5 --max-time 9 --socks5-hostname "127.0.0.1:${warp_port}" \ https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null || true) if echo "$warp_trace" | grep -q '^warp=on'; then _ok "WARP proxy test: warp=on" elif [[ -n "$warp_trace" ]]; then _warn "WARP proxy отвечает, но trace не показал warp=on — общий IP VPS в proxy mode не меняется" else _warn "WARP proxy test не прошёл" fi fi else _info "WARP не установлен (опционально: меню → 21)" fi if [[ -x "$XRAY_BIN" || -f "$XRAY_CONFIG" ]]; then if systemctl is-active xray &>/dev/null; then _ok "Xray: сервис активен" else _warn "Xray установлен, но сервис не активен" fi if [[ -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]] && "$XRAY_BIN" run -test -config "$XRAY_CONFIG" >/dev/null 2>&1; then _ok "Xray config валиден" else _warn "Xray config не проверен или содержит ошибку" fi if [[ "${XRAY_FALLBACK_ENABLED:-0}" == "1" ]]; then if ss -tlnp 2>/dev/null | grep -E ":443([[:space:]]|$)" >/dev/null; then _ok "Xray fallback hub: порт 443 слушается" else _fail "Xray fallback hub включён, но порт 443 не слушается" fi fi else _info "Xray Modern не установлен (опционально: меню → 23)" fi echo # ── БЛОК 5: РЕСУРСЫ ─────────────────────────────────────── echo -e " ${BOLD}[5/7] Ресурсы системы${RESET}" _sep # RAM local ram_used ram_total ram_pct ram_used=$(free -m | awk '/Mem:/{print $3}') ram_total=$(free -m | awk '/Mem:/{print $2}') ram_pct=$(( ram_used * 100 / ram_total )) if [[ ${ram_pct} -lt 80 ]]; then _ok "RAM: ${ram_used}/${ram_total} MB (${ram_pct}%)" elif [[ ${ram_pct} -lt 95 ]]; then _warn "RAM: ${ram_used}/${ram_total} MB (${ram_pct}%) — высокое потребление" else _fail "RAM: ${ram_used}/${ram_total} MB (${ram_pct}%) — критически мало!" fi # Диск local disk_used disk_total disk_pct disk_pct=$(df / | awk 'NR==2{print $5}' | tr -d '%') disk_used=$(df -h / | awk 'NR==2{print $3}') disk_total=$(df -h / | awk 'NR==2{print $2}') if [[ ${disk_pct} -lt 80 ]]; then _ok "Диск: ${disk_used}/${disk_total} (${disk_pct}%)" elif [[ ${disk_pct} -lt 95 ]]; then _warn "Диск: ${disk_used}/${disk_total} (${disk_pct}%) — мало места" else _fail "Диск: ${disk_used}/${disk_total} (${disk_pct}%) — критически мало!" fi # Load average local load load=$(uptime | awk -F'load average:' '{print $2}' | awk '{print $1}' | tr -d ',') local cpus cpus=$(nproc) local load_pct load_pct=$(echo "${load} ${cpus}" | awk '{printf "%d", ($1/$2)*100}') if [[ ${load_pct} -lt 80 ]]; then _ok "Нагрузка CPU: ${load} (${load_pct}% от ${cpus} ядер)" else _warn "Нагрузка CPU: ${load} (${load_pct}%) — высокая" fi echo # ── БЛОК 6: ЛОГИ ────────────────────────────────────────── echo -e " ${BOLD}[6/7] Анализ логов${RESET}" _sep local log_errors=0 if [[ -f "${LOG_DIR}/access.log" ]]; then # Считаем ошибки за последние 100 строк log_errors=$(tail -100 "${LOG_DIR}/access.log" 2>/dev/null \ | python3 -c " import sys,json errs=0 for line in sys.stdin: try: d=json.loads(line) if d.get('status',200) >= 500: errs+=1 except: pass print(errs) " 2>/dev/null || true) log_errors="${log_errors//[^0-9]/}" log_errors="${log_errors:-0}" local connect_count connect_count=$(tail -100 "${LOG_DIR}/access.log" 2>/dev/null \ | grep -c '"CONNECT"' 2>/dev/null || true) connect_count="${connect_count//[^0-9]/}" connect_count="${connect_count:-0}" if [[ ${log_errors} -eq 0 ]]; then _ok "Логи: нет серверных ошибок (последние 100 запросов)" else _warn "Логи: ${log_errors} ошибок в последних 100 запросах" fi _info "CONNECT туннелей в последних 100 записях: ${connect_count}" if [[ "${DEVICE_LIMIT_ENABLED:-0}" == "1" ]]; then local dev_report dev_over dev_report=$(mktemp) if device_usage_report "${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}" > "$dev_report"; then dev_over=$(awk -F'\t' -v limit="${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}" '$2+0 > limit {c++} END{print c+0}' "$dev_report") if [[ "$dev_over" -eq 0 ]]; then _ok "Лимит устройств: превышений нет" else _warn "Лимит устройств: пользователей с превышением: ${dev_over}" fi else _warn "Лимит устройств включён, но naive.log не найден" fi rm -f "$dev_report" else _info "Лимит устройств выключен (опционально: меню → 22)" fi else _warn "Лог файл не найден: ${LOG_DIR}/access.log" fi # Ошибки Caddy в journald local caddy_errors caddy_errors=$(journalctl -u caddy -n 50 --no-pager 2>/dev/null \ | grep -ci "error\|panic\|fatal" 2>/dev/null || true) caddy_errors="${caddy_errors//[^0-9]/}" # оставляем только цифры caddy_errors="${caddy_errors:-0}" if [[ "${caddy_errors}" -eq 0 ]]; then _ok "journald: нет критических ошибок Caddy" else _warn "journald: найдено ${caddy_errors} строк с ошибками — проверь: journalctl -u caddy -n 50" fi echo # ── БЛОК 7: ВЕРСИЯ И ОБНОВЛЕНИЯ ─────────────────────────── echo -e " ${BOLD}[7/7] Версия и обновления${RESET}" _sep _info "Текущая версия скрипта: v${VERSION}" local latest_ver latest_ver=$(curl -s --max-time 8 "${GITHUB_RAW}" 2>/dev/null | grep '^VERSION=' | grep -oP '"\K[^"]+' || echo "") if [[ -n "${latest_ver}" ]]; then if [[ "${latest_ver}" == "${VERSION}" ]]; then _ok "Скрипт актуален: v${VERSION}" else _warn "Доступно обновление: v${VERSION} → v${latest_ver} (меню → 14)" fi else _warn "Не удалось проверить обновления" fi # SSH Hardening выполнен? if [[ -f "${SSH_HARDENING_DONE}" ]]; then local ssh_port_saved ssh_port_saved=$(grep SSH_PORT "${SSH_HARDENING_DONE}" 2>/dev/null | cut -d= -f2 || echo "н/д") _ok "SSH Hardening выполнен (порт: ${ssh_port_saved})" else _warn "SSH Hardening не выполнен — рекомендуется: меню → 12" fi # ── ИТОГ ────────────────────────────────────────────────── echo hr echo -e " ${BOLD}[SUMMARY] ИТОГ ДИАГНОСТИКИ${RESET}" hr echo -e " ${GREEN}[OK] Пройдено: ${pass}${RESET}" echo -e " ${YELLOW}[WARN] Внимание: ${warn}${RESET}" echo -e " ${RED}[FAIL] Проблемы: ${fail}${RESET}" echo if [[ ${fail} -eq 0 && ${warn} -eq 0 ]]; then echo -e " ${GREEN}${BOLD}[OK] Всё работает отлично!${RESET}" elif [[ ${fail} -eq 0 ]]; then echo -e " ${YELLOW}${BOLD}[WARN] Есть предупреждения — рекомендуется проверить${RESET}" else echo -e " ${RED}${BOLD}[FAIL] Найдены проблемы — требуется вмешательство${RESET}" fi hr # Отправляем отчёт в Telegram если настроен echo -ne "\n${YELLOW}Отправить отчёт в Telegram? [y/N]: ${RESET}" read -r ans if [[ "${ans,,}" == "y" ]]; then tg_send "🔍 Диагностика Yurich Panel 🖥 Сервер: $(hostname) 🕐 $(date '+%Y-%m-%d %H:%M:%S') ${report} ✅ Пройдено: ${pass} ⚠️ Внимание: ${warn} ❌ Проблемы: ${fail}" ok "Отчёт отправлен в Telegram" fi } # ══════════════════════════════════════════════════════════════ # TELEGRAM BOT — ИНТЕРАКТИВНОЕ УПРАВЛЕНИЕ # ══════════════════════════════════════════════════════════════ # Проверка что пользователь является администратором tg_is_admin() { local from_id="$1" # Валидация: from_id должен быть числом [[ ! "${from_id}" =~ ^[0-9]+$ ]] && return 1 # Основной admin [[ -n "${TG_CHAT_ID:-}" && "${from_id}" == "${TG_CHAT_ID}" ]] && return 0 # Дополнительные admins if [[ -n "${TG_ADMINS:-}" ]]; then local IFS=',' for admin_id in ${TG_ADMINS}; do admin_id="${admin_id// /}" [[ "${from_id}" == "${admin_id}" ]] && return 0 done fi return 1 } tg_admin_ids() { local admin_id if [[ -n "${TG_CHAT_ID:-}" && "${TG_CHAT_ID}" =~ ^[0-9]+$ ]]; then printf '%s\n' "$TG_CHAT_ID" fi if [[ -n "${TG_ADMINS:-}" ]]; then local IFS=',' for admin_id in ${TG_ADMINS}; do admin_id="${admin_id// /}" [[ "$admin_id" =~ ^[0-9]+$ ]] && printf '%s\n' "$admin_id" done fi } # Отправка сообщения конкретному chat_id tg_reply() { local chat_id="$1" local message="$2" [[ -z "${TG_TOKEN:-}" ]] && return tg_api "sendMessage" -s --max-time 10 -X POST --data-urlencode "chat_id=${chat_id}" --data-urlencode "parse_mode=HTML" --data-urlencode "text=${message}" >/dev/null 2>&1 || true } tg_user_commands_json() { cat <<'EOF' [ {"command":"start","description":"Открыть пользовательское меню"}, {"command":"help","description":"Как подключить уведомления"}, {"command":"myid","description":"Показать мой Telegram ID"}, {"command":"apps","description":"Скачать приложения"}, {"command":"support","description":"Связаться с поддержкой"} ] EOF } tg_bot_commands_json() { cat <<'EOF' [ {"command":"start","description":"Открыть меню"}, {"command":"help","description":"Помощь и список команд"}, {"command":"menu","description":"Показать кнопки управления"}, {"command":"status","description":"Статус сервера"}, {"command":"stats","description":"Статистика ресурсов и трафика"}, {"command":"haproxy","description":"HAProxy SNI mux и маршруты"}, {"command":"diagnose","description":"Диагностика системы"}, {"command":"logs","description":"Последние логи Caddy"}, {"command":"cert","description":"Статус TLS сертификата"}, {"command":"users","description":"Список пользователей"}, {"command":"adduser","description":"Добавить пользователя"}, {"command":"deluser","description":"Удалить пользователя"}, {"command":"qr","description":"QR и ссылка пользователя"}, {"command":"sub","description":"Страница подписки"}, {"command":"subreset","description":"Перевыпустить подписку"}, {"command":"expiring","description":"Сроки подписок и Telegram ID"}, {"command":"bindtg","description":"Привязать Telegram ID к пользователю"}, {"command":"notifyrun","description":"Запустить проверку уведомлений"}, {"command":"testnotify","description":"Тест уведомления пользователю"}, {"command":"news_test","description":"Тест новости себе"}, {"command":"news","description":"Разослать новость пользователям"}, {"command":"devices","description":"Лимит устройств"}, {"command":"lockuser","description":"Отключить пользователя"}, {"command":"unlockuser","description":"Включить пользователя"}, {"command":"xray","description":"Xray ссылки пользователя"}, {"command":"xrayadduser","description":"Создать Xray пользователя"}, {"command":"xraystatus","description":"Статус Xray"}, {"command":"hysteria","description":"Статус Hysteria 2"}, {"command":"warp","description":"Статус WARP"}, {"command":"restart","description":"Перезапустить Caddy"}, {"command":"update","description":"Обновить Caddy"}, {"command":"selfupdate","description":"Обновить скрипт"}, {"command":"diagfix","description":"Автофикс проблем"}, {"command":"privatepage","description":"Личная фейковая страница"}, {"command":"admins","description":"Список администраторов"}, {"command":"addadmin","description":"Добавить администратора"}, {"command":"deladmin","description":"Удалить администратора"}, {"command":"donate","description":"Поддержать проект"} ] EOF } tg_apply_bot_menu() { [[ -z "${TG_TOKEN:-}" ]] && { err "Telegram не настроен"; return 1; } local user_commands admin_commands menu_button resp_cmd resp_menu admin_id admin_scope admin_resp failed=0 user_commands=$(tg_user_commands_json | tr -d '\n') admin_commands=$(tg_bot_commands_json | tr -d '\n') menu_button='{"type":"commands"}' resp_cmd=$(tg_api "setMyCommands" -s --max-time 15 \ -X POST \ --data-urlencode "commands=${user_commands}" \ 2>/dev/null || echo "{}") if ! echo "$resp_cmd" | grep -q '"ok":true'; then err "Не удалось установить пользовательское Telegram commands menu" echo "$resp_cmd" return 1 fi while IFS= read -r admin_id; do [[ -z "$admin_id" ]] && continue admin_scope=$(printf '{"type":"chat","chat_id":%s}' "$admin_id") admin_resp=$(tg_api "setMyCommands" -s --max-time 15 \ -X POST \ --data-urlencode "commands=${admin_commands}" \ --data-urlencode "scope=${admin_scope}" \ 2>/dev/null || echo "{}") if ! echo "$admin_resp" | grep -q '"ok":true'; then warn "Не удалось установить админское меню для Telegram ID ${admin_id}" failed=$((failed + 1)) fi done < <(tg_admin_ids | sort -u) resp_menu=$(tg_api "setChatMenuButton" -s --max-time 15 \ -X POST \ --data-urlencode "menu_button=${menu_button}" \ 2>/dev/null || echo "{}") if echo "$resp_menu" | grep -q '"ok":true'; then ok "Telegram Menu button включён" else warn "Команды установлены, но Menu button не подтвердился" echo "$resp_menu" fi [[ "$failed" -eq 0 ]] } tg_apply_bot_menu_silent() { tg_apply_bot_menu >/dev/null 2>&1 || true } tg_main_menu_markup() { cat <<'EOF' {"keyboard":[[{"text":"📊 Статус"},{"text":"👥 Пользователи"}],[{"text":"➕ Добавить пользователя"},{"text":"🗑 Удалить пользователя"}],[{"text":"📱 QR / ссылка"},{"text":"🔗 Подписка"}],[{"text":"📰 Новости"},{"text":"🔔 Уведомления"}],[{"text":"🧬 Xray"},{"text":"⚡ Hysteria"},{"text":"🌀 WARP"}],[{"text":"🔀 HAProxy"},{"text":"🔍 Диагностика"}],[{"text":"📄 Логи"},{"text":"♻️ Restart Caddy"}],[{"text":"🛠 Автофикс"},{"text":"💛 Донат"}],[{"text":"❓ Помощь"}]],"resize_keyboard":true,"one_time_keyboard":false,"is_persistent":true} EOF } tg_user_menu_markup() { cat <<'EOF' {"keyboard":[[{"text":"🆔 Мой Telegram ID"},{"text":"🔔 Уведомления"}],[{"text":"📱 Приложения"},{"text":"💬 Поддержка"}]],"resize_keyboard":true,"one_time_keyboard":false,"is_persistent":true} EOF } tg_reply_menu() { local chat_id="$1" local message="$2" local menu_kind="${3:-admin}" local reply_markup [[ -z "${TG_TOKEN:-}" ]] && return if [[ "$menu_kind" == "user" ]]; then reply_markup=$(tg_user_menu_markup) else reply_markup=$(tg_main_menu_markup) fi tg_api "sendMessage" -s --max-time 10 \ -X POST \ --data-urlencode "chat_id=${chat_id}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "text=${message}" \ --data-urlencode "reply_markup=${reply_markup}" \ >/dev/null 2>&1 || true } tg_user_welcome_message() { local from_id="$1" safe_id safe_android safe_windows safe_support safe_id=$(html_escape_text "$from_id") safe_android=$(html_escape_text "$ANDROID_APP_RELEASES_URL") safe_windows=$(html_escape_text "$WINDOWS_APP_RELEASES_URL") safe_support=$(html_escape_text "$TELEGRAM_COMMUNITY_URL") cat <Yurich Connect Это пользовательское меню уведомлений. 🆔 Твой Telegram ID: ${safe_id} Отправь этот ID Ивану Юрьевичу вместе с именем профиля, чтобы получать уведомления об окончании подписки, важные новости и сервисные сообщения. 📱 Android: ${safe_android} 🖥 Windows: ${safe_windows} 💬 Поддержка: ${safe_support} EOF } # ─── SALES BOT: продажи и автовыдача подписок ──────────────── sales_tg_api() { [[ -n "${SALES_BOT_TOKEN:-}" ]] || return 1 tg_api_with_token "$SALES_BOT_TOKEN" "$@" } sales_reply() { local chat_id="$1" message="$2" [[ -n "${SALES_BOT_TOKEN:-}" ]] || return 0 sales_tg_api "sendMessage" -s --max-time 12 \ -X POST \ --data-urlencode "chat_id=${chat_id}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "disable_web_page_preview=true" \ --data-urlencode "text=${message}" \ >/dev/null 2>&1 || true } sales_menu_markup() { cat <<'EOF' {"keyboard":[[{"text":"💎 Купить VPN"},{"text":"🟢 Моя подписка"}],[{"text":"📲 Приложения"},{"text":"🚀 Как подключить"}],[{"text":"📣 Канал"},{"text":"🛟 Поддержка"}]],"resize_keyboard":true,"one_time_keyboard":false,"is_persistent":true} EOF } sales_admin_menu_markup() { cat <<'EOF' {"keyboard":[[{"text":"🧾 Заявки"},{"text":"📊 Статус продаж"}],[{"text":"💎 Купить VPN"},{"text":"🟢 Моя подписка"}],[{"text":"📲 Приложения"},{"text":"📣 Канал"}],[{"text":"🛟 Поддержка"}]],"resize_keyboard":true,"one_time_keyboard":false,"is_persistent":true} EOF } sales_reply_menu() { local chat_id="$1" message="$2" kind="${3:-user}" reply_markup [[ "$kind" == "admin" ]] && reply_markup=$(sales_admin_menu_markup) || reply_markup=$(sales_menu_markup) sales_tg_api "sendMessage" -s --max-time 12 \ -X POST \ --data-urlencode "chat_id=${chat_id}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "disable_web_page_preview=true" \ --data-urlencode "text=${message}" \ --data-urlencode "reply_markup=${reply_markup}" \ >/dev/null 2>&1 || true } sales_welcome_text() { cat <<'EOF' 🤖 Yurich Connect VPN Быстрые VPN-подписки для телефона и компьютера. ✨ Что внутри • стабильные профили HTTPS, Turbo и Reality; • личная страница подписки; • удобный импорт в Yurich Connect, Hiddify, NekoBox, v2rayNG и Streisand; • уведомления о сроке подписки в Telegram. Выбери тариф ниже, оплати по QR и отправь скрин платежа сюда в бот. EOF } sales_send_welcome_visual() { local chat_id="$1" kind="${2:-user}" caption reply_markup animation_path image_path response caption=$(sales_welcome_text) [[ "$kind" == "admin" ]] && reply_markup=$(sales_admin_menu_markup) || reply_markup=$(sales_menu_markup) animation_path="${SALES_BOT_WELCOME_ANIMATION_PATH:-$SALES_BOT_WELCOME_ANIMATION_PATH_DEFAULT}" image_path="${SALES_BOT_WELCOME_IMAGE_PATH:-$SALES_BOT_WELCOME_IMAGE_PATH_DEFAULT}" if [[ -s "$animation_path" ]]; then response=$(sales_tg_api "sendAnimation" -s --max-time 45 \ -X POST \ --form-string "chat_id=${chat_id}" \ --form-string "parse_mode=HTML" \ --form-string "caption=${caption}" \ --form-string "reply_markup=${reply_markup}" \ -F "animation=@${animation_path};type=image/gif" \ 2>/dev/null || echo "{}") echo "$response" | grep -q '"ok":true' && return 0 fi if [[ -s "$image_path" ]]; then response=$(sales_tg_api "sendPhoto" -s --max-time 30 \ -X POST \ --form-string "chat_id=${chat_id}" \ --form-string "parse_mode=HTML" \ --form-string "caption=${caption}" \ --form-string "reply_markup=${reply_markup}" \ -F "photo=@${image_path};type=image/jpeg" \ 2>/dev/null || echo "{}") echo "$response" | grep -q '"ok":true' && return 0 fi return 1 } sales_reply_start() { local chat_id="$1" kind="${2:-user}" if ! sales_send_welcome_visual "$chat_id" "$kind"; then sales_reply_menu "$chat_id" "$(sales_welcome_text)" "$kind" fi sales_reply_buy_menu "$chat_id" "$(sales_plans_text)" } sales_bot_commands_json() { cat <<'EOF' [ {"command":"start","description":"Открыть меню"}, {"command":"plans","description":"Посмотреть тарифы"}, {"command":"status","description":"Моя подписка"}, {"command":"apps","description":"Скачать приложения"}, {"command":"channel","description":"Канал Yurich Connect"}, {"command":"support","description":"Поддержка"} ] EOF } sales_bot_admin_commands_json() { cat <<'EOF' [ {"command":"start","description":"Открыть меню"}, {"command":"sales_orders","description":"Новые заявки"}, {"command":"approve","description":"Подтвердить заявку"}, {"command":"reject","description":"Отклонить заявку"}, {"command":"sales_status","description":"Статус продаж"}, {"command":"plans","description":"Тарифы"}, {"command":"status","description":"Моя подписка"}, {"command":"apps","description":"Скачать приложения"}, {"command":"channel","description":"Канал Yurich Connect"}, {"command":"support","description":"Поддержка"} ] EOF } sales_admin_ids() { local admin_id if [[ -n "${SALES_BOT_ADMIN_ID:-}" && "${SALES_BOT_ADMIN_ID}" =~ ^[0-9]+$ ]]; then printf '%s\n' "$SALES_BOT_ADMIN_ID" fi if [[ -n "${SALES_BOT_ADMINS:-}" ]]; then local IFS=',' for admin_id in ${SALES_BOT_ADMINS}; do admin_id="${admin_id// /}" [[ "$admin_id" =~ ^[0-9]+$ ]] && printf '%s\n' "$admin_id" done fi } sales_is_admin() { local from_id="$1" admin_id while IFS= read -r admin_id; do [[ -n "$admin_id" && "$from_id" == "$admin_id" ]] && return 0 done < <(sales_admin_ids | sort -u) return 1 } sales_apply_bot_menu() { [[ -n "${SALES_BOT_TOKEN:-}" ]] || { err "Sales bot token не настроен"; return 1; } local user_commands admin_commands menu_button resp admin_id admin_scope admin_resp failed=0 user_commands=$(sales_bot_commands_json | tr -d '\n') admin_commands=$(sales_bot_admin_commands_json | tr -d '\n') menu_button='{"type":"commands"}' sales_tg_api "setMyName" -s --max-time 15 \ -X POST \ --data-urlencode "name=Yurich Connect VPN" \ >/dev/null 2>&1 || true sales_tg_api "setMyShortDescription" -s --max-time 15 \ -X POST \ --data-urlencode "short_description=VPN-подписки от 50 руб: оплата по QR, выдача после проверки." \ >/dev/null 2>&1 || true sales_tg_api "setMyDescription" -s --max-time 15 \ -X POST \ --data-urlencode "description=Yurich Connect VPN: HTTPS, Turbo и Reality-профили для телефона и компьютера. Выберите тариф на 1 день, 1, 3, 6 или 12 месяцев, оплатите по QR и отправьте скрин платежа в бот. После проверки бот пришлёт страницу подписки. Канал: ${SALES_BOT_CHANNEL_URL:-$SALES_BOT_CHANNEL_URL_DEFAULT}" \ >/dev/null 2>&1 || true resp=$(sales_tg_api "setMyCommands" -s --max-time 15 \ -X POST \ --data-urlencode "commands=${user_commands}" \ 2>/dev/null || echo "{}") echo "$resp" | grep -q '"ok":true' || { err "Не удалось установить команды sales bot"; echo "$resp"; return 1; } while IFS= read -r admin_id; do [[ -z "$admin_id" ]] && continue admin_scope=$(printf '{"type":"chat","chat_id":%s}' "$admin_id") admin_resp=$(sales_tg_api "setMyCommands" -s --max-time 15 \ -X POST \ --data-urlencode "commands=${admin_commands}" \ --data-urlencode "scope=${admin_scope}" \ 2>/dev/null || echo "{}") echo "$admin_resp" | grep -q '"ok":true' || failed=$((failed + 1)) done < <(sales_admin_ids | sort -u) sales_tg_api "setChatMenuButton" -s --max-time 15 \ -X POST \ --data-urlencode "menu_button=${menu_button}" \ >/dev/null 2>&1 || true [[ "$failed" -eq 0 ]] } sales_plan_price() { local term item key p normalized plans normalized=$(normalize_user_term "${1:-}" 2>/dev/null || true) [[ -n "$normalized" ]] || return 1 plans="${SALES_BOT_PLANS:-$SALES_BOT_PLANS_DEFAULT}" is_valid_sales_plans_value "$plans" || plans="$SALES_BOT_PLANS_DEFAULT" IFS=',' read -ra _sales_plan_items <<< "$plans" for item in "${_sales_plan_items[@]}"; do key="${item%%:*}" p="${item#*:}" [[ "$key" =~ ^[0-9]+$ ]] && key="${key}m" if [[ "$key" == "$normalized" ]] && is_canonical_uint_in_range "$p" 1 1000000000; then printf '%s\n' "$p" return 0 fi done return 1 } sales_plan_regular_price() { local term term=$(normalize_user_term "${1:-}" 2>/dev/null || true) case "$term" in 1d) printf '50\n' ;; *m) printf '%s\n' $(( ${term%m} * 250 )) ;; *) return 1 ;; esac } sales_plans_text() { local item key term p regular discount pct per_month currency channel plan_lines="" icon badge plans currency="${SALES_BOT_CURRENCY:-$SALES_BOT_CURRENCY_DEFAULT}" channel="${SALES_BOT_CHANNEL_URL:-$SALES_BOT_CHANNEL_URL_DEFAULT}" plans="${SALES_BOT_PLANS:-$SALES_BOT_PLANS_DEFAULT}" is_valid_sales_plans_value "$plans" || plans="$SALES_BOT_PLANS_DEFAULT" IFS=',' read -ra _sales_plan_items <<< "$plans" for item in "${_sales_plan_items[@]}"; do key="${item%%:*}" p="${item#*:}" [[ "$key" =~ ^[0-9]+$ ]] && key="${key}m" term=$(normalize_user_term "$key" 2>/dev/null || true) if [[ -z "$term" ]] || ! is_canonical_uint_in_range "$p" 1 1000000000; then continue fi case "$term" in 1d) icon="⚡"; badge="тест на день" ;; 1m) icon="🚀"; badge="стартовый" ;; 3m) icon="⭐"; badge="выгоднее" ;; 6m) icon="🔥"; badge="популярный" ;; 12m) icon="👑"; badge="максимальная выгода" ;; *) icon="💎"; badge="тариф" ;; esac regular=$(sales_plan_regular_price "$term" 2>/dev/null || echo "$p") if [[ "$p" -lt "$regular" ]]; then discount=$((regular - p)) pct=$(((discount * 100 + regular / 2) / regular)) if [[ "$term" == *m ]]; then per_month=$((p / ${term%m})) plan_lines="${plan_lines}${icon} $(user_term_label "$term") — ${p} ${currency} ${badge}: ~${per_month} руб/мес, экономия ${discount} руб / ${pct}% " else plan_lines="${plan_lines}${icon} $(user_term_label "$term") — ${p} ${currency} ${badge}: экономия ${discount} руб / ${pct}% " fi else plan_lines="${plan_lines}${icon} $(user_term_label "$term") — ${p} ${currency} ${badge} " fi done cat <Тарифы Yurich Connect VPN ${plan_lines} 💡 База: 250 руб / месяц. Чем больше срок, тем ниже цена за месяц. ✅ Включено • телефон и компьютер; • личная страница подписки; • быстрый импорт в приложения; • уведомления о сроке подписки. 🛡 Профили • HTTPS = NaiveProxy; • Turbo = Hysteria2; • Reality = VLESS Reality на 443. 📣 Канал Yurich Connect ${channel} 👇 Чтобы оформить заявку, нажми нужный тариф или отправь: /buy day, /buy 1, /buy 3, /buy 6, /buy 12 EOF } sales_buy_menu_markup() { local pd p1 p3 p6 p12 currency pd=$(sales_plan_price 1d 2>/dev/null || printf '50') p1=$(sales_plan_price 1m 2>/dev/null || printf '250') p3=$(sales_plan_price 3m 2>/dev/null || printf '700') p6=$(sales_plan_price 6m 2>/dev/null || printf '1300') p12=$(sales_plan_price 12m 2>/dev/null || printf '2400') currency="${SALES_BOT_CURRENCY:-$SALES_BOT_CURRENCY_DEFAULT}" cat </dev/null 2>&1 || true } sales_months_from_text() { local text="${1,,}" if [[ "$text" =~ ^/buy[[:space:]]+(day|день|1d) ]]; then printf '1d\n' return 0 fi if [[ "$text" =~ ^/buy[[:space:]]+([0-9]{1,2}) ]]; then printf '%sm\n' "${BASH_REMATCH[1]}" return 0 fi if [[ "$text" == *"день"* || "$text" == *"50"* ]]; then printf '1d\n' return 0 fi if [[ "$text" == *"250"* ]]; then printf '1m\n' return 0 fi if [[ "$text" == *"700"* ]]; then printf '3m\n' return 0 fi if [[ "$text" == *"1300"* ]]; then printf '6m\n' return 0 fi if [[ "$text" == *"2400"* ]]; then printf '12m\n' return 0 fi if [[ "$text" =~ (^|[[:space:]])(1|3|6|12)[[:space:]]*(мес|месяц|месяца|месяцев)? ]]; then printf '%sm\n' "${BASH_REMATCH[2]}" return 0 fi return 1 } sales_order_valid_id() { [[ "${1:-}" =~ ^[0-9]{14}-[0-9]{5,20}-[0-9]{1,5}$ ]] } sales_user_for_chat() { local chat_id="$1" chat_id="${chat_id#-}" printf 'tg%s\n' "$chat_id" } sales_notify_admins() { local message="$1" admin_id while IFS= read -r admin_id; do [[ -n "$admin_id" ]] && sales_reply "$admin_id" "$message" done < <(sales_admin_ids | sort -u) } sales_safe_chat_key() { local chat_id="${1:-}" [[ "$chat_id" =~ ^-?[0-9]{1,20}$ ]] || return 1 if [[ "$chat_id" == -* ]]; then printf 'm%s\n' "${chat_id#-}" else printf '%s\n' "$chat_id" fi } sales_captcha_ttl() { local ttl="${SALES_BOT_CAPTCHA_TTL_SECONDS:-$SALES_BOT_CAPTCHA_TTL_SECONDS_DEFAULT}" is_canonical_uint_in_range "$ttl" 300 2592000 || ttl="$SALES_BOT_CAPTCHA_TTL_SECONDS_DEFAULT" printf '%s\n' "$ttl" } sales_captcha_file() { local key key=$(sales_safe_chat_key "$1") || return 1 printf '%s/%s.env\n' "$SALES_BOT_CAPTCHA_DIR" "$key" } sales_verified_file() { local key key=$(sales_safe_chat_key "$1") || return 1 printf '%s/%s.ok\n' "$SALES_BOT_VERIFIED_DIR" "$key" } sales_captcha_is_verified() { local chat_id="$1" file expires now file=$(sales_verified_file "$chat_id") || return 1 [[ -f "$file" ]] || return 1 expires=$(head -n 1 "$file" 2>/dev/null | tr -cd '0-9') is_canonical_uint_in_range "$expires" 0 9999999999 || { rm -f "$file"; return 1; } now=$(date +%s) if [[ "$expires" -gt "$now" ]]; then return 0 fi rm -f "$file" return 1 } sales_captcha_has_pending() { local file file=$(sales_captcha_file "$1") || return 1 [[ -f "$file" ]] } sales_captcha_generate() { local chat_id="$1" file tmp now a b answer file=$(sales_captcha_file "$chat_id") || return 1 mkdir -p "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" chmod 700 "$SALES_BOT_DIR" "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" 2>/dev/null || true now=$(date +%s) a=$((RANDOM % 8 + 2)) b=$((RANDOM % 8 + 2)) answer=$((a + b)) tmp="${file}.tmp" { printf 'ANSWER=%q\n' "$answer" printf 'CREATED_AT=%q\n' "$now" printf 'TRIES=%q\n' "0" } > "$tmp" install -m 600 "$tmp" "$file" rm -f "$tmp" printf '%s + %s\n' "$a" "$b" } sales_captcha_prompt() { local chat_id="$1" question question=$(sales_captcha_generate "$chat_id") || { sales_reply "$chat_id" "Не удалось создать проверку безопасности. Напиши в поддержку." return 1 } sales_reply "$chat_id" "Проверка безопасности Чтобы защитить бот от спама, ответь одним числом: ${question} После правильного ответа открою тарифы и оплату." } sales_captcha_check() { local chat_id="$1" text="$2" file now pending_ttl user_answer tries tmp ttl verified_file expires file=$(sales_captcha_file "$chat_id") || return 1 if [[ ! -f "$file" ]]; then sales_captcha_prompt "$chat_id" return 0 fi unset ANSWER CREATED_AT TRIES # shellcheck source=/dev/null source "$file" 2>/dev/null || true now=$(date +%s) pending_ttl=900 if ! is_canonical_uint_in_range "${ANSWER:-}" 0 999 \ || ! is_canonical_uint_in_range "${CREATED_AT:-}" 0 9999999999 \ || ! is_canonical_uint_in_range "${TRIES:-0}" 0 3 \ || (( now - CREATED_AT > pending_ttl )); then sales_captcha_prompt "$chat_id" return 0 fi user_answer=$(printf '%s' "$text" | tr -d '[:space:]') if [[ ! "$user_answer" =~ ^[0-9]{1,3}$ ]]; then sales_reply "$chat_id" "Напиши ответ на проверку только цифрами." return 0 fi if [[ "$user_answer" == "$ANSWER" ]]; then ttl=$(sales_captcha_ttl) expires=$((now + ttl)) verified_file=$(sales_verified_file "$chat_id") || return 1 mkdir -p "$SALES_BOT_VERIFIED_DIR" tmp="${verified_file}.tmp" printf '%s\n' "$expires" > "$tmp" install -m 600 "$tmp" "$verified_file" rm -f "$tmp" "$file" sales_reply_buy_menu "$chat_id" "Проверка пройдена Теперь можно выбрать тариф и оплатить подписку." return 0 fi tries=$(( ${TRIES:-0} + 1 )) if [[ "$tries" -ge 3 ]]; then sales_captcha_prompt "$chat_id" sales_reply "$chat_id" "Слишком много неверных ответов. Я создал новый пример." return 0 fi tmp="${file}.tmp" { printf 'ANSWER=%q\n' "$ANSWER" printf 'CREATED_AT=%q\n' "$CREATED_AT" printf 'TRIES=%q\n' "$tries" } > "$tmp" install -m 600 "$tmp" "$file" rm -f "$tmp" sales_reply "$chat_id" "Ответ неверный. Попробуй ещё раз." } sales_send_payment_qr() { local chat_id="$1" caption="$2" qr_path="${SALES_BOT_PAYMENT_QR_PATH:-$SALES_BOT_PAYMENT_QR_PATH_DEFAULT}" response safe_path mime_type safe_path=$(html_escape_text "$qr_path") [[ -n "${SALES_BOT_TOKEN:-}" ]] || return 1 if [[ ! -s "$qr_path" ]]; then warn "Sales bot QR не найден или пустой: ${qr_path}" sales_reply "$chat_id" "QR-код для оплаты временно не найден на сервере. Реквизиты выше актуальны, после оплаты отправь скрин сюда в бот." sales_notify_admins "Ошибка QR оплаты Файл QR не найден или пустой: ${safe_path}" return 1 fi case "${qr_path,,}" in *.png) mime_type="image/png" ;; *.jpg|*.jpeg) mime_type="image/jpeg" ;; *) mime_type="application/octet-stream" ;; esac response=$(sales_tg_api "sendPhoto" -s --max-time 30 \ -X POST \ --form-string "chat_id=${chat_id}" \ --form-string "parse_mode=HTML" \ --form-string "caption=${caption}" \ -F "photo=@${qr_path};type=${mime_type}" \ 2>/dev/null || echo "{}") if echo "$response" | grep -q '"ok":true'; then return 0 fi warn "Telegram sendPhoto для QR вернул ошибку" sales_reply "$chat_id" "QR-код для оплаты не отправился автоматически. Реквизиты выше актуальны, после оплаты отправь скрин сюда в бот." sales_notify_admins "Ошибка отправки QR оплаты Telegram не принял QR-файл: ${safe_path} Проверь файл и логи: journalctl -u yurich-sales-bot -n 80 --no-pager" return 1 } sales_latest_pending_order_for_chat() { local chat_id="$1" file order_id status best_file="" best_order="" mkdir -p "$SALES_BOT_ORDERS_DIR" shopt -s nullglob for file in "$SALES_BOT_ORDERS_DIR"/*.env; do unset ORDER_ID STATUS CHAT_ID # shellcheck source=/dev/null source "$file" 2>/dev/null || continue [[ "${CHAT_ID:-}" == "$chat_id" ]] || continue [[ "${STATUS:-}" == "pending" ]] || continue order_id="${ORDER_ID:-$(basename "$file" .env)}" if [[ -z "$best_order" || "$order_id" > "$best_order" ]]; then best_order="$order_id" best_file="$file" fi done shopt -u nullglob [[ -n "$best_file" ]] || return 1 printf '%s\n' "$best_file" } sales_handle_payment_proof() { local chat_id="$1" from_id="$2" username="$3" first_name="$4" message_id="$5" proof_kind="$6" order_file order_id safe_user safe_name admin_id if ! order_file=$(sales_latest_pending_order_for_chat "$chat_id" 2>/dev/null); then sales_reply "$chat_id" "Скрин оплаты получил, но активной заявки не нашёл. Сначала выбери тариф через /plans." return 0 fi # shellcheck source=/dev/null source "$order_file" order_id="${ORDER_ID:-$(basename "$order_file" .env)}" safe_user=$(html_escape_text "${username:-без username}") safe_name=$(html_escape_text "${first_name:-без имени}") { grep -vE '^(PROOF_SENT_AT|PROOF_MESSAGE_ID|PROOF_KIND)=' "$order_file" 2>/dev/null || true printf 'PROOF_SENT_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'PROOF_MESSAGE_ID=%q\n' "$message_id" printf 'PROOF_KIND=%q\n' "$proof_kind" } > "${order_file}.tmp" install -m 600 "${order_file}.tmp" "$order_file" rm -f "${order_file}.tmp" while IFS= read -r admin_id; do [[ -z "$admin_id" ]] && continue sales_reply "$admin_id" "Скрин оплаты по заявке ORDER: ${order_id} Клиент: ${chat_id} Username: ${safe_user} Имя: ${safe_name} Тип файла: ${proof_kind} Тариф: $(user_term_label "${TERM:-${MONTHS:-?}}") Сумма: ${PRICE:-?} ${CURRENCY:-RUB} Подтвердить: /approve ${order_id} Отклонить: /reject ${order_id}" sales_tg_api "forwardMessage" -s --max-time 20 \ -X POST \ --data-urlencode "chat_id=${admin_id}" \ --data-urlencode "from_chat_id=${chat_id}" \ --data-urlencode "message_id=${message_id}" \ >/dev/null 2>&1 || true done < <(sales_admin_ids | sort -u) sales_reply "$chat_id" "Скрин оплаты получил и отправил администратору. После проверки тебе придёт ссылка на подписку." } sales_create_order() { local chat_id="$1" from_id="$2" username="$3" first_name="$4" term="$5" price order_id order_file created safe_user safe_name payment_text term_label local order_lock_fd file file_values file_chat file_status file_epoch now pending_count=0 total_count=0 newest_epoch=0 local min_interval max_pending max_files term=$(normalize_user_term "$term" 2>/dev/null || true) price=$(sales_plan_price "$term") || { sales_reply "$chat_id" "Этот тариф не найден. Открой /plans."; return 1; } term_label=$(user_term_label "$term") mkdir -p "$SALES_BOT_ORDERS_DIR" "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" chmod 700 "$SALES_BOT_DIR" "$SALES_BOT_ORDERS_DIR" "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" 2>/dev/null || true command -v flock >/dev/null 2>&1 || { sales_reply "$chat_id" "Сервис заявок временно недоступен. Напиши в поддержку."; return 1; } exec {order_lock_fd}>"${SALES_BOT_ORDERS_DIR}/.orders.lock" if ! flock -w 5 "$order_lock_fd"; then sales_reply "$chat_id" "Сервис обрабатывает другую заявку. Повтори через минуту." exec {order_lock_fd}>&- return 1 fi min_interval="${SALES_BOT_ORDER_MIN_INTERVAL_SECONDS:-$SALES_BOT_ORDER_MIN_INTERVAL_SECONDS_DEFAULT}" max_pending="${SALES_BOT_MAX_PENDING_PER_CHAT:-$SALES_BOT_MAX_PENDING_PER_CHAT_DEFAULT}" max_files="${SALES_BOT_MAX_ORDER_FILES:-$SALES_BOT_MAX_ORDER_FILES_DEFAULT}" is_canonical_uint_in_range "$min_interval" 10 3600 || min_interval="$SALES_BOT_ORDER_MIN_INTERVAL_SECONDS_DEFAULT" is_canonical_uint_in_range "$max_pending" 1 10 || max_pending="$SALES_BOT_MAX_PENDING_PER_CHAT_DEFAULT" is_canonical_uint_in_range "$max_files" 100 50000 || max_files="$SALES_BOT_MAX_ORDER_FILES_DEFAULT" shopt -s nullglob for file in "$SALES_BOT_ORDERS_DIR"/*.env; do total_count=$((total_count + 1)) file_values=$( ( unset CHAT_ID STATUS # Order files are root-owned 0600 files generated below with printf %q. # shellcheck source=/dev/null source "$file" 2>/dev/null || exit 0 printf '%s|%s\n' "${CHAT_ID:-}" "${STATUS:-}" ) ) IFS='|' read -r file_chat file_status <<< "$file_values" if [[ "$file_chat" == "$chat_id" ]]; then [[ "$file_status" == "pending" || "$file_status" == "processing" ]] && pending_count=$((pending_count + 1)) file_epoch=$(stat -c '%Y' "$file" 2>/dev/null || echo 0) [[ "$file_epoch" =~ ^[0-9]+$ && "$file_epoch" -gt "$newest_epoch" ]] && newest_epoch="$file_epoch" fi done shopt -u nullglob now=$(date +%s) if (( total_count >= max_files )); then flock -u "$order_lock_fd"; exec {order_lock_fd}>&- sales_reply "$chat_id" "Новые заявки временно приостановлены. Администратор уже получил уведомление." sales_notify_admins "Лимит файлов заявок достигнут: ${total_count}/${max_files}. Нужна ручная архивация." return 1 fi if (( pending_count >= max_pending )); then flock -u "$order_lock_fd"; exec {order_lock_fd}>&- sales_reply "$chat_id" "У тебя уже есть ${pending_count} необработанные заявки. Сначала дождись решения по ним." return 1 fi if (( newest_epoch > 0 && now - newest_epoch < min_interval )); then flock -u "$order_lock_fd"; exec {order_lock_fd}>&- sales_reply "$chat_id" "Заявка уже создавалась недавно. Подожди немного и повтори." return 1 fi created=$(date -u '+%Y-%m-%dT%H:%M:%SZ') order_id="$(date -u '+%Y%m%d%H%M%S')-${chat_id#-}-${RANDOM}" order_file="${SALES_BOT_ORDERS_DIR}/${order_id}.env" { printf 'ORDER_ID=%q\n' "$order_id" printf 'STATUS=%q\n' "pending" printf 'CHAT_ID=%q\n' "$chat_id" printf 'FROM_ID=%q\n' "$from_id" printf 'USERNAME=%q\n' "$username" printf 'FIRST_NAME=%q\n' "$first_name" printf 'TERM=%q\n' "$term" if [[ "$term" == *m ]]; then printf 'MONTHS=%q\n' "${term%m}" else printf 'MONTHS=%q\n' "" fi printf 'PRICE=%q\n' "$price" printf 'CURRENCY=%q\n' "${SALES_BOT_CURRENCY:-RUB}" printf 'CREATED_AT=%q\n' "$created" } > "$order_file" chmod 600 "$order_file" flock -u "$order_lock_fd" exec {order_lock_fd}>&- payment_text=$(html_escape_text "${SALES_BOT_PAYMENT_TEXT:-Оплата пока подтверждается вручную.}") sales_reply "$chat_id" "Заявка создана Номер: ${order_id} Тариф: Yurich Connect на ${term_label} Сумма: ${price} ${SALES_BOT_CURRENCY:-RUB} ${payment_text} После оплаты отправь скрин платежа сюда в бот. Я перешлю его администратору, и после проверки бот пришлёт ссылку на подписку." sales_send_payment_qr "$chat_id" "QR-код для оплаты Заявка: ${order_id} Сумма: ${price} ${SALES_BOT_CURRENCY:-RUB} После оплаты отправь скрин платежа сюда в бот." safe_user=$(html_escape_text "${username:-без username}") safe_name=$(html_escape_text "${first_name:-без имени}") sales_notify_admins "Новая заявка VPN ORDER: ${order_id} Клиент: ${chat_id} Username: ${safe_user} Имя: ${safe_name} Тариф: ${term_label} Сумма: ${price} ${SALES_BOT_CURRENCY:-RUB} Подтвердить: /approve ${order_id} Отклонить: /reject ${order_id}" } sales_subscription_status_text() { local chat_id="$1" user sub_url expires channel user=$(sales_user_for_chat "$chat_id") if ! subscription_user_exists "$user"; then cat </dev/null || true) channel="${SALES_BOT_CHANNEL_URL:-$SALES_BOT_CHANNEL_URL_DEFAULT}" cat <Твоя подписка Yurich Connect Профиль: ${user} Срок: ${expires} Страница подписки: ${sub_url:-не удалось создать ссылку} Канал: ${channel} EOF } sales_restore_issue_state() { local user="$1" users_backup="$2" meta_backup="$3" meta_existed="$4" meta_file meta_file=$(user_meta_file "$user") [[ -d "$users_backup/credential-state" ]] && credential_state_restore "$users_backup/credential-state" if [[ "$meta_existed" == "1" && -f "$meta_backup" ]]; then cp -a "$meta_backup" "$meta_file" else rm -f "$meta_file" fi rewrite_caddyfile_current >/dev/null 2>&1 || true systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy >/dev/null 2>&1 || true } sales_issue_subscription() { local chat_id="$1" term="$2" order_id="${3:-manual}" user pass users_backup meta_backup meta_file meta_existed=0 created=0 sub_url sync_note="" last_order user=$(sales_user_for_chat "$chat_id") term=$(normalize_user_term "$term" 2>/dev/null || true) if ! is_valid_user_term "$term"; then err "Некорректный срок sales-подписки" return 1 fi if ! is_valid_proxy_user "$user"; then err "Некорректный auto-user: $user" return 1 fi load_config load_users mkdir -p "$(dirname "$USERS_FILE")" touch "$USERS_FILE" chmod 600 "$USERS_FILE" last_order=$(user_meta_get "$user" LAST_SALES_ORDER_ID 2>/dev/null || true) if [[ "$order_id" != "manual" && "$last_order" == "$order_id" ]]; then apply_user_access_runtime || return 1 sub_url=$(generate_subscription_page "$user" 2>/dev/null || true) [[ -n "$sub_url" ]] || return 1 printf 'USER=%s\nCREATED=0\nSUB_URL=%s\nSYNC_NOTE=%s\n' "$user" "$sub_url" "Заявка уже была применена; повторное продление не выполнялось." return 0 fi pass=$(get_user_pass "$user" 2>/dev/null || true) users_backup=$(mktemp -d /run/yurich-sales-user.XXXXXX) credential_state_backup "$users_backup/credential-state" meta_file=$(user_meta_file "$user") meta_backup=$(mktemp) if [[ -f "$meta_file" ]]; then cp -a "$meta_file" "$meta_backup" meta_existed=1 fi if [[ -z "$pass" ]]; then pass=$(random_safe_token 20) credential_set_user "$user" "$pass" || { rm -rf "$users_backup"; rm -f "$meta_backup"; return 1; } created=1 fi set_user_expiry_extend_term "$user" "$term" || { sales_restore_issue_state "$user" "$users_backup" "$meta_backup" "$meta_existed"; rm -rf "$users_backup"; rm -f "$meta_backup"; return 1; } [[ "$order_id" == "manual" ]] || user_meta_set "$user" LAST_SALES_ORDER_ID "$order_id" || { sales_restore_issue_state "$user" "$users_backup" "$meta_backup" "$meta_existed"; rm -rf "$users_backup"; rm -f "$meta_backup"; return 1; } if ! rewrite_caddyfile_current >/dev/null 2>&1; then sales_restore_issue_state "$user" "$users_backup" "$meta_backup" "$meta_existed" rm -rf "$users_backup"; rm -f "$meta_backup" return 1 fi if ! systemctl reload caddy >/dev/null 2>&1 && ! systemctl restart caddy >/dev/null 2>&1; then sales_restore_issue_state "$user" "$users_backup" "$meta_backup" "$meta_existed" rm -rf "$users_backup"; rm -f "$meta_backup" return 1 fi rm -rf "$users_backup"; rm -f "$meta_backup" if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then sync_hysteria_users_if_active >/dev/null 2>&1 || { err "Hysteria не применил пользователя $user"; return 1; } fi if [[ "${XRAY_ENABLED:-0}" == "1" && -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then provision_xray_user "$user" >/dev/null 2>&1 || { err "Xray не применил пользователя $user"; return 1; } fi cmd_notify_bind_tg "$user" "$chat_id" >/dev/null 2>&1 || true if [[ "$(nodes_count 2>/dev/null || echo 0)" -gt 0 ]]; then if command -v systemd-run >/dev/null 2>&1; then local sync_unit sync_unit="yurich-sales-node-sync-${user}-$(date +%s)" systemd-run --quiet --unit "$sync_unit" --description "Yurich sales node sync for ${user}" /bin/bash "$SCRIPT_PATH" nodes-sync all >/dev/null 2>&1 \ || sync_note="Ноды синхронизируются с задержкой; основная подписка уже готова." else ( /bin/bash "$SCRIPT_PATH" nodes-sync all >> /var/log/yurich-sales-node-sync.log 2>&1 & ) \ || sync_note="Ноды синхронизируются с задержкой; основная подписка уже готова." fi [[ -z "$sync_note" ]] && sync_note="Ноды синхронизируются в фоне; если часть профилей заработает не сразу, обнови подписку через 1-3 минуты." fi sub_url=$(generate_subscription_page "$user" 2>/dev/null || true) [[ -n "$sub_url" ]] || return 1 printf 'USER=%s\nCREATED=%s\nSUB_URL=%s\nSYNC_NOTE=%s\n' "$user" "$created" "$sub_url" "$sync_note" } sales_approve_order() { # FROM_ID/FIRST_NAME are intentionally local: sourced order fields must not leak globally. # shellcheck disable=SC2034 local order_id="$1" admin_id="$2" order_file STATUS CHAT_ID FROM_ID USERNAME FIRST_NAME TERM MONTHS PRICE CURRENCY issue_tmp rc sub_url user sync_note term approval_lock_fd sales_order_valid_id "$order_id" || { sales_reply "$admin_id" "Неверный ORDER ID."; return 1; } order_file="${SALES_BOT_ORDERS_DIR}/${order_id}.env" [[ -f "$order_file" ]] || { sales_reply "$admin_id" "Заявка не найдена: ${order_id}"; return 1; } command -v flock >/dev/null 2>&1 || { sales_reply "$admin_id" "flock не установлен; подтверждение остановлено безопасно."; return 1; } exec {approval_lock_fd}>"${order_file}.lock" if ! flock -n "$approval_lock_fd"; then sales_reply "$admin_id" "Заявка уже обрабатывается: ${order_id}" exec {approval_lock_fd}>&- return 1 fi # shellcheck source=/dev/null source "$order_file" if [[ "${STATUS:-}" != "pending" ]]; then sales_reply "$admin_id" "Заявка уже обработана: ${STATUS:-unknown}" flock -u "$approval_lock_fd"; exec {approval_lock_fd}>&- return 1 fi { grep -vE '^(STATUS|PROCESSING_AT|PROCESSING_BY|ISSUE_ERROR_AT)=' "$order_file" 2>/dev/null || true printf 'STATUS=%q\n' "processing" printf 'PROCESSING_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'PROCESSING_BY=%q\n' "$admin_id" } > "${order_file}.tmp" install -m 600 "${order_file}.tmp" "$order_file" rm -f "${order_file}.tmp" issue_tmp=$(mktemp) term="${TERM:-${MONTHS:-}}" if sales_issue_subscription "$CHAT_ID" "$term" "$order_id" > "$issue_tmp" 2>&1; then rc=0 else rc=$? fi if [[ "$rc" -ne 0 ]]; then { grep -vE '^(STATUS|ISSUE_ERROR_AT)=' "$order_file" 2>/dev/null || true printf 'STATUS=%q\n' "error" printf 'ISSUE_ERROR_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" } > "${order_file}.tmp" install -m 600 "${order_file}.tmp" "$order_file" rm -f "${order_file}.tmp" sales_reply "$admin_id" "Ошибка выдачи подписки ORDER: ${order_id} Статус: error, повторное продление заблокировано до ручной проверки.
$(html_escape_text "$(tail -n 40 "$issue_tmp")")
" rm -f "$issue_tmp" flock -u "$approval_lock_fd"; exec {approval_lock_fd}>&- return 1 fi user=$(awk -F= '$1=="USER"{print $2}' "$issue_tmp") sub_url=$(awk -F= '$1=="SUB_URL"{print $2}' "$issue_tmp") sync_note=$(awk -F= '$1=="SYNC_NOTE"{print $2}' "$issue_tmp") { grep -vE '^(STATUS|PROCESSING_AT|PROCESSING_BY|ISSUE_ERROR_AT|APPROVED_AT|APPROVED_BY|ISSUED_USER|SUB_URL)=' "$order_file" 2>/dev/null || true printf 'STATUS=%q\n' "approved" printf 'APPROVED_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'APPROVED_BY=%q\n' "$admin_id" printf 'ISSUED_USER=%q\n' "$user" printf 'SUB_URL=%q\n' "$sub_url" } > "${order_file}.tmp" install -m 600 "${order_file}.tmp" "$order_file" rm -f "${order_file}.tmp" "$issue_tmp" flock -u "$approval_lock_fd" exec {approval_lock_fd}>&- sales_reply "$CHAT_ID" "Подписка активирована Срок: $(user_expiry_label "$user") Страница подписки: ${sub_url} Открой ссылку и импортируй подписку в Yurich Connect, Hiddify, NekoBox, v2rayNG или Streisand. Android: ${ANDROID_APP_RELEASES_URL} Windows: ${WINDOWS_APP_RELEASES_URL} Канал: ${SALES_BOT_CHANNEL_URL:-$SALES_BOT_CHANNEL_URL_DEFAULT} ${sync_note:+ ${sync_note}}" sales_reply "$admin_id" "Заявка подтверждена ORDER: ${order_id} Пользователь: ${user} Ссылка: ${sub_url}" } sales_reject_order() { local order_id="$1" admin_id="$2" reason="${3:-}" order_file STATUS CHAT_ID sales_order_valid_id "$order_id" || { sales_reply "$admin_id" "Неверный ORDER ID."; return 1; } order_file="${SALES_BOT_ORDERS_DIR}/${order_id}.env" [[ -f "$order_file" ]] || { sales_reply "$admin_id" "Заявка не найдена: ${order_id}"; return 1; } # shellcheck source=/dev/null source "$order_file" [[ "${STATUS:-}" == "pending" ]] || { sales_reply "$admin_id" "Заявка уже обработана: ${STATUS:-unknown}"; return 1; } { grep -vE '^(STATUS|REJECTED_AT|REJECTED_BY|REJECT_REASON)=' "$order_file" 2>/dev/null || true printf 'STATUS=%q\n' "rejected" printf 'REJECTED_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'REJECTED_BY=%q\n' "$admin_id" printf 'REJECT_REASON=%q\n' "${reason:-manual reject}" } > "${order_file}.tmp" install -m 600 "${order_file}.tmp" "$order_file" rm -f "${order_file}.tmp" sales_reply "$CHAT_ID" "Заявка отклонена${reason:+ Причина: $(html_escape_text "$reason")}" sales_reply "$admin_id" "Заявка отклонена: ${order_id}" } sales_orders_text() { mkdir -p "$SALES_BOT_ORDERS_DIR" local file count=0 out="" for file in "$SALES_BOT_ORDERS_DIR"/*.env; do [[ -f "$file" ]] || continue unset ORDER_ID STATUS CHAT_ID USERNAME TERM MONTHS PRICE CURRENCY CREATED_AT # shellcheck source=/dev/null source "$file" [[ "${STATUS:-}" == "pending" ]] || continue count=$((count + 1)) out="${out}${count}. ${ORDER_ID} Клиент: ${CHAT_ID:-} @$(html_escape_text "${USERNAME:-none}") Тариф: $(user_term_label "${TERM:-${MONTHS:-?}}") / ${PRICE:-?} ${CURRENCY:-RUB} Подтвердить: /approve ${ORDER_ID} Отклонить: /reject ${ORDER_ID} " done if [[ "$count" -eq 0 ]]; then printf 'Новых заявок нет.' else printf '%s' "$out" fi } sales_handle_command() { local chat_id="$1" from_id="$2" username="$3" first_name="$4" text="$5" is_admin=0 term order_id reason set +e sales_is_admin "$from_id" && is_admin=1 if [[ "$is_admin" -ne 1 ]]; then if sales_captcha_has_pending "$chat_id"; then sales_captcha_check "$chat_id" "$text" return fi case "$text" in *"Купить VPN"*|*"Тарифы"*|/start|/menu|/plans|/buy|/buy*) if ! sales_captcha_is_verified "$chat_id"; then sales_captcha_prompt "$chat_id" return fi ;; *) term=$(sales_months_from_text "$text" 2>/dev/null || true) if [[ -n "$term" ]] && ! sales_captcha_is_verified "$chat_id"; then sales_captcha_prompt "$chat_id" return fi ;; esac fi case "$text" in /start|/menu) sales_reply_start "$chat_id" "$([[ "$is_admin" -eq 1 ]] && echo admin || echo user)" ;; *"Купить VPN"*|*"Тарифы"*|/plans|/buy) sales_reply_buy_menu "$chat_id" "$(sales_plans_text)" ;; *"Моя подписка"*|/status|/sub|/subscription) sales_reply_menu "$chat_id" "$(sales_subscription_status_text "$chat_id")" "$([[ "$is_admin" -eq 1 ]] && echo admin || echo user)" ;; *"Как подключить"*|/help) sales_reply_menu "$chat_id" "Как подключить 1. Выбери тариф на 1 день, 1, 3, 6 или 12 месяцев. 2. Оплати по QR-коду и отправь скрин платежа сюда в бот. 3. После подтверждения оплаты бот пришлёт страницу подписки. 4. Открой страницу в приложении или скопируй URL. Android: Yurich Connect, Hiddify, NekoBox, v2rayNG. iPhone: Streisand. Windows: Yurich Connect Windows, v2rayN, Hiddify." "$([[ "$is_admin" -eq 1 ]] && echo admin || echo user)" ;; *"Приложения"*|/apps) sales_reply_menu "$chat_id" "Приложения Тариф включает телефон и компьютер. Android: ${ANDROID_APP_RELEASES_URL} Windows: ${WINDOWS_APP_RELEASES_URL} iPhone: ${STREISAND_APP_URL}" "$([[ "$is_admin" -eq 1 ]] && echo admin || echo user)" ;; *"Канал"*|/channel) sales_reply_menu "$chat_id" "Канал Yurich Connect Новости, обновления приложения и важные сообщения: ${SALES_BOT_CHANNEL_URL:-$SALES_BOT_CHANNEL_URL_DEFAULT}" "$([[ "$is_admin" -eq 1 ]] && echo admin || echo user)" ;; *"Поддержка"*|/support) sales_reply_menu "$chat_id" "Поддержка Telegram: ${TELEGRAM_COMMUNITY_URL} Email: ${SUPPORT_EMAIL}" "$([[ "$is_admin" -eq 1 ]] && echo admin || echo user)" ;; *"Заявки"*|/sales_orders) if [[ "$is_admin" -ne 1 ]]; then sales_reply "$chat_id" "Команда доступна только администратору."; return; fi sales_reply_menu "$chat_id" "$(sales_orders_text)" "admin" ;; *"Статус продаж"*|/sales_status) if [[ "$is_admin" -ne 1 ]]; then sales_reply "$chat_id" "Команда доступна только администратору."; return; fi sales_reply_menu "$chat_id" "Бот продаж Yurich Connect Сервис: yurich-sales-bot.service Тарифы: ${SALES_BOT_PLANS:-} Валюта: ${SALES_BOT_CURRENCY:-RUB} Канал: ${SALES_BOT_CHANNEL_URL:-$SALES_BOT_CHANNEL_URL_DEFAULT} Лимит устройств: выключен Заявки: ${SALES_BOT_ORDERS_DIR}" "admin" ;; /approve*) if [[ "$is_admin" -ne 1 ]]; then sales_reply "$chat_id" "Команда доступна только администратору."; return; fi order_id=$(printf '%s\n' "$text" | awk '{print $2}') sales_approve_order "$order_id" "$chat_id" ;; /reject*) if [[ "$is_admin" -ne 1 ]]; then sales_reply "$chat_id" "Команда доступна только администратору."; return; fi order_id=$(printf '%s\n' "$text" | awk '{print $2}') reason=$(printf '%s\n' "$text" | cut -d' ' -f3-) [[ "$reason" == "$text" ]] && reason="" sales_reject_order "$order_id" "$chat_id" "$reason" ;; *) term=$(sales_months_from_text "$text" 2>/dev/null || true) if [[ -n "$term" ]]; then sales_create_order "$chat_id" "$from_id" "$username" "$first_name" "$term" else sales_reply_menu "$chat_id" "Не понял команду. Открой меню или напиши /plans." "$([[ "$is_admin" -eq 1 ]] && echo admin || echo user)" fi ;; esac } cmd_sales_bot() { [[ -n "${SALES_BOT_TOKEN:-}" ]] || { err "Sales bot не настроен. Запусти интерактивно: sudo bash ${SCRIPT_PATH} sales-bot-install"; return 1; } sales_apply_bot_menu >/dev/null 2>&1 || true mkdir -p "$SALES_BOT_ORDERS_DIR" "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" chmod 700 "$SALES_BOT_DIR" "$SALES_BOT_ORDERS_DIR" "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" 2>/dev/null || true info "Запускаю Yurich Sales Bot..." local offset=0 response updates while true; do response=$(sales_tg_api "getUpdates?offset=${offset}&timeout=30&allowed_updates=%5B%22message%22%5D" -s --max-time 35 2>/dev/null || echo "") if [[ -z "$response" ]]; then sleep 5 continue fi updates=$(echo "$response" | python3 -c " import json, sys try: data=json.load(sys.stdin) if not data.get('ok'): sys.exit(0) for u in data.get('result', []): uid=u.get('update_id',0) msg=u.get('message') or {} chat=msg.get('chat') or {} frm=msg.get('from') or {} text=str(msg.get('text') or '') kind='text' if not text: if msg.get('photo'): kind='photo' text='__PAYMENT_PROOF__' elif msg.get('document'): kind='document' text='__PAYMENT_PROOF__' else: continue def clean(v): return str(v or '').replace('|',' ').replace('\\r',' ').replace('\\n',' ')[:120] print('|'.join([str(uid), clean(chat.get('id')), clean(chat.get('type')), clean(frm.get('id')), clean(frm.get('username')), clean(frm.get('first_name')), clean(msg.get('message_id')), clean(kind), clean(text)])) except Exception: pass " 2>/dev/null || echo "") while IFS='|' read -r update_id chat_id chat_type from_id username first_name message_id kind text; do [[ -z "${update_id:-}" ]] && continue if is_canonical_uint_in_range "$update_id" 0 2147483646; then offset=$(( update_id + 1 )) fi [[ "$chat_type" == "private" ]] || continue if [[ "${kind:-text}" == "photo" || "${kind:-text}" == "document" ]]; then sales_handle_payment_proof "$chat_id" "$from_id" "$username" "$first_name" "$message_id" "$kind" else sales_handle_command "$chat_id" "$from_id" "$username" "$first_name" "$text" fi done <<< "$updates" sleep 1 done } install_sales_bot_service() { local token="${1:-}" admin_id="${2:-}" script_path="${SCRIPT_PATH:-/usr/local/bin/yurich-panel.sh}" response bot_name load_config 2>/dev/null || true if [[ -n "$token" ]]; then warn "Передача token через argv видна в history/process list. В следующий раз запускай sales-bot-install без аргументов." SALES_BOT_TOKEN="$token" fi if [[ -n "$admin_id" ]]; then SALES_BOT_ADMIN_ID="$admin_id" fi if [[ -z "${SALES_BOT_TOKEN:-}" ]]; then echo -ne "${CYAN}Sales bot token: ${RESET}" read -rs SALES_BOT_TOKEN echo fi if [[ -z "${SALES_BOT_ADMIN_ID:-}" ]]; then echo -ne "${CYAN}Telegram admin ID: ${RESET}" read -r SALES_BOT_ADMIN_ID fi if [[ ! "${SALES_BOT_TOKEN:-}" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]]; then err "Sales bot token выглядит некорректно" return 1 fi if ! is_valid_tg_chat_id "${SALES_BOT_ADMIN_ID:-}"; then err "Передай корректный Telegram admin ID" return 1 fi response=$(tg_api_with_token "$SALES_BOT_TOKEN" "getMe" -s --max-time 15 2>/dev/null || echo "{}") if ! echo "$response" | grep -q '"ok":true'; then err "Telegram API не подтвердил sales bot token" echo "$response" return 1 fi bot_name=$(echo "$response" | grep -o '"username":"[^"]*"' | cut -d'"' -f4) save_config mkdir -p "$SALES_BOT_ORDERS_DIR" chmod 700 "$SALES_BOT_DIR" "$SALES_BOT_ORDERS_DIR" 2>/dev/null || true install -m 755 "$script_path" "$script_path" 2>/dev/null || true cat > "$SALES_BOT_SERVICE" <Бот продаж Yurich Connect запущен Бот: @${bot_name:-unknown} Команды администратора: /sales_orders /approve ORDER /reject ORDER" ok "Бот продаж установлен и запущен: yurich-sales-bot.service" [[ -n "$bot_name" ]] && ok "Bot: @${bot_name}" } cmd_sales_bot_apply_defaults() { local existing_sales_token="" load_config 2>/dev/null || true existing_sales_token="${SALES_BOT_TOKEN:-}" if [[ -z "${SALES_BOT_TOKEN:-}" && -n "$existing_sales_token" ]]; then SALES_BOT_TOKEN="$existing_sales_token" fi SALES_BOT_PLANS="$SALES_BOT_PLANS_DEFAULT" SALES_BOT_CURRENCY="$SALES_BOT_CURRENCY_DEFAULT" SALES_BOT_CHANNEL_URL="$SALES_BOT_CHANNEL_URL_DEFAULT" SALES_BOT_PAYMENT_TEXT="$SALES_BOT_PAYMENT_TEXT_DEFAULT" SALES_BOT_PAYMENT_QR_PATH="${SALES_BOT_PAYMENT_QR_PATH:-$SALES_BOT_PAYMENT_QR_PATH_DEFAULT}" SALES_BOT_CAPTCHA_TTL_SECONDS="${SALES_BOT_CAPTCHA_TTL_SECONDS:-$SALES_BOT_CAPTCHA_TTL_SECONDS_DEFAULT}" DEVICE_LIMIT_ENABLED="0" DEVICE_LIMIT="5" DEVICE_WINDOW_HOURS="${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}" DEVICE_LIMIT_MODE="alert" save_config write_device_cron mkdir -p "$SALES_BOT_DIR" "$SALES_BOT_ORDERS_DIR" "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" chmod 700 "$SALES_BOT_DIR" "$SALES_BOT_ORDERS_DIR" "$SALES_BOT_CAPTCHA_DIR" "$SALES_BOT_VERIFIED_DIR" 2>/dev/null || true if [[ -n "${SALES_BOT_TOKEN:-}" ]]; then sales_apply_bot_menu || warn "Не удалось применить меню бота продаж через Telegram API" if systemctl list-unit-files yurich-sales-bot.service >/dev/null 2>&1; then systemctl restart yurich-sales-bot || warn "Не удалось перезапустить yurich-sales-bot.service" fi else warn "SALES_BOT_TOKEN не настроен, меню Telegram не применялось" fi ok "Настройки продаж применены: тарифы 1 день/1/3/6/12 месяцев, QR, канал, автобан выключен" } tg_reply_pre() { local chat_id="$1" local title="$2" local content="$3" content=$(printf '%s' "$content" | sed -r 's/\x1B\[[0-9;]*[mK]//g' | sed 's/&/\&/g; s//\>/g' | head -c 3400 || true) [[ -z "$content" ]] && content="нет вывода" tg_reply "$chat_id" "${title}
${content}
" } tg_reply_file_tail() { local chat_id="$1" local title="$2" local file="$3" local tail_lines="${4:-60}" local content if [[ -f "$file" ]]; then content=$(tail -n "$tail_lines" "$file" 2>/dev/null || true) else content="файл не найден: ${file}" fi tg_reply_pre "$chat_id" "$title" "$content" } # Отправка фото (QR код) tg_send_photo() { local chat_id="$1" local photo_path="$2" local caption="$3" [[ -z "${TG_TOKEN:-}" || ! -f "${photo_path}" ]] && return # Используем только -F для multipart (-F и --data-urlencode несовместимы) tg_api "sendPhoto" -s --max-time 30 \ -X POST \ -F "chat_id=${chat_id}" \ -F "caption=${caption}" \ -F "photo=@${photo_path}" \ >/dev/null 2>&1 || true } tg_send_naive_qr() { local chat_id="$1" local user="$2" local uri="$3" if ! command -v qrencode &>/dev/null; then tg_reply "${chat_id}" "📦 Устанавливаю qrencode для QR..." apt-get install -y -q qrencode >/dev/null 2>&1 || true fi if command -v qrencode &>/dev/null; then local qr_file="/tmp/naiveproxy_qr_${user}_$$.png" if qrencode -o "${qr_file}" -s 8 "${uri}" 2>/dev/null && [[ -s "${qr_file}" ]]; then tg_send_photo "${chat_id}" "${qr_file}" "📱 Yurich Proxy QR для ${user}@${DOMAIN}" rm -f "${qr_file}" return 0 fi rm -f "${qr_file}" fi return 1 } tg_send_hysteria_qr() { local chat_id="$1" local user="$2" local uri="$3" if ! command -v qrencode &>/dev/null; then tg_reply "${chat_id}" "📦 Устанавливаю qrencode для Hysteria QR..." apt-get install -y -q qrencode >/dev/null 2>&1 || true fi if command -v qrencode &>/dev/null; then local qr_file="/tmp/hysteria_qr_${user}_$$.png" if qrencode -o "${qr_file}" -s 8 "${uri}" 2>/dev/null && [[ -s "${qr_file}" ]]; then tg_send_photo "${chat_id}" "${qr_file}" "⚡ Hysteria 2 QR для ${user}@${DOMAIN}" rm -f "${qr_file}" return 0 fi rm -f "${qr_file}" fi return 1 } # Обработка одной команды tg_handle_command() { local chat_id="$1" local from_id="$2" local text="$3" # Отключаем строгий режим внутри обработчика — иначе любая ошибка ломает бот set +e load_config 2>/dev/null || true local is_admin=0 tg_is_admin "${from_id}" && is_admin=1 # Очищаем text от \r \n и невидимых символов text="${text//$'\r'/}" text="${text//$'\n'/}" # Русские кнопки Telegram reply keyboard -> существующие команды case "${text}" in "🆔 Мой Telegram ID") text="/myid" ;; "🔔 Уведомления") text="/help" ;; "📱 Приложения") text="/apps" ;; "💬 Поддержка") text="/support" ;; "📊 Статус") text="/status" ;; "👥 Пользователи") text="/users" ;; "➕ Добавить пользователя") text="/adduser" ;; "🗑 Удалить пользователя") text="/deluser" ;; "📱 QR / ссылка") text="/qr" ;; "🔗 Подписка") text="/sub" ;; "📰 Новости") text="/news" ;; "🧬 Xray") text="/xray" ;; "⚡ Hysteria") text="/hysteria" ;; "🌀 WARP") text="/warp" ;; "🔀 HAProxy") text="/haproxy" ;; "🔍 Диагностика") text="/diagnose" ;; "📄 Логи") text="/logs" ;; "♻️ Restart Caddy") text="/restart" ;; "🛠 Автофикс") text="/diagfix" ;; "💛 Донат") text="/donate" ;; "❓ Помощь") text="/menu" ;; esac # Лимит длины команды — защита от flood/injection, но новости могут быть длиннее обычных команд. if [[ ${#text} -gt 3500 ]]; then tg_reply "${chat_id}" "❌ Команда слишком длинная. Максимум 3500 символов." return fi # Парсим команду и аргументы local cmd args cmd=$(echo "${text}" | awk '{print $1}' | tr '[:upper:]' '[:lower:]' | tr -d '\r\n[:cntrl:]') # args = всё после первого пробела if [[ "${text}" == *" "* ]]; then args="${text#* }" # Trim leading/trailing whitespace args="${args#"${args%%[![:space:]]*}"}" args="${args%"${args##*[![:space:]]}"}" else args="" fi # Убираем потенциально опасные символы из args (но оставляем безопасные) args=$(echo "${args}" | tr -d '`$();<>&|\\') if [[ "$is_admin" -ne 1 ]]; then case "${cmd}" in /start|/help|/menu) tg_reply_menu "${chat_id}" "$(tg_user_welcome_message "${from_id}")" "user" ;; /myid|/id) tg_reply_menu "${chat_id}" "🆔 Твой Telegram ID: ${from_id} Отправь его Ивану Юрьевичу вместе с именем профиля, чтобы подключить уведомления." "user" ;; /apps) tg_reply_menu "${chat_id}" "📱 Приложения Yurich Connect Android: ${ANDROID_APP_RELEASES_URL} Windows: ${WINDOWS_APP_RELEASES_URL}" "user" ;; /support) tg_reply_menu "${chat_id}" "💬 Поддержка Напиши в поддержку: ${TELEGRAM_COMMUNITY_URL} Не забудь отправить свой Telegram ID: ${from_id}" "user" ;; *) tg_reply_menu "${chat_id}" "👋 Это пользовательское меню Yurich Connect. Твой Telegram ID: ${from_id} Админские команды скрыты. Для уведомлений отправь этот ID Ивану Юрьевичу вместе с именем профиля." "user" ;; esac return fi case "${cmd}" in /start|/help|/menu) tg_reply_menu "${chat_id}" "🛡 Yurich Panel v${VERSION} 🖥 Сервер: $(hostname) Русское меню включено. Кнопки ниже запускают основные действия, а команды руками тоже работают. Доступные команды: 📊 Информация /status — статус сервера и сертификата /stats — статистика трафика и ресурсов /haproxy — HAProxy SNI mux, маршруты Naive/Reality и SNI-лог /diagnose — полная диагностика системы /logs — последние 20 строк логов /users — список пользователей /cert — статус TLS сертификата 👥 Пользователи /adduser логин [1-12 мес] — создать безопасный пароль, QR и подписку /deluser логин — удалить пользователя + страницу /qr логин — QR код для подключения /sub логин — страница подписки пользователя /subreset логин — перевыпустить ссылку подписки /expiring — сроки подписок и привязки Telegram /bindtg логин telegram_id — включить напоминания /notifyrun — проверить и отправить напоминания /testnotify логин — тест напоминания /news_test текст — тест новости себе /news текст — рассылка новости всем с Telegram ID /devices — отчёт по лимиту устройств /lockuser логин — отключить пользователя /unlockuser логин — вернуть пользователя 🧬 Xray / Modern /xray логин — ссылки VLESS/Trojan/REALITY /xrayadduser логин — создать Xray пользователя + подписка /xraystatus — статус Xray /hysteria — статус Hysteria 2 /warp — статус и тест WARP proxy ⚙️ Управление /restart — перезапустить Caddy /update — обновить Caddy /selfupdate — обновить скрипт /diagfix — автофикс диагностики /privatepage — личная фейковая страница /admins — список администраторов /addadmin ID — добавить администратора /deladmin ID — удалить администратора 💛 /donate — поддержать проект" ;; /status) local caddy_status="🔴 Остановлен" systemctl is-active caddy &>/dev/null && caddy_status="🟢 Работает" local cert_info="" if [[ -n "${DOMAIN:-}" ]]; then local not_after expire_ts now_ts cert_days not_after=$(echo | timeout 5 openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null | grep "notAfter" | cut -d= -f2 || echo "") if [[ -n "${not_after}" ]]; then expire_ts=$(date -d "${not_after}" +%s 2>/dev/null || echo 0) now_ts=$(date +%s) cert_days=$(( (expire_ts - now_ts) / 86400 )) cert_info=" 🔐 Сертификат: ${cert_days} дней" fi fi tg_reply "${chat_id}" "📡 Статус Yurich Panel 🖥 Сервер: $(hostname) ${caddy_status} 🌐 Домен: ${DOMAIN:-не настроен} 👥 Пользователей: $(get_users | wc -l) 💾 RAM: $(free -h | awk '/Mem:/{print $3"/"$2}') 💿 Диск: $(df -h / | awk 'NR==2{print $3"/"$2" ("$5")"}') 🕐 $(date '+%Y-%m-%d %H:%M:%S')${cert_info}" ;; /stats) tg_send_stats_to "${chat_id}" ;; /diagnose) tg_reply "${chat_id}" "🔍 Запускаю диагностику, подожди..." local diag_result="" local pass=0 warn=0 fail=0 # Caddy if systemctl is-active caddy &>/dev/null; then diag_result+="✅ Caddy запущен " pass=$((pass+1)) else diag_result+="❌ Caddy НЕ запущен " fail=$((fail+1)) fi # Padding local _p _p=$(strings /usr/local/bin/caddy 2>/dev/null | grep -cE "^(Padding|SetPadding|WithPadding)$" || true) _p="${_p//[^0-9]/}"; _p="${_p:-0}" if command -v strings &>/dev/null && [[ "${_p}" -ge 2 ]]; then diag_result+="✅ Naive padding OK " pass=$((pass+1)) else diag_result+="⚠️ Padding не проверен " warn=$((warn+1)) fi # Caddyfile if grep -qE "^:443,|^http://127\\.0\\.0\\.1:[0-9]+" "${CADDYFILE:-/etc/caddy/Caddyfile}" 2>/dev/null \ || { edge_routing_mode_is_haproxy && grep -q "^:${XRAY_CADDY_FALLBACK_PORT:-$XRAY_CADDY_FALLBACK_PORT_DEFAULT}," "${CADDYFILE:-/etc/caddy/Caddyfile}" 2>/dev/null; }; then diag_result+="✅ Caddyfile формат OK " pass=$((pass+1)) elif grep -qE "^[[:alnum:].-]+:443[[:space:]]*\\{" "${CADDYFILE:-/etc/caddy/Caddyfile}" 2>/dev/null; then diag_result+="⚠️ Caddyfile domain:443 ломает Naive CONNECT " warn=$((warn+1)) else diag_result+="❌ Caddyfile неправильный формат " fail=$((fail+1)) fi local hosts_overrides hosts_domains hosts_overrides=$(hosts_public_domain_overrides | head -5 || true) if [[ -n "$hosts_overrides" ]]; then hosts_domains=$(printf '%s\n' "$hosts_overrides" | format_hosts_override_domains) diag_result+="⚠️ /etc/hosts: ${hosts_domains} " warn=$((warn+1)) else diag_result+="✅ /etc/hosts clean " pass=$((pass+1)) fi # ALPN if [[ -n "${DOMAIN:-}" ]]; then local alpn alpn=$(echo | timeout 5 openssl s_client -connect "${DOMAIN}:443" -alpn h2 2>/dev/null | grep -a "ALPN protocol" | awk '{print $3}' || echo "") if [[ "${alpn}" == "h2" ]]; then diag_result+="✅ ALPN h2 OK " pass=$((pass+1)) else diag_result+="❌ ALPN не h2 " fail=$((fail+1)) fi fi # UFW if ufw status 2>/dev/null | grep -q "Status: active"; then diag_result+="✅ UFW активен " pass=$((pass+1)) else diag_result+="⚠️ UFW неактивен " warn=$((warn+1)) fi # Fail2Ban if systemctl is-active fail2ban &>/dev/null; then diag_result+="✅ Fail2Ban активен " pass=$((pass+1)) else diag_result+="⚠️ Fail2Ban не запущен " warn=$((warn+1)) fi # RAM local ram_pct ram_pct=$(free | awk '/Mem:/{printf "%d", $3/$2*100}') if [[ ${ram_pct} -lt 90 ]]; then diag_result+="✅ RAM: ${ram_pct}% " pass=$((pass+1)) else diag_result+="❌ RAM критически: ${ram_pct}% " fail=$((fail+1)) fi tg_reply "${chat_id}" "🔍 Диагностика Yurich Panel ${diag_result} ✅ Пройдено: ${pass} ⚠️ Внимание: ${warn} ❌ Проблемы: ${fail}" ;; /logs) local log_lines log_lines=$(journalctl -u caddy -n 20 --no-pager 2>/dev/null | tail -20 | sed 's//\>/g' || echo "Логи недоступны") tg_reply "${chat_id}" "📋 Логи Caddy (последние 20):
${log_lines}
" ;; /users) local user_list user_list=$(get_users | awk -F: '{print "• "$1""}' | head -20 || echo "Нет пользователей") tg_reply "${chat_id}" "👥 Пользователи ($(get_users | wc -l)): ${user_list}" ;; /adduser) local new_user new_pass new_months arg2 arg3 new_user=$(echo "${args}" | awk '{print $1}') arg2=$(echo "${args}" | awk '{print $2}') arg3=$(echo "${args}" | awk '{print $3}') new_months="${arg2:-12}" if [[ -z "${new_user}" ]]; then tg_reply "${chat_id}" "❌ Использование: /adduser логин [месяцы 1-12] Бот сам создаёт безопасный пароль и показывает его один раз. Пример: /adduser alice 6" return fi if [[ -n "$arg3" ]]; then tg_reply "${chat_id}" "❌ Пароль больше нельзя передавать в Telegram-команде. Используй: /adduser ${new_user} 12" return fi if ! is_valid_user_months "$new_months"; then tg_reply "${chat_id}" "❌ Срок должен быть от 1 до 12 месяцев" return fi # Валидация логина if ! is_valid_proxy_user "${new_user}"; then tg_reply "${chat_id}" "❌ Неверный логин ${new_user} Только буквы, цифры, _, - (2-32 символа)" return fi new_pass=$(random_safe_token 20) if get_user_hash "${new_user}" >/dev/null; then tg_reply "${chat_id}" "❌ Пользователь ${new_user} уже существует" return fi # Создаём папку и файл если нет mkdir -p "$(dirname "${USERS_FILE}")" touch "${USERS_FILE}" chmod 600 "${USERS_FILE}" local users_backup users_backup=$(mktemp -d /run/yurich-bot-add-user.XXXXXX) credential_state_backup "$users_backup/credential-state" if ! credential_set_user "${new_user}" "${new_pass}"; then rm -rf "$users_backup" tg_reply "${chat_id}" "❌ Не удалось безопасно сохранить credentials пользователя ${new_user}." return fi set_user_expiry_months "${new_user}" "${new_months}" || true if rewrite_caddyfile_current 2>/dev/null; then if ! systemctl reload caddy 2>/dev/null && ! systemctl restart caddy 2>/dev/null; then credential_state_restore "$users_backup/credential-state" rm -rf "$users_backup" cleanup_user_metadata "${new_user}" rewrite_caddyfile_current >/dev/null 2>&1 || true tg_reply "${chat_id}" "❌ Caddy не перезагрузился. Пользователь ${new_user} отменён." return fi rm -rf "$users_backup" local uri branded_uri sub_url sub_links xray_note xray_tmp xray_ok hy_note hy_uri hy_ok uri="naive+https://${new_user}:${new_pass}@${DOMAIN}:443" branded_uri=$(yurich_proxy_uri "${new_user}" "${new_pass}" "${new_user}-yurich") xray_note="" xray_ok=0 hy_note="" hy_uri="" hy_ok=0 if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then if sync_hysteria_users_if_active >/dev/null 2>&1; then hy_ok=1 hy_uri=$(hysteria_uri_for_user "${new_user}" 2>/dev/null || true) hy_note="⚡ Hysteria 2: создан персональный профиль" else hy_note="⚠️ Hysteria 2: пользователь создан, но конфиг не обновился" fi fi if [[ "${XRAY_ENABLED:-0}" == "1" && -x "$XRAY_BIN" && -f "$XRAY_CONFIG" ]]; then xray_tmp=$(mktemp) if provision_xray_user "${new_user}" > "$xray_tmp" 2>&1; then xray_ok=1 xray_note="🧬 Xray/VLESS: создан для этого же пользователя" else xray_note="⚠️ Xray/VLESS: Naive создан, но Xray профиль не применён" fi fi sub_url=$(generate_subscription_page "${new_user}" 2>/dev/null || true) sub_links="${sub_url:+${sub_url}links.txt}" tg_reply "${chat_id}" "✅ Пользователь добавлен 👤 Логин: ${new_user} 🔑 Пароль: ${new_pass} 🔒 На сервере: bcrypt + encrypted vault ⚠️ Пароль показан в этом сообщении для настройки клиента. 📅 Срок: $(user_expiry_label "${new_user}") ${hy_note} ${xray_note} 🌐 Yurich Proxy: ${branded_uri} Совместимый URI: ${uri} ⚡ Hysteria 2: ${hy_uri:-не создано} 📄 Страница: ${sub_url:-не создана автоматически} Raw links: ${sub_links:-не создано}" if ! tg_send_naive_qr "${chat_id}" "${new_user}" "${uri}"; then tg_reply "${chat_id}" "⚠️ QR не удалось создать автоматически. URI выше рабочий." fi if [[ "$hy_ok" -eq 1 && -n "$hy_uri" ]]; then tg_send_hysteria_qr "${chat_id}" "${new_user}" "$hy_uri" || true fi if [[ "$xray_ok" -eq 1 ]]; then local x_links x_links=$(print_xray_client_config "${new_user}" 2>&1) tg_reply_pre "${chat_id}" "🧬 Xray ссылки для ${new_user}" "$x_links" elif [[ -n "${xray_tmp:-}" && -f "$xray_tmp" ]]; then tg_reply_file_tail "${chat_id}" "⚠️ Xray профиль не применён" "$xray_tmp" 60 fi [[ -n "${xray_tmp:-}" ]] && rm -f "$xray_tmp" else credential_state_restore "$users_backup/credential-state" rm -rf "$users_backup" cleanup_user_metadata "${new_user}" tg_reply "${chat_id}" "❌ Caddyfile не обновлён. Пользователь ${new_user} отменён." fi ;; /deluser) local del_user="${args%% *}" if [[ -z "${del_user}" ]]; then tg_reply "${chat_id}" "❌ Использование: /deluser логин" return fi if ! is_valid_proxy_user "${del_user}" || ! subscription_user_exists "${del_user}"; then tg_reply "${chat_id}" "❌ Пользователь ${del_user} не найден" return fi if ! delete_subscription_user_everywhere "${del_user}"; then tg_reply "${chat_id}" "❌ Не удалось полностью удалить ${del_user}. Проверь логи панели." return fi tg_reply "${chat_id}" "🗑 Пользователь ${del_user} полностью удалён 📄 Подписка, token и публичные ссылки тоже удалены" ;; /qr) local qr_user="${args%% *}" # Если args содержит ":" — значит это конкретный логин с двоеточием (не должно быть) if [[ -z "${qr_user}" ]]; then # QR для первого пользователя qr_user=$(get_users | head -1 | cut -d: -f1) fi if [[ -z "${qr_user}" ]]; then tg_reply "${chat_id}" "❌ Нет пользователей. Добавь: /adduser логин [месяцы]" return fi local qr_pass qr_pass=$(get_user_pass "${qr_user}" || true) if [[ -z "${qr_pass}" ]]; then tg_reply "${chat_id}" "❌ Пользователь ${qr_user} не найден Список пользователей: /users" return fi if [[ -z "${DOMAIN:-}" ]]; then tg_reply "${chat_id}" "❌ Домен не настроен в конфиге" return fi local uri branded_uri uri="naive+https://${qr_user}:${qr_pass}@${DOMAIN}:443" branded_uri=$(yurich_proxy_uri "${qr_user}" "${qr_pass}" "${qr_user}-yurich") # Авто-установка qrencode если нет if ! command -v qrencode &>/dev/null; then tg_reply "${chat_id}" "📦 Устанавливаю qrencode..." apt-get install -y -q qrencode &>/dev/null fi if command -v qrencode &>/dev/null; then local qr_file="/tmp/naiveproxy_qr_${qr_user}_$$.png" if qrencode -o "${qr_file}" -s 8 "${uri}" 2>/dev/null && [[ -s "${qr_file}" ]]; then tg_send_photo "${chat_id}" "${qr_file}" "📱 Yurich Proxy QR для ${qr_user}@${DOMAIN}" # Дополнительно отправляем URI текстом tg_reply "${chat_id}" "🔗 Yurich Proxy: ${branded_uri} Совместимый URI: ${uri}" rm -f "${qr_file}" else tg_reply "${chat_id}" "⚠️ Ошибка генерации QR Yurich Proxy: ${branded_uri} Совместимый URI: ${uri}" fi else tg_reply "${chat_id}" "📱 Yurich Proxy для ${qr_user}: ${branded_uri} Совместимый URI: ${uri} (установи qrencode на сервере для QR картинки)" fi ;; /sub) local sub_user="${args%% *}" if [[ -z "${sub_user}" ]]; then sub_user=$(get_users | head -1 | cut -d: -f1) [[ -z "$sub_user" && -s "$XRAY_USERS_FILE" ]] && sub_user=$(head -1 "$XRAY_USERS_FILE" | cut -d: -f1) fi if [[ -z "$sub_user" ]]; then tg_reply "${chat_id}" "❌ Нет пользователей. Создай Naive или Xray пользователя." return fi local sub_out sub_rc sub_out=$(generate_subscription_page "$sub_user" 2>&1) sub_rc=$? if [[ "$sub_rc" -eq 0 ]]; then tg_reply "${chat_id}" "🔗 Подписка для ${sub_user} ${sub_out} Raw links: ${sub_out}links.txt" else tg_reply_pre "${chat_id}" "❌ Ошибка создания подписки" "$sub_out" fi ;; /subreset) local reset_user="${args%% *}" if [[ -z "$reset_user" ]]; then tg_reply "${chat_id}" "❌ Использование: /subreset логин" return fi local reset_out reset_rc reset_out=$(cmd_subscription_reset "$reset_user" 2>&1) reset_rc=$? if [[ "$reset_rc" -eq 0 ]]; then tg_reply_pre "${chat_id}" "✅ Подписка перевыпущена" "$reset_out" else tg_reply_pre "${chat_id}" "❌ Ошибка перевыпуска подписки" "$reset_out" fi ;; /expiring|/expires|/notifylist) local exp_tmp exp_tmp=$(mktemp) cmd_notify_expiry_list > "$exp_tmp" 2>&1 tg_reply_file_tail "${chat_id}" "📅 Сроки подписок" "$exp_tmp" 120 rm -f "$exp_tmp" ;; /bindtg|/tgbind) local bind_user bind_chat bind_tmp bind_rc bind_user=$(echo "${args}" | awk '{print $1}') bind_chat=$(echo "${args}" | awk '{print $2}') if [[ -z "$bind_user" || -z "$bind_chat" ]]; then tg_reply "${chat_id}" "❌ Использование: /bindtg логин telegram_id" return fi bind_tmp=$(mktemp) cmd_notify_bind_tg "$bind_user" "$bind_chat" > "$bind_tmp" 2>&1 bind_rc=$? if [[ "$bind_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "✅ Telegram ID привязан" "$bind_tmp" 40 else tg_reply_file_tail "${chat_id}" "❌ Telegram ID не привязан" "$bind_tmp" 40 fi rm -f "$bind_tmp" ;; /untg|/unbindtg) local unbind_user unbind_tmp unbind_rc unbind_user="${args%% *}" if [[ -z "$unbind_user" ]]; then tg_reply "${chat_id}" "❌ Использование: /untg логин" return fi unbind_tmp=$(mktemp) cmd_notify_unbind_tg "$unbind_user" > "$unbind_tmp" 2>&1 unbind_rc=$? if [[ "$unbind_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "✅ Telegram уведомления отключены" "$unbind_tmp" 40 else tg_reply_file_tail "${chat_id}" "❌ Не удалось отключить Telegram" "$unbind_tmp" 40 fi rm -f "$unbind_tmp" ;; /notifyrun|/notifynow) local notify_tmp notify_rc notify_tmp=$(mktemp) cmd_notify_expiry_run > "$notify_tmp" 2>&1 notify_rc=$? if [[ "$notify_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "🔔 Проверка уведомлений выполнена" "$notify_tmp" 40 else tg_reply_file_tail "${chat_id}" "⚠️ Проверка уведомлений с ошибками" "$notify_tmp" 40 fi rm -f "$notify_tmp" ;; /testnotify) local test_user test_tmp test_rc test_user="${args%% *}" if [[ -z "$test_user" ]]; then tg_reply "${chat_id}" "❌ Использование: /testnotify логин" return fi test_tmp=$(mktemp) cmd_notify_expiry_test "$test_user" > "$test_tmp" 2>&1 test_rc=$? if [[ "$test_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "✅ Тест отправлен" "$test_tmp" 40 else tg_reply_file_tail "${chat_id}" "❌ Тест не отправлен" "$test_tmp" 40 fi rm -f "$test_tmp" ;; /news_test|/newstest|/broadcast_test|/broadcasttest) local news_test_tmp news_test_rc if [[ -z "$args" ]]; then tg_reply "${chat_id}" "❌ Использование: /news_test текст новости Пример: /news_test Обновили профили и улучшили стабильность подключения." return fi news_test_tmp=$(mktemp) cmd_notify_news_test "$args" "$chat_id" > "$news_test_tmp" 2>&1 news_test_rc=$? if [[ "$news_test_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "✅ Тест новости отправлен" "$news_test_tmp" 40 else tg_reply_file_tail "${chat_id}" "❌ Тест новости не отправлен" "$news_test_tmp" 40 fi rm -f "$news_test_tmp" ;; /news|/broadcast) local news_tmp news_rc if [[ -z "$args" ]]; then tg_reply "${chat_id}" "📰 Рассылка новостей Yurich Connect Сначала проверь внешний вид: /news_test текст новости Потом отправь всем пользователям с привязанным Telegram ID: /news текст новости" return fi news_tmp=$(mktemp) cmd_notify_news_broadcast "$args" > "$news_tmp" 2>&1 news_rc=$? if [[ "$news_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "✅ Новость разослана" "$news_tmp" 40 else tg_reply_file_tail "${chat_id}" "⚠️ Рассылка завершилась с проблемами" "$news_tmp" 40 fi rm -f "$news_tmp" ;; /devices) local dev_tmp dev_rc dev_tmp=$(mktemp) cmd_devices_scan > "$dev_tmp" 2>&1 dev_rc=$? if [[ "$dev_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "📱 Лимит устройств" "$dev_tmp" 80 else tg_reply_file_tail "${chat_id}" "⚠️ Лимит устройств" "$dev_tmp" 80 fi rm -f "$dev_tmp" ;; /lockuser) local lock_user="${args%% *}" if [[ -z "$lock_user" ]]; then tg_reply "${chat_id}" "❌ Использование: /lockuser логин" return fi local lock_out lock_rc lock_out=$(device_disable_user "$lock_user" 2>&1) lock_rc=$? if [[ "$lock_rc" -eq 0 ]]; then tg_reply_pre "${chat_id}" "🔒 Пользователь отключён" "$lock_out" else tg_reply_pre "${chat_id}" "❌ Не удалось отключить пользователя" "$lock_out" fi ;; /unlockuser) local unlock_user="${args%% *}" if [[ -z "$unlock_user" ]]; then tg_reply "${chat_id}" "❌ Использование: /unlockuser логин" return fi local unlock_out unlock_rc unlock_out=$(device_enable_user "$unlock_user" 2>&1) unlock_rc=$? if [[ "$unlock_rc" -eq 0 ]]; then tg_reply_pre "${chat_id}" "🔓 Пользователь возвращён" "$unlock_out" else tg_reply_pre "${chat_id}" "❌ Не удалось вернуть пользователя" "$unlock_out" fi ;; /xray) local x_user="${args%% *}" if [[ ! -s "$XRAY_USERS_FILE" ]]; then tg_reply "${chat_id}" "❌ Xray пользователи не найдены. Сначала настрой Xray: sudo bash yurich-panel.sh xray-install" return fi [[ -z "$x_user" ]] && x_user=$(head -1 "$XRAY_USERS_FILE" | cut -d: -f1) local x_out x_rc x_out=$(print_xray_client_config "$x_user" 2>&1) x_rc=$? if [[ "$x_rc" -eq 0 ]]; then tg_reply_pre "${chat_id}" "🧬 Xray ссылки для ${x_user}" "$x_out" else tg_reply_pre "${chat_id}" "❌ Xray config недоступен" "$x_out" fi ;; /xrayadduser|/xrayuser) local x_new x_months x_new=$(echo "${args}" | awk '{print $1}') x_months=$(echo "${args}" | awk '{print $2}') x_months="${x_months:-12}" if [[ -z "$x_new" ]]; then tg_reply "${chat_id}" "❌ Использование: /xrayadduser логин [месяцы 1-12]" return fi if ! is_valid_user_months "$x_months"; then tg_reply "${chat_id}" "❌ Срок должен быть от 1 до 12 месяцев" return fi local xa_tmp xa_rc xa_tmp=$(mktemp) cmd_xray_add_user "$x_new" "$x_months" > "$xa_tmp" 2>&1 xa_rc=$? if [[ "$xa_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "✅ Xray пользователь создан" "$xa_tmp" 100 else tg_reply_file_tail "${chat_id}" "❌ Xray пользователь не создан" "$xa_tmp" 100 fi rm -f "$xa_tmp" ;; /xraystatus) local xs_tmp xs_tmp=$(mktemp) cmd_xray_status > "$xs_tmp" 2>&1 tg_reply_file_tail "${chat_id}" "🧬 Xray статус" "$xs_tmp" 80 rm -f "$xs_tmp" ;; /hysteria|/hy2|/hysteriastatus|/hy2status) local hy_tmp hy_tmp=$(mktemp) cmd_hysteria_status > "$hy_tmp" 2>&1 tg_reply_file_tail "${chat_id}" "⚡ Hysteria 2 статус" "$hy_tmp" 80 rm -f "$hy_tmp" ;; /warp|/warpstatus) local warp_tmp warp_tmp=$(mktemp) cmd_warp_status > "$warp_tmp" 2>&1 echo >> "$warp_tmp" cmd_warp_test >> "$warp_tmp" 2>&1 || true tg_reply_file_tail "${chat_id}" "🌀 WARP proxy статус" "$warp_tmp" 100 rm -f "$warp_tmp" ;; /haproxy|/sni|/sni_mux) local hap_tmp hap_tmp=$(mktemp) cmd_haproxy_status > "$hap_tmp" 2>&1 tg_reply_file_tail "${chat_id}" "🔀 HAProxy SNI mux" "$hap_tmp" 120 rm -f "$hap_tmp" ;; /diagfix) local fix_tmp fix_rc fix_tmp=$(mktemp) tg_reply "${chat_id}" "🛠 Запускаю автофикс, подожди..." cmd_diagnose_fix > "$fix_tmp" 2>&1 fix_rc=$? if [[ "$fix_rc" -eq 0 ]]; then tg_reply_file_tail "${chat_id}" "✅ Автофикс завершён" "$fix_tmp" 80 else tg_reply_file_tail "${chat_id}" "⚠️ Автофикс завершился с ошибкой" "$fix_tmp" 80 fi rm -f "$fix_tmp" ;; /privatepage) local private_out private_rc private_out=$(install_private_camouflage_page 2>&1) private_rc=$? if [[ "$private_rc" -eq 0 ]]; then tg_reply_pre "${chat_id}" "🎭 Личная фейковая страница" "$private_out" else tg_reply_pre "${chat_id}" "❌ Ошибка личной страницы" "$private_out" fi ;; /donate) local donate_link donate_label donate_link="${PROJECT_DONATION_URL:-$PROJECT_GITHUB_URL}" donate_label="$([[ -n "${PROJECT_DONATION_URL:-}" ]] && printf 'Поддержать проект' || printf 'GitHub проекта')" tg_reply "${chat_id}" "💛 Поддержать проект Если Yurich Panel помог тебе — поддержи разработку! 👉 $(html_escape_text "$donate_label") Что даст твой донат: 🚀 Больше времени на разработку 🐛 Быстрые фиксы багов ✨ Новые фичи каждый месяц 📚 Документация и поддержка Спасибо за поддержку! 🙏" ;; /cert) if [[ -z "${DOMAIN:-}" ]]; then tg_reply "${chat_id}" "❌ Домен не настроен" return fi local cert_out cert_out=$(echo | timeout 5 openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null | openssl x509 -noout -dates -issuer 2>/dev/null || echo "") if [[ -z "${cert_out}" ]]; then tg_reply "${chat_id}" "❌ Не удалось получить сертификат для ${DOMAIN}" return fi local not_after issuer cert_days not_after=$(echo "${cert_out}" | grep "notAfter" | cut -d= -f2) issuer=$(echo "${cert_out}" | grep "issuer" | grep -oP 'O=\K[^,]+' || echo "н/д") expire_ts=$(date -d "${not_after}" +%s 2>/dev/null || echo 0) cert_days=$(( ($(date +%s) - expire_ts) / -86400 )) local cert_icon="🟢" [[ ${cert_days} -lt 30 ]] && cert_icon="🟡" [[ ${cert_days} -lt 7 ]] && cert_icon="🔴" tg_reply "${chat_id}" "${cert_icon} TLS Сертификат 🌐 Домен: ${DOMAIN} 📅 Истекает: ${not_after} ⏳ Осталось: ${cert_days} дней 🏢 Выдан: ${issuer}" ;; /restart) tg_reply "${chat_id}" "🔄 Перезапускаю Caddy..." if systemctl restart caddy 2>/dev/null; then sleep 2 tg_reply "${chat_id}" "✅ Caddy перезапущен" else tg_reply "${chat_id}" "❌ Ошибка перезапуска. Проверь: journalctl -u caddy -n 20" fi ;; /update) tg_reply "${chat_id}" "🔄 Обновляю Caddy, подожди 5-15 минут..." local _script _script="${SCRIPT_PATH:-/usr/local/bin/yurich-panel.sh}" if [[ ! -f "${_script}" ]]; then tg_reply "${chat_id}" "❌ Скрипт не найден: ${_script}" return fi if bash "${_script}" update >/dev/null 2>&1; then tg_reply "${chat_id}" "✅ Caddy обновлён" else tg_reply "${chat_id}" "❌ Ошибка обновления Caddy" fi ;; /selfupdate) tg_reply "${chat_id}" "⬆️ Проверяю обновления скрипта..." local latest_ver latest_ver=$(curl -s --max-time 8 "${GITHUB_RAW}" 2>/dev/null | grep '^VERSION=' | grep -oP '"\K[^"]+' || echo "") if [[ -z "${latest_ver}" ]]; then tg_reply "${chat_id}" "❌ Не удалось проверить обновления" elif [[ "${latest_ver}" == "${VERSION}" ]]; then tg_reply "${chat_id}" "✅ Скрипт актуален: v${VERSION}" else tg_reply "${chat_id}" "⬆️ Доступно обновление v${VERSION} → v${latest_ver} Запусти на сервере: sudo bash yurich-panel.sh self-update" fi ;; /admins) local admin_list="• Главный: ${TG_CHAT_ID}" if [[ -n "${TG_ADMINS:-}" ]]; then local IFS=',' for aid in ${TG_ADMINS}; do aid="${aid// /}" admin_list+=" • ${aid}" done fi tg_reply "${chat_id}" "👮 Администраторы: ${admin_list}" ;; /addadmin) local new_admin="${args%% *}" # Защита: только числа, разумная длина if [[ -z "${new_admin}" || ! "${new_admin}" =~ ^[0-9]{5,15}$ ]]; then tg_reply "${chat_id}" "❌ Использование: /addadmin 123456789" return fi if [[ -z "${TG_ADMINS}" ]]; then TG_ADMINS="${new_admin}" else TG_ADMINS="${TG_ADMINS},${new_admin}" fi save_config tg_reply "${chat_id}" "✅ Администратор ${new_admin} добавлен" ;; /deladmin) local del_admin="${args%% *}" if [[ -z "${del_admin}" || ! "${del_admin}" =~ ^[0-9]{5,15}$ ]]; then tg_reply "${chat_id}" "❌ Использование: /deladmin 123456789" return fi TG_ADMINS=$(echo "${TG_ADMINS}" | tr ',' ' ' | grep -Fvx "${del_admin}" | tr ' ' ',' | sed 's/,$//') save_config tg_reply "${chat_id}" "🗑 Администратор ${del_admin} удалён" ;; *) tg_reply_menu "${chat_id}" "❓ Неизвестная команда. Используй /help или кнопки меню." ;; esac } # Основной цикл бота (long polling) cmd_bot() { [[ -z "${TG_TOKEN:-}" ]] && err "Telegram не настроен. Запусти: sudo bash yurich-panel.sh" && return 1 tg_apply_bot_menu_silent info "Запускаю Telegram бот..." info "Бот работает. Нажми Menu или напиши /menu в Telegram." info "Для остановки: Ctrl+C" echo local offset=0 while true; do # Получаем обновления local response response=$(tg_api "getUpdates?offset=${offset}&timeout=30&allowed_updates=%5B%22message%22%5D" -s --max-time 35 \ 2>/dev/null || echo "") if [[ -z "${response}" ]]; then sleep 5 continue fi # Парсим обновления через python3 local updates updates=$(echo "${response}" | python3 -c " import sys, json try: data = json.load(sys.stdin) if not data.get('ok'): sys.exit(0) for u in data.get('result', []): uid = u.get('update_id', 0) msg = u.get('message', {}) chat = msg.get('chat', {}) chat_id = chat.get('id', '') chat_type = chat.get('type', '') from_id = msg.get('from', {}).get('id', '') text = str(msg.get('text') or '') if text: text = text.replace('|', ' ').replace('\\r', ' ').replace('\\n', ' ') print(f'{uid}|{chat_id}|{chat_type}|{from_id}|{text}') except: pass " 2>/dev/null || echo "") while IFS='|' read -r update_id chat_id chat_type from_id text; do [[ -z "${update_id}" ]] && continue # Защита от переполнения if is_canonical_uint_in_range "${update_id}" 0 2147483646; then offset=$(( update_id + 1 )) fi [[ "$chat_type" == "private" ]] || continue tg_handle_command "${chat_id}" "${from_id}" "${text}" done <<< "${updates}" sleep 1 done } # Запуск бота как systemd сервиса install_bot_service() { local script_path="${SCRIPT_PATH:-/usr/local/bin/yurich-panel.sh}" local running_script running_script=$(realpath "$0" 2>/dev/null || echo "") mkdir -p "$(dirname "$script_path")" if [[ -n "$running_script" && -f "$running_script" && "$running_script" != /dev/fd/* && "$running_script" != /proc/* ]]; then if bash -n "$running_script" 2>/dev/null; then install -m 755 "$running_script" "$script_path" 2>/dev/null || true fi fi if [[ ! -f "$script_path" ]]; then err "Не найден ${script_path}. Сначала обнови скрипт: sudo bash yurich-panel.sh self-update" return 1 fi chmod +x "$script_path" 2>/dev/null || true if [[ -n "${LEGACY_SCRIPT_PATH:-}" && "$script_path" != "$LEGACY_SCRIPT_PATH" ]]; then install -m 755 "$script_path" "$LEGACY_SCRIPT_PATH" 2>/dev/null || true fi cat > /etc/systemd/system/naiveproxy-bot.service << EOF [Unit] Description=Yurich Panel Telegram Bot After=network-online.target caddy.service Wants=network-online.target StartLimitIntervalSec=60 StartLimitBurst=5 [Service] Type=simple ExecStart=/bin/bash ${script_path} bot Restart=always RestartSec=10 User=root UMask=0077 NoNewPrivileges=true PrivateTmp=true KeyringMode=private LimitNOFILE=65536 TasksMax=512 TimeoutStopSec=20s [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable naiveproxy-bot --quiet tg_apply_bot_menu || warn "Команды Telegram Menu можно применить позже: sudo bash yurich-panel.sh bot-menu" systemctl restart naiveproxy-bot ok "Telegram бот установлен как системный сервис" ok "Статус: systemctl status naiveproxy-bot" } # Обёртка tg_send_stats_to для отправки конкретному chat_id tg_send_stats_to() { local target_chat="$1" local caddy_ver caddy_ver=$(/usr/local/bin/caddy version 2>/dev/null | head -1 | awk '{print $1}' || echo "н/д") local caddy_status="🔴 Остановлен" systemctl is-active caddy &>/dev/null && caddy_status="🟢 Работает" local cert_days="н/д" if [[ -n "${DOMAIN:-}" ]]; then local not_after expire_ts not_after=$(echo | timeout 5 openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null | grep "notAfter" | cut -d= -f2 || echo "") if [[ -n "${not_after}" ]]; then expire_ts=$(date -d "${not_after}" +%s 2>/dev/null || echo 0) cert_days=$(( (expire_ts - $(date +%s)) / 86400 )) fi fi local haproxy_raw="" haproxy_block="" if edge_routing_mode_is_haproxy; then haproxy_raw=$(haproxy_stats_text 2>/dev/null | head -n 18 || true) if [[ -n "$haproxy_raw" ]]; then haproxy_block=$'\n\n🔀 HAProxy SNI mux\n
'"$(html_escape_text "$haproxy_raw")"$'
' fi fi tg_reply "${target_chat}" "📊 Статистика Yurich Panel 🌐 Домен: ${DOMAIN:-н/д} 📡 Статус: ${caddy_status} 📦 Caddy: ${caddy_ver} 👥 Пользователей: $(get_users | wc -l) 🖥 Сервер: $(hostname) 💾 RAM: $(free -h | awk '/Mem:/{print $3"/"$2}') 💿 Диск: $(df -h / | awk 'NR==2{print $3"/"$2" ("$5")"}') ⚡ CPU: $(uptime | awk -F'load average:' '{print $2}' | awk '{print $1}' | tr -d ',') 🔐 Сертификат: ${cert_days} дней 🕐 $(date '+%Y-%m-%d %H:%M:%S')${haproxy_block}" } # ══════════════════════════════════════════════════════════════ # YURICH DNS (Unbound recursive resolver for VPN clients) # ══════════════════════════════════════════════════════════════ DNS_CONF="/etc/unbound/unbound.conf.d/yurich-dns.conf" DNS_BLOCKLIST_CONF="/etc/unbound/unbound.conf.d/yurich-dns-blocklist.conf" DNS_ALLOWLIST_FILE="/etc/naiveproxy/dns-allowlist.txt" DNS_FILTER_STATE_FILE="/etc/naiveproxy/dns-filter.state" DNS_FILTER_CRON="/etc/cron.d/yurich-dns-filter" DNS_FILTER_MONITOR_CRON="/etc/cron.d/yurich-dns-monitor" DNS_FILTER_LOG="/var/log/yurich-dns-filter.log" DNS_FILTER_MONITOR_LOG="/var/log/yurich-dns-monitor.log" DNS_FILTER_URLS_DEFAULT="https://urlhaus.abuse.ch/downloads/hostfile/ https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt" DNS_FILTER_MAX_DOMAINS_DEFAULT="200000" DNS_LEGACY_OLD_DNS_CONF="/etc/unbound/unbound.conf.d/aurum-vpn.conf" DNS_LEGACY_CONF="/etc/unbound/unbound.conf.d/naiveproxy-dns.conf" DNS_LEGACY_BLOCKLIST="/etc/unbound/blocklist.conf" DNS_LEGACY_WHITELIST="/etc/unbound/whitelist.txt" DNS_RESOLVED_NO_STUB="/etc/systemd/resolved.conf.d/no-stub.conf" DNS_GATEWAY_SERVICE="/etc/systemd/system/yurich-dns-gateway.service" DNS_LEGACY_GATEWAY_SERVICE="/etc/systemd/system/aurum-dns-gateway.service" DNS_DEFAULT_GATEWAY_IP="10.0.0.1" DNS_DEFAULT_VPN_CIDRS="10.0.0.0/24" DNS_STATS_FILE="/etc/naiveproxy/dns_stats" DNS_LAST_CONFIG_BACKUP="" is_valid_ipv4() { local ip="$1" part local -a oct [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 IFS='.' read -r -a oct <<< "$ip" for part in "${oct[@]}"; do [[ "$part" =~ ^[0-9]+$ ]] || return 1 [[ "$part" == "0" || "$part" != 0* ]] || return 1 (( 10#$part <= 255 )) || return 1 done } is_valid_cidr4() { local cidr="$1" ip part local -a oct [[ "$cidr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]] || return 1 ip="${cidr%/*}" IFS='.' read -r -a oct <<< "$ip" for part in "${oct[@]}"; do [[ "$part" =~ ^[0-9]+$ ]] || return 1 [[ "$part" == "0" || "$part" != 0* ]] || return 1 (( 10#$part <= 255 )) || return 1 done } is_private_vpn_ipv4() { local ip="$1" a b is_valid_ipv4 "$ip" || return 1 IFS='.' read -r a b _ _ <<< "$ip" case "$a" in 10) return 0 ;; 172) (( 10#$b >= 16 && 10#$b <= 31 )) ;; 192) (( 10#$b == 168 )) ;; 100) (( 10#$b >= 64 && 10#$b <= 127 )) ;; 127) [[ "$ip" == "127.0.0.1" ]] ;; *) return 1 ;; esac } is_allowed_vpn_cidr4() { local cidr="$1" ip mask a b is_valid_cidr4 "$cidr" || return 1 ip="${cidr%/*}" mask="${cidr#*/}" IFS='.' read -r a b _ _ <<< "$ip" case "$a" in 10) (( mask >= 8 )) ;; 172) (( 10#$b >= 16 && 10#$b <= 31 && mask >= 12 )) ;; 192) (( 10#$b == 168 && mask >= 16 )) ;; 100) (( 10#$b >= 64 && 10#$b <= 127 && mask >= 10 )) ;; *) [[ "${YURICH_DNS_ALLOW_PUBLIC_CIDRS:-0}" == "1" && "$mask" -ge 24 ]] ;; esac } normalize_cidr_list() { local raw="$1" item out="" count=0 local -a cidrs raw="${raw// /}" IFS=',' read -ra cidrs <<< "$raw" for item in "${cidrs[@]}"; do [[ -z "$item" ]] && continue if ! is_valid_cidr4 "$item"; then err "Некорректная VPN CIDR подсеть: $item" return 1 fi if ! is_allowed_vpn_cidr4 "$item"; then err "Небезопасная VPN CIDR: $item. Разрешены private/CGNAT сети; публичные требуют YURICH_DNS_ALLOW_PUBLIC_CIDRS=1 и /24 или уже." return 1 fi count=$((count + 1)) if (( count > 32 )); then err "Слишком много VPN CIDR (максимум 32)" return 1 fi out="${out},${item}" done if [[ -z "$out" ]]; then err "Нужна хотя бы одна VPN CIDR подсеть" return 1 fi printf '%s\n' "${out#,}" } unbound_mode_label() { echo "Yurich recursive DNSSEC" } dns_config_backup() { local file="$1" [[ -f "$file" ]] || return 0 cp -a "$file" "${file}.bak.$(date '+%Y%m%d-%H%M%S')" 2>/dev/null || true } get_unbound_vpn_bind_ips() { command -v ip >/dev/null 2>&1 || return 0 ip -o -4 addr show up 2>/dev/null \ | awk '{split($4, a, "/"); if (a[1] != "" && a[1] !~ /^127\./ && a[1] !~ /^169\.254\./) print a[1]}' \ | sort -u } ip_is_on_server() { local ip_addr="$1" [[ "$ip_addr" == "127.0.0.1" ]] && return 0 get_unbound_vpn_bind_ips | grep -Fxq "$ip_addr" } ensure_managed_dns_gateway() { local gateway_ip="$1" local ip_bin is_valid_ipv4 "$gateway_ip" || return 1 ip_bin=$(command -v ip || echo "/usr/sbin/ip") cat > "$DNS_GATEWAY_SERVICE" </dev/null || true' [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now "$(basename "$DNS_GATEWAY_SERVICE")" >/dev/null 2>&1 || { err "Не смог запустить $(basename "$DNS_GATEWAY_SERVICE")" journalctl -u "$(basename "$DNS_GATEWAY_SERVICE")" -n 20 --no-pager || true return 1 } ip addr show dev lo | grep -q "${gateway_ip}/32" || { err "Gateway IP ${gateway_ip} не появился на lo" return 1 } ok "Локальный DNS gateway поднят: ${gateway_ip}/32 на lo" } remove_managed_dns_gateway() { systemctl disable --now "$(basename "$DNS_GATEWAY_SERVICE")" >/dev/null 2>&1 || true systemctl disable --now "$(basename "$DNS_LEGACY_GATEWAY_SERVICE")" >/dev/null 2>&1 || true rm -f "$DNS_GATEWAY_SERVICE" "$DNS_LEGACY_GATEWAY_SERVICE" 2>/dev/null || true systemctl daemon-reload 2>/dev/null || true } prepare_unbound_gateway_ip() { local gateway_ip="$1" local ans if ! is_private_vpn_ipv4 "$gateway_ip" && [[ "${YURICH_DNS_ALLOW_PUBLIC_GATEWAY:-0}" != "1" ]]; then err "Публичный DNS gateway запрещён без YURICH_DNS_ALLOW_PUBLIC_GATEWAY=1: $gateway_ip" return 1 fi if ip_is_on_server "$gateway_ip"; then UNBOUND_MANAGED_GATEWAY="0" return 0 fi warn "IP ${gateway_ip} не найден на интерфейсах сервера." echo -e "${CYAN}Могу создать безопасный локальный DNS gateway ${gateway_ip}/32 на lo.${RESET}" echo -e "${DIM}Это нужно, чтобы Unbound мог слушать DNS для клиентских TUN/VPN конфигов. Наружу DNS всё равно не открывается.${RESET}" if [[ -t 0 ]]; then echo -ne "${YELLOW}Создать автоматически? [Y/n]: ${RESET}" read -r ans else ans="y" fi if [[ "${ans,,}" == "n" ]]; then err "Gateway не создан. Сначала подними VPN-интерфейс с IP ${gateway_ip} или разреши авто-gateway." return 1 fi ensure_managed_dns_gateway "$gateway_ip" || return 1 UNBOUND_MANAGED_GATEWAY="1" } detect_private_dns_gateway() { get_unbound_vpn_bind_ips \ | awk '/^10\./ || /^192\.168\./ || /^172\.(1[6-9]|2[0-9]|3[0-1])\./ { print; exit }' } disable_resolved_stub_if_needed() { if ! command -v systemctl >/dev/null 2>&1 || ! systemctl cat systemd-resolved.service >/dev/null 2>&1; then return 0 fi if port53_listeners | grep -qi 'systemd-resolve'; then info "Порт 53 занят systemd-resolved stub, отключаю DNSStubListener..." mkdir -p "$(dirname "$DNS_RESOLVED_NO_STUB")" dns_config_backup "$DNS_RESOLVED_NO_STUB" cat > "$DNS_RESOLVED_NO_STUB" <<'EOF' [Resolve] DNSStubListener=no EOF systemctl restart systemd-resolved 2>/dev/null || true sleep 1 ok "systemd-resolved DNSStubListener отключён без изменения /etc/resolv.conf" fi } port53_listeners() { ss -H -lntup 2>/dev/null | awk '$5 ~ /:53$/ || $5 ~ /:53%/ {print}' } check_port53_for_yurich_dns() { local conflicts conflicts=$(port53_listeners | grep -Ev 'unbound|systemd-resolve|systemd-resolved' || true) if [[ -n "$conflicts" ]]; then err "Порт 53 занят другим сервисом. DNS (Unbound) не будет ломать его автоматически:" echo "$conflicts" return 1 fi } cleanup_legacy_dns_files() { local file for file in "$DNS_LEGACY_OLD_DNS_CONF" "$DNS_LEGACY_CONF" "$DNS_LEGACY_BLOCKLIST" "$DNS_LEGACY_WHITELIST"; do if [[ -f "$file" ]]; then dns_config_backup "$file" rm -f "$file" fi done } ensure_dns_allowlist_file() { mkdir -p "$(dirname "$DNS_ALLOWLIST_FILE")" if [[ ! -f "$DNS_ALLOWLIST_FILE" ]]; then cat > "$DNS_ALLOWLIST_FILE" <<'EOF' # Yurich DNS allowlist. # Один домен на строку. Пример: # example.com EOF chmod 600 "$DNS_ALLOWLIST_FILE" 2>/dev/null || true fi } write_dns_blocklist_disabled() { mkdir -p "$(dirname "$DNS_BLOCKLIST_CONF")" cat > "$DNS_BLOCKLIST_CONF" <<'EOF' # Yurich DNS filtering disabled. server: EOF chmod 644 "$DNS_BLOCKLIST_CONF" 2>/dev/null || true } dns_filter_state_set() { local status="$1" count="${2:-0}" message="${3:-}" mkdir -p "$(dirname "$DNS_FILTER_STATE_FILE")" { printf 'STATUS=%q\n' "$status" printf 'COUNT=%q\n' "$count" printf 'UPDATED_AT=%q\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'MESSAGE=%q\n' "$message" } > "$DNS_FILTER_STATE_FILE" chmod 600 "$DNS_FILTER_STATE_FILE" 2>/dev/null || true } dns_filter_state_value() { local key="$1" [[ -f "$DNS_FILTER_STATE_FILE" ]] || return 1 awk -F= -v k="$key" '$1 == k {print substr($0, index($0, "=") + 1); exit}' "$DNS_FILTER_STATE_FILE" \ | sed "s/^'//; s/'$//" } generate_dns_blocklist_conf() { local out_file="$1" max_domains="${UNBOUND_FILTER_MAX_DOMAINS:-$DNS_FILTER_MAX_DOMAINS_DEFAULT}" urls="${UNBOUND_FILTER_URLS:-$DNS_FILTER_URLS_DEFAULT}" local workdir source_file source_size idx=0 url max_source_bytes=26214400 is_canonical_uint_in_range "$max_domains" 1000 500000 || max_domains="$DNS_FILTER_MAX_DOMAINS_DEFAULT" if ! import_https_url_list_is_safe "$urls"; then warn "UNBOUND_FILTER_URLS содержит небезопасные URL, использую встроенный список" urls="$DNS_FILTER_URLS_DEFAULT" fi workdir=$(mktemp -d /tmp/yurich-dns-filter-XXXXXX) ensure_dns_allowlist_file for url in $urls; do idx=$((idx + 1)) source_file="${workdir}/source-${idx}.txt" if ! curl -fsSL --connect-timeout 10 --max-time 45 --max-filesize "$max_source_bytes" -o "$source_file" -- "$url"; then warn "DNS filter source недоступен: $url" >&2 rm -f "$source_file" continue fi source_size=$(stat -c '%s' "$source_file" 2>/dev/null || echo 0) if [[ ! "$source_size" =~ ^[0-9]+$ || "$source_size" -gt "$max_source_bytes" ]]; then warn "DNS filter source превышает лимит 25 MiB: $url" >&2 rm -f "$source_file" fi done if ! ls "${workdir}"/source-*.txt >/dev/null 2>&1; then rm -rf "$workdir" err "Не удалось скачать ни один DNS blocklist source" return 1 fi if ! command -v python3 >/dev/null 2>&1; then rm -rf "$workdir" err "python3 не найден, не могу безопасно собрать DNS blocklist" return 1 fi python3 - "$out_file" "$DNS_ALLOWLIST_FILE" "$max_domains" "${workdir}"/source-*.txt <<'PY' import ipaddress import re import sys from pathlib import Path out_path = Path(sys.argv[1]) allow_path = Path(sys.argv[2]) limit = int(sys.argv[3]) sources = [Path(p) for p in sys.argv[4:]] domain_re = re.compile(r"^(?=.{1,253}$)([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)+[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$") def clean_token(token: str) -> str: token = token.strip().lower() if not token: return "" token = token.split("#", 1)[0].strip() token = token.strip('"').strip("'").strip() token = token.removeprefix("address=/").split("/", 1)[0] if token.startswith("address=/") else token token = token.removeprefix("server=/").split("/", 1)[0] if token.startswith("server=/") else token token = token.removeprefix("||") token = token.removeprefix("|") token = token.removeprefix("*.") token = token.removeprefix(".") token = token.replace("\\.", ".") token = token.split("^", 1)[0] token = token.split("/", 1)[0] token = token.rstrip(".") if token.startswith("www."): token = token[4:] return token def is_ip(token: str) -> bool: try: ipaddress.ip_address(token) return True except ValueError: return False def valid_domain(token: str) -> bool: if not token or " " in token or ":" in token or token in {"localhost", "local", "broadcasthost"}: return False if is_ip(token): return False if not domain_re.match(token): return False tld = token.rsplit(".", 1)[-1] return not tld.isdigit() def extract_domains(line: str): original = line.strip() if not original or original.startswith(("#", "!", ";")): return [] if "local-zone:" in original: match = re.search(r'local-zone:\s*"([^"]+)"', original) return [clean_token(match.group(1))] if match else [] line = original.replace("\t", " ") parts = [p for p in line.split(" ") if p] if len(parts) >= 2: first = clean_token(parts[0]) second = clean_token(parts[1]) if is_ip(first): return [second] if is_ip(second): return [first] return [clean_token(parts[0])] allow = set() if allow_path.exists(): for line in allow_path.read_text(encoding="utf-8", errors="ignore").splitlines(): value = clean_token(line) if valid_domain(value): allow.add(value) def allowed(domain: str) -> bool: return any(domain == item or domain.endswith("." + item) for item in allow) domains = set() for source in sources: try: lines = source.open("r", encoding="utf-8", errors="ignore") except OSError: continue with lines: for line in lines: for domain in extract_domains(line): if valid_domain(domain) and not allowed(domain): domains.add(domain) if len(domains) >= limit: break if len(domains) >= limit: break if len(domains) >= limit: break if len(domains) < 100: raise SystemExit(f"too few domains parsed: {len(domains)}") with out_path.open("w", encoding="utf-8") as out: out.write("# Yurich DNS security blocklist. Generated automatically.\n") out.write("# Sources are configured in UNBOUND_FILTER_URLS.\n") out.write("server:\n") for domain in sorted(domains): out.write(f' local-zone: "{domain}" always_nxdomain\n') print(len(domains)) PY local rc=$? rm -rf "$workdir" return "$rc" } cmd_dns_update() { load_config hr echo -e "${BOLD} [DNS] Обновление DNS security filter${RESET}" hr if [[ "${UNBOUND_FILTER_ENABLED:-1}" != "1" ]]; then write_dns_blocklist_disabled dns_filter_state_set "disabled" "0" "filter disabled" restart_unbound_checked || return 1 ok "DNS filter выключен" return 0 fi if ! command -v unbound >/dev/null 2>&1; then err "unbound не установлен" return 1 fi mkdir -p "$(dirname "$DNS_BLOCKLIST_CONF")" local tmp_conf backup_conf count old_count tmp_conf=$(mktemp /tmp/yurich-dns-blocklist-XXXXXX.conf) if ! count=$(generate_dns_blocklist_conf "$tmp_conf"); then rm -f "$tmp_conf" dns_filter_state_set "failed" "0" "download or parse failed" return 1 fi backup_conf="" if [[ -f "$DNS_BLOCKLIST_CONF" ]]; then backup_conf="${DNS_BLOCKLIST_CONF}.bak.$(date '+%Y%m%d-%H%M%S')" cp -a "$DNS_BLOCKLIST_CONF" "$backup_conf" 2>/dev/null || true fi install -m 644 "$tmp_conf" "$DNS_BLOCKLIST_CONF" rm -f "$tmp_conf" if ! unbound-checkconf >/tmp/yurich-dns-filter-check.out 2>&1; then [[ -n "$backup_conf" && -f "$backup_conf" ]] && cp -a "$backup_conf" "$DNS_BLOCKLIST_CONF" err "Новый DNS blocklist не прошёл unbound-checkconf, откатил" cat /tmp/yurich-dns-filter-check.out 2>/dev/null || true rm -f /tmp/yurich-dns-filter-check.out dns_filter_state_set "failed" "$count" "unbound-checkconf failed" return 1 fi rm -f /tmp/yurich-dns-filter-check.out restart_unbound_checked || { [[ -n "$backup_conf" && -f "$backup_conf" ]] && cp -a "$backup_conf" "$DNS_BLOCKLIST_CONF" && systemctl restart unbound 2>/dev/null || true dns_filter_state_set "failed" "$count" "unbound restart failed" return 1 } old_count=$(dns_filter_state_value COUNT 2>/dev/null || true) dns_filter_state_set "ok" "$count" "security filter updated" UNBOUND_FILTER_ENABLED="1" UNBOUND_ADBLOCK="0" save_config ok "DNS security filter обновлён: ${count} доменов" [[ -n "$old_count" && "$old_count" != "$count" ]] && info "Предыдущий размер: ${old_count}" return 0 } cmd_dns_filter_install() { load_config UNBOUND_FILTER_ENABLED="1" UNBOUND_FILTER_URLS="${UNBOUND_FILTER_URLS:-$DNS_FILTER_URLS_DEFAULT}" UNBOUND_FILTER_MAX_DOMAINS="${UNBOUND_FILTER_MAX_DOMAINS:-$DNS_FILTER_MAX_DOMAINS_DEFAULT}" save_config cmd_dns_update || return 1 cat > "$DNS_FILTER_CRON" <> ${DNS_FILTER_LOG} 2>&1 EOF chmod 644 "$DNS_FILTER_CRON" touch "$DNS_FILTER_LOG" chmod 600 "$DNS_FILTER_LOG" ok "DNS filter cron включён: $DNS_FILTER_CRON" } dns_monitor_resolve_ok() { local domain="${1:-cloudflare.com}" server="${2:-127.0.0.1}" _attempt for _attempt in 1 2 3; do dig @"$server" "$domain" A +time=3 +tries=1 >/dev/null 2>&1 && return 0 sleep 2 done return 1 } dns_monitor_resolve_tcp_ok() { local domain="${1:-cloudflare.com}" server="${2:-127.0.0.1}" _attempt for _attempt in 1 2 3; do dig @"$server" "$domain" A +tcp +time=3 +tries=1 >/dev/null 2>&1 && return 0 sleep 2 done return 1 } dns_monitor_query_time_ms() { local domain="${1:-cloudflare.com}" server="${2:-127.0.0.1}" out out=$(dig @"$server" "$domain" A +stats +time=3 +tries=1 2>/dev/null) || return 1 awk '/Query time:/ {print $4; exit}' <<<"$out" } dns_monitor_block_ok() { local domain="$1" _attempt [[ -n "$domain" ]] || return 1 for _attempt in 1 2 3; do if dig @127.0.0.1 "$domain" A +time=3 +tries=1 2>/dev/null | grep -q "status: NXDOMAIN"; then return 0 fi sleep 2 done return 1 } cmd_dns_monitor() { load_config local failed=0 status count updated_at message public_53 first_blocked gateway_ip fail_details="" local domain ms valid_status invalid_status latency_warn=0 latency_warn_details="" dns_latency_warn_ms="${DNS_MONITOR_LATENCY_WARN_MS:-250}" dns_latency_fail_ms="${DNS_MONITOR_LATENCY_FAIL_MS:-1500}" local alert_repeat="${DNS_MONITOR_ALERT_REPEAT:-3}" alert_cooldown_minutes="${DNS_MONITOR_ALERT_COOLDOWN_MINUTES:-180}" local alert_state="${DNS_MONITOR_ALERT_STATE:-$CONFIG_DIR/dns-monitor-alert.state}" recovery_alert="${DNS_MONITOR_RECOVERY_ALERT:-1}" local fingerprint state_line state_a state_b state_c state_d prev_count=0 prev_sent=0 prev_fingerprint="" local issue_count=1 now_ts cooldown_sec should_alert=0 last_sent=0 safe_fail_details is_canonical_uint_in_range "$dns_latency_warn_ms" 1 600000 || dns_latency_warn_ms=250 is_canonical_uint_in_range "$dns_latency_fail_ms" 1 600000 || dns_latency_fail_ms=1500 is_canonical_uint_in_range "$alert_repeat" 1 10 || alert_repeat=3 is_canonical_uint_in_range "$alert_cooldown_minutes" 5 10080 || alert_cooldown_minutes=180 if systemctl is-active --quiet unbound 2>/dev/null; then ok "Unbound active" else err "Unbound не active" failed=$((failed + 1)) fail_details+=$'- Unbound service is not active\n' fi if dns_monitor_resolve_ok cloudflare.com; then ok "DNS resolve OK" else err "DNS resolve failed" failed=$((failed + 1)) fail_details+=$'- Local DNS resolve failed on 127.0.0.1\n' fi valid_status=$(dig @127.0.0.1 sigok.verteiltesysteme.net A +time=4 +tries=1 2>/dev/null | awk -F'status: ' '/status:/ {split($2,a,","); print a[1]; exit}' || true) invalid_status=$(dig @127.0.0.1 dnssec-failed.org A +time=4 +tries=1 2>/dev/null | awk -F'status: ' '/status:/ {split($2,a,","); print a[1]; exit}' || true) if [[ "$valid_status" == "NOERROR" && "$invalid_status" == "SERVFAIL" ]]; then ok "DNSSEC validation OK" else err "DNSSEC validation failed: valid=${valid_status:-none}, invalid=${invalid_status:-none}" failed=$((failed + 1)) fail_details+="- DNSSEC validation failed: valid=${valid_status:-none}, invalid=${invalid_status:-none}"$'\n' fi if unbound-checkconf -o serve-expired "$DNS_CONF" 2>/dev/null | grep -qi '^yes$'; then ok "DNS stale-cache resilience enabled" else err "Unbound serve-expired is disabled" failed=$((failed + 1)) fail_details+=$'- Unbound serve-expired is disabled\n' fi for domain in cloudflare.com google.com telegram.org github.com; do if ms=$(dns_monitor_query_time_ms "$domain"); then if is_canonical_uint_in_range "$ms" 0 600000; then if (( ms >= dns_latency_fail_ms )); then err "DNS latency failed: ${domain} ${ms}ms" failed=$((failed + 1)) fail_details+="- Local DNS latency is critical: ${domain} ${ms}ms"$'\n' elif (( ms >= dns_latency_warn_ms )); then warn "DNS latency slow: ${domain} ${ms}ms" latency_warn=1 latency_warn_details+="- ${domain}: ${ms}ms"$'\n' else ok "DNS latency OK: ${domain} ${ms}ms" fi else warn "DNS latency unknown: ${domain}" latency_warn=1 latency_warn_details+="- ${domain}: unknown"$'\n' fi else err "DNS latency query failed: ${domain}" failed=$((failed + 1)) fail_details+="- Local DNS latency query failed: ${domain}"$'\n' fi done public_53=$(ss -H -lntup 2>/dev/null | awk -v gw="${UNBOUND_GATEWAY_IP:-${DNS_DEFAULT_GATEWAY_IP:-10.0.0.1}}" ' $5 ~ /:53$/ { endpoint=$5 if (endpoint ~ /127\.0\.0\.1:53$/) next if (gw != "" && endpoint == (gw ":53")) next print }' || true) if [[ -n "$public_53" ]]; then err "Порт 53 слушает не только loopback/VPN gateway:" echo "$public_53" failed=$((failed + 1)) fail_details+=$'- Port 53 has unexpected public listener\n' else ok "DNS не выглядит публично открытым" fi if [[ "${UNBOUND_VPN_ENABLED:-0}" == "1" ]]; then gateway_ip="${UNBOUND_GATEWAY_IP:-}" if [[ -z "$gateway_ip" ]]; then err "VPN DNS включён, но UNBOUND_GATEWAY_IP не задан" failed=$((failed + 1)) fail_details+=$'- VPN DNS is enabled but UNBOUND_GATEWAY_IP is empty\n' else if ip_is_on_server "$gateway_ip"; then ok "VPN DNS gateway IP есть на интерфейсе: $gateway_ip" else warn "VPN DNS gateway IP не найден на интерфейсах: $gateway_ip" fi if dns_monitor_resolve_ok cloudflare.com "$gateway_ip"; then ok "VPN DNS gateway UDP resolve OK: $gateway_ip" else err "VPN DNS gateway UDP resolve failed: $gateway_ip" failed=$((failed + 1)) fail_details+="- VPN DNS gateway UDP resolve failed: ${gateway_ip}"$'\n' fi if dns_monitor_resolve_tcp_ok cloudflare.com "$gateway_ip"; then ok "VPN DNS gateway TCP resolve OK: $gateway_ip" else err "VPN DNS gateway TCP resolve failed: $gateway_ip" failed=$((failed + 1)) fail_details+="- VPN DNS gateway TCP resolve failed: ${gateway_ip}"$'\n' fi fi fi if [[ -f "$DNS_FILTER_STATE_FILE" ]]; then status=$(dns_filter_state_value STATUS 2>/dev/null || true) count=$(dns_filter_state_value COUNT 2>/dev/null || true) updated_at=$(dns_filter_state_value UPDATED_AT 2>/dev/null || true) message=$(dns_filter_state_value MESSAGE 2>/dev/null || true) [[ "$status" == "ok" ]] && ok "DNS filter OK: ${count:-0} доменов, ${updated_at:-unknown}" || { warn "DNS filter state: ${status:-unknown} ${message:-}"; failed=$((failed + 1)); fail_details+="- DNS filter state is ${status:-unknown}: ${message:-empty message}"$'\n'; } else warn "DNS filter state отсутствует" failed=$((failed + 1)) fail_details+=$'- DNS filter state file is missing\n' fi first_blocked=$(awk -F'"' '/local-zone:/ {print $2; exit}' "$DNS_BLOCKLIST_CONF" 2>/dev/null || true) if [[ -n "$first_blocked" ]]; then if dns_monitor_block_ok "$first_blocked"; then ok "Blocklist test OK: $first_blocked" else warn "Blocklist test не подтвердил NXDOMAIN: $first_blocked" failed=$((failed + 1)) fail_details+="- Blocklist NXDOMAIN test failed: ${first_blocked}"$'\n' fi fi if [[ "$latency_warn" -ne 0 && "$failed" -eq 0 ]]; then warn "DNS latency warning threshold=${dns_latency_warn_ms}ms" printf '%s' "$latency_warn_details" fi if [[ "$failed" -ne 0 ]]; then fingerprint=$(printf '%s\n' "$fail_details" | monitor_issue_fingerprint) if [[ -s "$alert_state" ]]; then state_line=$(head -n1 "$alert_state" 2>/dev/null || true) IFS='|' read -r state_a state_b state_c state_d <<< "$state_line" || true if [[ "$state_a" == "DNSMON1" ]]; then prev_count="$state_b"; prev_sent="$state_c"; prev_fingerprint="$state_d" fi fi is_canonical_uint_in_range "$prev_count" 0 1000000000 || prev_count=0 is_canonical_uint_in_range "$prev_sent" 0 9999999999 || prev_sent=0 if [[ "$prev_fingerprint" == "$fingerprint" ]]; then issue_count=$((prev_count + 1)) fi now_ts=$(date +%s) cooldown_sec=$((alert_cooldown_minutes * 60)) if (( issue_count >= alert_repeat && (prev_sent == 0 || now_ts - prev_sent >= cooldown_sec) )); then should_alert=1 last_sent="$now_ts" else last_sent="$prev_sent" fi { printf 'DNSMON1|%s|%s|%s\n' "$issue_count" "$last_sent" "$fingerprint" > "$alert_state" && chmod 600 "$alert_state"; } 2>/dev/null || true if [[ "$should_alert" -eq 1 ]]; then safe_fail_details=$(html_escape_text "${fail_details:-details unavailable}") tg_send "⚠️ Yurich DNS monitor 📡 Сервер: $(hostname) 🌐 Домен: ${DOMAIN:-unknown} 🔕 Антиспам: повтор ${issue_count}, cooldown ${alert_cooldown_minutes} мин. 🕐 $(date '+%Y-%m-%d %H:%M:%S')
DNS monitor failed=${failed}
${safe_fail_details}
filter=${status:-unknown} count=${count:-0} updated=${updated_at:-unknown}
${message:-}
" else info "DNS alert suppressed: repeat=${issue_count}/${alert_repeat}, cooldown=${alert_cooldown_minutes}m" fi return 1 fi if [[ -s "$alert_state" && "$recovery_alert" == "1" ]]; then state_line=$(head -n1 "$alert_state" 2>/dev/null || true) IFS='|' read -r state_a state_b state_c state_d <<< "$state_line" || true prev_sent=0 [[ "$state_a" == "DNSMON1" ]] && prev_sent="$state_c" is_canonical_uint_in_range "$prev_sent" 0 9999999999 || prev_sent=0 if (( prev_sent > 0 )); then tg_send "✅ Yurich DNS monitor восстановлен 📡 Сервер: $(hostname) 🌐 Домен: ${DOMAIN:-unknown} 🕐 $(date '+%Y-%m-%d %H:%M:%S')
DNS, DNSSEC и security filter снова проходят проверку.
" fi fi rm -f "$alert_state" 2>/dev/null || true } cmd_dns_monitor_install() { load_config normalize_monitor_noise_settings save_config cat > "$DNS_FILTER_MONITOR_CRON" <> ${DNS_FILTER_MONITOR_LOG} 2>&1 EOF chmod 644 "$DNS_FILTER_MONITOR_CRON" touch "$DNS_FILTER_MONITOR_LOG" chmod 600 "$DNS_FILTER_MONITOR_LOG" ok "DNS monitor cron включён: $DNS_FILTER_MONITOR_CRON" } dns_self_public_ipv4() { { curl -s4 --max-time 5 https://ifconfig.me 2>/dev/null \ || curl -s4 --max-time 5 https://api.ipify.org 2>/dev/null \ || curl -s4 --max-time 5 https://checkip.amazonaws.com 2>/dev/null } | tr -d '[:space:]' } dns_probe_open_resolver_once() { local target_ip="$1" domain="${2:-cloudflare.com}" out status out=$(dig @"$target_ip" "$domain" A +time=4 +tries=1 2>/dev/null || true) status=$(awk -F'status: ' '/status:/ {split($2,a,","); print a[1]; exit}' <<<"$out") if grep -Eq '^[^;[:space:]]+[[:space:]]+[0-9]+[[:space:]]+IN[[:space:]]+A[[:space:]]+([0-9]{1,3}\.){3}[0-9]{1,3}' <<<"$out"; then printf 'OPEN|%s\n' "${status:-ANSWER}" return 2 fi case "${status:-}" in REFUSED|SERVFAIL|NXDOMAIN) printf 'CLOSED|%s\n' "$status"; return 0 ;; "") printf 'CLOSED|timeout-or-no-response\n'; return 0 ;; *) printf 'CLOSED|%s\n' "$status"; return 0 ;; esac } cmd_dns_open_resolver_check() { load_config hr echo -e "${BOLD} [DNS] External open-resolver check${RESET}" hr local target_ip="${1:-${DNS_EXTERNAL_TARGET_IP:-}}" domain="${DNS_EXTERNAL_TEST_DOMAIN:-cloudflare.com}" self_result local failed=0 checked=0 skipped=0 node_fail=0 line node_name node_host node_port node_user node_domain node_role node_weight node_enabled remote_cmd result target_q domain_q if [[ -z "$target_ip" ]]; then target_ip=$(dns_self_public_ipv4 || true) fi if ! is_valid_ipv4 "${target_ip:-}"; then err "Не смог определить публичный IPv4 сервера. Передай явно: dns-open-check " return 1 fi echo -e " Target: ${CYAN}${target_ip}:53${RESET}" echo -e " Domain: ${CYAN}${domain}${RESET}" echo info "Self-probe с этого сервера (это не полноценная внешняя проверка):" self_result=$(dns_probe_open_resolver_once "$target_ip" "$domain" || true) case "$self_result" in OPEN*) err "Self-probe получил DNS-ответ: ${self_result}" ;; CLOSED*) ok "Self-probe не получил recursive answer: ${self_result#CLOSED|}" ;; *) warn "Self-probe: ${self_result:-unknown}" ;; esac echo nodes_ensure_file target_q=$(printf '%q' "$target_ip") domain_q=$(printf '%q' "$domain") remote_cmd="target=${target_q}; domain=${domain_q}; if ! command -v dig >/dev/null 2>&1; then echo NO_DIG; exit 3; fi; out=\$(dig @\"\$target\" \"\$domain\" A +time=4 +tries=1 2>/dev/null || true); status=\$(printf '%s\n' \"\$out\" | awk -F'status: ' '/status:/ {split(\$2,a,\",\"); print a[1]; exit}'); if printf '%s\n' \"\$out\" | grep -Eq '^[^;[:space:]]+[[:space:]]+[0-9]+[[:space:]]+IN[[:space:]]+A[[:space:]]+([0-9]{1,3}\\.){3}[0-9]{1,3}'; then echo \"OPEN|\${status:-ANSWER}\"; exit 2; fi; echo \"CLOSED|\${status:-timeout-or-no-response}\"" if [[ "$(nodes_count)" -eq 0 ]]; then warn "Внешних node-точек нет в ${NODES_FILE}. Для настоящей проверки добавь хотя бы одну node." return 0 fi while IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled; do [[ "$node_enabled" == "1" ]] || continue if [[ "$node_host" == "$target_ip" ]]; then skipped=$((skipped + 1)) warn "Пропускаю ${node_name}: host совпадает с target IP" continue fi line="${node_name}|${node_host}|${node_port}|${node_user}|${node_domain}|${node_role}|${node_weight}|${node_enabled}" printf ' %-14s ' "$node_name" if result=$(nodes_ssh "$line" "$remote_cmd" 2>/dev/null); then checked=$((checked + 1)) case "$result" in OPEN*) echo -e "${RED}OPEN${RESET} ${result#OPEN|}"; failed=$((failed + 1)) ;; CLOSED*) echo -e "${GREEN}closed${RESET} ${result#CLOSED|}" ;; NO_DIG*) echo -e "${YELLOW}skip${RESET} dig not installed"; skipped=$((skipped + 1)) ;; *) echo -e "${YELLOW}unknown${RESET} ${result}" ; node_fail=$((node_fail + 1)) ;; esac else echo -e "${YELLOW}unreachable${RESET}" node_fail=$((node_fail + 1)) fi done < <(nodes_list_lines) echo echo -e " Checked: ${CYAN}${checked}${RESET}, skipped: ${CYAN}${skipped}${RESET}, ssh_failed: ${CYAN}${node_fail}${RESET}" if [[ "$failed" -gt 0 ]]; then err "OPEN RESOLVER RISK: ${failed} внешних точек получили DNS-ответ от ${target_ip}:53" return 1 fi ok "Внешний open-resolver не подтверждён" } dns_latency_probe() { local label="${1:-local}" server="${2:-127.0.0.1}" domains="${DNS_LATENCY_DOMAINS:-cloudflare.com google.com telegram.org github.com}" domain cold warm status printf '%-18s %-28s %-8s %-8s %s\n' "LOCATION" "DOMAIN" "COLD" "WARM" "STATUS" for domain in $domains; do cold=$(dns_monitor_query_time_ms "$domain" "$server" 2>/dev/null || echo "-") warm=$(dns_monitor_query_time_ms "$domain" "$server" 2>/dev/null || echo "-") status="ok" if [[ ! "$cold" =~ ^[0-9]+$ || ! "$warm" =~ ^[0-9]+$ ]]; then status="fail" elif (( warm <= cold )); then status="warm-cache" elif (( warm <= 50 )); then status="warm-fast" fi printf '%-18s %-28s %-8s %-8s %s\n' "$label" "$domain" "$cold" "$warm" "$status" done } cmd_dns_latency_local() { load_config 2>/dev/null || true dns_latency_probe "${1:-local}" "${2:-127.0.0.1}" } cmd_dns_latency_report() { load_config hr echo -e "${BOLD} [DNS] Latency / cache-warm report${RESET}" hr echo -e " Domains: ${CYAN}${DNS_LATENCY_DOMAINS:-cloudflare.com google.com telegram.org github.com}${RESET}" echo dns_latency_probe "local" "127.0.0.1" if [[ "${UNBOUND_VPN_ENABLED:-0}" == "1" && -n "${UNBOUND_GATEWAY_IP:-}" ]]; then echo dns_latency_probe "gateway" "$UNBOUND_GATEWAY_IP" fi nodes_ensure_file if [[ "$(nodes_count)" -eq 0 ]]; then echo warn "Node-серверы не добавлены, отчёт только по локальному DNS." return 0 fi local line node_name node_host node_port node_user node_domain node_role node_weight node_enabled remote_cmd result domains_q label_q domains_q=$(printf '%q' "${DNS_LATENCY_DOMAINS:-cloudflare.com google.com telegram.org github.com}") while IFS='|' read -r node_name node_host node_port node_user node_domain node_role node_weight node_enabled; do [[ "$node_enabled" == "1" ]] || continue line="${node_name}|${node_host}|${node_port}|${node_user}|${node_domain}|${node_role}|${node_weight}|${node_enabled}" label_q=$(printf '%q' "$node_name") remote_cmd="label=${label_q}; server=127.0.0.1; domains=${domains_q}; if ! command -v dig >/dev/null 2>&1; then echo NO_DIG; exit 3; fi; printf '%-18s %-28s %-8s %-8s %s\\n' LOCATION DOMAIN COLD WARM STATUS; for domain in \$domains; do cold=\$(dig @\"\$server\" \"\$domain\" A +time=3 +tries=1 +stats 2>/dev/null | awk '/Query time:/ {print \$4; exit}'); warm=\$(dig @\"\$server\" \"\$domain\" A +time=3 +tries=1 +stats 2>/dev/null | awk '/Query time:/ {print \$4; exit}'); cold=\${cold:-'-'}; warm=\${warm:-'-'}; status=ok; if ! printf '%s' \"\$cold\" | grep -Eq '^[0-9]+$' || ! printf '%s' \"\$warm\" | grep -Eq '^[0-9]+$'; then status=fail; elif [ \"\$warm\" -le \"\$cold\" ]; then status=warm-cache; elif [ \"\$warm\" -le 50 ]; then status=warm-fast; fi; printf '%-18s %-28s %-8s %-8s %s\\n' \"\$label\" \"\$domain\" \"\$cold\" \"\$warm\" \"\$status\"; done" echo echo -e "${BOLD}${node_name}${RESET} (${node_user}@${node_host}:${node_port})" if result=$(nodes_ssh "$line" "$remote_cmd" 2>/dev/null); then if [[ "$result" == "NO_DIG"* ]]; then warn "На node нет dig. Установи dnsutils: sudo apt install -y dnsutils" else printf '%s\n' "$result" fi else warn "Не удалось получить latency report с node ${node_name}" fi done < <(nodes_list_lines) } write_unbound_config() { local candidate mkdir -p "$(dirname "$DNS_CONF")" /var/lib/unbound dns_config_backup "$DNS_CONF" if [[ -f "$DNS_CONF" ]]; then DNS_LAST_CONFIG_BACKUP=$(mktemp /tmp/yurich-unbound-config.XXXXXX) cp -a "$DNS_CONF" "$DNS_LAST_CONFIG_BACKUP" else DNS_LAST_CONFIG_BACKUP="absent" fi candidate=$(mktemp "$(dirname "$DNS_CONF")/.yurich-dns.conf.XXXXXX") local vpn_enabled="${UNBOUND_VPN_ENABLED:-0}" local vpn_cidrs="${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}" local gateway_ip="${UNBOUND_GATEWAY_IP:-}" local cidr local -a cidrs cat > "$candidate" <> "$candidate" fi cat >> "$candidate" <<'EOF' port: 53 do-ip4: yes do-ip6: no do-udp: yes do-tcp: yes # Private recursive resolver: keep DNSSEC validation and disable the ECS # subnet cache, which does not interact with prefetch/serve-expired. module-config: "validator iterator" # Never become an open resolver. access-control: 0.0.0.0/0 refuse access-control: 127.0.0.0/8 allow EOF if [[ "$vpn_enabled" == "1" ]]; then IFS=',' read -ra cidrs <<< "$vpn_cidrs" for cidr in "${cidrs[@]}"; do [[ -n "$cidr" ]] && printf ' access-control: %s allow\n' "$cidr" >> "$candidate" done fi cat >> "$candidate" <<'EOF' # Privacy and hardening. hide-identity: yes hide-version: yes harden-glue: yes harden-dnssec-stripped: yes harden-large-queries: yes harden-short-bufsize: yes qname-minimisation: yes aggressive-nsec: yes val-clean-additional: yes # DNSSEC root trust anchor is managed by Ubuntu's Unbound package. # Do not repeat auto-trust-anchor-file here, or Unbound can fail with: # "trust anchor presented twice". root-hints: "/usr/share/dns/root.hints" # Cache and speed. prefetch: yes prefetch-key: yes # RFC 8767 style resilience: answer from validated stale cache while # authoritative servers are temporarily unreachable, then refresh it. serve-expired: yes serve-expired-ttl: 86400 serve-expired-reply-ttl: 30 serve-expired-client-timeout: 1800 num-threads: 2 so-rcvbuf: 256k ip-ratelimit: 200 msg-cache-size: 64m rrset-cache-size: 128m cache-min-ttl: 300 cache-max-ttl: 86400 # Logs are off by default to preserve privacy. log-queries: no statistics-interval: 0 verbosity: 1 # Recursive mode: no Google/Cloudflare forwarders here. # Unbound queries root and authoritative DNS servers directly. EOF install -m 644 "$candidate" "$DNS_CONF" rm -f "$candidate" chown -R unbound:unbound /var/lib/unbound 2>/dev/null || true } rollback_unbound_config() { if [[ "$DNS_LAST_CONFIG_BACKUP" == "absent" ]]; then rm -f "$DNS_CONF" elif [[ -n "$DNS_LAST_CONFIG_BACKUP" && -f "$DNS_LAST_CONFIG_BACKUP" ]]; then install -m 644 "$DNS_LAST_CONFIG_BACKUP" "$DNS_CONF" fi [[ -n "$DNS_LAST_CONFIG_BACKUP" && "$DNS_LAST_CONFIG_BACKUP" == /tmp/yurich-unbound-config.* ]] && rm -f "$DNS_LAST_CONFIG_BACKUP" DNS_LAST_CONFIG_BACKUP="" systemctl restart unbound >/dev/null 2>&1 || true } restart_unbound_checked() { if ! unbound-checkconf >/dev/null 2>&1; then err "Ошибка конфига Unbound:" unbound-checkconf || true rollback_unbound_config return 1 fi systemctl enable unbound --quiet systemctl reset-failed unbound 2>/dev/null || true systemctl restart unbound sleep 2 if ! systemctl is-active --quiet unbound; then err "Unbound не запустился!" journalctl -u unbound -n 20 --no-pager rollback_unbound_config return 1 fi [[ -n "$DNS_LAST_CONFIG_BACKUP" && "$DNS_LAST_CONFIG_BACKUP" == /tmp/yurich-unbound-config.* ]] && rm -f "$DNS_LAST_CONFIG_BACKUP" DNS_LAST_CONFIG_BACKUP="" } install_yurich_dns_cli_commands() { local script_path="${SCRIPT_PATH:-/usr/local/bin/yurich-panel.sh}" cat > /usr/local/bin/yurich-dns-status < /usr/local/bin/yurich-dns-test < /usr/local/bin/yurich-dns-restart </dev/null || true ln -sf /usr/local/bin/yurich-dns-test /usr/local/bin/aurum-dns-test 2>/dev/null || true ln -sf /usr/local/bin/yurich-dns-restart /usr/local/bin/aurum-dns-restart 2>/dev/null || true } apply_unbound_ufw_rules() { local cidr local -a cidrs command -v ufw >/dev/null 2>&1 || return 0 if [[ "${UNBOUND_VPN_ENABLED:-0}" == "1" ]]; then remove_unbound_ufw_rules IFS=',' read -ra cidrs <<< "${UNBOUND_VPN_CIDRS:-}" for cidr in "${cidrs[@]}"; do [[ -z "$cidr" ]] && continue ufw allow from "$cidr" to any port 53 proto udp comment "Unbound DNS VPN" >/dev/null 2>&1 || true ufw allow from "$cidr" to any port 53 proto tcp comment "Unbound DNS VPN" >/dev/null 2>&1 || true done ok "UFW: DNS открыт только для VPN CIDR: ${UNBOUND_VPN_CIDRS}" fi } remove_unbound_ufw_rules() { local cidr candidates extra_cidrs="${1:-}" local -a cidrs command -v ufw >/dev/null 2>&1 || return 0 candidates="${extra_cidrs},${UNBOUND_VPN_CIDRS:-},10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" IFS=',' read -ra cidrs <<< "$candidates" for cidr in "${cidrs[@]}"; do [[ -z "$cidr" ]] && continue ufw delete allow from "$cidr" to any port 53 proto udp >/dev/null 2>&1 || true ufw delete allow from "$cidr" to any port 53 proto tcp >/dev/null 2>&1 || true done } cmd_dns_install() { hr echo -e "${BOLD} [DNS] Установка Yurich DNS (Unbound)${RESET}" hr info "Обновляю apt cache и устанавливаю зависимости..." apt-get update -qq apt-get install -y -q unbound unbound-anchor dnsutils dns-root-data ca-certificates curl cleanup_legacy_dns_files disable_resolved_stub_if_needed check_port53_for_yurich_dns || return 1 mkdir -p /var/lib/unbound "$(dirname "$DNS_CONF")" unbound-anchor -a /var/lib/unbound/root.key >/dev/null 2>&1 || true echo echo -e "${CYAN}DNS (Unbound) работает как recursive DNSSEC resolver.${RESET}" echo -e "${DIM}Security-фильтр malware/phishing/scam включается отдельным безопасным include-файлом.${RESET}" echo -e "${DIM}Если нужен DNS для VPN-клиентов, укажи IP gateway на VPN-интерфейсе, например 10.0.0.1.${RESET}" local detected_gateway gateway_input vpn_cidrs detected_gateway=$(detect_private_dns_gateway || true) echo -ne "${CYAN}VPN gateway IP [${detected_gateway:-$DNS_DEFAULT_GATEWAY_IP}, 0 = только локально]: ${RESET}" read -r gateway_input gateway_input="${gateway_input:-${detected_gateway:-$DNS_DEFAULT_GATEWAY_IP}}" UNBOUND_MODE="recursive" UNBOUND_ADBLOCK="0" if [[ "$gateway_input" =~ ^(0|local|none|нет)$ ]]; then UNBOUND_GATEWAY_IP="" UNBOUND_MANAGED_GATEWAY="0" UNBOUND_VPN_ENABLED="0" UNBOUND_VPN_CIDRS="${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}" elif [[ -n "$gateway_input" ]]; then if ! is_valid_ipv4 "$gateway_input"; then err "Некорректный IPv4: $gateway_input" return 1 fi prepare_unbound_gateway_ip "$gateway_input" || return 1 UNBOUND_GATEWAY_IP="$gateway_input" UNBOUND_VPN_ENABLED="1" echo -ne "${CYAN}VPN CIDR через запятую [${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}]: ${RESET}" read -r vpn_cidrs UNBOUND_VPN_CIDRS=$(normalize_cidr_list "${vpn_cidrs:-${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}}") || return 1 if [[ "$UNBOUND_VPN_CIDRS" == *"0.0.0.0/0"* ]]; then err "Open resolver запрещён: 0.0.0.0/0 использовать нельзя" return 1 fi else UNBOUND_GATEWAY_IP="" UNBOUND_MANAGED_GATEWAY="0" UNBOUND_VPN_ENABLED="0" UNBOUND_VPN_CIDRS="${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}" fi write_unbound_config restart_unbound_checked || return 1 apply_unbound_ufw_rules install_yurich_dns_cli_commands # Статистика mkdir -p "$(dirname "${DNS_STATS_FILE}")" echo "0" > "${DNS_STATS_FILE}" UNBOUND_ENABLED="1" save_config cmd_dns_filter_install || warn "DNS security filter не включился, базовый Unbound продолжает работать" ok "Yurich DNS (Unbound) установлен!" tg_send "🛡️ Yurich DNS (Unbound) установлен 🖥 Сервер: $(hostname) 🔒 Режим: recursive DNSSEC, gateway=${UNBOUND_GATEWAY_IP:-127.0.0.1}, VPN=${UNBOUND_VPN_ENABLED:-0} 🕐 $(date '+%Y-%m-%d %H:%M:%S')" } cmd_dns_restart() { load_config hr echo -e "${BOLD} [DNS] Restart DNS (Unbound)${RESET}" hr if ! command -v unbound &>/dev/null; then err "unbound не установлен" return 1 fi if [[ "${UNBOUND_VPN_ENABLED:-0}" == "1" && "${UNBOUND_MANAGED_GATEWAY:-0}" == "1" && -n "${UNBOUND_GATEWAY_IP:-}" ]]; then ensure_managed_dns_gateway "$UNBOUND_GATEWAY_IP" || return 1 fi write_unbound_config restart_unbound_checked || return 1 ok "DNS (Unbound) перезапущен" } # Статус и тест DNS (Unbound) cmd_dns_status() { load_config hr echo -e "${BOLD} [DNS] Yurich DNS (Unbound)${RESET}" hr if ! command -v unbound &>/dev/null; then warn "unbound не установлен" return fi if systemctl is-active unbound &>/dev/null; then ok "unbound: запущен" else err "unbound: остановлен" fi echo -e " Режим: ${CYAN}$(unbound_mode_label)${RESET}" echo -e " Local DNS: ${CYAN}127.0.0.1:53${RESET}" if [[ "${UNBOUND_VPN_ENABLED:-0}" == "1" ]]; then echo -e " VPN DNS: ${CYAN}${UNBOUND_GATEWAY_IP:-не задан}:53${RESET} только для ${CYAN}${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}${RESET}" echo -e " Gateway mode: ${CYAN}$([[ "${UNBOUND_MANAGED_GATEWAY:-0}" == "1" ]] && echo "auto lo /32" || echo "server interface")${RESET}" else echo -e " VPN DNS: ${YELLOW}выключен${RESET}" fi if [[ "${UNBOUND_FILTER_ENABLED:-1}" == "1" ]]; then local filter_status filter_count filter_updated filter_status=$(dns_filter_state_value STATUS 2>/dev/null || echo "unknown") filter_count=$(dns_filter_state_value COUNT 2>/dev/null || echo "0") filter_updated=$(dns_filter_state_value UPDATED_AT 2>/dev/null || echo "unknown") echo -e " Security filter: ${CYAN}${filter_status}${RESET}, доменов: ${CYAN}${filter_count}${RESET}, обновлено: ${CYAN}${filter_updated}${RESET}" else echo -e " Security filter: ${YELLOW}выключен${RESET}" fi [[ -f "$DNS_CONF" ]] && unbound-checkconf "$DNS_CONF" >/dev/null 2>&1 && ok "Конфиг Unbound валиден" || warn "Конфиг Unbound не прошёл проверку" if unbound-checkconf -o serve-expired "$DNS_CONF" 2>/dev/null | grep -qi '^yes$'; then ok "Serve-expired включён для устойчивости DNS" else warn "Serve-expired не включён" fi echo info "Порт 53:" port53_listeners || true echo info "Тест DNS-запросов..." local ok_domains=("google.com" "cloudflare.com") for domain in "${ok_domains[@]}"; do local result result=$(dig "@127.0.0.1" "${domain}" +short +time=3 +tries=1 2>/dev/null | head -1) if [[ -n "${result}" ]]; then echo -e " ${GREEN}[OK] ${domain} → ${result}${RESET}" else echo -e " ${RED}[FAIL] ${domain} — не резолвится!${RESET}" fi done echo info "DNSSEC test..." if dig "@127.0.0.1" sigok.verteiltesysteme.net A +time=4 +tries=1 2>/dev/null | grep -q "status: NOERROR"; then echo -e " ${GREEN}[OK] DNSSEC valid domain: OK${RESET}" else echo -e " ${YELLOW}[WARN] DNSSEC valid test не дал NOERROR${RESET}" fi if dig "@127.0.0.1" dnssec-failed.org A +time=4 +tries=1 2>/dev/null | grep -q "status: SERVFAIL"; then echo -e " ${GREEN}[OK] DNSSEC invalid domain отклонён${RESET}" else echo -e " ${YELLOW}[WARN] DNSSEC invalid test не подтвердил SERVFAIL${RESET}" fi echo info "Последние логи Unbound:" journalctl -u unbound -n 20 --no-pager 2>/dev/null || true hr } cmd_dns_set_mode() { load_config hr echo -e "${BOLD} [DNS] DNS (Unbound) mode${RESET}" hr warn "Forward/adblock режимы не используются. DNS работает как recursive resolver, security-фильтр включается отдельно." UNBOUND_MODE="recursive" UNBOUND_ADBLOCK="0" UNBOUND_ENABLED="1" save_config if command -v unbound &>/dev/null; then write_unbound_config restart_unbound_checked || return 1 ok "Режим применён: $(unbound_mode_label)" else info "Unbound ещё не установлен. Запусти меню 17 → 1." fi } cmd_dns_vpn_access() { load_config local old_unbound_vpn_cidrs="${UNBOUND_VPN_CIDRS:-}" local old_unbound_gateway="${UNBOUND_GATEWAY_IP:-}" local old_unbound_managed="${UNBOUND_MANAGED_GATEWAY:-0}" hr echo -e "${BOLD} 🔐 DNS доступ для VPN-клиентов${RESET}" hr echo -e " Сейчас: VPN=${CYAN}${UNBOUND_VPN_ENABLED:-0}${RESET}, gateway=${CYAN}${UNBOUND_GATEWAY_IP:-нет}${RESET}, CIDR=${CYAN}${UNBOUND_VPN_CIDRS:-нет}${RESET}" echo warn "Не включай DNS для 0.0.0.0/0. Скрипт откажется делать open resolver." echo -e " ${BOLD}1)${RESET} Включить / изменить VPN DNS" echo -e " ${BOLD}2)${RESET} Выключить VPN DNS" echo -e " ${BOLD}0)${RESET} Назад" echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "$choice" in 1) local cidrs gateway_input detected_gateway detected_gateway=$(detect_private_dns_gateway || true) echo -ne "${CYAN}VPN gateway IP [${UNBOUND_GATEWAY_IP:-${detected_gateway:-$DNS_DEFAULT_GATEWAY_IP}}]: ${RESET}" read -r gateway_input gateway_input="${gateway_input:-${UNBOUND_GATEWAY_IP:-${detected_gateway:-$DNS_DEFAULT_GATEWAY_IP}}}" if ! is_valid_ipv4 "$gateway_input"; then err "Некорректный IPv4: $gateway_input" return 1 fi if [[ "$old_unbound_managed" == "1" && -n "$old_unbound_gateway" && "$old_unbound_gateway" != "$gateway_input" ]]; then remove_managed_dns_gateway fi prepare_unbound_gateway_ip "$gateway_input" || return 1 echo -ne "${CYAN}VPN CIDR через запятую [${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}]: ${RESET}" read -r cidrs UNBOUND_VPN_CIDRS=$(normalize_cidr_list "${cidrs:-${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}}") || return 1 if [[ "$UNBOUND_VPN_CIDRS" == *"0.0.0.0/0"* ]]; then err "Open resolver запрещён: 0.0.0.0/0 использовать нельзя" return 1 fi UNBOUND_GATEWAY_IP="$gateway_input" UNBOUND_VPN_ENABLED="1" ;; 2) UNBOUND_VPN_ENABLED="0" UNBOUND_GATEWAY_IP="" UNBOUND_MANAGED_GATEWAY="0" remove_unbound_ufw_rules "$old_unbound_vpn_cidrs" [[ "$old_unbound_managed" == "1" ]] && remove_managed_dns_gateway ;; 0) return 0 ;; *) err "Неверный выбор"; return 1 ;; esac UNBOUND_MODE="recursive" UNBOUND_ADBLOCK="0" write_unbound_config restart_unbound_checked || return 1 remove_unbound_ufw_rules "$old_unbound_vpn_cidrs" apply_unbound_ufw_rules save_config [[ "$old_unbound_gateway" != "${UNBOUND_GATEWAY_IP:-}" ]] && info "Gateway изменён: ${old_unbound_gateway:-нет} → ${UNBOUND_GATEWAY_IP:-нет}" ok "VPN DNS настройки применены" } # Совместимость со старым пунктом whitelist. cmd_dns_whitelist() { hr echo -e "${BOLD} [DNS] Allowlist security filter${RESET}" hr ensure_dns_allowlist_file echo -e "Файл allowlist: ${CYAN}${DNS_ALLOWLIST_FILE}${RESET}" echo -e "${DIM}Добавь домен по одному на строку, затем запусти: sudo yurich-panel.sh dns-update${RESET}" sed -n '1,80p' "$DNS_ALLOWLIST_FILE" 2>/dev/null || true } # Удалить DNS (Unbound) cmd_dns_remove() { echo -ne "${YELLOW}Удалить DNS (Unbound) конфиг и команды? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" != "y" ]] && return systemctl stop unbound 2>/dev/null || true systemctl disable unbound 2>/dev/null || true remove_unbound_ufw_rules [[ "${UNBOUND_MANAGED_GATEWAY:-0}" == "1" || -f "$DNS_GATEWAY_SERVICE" ]] && remove_managed_dns_gateway cleanup_legacy_dns_files dns_config_backup "$DNS_CONF" dns_config_backup "$DNS_BLOCKLIST_CONF" rm -f "$DNS_CONF" "$DNS_BLOCKLIST_CONF" "$DNS_LEGACY_OLD_DNS_CONF" rm -f "$DNS_FILTER_CRON" "$DNS_FILTER_MONITOR_CRON" "$DNS_FILTER_STATE_FILE" 2>/dev/null || true rm -f /usr/local/bin/yurich-dns-status /usr/local/bin/yurich-dns-test /usr/local/bin/yurich-dns-restart 2>/dev/null || true rm -f /usr/local/bin/aurum-dns-status /usr/local/bin/aurum-dns-test /usr/local/bin/aurum-dns-restart 2>/dev/null || true if [[ -f "$DNS_RESOLVED_NO_STUB" ]]; then dns_config_backup "$DNS_RESOLVED_NO_STUB" rm -f "$DNS_RESOLVED_NO_STUB" systemctl restart systemd-resolved 2>/dev/null || true fi UNBOUND_ENABLED="0" UNBOUND_GATEWAY_IP="" UNBOUND_MANAGED_GATEWAY="0" UNBOUND_VPN_ENABLED="0" UNBOUND_ADBLOCK="0" UNBOUND_FILTER_ENABLED="0" save_config systemctl daemon-reload 2>/dev/null || true ok "Yurich DNS (Unbound) удалён. Пакеты unbound/dnsutils не удалял." } # ─── Донат ───────────────────────────────────────────────────── cmd_donate() { local support_url="${PROJECT_DONATION_URL:-$PROJECT_GITHUB_URL}" clear 2>/dev/null || true echo echo -e "${BOLD}${GOLD} ╔════════════════════════════════════════════╗${RESET}" echo -e "${BOLD}${GOLD} ║ ПОДДЕРЖАТЬ ПРОЕКТ ║${RESET}" echo -e "${BOLD}${GOLD} ╚════════════════════════════════════════════╝${RESET}" echo echo -e " ${CYAN}Если Yurich Panel помог тебе —${RESET}" echo -e " ${CYAN}поддержи разработку! Это очень мотивирует.${RESET}" echo if [[ -n "${PROJECT_DONATION_URL:-}" ]]; then echo -e " ${BOLD}Ссылка на поддержку:${RESET}" echo -e " ${BOLD}${GOLD}👉 ${PROJECT_DONATION_URL}${RESET}" else echo -e " ${BOLD}Поддержать проект можно через GitHub:${RESET}" echo -e " ${BOLD}${GOLD}👉 ${PROJECT_GITHUB_URL}${RESET}" fi echo echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo -e " ${BOLD}Что даст твой донат:${RESET}" echo -e " ${GREEN}-${RESET} Больше времени на разработку" echo -e " ${GREEN}🐛${RESET} Быстрые фиксы багов" echo -e " ${GREEN}✨${RESET} Новые фичи каждый месяц" echo -e " ${GREEN}📚${RESET} Документация и поддержка" echo -e " ${GREEN}🆕${RESET} Эксклюзив для донатеров в Telegram" echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo echo -e " ${BOLD}Другие способы поддержки:${RESET}" echo -e " ${CYAN}⭐${RESET} Поставь звезду: ${PROJECT_GITHUB_SHORT}" [[ -n "${TELEGRAM_COMMUNITY_URL:-}" ]] && echo -e " ${CYAN}[TG]${RESET} Telegram канал: ${TELEGRAM_COMMUNITY_URL}" [[ -n "${PROJECT_WEBSITE_URL:-}" ]] && echo -e " ${CYAN}[WEB]${RESET} Сайт: ${PROJECT_WEBSITE_URL}" echo -e " ${CYAN}📢${RESET} Расскажи друзьям!" echo echo -e " ${BOLD}${GOLD}Спасибо за поддержку! 🙏${RESET}" echo if command -v qrencode &>/dev/null; then echo -e " ${DIM}QR код:${RESET}" qrencode -t ANSIUTF8 "$support_url" 2>/dev/null | sed 's/^/ /' echo fi echo -ne " ${YELLOW}Enter для возврата в меню...${RESET}" read -r } # Меню DNS (Unbound) cmd_dns_menu() { while true; do load_config hr echo -e "${BOLD} [DNS] Yurich DNS (Unbound)${RESET}" hr local dns_status="${RED}не установлен${RESET}" if command -v unbound &>/dev/null; then dns_status="${YELLOW}установлен, не запущен${RESET}" systemctl is-active --quiet unbound 2>/dev/null && dns_status="${GREEN}активен${RESET}" fi echo -e " Статус: ${dns_status}" echo -e " Режим: ${CYAN}$(unbound_mode_label)${RESET}" echo -e " VPN DNS: ${CYAN}${UNBOUND_VPN_ENABLED:-0}${RESET} | Gateway: ${CYAN}${UNBOUND_GATEWAY_IP:-нет}${RESET} | CIDR: ${CYAN}${UNBOUND_VPN_CIDRS:-$DNS_DEFAULT_VPN_CIDRS}${RESET}" echo -e " Security filter: ${CYAN}${UNBOUND_FILTER_ENABLED:-1}${RESET} | Domains: ${CYAN}$(dns_filter_state_value COUNT 2>/dev/null || echo 0)${RESET}" [[ "${UNBOUND_MANAGED_GATEWAY:-0}" == "1" ]] && echo -e " Gateway mode: ${CYAN}auto lo /32${RESET}" echo echo -e " ${BOLD}1)${RESET} Установить / переустановить DNS (Unbound)" echo -e " ${BOLD}2)${RESET} Настроить DNS для VPN-клиентов" echo -e " ${BOLD}3)${RESET} Статус, порт 53, DNSSEC и тесты" echo -e " ${BOLD}4)${RESET} Перезапустить DNS (Unbound)" echo -e " ${BOLD}5)${RESET} Обновить security-фильтр" echo -e " ${BOLD}6)${RESET} Allowlist security-фильтра" echo -e " ${BOLD}7)${RESET} Включить DNS monitor" echo -e " ${BOLD}8)${RESET} Проверить open-resolver с внешних node" echo -e " ${BOLD}9)${RESET} Latency/cache-warm отчёт по локациям" echo -e " ${BOLD}10)${RESET} Удалить DNS (Unbound)" echo -e " ${BOLD}0)${RESET} Назад" hr echo -ne "${CYAN}Выбор: ${RESET}" read -r choice case "${choice}" in 1) cmd_dns_install ;; 2) cmd_dns_vpn_access ;; 3) cmd_dns_status ;; 4) cmd_dns_restart ;; 5) cmd_dns_filter_install ;; 6) cmd_dns_whitelist ;; 7) cmd_dns_monitor_install ;; 8) cmd_dns_open_resolver_check ;; 9) cmd_dns_latency_report ;; 10) cmd_dns_remove ;; 0) break ;; *) warn "Неверный выбор" ;; esac echo -ne "${YELLOW}Enter для продолжения...${RESET}"; read -r done } cmd_unbound_plugin() { cmd_dns_menu } # ─── СТАТУС ────────────────────────────────────────────────── cmd_status() { hr echo -e "${BOLD} Статус Yurich Panel${RESET}" hr systemctl is-active --quiet caddy 2>/dev/null \ && ok "Caddy: ${GREEN}работает${RESET}" \ || err "Caddy: ${RED}не работает${RESET}" systemctl is-enabled --quiet caddy 2>/dev/null \ && ok "Автозапуск: включён" \ || warn "Автозапуск: выключен" load_config [[ -n "${DOMAIN:-}" ]] && echo -e "\n Домен: $DOMAIN" echo -e " Юзеров: $(get_users | wc -l)" if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then systemctl is-active --quiet hysteria 2>/dev/null \ && ok "Hysteria 2: работает на UDP/${HYSTERIA_PORT:-8443}" \ || warn "Hysteria 2: установлен, но не работает" fi if command -v warp-cli &>/dev/null; then ok "WARP proxy: установлен, порт ${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}" fi if [[ -x "$XRAY_BIN" || -f "$XRAY_CONFIG" ]]; then systemctl is-active --quiet xray 2>/dev/null \ && ok "Xray: работает (fallback 443: ${XRAY_FALLBACK_ENABLED:-0})" \ || warn "Xray: установлен, но не работает" fi if [[ "${DEVICE_LIMIT_ENABLED:-0}" == "1" ]]; then ok "Лимит устройств: ${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT} IP / ${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT} ч (${DEVICE_LIMIT_MODE:-alert})" else warn "Лимит устройств: выключен" fi if command -v unbound &>/dev/null; then systemctl is-active --quiet unbound 2>/dev/null \ && ok "DNS (Unbound): $(unbound_mode_label), gateway=${UNBOUND_GATEWAY_IP:-127.0.0.1}, vpn=${UNBOUND_VPN_ENABLED:-0}" \ || warn "DNS (Unbound): установлен, но не работает" fi check_cert "${DOMAIN:-}" echo info "Последние 10 строк лога:" journalctl -u caddy -n 10 --no-pager 2>/dev/null || true hr } # ─── ПЕРЕЗАПУСК ────────────────────────────────────────────── cmd_restart() { info "Перезапускаю Caddy..." systemctl restart caddy sleep 2 if systemctl is-active --quiet caddy; then ok "Caddy перезапущен" load_config; tg_alert_up else err "Caddy не запустился:" journalctl -u caddy -n 20 --no-pager fi } cmd_reload() { info "Проверяю Caddyfile перед reload..." if ! "$CADDY_BIN" validate --config "$CADDYFILE" >/dev/null 2>&1; then err "Caddyfile содержит ошибку. Reload отменён." "$CADDY_BIN" validate --config "$CADDYFILE" || true return 1 fi "$CADDY_BIN" fmt --overwrite "$CADDYFILE" >/dev/null 2>&1 || true info "Применяю конфиг без полного перезапуска Caddy..." if systemctl reload caddy; then ok "Caddy reload выполнен без разрыва активных соединений" load_config; tg_alert_up else err "Reload не удался. Caddy не перезапускал, смотри лог:" journalctl -u caddy -n 20 --no-pager return 1 fi } # ─── ОБНОВЛЕНИЕ ────────────────────────────────────────────── cmd_update() { hr echo -e "${BOLD} Обновление Caddy${RESET}" hr check_installed || { err "Yurich Panel не установлен"; return 1; } local old_ver old_ver=$("$CADDY_BIN" version 2>/dev/null | head -1 || echo "unknown") info "Текущая версия: $old_ver" backup_config local tmp_caddy_dir tmp_caddy backup_bin="" staging_bin="" restore_stage="" tmp_caddy_dir=$(mktemp -d /tmp/naiveproxy_caddy_XXXXXX) tmp_caddy="${tmp_caddy_dir}/caddy" trap 'rm -rf "${tmp_caddy_dir:-}" 2>/dev/null; rm -f "${backup_bin:-}" "${staging_bin:-}" "${restore_stage:-}" 2>/dev/null; trap - RETURN' RETURN build_caddy "$tmp_caddy" || return 1 if ! "$tmp_caddy" validate --config "$CADDYFILE" >/dev/null 2>&1; then err "Новый Caddy не принимает текущий Caddyfile. Обновление отменено." "$tmp_caddy" validate --config "$CADDYFILE" || true return 1 fi if ! "$tmp_caddy" list-modules 2>/dev/null | grep -Fxq 'http.handlers.forward_proxy'; then err "Новый Caddy не содержит forward_proxy. Обновление отменено." return 1 fi backup_bin=$(mktemp "${CADDY_BIN}.rollback.XXXXXX") || { err "Не удалось создать rollback-файл Caddy"; return 1; } if ! cp -p -- "$CADDY_BIN" "$backup_bin"; then err "Не удалось сохранить текущий бинарник Caddy" return 1 fi staging_bin=$(mktemp "${CADDY_BIN}.update.XXXXXX") || { err "Не удалось создать staging-файл Caddy"; return 1; } if ! install -m 755 "$tmp_caddy" "$staging_bin" || ! mv -f -- "$staging_bin" "$CADDY_BIN"; then err "Не удалось атомарно заменить Caddy" return 1 fi staging_bin="" if ! systemctl restart caddy \ || ! timeout 20s bash -c 'until systemctl is-active --quiet caddy; do sleep 1; done'; then err "Новый Caddy не запустился. Возвращаю предыдущий бинарник." restore_stage=$(mktemp "${CADDY_BIN}.restore.XXXXXX") || true if [[ -n "$restore_stage" ]] \ && install -m 755 "$backup_bin" "$restore_stage" \ && mv -f -- "$restore_stage" "$CADDY_BIN"; then restore_stage="" systemctl restart caddy || true if systemctl is-active --quiet caddy; then ok "Rollback Caddy выполнен, рабочая версия восстановлена" else err "Rollback-файл возвращён, но Caddy не active. Проверь journalctl -u caddy" fi else err "Автоматический rollback Caddy не удался. Бэкап: $backup_bin" backup_bin="" fi journalctl -u caddy -n 40 --no-pager 2>/dev/null || true return 1 fi local new_ver new_ver=$("$CADDY_BIN" version 2>/dev/null | head -1 || echo "unknown") rm -f "$backup_bin" backup_bin="" ok "Обновлено: $old_ver → $new_ver" load_config; tg_alert_updated "$old_ver" "$new_ver" } # ─── УДАЛЕНИЕ ──────────────────────────────────────────────── cmd_remove() { hr echo -e "${BOLD}${RED} Удаление Yurich Panel${RESET}" hr echo -ne "${RED}Удалить всё? [y/N]: ${RESET}" read -r ans [[ "${ans,,}" == "y" ]] || return systemctl stop caddy 2>/dev/null || true systemctl disable caddy 2>/dev/null || true systemctl stop hysteria 2>/dev/null || true systemctl disable hysteria 2>/dev/null || true systemctl stop xray 2>/dev/null || true systemctl disable xray 2>/dev/null || true systemctl stop warp-svc cloudflare-warp >/dev/null 2>&1 || true rm -f "$CADDY_SERVICE" "$CADDY_BIN" "$CADDYFILE" "$HYSTERIA_SERVICE" "$HYSTERIA_BIN" "$HYSTERIA_CONFIG" "$XRAY_SERVICE" "$XRAY_BIN" "$XRAY_CONFIG" "$DEVICE_CRON" [[ -n "${CONFIG_DIR:-}" && "$CONFIG_DIR" != "/" ]] && rm -rf "$CONFIG_DIR" systemctl daemon-reload ufw delete allow 80/tcp >/dev/null 2>&1 || true ufw delete allow 443/tcp >/dev/null 2>&1 || true ufw delete allow 443/udp >/dev/null 2>&1 || true ufw delete allow "${HYSTERIA_PORT:-8443}/udp" >/dev/null 2>&1 || true ufw delete allow "${XRAY_REALITY_PORT:-8444}/tcp" >/dev/null 2>&1 || true ufw delete allow "${XRAY_MKCP_PORT:-8446}/udp" >/dev/null 2>&1 || true ufw delete allow 8447/tcp >/dev/null 2>&1 || true ufw delete allow "${XRAY_XHTTP_PORT:-8448}/tcp" >/dev/null 2>&1 || true ( crontab -l 2>/dev/null | grep -v "naiveproxy\|monitor\.sh" || true ) | crontab - ok "Yurich Panel удалён" } # ─── ЛОГИ ──────────────────────────────────────────────────── cmd_logs() { echo -e "${BOLD}Лог Caddy (Ctrl+C для выхода):${RESET}" journalctl -u caddy -n 50 -f } cmd_language() { load_config 2>/dev/null || true hr echo -e "${BOLD} $(t "Язык SSH-панели" "SSH panel language")${RESET}" hr echo -e " $(t "Текущий язык" "Current language"): ${CYAN}${LANG_UI:-ru}${RESET}" echo echo -e " ${BOLD}1)${RESET} Русский" echo -e " ${BOLD}2)${RESET} English" echo -e " ${BOLD}0)${RESET} $(t "Назад" "Back")" hr echo -ne "${CYAN}$(t "Выбор" "Choice") [0-2]: ${RESET}" read -r lang_choice case "${lang_choice,,}" in 1|ru|rus|russian|русский) LANG_UI="ru" save_config ok "Язык панели: Русский" ;; 2|en|eng|english) LANG_UI="en" save_config ok "Panel language: English" ;; 0|"") return 0 ;; *) warn "$(t "Неверный выбор" "Invalid choice")" return 1 ;; esac } # ─── МЕНЮ ──────────────────────────────────────────────────── show_menu() { clear load_config local status_str status_str="${YELLOW}● $(t "не установлен" "not installed")${RESET}" if check_installed; then systemctl is-active --quiet caddy 2>/dev/null \ && status_str="${GREEN}● $(t "работает" "running")${RESET}" \ || status_str="${RED}● $(t "остановлен" "stopped")${RESET}" fi local tg_str tg_str="${RED}$(t "не настроен" "not configured")${RESET}" [[ -n "${TG_TOKEN:-}" ]] && tg_str="${GREEN}$(t "подключён" "connected")${RESET}" hr echo -e "${BOLD}${CYAN} Yurich Panel v${VERSION}${RESET} ${DIM}[$(t "РУС" "ENG")]${RESET}" echo -e " $(t "Статус" "Status"): ${status_str} | $(t "Домен" "Domain"): ${CYAN}${DOMAIN:-$(t "не задан" "not set")}${RESET}" local ssh_str ssh_str="${YELLOW}$(t "не настроен" "not configured")${RESET}" [[ -f "$SSH_HARDENING_DONE" ]] && ssh_str="${GREEN}$(grep SSH_PORT "$SSH_HARDENING_DONE" 2>/dev/null | cut -d= -f2)${RESET}" echo -e " Telegram: ${tg_str} | $(t "Юзеров" "Users"): $(get_users | wc -l) | $(t "SSH порт" "SSH port"): ${ssh_str}" local hysteria_str hysteria_str="${YELLOW}$(t "не установлен" "not installed")${RESET}" if [[ -f "$HYSTERIA_CONFIG" || -x "$HYSTERIA_BIN" ]]; then systemctl is-active --quiet hysteria 2>/dev/null \ && hysteria_str="${GREEN}UDP/${HYSTERIA_PORT:-8443}${RESET}" \ || hysteria_str="${RED}$(t "остановлен" "stopped")${RESET}" fi echo -e " Hysteria 2: ${hysteria_str}" local warp_str warp_str="${YELLOW}$(t "не установлен" "not installed")${RESET}" if command -v warp-cli &>/dev/null; then case "${WARP_MODE:-off}" in proxy) warp_str="${GREEN}proxy 127.0.0.1:${WARP_PROXY_PORT:-$WARP_PROXY_PORT_DEFAULT}${RESET}" ;; warp|warp+doh) warp_str="${GREEN}full ${WARP_MODE}${RESET}" ;; *) warp_str="${YELLOW}$(t "установлен" "installed")${RESET}" ;; esac fi echo -e " WARP: ${warp_str}" local xray_str xray_str="${YELLOW}$(t "не установлен" "not installed")${RESET}" if [[ -x "$XRAY_BIN" || -f "$XRAY_CONFIG" ]]; then systemctl is-active --quiet xray 2>/dev/null \ && xray_str="${GREEN}active${RESET}" \ || xray_str="${RED}$(t "остановлен" "stopped")${RESET}" [[ "${XRAY_FALLBACK_ENABLED:-0}" == "1" ]] && xray_str="${xray_str} ${CYAN}443-fallback${RESET}" fi echo -e " Xray Modern: ${xray_str}" local unbound_str unbound_str="${YELLOW}$(t "не установлен" "not installed")${RESET}" if command -v unbound &>/dev/null; then if systemctl is-active --quiet unbound 2>/dev/null; then unbound_str="${GREEN}active${RESET} ${CYAN}${UNBOUND_GATEWAY_IP:-127.0.0.1}${RESET}" else unbound_str="${RED}$(t "остановлен" "stopped")${RESET}" fi fi echo -e " DNS (Unbound): ${unbound_str}" local device_str device_str="${YELLOW}$(t "выкл" "off")${RESET}" if [[ "${DEVICE_LIMIT_ENABLED:-0}" == "1" ]]; then device_str="${GREEN}${DEVICE_LIMIT:-$DEVICE_LIMIT_DEFAULT}/${DEVICE_WINDOW_HOURS:-$DEVICE_WINDOW_HOURS_DEFAULT}ч ${DEVICE_LIMIT_MODE:-alert}${RESET}" fi echo -e " $(t "Лимит устройств" "Device limit"): ${device_str}" local nodes_str="${YELLOW}0${RESET}" if [[ -f "$NODES_FILE" ]]; then nodes_str="${GREEN}$(nodes_count)${RESET}" fi echo -e " Multi-server nodes: ${nodes_str}" echo -e " Режим 443: ${GREEN}$(edge_routing_mode_label)${RESET}" local credential_str if [[ "$(credential_plaintext_count)" -eq 0 && -s "$CREDENTIALS_PRIVATE_KEY" ]]; then credential_str="${GREEN}bcrypt + encrypted vault${RESET}" elif [[ "$(credential_plaintext_count)" -gt 0 ]]; then credential_str="${YELLOW}legacy, нужна миграция${RESET}" else credential_str="${YELLOW}будет создан при первом пользователе${RESET}" fi echo -e " Credentials: ${credential_str}" hr echo -e " ${BOLD}1)${RESET} $(t "Установить Yurich Panel" "Install Yurich Panel")" echo -e " ${BOLD}2)${RESET} $(t "Статус" "Status")" echo -e " ${BOLD}3)${RESET} $(t "Клиентский конфиг" "Client config")" echo -e " ${BOLD}4)${RESET} $(t "Управление пользователями" "User management")" echo -e " ${BOLD}5)${RESET} [DOM] $(t "Управление доменами" "Domain management")" echo -e " ${BOLD}6)${RESET} $(t "Мониторинг и статистика" "Monitoring and stats")" echo -e " ${BOLD}7)${RESET} $(t "Настройка Telegram + Бот" "Telegram + Bot setup")" echo -e " ${BOLD}8)${RESET} $(t "Перезапустить Caddy" "Restart Caddy")" echo -e " ${BOLD}9)${RESET} $(t "Обновить Caddy" "Update Caddy")" echo -e " ${BOLD}10)${RESET} $(t "Логи" "Logs")" echo -e " ${BOLD}11)${RESET} $(t "Удалить Yurich Panel" "Remove Yurich Panel")" echo -e " ${BOLD}16)${RESET} [DIAG] $(t "Диагностика системы" "System diagnostics")" echo -e " ${BOLD}17)${RESET} [DNS] Yurich DNS (Unbound)" echo -e " ${BOLD}18)${RESET} [DONATE] $(t "Поддержать проект (донат)" "Support project (donation)")" echo -e " ──────────────────────────" echo -e " ${BOLD}12)${RESET} [SSH] SSH Hardening" echo -e " ${BOLD}13)${RESET} [SYS] $(t "Обновить систему" "Update system")" echo -e " ${BOLD}14)${RESET} [UPD] $(t "Обновить скрипт" "Update script")" echo -e " ${BOLD}15)${RESET} [CAMO] $(t "Обновить камуфляж" "Update camouflage")" echo -e " ${BOLD}19)${RESET} [RLD] $(t "Reload Caddy без разрыва" "Reload Caddy without restart")" echo -e " ${BOLD}20)${RESET} [HY2] Hysteria 2 ($(t "UDP порт на выбор" "custom UDP port"))" echo -e " ${BOLD}21)${RESET} [WARP] WARP modes (proxy/full tunnel)" echo -e " ${BOLD}22)${RESET} [DEV] $(t "Лимит устройств / анти-шаринг" "Device limit / anti-sharing")" echo -e " ${BOLD}23)${RESET} [XRAY] Xray VLESS/Trojan/REALITY fallback" echo -e " ${BOLD}24)${RESET} [FIX] Diagnose --fix" echo -e " ${BOLD}25)${RESET} [SUB] $(t "Страница подписки пользователя" "User subscription page")" echo -e " ${BOLD}26)${RESET} [PAGE] $(t "Личная фейковая страница" "Private camouflage page")" echo -e " ${BOLD}27)${RESET} [PROD] Production tools / Bridge" echo -e " ${BOLD}28)${RESET} [LANG] $(t "Язык панели RU/EN" "Panel language RU/EN")" echo -e " ${BOLD}29)${RESET} [NODES] Multi-server management" echo -e " ${BOLD}30)${RESET} [EDGE] Routing mode / HAProxy" echo -e " ${BOLD}31)${RESET} [SALES] VPN sales Telegram bot" echo -e " ${BOLD}0)${RESET} $(t "Выход" "Exit")" hr echo -ne "${CYAN}$(t "Выбор" "Choice") [0-31]: ${RESET}" } # ─── MAIN ──────────────────────────────────────────────────── main() { if [[ $# -gt 0 ]]; then case "$1" in version|--version|-v) echo "Yurich Panel v${VERSION}" [[ -n "${TELEGRAM_COMMUNITY_URL:-}" ]] && echo "Telegram: ${TELEGRAM_COMMUNITY_URL}" [[ -n "${PROJECT_WEBSITE_URL:-}" ]] && echo "Website: ${PROJECT_WEBSITE_URL}" echo "GitHub: ${PROJECT_GITHUB_SHORT}" return 0 ;; help|--help|-h) echo "Yurich Panel v${VERSION}" echo "Usage: sudo bash yurich-panel.sh [command]" echo "Commands: install status config [user] reload restart update remove logs monitor credentials-status credentials-migrate routing-mode haproxy-status haproxy-apply haproxy-logs haproxy-tg users users-list-machine hysteria hy2 hysteria-sync hysteria-repair hysteria-port hysteria-warp-enable hysteria-warp-disable hysteria-hop-enable hysteria-hop-disable warp warp-proxy warp-full warp-health warp-protocol warp-ssh-allow xray xray-target xray-add-user [user] xray-rebuild vless-tune egress-ipv4 egress-dualstack pingtunnel-install pingtunnel-status pingtunnel-config pingtunnel-rotate pingtunnel-remove xray-zapret devices subscription subscription-clean private-page protocol-health protocol-validate protocol-benchmark protocol-benchmark-monitor protocol-benchmark-install protocol-benchmark-history protocol-stability-report protocol-monitor protocol-monitor-install security-audit notify-expiry-list notify-bind-tg notify-expiry-run notify-news notify-news-test expiry-enforce notify-expiry-install tg-stats bot-menu health safe-apply backup export import bridge nodes node-subscription-export fail2ban language ssh-hardening ssh-rescue sysupdate cert domains dns unbound yurich-dns yurich-dns-status yurich-dns-restart dns-open-check dns-latency-report self-update version camouflage" return 0 ;; esac fi check_root check_os run_startup_migrations || true if [[ $# -gt 0 ]]; then load_config; load_users case "$1" in install) cmd_install ;; status) cmd_status ;; config) print_client_config "${2:-}" ;; reload) cmd_reload ;; restart) cmd_restart ;; update) cmd_update ;; remove) cmd_remove ;; logs) cmd_logs ;; monitor) cmd_monitor ;; routing-mode|edge-mode|frontend-mode) cmd_edge_routing_mode "${2:-}" ;; haproxy-status|haproxy) cmd_haproxy_status ;; haproxy-apply|haproxy-sni-apply) cmd_haproxy_apply ;; haproxy-logs|sni-logs) cmd_haproxy_logs ;; haproxy-tg|haproxy-telegram) cmd_haproxy_tg ;; users) cmd_users ;; users-list-machine) cmd_users_list_machine ;; credentials-status|credential-status) cmd_credentials_status ;; credentials-migrate|credential-migrate) cmd_credentials_migrate ;; hysteria|hy2) cmd_hysteria_menu ;; hysteria-install|hy2-install) cmd_hysteria_install ;; hysteria-config|hy2-config) print_hysteria_client_config "${2:-}" ;; hysteria-status|hy2-status) cmd_hysteria_status ;; hysteria-repair|hy2-repair) cmd_hysteria_repair ;; hysteria-logs|hy2-logs) cmd_hysteria_logs ;; hysteria-sync|hy2-sync) cmd_hysteria_sync_cli ;; hysteria-port|hy2-port) cmd_hysteria_change_port "${2:-}" ;; hysteria-warp-enable|hy2-warp-enable|turbo-warp-enable) cmd_hysteria_warp_enable ;; hysteria-warp-disable|hy2-warp-disable|turbo-warp-disable) cmd_hysteria_warp_disable ;; hysteria-hop-enable|hy2-hop-enable) cmd_hysteria_hop_enable "${2:-}" ;; hysteria-hop-disable|hy2-hop-disable) cmd_hysteria_hop_disable ;; hysteria-remove|hy2-remove) cmd_hysteria_remove ;; warp) cmd_warp_menu ;; warp-install|warp-proxy) cmd_warp_install ;; warp-full|warp-full-install) cmd_warp_full_install ;; warp-config) print_warp_proxy_config ;; warp-status) cmd_warp_status ;; warp-test) cmd_warp_test ;; warp-full-test) cmd_warp_test_full ;; warp-health) cmd_warp_health ;; warp-protocol) cmd_warp_protocol ;; warp-ssh-allow|warp-ssh|warp-allow) cmd_warp_ssh_allow ;; warp-logs) cmd_warp_logs ;; warp-disable) cmd_warp_disable ;; warp-remove) cmd_warp_remove ;; xray) cmd_xray_menu ;; xray-install) cmd_xray_install ;; xray-target|xray-reality-target) cmd_xray_reality_target ;; xray-add-user|xray-user) cmd_xray_add_user "${2:-}" "${3:-}" ;; xray-compat-user|xray-reality-compat) cmd_xray_add_compat_user "${2:-}" ;; xray-rebuild) cmd_xray_rebuild ;; vless-tune|xray-tune|reality-tune) cmd_vless_tune ;; egress-ipv4|egress-prefer-ipv4|location-ipv4) cmd_egress_prefer_ipv4 ;; egress-dualstack|egress-ipv6-restore|location-dualstack) cmd_egress_dualstack ;; pingtunnel-install|icmp-install) cmd_pingtunnel_install ;; pingtunnel-status|icmp-status) cmd_pingtunnel_status ;; pingtunnel-config|icmp-config) cmd_pingtunnel_config "${2:-}" ;; pingtunnel-rotate|icmp-rotate) cmd_pingtunnel_rotate ;; pingtunnel-remove|icmp-remove) cmd_pingtunnel_remove ;; xray-zapret) cmd_xray_zapret "${2:-enable}" ;; xray-config) print_xray_client_config "${2:-}" ;; xray-status) cmd_xray_status ;; xray-logs) cmd_xray_logs ;; xray-remove) cmd_xray_remove ;; devices) cmd_devices_menu ;; devices-scan) cmd_devices_scan ;; devices-config) cmd_devices_config ;; devices-disable) cmd_devices_disable ;; devices-lock) device_disable_user "${2:-}" ;; devices-unlock) device_enable_user "${2:-}" ;; devices-unlock-all) cmd_devices_unlock_all ;; subscription|sub) cmd_subscription_user "${2:-}" ;; subscription-reset|sub-reset) cmd_subscription_reset "${2:-}" ;; subscription-clean|subscriptions-clean|subscription-rebuild-clean) cmd_subscription_rebuild_clean "${2:-}" ;; protocol-validate|protocols-validate) cmd_protocol_validate ;; protocol-benchmark|protocols-benchmark|benchmark) cmd_protocol_benchmark "${2:-}" "${3:-1}" ;; protocol-benchmark-monitor|protocols-benchmark-monitor|benchmark-monitor) cmd_protocol_benchmark_monitor "${2:-}" "${3:-}" ;; protocol-benchmark-install|protocols-benchmark-install|benchmark-install) cmd_protocol_benchmark_install "${2:-}" "${3:-}" ;; protocol-benchmark-history|protocols-benchmark-history|benchmark-history) cmd_protocol_benchmark_history "${2:-30}" ;; protocol-stability-report|stability-report) cmd_protocol_stability_report "${2:-24}" ;; protocol-health|protocols-health) cmd_protocol_health ;; protocol-monitor|protocols-monitor) cmd_protocol_monitor ;; protocol-monitor-install|protocols-monitor-install) cmd_protocol_monitor_install ;; private-page) install_private_camouflage_page "${2:-}" ;; notify-bind-tg|bind-tg) cmd_notify_bind_tg "${2:-}" "${3:-}" ;; notify-unbind-tg|unbind-tg) cmd_notify_unbind_tg "${2:-}" ;; notify-expiry-list|notify-list|expiring) cmd_notify_expiry_list ;; notify-expiry-run|notify-run) cmd_notify_expiry_run ;; expiry-enforce|notify-expiry-enforce) cmd_enforce_expired_users ;; notify-expiry-test|notify-test|test-notify) cmd_notify_expiry_test "${2:-}" ;; notify-news-test|news-test|broadcast-test) shift; cmd_notify_news_test "$*" ;; notify-news|news|broadcast) shift; cmd_notify_news_broadcast "$*" ;; notify-expiry-install|notify-install) cmd_notify_expiry_install ;; tg-stats) tg_send_stats; ok "Отправлено" ;; security-audit|audit-security) cmd_security_audit ;; ssh-hardening) cmd_ssh_hardening ;; ssh-rescue) cmd_ssh_rescue ;; sysupdate) cmd_sysupdate ;; cert) load_config; check_cert "${DOMAIN:-}" ;; domains) load_config; cmd_domains ;; qr) load_config; print_client_config ;; ssh-key) cat "${CONFIG_DIR}/ssh_private_key" 2>/dev/null || err "Ключ не найден: ${CONFIG_DIR}/ssh_private_key" ;; diagnose) cmd_diagnose "${2:-}" ;; dns|unbound|yurich-dns|aurum-dns) cmd_unbound_plugin ;; dns-install|unbound-install|yurich-dns-install|aurum-dns-install) cmd_dns_install ;; dns-mode|unbound-mode) cmd_dns_set_mode ;; dns-vpn|unbound-vpn|yurich-dns-vpn|aurum-dns-vpn) cmd_dns_vpn_access ;; dns-update|unbound-update) cmd_dns_update ;; dns-filter-install|unbound-filter-install) cmd_dns_filter_install ;; dns-monitor|unbound-monitor) cmd_dns_monitor ;; dns-monitor-install|unbound-monitor-install) cmd_dns_monitor_install ;; dns-open-check|dns-external-check|unbound-open-check) cmd_dns_open_resolver_check "${2:-}" ;; dns-latency-report|dns-latency|unbound-latency) cmd_dns_latency_report ;; dns-latency-local) cmd_dns_latency_local "${2:-local}" "${3:-127.0.0.1}" ;; dns-allowlist|unbound-allowlist|dns-whitelist|unbound-whitelist) cmd_dns_whitelist ;; dns-status|unbound-status|unbound-test|yurich-dns-status|yurich-dns-test|aurum-dns-status|aurum-dns-test) cmd_dns_status ;; dns-restart|unbound-restart|yurich-dns-restart|aurum-dns-restart) cmd_dns_restart ;; dns-remove|unbound-remove|yurich-dns-remove|aurum-dns-remove) cmd_dns_remove ;; bot) load_config; cmd_bot ;; bot-install) load_config; install_bot_service ;; bot-menu) load_config; tg_apply_bot_menu ;; sales-bot) load_config; cmd_sales_bot ;; sales-bot-install|sales-install) load_config; install_sales_bot_service "${2:-}" "${3:-}" ;; sales-bot-menu|sales-menu) load_config; sales_apply_bot_menu ;; sales-bot-defaults|sales-defaults|sales-config) load_config; cmd_sales_bot_apply_defaults ;; sales-orders) load_config; sales_orders_text ;; health|health-check) cmd_health_check ;; safe-apply) cmd_safe_apply ;; backup|backup-encrypted) cmd_backup_encrypted ;; export|export-state) cmd_export_state ;; import|import-state) cmd_import_state "${2:-}" ;; bridge) cmd_bridge_menu ;; bridge-show) cmd_bridge_show ;; bridge-remove) cmd_bridge_remove ;; nodes|node|multi-server) cmd_nodes_menu ;; nodes-list|node-list) cmd_nodes_list ;; nodes-add|node-add) cmd_nodes_add ;; nodes-test|node-test) cmd_nodes_test "${2:-all}" ;; nodes-deploy|node-deploy) cmd_nodes_deploy_script "${2:-}" ;; nodes-sync|node-sync) cmd_nodes_sync_users "${2:-all}" ;; nodes-subscriptions|nodes-rebuild-subscriptions) cmd_nodes_rebuild_subscriptions ;; node-subscription-export) cmd_node_subscription_export "${2:-}" ;; nodes-remove|node-remove) cmd_nodes_remove "${2:-}" ;; fail2ban|f2b|security) setup_fail2ban "$(current_ssh_port)" ;; language|lang) cmd_language ;; self-update) load_config; cmd_self_update ;; post-update) cmd_post_update "${2:-unknown}" ;; camouflage) install_camouflage_page ;; version) echo "Yurich Panel v${VERSION}" [[ -n "${TELEGRAM_COMMUNITY_URL:-}" ]] && echo "Telegram: ${TELEGRAM_COMMUNITY_URL}" [[ -n "${PROJECT_WEBSITE_URL:-}" ]] && echo "Website: ${PROJECT_WEBSITE_URL}" echo "GitHub: ${PROJECT_GITHUB_SHORT}" ;; *) err "Неизвестная команда: $1" echo "Доступные: install status config [user] reload restart update remove logs monitor credentials-status credentials-migrate routing-mode haproxy-status haproxy-apply haproxy-logs haproxy-tg users users-list-machine hysteria hy2 hysteria-sync hysteria-repair hysteria-port hysteria-warp-enable hysteria-warp-disable hysteria-hop-enable hysteria-hop-disable warp warp-proxy warp-full warp-health warp-protocol warp-ssh-allow xray xray-target xray-add-user [user] xray-compat-user [user] xray-rebuild vless-tune egress-ipv4 egress-dualstack pingtunnel-install pingtunnel-status pingtunnel-config pingtunnel-rotate pingtunnel-remove xray-zapret devices subscription subscription-clean private-page protocol-health protocol-validate protocol-benchmark protocol-benchmark-monitor protocol-benchmark-install protocol-benchmark-history protocol-stability-report protocol-monitor protocol-monitor-install security-audit notify-expiry-list notify-bind-tg notify-expiry-run notify-news notify-news-test expiry-enforce notify-expiry-install tg-stats bot-menu health safe-apply backup export import bridge nodes node-subscription-export fail2ban language ssh-hardening ssh-rescue sysupdate cert domains dns unbound yurich-dns yurich-dns-status yurich-dns-restart dns-open-check dns-latency-report self-update version camouflage" exit 1 ;; esac exit 0 fi # Тихая проверка обновлений в фоне check_update_available while true; do show_menu read -r choice; echo load_config; load_users case "$choice" in 1) cmd_install ;; 2) cmd_status ;; 3) print_client_config ;; 4) cmd_users ;; 5) cmd_domains ;; 6) cmd_monitor ;; 7) setup_telegram ;; 8) cmd_restart ;; 9) cmd_update ;; 10) cmd_logs ;; 11) cmd_remove ;; 12) cmd_ssh_hardening ;; 13) cmd_sysupdate ;; 14) cmd_self_update ;; 15) install_camouflage_page && ok "Камуфляж обновлён" ;; 16) cmd_diagnose ;; 17) cmd_dns_menu ;; 18) cmd_donate ;; 19) cmd_reload ;; 20) cmd_hysteria_menu ;; 21) cmd_warp_menu ;; 22) cmd_devices_menu ;; 23) cmd_xray_menu ;; 24) cmd_diagnose_fix ;; 25) cmd_subscription_user ;; 26) install_private_camouflage_page ;; 27) cmd_production_tools_menu ;; 28) cmd_language ;; 29) cmd_nodes_menu ;; 30) cmd_haproxy_menu ;; 31) install_sales_bot_service ;; 0) echo -e "${GREEN}$(t "Пока!" "Bye!")${RESET}"; exit 0 ;; *) warn "$(t "Неверный выбор" "Invalid choice")" ;; esac echo echo -ne "${YELLOW}$(t "Нажми Enter чтобы вернуться в меню..." "Press Enter to return to menu...")${RESET}" read -r done } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi