#!/usr/bin/env bash # 3X-UI со всеми протоколами одной командой – https://github.com/itsnotkubrick/3X-UI_KIT # # Установка: bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh) # # Ставит официальную панель 3X-UI (версия закреплена ниже) её собственным # установщиком, получает сертификат Let's Encrypt на IP, создаёт подключения # REALITY, XHTTP, VLESS/VMess WS, Trojan gRPC, Shadowsocks 2022, Hysteria2, TUIC, # AmneziaWG (классика и 3.1) и MTProto (и WireGuard по запросу), включает единую подписку # с форматом под каждый клиент и настраивает ufw. Домены не нужны. # Запуск через sh (dash) ломается на непонятной ошибке синтаксиса – подскажем сразу. [ -n "${BASH_VERSION:-}" ] || { echo "Запустите через bash, а не через sh." >&2; exit 1; } set -Eeuo pipefail # На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем. apt-get() { command apt-get -o DPkg::Lock::Timeout=900 "$@"; } wait_apt_idle() { local i pgrep -f '/usr/bin/unattended-upgrade$|apt\.systemd\.daily' >/dev/null || return 0 printf '%s\n' "==> Система сама ставит обновления – жду, пока закончит (до 15 минут)" for i in $(seq 1 180); do pgrep -f '/usr/bin/unattended-upgrade$|apt\.systemd\.daily' >/dev/null || return 0 sleep 5 done return 0 } XUI_VERSION="v3.9.0" # SHA256 установщика 3X-UI этой версии: тег могут передвинуть, а хеш – нет (проверено 2026-09-30). XUI_INSTALL_SHA256="18616fe26c8f6c92db6daa2dcd7cd53c5143ee69ecfd26d8ea6dc9b2c78607a6" KIT_VERSION="1.2.5" # kit и kit-sub берём из того же релиза, что и этот скрипт, а не из меняющейся ветки main. KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$KIT_VERSION" # Ядро Xray для панели. С 26.7.x клиенты на Mihomo и sing-box (Hiddify, FlClash, # Clash Verge, Mihomo в XKeen) не проходят REALITY – проверено 2026-09-25. # 26.6.27 – последняя версия, с которой работают все клиенты и которую принимает 3X-UI. XRAY_CORE="v26.6.27" # SHA256 архивов этой версии ядра (из официальных файлов .dgst релиза Xray-core). Запасной путь # через зеркало ставит архив, только если сумма совпала: зеркалу верить не нужно. # При смене XRAY_CORE обновите суммы: tools/release.sh сверяет их с официальными. declare -A XRAY_ZIP_SHA256=( [64]=b3e5902d06d6282fe53cfa2fc426058b9aeaa429b2c812e20887cd47f26d08bf [arm64-v8a]=13a251379bea366c2cf10363ad71e75734193d401f26f518bf0c25e5c8f8c931 ) XRAY_MIRRORS=("https://github.com" "https://ghfast.top/https://github.com") XUI_REPO="MHSanaei/3x-ui" RESULT=/root/3x-ui.txt XUI_ENV=/etc/x-ui/install-result.env # Сайты для маскировки REALITY: нужны TLS 1.3 и HTTP/2. Берём первый доступный. # Apple, iCloud, Microsoft и домены .ru сам Xray не советует – их тут нет. SNI_CANDIDATES=(dl.google.com www.amazon.com www.samsung.com www.yahoo.com) ALL_PROTOS=(reality reality2 hy2 xhttp ws trojan vmess ss tuic wg awg awg3 mtproto) # Обычный WireGuard легко распознаётся сетевым оборудованием и работает нестабильно # (проверено 2026-09-27: рукопожатие доходит до сервера, ответ – нет). По умолчанию не ставим. DEFAULT_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic awg awg3 mtproto) declare -A PORTS=([xhttp]=8443 [ws]=2053 [trojan]=2083 [vmess]=2087 [ss]=8388 [tuic]=8444 [wg]=51820 [awg]=51821 [awg3]=51822 [mtproto]=8445) PROTOS=(); CREATED=(); OPEN=(); NB_ALL=() # Режим «всё TCP на 443»: nginx разводит по SNI и путям, подключения слушают только localhost. SINGLE=no declare -A INNER=([reality]=10443 [xhttp]=10444 [mtproto]=10445 [web]=10446 [selfweb]=10447 [ws]=10451 [vmess]=10452 [trojan]=10453 [sub]=10460) SNI2=""; SNI3="" # Свой домен (self-steal): REALITY маскируется под сайт на этом же сервере, а не под чужой. DOMAIN="" PANEL_ON=ip # где открываются панель и подписка: ip или domain (domain – только со своим доменом) LINK_HOST="" # адрес в ссылках: свой домен, если он выбран, иначе IP DOMAIN_CERT_DIR=/root/cert/domain SELF_IP_CERT=no # yes – сертификат на IP самоподписанный (Let's Encrypt отказал, пользователь согласился) if [[ -t 1 ]]; then G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m' else G=; Y=; R=; B=; D=; N= fi WARNINGS=(); STEP=0; STEPS=6 say() { printf ' %s\n' "$*"; } # подробность под шагом ok() { printf ' %s\n' "${G}✓${N} $*"; } step() { STEP=$((STEP + 1)); printf '\n%s\n' "${B}[$STEP/$STEPS]${N} $1"; } warn() { printf '%s\n' "${Y}!${N} $*" >&2; } # сразу (диалоги, ошибки перед остановкой) later() { WARNINGS+=("$*"); } # в блок «Внимание» в конце die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; } trap 'die "Ошибка в строке $LINENO. Исправьте причину и запустите скрипт ещё раз."' ERR rand_str() { openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$1"; } port_busy() { ss -H -ln"${2:0:1}" "sport = :$1" 2>/dev/null | grep -q .; } public_ip() { local ip for u in https://api.ipify.org https://ifconfig.me/ip https://ipv4.icanhazip.com; do ip=$(curl -4 -fsS -m 6 "$u" 2>/dev/null | tr -d '[:space:]') || true [[ $ip =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && { echo "$ip"; return; } done ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' } free_port() { local p for _ in $(seq 1 50); do p=$(shuf -i 20000-60000 -n 1) port_busy "$p" tcp || { echo "$p"; return; } done die "Не нашёл свободный порт для панели." } # REALITY маскируется под чужой сайт: он должен отвечать по TLS 1.3 и HTTP/2. sni_ok() { echo | timeout 8 openssl s_client -connect "$1:443" -servername "$1" -tls1_3 -alpn h2 2>/dev/null \ | grep -q 'ALPN protocol: h2' } sni_alive() { sni_ok "$1"; } # --- поиск сайта-прикрытия среди соседей по подсети (общий код kit и установщика) --- SNI_RE='^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$' # Бесплатные и динамические имена (sslip.io, work.gd и т. п.) часто используют чужие прокси-серверы: под них маскироваться не стоит. SNI_DYN_RE='(^|\.)(sslip\.io|nip\.io|xip\.io|traefik\.me|work\.gd|duckdns\.org|ddns\.net|hopto\.org|zapto\.org|myftp\.biz|dynu\.net|freeddns\.org|no-ip\.(org|biz|info)|nom\.za|tk|ml|ga|cf|gq)$' # Известные сайты на чужом IP не подходят: сайт для маскировки должен быть «своим» для подсети сервера. SNI_BRAND_RE='(^|\.)(google|googleapis|gstatic|youtube|microsoft|windows|apple|icloud|amazon|amazonaws|samsung|yahoo|cloudflare|facebook|instagram|netflix|github|telegram)\.[a-z.]+$' # Имена с «сомнительными» словами не берём: брать такой сайт для маскировки неприятно и небезопасно для вас. SNI_BAD_RE='(probiv|porn|xxx|sex|adult|casino|bet|vpn|proxy|torrent|crack|hack|warez|drug|weapon|leak|escort|gambl|poker)' # У сайта настоящий сертификат: цепочка проходит проверку, имя совпадает. sni_trusted() { echo | timeout 8 openssl s_client -connect "$1:443" -servername "$1" -verify_hostname "$1" -verify_return_error 2>/dev/null | grep -q 'Verification: OK' } # На «/» отвечает страница (2xx) или переход на этот же сайт; не за Cloudflare, не переход на чужой сайт. sni_quality() { local out code loc out=$(curl -sS -m 10 -o /dev/null -D- "https://$1/" 2>/dev/null | tr -d '\r') || return 1 code=$(awk 'NR == 1 {print $2}' <<<"$out") grep -qiE '^(server: cloudflare|cf-ray:)' <<<"$out" && return 1 case $code in 2??) return 0 ;; 3??) loc=$(awk 'tolower($1) == "location:" {print $2; exit}' <<<"$out"); [[ $loc == /* || $loc =~ ^https?://(www\.)?${1#www.}(/|:|$) ]] ;; *) return 1 ;; esac } host_ip() { # IPv4 сервера local h=${HOST:-} if [[ $h =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then echo "$h"; else getent ahostsv4 "$h" 2>/dev/null | awk 'NR == 1 {print $1}'; fi } # Тот же блок /23 (две соседние /24), что и у сервера. same_net() { # имя local me ip a b c x y z me=$(host_ip); [[ -n $me ]] || return 1 IFS=. read -r a b c _ <<<"$me" while read -r ip; do [[ -n $ip ]] || continue IFS=. read -r x y z _ <<<"$ip" [[ $x == "$a" && $y == "$b" && $((z >> 1)) == $((c >> 1)) ]] && return 0 done < <(getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u) return 1 } # Имена из сертификатов соседних адресов /24: заходим на 443 без имени и читаем subjectAltName. nearby_scan() { # основа "a.b.c" local me; me=$(host_ip) seq 1 254 | xargs -P 8 -I{} bash -c ' ip=$1; [ "$ip" = "$2" ] && exit 0 echo | timeout 4 openssl s_client -connect "$ip:443" -tls1_3 -alpn h2 2>/dev/null | openssl x509 -noout -ext subjectAltName 2>/dev/null \ | tr "," "\n" | sed -n "s/^ *DNS://p"' _ "$1.{}" "$me" 2>/dev/null | sort -u | grep -E "$SNI_RE" | head -40 || true } # Подходящие сайты: сначала в /24 сервера, если пусто – во втором /24 того же /23. До 8 имён. nearby_sites() { local me a b c base n names found=0 set +e; trap - ERR # выполняется в подпроцессе: сбой одной проверки не должен обрывать поиск me=$(host_ip) [[ $me =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 0 IFS=. read -r a b c _ <<<"$me" for base in "$a.$b.$c" "$a.$b.$((c ^ 1))"; do names=$(nearby_scan "$base") for n in $names; do [[ $n == "$me" || $n =~ $SNI_DYN_RE || $n =~ $SNI_BRAND_RE || $n =~ $SNI_BAD_RE ]] && continue if same_net "$n" && sni_alive "$n" && sni_trusted "$n" && sni_quality "$n"; then echo "$n"; found=$((found + 1)); ((found >= 8)) && return 0; fi done ((found > 0)) && return 0 done return 0 } # ---------- свой домен (self-steal) ---------- # Домен должен смотреть на этот сервер: иначе Let's Encrypt не выдаст сертификат, # а маскировка под чужой адрес ничего не даст. domain_points_here() { # домен local me=$HOST ips [[ $me =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || me=$(public_ip) ips=$(getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u) if [[ -z $ips ]]; then warn "У домена $1 нет A-записи. Добавьте её у регистратора: тип A, значение $me." elif ! grep -qx "$me" <<<"$ips"; then warn "Домен $1 сейчас указывает на $(tr '\n' ' ' <<<"$ips")– а нужен IP этого сервера: $me." echo " Если домен за Cloudflare, выключите проксирование (серое облако вместо оранжевого)." >&2 else return 0 fi return 1 } ask_tty() { # приглашение; ответ – в REPLY; не 0, если терминал пропал (обрыв SSH, EOF) read -r -p "$1" REPLY /dev/null | grep -q 'does match' \ && openssl x509 -in "$DOMAIN_CERT_DIR/fullchain.pem" -noout -issuer 2>/dev/null | grep -q "Let's Encrypt" \ && openssl x509 -in "$DOMAIN_CERT_DIR/fullchain.pem" -noout -checkend 864000 >/dev/null 2>&1; then say "Сертификат для ${B}$DOMAIN${N} уже есть" return 0 fi [[ -x $acme ]] || { warn "Не нашёл acme.sh, которым установщик 3X-UI получает сертификаты: не могу выпустить сертификат для $DOMAIN."; return 1; } port_busy 80 tcp && { warn "Порт 80/tcp занят: Let's Encrypt не сможет проверить домен $DOMAIN."; return 1; } say "Получаю сертификат Let's Encrypt для ${B}$DOMAIN${N}" install -m 600 /dev/null "$log" # Сбои бывают временными (например, ошибка TLS при скачивании готового сертификата): до трёх попыток. local try for try in 1 2 3; do rc=0 "$acme" --issue -d "$DOMAIN" --standalone --httpport 80 --server letsencrypt --keylength ec-256 ${rc_force:-} >>"$log" 2>&1 || rc=$? [[ $rc == 0 || $rc == 2 ]] && break local rc_force="--force" sleep 10 done # 2 – сертификат уже свежий, выпускать заново не нужно. [[ $rc == 0 || $rc == 2 ]] || { warn "Let's Encrypt не выдал сертификат для $DOMAIN. Обычно дело в одном из трёх: A-запись ещё не обновилась, порт 80 закрыт у хостера или домен за проксированием Cloudflare. Лог: $log"; return 1; } # Каталог и ключ – только для root (nginx читает их от root). install -d -m 700 "$DOMAIN_CERT_DIR" (umask 077; "$acme" --install-cert -d "$DOMAIN" --ecc --fullchain-file "$DOMAIN_CERT_DIR/fullchain.pem" \ --key-file "$DOMAIN_CERT_DIR/privkey.pem" --reloadcmd "systemctl reload nginx >/dev/null 2>&1 || true" >>"$log" 2>&1) \ || { warn "Не удалось сохранить сертификат для $DOMAIN. Лог: $log"; return 1; } chmod 644 "$DOMAIN_CERT_DIR/fullchain.pem" chmod 600 "$DOMAIN_CERT_DIR/privkey.pem" return 0 } # Let's Encrypt не выдал сертификат на IP, а режим со своим доменом без него не работает: предлагаем # самоподписанный сертификат на IP. Ссылки на подключения продолжат работать (отпечаток уходит в # ссылки), а подписка в приложениях может не открыться: им такой сертификат не нравится. ip_cert_self_signed_fallback() { local san="IP:$HOST" [[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST" warn "Let's Encrypt не выдал сертификат на IP $HOST. Частые причины:" { echo " – лимит Let's Encrypt: 5 сертификатов в неделю на один IP;" echo " – входящий порт 80 закрыт (у хостера или в файрволе);" echo " – сбой у самого Let's Encrypt (лог: /var/log/3x-ui-install.log)." echo " Можно продолжить с самоподписанным сертификатом: ссылки на подключения работают, браузер покажет" echo " предупреждение, а подписка в приложениях может не открыться." } >&2 if [[ $yes == no && -t 1 ]] && { : /dev/null; then ask_tty "Продолжить с самоподписанным сертификатом? [Y/n] " || REPLY="" REPLY=${REPLY//[[:space:]]/} [[ ${REPLY,,} == n* || ${REPLY,,} == н* ]] && return 1 else say "Продолжаю с самоподписанным сертификатом (без вопросов)." fi install -d -m 700 /root/cert/ip (umask 077; openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout /root/cert/ip/privkey.pem \ -out /root/cert/ip/fullchain.pem -subj "/CN=$HOST" -addext "subjectAltName=$san" -days 3650 2>/dev/null) chmod 644 /root/cert/ip/fullchain.pem chmod 600 /root/cert/ip/privkey.pem say "Подключаю самоподписанный сертификат к панели" /usr/local/x-ui/x-ui cert -webCert /root/cert/ip/fullchain.pem -webCertKey /root/cert/ip/privkey.pem >/dev/null 2>&1 systemctl restart x-ui API="https://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" wait_panel SELF_IP_CERT=yes return 0 } # Куда REALITY отправляет чужих гостей: для своего домена – на nginx этого сервера. reality_target() { # сайт if [[ -n $DOMAIN && $1 == "$DOMAIN" ]]; then echo "127.0.0.1:${INNER[selfweb]}"; else echo "$1:443"; fi } # ---------- API панели ---------- api() { # METHOD path [json] local url="$API/$2" out if [[ $1 == GET ]]; then out=$(curl -fsSk -m 20 -H "Authorization: Bearer $TOKEN" "$url") else out=$(curl -fsSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X "$1" -d "$3" "$url") fi [[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель ответила ошибкой на $2: $(jq -r '.msg // .' <<<"$out" | head -c 300)" jq -c '.obj' <<<"$out" } wait_panel() { local i for i in $(seq 1 60); do curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $TOKEN" "$API/server/getNewUUID" 2>/dev/null && return 0 sleep 2 done die "Панель не отвечает. Лог: journalctl -u x-ui -n 50" } # ---------- установка ---------- # Пасхалка – только в конце установки. kit_banner() { echo printf '%s' "$G" cat <<'ART' _ _ _ _ _ _ _ (_) |_ ___ _ __ ___ | |_| | ___ _| |__ _ __(_) ___| | __ | | __/ __| '_ \ / _ \| __| |/ / | | | '_ \| '__| |/ __| |/ / | | |_\__ \ | | | (_) | |_| <| |_| | |_) | | | | (__| < |_|\__|___/_| |_|\___/ \__|_|\_\\__,_|_.__/|_| |_|\___|_|\_\ ART printf '%s' "$N" echo echo "${B}3X-UI KIT $KIT_VERSION${N} · it's not Kubrick. it's just a VPN." echo "${D}на основе панели 3X-UI (MHSanaei/3x-ui) и ядра Xray · github.com/itsnotkubrick/3X-UI_KIT${N}" } main() { local a args=() for a in "$@"; do [[ $a == -h || $a == --help ]] && { usage; exit 0; } # --port=8443 понимаем так же, как --port 8443 if [[ $a == --*=* ]]; then args+=("${a%%=*}" "${a#*=}"); else args+=("$a"); fi done set -- ${args[@]+"${args[@]}"} [[ $EUID -eq 0 ]] || die "Запустите от root: sudo -i, затем команду ещё раз." command -v systemctl >/dev/null || die "Нужен systemd." # kit-sub работает без root и получает конфиг через LoadCredential: он появился в systemd 247 (Ubuntu 22.04+, Debian 11+). local sysv sysv=$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}') [[ $sysv =~ ^[0-9]+$ ]] && ((sysv < 247)) && die "Версия systemd ($sysv) слишком старая, нужна 247 и новее: Ubuntu 22.04 и новее, Debian 11 и новее. На этой системе подписка не запустится – поставьте поддерживаемую." if [[ -f $RESULT && -x /usr/local/x-ui/x-ui ]]; then die "3X-UI уже установлена этим скриптом. Управление: команда x-ui, данные для входа: cat $RESULT" fi # Панель удалили через меню x-ui, а наши файлы остались – убираем их и ставим заново. if [[ -f $RESULT ]]; then warn "Панель 3X-UI удалена, но остались файлы прошлой установки – убираю их." systemctl disable --now kit-sub kit-update.timer >/dev/null 2>&1 || true rm -rf /etc/systemd/system/kit-sub.service /etc/systemd/system/kit-update.service /etc/systemd/system/kit-update.timer /usr/local/lib/kit-sub /etc/kit-sub /etc/kit /usr/local/bin/kit \ /etc/cron.d/kit-nginx-reload /etc/cron.d/kit-xui-menu /etc/cron.d/kit-sub-cert "$RESULT" systemctl daemon-reload # Наш nginx держит 443 – без этого проверка порта ниже не пустит REALITY. if [[ -f /etc/nginx/kit-stream.conf ]]; then systemctl stop nginx >/dev/null 2>&1 || true rm -f /etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf sed -i '/kit-stream\.conf/d' /etc/nginx/nginx.conf fi fi if [[ -d /usr/local/x-ui && ! -f $XUI_ENV ]]; then die "3X-UI уже установлена другим способом – не трогаю её. Удалите её (x-ui uninstall) или добавьте REALITY в панели вручную." fi local PORT=443 SNI="" PANEL_SSL=auto HOST="" UFW=yes NAME="admin" yes=no protos=all ucert="" ukey="" multi=no restore="" while [[ $# -gt 0 ]]; do case $1 in --port | --sni | --panel-ssl | --host | --user | --protocols | --domain | --cert | --key | --restore | --panel-on) [[ -n ${2-} ]] || die "У параметра $1 нет значения (см. --help)" ;; esac case $1 in --port) PORT=$2; shift 2 ;; --sni) SNI=$2; shift 2 ;; --panel-ssl) PANEL_SSL=$2; shift 2 ;; --host) HOST=$2; shift 2 ;; --user) NAME=$2; shift 2 ;; --protocols) protos=$2; shift 2 ;; --domain) DOMAIN=$2; shift 2 ;; --cert) ucert=$2; shift 2 ;; --multi-port) multi=yes; shift ;; --key) ukey=$2; shift 2 ;; --restore) restore=$2; shift 2 ;; --panel-on) PANEL_ON=${2,,}; shift 2 ;; --no-ufw) UFW=no; shift ;; -y|--yes) yes=yes; shift ;; -h|--help) usage; exit 0 ;; *) die "Неизвестный параметр: $1 (см. --help)" ;; esac done [[ $PORT =~ ^[0-9]{1,5}$ ]] && ((10#$PORT > 0 && 10#$PORT < 65536)) || die "Неверный порт: $PORT" PORT=$((10#$PORT)) if [[ -n $restore ]]; then [[ -z $HOST || $HOST =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "--host при восстановлении: IP нового сервера" restore_main "$restore" return fi # Регистр и точка в конце не важны: vpn.Example.com. – это тот же vpn.example.com. local k for k in SNI DOMAIN HOST; do [[ -z ${!k} ]] || { local v=${!k,,}; v=${v%.}; printf -v "$k" '%s' "$v"; } done PANEL_SSL=${PANEL_SSL,,}; protos=${protos,,}; protos=${protos// /} local re_host='^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+([A-Za-z]{2,63}|xn--[A-Za-z0-9-]{1,59})$' for k in "$SNI" "$DOMAIN" "$HOST"; do [[ $k != *://* && $k != */* ]] || die "Нужно только имя, без https:// и без «/»: например vpn.example.com" [[ $k =~ ^[A-Za-z0-9.-]*$ ]] || die "Имя «$k» с не латинскими буквами не подойдёт: запишите его в виде punycode (xn--…), например через idn или в личном кабинете регистратора." done [[ -z $SNI || $SNI =~ $re_host ]] || die "--sni: нужно имя сайта, например dl.google.com" [[ -z $DOMAIN || $DOMAIN =~ $re_host ]] || die "--domain: нужно имя вашего домена, например vpn.example.com" [[ -z $DOMAIN || -z $SNI ]] || die "--sni и --domain вместе не нужны: выберите либо чужой сайт (--sni), либо свой домен (--domain)." [[ -z $DOMAIN || $multi == no ]] || die "Свой домен работает только в режиме «всё на 443» – уберите --multi-port." [[ $PANEL_ON == ip || $PANEL_ON == domain ]] || die "--panel-on: ip или domain" [[ $PANEL_ON == ip || -n $DOMAIN || $yes == no ]] || die "--panel-on domain работает только со своим доменом (--domain vpn.example.com)." [[ -z $HOST || $HOST =~ $re_host || $HOST =~ ^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$ ]] || die "--host: нужен IP (например 1.2.3.4) или домен" [[ $NAME =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Имя: латиница, цифры, _ . - (до 32 символов)." [[ $PANEL_SSL =~ ^(auto|ip|none)$ ]] || die "--panel-ssl: auto, ip или none" if [[ -n $ucert || -n $ukey ]]; then [[ -s $ucert && -s $ukey ]] || die "Нужны оба файла: --cert fullchain.pem --key privkey.pem" openssl x509 -in "$ucert" -noout 2>/dev/null || die "$ucert – не сертификат в формате PEM" PANEL_SSL=custom fi case $protos in all) PROTOS=("${DEFAULT_PROTOS[@]}") ;; minimal) PROTOS=(reality) ;; *) IFS=, read -ra PROTOS <<<"$protos" local x for x in "${PROTOS[@]}"; do [[ " ${ALL_PROTOS[*]} " == *" $x "* ]] || die "Неизвестный протокол: $x. Доступны: ${ALL_PROTOS[*]}"; done ;; esac if [[ $multi == no && -d /etc/nginx ]] && grep -rqsE '^[[:space:]]*stream[[:space:]]*\{' /etc/nginx/nginx.conf /etc/nginx/conf.d /etc/nginx/sites-enabled /etc/nginx/streams-enabled 2>/dev/null; then die "В вашем nginx уже есть блок stream: установщик в режиме «всё на 443» не сможет с ним ужиться. Запустите с --multi-port или уберите свой блок." fi if port_busy "$PORT" tcp && ! { [[ -f $XUI_ENV ]] && ss -H -ltnp "sport = :$PORT" | grep -q -E 'xray|nginx'; }; then die "Порт $PORT/tcp уже занят. REALITY нужен свободный порт – укажите другой: --port 8443" fi if [[ $PANEL_SSL == auto ]]; then if port_busy 80 tcp; then PANEL_SSL=none later "Порт 80 занят – сертификат для панели не получить. Панель будет доступна только через SSH-туннель." else PANEL_SSL=ip fi fi [[ $PANEL_SSL == ip ]] && port_busy 80 tcp && die "Для сертификата панели нужен свободный порт 80/tcp." # Доверенный сертификат (Let's Encrypt или свой) – панель и подписка доступны снаружи по HTTPS. TRUSTED=no [[ $PANEL_SSL == ip || $PANEL_SSL == custom ]] && TRUSTED=yes if [[ $PANEL_SSL == custom ]]; then mkdir -p /root/cert/custom install -m 644 "$ucert" /root/cert/custom/fullchain.pem install -m 600 "$ukey" /root/cert/custom/privkey.pem fi kit_banner echo echo "Поставлю панель 3X-UI и протоколы на этот сервер. Займёт 3–5 минут." if ! command -v curl >/dev/null || ! command -v openssl >/dev/null; then export DEBIAN_FRONTEND=noninteractive; wait_apt_idle apt-get update -qq; apt-get install -y -qq curl openssl ca-certificates iproute2 >/dev/null fi local host_given=${HOST:+yes} HOST=${HOST:-$(public_ip)} [[ -n $HOST ]] || die "Не удалось узнать внешний IP. Укажите его: --host 1.2.3.4" local os_name mem_gb os_name=$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-Linux}") mem_gb=$(awk '/MemTotal/ {printf "%.1f", $2 / 1048576}' /proc/meminfo 2>/dev/null || echo "?") echo echo "Сервер: ${B}$HOST${N} · $os_name · ${mem_gb} ГБ памяти" # IP определился снаружи, а ссылки получат именно его: у части хостеров это не адрес самого сервера. if [[ -z $host_given && $yes == no && -t 1 ]] && { : /dev/null; then ask_tty "Адрес в ссылках: $HOST. Верно? Enter – да, или введите другой IPv4: " || true REPLY=${REPLY//[[:space:]]/} if [[ -n $REPLY ]]; then [[ $REPLY =~ ^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$ ]] || die "Нужен IPv4-адрес, например 203.0.113.10 (или запустите с --host)." HOST=$REPLY fi fi if [[ $HOST =~ ^[0-9.]+$ ]] && ! ip -4 -o addr show 2>/dev/null | grep -qw "$HOST"; then later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP." fi # Маскировка: из флагов, по вопросу пользователю или автоматически (сосед по подсети). if [[ -n $DOMAIN ]]; then [[ $PANEL_SSL == ip ]] || die "Свой домен работает с сертификатом панели Let's Encrypt на IP: освободите порт 80 и не указывайте --cert, --key и --panel-ssl none (сертификат для домена установщик получит сам)." domain_points_here "$DOMAIN" || die "Исправьте A-запись домена и запустите скрипт снова (DNS обновляется от нескольких минут до нескольких часов)." elif [[ -z $SNI && $yes == no && $PANEL_SSL == ip && $multi == no && ! -f $XUI_ENV && -t 1 ]] && { : /dev/null; then choose_masking fi if [[ -n $DOMAIN ]]; then SNI=$DOMAIN elif [[ -z $SNI ]]; then echo echo "Подбираю сайт для маскировки среди соседей по подсети… (около 250 коротких обращений к порту 443 соседних адресов, около минуты; не нужно – запустите с --sni или --domain)" local -a nb=() mapfile -t nb < <(nearby_sites | shuf) NB_ALL=("${nb[@]}") if ((${#nb[@]})); then SNI=${nb[0]}; SNI2=${nb[1]:-}; SNI3=${nb[2]:-} ok "найден: ${B}$SNI${N} · та же подсеть · TLS 1.3, h2, сертификат верный" else warn "В подсети подходящего сайта не нашёл." echo " Лучший выход – свой домен: запустите снова с --domain vpn.example.com" if [[ $yes == no && -t 1 ]] && { : /dev/null; then ask_tty " Продолжить с запасным сайтом из списка? [y/N] " || true [[ $REPLY =~ ^[yYдД]$ ]] || die "Остановился по вашей просьбе. Ставить можно снова в любой момент." else later "Сайт для маскировки взят из общего списка (в подсети сервера подходящего не нашлось). Надёжнее свой домен или kit net site." fi local -a pool=() mapfile -t pool < <(printf '%s\n' "${SNI_CANDIDATES[@]}" | shuf) for s in "${pool[@]}"; do if sni_ok "$s"; then SNI=$s; break; fi done [[ -n $SNI ]] || die "Ни один сайт из списка не ответил по TLS 1.3 + HTTP/2. Укажите свой: --sni example.com" ok "запасной сайт: ${B}$SNI${N}" fi elif ! sni_ok "$SNI"; then die "$SNI не отвечает по TLS 1.3 + HTTP/2 – REALITY с ним работать не будет. Выберите другой сайт." fi LINK_HOST=${DOMAIN:-$HOST} [[ $PANEL_ON == ip || -n $DOMAIN ]] || { PANEL_ON=ip; later "--panel-on domain нужен со своим доменом: панель и подписка открываются по IP."; } # Режим «всё на 443»: XHTTP и MTProto различаются по имени сайта, берём и им соседей по подсети. if [[ $TRUSTED == yes && $multi == no && ( -z $SNI2 || -z $SNI3 ) ]]; then local -a nb2=() echo if ((${#NB_ALL[@]})); then nb2=("${NB_ALL[@]}") else echo "Подбираю сайты для XHTTP и MTProto… (около 250 коротких обращений к порту 443 соседних адресов вашей подсети)" mapfile -t nb2 < <(nearby_sites | shuf) fi for s in "${nb2[@]}"; do [[ $s == "$SNI" || $s == "$SNI2" || $s == "$SNI3" ]] && continue if [[ -z $SNI2 ]]; then SNI2=$s; elif [[ -z $SNI3 ]]; then SNI3=$s; fi done fi # Для режима «всё на 443» XHTTP и MTProto нужны свои сайты: nginx различает их по SNI. local -a pool2=() mapfile -t pool2 < <(printf '%s\n' "${SNI_CANDIDATES[@]}" | shuf) for s in "${pool2[@]}"; do [[ $s == "$SNI" || $s == "$SNI2" || $s == "$SNI3" ]] && continue if [[ -z $SNI2 ]] && sni_ok "$s"; then SNI2=$s; continue; fi [[ -z $SNI3 && -n $SNI2 ]] && { SNI3=$s; break; } done SNI2=${SNI2:-$SNI}; SNI3=${SNI3:-www.cloudflare.com} if [[ $TRUSTED == yes && $multi == no && ( $SNI2 =~ $SNI_BRAND_RE || $SNI3 =~ $SNI_BRAND_RE ) ]]; then later "XHTTP и MTProto используют известные сайты из общего списка (достаточно соседей по подсети не нашлось). Заменить: kit net site" fi step "Пакеты" export DEBIAN_FRONTEND=noninteractive wait_apt_idle apt-get update -qq apt-get install -y -qq curl jq openssl qrencode ca-certificates iproute2 ufw socat cron unzip >/dev/null ok "curl, jq, openssl, qrencode, ufw и другое" # --- официальный установщик 3X-UI с закреплённой версией --- step "Панель 3X-UI $XUI_VERSION" local panel_port panel_path panel_user panel_pass panel_port=$(free_port) panel_path=$(rand_str 18) panel_user=$(rand_str 10) panel_pass=$(rand_str 20) if [[ -f $XUI_ENV ]]; then say "3X-UI уже стоит после прошлого запуска – продолжаю с создания подключений" else install_xui "$panel_port" "$panel_path" "$panel_user" "$panel_pass" fi [[ -f $XUI_ENV ]] || die "Установщик не сохранил данные входа. Лог: /var/log/3x-ui-install.log" # Данные для входа – из файла, который пишет сам установщик. connect_panel ok "панель установлена, установщик проверен по SHA256" if [[ $PANEL_SSL == custom ]]; then say "Подключаю ваш сертификат к панели" /usr/local/x-ui/x-ui cert -webCert /root/cert/custom/fullchain.pem -webCertKey /root/cert/custom/privkey.pem >/dev/null 2>&1 systemctl restart x-ui API="https://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" wait_panel fi # Установщик 3X-UI мог не получить сертификат на IP (порт 80 закрыт у хостера, лимит # Let's Encrypt, сбой). Без него панель осталась бы без TLS, а nginx проксирует её по https: # получился бы сервер, который говорит «Готово», а панель не открывается. if [[ $PANEL_SSL == ip && ! -s /root/cert/ip/fullchain.pem ]]; then if [[ -n $DOMAIN ]]; then ip_cert_self_signed_fallback || die "Остановил установку по вашему выбору. Запустите скрипт снова без --domain (панель будет доступна через SSH-туннель) или позже, когда сертификат на IP снова можно будет получить." else later "Let's Encrypt не выдал сертификат на IP $HOST (порт 80 закрыт у хостера, лимит выпусков или сбой). Ставлю без него: панель будет доступна только через SSH-туннель." PANEL_SSL=none TRUSTED=no fi fi # Без сертификата панель и подписки не должны торчать наружу по HTTP. if [[ $PANEL_SSL == none ]]; then say "Панель без сертификата – открываю её только для SSH-туннеля (127.0.0.1)" local all all=$(api POST setting/all '{}') api POST setting/update "$(jq -c '.webListen = "127.0.0.1" | .subListen = "127.0.0.1"' <<<"$all")" >/dev/null systemctl restart x-ui wait_panel fi # --- ядро Xray, совместимое со всеми клиентами --- step "Ядро Xray $XRAY_CORE и сертификат" set_xray_core # --- сертификат для протоколов с TLS --- setup_tls_cert ok "ядро закреплено, сертификат для протоколов с TLS готов" # --- подключения: все выбранные протоколы, один subId на пользователя --- step "Подключения" EXISTING=$(api GET inbounds/list) SUBID="" # «Всё на 443» – для новых установок с доверенным сертификатом. Старую многопортовую # установку не переделываем: перенос работающих подключений – осознанное решение. if [[ $TRUSTED == yes && $multi == no ]]; then if jq -e 'any(.[]; .remark == "REALITY" and (.listen // "") != "127.0.0.1")' <<<"$EXISTING" >/dev/null; then later "Установка уже работает в режиме с отдельными портами – оставляю его." else SINGLE=yes fi fi if [[ -n $DOMAIN ]]; then [[ $SINGLE == yes ]] || die "Свой домен работает только в режиме «всё на 443», а эта установка уже работает с отдельными портами." issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя. Исправьте причину и запустите скрипт снова." OPEN+=("80/tcp") fi # Одна заглушка на сервер: её показывают nginx на 443 и Hysteria2 (masquerade), её же берёт kit net masq. install -d -m 755 /var/www/kit [[ -s /var/www/kit/index.html ]] || stub_site >/var/www/kit/index.html chmod 644 /var/www/kit/index.html # Байт в байт как у nginx: $(…) срезал бы последний перевод строки, и длина ответа Hysteria2 отличалась бы. STUB_HTML=$(cat /var/www/kit/index.html; printf x); STUB_HTML=${STUB_HTML%x} local p for p in "${PROTOS[@]}"; do "proto_$p"; done # Первый пользователь – сразу на всех протоколах (как «kit user add»). ensure_user # --- подписка: ссылки, Clash/Mihomo и JSON с автоопределением клиента --- setup_subscription [[ $SINGLE == yes ]] && setup_nginx ok "${#CREATED[@]} подключений, пользователь $NAME" step "Защита и обновления" install_kit_cli if [[ $TRUSTED == yes ]]; then /usr/local/bin/kit __subextra >/dev/null 2>&1 || later "Адреса Clash и JSON на странице подписки не подключились: kit update --force" fi if [[ $PANEL_ON == domain ]]; then /usr/local/bin/kit net panel domain >/dev/null 2>&1 || { PANEL_ON=ip; later "Панель и подписку по домену включить не удалось: оставил по IP. Попробуйте позже: kit net panel domain"; } fi brand_xui_menu # Автообновление kit и kit-sub: только подписанные релизы, выключается kit update --manual. /usr/local/bin/kit update --auto >/dev/null 2>&1 || later "Автообновление не включилось – включите позже: kit update --auto" # Общий лимит трафика: у пользователя несколько записей (AmneziaWG считается отдельно), раз в 5 минут их трафик складывается. /usr/local/bin/kit __limit-timer on >/dev/null 2>&1 || later "Проверка общего лимита трафика не включилась – включите позже: kit fix" /usr/local/bin/kit net dns on >/dev/null 2>&1 || later "DNS в подписке не настроился – включите позже: kit net dns on" # --- файрвол --- if [[ $UFW == yes ]]; then [[ $TRUSTED == yes && $SINGLE == no ]] && OPEN+=("$XUI_PANEL_PORT/tcp" "$SUB_PORT/tcp") [[ $PANEL_SSL == ip ]] && OPEN+=("80/tcp") setup_ufw fi step "Проверка" local chk="" i bad=yes for i in 1 2 3; do sleep 4 chk=$(/usr/local/bin/kit check --deep 2>&1) && { bad=no; break; } done if [[ $bad == no ]]; then ok "подключения работают: REALITY, XHTTP и Hysteria2 проверены клиентом с самого сервера" else install -m 600 /dev/null /var/log/kit-install-check.log printf '%s\n' "$chk" >/var/log/kit-install-check.log later "kit check нашёл: $(sed 's/\x1b\[[0-9;]*m//g' <<<"$chk" | grep -a -m1 '❌' | cut -c1-110). Подробности: kit check --deep" fi # --- итог --- local panel_url links local ph=$HOST if [[ $PANEL_ON == domain ]]; then ph=$DOMAIN; SUB_URL=${SUB_URL/\/\/$HOST\//\/\/$DOMAIN\/}; fi if [[ $SINGLE == yes ]]; then panel_url="https://$ph/${XUI_WEB_BASE_PATH#/}" panel_url="${panel_url%/}/" elif [[ $TRUSTED == yes ]]; then panel_url="https://$HOST:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH" else panel_url="http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH (через SSH-туннель: ssh -L $XUI_PANEL_PORT:127.0.0.1:$XUI_PANEL_PORT root@$HOST)" fi links=$(sub_links "$SUBID") # У установок до kit 1.1 AmneziaWG и MTProto лежат в подписках «-awg» и «-tg». local extra extra=$(sub_links "$SUBID-awg" 1; sub_links "$SUBID-tg" 1) [[ -n $extra ]] && links+=$'\n'"$extra" AWG_LINKS=$(grep '^vpn://' <<<"$links" || true) # MTProto слушает localhost, а клиенты приходят через nginx на 443. [[ $SINGLE == yes ]] && links=$(sed "s/^\(tg:\/\/proxy?\)\(.*\)port=${INNER[mtproto]}/\1\2port=443/" <<<"$links") umask 077 { echo "3X-UI KIT (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)" echo echo "Панель: $panel_url" echo "Логин: $XUI_USERNAME" echo "Пароль: $XUI_PASSWORD" echo [[ $TRUSTED == yes ]] && { echo "Подписка ($NAME) – все протоколы одной ссылкой:"; echo "$SUB_URL"; echo; } echo "Отдельные подключения ($NAME):" echo "$links" } >"$RESULT" echo echo "${G}${B}Готово! Сервер работает.${N}" [[ -n $DOMAIN ]] && echo "Маскировка: свой домен ${B}$DOMAIN${N}, сертификат Let's Encrypt продлевается сам." [[ $SELF_IP_CERT == yes ]] && later "Сертификат на IP самоподписанный: пользуйтесь ссылками на отдельные подключения, подписка в приложениях может не открыться." echo echo "${B}ПАНЕЛЬ${N}" if [[ $TRUSTED == yes ]]; then echo " $panel_url" else echo " Только через SSH-туннель. На вашем компьютере:" echo " ssh -L $XUI_PANEL_PORT:127.0.0.1:$XUI_PANEL_PORT root@$HOST" echo " Затем в браузере: http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH" fi echo " Логин: ${B}$XUI_USERNAME${N} Пароль: ${B}$XUI_PASSWORD${N}" echo " ${D}(сохранено в $RESULT, файл виден только root)${N}" echo if [[ $TRUSTED == yes ]]; then echo "${B}ПОДПИСКА${N} – одна ссылка для всех протоколов" echo " ${B}$SUB_URL${N}" echo " Вставьте в Happ, Hiddify, Karing, v2rayN, Clash Verge или FlClash." qrencode -t ANSIUTF8 -m 1 "$SUB_URL" || true echo KIT_NO_QR=1 /usr/local/bin/kit __links "$NAME" "$SUBID" || true else /usr/local/bin/kit __links "$NAME" "$SUBID" || true fi echo echo "${D}Порты, выключение и включение протоколов: ${N}${B}kit net${N}" if ((${#WARNINGS[@]})); then echo echo "${B}ВНИМАНИЕ${N}" local w n=0 for w in "${WARNINGS[@]}"; do n=$((n + 1)); ((n > 3)) && break echo " ${Y}!${N} $w" done ((${#WARNINGS[@]} > 3)) && echo " … и ещё $((${#WARNINGS[@]} - 3)): kit check" fi echo echo "${B}ДАЛЬШЕ${N}" echo " ${B}kit${N} меню управления" echo " ${B}kit user add имя --gb 50 --days 30${N} добавить пользователя" echo " ${B}kit check --deep${N} проверить, что всё работает" echo " ${B}kit backup${N} копия сервера (держите в надёжном месте)" } # Официальный установщик 3X-UI закреплённой версии, сверенный по SHA256. install_xui() { # порт путь логин пароль local tmp tmp=$(mktemp) say "Ставлю 3X-UI $XUI_VERSION официальным установщиком (пара минут)" curl -fsSL --retry 3 -o "$tmp" "https://raw.githubusercontent.com/$XUI_REPO/$XUI_VERSION/install.sh" [[ $(sha256sum "$tmp" | awk '{print $1}') == "$XUI_INSTALL_SHA256" ]] \ || die "Установщик 3X-UI $XUI_VERSION не совпал с проверенным (SHA256) – не запускаю. Сообщите нам: github.com/itsnotkubrick/3X-UI_KIT/issues" if ! XUI_NONINTERACTIVE=1 XUI_SSL_MODE="${PANEL_SSL/custom/none}" XUI_SERVER_IP="$HOST" \ XUI_PANEL_PORT="$1" XUI_WEB_BASE_PATH="$2" \ XUI_USERNAME="$3" XUI_PASSWORD="$4" \ bash "$tmp" "$XUI_VERSION" /var/log/3x-ui-install.log 2>&1; then tail -20 /var/log/3x-ui-install.log >&2 die "Установщик 3X-UI завершился с ошибкой. Полный лог: /var/log/3x-ui-install.log" fi rm -f "$tmp" } # Панель: API по https или http – сертификат мог появиться после установщика. connect_panel() { # shellcheck disable=SC1090 . "$XUI_ENV" TOKEN=$XUI_API_TOKEN local scheme i # Панель только что запущена и отвечает не сразу: перебираем обе схемы заново, пока не ответит (раньше после первой неудачной # проверки скрипт оставался на http и до конца стучался по http в порт с TLS). for i in $(seq 1 60); do for scheme in https http; do API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null && return 0 done sleep 2 done die "Панель не отвечает. Лог: journalctl -u x-ui -n 50" } # Архив ядра: скачиваем по очереди с GitHub и с зеркала и принимаем только тот, чья SHA256 совпала. xray_zip_arch() { case "$(uname -m)" in x86_64 | amd64) echo 64 ;; aarch64 | arm64) echo arm64-v8a ;; *) return 1 ;; esac } xray_fetch_verified() { # каталог; кладёт xray.zip, 0 – сумма совпала local d=$1 arch want got base arch=$(xray_zip_arch) || return 1 want=${XRAY_ZIP_SHA256[$arch]} for base in "${XRAY_MIRRORS[@]}"; do curl -fsSL --connect-timeout 10 --max-time 180 --retry 1 -o "$d/xray.zip" \ "$base/XTLS/Xray-core/releases/download/$XRAY_CORE/Xray-linux-$arch.zip" 2>/dev/null || continue got=$(sha256sum "$d/xray.zip" | awk '{print $1}') [[ $got == "$want" ]] && return 0 warn "Архив ядра Xray с ${base#https://} не прошёл проверку SHA256 – отбрасываю." done return 1 } xray_install_fallback() { local d bin d=$(mktemp -d) if ! command -v unzip >/dev/null; then wait_apt_idle apt-get install -y -qq unzip >/dev/null || { warn "Не удалось поставить unzip – запасной путь загрузки ядра недоступен."; rm -rf "$d"; return 0; } fi bin=$(ls /usr/local/x-ui/bin/xray-linux-* 2>/dev/null | head -n 1) || true if [[ -n $bin ]] && xray_fetch_verified "$d" && unzip -q -o "$d/xray.zip" xray -d "$d"; then install -m 755 "$d/xray" "$bin.new" && mv -f "$bin.new" "$bin" systemctl restart x-ui wait_panel fi rm -rf "$d" } set_xray_core() { local cur_core cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}') if [[ $cur_core != "$XRAY_CORE" ]]; then say "Ставлю ядро Xray $XRAY_CORE (совместимо с Hiddify, Mihomo и другими клиентами)" # 3X-UI 3.9 бывает отвечает ошибкой «GitHub API error», хотя ядро уже заменено: ответ не считаем # проверкой, смотрим на версию ядра ниже. (api POST "server/installXray/$XRAY_CORE" '{}' >/dev/null) 2>/dev/null || warn "Панель не подтвердила смену ядра – проверяю версию сам." for _ in $(seq 1 30); do cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}') [[ $cur_core == "$XRAY_CORE" ]] && break sleep 2 done if [[ $cur_core != "$XRAY_CORE" ]]; then warn "Панель не смогла скачать ядро с GitHub – пробую запасной путь: зеркало с проверкой SHA256." xray_install_fallback cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}') fi [[ $cur_core == "$XRAY_CORE" ]] || later "Не удалось сменить ядро Xray (сейчас $cur_core). Клиенты на Mihomo и sing-box могут не подключиться." fi } # Порты SSH, на которых сервер слушает сейчас: из настроек sshd, из ss (в Ubuntu 24.04 порт держит # systemd, но сам sshd тоже в списке) и порт текущего подключения. Нужны, чтобы ufw не запер вас. ssh_ports() { { sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' ss -H -ltnp 2>/dev/null | awk '/"sshd"/ {n = split($4, a, ":"); print a[n]}' awk '{print $4}' <<<"${SSH_CONNECTION:-}" } | grep -E '^[0-9]{1,5}$' | sort -un || true } setup_ufw() { local ssh_port o ssh_port=$(ssh_ports) for o in ${ssh_port:-22}; do OPEN+=("$o/tcp"); done ok "ufw: открыто ${#OPEN[@]} портов (${OPEN[*]}), панель не торчит наружу" for o in "${OPEN[@]}"; do ufw allow "$o" >/dev/null; done ufw --force enable >/dev/null || later "ufw не включился (так бывает в контейнерах) – откройте порты у хостера вручную." } # ---------- сертификат ---------- setup_tls_cert() { PIN="" if [[ $PANEL_SSL == custom ]]; then CERT=/root/cert/custom/fullchain.pem; KEY=/root/cert/custom/privkey.pem elif [[ $PANEL_SSL == ip && -s /root/cert/ip/fullchain.pem ]]; then CERT=/root/cert/ip/fullchain.pem; KEY=/root/cert/ip/privkey.pem # Самоподписанный сертификат на IP: отпечаток уходит в ссылки, чтобы клиенты доверяли именно ему. if [[ $SELF_IP_CERT == yes ]]; then PIN=$(openssl x509 -in "$CERT" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f') fi else # Без Let's Encrypt – свой сертификат, а его отпечаток уходит в ссылки (pcs), # чтобы клиенты доверяли именно ему. CERT=/root/cert/self/fullchain.pem; KEY=/root/cert/self/privkey.pem if [[ ! -s $CERT ]]; then mkdir -p /root/cert/self local san="DNS:$HOST" [[ $HOST =~ ^[0-9.]+$ ]] && san="IP:$HOST" openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "$KEY" -out "$CERT" \ -subj "/CN=$HOST" -addext "subjectAltName=$san" -days 3650 2>/dev/null chmod 600 "$KEY" fi PIN=$(openssl x509 -in "$CERT" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f') fi } tls_json() { # alpn(JSON-массив) jq -nc --arg sni "$HOST" --arg c "$CERT" --arg k "$KEY" --arg pin "$PIN" --argjson alpn "$1" '{ serverName: $sni, alpn: $alpn, certificates: [{certificateFile: $c, keyFile: $k}], settings: ({fingerprint: "chrome"} + (if $pin != "" then {pinnedPeerCertSha256: [$pin]} else {} end))}' } # ---------- протоколы ---------- # AmneziaWG – в отдельной подписке: приложения на Xray и sing-box (Happ, v2rayN, Karing, # Hiddify) его не умеют и видят как обычный WireGuard, который не подключается. client_base() { # суффикс local sid=$SUBID [[ $1 == awg* ]] && sid="$SUBID-awg" [[ $1 == mtproto ]] && sid="$SUBID-tg" # ссылка для Telegram, VPN-приложениям не нужна jq -nc --arg e "$NAME-$1" --arg s "$sid" '{email: $e, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: 0, subId: $s, comment: "", reset: 0}' } uuid() { cat /proc/sys/kernel/random/uuid; } rnd() { shuf -i "$1-$2" -n 1; } # add_inbound remark port proto(tcp|udp|both|inner) protocol settings stream # inner – подключение за nginx: слушает 127.0.0.1, наружу порт не открываем. add_inbound() { local remark=$1 port=$2 net=$3 protocol=$4 settings=$5 stream=$6 body listen="" [[ $net == inner ]] && listen=127.0.0.1 if jq -e --arg r "$remark" 'any(.[]; .remark == $r)' <<<"$EXISTING" >/dev/null; then CREATED+=("$remark"); open_port "$port" "$net"; return fi # Клиентов в подключение не кладём: пользователь добавляется потом сразу во все подключения. settings=$(jq -c 'if has("clients") then .clients = [] else . end' <<<"$settings") local n local nets=$net [[ $net == both ]] && nets="tcp udp" [[ $net == inner ]] && nets=tcp for n in $nets; do if port_busy "$port" "$n"; then later "$remark пропущен: порт $port/$n занят"; return; fi done body=$(jq -nc --arg rm "$remark" --argjson port "$port" --arg p "$protocol" --arg s "$settings" --arg st "$stream" --arg l "$listen" '{ remark: $rm, enable: true, listen: $l, port: $port, protocol: $p, settings: $s, streamSettings: $st, sniffing: "{\"enabled\":true,\"destOverride\":[\"http\",\"tls\",\"quic\"],\"metadataOnly\":false,\"routeOnly\":false}", expiryTime: 0, total: 0}') local out out=$(curl -sSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X POST -d "$body" "$API/inbounds/add") if [[ $(jq -r '.success' <<<"$out") != true ]] && grep -q 'Duplicate email' <<<"$out"; then # Клиент с таким именем остался от удалённого подключения – берём уникальное имя. body=$(jq -c --arg sfx "-$(openssl rand -hex 2)" '.settings |= (fromjson | .clients[0].email += $sfx | tojson)' <<<"$body") out=$(curl -sSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X POST -d "$body" "$API/inbounds/add") fi [[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель не создала $remark: $(jq -r '.msg // .' <<<"$out" | head -c 300)" CREATED+=("$remark"); open_port "$port" "$net" } open_port() { # port net case $2 in inner) ;; tcp|udp) OPEN+=("$1/$2") ;; both) OPEN+=("$1/tcp" "$1/udp") ;; esac } # External Proxy 3X-UI: ссылки ведут на HOST:443, хотя подключение слушает localhost. # SNI – только для TLS через nginx: у REALITY своё имя сайта маскировки, его не трогаем. ext_proxy() { # forceTls(same|tls) alpn(JSON) local sni="" [[ $HOST =~ ^[0-9.]+$ ]] || sni=$HOST jq -nc --arg f "$1" --arg h "$HOST" --arg lh "${LINK_HOST:-$HOST}" --arg sni "$sni" --argjson alpn "${2:-null}" '[{forceTls: $f, dest: (if $f == "same" then $lh else $h end), port: 443, remark: ""} + (if $f == "tls" then {fingerprint: "chrome", alpn: $alpn} + (if $sni != "" then {sni: $sni} else {} end) else {} end)]' } proto_reality() { local keys stream settings keys=$(api GET server/getNewX25519Cert) settings=$(jq -nc --arg id "$(uuid)" --argjson c "$(client_base reality)" '{clients: [$c + {id: $id, flow: "xtls-rprx-vision"}], decryption: "none", fallbacks: []}') stream=$(jq -nc --arg sni "$SNI" --arg target "$(reality_target "$SNI")" --argjson k "$keys" --arg sid "$(openssl rand -hex 8)" '{ network: "tcp", security: "reality", externalProxy: [], realitySettings: {show: false, xver: 0, target: $target, serverNames: [$sni], privateKey: $k.privateKey, minClientVer: "", maxClientVer: "", maxTimediff: 0, shortIds: [$sid], settings: {publicKey: $k.publicKey, fingerprint: "chrome", serverName: "", spiderX: "/"}}, tcpSettings: {acceptProxyProtocol: false, header: {type: "none"}}}') if [[ $SINGLE == yes ]]; then stream=$(jq -c --argjson e "$(ext_proxy same)" '.externalProxy = $e | .tcpSettings.acceptProxyProtocol = true' <<<"$stream") add_inbound "REALITY" "${INNER[reality]}" inner vless "$settings" "$stream" else add_inbound "REALITY" "$PORT" tcp vless "$settings" "$stream" fi } # Второй REALITY на высоком свободном порту, напрямую (без nginx). Где-то 443 и привычные порты # бывает удобнее высокий случайный порт; ключи, shortId и порт у него свои, сайт маскировки тот же. proto_reality2() { local keys stream settings port keys=$(api GET server/getNewX25519Cert) settings=$(jq -nc --arg id "$(uuid)" --argjson c "$(client_base reality2)" '{clients: [$c + {id: $id, flow: "xtls-rprx-vision"}], decryption: "none", fallbacks: []}') stream=$(jq -nc --arg sni "$SNI" --arg target "$(reality_target "$SNI")" --argjson k "$keys" --arg sid "$(openssl rand -hex 8)" '{ network: "tcp", security: "reality", externalProxy: [], realitySettings: {show: false, xver: 0, target: $target, serverNames: [$sni], privateKey: $k.privateKey, minClientVer: "", maxClientVer: "", maxTimediff: 0, shortIds: [$sid], settings: {publicKey: $k.publicKey, fingerprint: "chrome", serverName: "", spiderX: "/"}}, tcpSettings: {acceptProxyProtocol: false, header: {type: "none"}}}') port=$(free_port) add_inbound "REALITY-2" "$port" tcp vless "$settings" "$stream" } proto_xhttp() { local keys stream settings keys=$(api GET server/getNewX25519Cert) settings=$(jq -nc --arg id "$(uuid)" --argjson c "$(client_base xhttp)" '{clients: [$c + {id: $id, flow: ""}], decryption: "none"}') stream=$(jq -nc --arg sni "$SNI" --argjson k "$keys" --arg sid "$(openssl rand -hex 8)" --arg path "/$(rand_str 10 | tr 'A-Z' 'a-z')" '{ network: "xhttp", security: "reality", xhttpSettings: {path: $path, mode: "auto"}, realitySettings: {target: ($sni + ":443"), serverNames: [$sni], privateKey: $k.privateKey, shortIds: [$sid], settings: {publicKey: $k.publicKey, fingerprint: "chrome", spiderX: "/"}}}') if [[ $SINGLE == yes ]]; then stream=$(jq -c --arg sni "$SNI2" --argjson e "$(ext_proxy same)" '.realitySettings.target = ($sni + ":443") | .realitySettings.serverNames = [$sni] | .externalProxy = $e | .sockopt = {acceptProxyProtocol: true}' <<<"$stream") add_inbound "XHTTP" "${INNER[xhttp]}" inner vless "$settings" "$stream" else add_inbound "XHTTP" "${PORTS[xhttp]}" tcp vless "$settings" "$stream" fi } proto_ws() { local settings stream settings=$(jq -nc --arg id "$(uuid)" --argjson c "$(client_base ws)" '{clients: [$c + {id: $id, flow: ""}], decryption: "none"}') stream=$(jq -nc --argjson t "$(tls_json '["http/1.1"]')" --arg path "/$(rand_str 10 | tr 'A-Z' 'a-z')" '{network: "ws", security: "tls", wsSettings: {path: $path}, tlsSettings: $t}') if [[ $SINGLE == yes ]]; then stream=$(jq -c --argjson e "$(ext_proxy tls '["http/1.1"]')" '{network, wsSettings, security: "none", externalProxy: $e}' <<<"$stream") add_inbound "VLESS-WS" "${INNER[ws]}" inner vless "$settings" "$stream" else add_inbound "VLESS-WS" "${PORTS[ws]}" tcp vless "$settings" "$stream" fi } proto_trojan() { local settings stream settings=$(jq -nc --arg pw "$(rand_str 16)" --argjson c "$(client_base trojan)" '{clients: [$c + {password: $pw}]}') stream=$(jq -nc --argjson t "$(tls_json '["h2"]')" --arg sn "$(rand_str 8 | tr 'A-Z' 'a-z')" '{network: "grpc", security: "tls", grpcSettings: {serviceName: $sn}, tlsSettings: $t}') if [[ $SINGLE == yes ]]; then stream=$(jq -c --argjson e "$(ext_proxy tls '["h2"]')" '{network, grpcSettings, security: "none", externalProxy: $e}' <<<"$stream") add_inbound "Trojan-gRPC" "${INNER[trojan]}" inner trojan "$settings" "$stream" else add_inbound "Trojan-gRPC" "${PORTS[trojan]}" tcp trojan "$settings" "$stream" fi } proto_vmess() { local settings stream settings=$(jq -nc --arg id "$(uuid)" --argjson c "$(client_base vmess)" '{clients: [$c + {id: $id, security: "auto", alterId: 0}]}') stream=$(jq -nc --argjson t "$(tls_json '["http/1.1"]')" --arg path "/$(rand_str 10 | tr 'A-Z' 'a-z')" '{network: "ws", security: "tls", wsSettings: {path: $path}, tlsSettings: $t}') if [[ $SINGLE == yes ]]; then stream=$(jq -c --argjson e "$(ext_proxy tls '["http/1.1"]')" '{network, wsSettings, security: "none", externalProxy: $e}' <<<"$stream") add_inbound "VMess-WS" "${INNER[vmess]}" inner vmess "$settings" "$stream" else add_inbound "VMess-WS" "${PORTS[vmess]}" tcp vmess "$settings" "$stream" fi } proto_ss() { local settings settings=$(jq -nc --arg pw "$(openssl rand -base64 16)" --arg upw "$(openssl rand -base64 16)" --argjson c "$(client_base ss)" '{ method: "2022-blake3-aes-128-gcm", password: $pw, network: "tcp,udp", clients: [$c + {method: "", password: $upw}]}') add_inbound "Shadowsocks" "${PORTS[ss]}" both shadowsocks "$settings" '{"network":"tcp","security":"none"}' } proto_hy2() { local settings stream settings=$(jq -nc --arg a "$(rand_str 16)" --argjson c "$(client_base hy2)" '{version: 2, clients: [$c + {auth: $a}]}') # На посторонний HTTP/3-запрос сервер отвечает страницей сайта (masquerade), а не молчанием или ошибкой. stream=$(jq -nc --argjson t "$(tls_json '["h3"]')" --arg page "${STUB_HTML:-}" '{network: "hysteria", security: "tls", tlsSettings: $t, hysteriaSettings: ({version: 2} + (if $page != "" then {masquerade: {type: "string", content: $page, statusCode: 200, headers: {"content-type": "text/html; charset=utf-8"}}} else {} end))}') add_inbound "Hysteria2" "$PORT" udp hysteria "$settings" "$stream" } proto_tuic() { local settings id id=$(uuid) settings=$(jq -nc --arg id "$id" --arg pw "$(rand_str 16)" --arg c "$CERT" --arg k "$KEY" --argjson cb "$(client_base tuic)" '{ server: {certificate: $c, private_key: $k, congestion_control: "bbr", alpn: ["h3"], udp_relay_mode: "native", zero_rtt_handshake: false, log_level: "warn", sni: ""}, clients: [$cb + {uuid: $id, id: $id, password: $pw}]}') add_inbound "TUIC" "${PORTS[tuic]}" udp tuic "$settings" '{}' } wg_pair() { /usr/local/x-ui/bin/xray-linux-* wg; } proto_wg() { local srv cli settings srv=$(wg_pair); cli=$(wg_pair) settings=$(jq -nc --arg sk "$(awk '/Private/ {print $NF}' <<<"$srv")" \ --arg pr "$(awk '/Private/ {print $NF}' <<<"$cli")" --arg pu "$(awk '/Public|Password/ {print $NF}' <<<"$cli" | head -1)" \ --argjson c "$(client_base wg)" '{mtu: 1420, secretKey: $sk, peers: [], subnetIp: "10.0.0.0", subnetCidr: 24, clients: [$c + {privateKey: $pr, publicKey: $pu, allowedIPs: ["10.0.0.2/32"]}]}') add_inbound "WireGuard" "${PORTS[wg]}" udp wireguard "$settings" '{"network":"tcp","security":"none"}' } # AmneziaWG: классические параметры понимают Mihomo и роутеры Keenetic, # полный набор 3.1 – только приложения AmneziaVPN/AmneziaWG. Поэтому два подключения. awg_obfuscation() { # classic|full local jmin s1 s2 jmin=$(rnd 40 89); s1=$(rnd 15 150); s2=$(rnd 15 150) while ((s1 + 56 == s2)); do s2=$(rnd 15 150); done local o o=$(jq -nc --argjson jc "$(rnd 3 6)" --argjson jmin "$jmin" --argjson jmax "$((jmin + $(rnd 50 250)))" --argjson s1 "$s1" --argjson s2 "$s2" \ --arg h1 "$(rnd 5 536870911)" --arg h2 "$(rnd 536870912 1073741823)" --arg h3 "$(rnd 1073741824 1610612735)" --arg h4 "$(rnd 1610612736 2147483647)" \ '{jc: $jc, jmin: $jmin, jmax: $jmax, s1: $s1, s2: $s2, h1: $h1, h2: $h2, h3: $h3, h4: $h4}') if [[ $1 == full ]]; then # С защитой заголовков (3.1) документация AmneziaWG советует H1–H4 = 1, 2, 3, 4: # тип сообщения тогда скрывает сама защита, а свои заголовки отключаются. local cp rk rj rt ka ha cp=$(rnd 8 24); rk=$(rnd 100 120); rj=$((rk + $(rnd 10 40) + $(rnd 30 60))); rt=$(rnd 3 6); ka=$(rnd 8 12); ha=$(rnd 15 25) o=$(jq -c --argjson s3 "$(rnd 12 55)" --argjson s4 "$(rnd 12 27)" --arg i1 "" --arg hp "$(openssl rand -base64 32)" \ --arg cp "$cp-$((cp + $(rnd 8 40)))" --arg rk "$rk-$((rk + $(rnd 10 40)))" --arg rj "$rj-$((rj + $(rnd 30 90)))" \ --arg rt "$rt-$((rt + $(rnd 1 4)))" --arg ka "$ka-$((ka + $(rnd 2 8)))" --arg ha "$ha-$((ha + $(rnd 5 25)))" \ '. + {h1: "1", h2: "2", h3: "3", h4: "4", s3: $s3, s4: $s4, i1: $i1, headerProtectionKey: $hp, contentPaddingAddition: $cp, rekeyAfterTime: $rk, rejectAfterTime: $rj, rekeyTimeout: $rt, keepaliveTimeout: $ka, maxHandshakeAttempts: $ha}' <<<"$o") fi echo "$o" } awg_inbound() { # remark port subnet client-ip suffix mode local srv cli settings srv=$(wg_pair); cli=$(wg_pair) settings=$(jq -nc --arg pk "$(awk '/Private/ {print $NF}' <<<"$srv")" --arg pub "$(awk '/Public|Password/ {print $NF}' <<<"$srv" | head -1)" \ --arg pr "$(awk '/Private/ {print $NF}' <<<"$cli")" --arg pu "$(awk '/Public|Password/ {print $NF}' <<<"$cli" | head -1)" \ --arg net "$3" --arg ip "$4" --argjson o "$(awg_obfuscation "$6")" --argjson c "$(client_base "$5")" '{ server: ({privateKey: $pk, publicKey: $pub, subnetIp: $net, subnetCidr: 24, primaryDns: "1.1.1.1", secondaryDns: "8.8.8.8"} + $o), clients: [$c + {privateKey: $pr, publicKey: $pu, allowedIPs: [$ip]}]}') add_inbound "$1" "$2" udp amneziawg "$settings" '{}' } proto_awg() { awg_inbound "AmneziaWG" "${PORTS[awg]}" 10.8.1.0 10.8.1.2/32 awg classic; } proto_awg3() { awg_inbound "AmneziaWG-3.1" "${PORTS[awg3]}" 10.8.2.0 10.8.2.2/32 awg3 full; } telegram_reachable() { local ip for ip in 149.154.167.51 149.154.175.50 91.108.56.130; do timeout 5 bash -c "/dev/null && return 0 done return 1 } proto_mtproto() { # MTProto бесполезен, если сам сервер не достаёт до Telegram (так бывает у отдельных хостеров). if ! telegram_reachable; then later "MTProto пропущен: с этого сервера недоступны серверы Telegram – прокси для Telegram здесь работать не будет." return fi local settings if [[ $SINGLE == yes ]]; then # FakeTLS-домен – свой сайт: nginx узнаёт MTProto по нему и передаёт mtg с реальным IP клиента. settings=$(jq -nc --arg d "$SNI3" --argjson c "$(client_base mtproto)" '{fakeTlsDomain: $d, proxyProtocolListener: true, clients: [$c + {secret: ""}]}') add_inbound "MTProto" "${INNER[mtproto]}" inner mtproto "$settings" '{}' else settings=$(jq -nc --argjson c "$(client_base mtproto)" '{fakeTlsDomain: "www.cloudflare.com", clients: [$c + {secret: ""}]}') add_inbound "MTProto" "${PORTS[mtproto]}" tcp mtproto "$settings" '{}' fi } # ---------- пользователи ---------- # Один клиент 3X-UI на все подключения: общие трафик, лимиты и срок, одна подписка. ensure_user() { local list me ids missing legacy list=$(api GET clients/list | jq -c 'if type == "array" then . else .clients end') ids=$(non_awg_ids) me=$(jq -c --arg e "$NAME" 'map(select(.email == $e))[0] // empty' <<<"$list") legacy=$(jq -r --arg p "$NAME-" 'map(select((.email | startswith($p)) and (.email | test("-awg[0-9]*$") | not))) | .[0].subId // empty' <<<"$list") if [[ -n $me ]]; then SUBID=$(jq -r '.subId' <<<"$me") missing=$(jq -c --argjson all "$ids" '$all - (.inboundIds // [])' <<<"$me") [[ $missing == "[]" ]] || api POST "clients/$NAME/attach" "$(jq -nc --argjson i "$missing" '{inboundIds: $i}')" >/dev/null awg_attach "$NAME" "$SUBID" elif [[ -n $legacy ]]; then # Установка до kit 1.1: у каждого протокола свой клиент – оставляем как есть. SUBID=$legacy else SUBID=$(rand_str 16 | tr 'A-Z' 'a-z') api POST clients/add "$(jq -nc --arg e "$NAME" --arg s "$SUBID" --argjson ids "$ids" \ '{client: {email: $e, subId: $s, totalGB: 0, expiryTime: 0, limitIp: 0, enable: true, flow: "xtls-rprx-vision", comment: "kit"}, inboundIds: $ids}')" >/dev/null awg_attach "$NAME" "$SUBID" fi } # В 3X-UI 3.x у клиента одна запись и в ней одна пара ключей WireGuard и один адрес. Если # клиент подключён к двум AmneziaWG, в подписку для обоих уходят ключ и адрес одного из них, # и второй сервер клиента не узнаёт (проверено 2026-09-27). Поэтому к первому AmneziaWG # подключаем основную запись, а ко второму – запись-«двойник» «имя-awg» с подпиской «-awg» # (subId в 3X-UI обязан быть уникальным) и теми же лимитами; kit-sub подмешивает её в Clash. awg_ids() { api GET inbounds/list | jq -r '[.[] | select(.protocol == "amneziawg") | .id] | sort | .[]'; } non_awg_ids() { api GET inbounds/list | jq -c '[.[] | select(.protocol != "amneziawg") | .id]'; } awg_attach() { # имя subId [лимит-байт] [срок-мс] [устройств] local name=$1 sid=$2 total=${3:-0} exp=${4:-0} lim=${5:-0} n=1 id email have have=$(api GET clients/list | jq -c 'if type == "array" then . else .clients end') for id in $(awg_ids); do local esid=$sid if ((n == 1)); then email=$name elif ((n == 2)); then email="$name-awg"; esid="$sid-awg" else email="$name-awg$n"; esid="$sid-awg$n"; fi n=$((n + 1)) if jq -e --arg e "$email" --argjson i "$id" 'any(.[]; .email == $e and ((.inboundIds // []) | index($i)))' <<<"$have" >/dev/null; then continue elif jq -e --arg e "$email" 'any(.[]; .email == $e)' <<<"$have" >/dev/null; then api POST "clients/$email/attach" "$(jq -nc --argjson i "$id" '{inboundIds: [$i]}')" >/dev/null else api POST clients/add "$(jq -nc --arg e "$email" --arg s "$esid" --argjson t "$total" --argjson x "$exp" --argjson l "$lim" --argjson i "$id" \ '{client: {email: $e, subId: $s, totalGB: $t, expiryTime: $x, limitIp: $l, enable: true, comment: "kit"}, inboundIds: [$i]}')" >/dev/null fi done } KIT_CLI_URL="$KIT_RAW/scripts/kit.sh" KIT_LOCAL_URL="$KIT_RAW/scripts/kit-local.sh" install_kit_cli() { install -d -m 700 /etc/kit { printf 'HOST=%q\n' "$HOST" printf 'LINK_HOST=%q\n' "${LINK_HOST:-$HOST}" printf 'PANEL_ON=%q\n' "ip" printf 'SUB_BASE=%q\n' "${SUB_URL%$SUBID}" printf 'SUB_PATH=%q\n' "$SUB_PATH" printf 'SUB_INTERNAL=%q\n' "${SUB_INTERNAL:-$SUB_PORT}" printf 'SINGLE=%q\n' "$SINGLE" printf 'MTPROTO_INNER=%q\n' "${INNER[mtproto]}" } >/etc/kit/kit.env chmod 600 /etc/kit/kit.env install_kit_file } install_kit_file() { local d src="" d=$(dirname "${BASH_SOURCE[0]}") [[ -f $d/kit.sh && ${BASH_SOURCE[0]} != /dev/fd/* ]] && src=$d/kit.sh if [[ -n $src ]]; then install -m 755 "$src" /usr/local/bin/kit else curl -fsSL --retry 3 -o /usr/local/bin/kit "$KIT_CLI_URL" && chmod 755 /usr/local/bin/kit; fi # Форк: ставим наши локальные дополнения (см. scripts/kit-local.sh). install -d -m 755 /usr/local/lib/kit if [[ -n $src && -f $d/kit-local.sh ]]; then install -m 644 "$d/kit-local.sh" /usr/local/lib/kit/kit-local.sh else curl -fsSL --retry 3 -o /usr/local/lib/kit/kit-local.sh "$KIT_LOCAL_URL" \ || warn "Локальные дополнения kit не скачались: kit local не будет работать" fi bash -n /usr/local/bin/kit || die "Команда kit скачалась повреждённой" } KIT_INSTALL_CMD="bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui.sh)" # После «x-ui → Uninstall» меню подсказывает команду официального установщика – # меняем её на нашу. Только в echo: вызов установщика в «Update» не трогаем. # Правим один раз при установке, без cron: чужие файлы по расписанию не трогаем. brand_xui_menu() { printf '%s\n' '/echo.*mhsanaei\/3x-ui\/[a-z]*\/install\.sh/ s#bash <(curl -Ls https://raw\.githubusercontent\.com/mhsanaei/3x-ui/[a-z]*/install\.sh)#'"$KIT_INSTALL_CMD"'#' \ >/etc/kit/xui-menu.sed local f for f in /usr/bin/x-ui /usr/local/x-ui/x-ui.sh; do [[ -f $f ]] && sed -i -f /etc/kit/xui-menu.sed "$f" done rm -f /etc/cron.d/kit-xui-menu } # ---------- всё на 443: nginx ---------- # Сайт-заглушка: на случайный заход по 443 сервер показывает обычный сайт. Страница собирается при # установке из случайных частей: тема (кафе, фотограф, блог, мастерская…), название, тексты, вёрстка, # цвета и шрифт. 20 тем × 5 вёрсток – 100 вариантов, а с названиями, адресами и палитрами страницы # разных серверов не совпадают. Всё внутри скрипта: без внешних шрифтов, скриптов и ссылок. stub_site() { stub_pick() { shift $(($(rnd 1 $#) - 1)); printf '%s' "$1"; } local year kind sfx tags items foot name tag font rad bg fg ac mu cd addr copy h k j t mo yy local -a ti tx opts idx mon year=$(date +%Y) # тип (b – название места, p – имя человека)|слова для названия|подзаголовки|пункты «заголовок~текст»|подпись # вместо адреса (варианты через ^). В текстах нельзя символы | ^ ~ & < > и двойные кавычки. IFS='|' read -r kind sfx tags items foot <<<"$(stub_pick \ "b|Coffee^Café^Coffee House^Coffee Roasters|Small-batch coffee and slow mornings.^Freshly roasted beans, pastries and a quiet corner.^Good coffee, made without hurry.|Our beans~Single-origin lots, roasted in small batches every week.^Breakfast~Granola, toast and pastries from the bakery next door.^Brew bar~Filter, espresso and a seasonal drink of the month.^Take home~Whole beans and ground coffee in 250 g bags.^Hours~Weekdays from 8 to 6, weekends from 9 to 4." \ "b|Bakery^Bakehouse^Bread Co.|Sourdough baked before sunrise.^Bread, buns and cakes from a wood-fired oven.^A neighbourhood bakery since the old days.|Daily bread~Rye, spelt and white sourdough, fresh every morning.^Pastries~Cinnamon buns, croissants and seasonal fruit tarts.^Cakes to order~Birthday and wedding cakes, ordered two days ahead.^Flour~We mill part of our grain on site.^Hours~Open daily from 7, until the bread runs out." \ "p|Photography^Photo^Pictures|Portraits, weddings and quiet landscapes.^Natural light and honest moments.^Stories told in pictures.|Portraits~Relaxed sessions outdoors or in the studio.^Weddings~Full-day coverage with a printed album.^Landscapes~Limited prints from northern coasts and hills.^Prints~Archival paper, signed and numbered.^Booking~Sessions are booked about a month ahead." \ "p|Notes^Journal^Field Notes|Small notes on things I learn.^Writing things down so I do not forget them.^Thoughts on books, tools and long walks.|Keeping a reading log~Two years of short notes on every book I finished.^Plain text everywhere~Why my notes live in simple files and folders.^A slower kitchen~Learning to bake bread on weekends.^Walking the coast~Notes from a week on foot along the shore.^On small habits~What stuck after a year of tiny changes.|Written slowly, updated now and then" \ "b|Books^Bookshop^Book Room|New and second-hand books.^A small shop with a big back room.^Books, maps and a reading chair.|New arrivals~Fresh fiction, travel writing and essays every Friday.^Second-hand~We buy and sell used books in good condition.^Children~A corner full of picture books and stories.^Book club~We meet on the first Thursday of every month.^Orders~Anything in print, usually within a week." \ "b|Bicycles^Bike Works^Cycle Shop|Repairs, parts and friendly advice.^Keeping the town on two wheels.^Bikes fixed while you drink a coffee.|Repairs~Brakes, gears and wheels, usually the same day.^Service~A full check-up before the season starts.^Second-hand bikes~Refurbished city and touring bikes.^Parts~Tyres, chains, lights and locks in stock.^Workshop~Monday to Saturday, no appointment needed." \ "b|Flowers^Florist^Flower Shop|Seasonal flowers, arranged by hand.^Bouquets for every day and every occasion.^Fresh stems from local growers.|Bouquets~Hand-tied and wrapped in paper, ready to go.^Weddings~Flowers for the ceremony, tables and the bride.^Plants~Easy houseplants and pots for small flats.^Subscriptions~A fresh bouquet every week or every month.^Delivery~Same-day delivery across town." \ "b|Yoga^Yoga House^Yoga Studio|Slow classes for busy people.^Move, breathe and rest.^A calm room in the middle of town.|Morning flow~A gentle start to the day, all levels welcome.^Beginners~A four-week course covering the basics.^Stretch and rest~Evening classes to unwind after work.^Private sessions~One-to-one classes by appointment.^Timetable~Classes every day except Sunday." \ "p|Translations^Language Services^Translation|English, German and Spanish translations.^Clear texts in another language.^Careful translation for people and small firms.|Documents~Certificates, letters and contracts.^Websites~Pages and product texts, adapted for readers.^Editing~Proofreading of texts written by non-native speakers.^Deadlines~Short texts are usually ready in two days.^Quote~Send the text and get a price within a day." \ "b|Architects^Architecture^Design Studio|Homes and small buildings.^Quiet buildings that age well.^Design for houses, shops and gardens.|Houses~New homes and careful renovations.^Interiors~Kitchens, built-in furniture and lighting.^Planning~Drawings and paperwork for permits.^Projects~A selection of recent work is available on request.^Visits~Meetings at the studio by appointment." \ "b|Bookkeeping^Accounting^Tax Advisors|Bookkeeping for small businesses.^Numbers in order, every month.^Tax returns without the headache.|Bookkeeping~Monthly records, invoices and bank reconciliation.^Payroll~Salaries and reports for teams of up to fifty.^Tax returns~Yearly returns for companies and sole traders.^Advice~A short call before big decisions.^Office~Open on weekdays, meetings by appointment." \ "b|Woodworks^Furniture^Joinery|Furniture made to last.^Solid wood, simple shapes.^Tables, shelves and cabinets made by hand.|Tables~Oak and ash dining tables made to size.^Shelves~Bookcases and wall shelves for awkward corners.^Repairs~Old chairs and cabinets brought back to life.^Materials~Wood from local sawmills, natural oils only.^Workshop~Visitors welcome on Saturdays." \ "b|Guesthouse^Inn^Rooms|Six rooms and a big breakfast.^A quiet place to stay near the old town.^Simple rooms with a garden view.|Rooms~Double and twin rooms with private bathrooms.^Breakfast~Served from 8 to 10 in the garden room.^Location~Ten minutes on foot from the station.^Long stays~Lower rates for a week or more.^Booking~Write or call, we answer the same day." \ "b|Language School^Learning Center^Language Club|Small groups, real conversations.^Learn a language one evening at a time.^Courses for adults and teenagers.|Group courses~Six to eight students, twice a week.^Private lessons~Flexible times, in person or at home.^Exam preparation~Courses for common language exams.^Placement~A free short test to find your level.^Terms~New groups start every September and February." \ "p|Illustration^Drawings^Art|Illustration for books and packaging.^Ink, paper and a lot of coffee.^Drawings with a story behind them.|Books~Covers and inside pages for children and adults.^Packaging~Labels for tea, coffee and small brands.^Portraits~Drawn portraits from your photos.^Prints~A small shop of signed prints.^Commissions~Open for new work from next month." \ "b|Ceramics^Pottery^Clay Works|Handmade cups, bowls and plates.^Stoneware for everyday use.^A small pottery with an open door.|Tableware~Plates and bowls fired at high temperature.^Classes~Wheel classes for beginners on weekday evenings.^Commissions~Sets for cafés and restaurants.^Glazes~Mixed in the studio from simple recipes.^Studio shop~Open on Fridays and Saturdays." \ "b|Gardens^Landscaping^Garden Design|Gardens planned for real life.^Green spaces that are easy to keep.^Design and care for small gardens.|Design~Plans and planting lists for new gardens.^Planting~Trees, hedges and borders put in by our team.^Care~Seasonal visits for pruning and tidying.^Courtyards~Ideas for small yards and balconies.^Consultation~A first visit with a short written plan." \ "b|Music School^Piano Lessons^Music Room|Lessons for all ages.^Piano, guitar and singing.^Learn to play the music you love.|Piano~Lessons for beginners and returning players.^Guitar~Acoustic and electric, chords to fingerstyle.^Singing~Voice lessons for choirs and solo work.^Concerts~Students play twice a year for family and friends.^Trial lesson~The first half hour is free." \ "b|Docs^Handbook^Documentation|Guides and reference for the project.^Everything you need to get started.^Documentation for users and contributors.|Getting started~Install, set up and run your first project.^Configuration~All options with short examples.^Guides~Step-by-step recipes for common tasks.^Reference~Commands, settings and file formats.^Changelog~What changed in each release.|Documentation for the current release" \ "b|Tailoring^Alterations^Tailors|Alterations and made-to-measure clothes.^Clothes that fit, repaired with care.^A small sewing studio near the market.|Alterations~Hems, sleeves and waistlines adjusted.^Repairs~Zips, buttons and patches done while you wait.^Made to measure~Shirts and dresses cut for you.^Fabrics~Linen, wool and cotton from small mills.^Hours~Tuesday to Saturday, from 10 to 6.")" if [[ $kind == p ]]; then name="$(stub_pick Anna Clara Daniel Elena Felix Hanna Iris Jonas Lena Lucas Maya Nina Oscar Paula Ruth Simon Tom Vera)" name+=" $(stub_pick Avery Bell Carter Dale Ellis Foster Grant Hale Hart Lowe Marsh Moreau Nolan Price Reyes Sato Vance Wells)" else name=$(stub_pick Alder Amber Ashgrove Birch Bluestone Bramble Brightwater Cedar Clover Copper Driftwood Eastgate Fern Fieldstone \ Foxglove Granite Greenway Hazel Heron Highland Hillcrest Juniper Kestrel Lakeside Lantern Larch Linden Maple Meadow Mill Moss \ Northfield Oakridge Orchard Pinecone Quiet\ Hill Red\ Door Riverside Rowan Saffron Saltmarsh Sparrow Stonebridge Sunfield \ Thistle Tidewater Two\ Rivers Westbrook Willow Wren) fi IFS='^' read -ra opts <<<"$sfx"; name+=" $(stub_pick "${opts[@]}")" IFS='^' read -ra opts <<<"$tags"; tag=$(stub_pick "${opts[@]}") # Три пункта из пяти в случайном порядке. IFS='^' read -ra opts <<<"$items" idx=(0 1 2 3 4) for k in 0 1 2; do j=$(rnd "$k" 4); t=${idx[k]}; idx[k]=${idx[j]}; idx[j]=$t; done ti=(); tx=() for k in 0 1 2; do ti+=("${opts[${idx[k]}]%%~*}"); tx+=("${opts[${idx[k]}]#*~}"); done read -r bg fg ac mu cd <<<"$(stub_pick "#f6f7f9 #1f2328 #0969da #57606a #ffffff" "#fdfaf4 #3b2f24 #8a5a2b #7a6a5a #fffdf8" \ "#111111 #eeeeee #d4a373 #a0a0a0 #1c1c1c" "#f4f1ea #2b2b2b #2f6f4f #6b6b6b #ffffff" "#fbfbfd #1d1d1f #c2410c #6e6e73 #ffffff" \ "#eef3f8 #102a43 #1f6feb #52606d #ffffff" "#fff8f3 #3d2c2e #b5485d #8c7376 #ffffff" "#f7f7f2 #222222 #6a4c93 #666666 #ffffff" \ "#0f172a #e2e8f0 #38bdf8 #94a3b8 #1e293b" "#fafaf9 #292524 #4d7c0f #78716c #ffffff")" font=$(stub_pick "-apple-system,Segoe UI,Roboto,sans-serif" "Georgia,serif" "Helvetica,Arial,sans-serif" \ "ui-monospace,Menlo,Consolas,monospace" "Palatino,Book Antiqua,serif" "Verdana,Geneva,sans-serif") rad=$(stub_pick 0 4px 6px 10px 16px) addr=${foot:-$(rnd 2 180) $(stub_pick Mill\ Lane Station\ Road Church\ Street Bridge\ Street Park\ Avenue Market\ Square \ Garden\ Row Orchard\ Way River\ Walk Hill\ Road Elm\ Street Harbour\ Road Chapel\ Lane Meadow\ Close)} copy="© $year $name" h="$name

$name

$tag

${tx[0]}

${ti[1]}$addr · $copy
" ;; 2) h+="header{background:$ac;color:$cd;padding:44px 20px;text-align:center}h1{margin:0 0 6px;font-size:28px}header p{margin:0;opacity:.85}main{max-width:820px;margin:32px auto;padding:0 20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}.c{background:$cd;border:1px solid ${mu}33;border-radius:$rad;padding:18px}.c b{display:block;margin-bottom:6px}.c span{color:$mu;font-size:14px;line-height:1.5}footer{text-align:center;color:$mu;font-size:13px;padding:28px}

$name

$tag

" for k in 0 1 2; do h+="
${ti[k]}${tx[k]}
"; done h+="
$addr · $copy
" ;; 3) h+="body{display:flex;min-height:100vh;line-height:1.6}nav{width:220px;background:$cd;border-right:1px solid ${mu}33;padding:28px 20px;box-sizing:border-box}nav b{display:block;margin-bottom:18px;color:$ac}nav p{margin:8px 0;color:$mu}main{flex:1;max-width:720px;padding:36px 32px}h1{font-size:26px;margin-top:0}h2{font-size:18px;margin:28px 0 6px}small{display:block;margin-top:40px;color:$mu}@media(max-width:640px){nav{display:none}}

$tag

" for k in 0 1 2; do h+="

${ti[k]}

${tx[k]}

"; done h+="$copy
" ;; 4) h+="body{line-height:1.6}main{max-width:640px;margin:0 auto;padding:48px 20px}h1{font-size:24px;margin:0}header p{color:$mu;margin:4px 0 32px}article{padding:16px 0;border-top:1px solid ${mu}33}article h2{font-size:17px;margin:0;color:$ac}time{font-size:13px;color:$mu}article p{margin:6px 0 0}footer{margin-top:32px;font-size:13px;color:$mu}

$name

$tag

" mon=(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); mo=$((10#$(date +%m) - 1)); yy=$year for k in 0 1 2; do h+="

${ti[k]}

${tx[k]}

" mo=$((mo - $(rnd 1 3))); ((mo >= 0)) || { mo=$((mo + 12)); yy=$((yy - 1)); } done h+="
$addr · $copy
" ;; *) h+=".t{background:$fg;color:$bg;padding:56px 20px}.t div,main{max-width:760px;margin:0 auto}h1{font-size:34px;margin:0 0 8px}.t p{margin:0;color:$ac}main{padding:28px 20px 48px}.r{display:flex;gap:24px;padding:18px 0;border-bottom:1px solid ${mu}33}.r b{flex:0 0 180px;color:$ac}.r span{color:$mu}footer{padding-top:24px;font-size:13px;color:$mu}@media(max-width:600px){.r{display:block}}

$name

$tag

" for k in 0 1 2; do h+="
${ti[k]}${tx[k]}
"; done h+="
$addr · $copy
" ;; esac printf '%s\n' "$h" } setup_nginx() { say "Настраиваю nginx: всё TCP через порт 443" apt-get install -y -qq nginx libnginx-mod-stream >/dev/null # Порт 80 нужен Let's Encrypt для продления сертификата – сайт nginx по умолчанию убираем. rm -f /etc/nginx/sites-enabled/default # Панель – только через nginx. local all all=$(api POST setting/all '{}') if [[ $(jq -r '.webListen' <<<"$all") != 127.0.0.1 ]]; then api POST setting/update "$(jq -c '.webListen = "127.0.0.1"' <<<"$all")" >/dev/null systemctl restart x-ui wait_panel fi install -d -m 755 /var/www/kit [[ -f /var/www/kit/index.html ]] || { if [[ -n ${STUB_HTML:-} ]]; then printf '%s' "$STUB_HTML"; else stub_site; fi; } >/var/www/kit/index.html # Маршруты – из текущих подключений панели: сайты REALITY, пути WebSocket, сервисы gRPC. local list reality_sni xhttp_sni mt_sni steal_domain locs="" kind path port list=$(api GET inbounds/list) # Свой домен: REALITY отдаёт чужим гостям сайт с этого же сервера (nginx на внутреннем порту). steal_domain=$(jq -r --arg t "127.0.0.1:${INNER[selfweb]}" '.[] | select(.remark == "REALITY" and .listen == "127.0.0.1") | (.streamSettings | if type == "string" then fromjson else . end).realitySettings | select(.target == $t) | .serverNames[0]' <<<"$list") reality_sni=$(jq -r '.[] | select(.remark == "REALITY" and .listen == "127.0.0.1") | (.streamSettings | if type == "string" then fromjson else . end).realitySettings.serverNames[0]' <<<"$list") xhttp_sni=$(jq -r '.[] | select(.remark == "XHTTP" and .listen == "127.0.0.1") | (.streamSettings | if type == "string" then fromjson else . end).realitySettings.serverNames[0]' <<<"$list") mt_sni=$(jq -r '.[] | select(.protocol == "mtproto" and .listen == "127.0.0.1") | (.settings | if type == "string" then fromjson else . end).fakeTlsDomain' <<<"$list") # Домен и сертификат проверяем до записи конфигов: nginx не должен остаться с маршрутом в никуда. if [[ -n $steal_domain ]]; then [[ $steal_domain =~ ^[A-Za-z0-9.-]+$ ]] || die "В подключении REALITY странное имя домена – не трогаю nginx." [[ -s $DOMAIN_CERT_DIR/fullchain.pem && -s $DOMAIN_CERT_DIR/privkey.pem ]] || die "Нет сертификата для $steal_domain ($DOMAIN_CERT_DIR) – не трогаю nginx." fi # Всё из базы панели попадает в конфиг nginx, поэтому чужая база (например, из копии) # не должна протащить туда лишние директивы: имена и пути проверяем строго. local n for n in "$reality_sni" "$xhttp_sni" "$mt_sni"; do [[ -z $n || $n =~ ^[A-Za-z0-9.-]+$ ]] || die "В подключениях странное имя сайта маскировки – не трогаю nginx." done while IFS=$'\t' read -r kind path port; do [[ -n $path ]] || continue [[ $port =~ ^[0-9]{1,5}$ && $path =~ ^/?[A-Za-z0-9._~/-]+$ ]] || die "В подключениях странный путь ($kind) – не трогаю nginx." if [[ $kind == ws ]]; then locs+=" location = $path { proxy_pass http://127.0.0.1:$port; proxy_http_version 1.1; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection \"upgrade\"; proxy_set_header Host \$host; proxy_set_header X-Forwarded-For \$proxy_protocol_addr; proxy_read_timeout 1h; }" else locs+=" location /$path/ { grpc_pass grpc://127.0.0.1:$port; grpc_set_header X-Real-IP \$proxy_protocol_addr; grpc_read_timeout 1h; grpc_send_timeout 1h; client_max_body_size 0; }" fi done < <(jq -r '.[] | select(.listen == "127.0.0.1") | (.streamSettings | if type == "string" then fromjson else . end) as $st | if $st.network == "ws" then ["ws", $st.wsSettings.path, .port] elif $st.network == "grpc" then ["grpc", $st.grpcSettings.serviceName, .port] else empty end | @tsv' <<<"$list") # Пути панели (из install-result.env, при --restore – из копии) и подписки попадают в конфиг nginx. [[ $XUI_WEB_BASE_PATH =~ ^/?[A-Za-z0-9_-]{1,64}/?$ && $SUB_PATH =~ ^/[A-Za-z0-9_-]{1,64}/$ ]] \ || die "Странный путь панели или подписки ($XUI_ENV) – не собираю nginx." local panel_path=/${XUI_WEB_BASE_PATH#/} panel_path=${panel_path%/}/ { echo "# Сгенерировано 3x-ui.sh (3X-UI KIT) – перезаписывается при повторном запуске." echo "stream {" echo " map \$ssl_preread_server_name \$kit_upstream {" [[ -n $reality_sni ]] && echo " $reality_sni 127.0.0.1:${INNER[reality]};" [[ -n $xhttp_sni && $xhttp_sni != "$reality_sni" ]] && echo " $xhttp_sni 127.0.0.1:${INNER[xhttp]};" [[ -n $mt_sni ]] && echo " $mt_sni 127.0.0.1:${INNER[mtproto]};" echo " default 127.0.0.1:${INNER[web]};" echo " }" echo " server {" echo " listen 443;" echo " listen [::]:443;" echo " ssl_preread on;" echo " proxy_pass \$kit_upstream;" echo " proxy_protocol on;" echo " proxy_connect_timeout 10s;" echo " proxy_timeout 1h;" echo " }" echo "}" } >/etc/nginx/kit-stream.conf cat >/etc/nginx/conf.d/kit.conf <>/etc/nginx/conf.d/kit.conf <>/etc/nginx/nginx.conf local ngx_log ngx_log=$(mktemp) if ! nginx -t >"$ngx_log" 2>&1; then cat "$ngx_log" >&2; rm -f "$ngx_log" # Наш include убираем, чтобы не оставить чужой nginx сломанным. sed -i '/kit-stream\.conf/d' /etc/nginx/nginx.conf rm -f /etc/nginx/kit-stream.conf die "nginx не принял конфиг – лог выше. Если в вашем nginx уже есть блок stream, запустите установку с --multi-port." fi systemctl enable nginx >/dev/null 2>&1 systemctl restart nginx # Let's Encrypt на IP продлевается каждые несколько дней – nginx раз в сутки перечитывает сертификат. echo '17 4 * * * root systemctl reload nginx >/dev/null 2>&1' >/etc/cron.d/kit-nginx-reload OPEN+=("443/tcp") local i for i in $(seq 1 10); do port_busy 443 tcp && return 0; sleep 1; done die "nginx не открыл порт 443." } # ---------- подписка ---------- setup_subscription() { local all upd all=$(api POST setting/all '{}') SUB_PATH=$(jq -r '.subPath // "/sub/"' <<<"$all") if [[ $SUB_PATH == /sub/ || -z $SUB_PATH ]]; then SUB_PATH="/$(rand_str 12 | tr 'A-Z' 'a-z')/"; fi if [[ $TRUSTED == yes ]]; then # Наружу смотрит kit-sub (подписка с учётом приложения), 3X-UI – только на 127.0.0.1. SUB_PORT=2096; SUB_INTERNAL=2097 # Без subURI панель показывает ссылку на внутренний порт 2097, до которого снаружи не достучаться. local uri="https://$HOST:$SUB_PORT$SUB_PATH" [[ $SINGLE == yes ]] && uri="https://$HOST$SUB_PATH" upd=$(jq -c --arg path "$SUB_PATH" --argjson ip "$SUB_INTERNAL" --arg title "3X-UI KIT" --arg uri "$uri" ' .subEnable = true | .subPath = $path | .subTitle = $title | .subListen = "127.0.0.1" | .subPort = $ip | .subURI = $uri | .subCertFile = "" | .subKeyFile = "" | .subClashEnable = true | .subClashAutoDetect = true | .subJsonEnable = true | .subJsonAutoDetect = true' <<<"$all") else SUB_PORT=$(jq -r '.subPort // 2096' <<<"$all") upd=$(jq -c --arg path "$SUB_PATH" --arg title "3X-UI KIT" ' .subEnable = true | .subPath = $path | .subTitle = $title | .subClashEnable = true | .subClashAutoDetect = true | .subJsonEnable = true | .subJsonAutoDetect = true' <<<"$all") fi if [[ $upd != "$all" ]]; then api POST setting/update "$upd" >/dev/null systemctl restart x-ui wait_panel fi [[ $TRUSTED == yes ]] && install_kit_sub if [[ $SINGLE == yes ]]; then SUB_URL="https://$HOST$SUB_PATH$SUBID" elif [[ $TRUSTED == yes ]]; then SUB_URL="https://$HOST:$SUB_PORT$SUB_PATH$SUBID"; else SUB_URL="http://127.0.0.1:$SUB_PORT$SUB_PATH$SUBID"; fi SUB_FETCH="$(if [[ $TRUSTED == yes ]]; then echo https; else echo http; fi)://$HOST:$SUB_PORT$SUB_PATH$SUBID" } # Юнит kit-sub: без root (DynamicUser), конфиг и сертификат – через LoadCredential. # Сертификат Let's Encrypt на IP продлевается раз в несколько дней, поэтому при отдельном # порте kit-sub перезапускается раз в сутки и берёт свежий. kit_sub_unit() { # путь-к-сертификату путь-к-ключу (пусто – за nginx) cat </dev/null install_kit_sub_file if [[ $SINGLE == yes ]]; then # За nginx: слушаем только localhost, TLS снимает nginx на 443. jq -n --arg path "$SUB_PATH" --argjson port "${INNER[sub]}" --arg up "http://127.0.0.1:$SUB_INTERNAL" --arg host "$HOST" --arg lh "${LINK_HOST:-$HOST}" \ '{listen: "127.0.0.1", port: $port, path: $path, upstream: $up, host: $host} + (if $lh != $host then {link_host: $lh} else {} end)' >/etc/kit-sub/config.json else jq -n --arg path "$SUB_PATH" --argjson port "$SUB_PORT" --arg up "http://127.0.0.1:$SUB_INTERNAL" \ --arg cert "$CERT" --arg key "$KEY" --arg host "$HOST" --arg lh "${LINK_HOST:-$HOST}" \ '{listen: "0.0.0.0", port: $port, path: $path, upstream: $up, cert: $cert, key: $key, host: $host} + (if $lh != $host then {link_host: $lh} else {} end)' >/etc/kit-sub/config.json fi chmod 600 /etc/kit-sub/config.json if [[ $SINGLE == yes ]]; then kit_sub_unit >/etc/systemd/system/kit-sub.service else kit_sub_unit "$CERT" "$KEY" >/etc/systemd/system/kit-sub.service echo '19 4 * * * root systemctl restart kit-sub >/dev/null 2>&1' >/etc/cron.d/kit-sub-cert fi systemctl daemon-reload systemctl enable kit-sub >/dev/null 2>&1 systemctl restart kit-sub local i local kp=$SUB_PORT [[ $SINGLE == yes ]] && kp=${INNER[sub]} for i in $(seq 1 20); do port_busy "$kp" tcp && return 0; sleep 1; done journalctl -u kit-sub -n 20 --no-pager >&2 || true die "kit-sub не запустился – лог выше." } # Ссылки пользователя – из его же подписки (её собирает сама 3X-UI). # Ссылки пользователя – прямо из подписки 3X-UI внутри сервера (мимо kit-sub, который # прячет от VPN-приложений vpn:// и tg://). sub_links subId [попыток] sub_links() { local id=$1 tries=${2:-20} raw="" i port=${SUB_INTERNAL:-$SUB_PORT} scheme=http [[ -z ${SUB_INTERNAL:-} && $TRUSTED == yes ]] && scheme=https for i in $(seq 1 "$tries"); do # Настоящий адрес в Host – 3X-UI подставит его в ссылки. raw=$(curl -fsSk -m 10 -A "v2rayN/7.0" -H "Host: ${LINK_HOST:-$HOST}:$SUB_PORT" "$scheme://127.0.0.1:$port$SUB_PATH$id" 2>/dev/null) && [[ -n $raw ]] && break raw=""; sleep 2 done if grep -q '://' <<<"$raw"; then echo "$raw"; else base64 -d <<<"$raw" 2>/dev/null || true; fi } # Файл настроек из копии можно читать, только если в нём нет ничего, кроме # ИМЯ=значение без подстановок и команд, и все имена – из списка для этого файла. # Иначе чужой архив подменил бы переменные установщика или kit (RESULT, PATH, KIT_VERSION…). # Значение каждого имени – тоже по его формату: kit потом подставляет их в sed, nginx и ссылки. # Кавычки – только пустые (''): так printf %q пишет пустое значение, остальное у нас без кавычек. # Сначала – только печатные ASCII и перевод строки: файл с нулевым байтом GNU grep считает двоичным и молчит # (проверка «прошла» бы), а bash при «.» нулевые байты выкидывает и выполняет остальное. Поэтому и grep -a. safe_env() { # файл имя... local f=$1 n re; shift [[ -r $f ]] || die "В копии нет файла ${f##*/}." [[ $(LC_ALL=C tr -d '\n -~' <"$f" | wc -c) -eq 0 ]] \ || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял." LC_ALL=C grep -aqvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=_-]*|''))?$" "$f" \ && die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял." while IFS= read -r n; do [[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял." case $n in BACKUP_KIT_VERSION) re='^[0-9]{1,3}(\.[0-9]{1,3}){1,3}$' ;; BACKUP_HOST | HOST) re='^[A-Za-z0-9.:-]{1,253}$' ;; LINK_HOST) re='^[A-Za-z0-9.:-]{0,253}$' ;; BACKUP_SSL) re='^(ip|custom|none)$' ;; BACKUP_DATE) re='^[0-9]{4}-[0-9]{2}-[0-9]{2}$' ;; XUI_USERNAME | XUI_PASSWORD) re='^[A-Za-z0-9._:/@%+,=-]{1,128}$' ;; XUI_PANEL_PORT | SUB_INTERNAL) re='^[0-9]{1,5}$' ;; XUI_WEB_BASE_PATH) re='^/?[A-Za-z0-9_-]{1,64}/?$' ;; XUI_ACCESS_URL) re='^(https?://[A-Za-z0-9._:-]{1,253}/[A-Za-z0-9_/-]{0,130})?$' ;; XUI_API_TOKEN) re='^[A-Za-z0-9._+/=-]{0,200}$' ;; XUI_DB_TYPE) re='^(sqlite|postgres)?$' ;; PANEL_ON) re='^(ip|domain)?$' ;; SUB_BASE) re='^https?://[A-Za-z0-9.:-]{1,253}(/[A-Za-z0-9_-]{1,64}){0,4}/?$' ;; SUB_PATH) re='^/[A-Za-z0-9_-]{1,64}/$' ;; SINGLE) re='^(yes|no)?$' ;; MTPROTO_INNER) re='^[0-9]{0,5}$' ;; *) die "Это не резервная копия 3X-UI KIT или она повреждена (имя $n в ${f##*/}). Ничего не менял." ;; esac [[ $(env_get "$f" "$n") =~ $re ]] \ || die "Это не резервная копия 3X-UI KIT или она повреждена (значение $n в ${f##*/}). Ничего не менял." done < <(LC_ALL=C grep -aoE '^[A-Z][A-Z0-9_]*' "$f") return 0 } # Одно значение из файла, проверенного safe_env: читаем в подоболочке, основной shell не трогаем. env_get() { # файл имя ( . "$1"; printf '%s' "${!2:-}" ) } # Меняет старый IP на новый в базе панели и файлах kit (только целиком, 1.2.3.4 не заденет 11.2.3.45). replace_host() { # старый новый файлы... python3 - "$@" <<'PY' import re, sqlite3, sys old, new, files = sys.argv[1], sys.argv[2], sys.argv[3:] rx = re.compile(r"(?&2 later "Нет сертификата $cert, на который ссылаются подключения: Xray не запустится, пока файл не вернётся на место." return 0 fi dir=${cert%/*} [[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST" if [[ $dir == /root/cert/ip ]]; then printf '%s\n' "${R}!${N} Let's Encrypt не выдал сертификат на IP $HOST (лимит: 5 сертификатов в неделю на один IP, закрытый порт 80 или сбой; лог: /var/log/3x-ui-install.log)." >&2 echo " Ставлю самоподписанный: ссылки на подключения работают (отпечаток в них), браузер покажет предупреждение, а подписка в приложениях может не открыться." >&2 later "Сертификат на IP самоподписанный (Let's Encrypt не выдал): пользуйтесь ссылками на отдельные подключения (kit user link имя), подписка в приложениях может не открыться." SELF_IP_CERT=yes fi install -d -m 700 "$dir" (umask 077; openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "$dir/privkey.pem" \ -out "$cert" -subj "/CN=$HOST" -addext "subjectAltName=$san" -days 3650 2>/dev/null) chmod 644 "$cert" chmod 600 "$dir/privkey.pem" pin=$(openssl x509 -in "$cert" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f') # Панель берёт тот же путь из настроек копии. «x-ui cert» не зовём: он включает TLS и у встроенной подписки, # а kit-sub ходит к ней по http. pin_cert_in_db "$cert" "$pin" } xray_alive() { # ждём ядро Xray (дочерний процесс x-ui) до 20 секунд local i pid for i in $(seq 1 10); do pid=$(systemctl show -p MainPID --value x-ui 2>/dev/null || true) [[ $pid =~ ^[1-9][0-9]*$ ]] && pgrep -P "$pid" '^xray-linux-' >/dev/null 2>&1 && return 0 sleep 2 done return 1 } restore_main() { # файл local file=$1 tmp old_ip="" [[ -f $file ]] || die "Нет файла $file. Сначала скопируйте копию на этот сервер: scp kit-backup-….tar.gz root@IP:" [[ -d /usr/local/x-ui ]] && die "Восстанавливать можно только на чистый сервер, а здесь уже стоит 3X-UI." port_busy 443 tcp && die "Порт 443/tcp занят. Восстанавливать нужно на чистый VPS." say "Ставлю пакеты: curl, jq, openssl, qrencode, ufw, python3" export DEBIAN_FRONTEND=noninteractive wait_apt_idle apt-get update -qq apt-get install -y -qq curl jq openssl qrencode ca-certificates iproute2 ufw socat cron python3 python3-yaml >/dev/null # Сначала проверяем архив целиком: только наши пути, только файлы и папки, без ссылок # и «../». Чужой архив не должен ничего записать мимо. tmp=$(mktemp -d) # shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет trap "rm -rf -- '$tmp'" EXIT python3 - "$file" "$tmp" <<'PY' || die "Это не резервная копия 3X-UI KIT или она повреждена. Ничего не менял." import os, sys, tarfile allowed = ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "etc/kit/kit.env", "etc/kit-sub/config.json", "etc/kit-sub/rules.yaml", "etc/nginx/kit-stream.conf", "etc/nginx/conf.d/kit.conf", "var/www/kit", "root/cert/self", "root/cert/custom", "root/3x-ui.txt", "kit-backup.env") parents = {"etc", "etc/x-ui", "etc/kit", "etc/kit-sub", "etc/nginx", "etc/nginx/conf.d", "var", "var/www", "root", "root/cert"} with tarfile.open(sys.argv[1], "r:gz") as t: ms = [] for m in t.getmembers(): n = os.path.normpath(m.name) if n == ".": continue if n.startswith("/") or ".." in n.split("/"): sys.exit("путь " + m.name) if not (m.isfile() or m.isdir()): sys.exit("не файл " + m.name) if not (n in parents and m.isdir()) and not any(n == a or n.startswith(a + "/") for a in allowed): sys.exit("лишний " + m.name) m.name = n m.uid = m.gid = 0 m.uname = m.gname = "root" m.mode = 0o700 if m.isdir() else 0o600 ms.append(m) names = {m.name for m in ms} for need in ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "kit-backup.env"): if need not in names: sys.exit("нет " + need) # Файлы настроек потом подключаются от root: в них только печатные ASCII и перевод строки. Нулевой байт, # \r или не-ASCII ломают построчную проверку (GNU grep считает такой файл двоичным). Данные для входа # (3x-ui.txt) показываются в терминале: только UTF-8 без управляющих символов, кроме \n и \t. for m in ms: if not m.isfile(): continue if m.name in ("etc/x-ui/install-result.env", "etc/kit/kit.env", "kit-backup.env"): data = t.extractfile(m).read(65537) if len(data) > 65536 or data.translate(None, bytes(range(0x20, 0x7f)) + b"\n"): sys.exit("байты " + m.name) elif m.name == "root/3x-ui.txt": data = t.extractfile(m).read(1048577) try: text = data.decode("utf-8") except UnicodeDecodeError: sys.exit("байты " + m.name) if len(data) > 1048576 or any((c < " " and c not in "\n\t") or "\x7f" <= c <= "\x9f" for c in text): sys.exit("байты " + m.name) # Каталог сертификата есть – в нём оба файла, иначе установка споткнётся на полпути. files = {m.name for m in ms if m.isfile() and m.size > 0} for c in ("root/cert/self", "root/cert/custom"): if any(n == c or n.startswith(c + "/") for n in names): for f in ("fullchain.pem", "privkey.pem"): if c + "/" + f not in files: sys.exit("нет " + c + "/" + f) if hasattr(tarfile, "data_filter"): t.extractall(sys.argv[2], members=ms, filter="data") else: t.extractall(sys.argv[2], members=ms) PY local f safe_env "$tmp/kit-backup.env" BACKUP_KIT_VERSION BACKUP_HOST BACKUP_SSL BACKUP_DATE safe_env "$tmp/etc/x-ui/install-result.env" XUI_USERNAME XUI_PASSWORD XUI_PANEL_PORT XUI_WEB_BASE_PATH XUI_ACCESS_URL XUI_API_TOKEN XUI_DB_TYPE [[ $(env_get "$tmp/etc/x-ui/install-result.env" XUI_PANEL_PORT) =~ ^[0-9]{1,5}$ ]] \ || die "Это не резервная копия 3X-UI KIT или она повреждена (порт панели). Ничего не менял." if [[ -f $tmp/etc/kit/kit.env ]]; then safe_env "$tmp/etc/kit/kit.env" HOST LINK_HOST PANEL_ON SUB_BASE SUB_PATH SUB_INTERNAL SINGLE MTPROTO_INNER fi python3 -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); assert c.execute('PRAGMA integrity_check').fetchone()[0]=='ok'; c.execute('SELECT count(*) FROM inbounds')" \ "$tmp/etc/x-ui/x-ui.db" 2>/dev/null || die "База панели в копии повреждена. Ничего не менял." local BACKUP_HOST BACKUP_SSL BACKUP_DATE BACKUP_KIT_VERSION BACKUP_HOST=$(env_get "$tmp/kit-backup.env" BACKUP_HOST) BACKUP_SSL=$(env_get "$tmp/kit-backup.env" BACKUP_SSL) BACKUP_DATE=$(env_get "$tmp/kit-backup.env" BACKUP_DATE) BACKUP_KIT_VERSION=$(env_get "$tmp/kit-backup.env" BACKUP_KIT_VERSION) [[ $BACKUP_HOST =~ ^[A-Za-z0-9.:-]+$ ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (адрес сервера). Ничего не менял." [[ $BACKUP_SSL =~ ^(ip|custom|none)$ ]] || die "В копии нет данных о сертификате." [[ $BACKUP_SSL != custom || -f $tmp/root/cert/custom/fullchain.pem ]] \ || die "Это не резервная копия 3X-UI KIT или она повреждена (нет своего сертификата). Ничего не менял." PANEL_SSL=$BACKUP_SSL # Домен переезжает вместе с сервером (поменяйте A-запись), IP – нет. if [[ $BACKUP_HOST =~ ^[0-9.]+$ ]]; then HOST=${HOST:-$(public_ip)} [[ -n $HOST ]] || die "Не удалось узнать внешний IP. Укажите его: --host 1.2.3.4" [[ $HOST != "$BACKUP_HOST" ]] && old_ip=$BACKUP_HOST else HOST=$BACKUP_HOST fi # Свой домен (self-steal): берём из подключения REALITY в базе. Сертификат в копию не кладём, # на новом сервере он выпускается заново, а для этого домен уже должен вести на новый IP. DOMAIN=$(python3 - "$tmp/etc/x-ui/x-ui.db" 2>/dev/null <<'PY' || true import json, sqlite3, sys db = sqlite3.connect(sys.argv[1]) for (s,) in db.execute("SELECT stream_settings FROM inbounds WHERE remark = 'REALITY'"): try: r = json.loads(s)["realitySettings"] except Exception: continue if r.get("target") == "127.0.0.1:10447" and r.get("serverNames"): print(r["serverNames"][0]) break PY ) [[ $PANEL_SSL == ip || -n $DOMAIN ]] && port_busy 80 tcp && die "Для сертификата нужен свободный порт 80/tcp." if [[ -n $DOMAIN ]]; then [[ $DOMAIN =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] || die "В копии странное имя домена – не восстанавливаю." domain_points_here "$DOMAIN" || die "Сервер в копии маскируется под домен $DOMAIN. Сначала направьте его A-запись на этот сервер, потом запустите восстановление снова. Ничего не менял." fi # Конфиги nginx и kit-sub из архива не берём, а собираем заново: из копии – только # проверенные значения (путь подписки, порты, режим). SINGLE=no; SUB_PATH=""; SUB_INTERNAL=""; SUB_PORT="" if [[ -f $tmp/etc/kit/kit.env ]]; then SINGLE=$(env_get "$tmp/etc/kit/kit.env" SINGLE); SINGLE=${SINGLE:-no} SUB_PATH=$(env_get "$tmp/etc/kit/kit.env" SUB_PATH) LINK_HOST=$(env_get "$tmp/etc/kit/kit.env" LINK_HOST) SUB_INTERNAL=$(env_get "$tmp/etc/kit/kit.env" SUB_INTERNAL) # kit потом подключает этот файл от root: адрес в нём должен быть тем же, что в копии. [[ $SINGLE =~ ^(yes|no)$ && $SUB_PATH =~ ^/[A-Za-z0-9_-]+/$ && $SUB_INTERNAL =~ ^[0-9]{1,5}$ \ && $(env_get "$tmp/etc/kit/kit.env" HOST) == "$BACKUP_HOST" ]] \ || die "В копии странные настройки kit (kit.env) – не восстанавливаю." fi if [[ -f $tmp/etc/kit-sub/config.json ]]; then SUB_PORT=$(jq -r '.port' "$tmp/etc/kit-sub/config.json" 2>/dev/null || true) [[ $SUB_PORT =~ ^[0-9]{1,5}$ && -n $SUB_PATH ]] || die "В копии странные настройки подписки – не восстанавливаю." fi say "Копия от ${BACKUP_DATE:-?} (kit ${BACKUP_KIT_VERSION:-?}), сервер ${B}$HOST${N}" # 3X-UI той же закреплённой версии, потом подменяем её базу на базу из копии: # подключения, ключи REALITY, пользователи и их подписки остаются прежними. install_xui "$(free_port)" "$(rand_str 18)" "$(rand_str 10)" "$(rand_str 20)" systemctl stop x-ui # Служебные файлы WAL от базы, созданной установщиком, к базе из копии не подходят: с ними она «битая». rm -f /etc/x-ui/x-ui.db-wal /etc/x-ui/x-ui.db-shm install -m 600 "$tmp/etc/x-ui/x-ui.db" /etc/x-ui/x-ui.db install -m 600 "$tmp/etc/x-ui/install-result.env" "$XUI_ENV" local c for c in self custom; do [[ -d $tmp/root/cert/$c ]] || continue install -d -m 700 "/root/cert/$c" install -m 644 "$tmp/root/cert/$c/fullchain.pem" "/root/cert/$c/fullchain.pem" install -m 600 "$tmp/root/cert/$c/privkey.pem" "/root/cert/$c/privkey.pem" done install -d -m 700 /etc/kit install -d -m 755 /etc/kit-sub # kit-sub (без root) читает rules.yaml [[ -f $tmp/etc/kit-sub/rules.yaml ]] && install -m 644 "$tmp/etc/kit-sub/rules.yaml" /etc/kit-sub/rules.yaml [[ -f $tmp/etc/kit/kit.env ]] && install -m 600 "$tmp/etc/kit/kit.env" /etc/kit/kit.env [[ -f $tmp/root/3x-ui.txt ]] && install -m 600 "$tmp/root/3x-ui.txt" "$RESULT" if [[ -n $old_ip ]]; then say "Меняю адрес сервера в подключениях: $old_ip → $HOST" local files=() for f in "$XUI_ENV" /etc/kit/kit.env "$RESULT"; do if [[ -f $f ]]; then files+=("$f"); fi; done replace_host "$old_ip" "$HOST" "${files[@]}" # Адрес в ссылках, взятый из копии, тоже был старым IP (со своим доменом он остаётся доменом). [[ ${LINK_HOST:-} == "$old_ip" ]] && LINK_HOST=$HOST fi local cf while IFS= read -r cf; do [[ -s $cf ]] || restore_self_cert "$cf" done < <(db_cert_files) systemctl start x-ui connect_panel set_xray_core # Сертификат: Let's Encrypt на новый IP выпустил установщик, свой и самоподписанный – из копии. setup_tls_cert TRUSTED=no [[ $PANEL_SSL == ip || $PANEL_SSL == custom ]] && TRUSTED=yes if [[ -n $SUB_PORT ]]; then install_kit_sub [[ $SINGLE == yes ]] || OPEN+=("$SUB_PORT/tcp") fi # Заглушка – из копии (её показывают nginx и Hysteria2). Всё на 443: nginx строит маршруты по подключениям из базы. install -d -m 755 /var/www/kit [[ -f $tmp/var/www/kit/index.html ]] && install -m 644 "$tmp/var/www/kit/index.html" /var/www/kit/index.html if [[ $SINGLE == yes ]]; then [[ -n $DOMAIN ]] && { issue_domain_cert || die "Не удалось получить сертификат для $DOMAIN: порт 80 должен быть свободен, а A-запись домена указывать на этот сервер. Лог: /var/log/kit-domain-cert.log"; } setup_nginx fi if [[ -f /etc/kit/kit.env ]]; then install_kit_file # Панель по домену: nginx собран заново, значит блок домена нужно вернуть (домен должен указывать на этот сервер). if grep -aq '^PANEL_ON=domain' /etc/kit/kit.env; then sed -i 's/^PANEL_ON=.*/PANEL_ON=ip/' /etc/kit/kit.env /usr/local/bin/kit net panel domain >/dev/null 2>&1 || { /usr/local/bin/kit __xver 0 >/dev/null 2>&1 || true; warn "Панель по домену не включилась (домен должен указывать на этот сервер): kit net panel domain"; } fi brand_xui_menu /usr/local/bin/kit update --auto >/dev/null 2>&1 || later "Автообновление не включилось – включите позже: kit update --auto" /usr/local/bin/kit __limit-timer on >/dev/null 2>&1 || later "Проверка общего лимита трафика не включилась – включите позже: kit fix" fi # Порты – по подключениям из копии: что слушает не только localhost, то и открываем. if [[ $UFW == yes ]]; then local p while read -r p; do OPEN+=("$p"); done < <(api GET inbounds/list | jq -r '.[] | select(.listen != "127.0.0.1") | if (.protocol | test("^(hysteria|hysteria2|tuic|wireguard|amneziawg)$")) then "\(.port)/udp" elif .protocol == "shadowsocks" then "\(.port)/tcp", "\(.port)/udp" else "\(.port)/tcp" end') [[ $TRUSTED == yes && $SINGLE == no ]] && OPEN+=("$XUI_PANEL_PORT/tcp") [[ $PANEL_SSL == ip || -n $DOMAIN ]] && OPEN+=("80/tcp") setup_ufw fi local n_in n_cl n_in=$(api GET inbounds/list | jq length) n_cl=$(api GET inbounds/list | jq '[.[] | (.settings | if type == "string" then fromjson else . end).clients // [] | .[].email] | unique | length') local core_ok=yes xray_alive || core_ok=no kit_banner echo if [[ $core_ok == yes ]]; then echo "${G}${B}Готово! Сервер восстановлен из копии: $n_in подключений, $n_cl клиентских записей.${N}" else echo "${R}${B}Сервер восстановлен из копии ($n_in подключений, $n_cl клиентских записей), но ядро Xray не запустилось: подключения не работают.${N}" echo "Причина – в журнале: ${B}tail -n 30 /var/log/x-ui/3xui.log${N}, проверка: ${B}kit check${N}" fi echo "Логин и пароль панели прежние, адрес панели и подписки: ${B}cat $RESULT${N}" echo if [[ -n $old_ip ]]; then warn "IP сервера сменился: $old_ip → $HOST." echo "Старые подписки указывают на старый IP, поэтому клиентам нужно один раз добавить" echo "подписку заново: ${B}kit user list${N}, затем ${B}kit user link имя${N}." echo "${D}Чтобы в следующий раз переезд прошёл без хлопот для клиентов, ставьте сервер на домен (--cert, --key, --host).${N}" else echo "Клиентам ничего менять не нужно: ключи, ссылки и подписки те же." fi [[ $PANEL_SSL == custom ]] && echo "Направьте A-запись домена ${B}$HOST${N} на IP этого сервера, если ещё не сделали." if ((${#WARNINGS[@]})); then echo echo "${B}ВНИМАНИЕ${N}" local w for w in "${WARNINGS[@]}"; do echo " ${Y}!${N} $w"; done fi [[ $core_ok == yes ]] || exit 1 return 0 } usage() { cat <