#!/usr/bin/env bash # kit – управление сервером 3X-UI_autoVPS: пользователи, обновление, резервная копия. # https://github.com/itsnotkubrick/3X-UI_KIT # # kit user add имя [--gb 50] [--days 30] [--devices 3] # kit user list | link имя | limit имя [--gb N] [--days N] | off имя | on имя | del имя # kit update [--auto | --manual] | kit backup | kit check | kit fix | kit version # Запуск через sh (dash) ломается на непонятной ошибке синтаксиса – подскажем сразу. [ -n "${BASH_VERSION:-}" ] || { echo "Запустите через bash, а не через sh." >&2; exit 1; } set -Eeuo pipefail export LC_ALL=C.UTF-8 # ширина колонок по символам, а не байтам KIT_VERSION="1.2.24" KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main" # Файлы новой версии берём из её тега, а не из меняющейся ветки main. kit_ref_raw() { echo "https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$1"; } # --- 3X-UI_KIT (форк hacker07): локальные команды и хуки --- # Подключаем scripts/kit-local.sh, если он установлен. Наш файл: правки автора его не касаются. # Все команды форка — под «kit local …» (см. kit local help). KIT_LOCAL_LIB="${KIT_LOCAL_LIB:-/usr/local/lib/kit/kit-local.sh}" if [[ -r $KIT_LOCAL_LIB ]]; then . "$KIT_LOCAL_LIB"; kit_local_dispatch "$@"; fi XUI_ENV=/etc/x-ui/install-result.env KIT_ENV=/etc/kit/kit.env KIT_LATEST=/etc/kit/latest-version # Проверенные версии: kit panel update ставит именно их, kit check предупреждает о других. # Суммы архива панели и x-ui.sh сверяет tools/release.sh с официальными (перед каждым выпуском). XUI_PIN="v3.9.0" XRAY_PIN="v26.6.27" declare -A XUI_TARBALL_SHA256=( [amd64]=d7cbe0bf6358ee0d2117c24fd2efb483502e411d38e2ea59bd0bf5e7a3e39390 [arm64]=9a2e43c976a2e71618a30d8f38b52476b25ed363c6989599e2f625cc52f51a81 ) XUI_SH_SHA256=d28959cb5da86c8ddaf2199e5c32dd1ea5dc0a87fcc53f900dd39b133070898a 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 say() { printf '%s\n' "${G}==>${N} $*"; } warn() { printf '%s\n' "${Y}!${N} $*" >&2; } die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Запустите от root: sudo -i, затем команду ещё раз." [[ -f $XUI_ENV && -f $KIT_ENV ]] || die "Не найдена установка – сначала поставьте сервер скриптом 3x-ui.sh." # shellcheck disable=SC1090 . "$XUI_ENV"; . "$KIT_ENV" API=""; API_OK=no for scheme in https http; do API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" if curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null; then API_OK=yes; break; fi done if [[ $API_OK == no && -x /usr/local/x-ui/x-ui ]]; then # Токен из файла не подошёл (после обновления с 1.0, смены токена, переустановки панели). Токены хранятся хэшами, прочитать # нельзя, но команда панели выпускает отдельный токен «cli-fallback», не трогая остальные: берём его и сохраняем в файл. new_token=$(/usr/local/x-ui/x-ui setting -getApiToken 2>/dev/null | sed -n 's/^apiToken: *//p' | tail -1 | tr -d '[:space:]' || true) if [[ $new_token =~ ^[A-Za-z0-9_-]{16,128}$ ]]; then for scheme in https http; do API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" if curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $new_token" "$API/server/getNewUUID" 2>/dev/null; then API_OK=yes; break; fi done if [[ $API_OK == yes ]]; then XUI_API_TOKEN=$new_token env_tmp=$(mktemp); { grep -av '^XUI_API_TOKEN=' "$XUI_ENV" || true; printf 'XUI_API_TOKEN=%q\n' "$new_token"; } >"$env_tmp" install -m 600 "$env_tmp" "$XUI_ENV"; rm -f "$env_tmp" warn "Токен API панели в $XUI_ENV не подошёл – выпустил новый и сохранил (старый остаётся в панели, его можно отозвать: Настройки – API-токены)." fi fi unset new_token env_tmp fi if [[ $API_OK == no ]]; then # Токен не подошёл ни по https, ни по http. Схему выбираем по тому, говорит ли порт панели по TLS: # иначе запрос по http к TLS-порту даёт невнятное «Unsupported HTTP version in response». if [[ $(curl -sk -m 5 -o /dev/null -w '%{http_code}' "https://127.0.0.1:$XUI_PANEL_PORT/" 2>/dev/null || true) =~ ^[1-5] ]]; then scheme=https; else scheme=http; fi API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" fi api() { # METHOD path [json] local out code if [[ $1 == GET ]]; then out=$(curl -sSk -m 20 -w $'\n%{http_code}' -H "Authorization: Bearer $XUI_API_TOKEN" "$API/$2" || true) else out=$(curl -sSk -m 20 -w $'\n%{http_code}' -H "Authorization: Bearer $XUI_API_TOKEN" -H 'Content-Type: application/json' -X "$1" -d "${3:-{\}}" "$API/$2" || true) fi code=${out##*$'\n'}; out=${out%$'\n'*} if [[ $(jq -r '.success' <<<"$out" 2>/dev/null) != true ]]; then if [[ -z $out || $out != "{"* ]]; then die "Панель не приняла запрос (HTTP ${code:-нет ответа}). Частая причина: токен API в $XUI_ENV устарел или не подходит к этой панели. Проверьте: kit check" fi die "Панель ответила ошибкой: $(jq -r '.msg // .' <<<"$out" 2>/dev/null | head -c 300)" fi jq -c '.obj' <<<"$out" } # В 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 } clients() { api GET clients/list | jq -c 'if type == "array" then . else .clients end'; } client() { clients | jq -c --arg e "$1" 'map(select(.email == $e))[0] // empty'; } # Все записи пользователя: основная и «двойники» для AmneziaWG («имя-awgN»). emails_of() { clients | jq -r --arg e "$1" '.[] | select(.email == $e or ((.email | startswith($e + "-awg")) and (.email[($e | length) + 4:] | test("^[0-9]*$")))) | .email'; } valid_name() { [[ $1 =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Имя: латиница, цифры, _ . - (до 32 символов)."; } rand_id() { openssl rand -base64 48 | tr -dc 'a-z0-9' | head -c 16; } # Числа из командной строки: «08» не должно читаться как восьмеричное, а 30 цифр – переполнять счёт. gb_bytes() { [[ ${1-} =~ ^[0-9]{1,6}$ ]] || die "--gb: целое число гигабайт (до 999999), например --gb 50"; echo $((10#$1 * 1073741824)); } days_ms() { [[ ${1-} =~ ^[0-9]{1,5}$ ]] || die "--days: целое число дней (до 99999), например --days 30"; ((10#$1 == 0)) && { echo 0; return; }; echo $((($(date +%s) + 10#$1 * 86400) * 1000)); } # Флаги вида --gb=50 превращаем в «--gb 50»; у флага без значения – понятная ошибка. eq_args() { # печатает аргументы по одному local a for a in "$@"; do if [[ $a == --*=* ]]; then printf '%s\n%s\n' "${a%%=*}" "${a#*=}"; else printf '%s\n' "$a"; fi; done } need_val() { [[ -n ${2-} ]] || die "У параметра $1 нет значения."; } human() { # байты → «1.2 ГБ» awk -v b="$1" 'BEGIN { split("Б КБ МБ ГБ ТБ", u, " "); i = 1; while (b >= 1024 && i < 5) { b /= 1024; i++ } printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }' } sub_url() { echo "${SUB_BASE}$1"; } # Отдельные ссылки на протоколы – прямо из подписки 3X-UI внутри сервера (мимо kit-sub, который # прячет от VPN-приложений vpn:// и tg://). collect_links subId фильтр – печатает подходящие строки. collect_links() { local sid=$1 filter=$2 raw out="" suffix scheme for suffix in "" -awg; do raw="" # Внутренняя подписка отвечает по http (всё на 443) или по https (свой порт с сертификатом). for scheme in http https; do raw=$(curl -fsSk -m 10 -A "v2rayN/7" -H "Host: ${LINK_HOST:-$HOST}" "$scheme://127.0.0.1:$SUB_INTERNAL$SUB_PATH$sid$suffix" 2>/dev/null) && [[ -n $raw ]] && break raw="" done grep -q '://' <<<"$raw" || raw=$(base64 -d <<<"$raw" 2>/dev/null || true) out+=$(grep -E "$filter" <<<"$raw" || true)$'\n' done # Порт попадает в выражение sed – только если это число. local inner=${MTPROTO_INNER:-10445} [[ ${SINGLE:-no} == yes && $inner =~ ^[0-9]{1,5}$ ]] && out=$(sed "s/^\(tg:\/\/proxy?\)\(.*\)port=$inner/\1\2port=443/" <<<"$out") grep . <<<"$out" || true } direct_links() { # subId фильтр local out out=$(collect_links "$1" "$2") echo if [[ -n $out ]]; then echo "$out"; else echo "Отдельных ссылок нет."; fi } # Панель «через SSH-туннель»: подписка слушает только 127.0.0.1, с телефона по ней не зайти. local_only_sub() { [[ ${SUB_BASE:-} == http://127.0.0.1* || ${SUB_BASE:-} == http://localhost* ]]; } # Ссылки «открыть в приложении»: нажатие добавляет подписку сразу с названием. # Проверено на устройстве: Karing, Happ. Остальные собраны по документации приложений. app_links() { # адрес подписки local url=$1 enc enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$url") echo "Открыть в приложении (отправьте ссылку себе или близкому в мессенджер и нажмите):" echo " Karing: karing://install-config?url=$enc&name=3X-UI%20KIT" echo " Happ: happ://add/$url" echo " Hiddify: hiddify://import/$url#3X-UI%20KIT" echo " Clash Verge: clash://install-config?url=$enc&name=3X-UI%20KIT" echo " FlClash: flclash://install-config?url=$enc" echo " Mihomo Party: mihomo://install-config?url=$enc&name=3X-UI%20KIT" echo " v2rayNG: v2rayng://install-sub?url=$enc&name=3X-UI%20KIT" } show_link() { # имя subId local url url=$(sub_url "$2") echo if local_only_sub; then echo "В режиме «панель через SSH-туннель» подписка открывается только на самом сервере," echo "поэтому подключайтесь ссылками на протоколы ниже." echo links_block "$1" "$2" return 0 fi echo "Подписка ${B}$1${N} – все протоколы одной ссылкой. Вставьте в Happ, Hiddify, Karing," echo "v2rayN, Clash Verge или FlClash:" echo echo "$url" echo command -v qrencode >/dev/null && qrencode -t ANSIUTF8 -m 1 "$url" app_links "$url" echo echo "${D}Отдельные ссылки на каждый протокол: kit user link $1 --all${N}" } cmd_add() { local name=${1:-} gb=0 days=0 devices=0 valid_name "$name"; shift mapfile -t _args < <(eq_args "$@"); set -- ${_args[@]+"${_args[@]}"} while [[ $# -gt 0 ]]; do case $1 in --gb) need_val "$@"; gb=$2; shift 2 ;; --days) need_val "$@"; days=$2; shift 2 ;; --devices) need_val "$@"; devices=$2; shift 2 ;; *) die "Неизвестный параметр: $1" ;; esac done [[ $devices =~ ^[0-9]{1,4}$ ]] || die "--devices: целое число" devices=$((10#$devices)) [[ -z $(client "$name") ]] || die "Пользователь $name уже есть. Ссылка: kit user link $name" local ids sid body ids=$(non_awg_ids) [[ $ids != "[]" ]] || die "На сервере нет подключений." gb_bytes "$gb" >/dev/null; days_ms "$days" >/dev/null gb=$((10#$gb)); days=$((10#$days)) sid=$(rand_id) body=$(jq -nc --arg e "$name" --arg s "$sid" --argjson t "$(gb_bytes "$gb")" --argjson x "$(days_ms "$days")" \ --argjson ip "$devices" --argjson ids "$ids" '{client: {email: $e, subId: $s, totalGB: $t, expiryTime: $x, limitIp: $ip, enable: true, flow: "xtls-rprx-vision", comment: "kit"}, inboundIds: $ids}') api POST clients/add "$body" >/dev/null awg_attach "$name" "$sid" "$(gb_bytes "$gb")" "$(days_ms "$days")" "$devices" say "Пользователь $name добавлен во все протоколы ($(api GET inbounds/list | jq length))$( ((gb)) && echo ", лимит $gb ГБ")$( ((days)) && echo ", на $days дн")." show_link "$name" "$sid" } cmd_link() { local name=${1:-} flag=${2:-} c sid out valid_name "$name" c=$(client "$name"); [[ -n $c ]] || die "Нет пользователя $name" sid=$(jq -r '.subId' <<<"$c") case $flag in --amnezia) out=$(collect_links "$sid" '^vpn://') [[ -n $out ]] || die "Ссылок AmneziaVPN нет: AmneziaWG на этом сервере не установлен." echo echo "AmneziaVPN: в приложении «Добавить подключение», вставьте ссылку целиком." echo "Первая – классический AmneziaWG, вторая (если есть) – версия 3.1." echo echo "$out" ;; --telegram) out=$(collect_links "$sid" '^tg://') [[ -n $out ]] || die "Ссылки для Telegram нет: MTProto на этом сервере не установлен." echo echo "Telegram: откройте ссылку на телефоне или наведите камеру на QR-код, Telegram предложит добавить прокси." echo echo "$out" echo command -v qrencode >/dev/null && qrencode -t ANSIUTF8 -m 1 "$(head -1 <<<"$out")" return 0 ;; "" | --all) show_link "$name" "$sid" # В режиме «только на сервере» показ уже содержит все ссылки. if [[ $flag == --all ]] && ! local_only_sub; then echo; links_block "$name" "$sid"; fi ;; *) die "kit user link имя [--all | --amnezia | --telegram]" ;; esac } cmd_list() { local now now=$(($(date +%s) * 1000)) clients | jq -r --argjson now "$now" ' map(select(.subId != null)) | group_by(.subId | sub("-awg[0-9]*$"; "")) | map(sort_by(.email | length) as $g | $g[0] + {used: ([$g[] | (.traffic.up // 0) + (.traffic.down // 0)] | add), seen: ([$g[] | .traffic.lastOnline // 0] | max)}) | sort_by(.email)[] | [.email, .used, (.totalGB // 0), (.expiryTime // 0), .enable, .seen] | @tsv' | { printf "${B}%-18s %-22s %-14s %-10s %s${N}\n" "Пользователь" "Трафик" "До" "Статус" "Был в сети" while IFS=$'\t' read -r email used total exp en last; do local tr till st seen tr="$(human "$used")"; ((total > 0)) && tr="$tr / $(human "$total")" if ((exp > 0)); then till=$(date -d "@$((exp / 1000))" +%d.%m.%Y); else till="бессрочно"; fi if [[ $en != true ]]; then st="${R}выключен${N}" elif ((exp > 0 && exp < $(date +%s) * 1000)); then st="${Y}истёк${N}" elif ((total > 0 && used >= total)); then st="${Y}лимит${N}" else st="${G}активен${N}"; fi if ((last > 0)); then seen=$(date -d "@$((last / 1000))" '+%d.%m %H:%M'); else seen="–"; fi printf "%-18s %-22s %-14s %-19s %s\n" "$email" "$tr" "$till" "$st" "$seen" done } } # Меняет все записи пользователя (основную и «двойников» AmneziaWG), каждую – от её собственных данных. update_user() { # имя jq-фильтр [аргументы jq...] local name=$1 filter=$2 e rec body shift 2 for e in $(emails_of "$name"); do rec=$(client "$e") body=$(jq -c "$@" "{email, subId, totalGB, expiryTime, limitIp, enable, comment} | $filter" <<<"$rec") api POST "clients/update/$e" "$body" >/dev/null done } cmd_limit() { local name=${1:-} f="." gb="" days="" dev="" valid_name "$name"; shift [[ -n $(client "$name") ]] || die "Нет пользователя $name" mapfile -t _args < <(eq_args "$@"); set -- ${_args[@]+"${_args[@]}"} while [[ $# -gt 0 ]]; do case $1 in --gb) need_val "$@"; gb=$(gb_bytes "$2"); f+=" | .totalGB = \$gb"; shift 2 ;; --days) need_val "$@"; days=$(days_ms "$2"); f+=" | .expiryTime = \$days"; shift 2 ;; --devices) need_val "$@"; [[ $2 =~ ^[0-9]{1,4}$ ]] || die "--devices: целое число"; dev=$((10#$2)); f+=" | .limitIp = \$dev"; shift 2 ;; *) die "Неизвестный параметр: $1" ;; esac done update_user "$name" "$f" --argjson gb "${gb:-0}" --argjson days "${days:-0}" --argjson dev "${dev:-0}" cmd_enforce --quiet say "Лимиты $name обновлены (0 – без ограничений)." } # Общий лимит трафика. У пользователя несколько записей в панели (основная и «двойник» AmneziaWG), # и панель считает трафик каждой записи отдельно. Раз в 5 минут складываем трафик всех записей и, # если сумма дошла до лимита основной записи, отключаем их все. Когда счётчики сброшены, лимит # поднят или трафик ушёл ниже лимита, включаем обратно только те записи, которые отключили сами # (в комментарии записи «kit:limit»), чтобы не перебить ваше ручное «kit user off». cmd_enforce() { local dry=no quiet=no a list plan line e act used lim rec body for a in "$@"; do case $a in --dry-run) dry=yes ;; --quiet) quiet=yes ;; *) die "kit user enforce [--dry-run]" ;; esac done list=$(clients) plan=$(jq -c --argjson now "$(($(date +%s) * 1000))" ' map(select((.subId // "") != "")) | group_by(.subId | sub("-awg[0-9]*$"; "")) | .[] | (sort_by(.email | length)) as $g | ($g[0].totalGB // 0) as $lim | ([$g[] | (.traffic.up // 0) + (.traffic.down // 0)] | add) as $used | $g[] | if $lim > 0 and $used >= $lim and .enable == true then {email, act: "off", used: $used, lim: $lim} elif ($lim == 0 or $used < $lim) and .enable == false and .comment == "kit:limit" and ((.expiryTime // 0) == 0 or .expiryTime > $now) then {email, act: "on", used: $used, lim: $lim} else empty end' <<<"$list") while IFS= read -r line; do [[ -n $line ]] || continue e=$(jq -r '.email' <<<"$line"); act=$(jq -r '.act' <<<"$line") used=$(jq -r '.used' <<<"$line"); lim=$(jq -r '.lim' <<<"$line") if [[ $dry == yes ]]; then echo "$e: $([[ $act == off ]] && echo отключить || echo включить) (трафик $(human "$used") из $(human "$lim"))" continue fi rec=$(jq -c --arg e "$e" 'map(select(.email == $e))[0]' <<<"$list") if [[ $act == off ]]; then body=$(jq -c '{email, subId, totalGB, expiryTime, limitIp, enable, comment} | .enable = false | .comment = "kit:limit"' <<<"$rec") else body=$(jq -c '{email, subId, totalGB, expiryTime, limitIp, enable, comment} | .enable = true | .comment = "kit"' <<<"$rec") fi api POST "clients/update/$e" "$body" >/dev/null if [[ $quiet == no || $act == off ]]; then if [[ $act == off ]]; then echo "$e: отключён, общий лимит исчерпан ($(human "$used") из $(human "$lim"))" elif ((lim == 0)); then echo "$e: включён обратно, лимит снят" else echo "$e: включён обратно, трафик ниже лимита ($(human "$used") из $(human "$lim"))"; fi fi done <<<"$plan" return 0 } # Проверка общего лимита раз в 5 минут (systemd-таймер). limit_timer_on() { cat >/etc/systemd/system/kit-limit.service <<'UNIT' [Unit] Description=3X-UI_autoVPS: общий лимит трафика пользователей (сумма по всем записям) After=x-ui.service [Service] Type=oneshot ExecStart=/usr/local/bin/kit user enforce --quiet TimeoutStartSec=120 UNIT cat >/etc/systemd/system/kit-limit.timer <<'UNIT' [Unit] Description=3X-UI_autoVPS: проверка общего лимита трафика раз в 5 минут [Timer] OnBootSec=2min OnUnitActiveSec=5min AccuracySec=30s [Install] WantedBy=timers.target UNIT systemctl daemon-reload systemctl enable --now kit-limit.timer >/dev/null 2>&1 } limit_timer_enabled() { systemctl is-enabled -q kit-limit.timer 2>/dev/null; } # xtls-rprx-vision для REALITY. Панель сама ставит flow только там, где он допустим (REALITY по TCP), на XHTTP, WS и др. его нет. # Клиент без Vision не подключится к серверу, где у пользователя Vision включён, поэтому у существующих пользователей # включаем только по команде: ссылки в подписке обновятся сами, а вручную сохранённые ссылки на REALITY придётся заменить. cmd_vision() { # имя|--all [off] local target=${1:-} flow="xtls-rprx-vision" e rec body n=0 [[ -n $target ]] || die "kit user vision имя|--all [off]" [[ ${2:-} == off ]] && flow="" if [[ $target == --all ]]; then mapfile -t _emails < <(clients | jq -r '.[] | select(.email | test("-awg[0-9]*$") | not) | .email') else valid_name "$target"; [[ -n $(client "$target") ]] || die "Нет пользователя $target" _emails=("$target") fi for e in "${_emails[@]}"; do rec=$(client "$e") body=$(jq -c --arg f "$flow" '{email, subId, totalGB, expiryTime, limitIp, enable, comment} + {flow: $f}' <<<"$rec") api POST "clients/update/$e" "$body" >/dev/null n=$((n + 1)) done if [[ -n $flow ]]; then say "Vision включён у $n польз.: ссылка REALITY в подписке теперь с flow=xtls-rprx-vision (приложения подхватят при обновлении подписки)." else say "Vision выключен у $n польз."; fi } cmd_toggle() { # имя true|false valid_name "$1" [[ -n $(client "$1") ]] || die "Нет пользователя $1" update_user "$1" ".enable = \$v | .comment = \"kit\"" --argjson v "$2" if [[ $2 == true ]]; then say "Пользователь $1 включён."; else say "Пользователь $1 выключен – подписка и подключения не работают."; fi } cmd_del() { local name=${1:-} ans="" valid_name "$name" [[ -n $(client "$name") ]] || die "Нет пользователя $name" if [[ ${2:-} != -y && -t 0 ]]; then read -rp "Удалить $name со всех протоколов? [y/N] " ans [[ $ans =~ ^[yYдД]$ ]] || { echo "Отменено."; return; } fi local e for e in $(emails_of "$name"); do api POST "clients/del/$e" >/dev/null; done say "Пользователь $name удалён, его подписка больше не работает." } # ---------- версия и обновление ---------- # Открытый ключ, которым автор подписывает релизы (ssh-keygen -Y sign). Закрытая часть # есть только у автора, поэтому подменить обновление, взломав один GitHub, не выйдет. # Новый ключ приходит только в релизе, подписанном старым. KIT_SIGNERS=( # Ключ подписи форка (владелец: hacker07). Закрытая часть — только на рабочей станции. "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)" ) KIT_SIG_NS="3x-ui-kit-release" KIT_SIG_ID="releases@3x-ui-kit" KIT_MANUAL=/etc/kit/manual-update KIT_UPDATE_LOG=/var/log/kit-update.log xray_version() { local b for b in /usr/local/x-ui/bin/xray-linux-*; do [[ -x $b ]] && "$b" version 2>/dev/null | awk 'NR==1 {print "v" $2}'; return; done } remote_version() { curl -fsS -m "${1:-5}" "$KIT_RAW/VERSION" 2>/dev/null | tr -d '[:space:]'; } newer() { [[ $1 != "$2" && $(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1) == "$1" ]]; } # $1 новее $2? auto_enabled() { systemctl is-enabled -q kit-update.timer 2>/dev/null; } # Раз в сутки узнаём последнюю версию (не дольше 3 секунд) и подсказываем обновиться. update_hint() { # подсказка про новую версию [[ -d /etc/kit ]] || return 0 # kit удалён — подсказывать нечего (раньше тут сыпались ошибки про latest-version) local latest="" if [[ ! -f $KIT_LATEST ]] || (($(date +%s) - $(stat -c %Y "$KIT_LATEST") > 86400)); then latest=$(remote_version 3) || true [[ $latest =~ ^[0-9]+(\.[0-9]+)+$ ]] && echo "$latest" >"$KIT_LATEST" || touch "$KIT_LATEST" fi latest=$(cat "$KIT_LATEST" 2>/dev/null || true) if [[ -n $latest ]] && newer "$latest" "$KIT_VERSION"; then echo if auto_enabled; then echo "${Y}↑ Вышла версия $latest${N} (у вас $KIT_VERSION). Встанет сама этой ночью или сейчас: ${B}kit update${N}" else echo "${Y}↑ Доступна версия $latest${N} (у вас $KIT_VERSION). Обновить: ${B}kit update${N}" fi fi } cmd_version() { echo "3X-UI_autoVPS $KIT_VERSION" echo "${D}панель 3X-UI $(/usr/local/x-ui/x-ui -v 2>/dev/null | head -1 || echo '?'), ядро Xray $(xray_version)${N}" if auto_enabled; then echo "${D}автообновление: включено (только подписанные релизы), журнал $KIT_UPDATE_LOG${N}" else echo "${D}автообновление: выключено, включить: kit update --auto${N}"; fi update_hint } # Что нового в версии $1 – из CHANGELOG.md, без разметки. changelog_of() { { curl -fsS -m 5 "$KIT_RAW/CHANGELOG.md" 2>/dev/null; curl -fsS -m 5 "$KIT_RAW/FORK-CHANGELOG.md" 2>/dev/null; } | awk -v v="## v$1" ' index($0, v) == 1 { on = 1; t = substr($0, length(v) + 1); sub(/^[: ]+/, "", t); if (t != "") print t; next } on && /^## / { exit } on' | sed -E 's/ ?Спасибо .*$//; s/\[([^]]*)\]\([^)]*\)/\1/g; s/\*\*//g; s/`//g' | grep -v '^[[:space:]]*$' | head -40 || true } # Скачивает релиз $1 в каталог $2 и проверяет: подпись SHA256SUMS нашим ключом, версию # внутри подписанного файла и SHA256 каждого файла. Любое несовпадение – отказ. fetch_release() { # версия каталог local v=$1 d=$2 raw f sum k ((${#KIT_SIGNERS[@]})) || { warn "В этой сборке kit нет ключа подписи – проверить обновление нечем."; return 1; } command -v ssh-keygen >/dev/null || { warn "Нет ssh-keygen (пакет openssh-client) – подпись не проверить."; return 1; } raw=$(kit_ref_raw "$v") curl -fsSL --retry 3 -o "$d/SHA256SUMS" "$raw/SHA256SUMS" && curl -fsSL --retry 3 -o "$d/SHA256SUMS.sig" "$raw/SHA256SUMS.sig" \ || { warn "Не удалось скачать подпись релиза $v."; return 1; } : >"$d/allowed_signers" for k in "${KIT_SIGNERS[@]}"; do printf '%s namespaces="%s" %s\n' "$KIT_SIG_ID" "$KIT_SIG_NS" "$k" >>"$d/allowed_signers"; done if ! ssh-keygen -Y verify -f "$d/allowed_signers" -I "$KIT_SIG_ID" -n "$KIT_SIG_NS" -s "$d/SHA256SUMS.sig" <"$d/SHA256SUMS" >/dev/null 2>&1; then warn "Подпись релиза $v не сошлась с ключом автора – это не наш релиз. Ничего не ставлю." return 1 fi # Версия записана внутри подписанного файла: старый подписанный релиз под видом нового не пройдёт. grep -qxE "# (3X-UI KIT|3X-UI_autoVPS) $v" "$d/SHA256SUMS" || { warn "Подписанный релиз не той версии – ничего не ставлю."; return 1; } for f in scripts/kit.sh scripts/kit-sub.py scripts/kit-local.sh; do sum=$(awk -v f="$f" '$2 == f {print $1}' "$d/SHA256SUMS") [[ $sum =~ ^[0-9a-f]{64}$ ]] || { warn "В подписанном списке нет $f."; return 1; } curl -fsSL --retry 3 -o "$d/${f##*/}" "$raw/$f" || { warn "Не удалось скачать $f."; return 1; } [[ $(sha256sum "$d/${f##*/}" | awk '{print $1}') == "$sum" ]] || { warn "$f не совпал с подписанным SHA256 – ничего не ставлю."; return 1; } done } # Юнит kit-sub: без root (DynamicUser), конфиг и сертификат – через LoadCredential. # Сертификат Let's Encrypt на IP продлевается раз в несколько дней, поэтому при отдельном # порте kit-sub перезапускается раз в сутки и берёт свежий. kit_sub_unit() { # путь-к-сертификату путь-к-ключу (пусто – за nginx) cat </dev/null | jq -r '[.[] | .subId // empty | select(. != "")][0] // empty' 2>/dev/null || true) for i in $(seq 1 10); do if [[ -n $sid ]]; then curl -fsSk -m 5 -o /dev/null -A "Happ/1.0" "$scheme://127.0.0.1:$port$path$sid" 2>/dev/null && return 0 else # Пользователей нет – достаточно, что служба жива и слушает порт. systemctl is-active -q kit-sub && ss -Hltn "sport = :$port" | grep -q . && return 0 fi sleep 1 done return 1 } # Сервер ещё не получил исправления 1.1 (например, kit обновили вручную из 1.0)? needs_migration() { [[ -f /etc/cron.d/kit-xui-menu ]] && return 0 [[ -f /etc/systemd/system/kit-sub.service ]] && ! grep -q '^DynamicUser=yes' /etc/systemd/system/kit-sub.service && return 0 [[ ! -f $KIT_MANUAL ]] && ! auto_enabled && return 0 limit_timer_enabled || return 0 [[ -f /etc/kit-sub/config.json ]] && ! jq -e 'has("extra_paths")' /etc/kit-sub/config.json >/dev/null 2>&1 && return 0 return 1 } # Автообновление: раз в сутки ночью со случайной задержкой, чтобы тысячи серверов не шли # на GitHub в одну минуту. Ставит только подписанные релизы и только kit и kit-sub. auto_on() { cat >/etc/systemd/system/kit-update.service </etc/systemd/system/kit-update.timer <<'UNIT' [Unit] Description=3X-UI_autoVPS: проверка обновлений раз в сутки [Timer] OnCalendar=*-*-* 03:00:00 RandomizedDelaySec=3h Persistent=true [Install] WantedBy=timers.target UNIT rm -f "$KIT_MANUAL" systemctl daemon-reload systemctl enable --now kit-update.timer >/dev/null 2>&1 } auto_off() { systemctl disable --now kit-update.timer >/dev/null 2>&1 || true install -d -m 700 /etc/kit touch "$KIT_MANUAL" } cmd_update() { local force="" unattended=no latest tmp panel=no while [[ $# -gt 0 ]]; do case $1 in --force) force=yes ;; --panel) panel=yes ;; --auto) auto_on; say "Автообновление включено: раз в сутки ночью, только подписанные релизы. Журнал: $KIT_UPDATE_LOG"; return ;; --manual) auto_off; say "Автообновление выключено. Обновляться вручную: kit update, включить снова: kit update --auto"; return ;; --unattended) unattended=yes ;; *) die "Неизвестный параметр: $1 (kit update [--panel | --force | --auto | --manual])" ;; esac shift done [[ $panel == no ]] || { panel_update ${force:+--force}; return; } # Ночной запуск и ручной не должны встретиться. exec 9>/run/kit-update.lock flock -n 9 || die "Обновление уже идёт." [[ $unattended == yes ]] && echo "--- $(date '+%F %T') kit $KIT_VERSION: проверяю обновления" latest=$(remote_version) || true [[ $latest =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "Не удалось узнать последнюю версию: GitHub недоступен с сервера. Попробуйте позже." echo "$latest" >"$KIT_LATEST" # Откатить на старую версию нельзя даже с подписью: только вперёд или та же. newer "$KIT_VERSION" "$latest" && die "На GitHub версия $latest старше вашей $KIT_VERSION – ничего не делаю." if ! newer "$latest" "$KIT_VERSION" && [[ $force != yes ]] && ! needs_migration; then say "У вас последняя версия: $KIT_VERSION." [[ $unattended == yes ]] || echo "${D}Переустановить файлы kit той же версии: kit update --force${N}" return fi if [[ $latest == "$KIT_VERSION" ]]; then say "Применяю исправления версии $latest"; else say "3X-UI_autoVPS $KIT_VERSION → $latest"; fi tmp=$(mktemp -d) # shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет trap "rm -rf -- '$tmp'" EXIT fetch_release "$latest" "$tmp" || die "Сервер не тронут." bash -n "$tmp/kit.sh" && python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$tmp/kit-sub.py" \ || die "Файлы релиза не прошли проверку синтаксиса – сервер не тронут." # Подписка kit-sub: ставим новую, проверяем, что отвечает, иначе возвращаем старую. if [[ -f /usr/local/lib/kit-sub/kit_sub.py ]]; then cp /usr/local/lib/kit-sub/kit_sub.py "$tmp/kit_sub.old" cp /etc/systemd/system/kit-sub.service "$tmp/kit-sub.service.old" install -m 644 "$tmp/kit-sub.py" /usr/local/lib/kit-sub/kit_sub.py # С 1.1 kit-sub работает без root: переписываем юнит под DynamicUser и LoadCredential. local c k c=$(jq -r '.cert // empty' /etc/kit-sub/config.json); k=$(jq -r '.key // empty' /etc/kit-sub/config.json) local sysv sysv=$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}') if [[ $sysv =~ ^[0-9]+$ ]] && ((sysv < 247)); then # LoadCredential появился в systemd 247: на более старой юнит без root не запустится, оставляем прежний. warn "systemd $sysv старше 247: юнит kit-sub не меняю (подписка работает как раньше). Лучше перейти на Ubuntu 22.04+ или Debian 11+." else kit_sub_unit "$c" "$k" >/etc/systemd/system/kit-sub.service [[ -n $c ]] && echo '19 4 * * * root systemctl restart kit-sub >/dev/null 2>&1' >/etc/cron.d/kit-sub-cert fi systemctl daemon-reload if systemctl restart kit-sub && sleep 2 && sub_ok; then say "Подписка kit-sub обновлена и отвечает" else install -m 644 "$tmp/kit_sub.old" /usr/local/lib/kit-sub/kit_sub.py install -m 644 "$tmp/kit-sub.service.old" /etc/systemd/system/kit-sub.service grep -q '^LoadCredential=cert.pem' "$tmp/kit-sub.service.old" || rm -f /etc/cron.d/kit-sub-cert systemctl daemon-reload systemctl restart kit-sub || true die "Новая подписка не ответила – вернул прежнюю, kit остался версии $KIT_VERSION. Лог: journalctl -u kit-sub -n 30" fi fi # Исправления для установок 1.0. local all uri all=$(api POST setting/all) uri=${SUB_BASE:-} if [[ $uri == https://* && $(jq -r '.subURI // ""' <<<"$all") != "$uri" ]]; then api POST setting/update "$(jq -c --arg u "$uri" '.subURI = $u' <<<"$all")" >/dev/null say "Ссылка подписки в панели: $uri…" fi # 1.0 ставил cron, который каждый день правил файлы x-ui, – убираем. rm -f /etc/cron.d/kit-xui-menu # Автообновление включено по умолчанию, пока его не выключили командой kit update --manual. [[ -f $KIT_MANUAL ]] || auto_enabled || { auto_on; say "Включил автообновление: kit update --manual, чтобы выключить"; } # Общий лимит трафика: проверка раз в 5 минут (с 1.2). limit_timer_enabled || { limit_timer_on; say "Включил проверку общего лимита трафика (раз в 5 минут)"; } # Через rename: bash дочитывает текущий kit по ходу работы, его файл трогать нельзя. install -m 755 "$tmp/kit.sh" /usr/local/bin/kit.new && mv -f /usr/local/bin/kit.new /usr/local/bin/kit # Форк: ставим наши локальные дополнения из того же подписанного релиза. install -d -m 755 /usr/local/lib/kit install -m 644 "$tmp/kit-local.sh" /usr/local/lib/kit/kit-local.sh # Новые пути подписки (Clash и JSON) подключает уже новая версия kit. /usr/local/bin/kit __subextra >/dev/null 2>&1 || warn "Дополнительные пути подписки не подключились: kit update --force" echo echo "${G}✓ Готово: 3X-UI_autoVPS $latest.${N} Пользователи, ссылки и подписки не менялись." [[ $unattended == yes ]] && return local news news=$(changelog_of "$latest") if [[ -n $news ]]; then echo; echo "${B}Что нового в $latest${N}"; echo "$news"; fi } # ---------- проверка и починка ---------- # kit check только читает и показывает, что с сервером. kit fix чинит безопасное: перезапускает # упавшие службы, возвращает права на файлы, включает автообновление, перечитывает сертификат. CHECK_BAD=0; CHECK_WARN=0; CHECK_FIX=(); CHECK_QUIET=no; CHECK_DEEP=no c_ok() { [[ $CHECK_QUIET == yes ]] || printf '%s\n' "${G}✅${N} $*"; } c_info() { [[ $CHECK_QUIET == yes ]] || printf '%s\n' "${D}ℹ $*${N}"; } c_warn() { printf '%s\n' "${Y}⚠${N} $*"; CHECK_WARN=$((CHECK_WARN + 1)); } c_bad() { printf '%s\n' "${R}❌${N} ${*:2}"; CHECK_BAD=$((CHECK_BAD + 1)); [[ -z $1 ]] || CHECK_FIX+=("$1"); } # код-починки сообщение # Сайт маскировки отвечает по TLS 1.3 и HTTP/2 (как требует REALITY)? Проверка с самого сервера. sni_alive() { echo | timeout 8 openssl s_client -connect "$1:443" -servername "$1" -tls1_3 -alpn h2 2>/dev/null | grep -q 'ALPN protocol: h2'; } # Порт подключения открыт? TCP и UDP смотрим отдельно: на 443/tcp сидит nginx, на 443/udp – Hysteria2. inbound_listens() { # порт tcp|udp|both [[ $1 =~ ^[0-9]+$ ]] || return 1 case $2 in tcp) ss -Hltn "sport = :$1" 2>/dev/null | grep -q . ;; udp) ss -Hlun "sport = :$1" 2>/dev/null | grep -q . ;; *) inbound_listens "$1" tcp && inbound_listens "$1" udp ;; esac } inbounds_down() { # [список-json] → «имяпортсеть» включённых подключений, чей порт никто не слушает local list=${1:-} name port proto net [[ -n $list ]] || list=$(api GET inbounds/list) while IFS=$'\t' read -r name port proto; do [[ -n $name ]] || continue net=$(port_net "$proto") inbound_listens "$port" "$net" || printf '%s\t%s\t%s\n' "$name" "$port" "${net/both/tcp+udp}" done < <(jq -r '.[] | select(.enable == true) | [.remark, .port, .protocol] | @tsv' <<<"$list") } xray_restart_core() { # только ядро, панель не трогаем; если API не ответил – вся служба if ! (api POST server/restartXrayService >/dev/null) 2>/dev/null; then systemctl restart x-ui; panel_healthy || true fi sleep 3 } # Панель 3.9.0 меняет подключение на ходу через API ядра: старое удаляет, а новое с маскировкой Hysteria2 # ядро 26.6.27 не принимает (форматы настроек у панели и ядра разошлись), и порт закрывается. Сама панель # ядро не перезапускает: config.json уже верный. Поэтому после правки сверяем порты и при нужде перезапускаем ядро. xray_heal() { # «было-не-слушало» (вывод inbounds_down до правки) local before=${1:-} down new sleep 1 down=$(inbounds_down 2>/dev/null) || return 0 new=$(grep -vxF -f <(printf '%s\n' "$before") <<<"$down" || true) [[ -n $new ]] || return 0 say "Ядро Xray не применило изменение на ходу ($(cut -f1 <<<"$new" | paste -sd, - | sed 's/,/, /g')) – перезапускаю ядро, подключения прервутся на пару секунд." xray_restart_core down=$(inbounds_down 2>/dev/null) || return 0 new=$(grep -vxF -f <(printf '%s\n' "$before") <<<"$down" || true) [[ -z $new ]] || warn "После перезапуска ядра не слушают: $(cut -f1,2 <<<"$new" | tr '\t' ':' | paste -sd' ' -). Журнал: tail -n 30 /var/log/x-ui/3xui.log" } # Ядро Xray запущено и каждое включённое подключение слушает свой порт (при --deep – построчно). check_xray() { local pid list name port proto net total=0 bad=0 systemctl is-active -q x-ui || return 0 # про службу сказано выше pid=$(systemctl show -p MainPID --value x-ui 2>/dev/null || true) if [[ $pid =~ ^[1-9][0-9]*$ ]] && pgrep -P "$pid" '^xray-linux-' >/dev/null 2>&1; then c_ok "ядро Xray запущено" else c_bad xray "ядро Xray не запущено (причина в журнале: tail -n 30 /var/log/x-ui/3xui.log)"; fi list=$(api GET inbounds/list 2>/dev/null) || return 0 while IFS=$'\t' read -r name port proto; do [[ -n $name ]] || continue net=$(port_net "$proto"); net=${net/both/tcp+udp}; total=$((total + 1)) if inbound_listens "$port" "${net/tcp+udp/both}"; then [[ $CHECK_DEEP != yes ]] || c_ok "$name: порт $port/$net слушает" else bad=1; c_bad xray "$name: порт $port/$net не слушает (подключение включено, но порт никто не открыл)"; fi done < <(jq -r '.[] | select(.enable == true) | [.remark, .port, .protocol] | @tsv' <<<"$list") ((bad)) || [[ $CHECK_DEEP == yes ]] || c_ok "все включённые подключения слушают свои порты ($total)" } check_services() { local pid u if systemctl is-active -q x-ui; then c_ok "служба x-ui работает"; else c_bad svc:x-ui "служба x-ui не работает (journalctl -u x-ui -n 50)"; fi if [[ ${SINGLE:-no} == yes ]]; then if systemctl is-active -q nginx; then c_ok "nginx работает"; else c_bad svc:nginx "nginx не работает (journalctl -u nginx -n 50)"; fi fi if [[ -f /usr/local/lib/kit-sub/kit_sub.py ]]; then if systemctl is-active -q kit-sub; then c_ok "служба kit-sub работает" pid=$(systemctl show -p MainPID --value kit-sub 2>/dev/null || true) u=$(ps -o user= -p "$pid" 2>/dev/null | tr -d ' ' || true) if [[ -n $u && $u != root ]]; then c_ok "kit-sub работает не от root (пользователь $u)"; else c_bad "" "kit-sub работает от root – это небезопасно, обновите kit: kit update --force"; fi else c_bad svc:kit-sub "служба kit-sub не работает (journalctl -u kit-sub -n 50)" fi fi if auto_enabled; then c_ok "автообновление включено" elif [[ -f $KIT_MANUAL ]]; then c_info "автообновление выключено вами (включить: kit update --auto)" else c_bad timer "автообновление не включено"; fi local novis novis=$(api GET inbounds/list 2>/dev/null | jq -r '[.[] | select(.remark == "REALITY") | (.settings | if type == "string" then fromjson else . end).clients[]? | select((.flow // "") == "")] | length' 2>/dev/null || echo 0) if [[ ${novis:-0} =~ ^[0-9]+$ ]] && ((novis > 0)); then c_info "у $novis польз. в REALITY не включён xtls-rprx-vision (включить: kit user vision имя|--all)"; fi if limit_timer_enabled; then c_ok "проверка общего лимита трафика включена" else c_bad limit "проверка общего лимита трафика не включена (лимит у AmneziaWG считался бы отдельно)"; fi } check_versions() { local xv pv; xv=$(xray_version || true); pv=$(/usr/local/x-ui/x-ui -v 2>/dev/null | head -1 || true) c_info "kit $KIT_VERSION, панель 3X-UI ${pv:-?}, ядро Xray ${xv:-?}" if [[ -n $xv ]] && newer "$xv" "$XRAY_PIN"; then c_warn "ядро Xray $xv новее проверенного (${XRAY_PIN#v}): клиенты на Mihomo и sing-box могут не подключаться к REALITY (kit fix вернёт проверенное)" CHECK_FIX+=(core) fi if [[ -n $pv ]] && newer "v${pv#v}" "$XUI_PIN"; then c_warn "панель 3X-UI $pv новее проверенной (${XUI_PIN#v}): часть команд kit может работать иначе" elif [[ -n $pv && v${pv#v} != "$XUI_PIN" ]]; then c_info "панель 3X-UI $pv, проверена ${XUI_PIN#v}: обновить можно командой kit panel update" fi if [[ -s $KIT_LATEST ]] && newer "$(cat "$KIT_LATEST")" "$KIT_VERSION"; then c_info "вышла версия $(cat "$KIT_LATEST"): kit update" fi } check_cert() { local cert end left cert=$(awk '$1 == "ssl_certificate" {sub(/;$/, "", $2); print $2; exit}' /etc/nginx/conf.d/kit.conf 2>/dev/null || true) [[ -n $cert ]] || cert=$(jq -r '.cert // empty' /etc/kit-sub/config.json 2>/dev/null || true) [[ -n $cert ]] || { c_info "сертификат не настроен (режим без TLS)"; return 0; } [[ -f $cert ]] || { c_bad cert "файл сертификата $cert из настроек не найден"; return 0; } if ! openssl x509 -in "$cert" -noout -checkend 0 >/dev/null 2>&1; then c_bad cert "сертификат $cert просрочен" else end=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | cut -d= -f2) left=$((($(date -d "$end" +%s) - $(date +%s)) / 86400)) if openssl x509 -in "$cert" -noout -checkend 86400 >/dev/null 2>&1; then c_ok "сертификат действителен ещё ~$left дн." else c_warn "сертификат истекает меньше чем через сутки: проверьте продление (acme.sh --cron)"; fi fi } check_exposure() { local bind f m bad=0 # Панель наружу не торчит: за nginx или в режиме «только SSH-туннель». if [[ ${SINGLE:-no} == yes || ! -f /usr/local/lib/kit-sub/kit_sub.py ]]; then bind=$(ss -ltnH "sport = :$XUI_PANEL_PORT" 2>/dev/null | awk '{print $4}' || true) if [[ -z $bind ]]; then c_bad "" "панель не слушает порт $XUI_PANEL_PORT" elif grep -qv '^127\.0\.0\.1:' <<<"$bind"; then c_bad "" "панель слушает наружу ($(tr '\n' ' ' <<<"$bind")): в настройках 3X-UI укажите адрес 127.0.0.1" else c_ok "панель слушает только localhost"; fi fi if [[ ${SINGLE:-no} == yes ]]; then if ss -ltnH 'sport = :443' 2>/dev/null | grep -q .; then c_ok "порт 443/tcp слушается (nginx)"; else c_bad svc:nginx "порт 443/tcp никто не слушает"; fi if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q '^Status: active'; then if ufw status | grep -Eq '^443/tcp +ALLOW'; then c_ok "ufw пропускает 443/tcp"; else c_bad "" "ufw не пропускает 443/tcp: ufw allow 443/tcp"; fi fi fi for f in /etc/x-ui/install-result.env /etc/kit/kit.env /etc/kit-sub/config.json /root/3x-ui.txt /root/cert/*/privkey.pem; do [[ -f $f ]] || continue m=$(stat -c %a "$f") [[ $m == 600 ]] || { c_bad perms "права на $f: $m (нужно 600)"; bad=1; } done ((bad)) || c_ok "файлы с паролями и ключами доступны только root" } # Ответ подписки похож на подписку: для Clash есть proxies, для остальных – ссылки (текстом или в base64). sub_body_ok() { # файл приложение case $2 in clash*) grep -q '^proxies:' "$1" ;; *) grep -q '://' "$1" || base64 -d "$1" 2>/dev/null | grep -q '://' ;; esac } check_subscription() { local cfg=/etc/kit-sub/config.json port path scheme=http sid ua code size body up sub_cert [[ -f $cfg ]] || { c_info "подписка не установлена (режим без сертификата: используйте отдельные ссылки из /root/3x-ui.txt)"; return 0; } port=$(jq -r '.port' "$cfg"); path=$(jq -r '.path' "$cfg") # Частая поломка: команда «x-ui cert» или меню панели включает TLS у встроенной подписки 3X-UI, # а kit-sub ходит к ней по http – подписка перестаёт отвечать. up=$(jq -r '.upstream // empty' "$cfg") sub_cert=$(api POST setting/all '{}' 2>/dev/null | jq -r '.subCertFile // empty' 2>/dev/null || true) if [[ $up == http://* && -n $sub_cert ]]; then c_bad subtls "у встроенной подписки 3X-UI включён TLS (сертификат $sub_cert), а kit-sub ходит к ней по http – подписка не отвечает" fi body=$(mktemp) [[ -n $(jq -r '.cert // empty' "$cfg") ]] && scheme=https sid=$(clients 2>/dev/null | jq -r '[.[] | .subId // empty | select(. != "")][0] // empty' 2>/dev/null || true) [[ -n $sid ]] || { c_info "пользователей нет, подписку проверить нечем"; return 0; } for ua in "Happ/1.0" "HiddifyNext/2.0" "v2rayN/7.0" "clash-verge/v2"; do : >"$body" code=$(curl -sgk -m 10 -A "$ua" -o "$body" -w '%{http_code}' "$scheme://127.0.0.1:$port$path$sid" 2>/dev/null || true) size=$(wc -c <"$body" | tr -d ' ') if [[ $code == 200 && ${size:-0} -gt 0 ]] && sub_body_ok "$body" "$ua"; then c_ok "подписка отвечает для $ua ($size байт)" elif [[ $code == 200 ]]; then c_bad svc:kit-sub "подписка для $ua: ответ 200, но в нём нет ссылок на подключения" else c_bad svc:kit-sub "подписка для $ua: HTTP ${code:-нет ответа}"; fi done rm -f "$body" if [[ ${SINGLE:-no} == yes ]]; then body=$(mktemp) code=$(curl -sk -m 10 -A "Happ/1.0" -o "$body" -w '%{http_code}' "https://127.0.0.1$path$sid" 2>/dev/null || true) if [[ $code == 200 ]] && sub_body_ok "$body" "Happ/1.0"; then c_ok "подписка отвечает и через nginx (443)"; else c_bad svc:nginx "подписка через nginx: HTTP ${code:-нет ответа} или ответ без ссылок (ищите причину выше)"; fi rm -f "$body" fi } check_masking() { local list line sni target host ips list=$(api GET inbounds/list 2>/dev/null) || { c_bad "" "не удалось получить подключения из панели (панель не отвечает?)"; return 0; } while IFS=$'\t' read -r name sni target; do [[ -n $sni ]] || continue if [[ $target == 127.0.0.1:* ]]; then ips=$(getent ahostsv4 "$sni" 2>/dev/null | awk '{print $1}' | sort -u || true); host=${HOST:-} if [[ -n $ips ]] && grep -qx "$host" <<<"$ips"; then c_ok "$name: свой домен $sni указывает на этот сервер" else c_bad "" "$name: свой домен $sni не указывает на этот сервер (A-запись: ${ips:-нет}, нужен $host)"; fi elif sni_alive "$sni"; then c_ok "$name: сайт маскировки $sni отвечает по TLS 1.3 и HTTP/2" else c_bad "" "$name: сайт маскировки $sni не отвечает по TLS 1.3 и HTTP/2 – смените его в панели 3X-UI" fi done < <(jq -r '.[] | select(.protocol == "vless") | (.streamSettings | if type == "string" then fromjson else . end) as $s | select($s.security == "reality") | [.remark, ($s.realitySettings.serverNames[0] // ""), ($s.realitySettings.target // "")] | @tsv' <<<"$list" 2>/dev/null || true) c_info "Проверка идёт с самого сервера: доступность из вашей сети она не покажет." } check_system() { local used if command -v timedatectl >/dev/null; then if [[ $(timedatectl show -p NTPSynchronized --value 2>/dev/null || true) == yes ]]; then c_ok "время синхронизировано" else c_warn "время не синхронизировано: при сильном сдвиге TLS и REALITY не работают (kit fix включит синхронизацию)"; CHECK_FIX+=(ntp); fi fi used=$(df -P / | awk 'NR==2 {gsub("%", "", $5); print $5}') if ((${used:-0} >= 95)); then c_warn "диск заполнен на ${used}%"; else c_ok "место на диске: занято ${used:-?}%"; fi } # Маскировка: известный сайт на чужом IP и запасные порты, которые на посторонний запрос отвечают пустой страницей. check_stealth() { local id kind remark sni names list while IFS=$'\t' read -r id kind remark sni; do [[ $kind == reality && -n $sni && $sni =~ $SNI_BRAND_RE ]] || continue same_net "$sni" || c_warn "$remark: $sni – известный сайт на чужом IP не годится для маскировки. Лучше сосед по подсети: kit net site" done < <(sni_targets 2>/dev/null || true) list=$(api GET inbounds/list 2>/dev/null) || return 0 if jq -e 'any(.[]; .protocol == "hysteria" and .enable == true)' <<<"$list" >/dev/null && [[ -z $(hy_masq_state) ]]; then c_warn "Hysteria2 на посторонний HTTP/3-запрос отвечает не как сайт. Включить: kit net masq on" fi if [[ -z $(jq -r '.subJsonDns // ""' <<<"$(api POST setting/all '{}' 2>/dev/null)" 2>/dev/null) ]]; then c_warn "DNS в подписке Xray JSON обычный (UDP 8.8.8.8). Включить DoH через прокси: kit net dns on" fi names=$(jq -r '[.[] | select(.enable == true and .listen != "127.0.0.1" and (.remark == "VLESS-WS" or .remark == "Trojan-gRPC" or .remark == "VMess-WS")) | .remark] | join(", ")' <<<"$list") [[ -z $names ]] || c_warn "$names открыты на своих портах и на посторонний запрос отвечают пустой страницей. Не нужны? kit net off имя (режим «всё на 443» прячет их за сайтом)" } run_checks() { CHECK_BAD=0; CHECK_WARN=0; CHECK_FIX=() check_services; check_xray; check_versions; check_cert; check_exposure; check_subscription; check_masking; check_stealth; check_system } # kit check --deep: подключения проверяются так, как это делает клиент, – с самого сервера. # Порты (каждое включённое подключение должно слушать) – в check_xray, построчно. Сами подключения: настоящий клиент Xray # ходит через REALITY (TCP и XHTTP) и Hysteria2 на публичный адрес сервера и открывает страницу через него. # Отпечаток сертификата Hysteria2 из самого подключения: в ссылке его может не быть (сертификат Let's Encrypt или свой). deep_hy_pin() { local f f=$(api GET inbounds/list 2>/dev/null | jq -r '[.[] | select(.protocol == "hysteria")][0] | (.streamSettings | if type == "string" then fromjson else . end).tlsSettings.certificates[0].certificateFile // empty' 2>/dev/null || true) [[ -r $f ]] && openssl x509 -in "$f" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f' return 0 } deep_client_config() { # ссылка порт-socks [отпечаток] → конфиг клиента Xray (печатает JSON); не 0 – проверять нечем python3 - "$1" "$2" "${3:-}" <<'PY' import json, sys from urllib.parse import urlparse, parse_qs, unquote u = urlparse(sys.argv[1]); q = {k: v[0] for k, v in parse_qs(u.query).items()} if u.scheme == "hysteria2": tls = {"serverName": q.get("sni", ""), "alpn": ["h3"], "fingerprint": q.get("fp", "chrome")} pin = q.get("pinSHA256") or (sys.argv[3] if len(sys.argv) > 3 else "") if not pin: sys.exit(1) tls["pinnedPeerCertSha256"] = pin print(json.dumps({"log": {"loglevel": "none"}, "inbounds": [{"listen": "127.0.0.1", "port": int(sys.argv[2]), "protocol": "socks", "settings": {"udp": False}}], "outbounds": [{"protocol": "hysteria", "settings": {"version": 2, "address": u.hostname, "port": u.port}, "streamSettings": {"network": "hysteria", "security": "tls", "tlsSettings": tls, "hysteriaSettings": {"version": 2, "auth": u.username}}}]})) sys.exit(0) if u.scheme != "vless" or q.get("security") != "reality": sys.exit(1) net = q.get("type", "tcp") st = {"network": net, "security": "reality", "realitySettings": { "serverName": q.get("sni", ""), "fingerprint": q.get("fp", "chrome"), "publicKey": q.get("pbk", ""), "shortId": q.get("sid", ""), "spiderX": unquote(q.get("spx", "/"))}} if net == "xhttp": st["xhttpSettings"] = {"path": unquote(q.get("path", "/")), "mode": q.get("mode", "auto")} user = {"id": u.username, "encryption": "none"} if q.get("flow"): user["flow"] = q["flow"] print(json.dumps({"log": {"loglevel": "none"}, "inbounds": [{"listen": "127.0.0.1", "port": int(sys.argv[2]), "protocol": "socks", "settings": {"udp": False}}], "outbounds": [{"protocol": "vless", "settings": {"vnext": [{"address": u.hostname, "port": u.port, "users": [user]}]}, "streamSettings": st}]})) PY } # Подключения клиентом, только итог (для проверки до и после обновления панели): 0 – всё подключается. deep_probe() { CHECK_BAD=0; CHECK_WARN=0; CHECK_QUIET=yes deep_clients >/dev/null 2>&1 || true CHECK_QUIET=no ((CHECK_BAD == 0)) } deep_clients() { local xray sid link name cfg port=18080 pid ok tmp hypin hypin=$(deep_hy_pin) xray=$(ls /usr/local/x-ui/bin/xray-linux-* 2>/dev/null | head -1) [[ -x $xray ]] || { c_warn "не нашёл Xray панели – подключения не проверил"; return 0; } command -v python3 >/dev/null || { c_warn "нет python3 – подключения не проверил"; return 0; } sid=$(clients 2>/dev/null | jq -r '[.[] | select(.enable == true and .subId != "") | .subId][0] // empty') [[ -n $sid ]] || { c_info "нет включённых пользователей – проверять подключения нечем (kit user add имя)"; return 0; } tmp=$(mktemp -d) while IFS= read -r link; do name=${link#*@}; name=${name%%\?*}; name=${name##*:} if [[ $link == hysteria2://* ]]; then name="Hysteria2 (порт $name/udp)"; elif [[ $link == *type=xhttp* ]]; then name="XHTTP (порт $name)"; else name="REALITY (порт $name)"; fi port=$((port + 1)); cfg=$tmp/$port.json deep_client_config "$link" "$port" "$hypin" >"$cfg" 2>/dev/null || continue "$xray" run -c "$cfg" >/dev/null 2>&1 & pid=$! sleep 2; ok=no # Ядро панели после смены настроек перезапускается несколько секунд: даём до трёх попыток. for _ in 1 2 3; do curl -fsS -m 15 --socks5-hostname "127.0.0.1:$port" -o /dev/null https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null && { ok=yes; break; } sleep 3 done kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null || true if [[ $ok == yes ]]; then c_ok "$name: клиент подключился к серверу и открыл страницу" else c_bad "" "$name: клиент не смог подключиться и открыть страницу (проверьте kit sni, журнал: journalctl -u x-ui -n 30)"; fi done < <(collect_links "$sid" '^(vless://.*security=reality|hysteria2://)') rm -rf "$tmp" c_info "Проверка идёт с самого сервера: доступность из вашей сети она не покажет. Остальные протоколы проверены только по открытому порту." } cmd_check() { local deep=no [[ ${1:-} != --fix ]] || { shift; cmd_fix "$@"; return; } [[ ${1:-} != --deep ]] || deep=yes [[ -z ${1:-} || $deep == yes ]] || die "Команда: kit check [--deep | --fix]" echo "${B}kit check${N} – проверка сервера (ничего не меняет)" echo CHECK_DEEP=$deep run_checks if [[ $deep == yes ]]; then echo; echo "${B}Подключения${N}"; deep_clients; fi echo if ((CHECK_BAD == 0)); then echo "${G}${B}Всё в порядке.${N}$( ((CHECK_WARN)) && echo " Предупреждений: $CHECK_WARN." || true)" else echo "${R}${B}Проблем: $CHECK_BAD.${N} Безопасные исправления: ${B}kit fix${N} (сначала можно посмотреть: kit fix --dry-run)." return 1 fi } fix_action() { # код case $1 in svc:x-ui) say "Перезапускаю x-ui"; systemctl restart x-ui; sleep 3 ;; svc:nginx) if nginx -t >/dev/null 2>&1; then say "Перезапускаю nginx"; systemctl restart nginx else warn "Конфиг nginx не проходит проверку (nginx -t) – не трогаю, чтобы не сломать сервер."; fi ;; svc:kit-sub) say "Перезапускаю kit-sub"; systemctl restart kit-sub; sleep 2 ;; xray) say "Перезапускаю ядро Xray"; xray_restart_core ;; subtls) say "Убираю сертификат у встроенной подписки 3X-UI (TLS снимает nginx, kit-sub ходит по http)" local all upd all=$(api POST setting/all '{}') upd=$(jq -c '.subCertFile = "" | .subKeyFile = ""' <<<"$all") api POST setting/update "$upd" >/dev/null systemctl restart x-ui; sleep 4 ;; timer) say "Включаю автообновление"; auto_on ;; limit) say "Включаю проверку общего лимита трафика"; limit_timer_on ;; core) say "Возвращаю проверенное ядро Xray ${XRAY_PIN#v}"; pin_core || true ;; perms) say "Возвращаю права 600 на файлы с паролями и ключами" local f for f in /etc/x-ui/install-result.env /etc/kit/kit.env /etc/kit-sub/config.json /root/3x-ui.txt /root/cert/*/privkey.pem; do if [[ -f $f ]]; then chmod 600 "$f"; fi done ;; cert) if nginx -t >/dev/null 2>&1; then say "Перечитываю сертификат в nginx"; systemctl reload nginx else warn "Конфиг nginx не проходит проверку – сертификат не перечитываю."; fi warn "Если сертификат всё ещё просрочен, запустите продление: ~/.acme.sh/acme.sh --cron" ;; ntp) fix_ntp ;; esac } # На минимальном Debian 13 нет клиента NTP, и «timedatectl set-ntp» отвечает «NTP not supported». # Ставим systemd-timesyncd, но только если нет другого. fix_ntp() { say "Включаю синхронизацию времени" timedatectl set-ntp true 2>/dev/null && return 0 if other_ntp_installed; then warn "Время синхронизирует другой NTP-клиент (chrony или ntp) – проверьте, что его служба запущена." return 0 fi say "Ставлю systemd-timesyncd" # timedatectl после установки ещё не видит новую службу, поэтому включаем её напрямую. if DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 install -y -qq systemd-timesyncd >/dev/null 2>&1 \ && systemctl enable --now systemd-timesyncd >/dev/null 2>&1; then return 0 fi warn "Не удалось включить синхронизацию времени. Поставьте клиент вручную: apt install systemd-timesyncd" } other_ntp_installed() { local p for p in chrony ntp ntpsec openntpd; do [[ $(dpkg-query -W -f='${Status}' "$p" 2>/dev/null) == "install ok installed" ]] && return 0 done return 1 } # Что чинить сейчас: если найдена причина (TLS у подписки), перезапуски kit-sub и nginx – лишь # следствия: сначала лечим причину, потом проверяем заново. fix_plan() { local c while IFS= read -r c; do if [[ $c == svc:kit-sub || $c == svc:nginx ]] && printf '%s\n' "${CHECK_FIX[@]}" | grep -qx subtls; then continue; fi echo "$c" done < <(printf '%s\n' "${CHECK_FIX[@]}" | sort -u) } cmd_fix() { local dry=no c pass plan case ${1:-} in --dry-run) dry=yes ;; "") ;; *) die "kit fix [--dry-run]" ;; esac echo "${B}kit fix${N} – безопасные исправления (службы, права, автообновление, сертификат, время)" echo CHECK_QUIET=yes; run_checks; CHECK_QUIET=no if ((${#CHECK_FIX[@]} == 0)); then if ((CHECK_BAD == 0)); then echo "${G}Чинить нечего: всё в порядке.${N}"; return 0; fi echo; echo "${Y}Автоматически эти проблемы не исправить, они описаны выше. Подробности: kit check${N}"; return 1 fi if [[ $dry == yes ]]; then while IFS= read -r c; do echo " будет сделано: $c"; done < <(fix_plan) return 0 fi for pass in 1 2; do plan=$(fix_plan) [[ -n $plan ]] || break while IFS= read -r c; do fix_action "$c"; done <<<"$plan" CHECK_QUIET=yes; run_checks; CHECK_QUIET=no ((${#CHECK_FIX[@]})) || break done echo; echo "${B}Повторная проверка${N}"; echo run_checks echo if ((CHECK_BAD == 0)); then echo "${G}${B}Готово: всё в порядке.${N}"; else echo "${R}Осталось проблем: $CHECK_BAD.${N} Часть из них нужно решать вручную (см. выше)."; return 1; fi } # ---------- резервная копия ---------- # Что входит в копию: база панели (пользователи, # ключи, подключения), настройки kit и kit-sub, nginx, сайт-заглушка, свои сертификаты. # Сертификаты Let's Encrypt (на IP и на свой домен) не берём: на новом сервере он выпускается заново. BACKUP_PATHS=(/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) # Вернуть проверенное ядро Xray (панель 3.9 иногда отвечает ошибкой GitHub API, хотя ядро заменено, поэтому смотрим на версию). pin_core() { local cur="" i (api POST "server/installXray/$XRAY_PIN" '{}' >/dev/null) 2>/dev/null || true for i in $(seq 1 30); do cur=$(xray_version || true) [[ $cur == "$XRAY_PIN" ]] && return 0 sleep 2 done warn "Не удалось вернуть ядро Xray $XRAY_PIN (сейчас ${cur:-?})." return 1 } panel_healthy() { # ждём ответа панели до 60 секунд local i for i in $(seq 1 30); do curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null && return 0 sleep 2 done return 1 } # Обновление панели до проверенной версии. Делает только безопасную часть официального обновления: # проверенный архив, замена файлов панели, миграция базы. Не трогает ядро Xray, сертификаты и настройки # (официальный update.sh при отсутствии сертификата сам выпускает его и включает TLS, меняет ядро и ставит fail2ban). panel_update() { local force=no a arch cur target=${XUI_PIN#v} tmp ts bak got want os_id keep_core for a in "$@"; do case $a in --force) force=yes ;; *) die "kit panel update [--force]" ;; esac done case "$(uname -m)" in x86_64 | amd64) arch=amd64 ;; aarch64 | arm64) arch=arm64 ;; *) die "Обновление панели умеет только amd64 и arm64." ;; esac cur=$(/usr/local/x-ui/x-ui -v 2>/dev/null | head -1 || true) [[ -n $cur ]] || die "Не удалось узнать версию панели." cur=${cur#v} if [[ $cur == "$target" && $force == no ]]; then say "Панель уже $target: это проверенная версия." return 0 fi if newer "v$cur" "$XUI_PIN" && [[ $force == no ]]; then die "У вас панель $cur новее проверенной $target. Откат не поддерживается; если что-то не работает, пришлите вывод kit check." fi say "Обновляю панель 3X-UI $cur → $target (настройки, пользователи и ядро Xray сохраняются)" tmp=$(mktemp -d) # shellcheck disable=SC2064 # путь подставляем сразу trap "rm -rf -- '$tmp'" EXIT # 1. Архив и скрипт меню – только с совпавшей зашитой суммой. curl -fL --retry 3 -m 600 -o "$tmp/x-ui.tar.gz" "https://github.com/MHSanaei/3x-ui/releases/download/$XUI_PIN/x-ui-linux-$arch.tar.gz" 2>/dev/null \ || die "Не удалось скачать панель с GitHub. Сервер не тронут." got=$(sha256sum "$tmp/x-ui.tar.gz" | awk '{print $1}'); want=${XUI_TARBALL_SHA256[$arch]} [[ $got == "$want" ]] || die "Архив панели не совпал с проверенным (SHA256) – не ставлю. Сервер не тронут." curl -fsSL --retry 3 -m 60 -o "$tmp/x-ui.sh" "https://raw.githubusercontent.com/MHSanaei/3x-ui/$XUI_PIN/x-ui.sh" \ || die "Не удалось скачать x-ui.sh. Сервер не тронут." [[ $(sha256sum "$tmp/x-ui.sh" | awk '{print $1}') == "$XUI_SH_SHA256" ]] || die "Скрипт меню x-ui не совпал с проверенным (SHA256) – не ставлю. Сервер не тронут." tar tzf "$tmp/x-ui.tar.gz" | grep -qE '^/|(^|/)\.\.(/|$)' && die "В архиве панели странные пути – не ставлю." tar xzf "$tmp/x-ui.tar.gz" -C "$tmp" [[ -s $tmp/x-ui/x-ui ]] || die "В архиве панели нет x-ui. Сервер не тронут." # Подключения клиентом до обновления: если они работали, после обновления должны работать так же, иначе вернём прежнюю панель. local pre=no deep_probe && pre=ok # 2. Копия на случай отката (база снимается средствами SQLite). ts=$(date +%Y%m%d-%H%M); bak=/root/x-ui-before-update-$ts install -d -m 700 "$bak/etc-x-ui" cp -a /usr/local/x-ui "$bak/usr-local-x-ui" cp -a /etc/x-ui/. "$bak/etc-x-ui/" cp -a /etc/systemd/system/x-ui.service "$bak/x-ui.service" 2>/dev/null || true cp -a /usr/bin/x-ui "$bak/x-ui.sh" 2>/dev/null || true python3 - /etc/x-ui/x-ui.db "$bak/x-ui.db" <<'PY' || die "Не удалось сохранить копию базы. Сервер не тронут." import sqlite3, sys src = sqlite3.connect("file:%s?mode=ro" % sys.argv[1], uri=True) dst = sqlite3.connect(sys.argv[2]); src.backup(dst); dst.close(); src.close() PY say "Копия прежней панели: $bak" # 3. Замена файлов. Ядро Xray (проверенное) оставляем: берём его из прежней установки. keep_core=$tmp/xray.keep cp -a "/usr/local/x-ui/bin/xray-linux-$arch" "$keep_core" systemctl stop x-ui pkill -f 'mtg-linux-[^ ]* run ' >/dev/null 2>&1 || true install -m 755 "$tmp/x-ui/x-ui" /usr/local/x-ui/x-ui.new && mv -f /usr/local/x-ui/x-ui.new /usr/local/x-ui/x-ui for f in "$tmp"/x-ui/bin/*; do case ${f##*/} in xray-linux-*) continue ;; esac cp -af "$f" /usr/local/x-ui/bin/ done install -m 755 "$keep_core" "/usr/local/x-ui/bin/xray-linux-$arch" chmod +x "/usr/local/x-ui/bin/mtg-linux-$arch" 2>/dev/null || true rm -rf /usr/local/x-ui/bin/tuic-server /usr/local/x-ui/bin/tuic # TUIC теперь встроен в панель install -m 755 "$tmp/x-ui.sh" /usr/local/x-ui/x-ui.sh install -m 755 "$tmp/x-ui.sh" /usr/bin/x-ui.new && mv -f /usr/bin/x-ui.new /usr/bin/x-ui os_id=$(. /etc/os-release && echo "${ID:-}") case $os_id in ubuntu | debian) [[ -f $tmp/x-ui/x-ui.service.debian ]] && install -m 644 "$tmp/x-ui/x-ui.service.debian" /etc/systemd/system/x-ui.service ;; *) warn "Файл службы не менял (система $os_id): оставил прежний." ;; esac chown -R root:root /usr/local/x-ui [[ -f /usr/local/x-ui/bin/config.json ]] && chmod 640 /usr/local/x-ui/bin/config.json systemctl daemon-reload /usr/local/x-ui/x-ui migrate >/dev/null 2>&1 || true systemctl enable x-ui >/dev/null 2>&1 || true systemctl start x-ui # 4. Проверка и откат при провале. if panel_healthy && [[ $(/usr/local/x-ui/x-ui -v 2>/dev/null | head -1) == "$target" ]] && { [[ $pre != ok ]] || { sleep 5; deep_probe; }; }; then systemctl is-active -q x-ui || true say "Панель обновлена до $target, ядро Xray ${XRAY_PIN#v} осталось." [[ $pre == ok ]] && say "Подключения клиентом после обновления работают (REALITY, XHTTP, Hysteria2)." echo "Копию прежней панели можно удалить, когда убедитесь, что всё работает: rm -rf $bak" echo "Проверить сервер: ${B}kit check${N}" return 0 fi warn "Панель после обновления не отвечает или подключения клиентом не работают – возвращаю прежнюю." systemctl stop x-ui || true rm -rf /usr/local/x-ui && cp -a "$bak/usr-local-x-ui" /usr/local/x-ui cp -a "$bak/etc-x-ui/." /etc/x-ui/ [[ -f $bak/x-ui.service ]] && cp -a "$bak/x-ui.service" /etc/systemd/system/x-ui.service [[ -f $bak/x-ui.sh ]] && install -m 755 "$bak/x-ui.sh" /usr/bin/x-ui systemctl daemon-reload; systemctl start x-ui || true die "Обновление не удалось, прежняя панель восстановлена ($cur). Копия: $bak. Лог: journalctl -u x-ui -n 50" } cmd_backup() { local out tmp p ssl=none c out=/root/kit-backup-$(date +%Y%m%d-%H%M).tar.gz tmp=$(mktemp -d) # shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет trap "rm -rf -- '$tmp'" EXIT install -d -m 700 "$tmp/etc/x-ui" # Снимок базы средствами SQLite: панель продолжает работать, копия целая. python3 - /etc/x-ui/x-ui.db "$tmp/etc/x-ui/x-ui.db" <<'PY' || die "Не удалось скопировать базу панели." import sqlite3, sys src = sqlite3.connect("file:%s?mode=ro" % sys.argv[1], uri=True) dst = sqlite3.connect(sys.argv[2]) src.backup(dst) dst.close(); src.close() PY # cp --parents с абсолютным путём падает («No such file»), если команду запустили не из «/» (например, из /root): копируем из корня. for p in "${BACKUP_PATHS[@]}"; do if [[ -e $p ]]; then (cd / && cp -a --parents "${p#/}" "$tmp"); fi; done # Какой сертификат был у панели и подписки – новый сервер должен получить такой же. c=$(jq -r '.cert // empty' /etc/kit-sub/config.json 2>/dev/null || true) [[ -z $c && -f /etc/nginx/conf.d/kit.conf ]] && c=$(awk '$1 == "ssl_certificate" {sub(/;$/, "", $2); print $2; exit}' /etc/nginx/conf.d/kit.conf) case $c in /root/cert/ip/*) ssl=ip ;; /root/cert/custom/*) ssl=custom ;; esac { printf 'BACKUP_KIT_VERSION=%q\n' "$KIT_VERSION" printf 'BACKUP_HOST=%q\n' "$HOST" printf 'BACKUP_SSL=%q\n' "$ssl" printf 'BACKUP_DATE=%q\n' "$(date +%F)" } >"$tmp/kit-backup.env" (umask 077; tar -czf "$out" -C "$tmp" .) chmod 600 "$out" say "Резервная копия: ${B}$out${N} ($(du -h "$out" | cut -f1))" echo echo "В ней ключи и пароли от сервера, храните её как пароль. Скачать к себе (на компьютере):" echo " ${B}scp root@$HOST:$out .${N}" echo } # ---------- сайт маскировки: kit sni ---------- SNI_POOL=(dl.google.com www.amazon.com www.samsung.com www.yahoo.com www.microsoft.com www.cloudflare.com) # --- поиск сайта-прикрытия среди соседей по подсети (общий код 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 } # Подключения, у которых есть сайт маскировки: id, вид (reality, mtproto, self – свой домен), название, сайт. sni_targets() { api GET inbounds/list | jq -r '.[] | (.streamSettings | if type == "string" then fromjson else . end) as $s | (.settings | if type == "string" then fromjson else . end) as $c | if .protocol == "vless" and $s.security == "reality" then [.id, (if (($s.realitySettings.target // "") | startswith("127.0.0.1:")) then "self" else "reality" end), .remark, ($s.realitySettings.serverNames[0] // "")] elif .protocol == "mtproto" then [.id, "mtproto", .remark, ($c.fakeTlsDomain // "")] else empty end | @tsv' } sni_body() { # id вид новый-сайт → тело для inbounds/update api GET inbounds/list | jq -c --argjson id "$1" --arg k "$2" --arg n "$3" '.[] | select(.id == $id) | (.streamSettings | if type == "string" then fromjson else . end) as $s | (.settings | if type == "string" then fromjson else . end) as $c | (if $k == "reality" then ($s | .realitySettings.serverNames = [$n] | .realitySettings.target = ($n + ":443")) else $s end) as $s2 | (if $k == "mtproto" then ($c | .fakeTlsDomain = $n) else $c end) as $c2 | {id, remark, enable, listen, port, protocol, expiryTime, total, settings: ($c2 | tojson), streamSettings: ($s2 | tojson), sniffing: (.sniffing | if type == "string" then . else tojson end)}' } sni_show() { local id kind remark sni mark say "Сайты маскировки (сервер: ${HOST:-?})" while IFS=$'\t' read -r id kind remark sni; do [[ -n $sni ]] || continue if [[ $kind == self ]]; then mark="свой домен" elif same_net "$sni"; then mark="из подсети сервера" else mark="чужая подсеть"; fi printf ' %-10s %-28s %s\n' "$remark" "$sni" "$mark" done < <(sni_targets) echo echo "Сменить: ${B}kit sni rotate${N} (сам подберёт), ${B}kit sni rotate --nearby${N} (искать в подсети сервера), ${B}kit sni rotate сайт.com${N}" } sni_rotate() { # [--nearby] [--dry-run] [сайт] local nearby=no dry=no site="" a while (($#)); do case $1 in --nearby) nearby=yes ;; --dry-run) dry=yes ;; -*) die "Неизвестный параметр $1. Команда: kit sni rotate [--nearby] [--dry-run] [сайт]" ;; *) site=${1,,}; [[ $site =~ $SNI_RE ]] || die "Нужно имя сайта, например dl.google.com (без https://)." ;; esac shift done local -a ids=() kinds=() remarks=() olds=() news=() pool=() used=() local id kind remark sni while IFS=$'\t' read -r id kind remark sni; do [[ $kind == self || -z $sni ]] && continue ids+=("$id"); kinds+=("$kind"); remarks+=("$remark"); olds+=("$sni"); used+=("$sni") done < <(sni_targets) ((${#ids[@]})) || die "Нет подключений, у которых можно сменить сайт маскировки (при своём домене маскировка уже под ваш сайт)." [[ -z $site ]] || { pool+=("$site"); } if [[ $nearby == yes ]]; then say "Ищу сайты в подсети сервера (около 250 коротких подключений к порту 443 соседних адресов)" while read -r a; do [[ -n $a ]] && pool+=("$a"); done < <(nearby_sites) if ((${#pool[@]} == 0)); then warn "В подсети подходящего сайта не нашёл. Известный сайт на чужом IP подходит хуже остальных." echo " Лучший выход – свой домен (установка с --domain) или свой сайт: kit net site сайт.com" if [[ -t 0 ]]; then ask_tty " Взять запасной сайт из списка? [y/N] " [[ $REPLY =~ ^[yYдД]$ ]] || { echo "Отменено."; return 0; } fi fi fi pool+=("${SNI_POOL[@]}") local c i taken for i in "${!ids[@]}"; do for c in "${pool[@]}"; do taken=no for a in "${used[@]}" "${news[@]}"; do [[ $a == "$c" ]] && taken=yes; done [[ $taken == yes ]] && continue if sni_alive "$c"; then news+=("$c"); break; fi [[ $c == "$site" ]] && die "$c не отвечает по TLS 1.3 + HTTP/2 – REALITY с ним работать не будет." done [[ -n ${news[$i]:-} ]] || die "Не нашёл свободный сайт для ${remarks[$i]}: все из списка заняты или не отвечают по TLS 1.3 + HTTP/2. Укажите свой: kit sni rotate сайт.com" done echo "${B}Было → станет:${N}" for i in "${!ids[@]}"; do printf ' %-10s %-28s → %s%s\n' "${remarks[$i]}" "${olds[$i]}" "${news[$i]}" "$(same_net "${news[$i]}" && echo ' (из подсети сервера)')" done for i in "${!ids[@]}"; do if [[ ${news[$i]} =~ $SNI_BRAND_RE ]] && ! same_net "${news[$i]}"; then warn "Для ${remarks[$i]} в подсети не хватило сайтов – взят известный сайт из общего списка, он подходит хуже соседа. Лучше свой сайт: kit net site сайт.com" break fi done [[ $dry == no ]] || { echo "${D}(--dry-run: ничего не менял)${N}"; return 0; } if [[ -t 0 ]]; then ask_tty "Применить? [Y/n] " [[ ! $REPLY =~ ^[nNнН]$ ]] || { echo "Отменено."; return 0; } fi local bak bak=/root/x-ui-before-sni-$(date +%Y%m%d-%H%M%S).db install -m 600 /dev/null "$bak" python3 - /etc/x-ui/x-ui.db "$bak" <<'PY' || die "Не удалось сохранить копию базы панели. Ничего не менял." import sqlite3, sys src = sqlite3.connect("file:%s?mode=ro" % sys.argv[1], uri=True) dst = sqlite3.connect(sys.argv[2]) src.backup(dst) dst.close(); src.close() PY # Храним три последние копии: в них ключи сервера. ls -1t /root/x-ui-before-sni-*.db 2>/dev/null | tail -n +4 | while read -r old; do rm -f -- "$old"; done say "Копия базы панели: $bak" local -a done_i=() sni_revert() { local j warn "Возвращаю прежние сайты." for j in "${done_i[@]}"; do (api POST "inbounds/update/${ids[$j]}" "$(sni_body "${ids[$j]}" "${kinds[$j]}" "${olds[$j]}")" >/dev/null) 2>/dev/null || warn "Не удалось вернуть ${remarks[$j]}: восстановите из $bak" done } local body for i in "${!ids[@]}"; do body=$(sni_body "${ids[$i]}" "${kinds[$i]}" "${news[$i]}") [[ -n $body ]] || { sni_revert; die "Не нашёл подключение ${remarks[$i]} в панели."; } if ! (api POST "inbounds/update/${ids[$i]}" "$body" >/dev/null); then sni_revert; die "Панель не приняла новый сайт для ${remarks[$i]}. Ничего не изменилось."; fi done_i+=("$i") done # Режим «всё на 443»: nginx различает подключения по имени сайта, обновляем его таблицу. local conf=/etc/nginx/kit-stream.conf if [[ ${SINGLE:-no} == yes && -f $conf ]]; then local cbak tmp cbak=$(mktemp); cp -p "$conf" "$cbak"; tmp=$(mktemp) cp "$conf" "$tmp" for i in "${!ids[@]}"; do awk -v o="${olds[$i]}" -v n="${news[$i]}" '$1 == o && $2 ~ /^127\.0\.0\.1:[0-9]+;$/ {printf " %s %s\n", n, $2; next} {print}' "$tmp" >"$tmp.n" && mv "$tmp.n" "$tmp" done cat "$tmp" >"$conf"; rm -f "$tmp" if ! nginx -t >/dev/null 2>&1 || ! systemctl reload nginx; then cat "$cbak" >"$conf"; rm -f "$cbak" sni_revert; nginx -t >/dev/null 2>&1 && systemctl reload nginx || true die "nginx не принял новые имена. Всё возвращено как было." fi rm -f "$cbak" fi sleep 2 systemctl is-active -q x-ui || warn "Панель не отвечает – проверьте: systemctl status x-ui" say "Готово. Сайт маскировки сменён." [[ $nearby == no ]] || echo "Это чужие сайты из вашей подсети: откройте их в браузере и убедитесь, что на них нет ничего неприятного. Не подошли – kit sni rotate ещё раз." echo "Подписка у клиентов обновится сама при следующем обновлении подписки в приложении." echo "Ссылки REALITY, XHTTP и MTProto, сохранённые вручную, нужно заменить на новые: ${B}kit user link имя --all${N}" } # ---------- порты подключений: kit port ---------- port_net() { # protocol → tcp|udp|both case $1 in hysteria | hysteria2 | tuic | wireguard | amneziawg) echo udp ;; shadowsocks) echo both ;; *) echo tcp ;; esac } port_show() { local list list=$(api GET inbounds/list) say "Порты подключений" jq -r '.[] | [.remark, .port, .protocol, .listen] | @tsv' <<<"$list" | while IFS=$'\t' read -r name port proto listen; do if [[ $listen == 127.0.0.1 ]]; then printf ' %-14s %-6s %s\n' "$name" "$port" "внутренний (за nginx на 443)" else printf ' %-14s %-6s %s\n' "$name" "$port" "$(port_net "$proto")"; fi done echo echo "Сменить: ${B}kit port set имя порт${N}, например: kit port set REALITY 8443" } port_set() { # имя порт local name=${1:-} new=${2:-} list row id old proto listen net body [[ -n $name && $new =~ ^[0-9]{1,5}$ ]] || die "kit port set имя порт, например: kit port set REALITY 8443" new=$((10#$new)); ((new >= 1 && new <= 65535)) || die "Порт: число от 1 до 65535." list=$(api GET inbounds/list) row=$(jq -c --arg n "${name,,}" '[.[] | select((.remark | ascii_downcase) == $n)][0] // empty' <<<"$list") [[ -n $row ]] || die "Нет подключения «$name». Список: kit port" id=$(jq -r '.id' <<<"$row"); old=$(jq -r '.port' <<<"$row"); proto=$(jq -r '.protocol' <<<"$row"); listen=$(jq -r '.listen' <<<"$row") [[ $listen != 127.0.0.1 ]] || die "$name работает за nginx на внутреннем порту, снаружи это 443: менять нечего (режим «всё на 443»)." [[ $new != "$old" ]] || die "$name уже на порту $new." case $new in 22 | 80) die "Порт $new занят под SSH или проверку сертификата – выберите другой." ;; esac [[ $new != "${XUI_PANEL_PORT:-}" ]] || die "Порт $new – это панель." net=$(port_net "$proto") # TCP и UDP на одном номере не конфликтуют (REALITY по tcp и Hysteria2 по udp делят 443). local on op opr oid while IFS=$'\t' read -r oid op opr; do [[ $oid != "$id" && $op == "$new" ]] || continue on=$(port_net "$opr") [[ $on == both || $net == both || $on == "$net" ]] && die "Порт $new/$on уже занят другим подключением." done < <(jq -r '.[] | [.id, .port, .protocol] | @tsv' <<<"$list") case $net in tcp) ss -Hltn "sport = :$new" | grep -q . && die "Порт $new/tcp занят другой программой." ;; udp) ss -Hlun "sport = :$new" | grep -q . && die "Порт $new/udp занят другой программой." ;; both) { ss -Hltn "sport = :$new"; ss -Hlun "sport = :$new"; } | grep -q . && die "Порт $new занят другой программой." ;; esac body=$(jq -c --argjson p "$new" '{id, remark, enable, listen, port: $p, protocol, expiryTime, total, settings: (.settings | if type == "string" then . else tojson end), streamSettings: (.streamSettings | if type == "string" then . else tojson end), sniffing: (.sniffing | if type == "string" then . else tojson end)}' <<<"$row") local ufw_on=no command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q '^Status: active' && ufw_on=yes [[ $ufw_on == no ]] || port_ufw allow "$new" "$net" local before; before=$(inbounds_down "$list") if ! (api POST "inbounds/update/$id" "$body" >/dev/null); then [[ $ufw_on == no ]] || port_ufw delete "$new" "$net" die "Панель не приняла новый порт. Ничего не изменилось." fi [[ $ufw_on == no ]] || port_ufw delete "$old" "$net" xray_heal "$before" if inbound_listens "$new" "$net"; then say "$name теперь на порту ${B}$new${N} (был $old)." else warn "$name переведён на порт $new, но он пока не слушает: проверьте kit check --deep."; fi echo "Подписка обновит ссылки сама. Сохранённые вручную ссылки на $name нужно заменить: ${B}kit user link имя --all${N}" echo "Если у хостера есть свой межсетевой экран (в личном кабинете), откройте в нём порт $new ($net) и закройте $old." } port_ufw() { # allow|delete порт tcp|udp|both local act=$1 p=$2 n=$3 if [[ $n == both ]]; then if [[ $act == allow ]]; then ufw allow "$p/tcp" >/dev/null; ufw allow "$p/udp" >/dev/null else ufw delete allow "$p/tcp" >/dev/null 2>&1 || true; ufw delete allow "$p/udp" >/dev/null 2>&1 || true; fi elif [[ $act == allow ]]; then ufw allow "$p/$n" >/dev/null else ufw delete allow "$p/$n" >/dev/null 2>&1 || true; fi } # ---------- второй REALITY на высоком порту: kit reality add ---------- reality_add() { # [порт] local want=${1:-} list base id port="" keys sid settings stream body c n=0 tries bid [[ -z $want || $want =~ ^[0-9]{1,5}$ ]] || die "kit reality add [порт], например: kit reality add 24443" list=$(api GET inbounds/list) jq -e 'any(.[]; .remark == "REALITY-2")' <<<"$list" >/dev/null && die "REALITY-2 уже есть. Порт: kit port, смена: kit port set REALITY-2 порт" base=$(jq -c '[.[] | select(.remark == "REALITY")][0] // empty' <<<"$list") [[ -n $base ]] || die "На сервере нет подключения REALITY, на которое можно опереться." if [[ -n $want ]]; then port=$((10#$want)); ((port >= 1024 && port <= 65535)) || die "Порт: число от 1024 до 65535." else for tries in $(seq 1 50); do port=$(shuf -i 20000-60000 -n 1) jq -e --argjson p "$port" 'any(.[]; .port == $p)' <<<"$list" >/dev/null && { port=""; continue; } ss -Hlntu "sport = :$port" | grep -q . || break port="" done [[ -n $port ]] || die "Не нашёл свободный порт." fi jq -e --argjson p "$port" 'any(.[]; .port == $p)' <<<"$list" >/dev/null && die "Порт $port уже занят другим подключением." ss -Hlntu "sport = :$port" | grep -q . && die "Порт $port занят другой программой." [[ $port != "${XUI_PANEL_PORT:-}" ]] || die "Порт $port – это панель." keys=$(api GET server/getNewX25519Cert) sid=$(openssl rand -hex 8) settings=$(jq -nc '{clients: [], decryption: "none", fallbacks: []}') # Сайт маскировки (или свой домен) – как у основного REALITY, ключи и shortId – свои. stream=$(jq -c --argjson k "$keys" --arg sid "$sid" '(.streamSettings | if type == "string" then fromjson else . end) | .externalProxy = [] | .tcpSettings.acceptProxyProtocol = false | .realitySettings.privateKey = $k.privateKey | .realitySettings.shortIds = [$sid] | .realitySettings.settings.publicKey = $k.publicKey | del(.sockopt)' <<<"$base") body=$(jq -nc --argjson port "$port" --arg s "$settings" --arg st "$stream" '{remark: "REALITY-2", enable: true, listen: "", port: $port, protocol: "vless", settings: $s, streamSettings: $st, sniffing: "{\"enabled\":true,\"destOverride\":[\"http\",\"tls\",\"quic\"],\"metadataOnly\":false,\"routeOnly\":false}", expiryTime: 0, total: 0}') api POST inbounds/add "$body" >/dev/null if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "$port/tcp" >/dev/null; fi id=$(api GET inbounds/list | jq -r '[.[] | select(.remark == "REALITY-2")][0].id') [[ $id =~ ^[0-9]+$ ]] || die "Панель не создала REALITY-2." # Тем же пользователям, что есть на основном REALITY (двойники AmneziaWG и Telegram не подключаются к нему). bid=$(jq -r '.id' <<<"$base") while read -r c; do [[ -n $c ]] || continue api POST "clients/$c/attach" "$(jq -nc --argjson i "$id" '{inboundIds: [$i]}')" >/dev/null && n=$((n + 1)) done < <(clients | jq -r --argjson b "$bid" '.[] | select((.inboundIds // []) | index($b)) | .email') say "REALITY-2 создан на порту ${B}$port${N}, подключено пользователей: $n. Подписка у клиентов обновится сама." echo "Если у хостера есть свой межсетевой экран (в личном кабинете), откройте в нём порт $port (tcp)." echo "Проверить: ${B}kit check --deep${N}. Ссылки: ${B}kit user link имя --all${N}" } # ---------- вид: блок ссылок, kit net, меню ---------- # Блок ссылок: основные и запасные по группам, у каждой название, порт и сама ссылка. Печатает и итог установки, и kit user link. links_block() { # имя subId local name=$1 sid=$2 raw raw=$(collect_links "$sid" '^[a-z0-9]+://') [[ -n $raw ]] || { echo "Отдельных ссылок нет."; return 0; } local script IFS= read -r -d '' script <<'PY' || true import base64, json, os, re, subprocess, shutil, sys from urllib.parse import urlparse, parse_qs, unquote name = sys.argv[1] MAIN = ["REALITY", "XHTTP", "Hysteria2"] items, vpn = [], 0 for line in sys.stdin.read().split(): sch = line.split("://", 1)[0] label, port, net, hint = "", "", "tcp", "" try: if sch == "vmess": d = json.loads(base64.b64decode(line[8:] + "=" * (-len(line[8:]) % 4))) label, port = d.get("ps", "VMess"), str(d.get("port", "")) elif sch == "vpn": vpn += 1 label, net, hint = ("AmneziaWG" if vpn == 1 else "AmneziaWG 3.1"), "udp", "вставьте в AmneziaVPN" elif sch == "tg": label, port, hint = "MTProto", parse_qs(urlparse(line).query).get("port", [""])[0], "для Telegram" else: u = urlparse(line) label, port = unquote(u.fragment), str(u.port or "") if sch in ("hysteria2", "tuic"): net = "udp" if sch == "tuic": hint = "если приложение ругается на сертификат, включите «Разрешить небезопасный»" except Exception: continue label = re.sub(r"-" + re.escape(name) + r"$", "", label) or sch items.append((label, port, net, hint, line)) def show(label, port, net, hint, line): where = (port + "/" + net) if port and sch_net_ok(label, net) else (port or net) print(" %s · %s%s" % (label, where, (" → " + hint) if hint else "")) print(" " + line) print() def sch_net_ok(label, net): return label not in ("Shadowsocks",) print("ПОДКЛЮЧЕНИЕ · %s" % name) print() ORDER = ["REALITY", "XHTTP", "Hysteria2", "REALITY-2", "VLESS-WS", "Trojan-gRPC", "VMess-WS", "Shadowsocks", "TUIC", "AmneziaWG", "AmneziaWG 3.1", "MTProto"] items.sort(key=lambda i: ORDER.index(i[0]) if i[0] in ORDER else len(ORDER)) qr_done = False for it in items: show(*it) if not qr_done and it[0] == "REALITY" and shutil.which("qrencode") and not os.environ.get("KIT_NO_QR"): sys.stdout.flush() subprocess.run(["qrencode", "-t", "ANSIUTF8", "-m", "1", it[4]]) print() qr_done = True PY python3 -c "$script" "$name" <<<"$raw" } # ---------- Hysteria2: ответ сайтом на чужой HTTP/3-запрос (masquerade) ---------- hy_masq_state() { api GET inbounds/list 2>/dev/null | jq -r '[.[] | select(.protocol == "hysteria")][0] | (.streamSettings | if type == "string" then fromjson else . end).hysteriaSettings.masquerade.type // empty' 2>/dev/null || true; } hy_masq() { # on|off local act=${1:-} id page [[ $act == on || $act == off ]] || die "kit net masq on|off" id=$(api GET inbounds/list | jq -r '[.[] | select(.protocol == "hysteria")][0].id // empty') [[ -n $id ]] || die "На сервере нет Hysteria2." if [[ $act == on ]]; then # Байт в байт как у nginx (с последним переводом строки). page=$(cat /var/www/kit/index.html 2>/dev/null && printf x || true); page=${page%x} # Страницы нет только у старых установок с отдельными портами – тогда простая страница без названия. [[ -n $page ]] || page='Welcome

Welcome

This site is being updated. Please check back soon.

' inbound_patch "$id" '.streamSettings |= ((if type == "string" then fromjson else . end) | .hysteriaSettings.masquerade = {type: "string", content: $c, statusCode: 200, headers: {"content-type": "text/html; charset=utf-8"}})' --arg c "$page" \ || die "Панель не приняла изменение. Ничего не изменилось." say "Hysteria2 теперь отвечает на посторонний HTTP/3-запрос страницей сайта." else inbound_patch "$id" '.streamSettings |= ((if type == "string" then fromjson else . end) | del(.hysteriaSettings.masquerade))' || die "Панель не приняла изменение." say "Маскировка Hysteria2 выключена." fi } # ---------- DNS в подписках ---------- # Clash/Mihomo: безопасный dns добавляет kit-sub. Xray JSON: dns берётся из настройки панели subJsonDns (по умолчанию – обычный # UDP на 8.8.8.8), ставим DoH. Запросы идут по правилам клиента, то есть через прокси. DNS_XRAY='{"servers":[{"address":"https://1.1.1.1/dns-query","skipFallback":false},{"address":"https://8.8.8.8/dns-query","skipFallback":true}],"queryStrategy":"UseIP","tag":"dns_out"}' net_dns() { # [on|off] local act=${1:-} all cur all=$(api POST setting/all '{}') cur=$(jq -r '.subJsonDns // ""' <<<"$all") case $act in "") if [[ -n $cur ]]; then echo "DNS в подписках: DoH через прокси (Clash – от kit-sub, Xray JSON – настройка панели). Выключить: kit net dns off" else echo "DNS в подписке Xray JSON: обычный UDP 8.8.8.8 (по умолчанию панели). Включить DoH: kit net dns on"; fi ;; on) [[ -z $cur ]] || { say "DNS уже настроен (DoH)."; return 0; } api POST setting/update "$(jq -c --arg d "$DNS_XRAY" '.subJsonDns = $d' <<<"$all")" >/dev/null || die "Панель не приняла настройку." systemctl restart x-ui; panel_healthy || warn "Панель не ответила после перезапуска: проверьте kit check." say "DNS в подписках: DoH через прокси. Клиенты подхватят при обновлении подписки." ;; off) api POST setting/update "$(jq -c '.subJsonDns = ""' <<<"$all")" >/dev/null || die "Панель не приняла настройку." systemctl restart x-ui; panel_healthy || true say "Вернул DNS панели по умолчанию." ;; *) die "kit net dns [on|off]" ;; esac } # ---------- раздельная маршрутизация в подписке ---------- # Необязательно: /etc/kit-sub/rules.yaml. Через VPN идёт только перечисленное, остальное (в том числе местные приложения) # напрямую. Работает для приложений на Mihomo (Clash Verge, FlClash, Mihomo Party): правила приезжают с подпиской, на телефонах # ничего настраивать не нужно. DNS при этом не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH # через VPN (подробности – в kit-sub.py). Нет файла – подписка как обычно, весь трафик через VPN. SPLIT_FILE=/etc/kit-sub/rules.yaml split_template() { cat <<'YAML' # Раздельная маршрутизация подписки (приложения на Mihomo: Clash Verge, FlClash, Mihomo Party). # Через VPN идёт ТОЛЬКО перечисленное ниже (сервисы, которым VPN нужен), всё остальное – напрямую. # Правка вступает в силу, когда приложение обновит подписку; перезапуск не нужен. # Проверить файл: kit net split check. Выключить: kit net split off. # # Правила (по одному в строке): ТИП,ЗНАЧЕНИЕ # GEOSITE,youtube – готовая категория сервиса (список: github.com/MetaCubeX/meta-rules-dat, geo/geosite) # DOMAIN-SUFFIX,example.org – домен и все его поддомены # DOMAIN,api.example.org – ровно этот домен # DOMAIN-KEYWORD,example – домен, в котором есть слово # IP-CIDR,203.0.113.0/24 – подсеть # GEOSITE и GEOIP: приложение один раз скачивает базу категорий с GitHub. via_vpn: # Нейросети - GEOSITE,category-ai-!cn # ChatGPT, Claude, Gemini, Perplexity и другие - GEOSITE,openai - GEOSITE,anthropic - GEOSITE,perplexity - GEOSITE,huggingface # Видео и музыка - GEOSITE,youtube - GEOSITE,netflix - GEOSITE,spotify - GEOSITE,twitch - GEOSITE,disney - GEOSITE,hbo - GEOSITE,primevideo - GEOSITE,soundcloud - GEOSITE,vimeo - GEOSITE,tiktok # Соцсети и мессенджеры - GEOSITE,telegram - GEOIP,telegram # Telegram подключается по IP-адресам, без имён - GEOSITE,meta # Instagram, Facebook, WhatsApp - GEOSITE,twitter # X (Twitter) - GEOSITE,discord - GEOSITE,reddit - GEOSITE,linkedin - GEOSITE,pinterest - GEOSITE,tumblr - GEOSITE,quora - GEOSITE,medium - GEOSITE,signal - GEOSITE,viber # Работа и разработка - GEOSITE,google # все сервисы Google - GEOSITE,github - GEOSITE,gitlab - GEOSITE,docker - GEOSITE,npmjs - GEOSITE,jetbrains - GEOSITE,atlassian - GEOSITE,slack - GEOSITE,notion - GEOSITE,figma - GEOSITE,canva - GEOSITE,adobe - GEOSITE,zoom - GEOSITE,trello - GEOSITE,dropbox - GEOSITE,protonmail - GEOSITE,mega - GEOSITE,imgur - GEOSITE,duckduckgo - GEOSITE,wikimedia # Иностранные СМИ - GEOSITE,bbc - GEOSITE,cnn - GEOSITE,nytimes - GEOSITE,reuters - GEOSITE,bloomberg # Раскомментируйте, если нужно (уберите «# » в начале строки). Это тяжёлый трафик или сервисы с привязкой к региону: # - GEOSITE,microsoft # - GEOSITE,apple # - GEOSITE,steam # - GEOSITE,epicgames # - GEOSITE,playstation # - GEOSITE,xbox # - GEOSITE,nintendo # - GEOSITE,cloudflare # - GEOSITE,paypal # - DOMAIN-SUFFIX,example.org # Необязательно: DNS для всего остального (по умолчанию – системный DNS приложения). Только адреса https://… # direct_dns: # - https://dns.example.net/dns-query YAML } # Приложениям на Xray правила раздаёт сама панель (3X-UI 3.9+): заголовок Routing для Happ и правила JSON-подписки. # Собираем их из того же rules.yaml. Чужой профиль, уже стоящий в панели, не трогаем. split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/ с Name "3X-UI_autoVPS" local v=$1 [[ $v == happ://routing/onadd/* ]] && v=$(printf '%s' "${v#happ://routing/onadd/}" | base64 -d 2>/dev/null || true) grep -q '"Name":"3X-UI_autoVPS"' <<<"$v" } split_panel_sync() { # on|off local all cur_h cur_j h j out all=$(api POST setting/all '{}') || return 1 cur_h=$(jq -r '.subRoutingRules // ""' <<<"$all"); cur_j=$(jq -r '.subJsonRoutingRules // ""' <<<"$all") if [[ $1 == on ]]; then if { [[ -n $cur_h ]] && ! split_panel_ours "$cur_h"; } || { [[ -n $cur_j ]] && ! split_panel_ours "$cur_j"; }; then warn "В панели уже настроена своя маршрутизация подписки (Happ или JSON) – не меняю. Приложения на Mihomo получат список как обычно." return 0 fi h=$(python3 /usr/local/lib/kit-sub/kit_sub.py --routing-payload happ 2>/dev/null) && j=$(python3 /usr/local/lib/kit-sub/kit_sub.py --routing-payload json 2>/dev/null) \ || { warn "Список не разобран – для Happ и Xray ничего не менял (kit net split check)."; return 1; } out=$(jq -c --arg h "$h" --arg j "$j" '.subEnableRouting = true | .subRoutingRules = $h | .subJsonRoutingRules = $j' <<<"$all") else [[ -n $cur_h || -n $cur_j ]] || return 0 if { [[ -n $cur_h ]] && ! split_panel_ours "$cur_h"; } || { [[ -n $cur_j ]] && ! split_panel_ours "$cur_j"; }; then return 0; fi out=$(jq -c '.subEnableRouting = false | .subRoutingRules = "" | .subJsonRoutingRules = ""' <<<"$all") fi api POST setting/update "$out" >/dev/null || { warn "Панель не приняла маршрутизацию для Happ и Xray."; return 1; } systemctl restart x-ui; panel_healthy || warn "Панель не ответила после перезапуска: проверьте kit check." } # kit-sub работает без root и читает файл сам: если права закрыты (после правки редактором, копирования), список молча не применится. split_perms() { [[ -f $SPLIT_FILE ]] || return 0 if ! [[ $(stat -c %a "$SPLIT_FILE") =~ ^[0-7][0-7][4-7]$ && $(stat -c %a /etc/kit-sub) =~ ^[0-7][0-7][1357]$ ]]; then chmod 644 "$SPLIT_FILE"; chmod 755 /etc/kit-sub warn "Права на $SPLIT_FILE были закрыты для подписки (kit-sub работает не от root) – исправил." fi } net_split() { # [on|off|check] local act=${1:-} n split_perms case $act in "") if [[ -f $SPLIT_FILE ]]; then n=$(python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules 2>&1 | grep -a '^Правил через VPN' || true) echo "Раздельная маршрутизация включена: $n" echo "Файл: $SPLIT_FILE (править, проверить: kit net split check, выключить: kit net split off)" echo "Mihomo-приложения берут список из подписки сами; Happ и JSON-подписка Xray – из панели (после правки файла: kit net split apply)." else echo "Раздельная маршрутизация выключена: через VPN идёт весь трафик. Включить: kit net split on" fi ;; on) [[ -f /usr/local/lib/kit-sub/kit_sub.py ]] || die "Нужна подписка kit-sub (обновите kit: kit update)." if [[ -f $SPLIT_FILE ]]; then say "Уже включено: $SPLIT_FILE"; return 0; fi if [[ -f $SPLIT_FILE.off ]]; then install -m 644 "$SPLIT_FILE.off" "$SPLIT_FILE"; else split_template >"$SPLIT_FILE"; fi chmod 755 /etc/kit-sub; chmod 644 "$SPLIT_FILE" say "Включил: $SPLIT_FILE" split_panel_sync on || true echo "Сейчас через VPN идут только перечисленные сервисы (популярные иностранные сервисы: YouTube, ChatGPT, Telegram и другие), остальное напрямую." echo "Список правится так: nano $SPLIT_FILE, затем kit net split check." echo "Приложения на Mihomo подхватят при обновлении подписки; после правки списка для Happ и Xray: kit net split apply." ;; off) [[ -f $SPLIT_FILE ]] || { say "Уже выключено."; return 0; } mv "$SPLIT_FILE" "$SPLIT_FILE.off" split_panel_sync off || true say "Выключил: подписка снова отдаёт весь трафик через VPN (список сохранён: $SPLIT_FILE.off)." ;; check) [[ -f $SPLIT_FILE ]] || die "Файла нет: $SPLIT_FILE (включить: kit net split on)" python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules && return 0 case $? in 2) die "Исправьте список: с неизвестной категорией приложения на Mihomo не смогут запустить конфиг." ;; *) die "Список не применится – подписка останется без раздельной маршрутизации." ;; esac ;; apply) [[ -f $SPLIT_FILE ]] || die "Файла нет: $SPLIT_FILE (включить: kit net split on)" python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules >/dev/null 2>&1 || die "Список не применится: сначала kit net split check." split_panel_sync on && say "Списки для Happ и Xray в панели обновлены (приложения на Mihomo подхватывают файл сами)." ;; *) die "kit net split [on|off|check|apply]" ;; esac } # ---------- панель и подписка: по IP или по домену ---------- # Со своим доменом (режим «всё на 443») заход по имени домена попадает к сайту-прикрытию. Панель и подписка по # умолчанию открываются по IP; `kit net panel domain` добавляет их и в блок домена (сертификат Let's Encrypt домена), # `kit net panel ip` возвращает как было. Пути у них секретные, как и на IP. PANEL_CONF=/etc/nginx/conf.d/kit.conf # Разбор и пересборка блока домена в конфиге nginx. info – домен, сертификат, ключ; render – новый текст конфига. panel_conf_py() { python3 - "$@" <<'PY' import re, sys op, conf = sys.argv[1], sys.argv[2] text = open(conf).read() def match_brace(t, start): depth = 0 for j in range(start, len(t)): if t[j] == "{": depth += 1 elif t[j] == "}": depth -= 1 if depth == 0: return j + 1 return -1 def servers(t): out, i = [], 0 while True: m = re.search(r"^server\s*\{", t[i:], re.M) if not m: return out a = i + m.start() b = match_brace(t, a) out.append((a, b)) i = b blocks = servers(text) web = next((b for b in blocks if "server_name _;" in text[b[0]:b[1]]), None) steal = next((b for b in blocks if "server_name _;" not in text[b[0]:b[1]] and re.search(r"server_name\s+\S+;", text[b[0]:b[1]])), None) def location(t, path): m = re.search(r"^[ \t]*location\s+" + re.escape(path) + r"\s*\{", t, re.M) if not m: return "" b = match_brace(t, m.end() - 1) return t[m.start():b] if op == "addloc": # addloc конфиг путь-подписки доп.пути…: в блок сайта по IP добавляются места для доп. путей (копия места подписки) if web is None: sys.exit(2) wt = text[web[0]:web[1]] base = location(wt, sys.argv[3]) if not base: sys.exit(3) new_wt = wt for e in sys.argv[4:]: if location(new_wt, e): continue copy = base.replace(sys.argv[3], e, 1) new_wt = new_wt.replace(base, base + "\n" + copy, 1) sys.stdout.write(text[:web[0]] + new_wt + text[web[1]:]) sys.exit(0) if steal is None: sys.exit(2) sb = text[steal[0]:steal[1]] domain = re.search(r"server_name\s+(\S+);", sb).group(1) cert = re.search(r"ssl_certificate\s+(\S+);", sb).group(1) key = re.search(r"ssl_certificate_key\s+(\S+);", sb).group(1) port = re.search(r"listen\s+127\.0\.0\.1:(\d+)", sb).group(1) if op == "info": print(domain, cert, key) sys.exit(0) mode, sub_path, panel_path = sys.argv[3], sys.argv[4], sys.argv[5] extras = sys.argv[6:] if mode == "domain": wb = text[web[0]:web[1]] if web else "" locs = "\n".join(x for x in [location(wb, sub_path), location(wb, panel_path)] + [location(wb, e) for e in extras] if x) if not locs: sys.exit(3) new = f"""server {{ listen 127.0.0.1:{port} ssl http2 proxy_protocol; server_name {domain}; ssl_certificate {cert}; ssl_certificate_key {key}; ssl_protocols TLSv1.2 TLSv1.3; set_real_ip_from 127.0.0.1; real_ip_header proxy_protocol; server_tokens off; absolute_redirect off; access_log off; # kit:panel-on-domain {locs} location / {{ root /var/www/kit; index index.html; }} }}""" else: new = f"""server {{ listen 127.0.0.1:{port} ssl http2; server_name {domain}; ssl_certificate {cert}; ssl_certificate_key {key}; ssl_protocols TLSv1.2 TLSv1.3; server_tokens off; access_log off; location / {{ root /var/www/kit; index index.html; }} }}""" sys.stdout.write(text[:steal[0]] + new + text[steal[1]:]) PY } # Путь панели (install-result.env) и подписки (kit.env) идут в конфиг nginx: только буквы, цифры, _ и -. panel_web_path() { [[ ${XUI_WEB_BASE_PATH:-} =~ ^/?[A-Za-z0-9_-]{1,64}/?$ && ${SUB_PATH:-} =~ ^/[A-Za-z0-9_-]{1,64}/$ ]] || return 1 local p=/${XUI_WEB_BASE_PATH#/} echo "${p%/}/" } kit_env_set() { # ключ значение: меняет или добавляет строку в /etc/kit/kit.env python3 - "$KIT_ENV" "$1" "$2" <<'PY' import re, shlex, sys p, k, v = sys.argv[1:4] lines = open(p).read().splitlines() new = f"{k}={shlex.quote(v)}" for i, l in enumerate(lines): if l.startswith(k + "="): lines[i] = new break else: lines.append(new) open(p, "w").write("\n".join(lines) + "\n") PY } # Страница подписки в браузере показывает ещё два адреса (Clash и JSON) с отдельными секретными путями. Пропускаем их через # kit-sub (те же «Авто» и DNS) и через nginx. Повторный запуск безопасен. sub_extra_sync() { local cfg=/etc/kit-sub/config.json all jp cp extras tmp bak panel_path [[ -f $cfg ]] || return 0 all=$(api POST setting/all '{}') jp=$(jq -r '.subJsonPath // ""' <<<"$all"); cp=$(jq -r '.subClashPath // ""' <<<"$all") extras=$(jq -nc --arg a "$jp" --arg b "$cp" --arg s "${SUB_PATH:-}" '[$a, $b] | map(select(. != "" and . != "/")) | map("/" + (ltrimstr("/") | rtrimstr("/")) + "/") | map(select(test("^/[A-Za-z0-9_-]+/$") and . != $s)) | unique') tmp=$(mktemp) jq --argjson e "$extras" '.extra_paths = $e' "$cfg" >"$tmp" && install -m 600 "$tmp" "$cfg" rm -f "$tmp" if [[ ${SINGLE:-no} == yes && -f $PANEL_CONF ]]; then local -a ex=() mapfile -t ex < <(jq -r '.[]' <<<"$extras") tmp=$(mktemp); bak=$(mktemp); cp -p "$PANEL_CONF" "$bak" if panel_conf_py addloc "$PANEL_CONF" "$SUB_PATH" "${ex[@]}" >"$tmp" && [[ -s $tmp ]]; then cat "$tmp" >"$PANEL_CONF" if [[ ${PANEL_ON:-ip} == domain ]] && panel_path=$(panel_web_path); then panel_conf_py render "$PANEL_CONF" domain "$SUB_PATH" "$panel_path" "${ex[@]}" >"$tmp" && [[ -s $tmp ]] && cat "$tmp" >"$PANEL_CONF" fi if nginx -t >/dev/null 2>&1; then systemctl reload nginx else cat "$bak" >"$PANEL_CONF"; warn "nginx не принял новые пути подписки – оставил прежние настройки."; fi fi rm -f "$tmp" "$bak" fi systemctl restart kit-sub } net_panel() { # [domain|ip] local want=${1:-} info domain cert key cur new tmp bak rid panel_path old_host new_host all [[ ${SINGLE:-no} == yes && -f $PANEL_CONF ]] || die "Работает в режиме «всё на 443» со своим доменом (--domain при установке)." info=$(panel_conf_py info "$PANEL_CONF") || die "В настройках nginx нет блока своего домена: панель по домену доступна только при установке с --domain." read -r domain cert key <<<"$info" cur=${PANEL_ON:-ip} if [[ -z $want ]]; then if [[ $cur == domain ]]; then echo "Панель и подписка открываются по домену $domain (вернуть на IP: kit net panel ip)" else echo "Панель и подписка открываются по IP ${HOST:-?}; домен $domain – сайт для маскировки (открывать и по домену: kit net panel domain)"; fi return 0 fi [[ $want == domain || $want == ip ]] || die "kit net panel [domain|ip]" [[ $want != "$cur" ]] || { say "Уже так: панель и подписка по $([[ $want == domain ]] && echo "домену $domain" || echo "IP")."; return 0; } panel_path=$(panel_web_path) || die "Странный путь панели или подписки в $XUI_ENV или $KIT_ENV – не трогаю nginx." if [[ $want == domain ]]; then getent ahostsv4 "$domain" 2>/dev/null | awk '{print $1}' | grep -qx "$(host_ip)" || die "Домен $domain не указывает на этот сервер ($(host_ip)). Исправьте A-запись и повторите." [[ -s $cert && -s $key ]] || die "Нет сертификата домена ($cert) – не трогаю nginx." fi tmp=$(mktemp); bak=$(mktemp); cp -p "$PANEL_CONF" "$bak" local -a ex=() mapfile -t ex < <(jq -r '.extra_paths[]?' /etc/kit-sub/config.json 2>/dev/null || true) panel_conf_py render "$PANEL_CONF" "$want" "$SUB_PATH" "$panel_path" "${ex[@]}" >"$tmp" || { rm -f "$tmp" "$bak"; die "Не удалось собрать настройки nginx. Ничего не изменилось."; } local -a rids=() mapfile -t rids < <(sni_targets | awk -F'\t' '$2 == "self" {print $1}') ((${#rids[@]})) || { rm -f "$tmp" "$bak"; die "Не нашёл REALITY со своим доменом."; } panel_xver() { # 0|1 – у всех REALITY, чей сайт – блок домена (в том числе REALITY-2) local r for r in "${rids[@]}"; do inbound_patch "$r" '.streamSettings |= ((if type == "string" then fromjson else . end) | .realitySettings.xver = $x)' --argjson x "$1" || return 1 done } # Передача настоящего IP клиента сайту домена (PROXY protocol) нужна, чтобы панель видела, кто заходит. local xv=0; [[ $want == domain ]] && xv=1 panel_revert() { cat "$bak" >"$PANEL_CONF"; panel_xver "$((1 - xv))" || true; nginx -t >/dev/null 2>&1 && systemctl reload nginx || true; } if [[ $want == domain ]]; then cat "$tmp" >"$PANEL_CONF" nginx -t >/dev/null 2>&1 || { cat "$bak" >"$PANEL_CONF"; rm -f "$tmp" "$bak"; die "nginx не принял настройки. Ничего не изменилось."; } panel_xver 1 || { cat "$bak" >"$PANEL_CONF"; rm -f "$tmp" "$bak"; die "Панель не приняла изменение. Ничего не изменилось."; } systemctl reload nginx || { panel_revert; rm -f "$tmp" "$bak"; die "nginx не перезагрузился. Всё возвращено."; } else panel_xver 0 || { rm -f "$tmp" "$bak"; die "Панель не приняла изменение. Ничего не изменилось."; } cat "$tmp" >"$PANEL_CONF" if ! nginx -t >/dev/null 2>&1 || ! systemctl reload nginx; then panel_revert; rm -f "$tmp" "$bak"; die "nginx не принял настройки. Всё возвращено."; fi fi rm -f "$tmp" "$bak" # Ссылки: адрес подписки в kit, панели и файле с данными входа. old_host=${HOST}; new_host=$domain if [[ $want == ip ]]; then old_host=$domain; new_host=${HOST}; fi kit_env_set PANEL_ON "$want" kit_env_set SUB_BASE "https://$([[ $want == domain ]] && echo "$domain" || echo "$HOST")${SUB_PATH}" all=$(api POST setting/all '{}') api POST setting/update "$(jq -c --arg u "https://$([[ $want == domain ]] && echo "$domain" || echo "$HOST")${SUB_PATH}" '.subURI = $u' <<<"$all")" >/dev/null || warn "Не обновил адрес подписки в панели." [[ -f /root/3x-ui.txt ]] && python3 - /root/3x-ui.txt "https://$old_host" "https://$new_host" <<'PY' import sys p, a, b = sys.argv[1:4] s = open(p).read() open(p, "w").write(s.replace(a + "/", b + "/").replace(a + ":", b + ":")) PY if [[ $want == domain ]]; then say "Панель и подписка теперь открываются и по домену $domain." echo "Панель: https://$domain$panel_path" echo "Подписка: https://$domain${SUB_PATH}ПОДПИСКА_ПОЛЬЗОВАТЕЛЯ (ссылки: kit user link имя)" echo "По IP они тоже работают. Вернуть как было: kit net panel ip" else say "Панель и подписка снова только по IP ${HOST}." echo "Панель: https://${HOST}$panel_path" fi } # ---------- kit net: протоколы, порты, сайт маскировки, отпечаток ---------- net_main_name() { case $1 in REALITY | XHTTP | Hysteria2) return 0 ;; *) return 1 ;; esac; } net_resolve() { # короткое имя → название подключения case ${1,,} in reality) echo REALITY ;; reality2 | reality-2) echo REALITY-2 ;; xhttp) echo XHTTP ;; hy2 | hysteria2 | hysteria) echo Hysteria2 ;; ws | vless-ws) echo VLESS-WS ;; grpc | trojan | trojan-grpc) echo Trojan-gRPC ;; vmess | vmess-ws) echo VMess-WS ;; ss | shadowsocks) echo Shadowsocks ;; tuic) echo TUIC ;; wg | wireguard) echo WireGuard ;; awg | amneziawg) echo AmneziaWG ;; awg3 | amneziawg-3.1) echo AmneziaWG-3.1 ;; mtproto) echo MTProto ;; *) echo "$1" ;; esac } inbound_body_of() { # строка подключения (json) → тело для inbounds/update jq -c '{id, remark, enable, listen, port, protocol, expiryTime, total, settings: (.settings | if type == "string" then . else tojson end), streamSettings: (.streamSettings | if type == "string" then . else tojson end), sniffing: (.sniffing | if type == "string" then . else tojson end)}' <<<"$1" } inbound_patch() { # id фильтр-jq [аргументы jq...]: меняет подключение, ошибка – код возврата, не выход local id=$1 filter=$2 row new list before shift 2 list=$(api GET inbounds/list) row=$(jq -c --argjson id "$id" '.[] | select(.id == $id)' <<<"$list") [[ -n $row ]] || return 1 new=$(jq -c "$@" "$filter" <<<"$row") || return 1 before=$(inbounds_down "$list") (api POST "inbounds/update/$id" "$(inbound_body_of "$new")" >/dev/null) || return 1 xray_heal "$before" } net_show() { local list name port proto listen en sni fp shown_reality2=no mark note fpl="" list=$(api GET inbounds/list) printf '%s %s %s %s\n' "$(padr Протоколы 22)" "$(padr Порт 12)" "$(padr Статус 8)" "Заметка" { while IFS='|' read -r name port proto listen en sni fp; do [[ -n $name ]] || continue [[ $name == REALITY-2 ]] && shown_reality2=yes if [[ $listen == 127.0.0.1 ]]; then mark="443/tcp"; else mark="$port/$(port_net "$proto")"; fi [[ $(port_net "$proto") == both ]] && mark=$port note="" [[ -z $sni ]] || note="сайт: $sni" [[ -n $fp && -z $fpl ]] && fpl=$fp printf ' %s %s %s %s\n' "$(padr "$name" 20)" "$(padr "$mark" 12)" "$(padr "$([[ $en == true ]] && echo вкл || echo выкл)" 8)" "$note" done < <(jq -r '.[] | (.streamSettings | if type == "string" then fromjson else . end) as $s | [.remark, .port, .protocol, .listen, .enable, ($s.realitySettings.serverNames[0] // ""), ($s.realitySettings.settings.fingerprint // $s.tlsSettings.settings.fingerprint // "")] | map(tostring) | join("|")' <<<"$list") } [[ $shown_reality2 == yes ]] || printf ' %s %s %s %s\n' "$(padr REALITY-2 20)" "$(padr – 12)" "$(padr выкл 8)" "включить: kit net on reality2" echo echo "Отпечаток клиента: ${fpl:-?} (сменить: kit net fp firefox)" if [[ ${SINGLE:-no} == yes && -f $PANEL_CONF ]] && panel_conf_py info "$PANEL_CONF" >/dev/null 2>&1; then net_panel fi if jq -e 'any(.[]; .protocol == "hysteria")' <<<"$list" >/dev/null; then if [[ -n $(hy_masq_state) ]]; then echo "Hysteria2 отвечает на посторонний запрос страницей сайта (выключить: kit net masq off)" else echo "Hysteria2 без маскировки под сайт (включить: kit net masq on)"; fi fi } net_toggle() { # on|off имя [-y] local act=$1 name row id en listen proto port net yes=no shift [[ -n ${1:-} ]] || die "kit net $act имя (например: kit net $act vmess-ws). Список: kit net" name=$(net_resolve "$1"); [[ ${2:-} == -y || ${2:-} == --yes ]] && yes=yes row=$(api GET inbounds/list | jq -c --arg n "${name,,}" '[.[] | select((.remark | ascii_downcase) == $n)][0] // empty') if [[ -z $row ]]; then if [[ $name == REALITY-2 && $act == on ]]; then reality_add; return; fi die "Нет подключения «$name». Список: kit net" fi id=$(jq -r '.id' <<<"$row"); en=$(jq -r '.enable' <<<"$row"); listen=$(jq -r '.listen' <<<"$row") proto=$(jq -r '.protocol' <<<"$row"); port=$(jq -r '.port' <<<"$row"); net=$(port_net "$proto") if [[ $act == off ]]; then [[ $en == true ]] || { say "$name уже выключен."; return 0; } if net_main_name "$name" && [[ $yes == no ]]; then if [[ -t 0 ]]; then local ans; read -r -p "$name – основной протокол. Выключить? [y/N] " ans [[ $ans =~ ^[yYдД]$ ]] || { echo "Отменено."; return 0; } else die "$name – основной протокол. Выключить: kit net off $1 -y"; fi fi (api POST "inbounds/setEnable/$id" '{"enable":false}' >/dev/null) || die "Панель не приняла изменение. Ничего не изменилось." if [[ $listen != 127.0.0.1 ]] && command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q '^Status: active'; then port_ufw delete "$port" "$net"; fi if [[ $listen != 127.0.0.1 ]]; then say "$name выключен, порт $port/$net закрыт. Вернуть: kit net on $1"; else say "$name выключен. Вернуть: kit net on $1"; fi echo "Из подписки пропадёт при её обновлении в приложении." else [[ $en == true ]] && { say "$name уже включён."; return 0; } if [[ $listen != 127.0.0.1 ]] && command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q '^Status: active'; then port_ufw allow "$port" "$net"; fi local before; before=$(inbounds_down) (api POST "inbounds/setEnable/$id" '{"enable":true}' >/dev/null) || die "Панель не приняла изменение. Ничего не изменилось." xray_heal "$before" say "$name включён." fi } net_fp() { # отпечаток local f=${1:-} n=0 id old [[ -n $f ]] || die "kit net fp отпечаток (chrome, firefox, safari, edge, ios, android, qq, 360, random, randomized)" f=${f,,} [[ $f =~ ^(chrome|firefox|safari|edge|ios|android|qq|360|random|randomized)$ ]] || die "Неизвестный отпечаток «$f». Доступны: chrome, firefox, safari, edge, ios, android, qq, 360, random, randomized." old=$(api GET inbounds/list | jq -r '[.[] | (.streamSettings | if type == "string" then fromjson else . end) | (.realitySettings.settings.fingerprint // .tlsSettings.settings.fingerprint // empty)][0] // "?"') while read -r id; do inbound_patch "$id" '.streamSettings |= ((if type == "string" then fromjson else . end) | (if .realitySettings.settings then .realitySettings.settings.fingerprint = $f else . end) | (if .tlsSettings.settings then .tlsSettings.settings.fingerprint = $f else . end) | (if .externalProxy then .externalProxy |= map(if has("fingerprint") then .fingerprint = $f else . end) else . end))' --arg f "$f" \ && n=$((n + 1)) done < <(api GET inbounds/list | jq -r '.[] | select((.streamSettings | if type == "string" then fromjson else . end) | (.security == "reality" or .security == "tls")) | .id') say "Отпечаток $old → ${B}$f${N}, обновлено подключений: $n." echo "Подписка обновится сама; вручную сохранённые ссылки замените (kit user link имя)." echo "Вернуть прежний: kit net fp chrome." } cmd_net() { local sub=${1:-} [[ -z $sub ]] || shift case ${sub,,} in "") net_show ;; site) [[ $# -gt 0 ]] || set -- --nearby; sni_rotate "$@" ;; port) port_set "$@" ;; off | on) net_toggle "${sub,,}" "$@" ;; fp) net_fp "$@" ;; masq) hy_masq "$@" ;; dns) net_dns "$@" ;; panel) net_panel "$@" ;; split) net_split "$@" ;; vision) case ${1:-} in on) cmd_vision --all ;; off) cmd_vision --all off ;; *) die "kit net vision on|off" ;; esac ;; *) die "kit net [site | port имя порт | off имя | on имя | fp отпечаток | masq on|off | dns on|off | panel domain|ip | split on|off|check|apply | vision on|off]" ;; esac } # ---------- меню (как у x-ui) ---------- BOX_W=48 box_rule() { local i; for ((i = 0; i < BOX_W; i++)); do printf '─'; done; } box_top() { printf '╔'; box_rule; printf '╗\n'; } box_bot() { printf '╚'; box_rule; printf '╝\n'; } box_sep() { printf '│'; box_rule; printf '│\n'; } padr() { local n=$(($2 - ${#1})); ((n < 0)) && n=0; printf '%s%*s' "$1" "$n" ''; } # по символам: printf %-Ns считает байты box_line() { printf '│ %s│\n' "$(padr "$1" $((BOX_W - 2)))"; } box() { # заголовок, затем пункты; пустой пункт – разделитель local t=$1 l; shift box_top; box_line "$t"; box_sep for l in "$@"; do if [[ -z $l ]]; then box_sep; else box_line "$l"; fi; done box_bot } ask_tty() { read -r -p "$1" REPLY /dev/null | awk -F'\t' '$2 == "reality" || $2 == "self" {print $4; exit}' || true) users=$(clients 2>/dev/null | jq '[.[] | select(.email | test("-awg[0-9]*$") | not)] | length' 2>/dev/null || echo "?") if systemctl is-active -q x-ui; then st="панель работает"; else st="панель НЕ работает"; fi if auto_enabled; then auto=вкл; else auto=выкл; fi echo "Сервер: ${HOST:-?} · Сайт маскировки: ${sni:--}" echo "Состояние: $st · Xray $(xray_version 2>/dev/null || echo '?') · пользователей $users · автообновление $auto" } pick_user() { cmd_list ask_tty "Имя пользователя: " PICK=${REPLY//[[:space:]]/} [[ -n $PICK ]] || return 1 valid_name "$PICK" } u_add() { local name gb days dev a=() ask_tty "Имя пользователя: "; name=${REPLY//[[:space:]]/} ask_tty "Лимит трафика, ГБ (Enter – без лимита): "; gb=${REPLY//[[:space:]]/} ask_tty "Срок, дней (Enter – без срока): "; days=${REPLY//[[:space:]]/} ask_tty "Устройств (Enter – без ограничения): "; dev=${REPLY//[[:space:]]/} [[ -z $gb ]] || a+=(--gb "$gb"); [[ -z $days ]] || a+=(--days "$days"); [[ -z $dev ]] || a+=(--devices "$dev") cmd_add "$name" "${a[@]}" } u_link() { pick_user || return 0 ask_num "1 – ссылки, 2 – AmneziaVPN, 3 – Telegram [1]: " case ${REPLY:-1} in 2) cmd_link "$PICK" --amnezia ;; 3) cmd_link "$PICK" --telegram ;; *) cmd_link "$PICK" ;; esac } u_limit() { local gb days dev a=() pick_user || return 0 echo "Enter – не менять, 0 – без ограничения." ask_tty "Лимит трафика, ГБ: "; gb=${REPLY//[[:space:]]/} ask_tty "Срок, дней: "; days=${REPLY//[[:space:]]/} ask_tty "Устройств: "; dev=${REPLY//[[:space:]]/} [[ -z $gb ]] || a+=(--gb "$gb"); [[ -z $days ]] || a+=(--days "$days"); [[ -z $dev ]] || a+=(--devices "$dev") ((${#a[@]})) || { echo "Ничего не изменено."; return 0; } cmd_limit "$PICK" "${a[@]}" } u_toggle() { pick_user || return 0 ask_num "1 – выключить, 2 – включить: " case $REPLY in 1) cmd_toggle "$PICK" false ;; 2) cmd_toggle "$PICK" true ;; *) echo "Отменено." ;; esac } u_del() { pick_user || return 0; cmd_del "$PICK"; } menu_users() { local c while :; do echo box "Пользователи" "1. Список (трафик, срок, статус)" "2. Добавить" "3. Показать ссылки" "4. Изменить лимит" "5. Выключить или включить" "6. Удалить" "" "0. Назад" ask_num "Выбор [0-6]: "; c=$REPLY case $c in 1) run_action cmd_list; pause ;; 2) run_action u_add; pause ;; 3) run_action u_link; pause ;; 4) run_action u_limit; pause ;; 5) run_action u_toggle; pause ;; 6) run_action u_del; pause ;; 0 | "") return 0 ;; *) echo "Нет такого пункта." ;; esac done } pick_proto() { # PICK = название подключения (в том числе ещё не созданный REALITY-2) local -a names=(); local i n en mapfile -t names < <(api GET inbounds/list | jq -r '.[] | [.remark, (if .enable then "вкл" else "выкл" end)] | @tsv') names+=("REALITY-2"$'\t'"создать") i=0 for n in "${names[@]}"; do i=$((i + 1)) if [[ $n == REALITY-2$'\t'* ]] && api GET inbounds/list | jq -e 'any(.[]; .remark == "REALITY-2")' >/dev/null; then names[i-1]=""; i=$((i)); continue; fi printf ' %2d %-16s %s\n' "$i" "${n%%$'\t'*}" "${n#*$'\t'}" done ask_num "Номер: " [[ $REPLY =~ ^[0-9]+$ ]] && ((REPLY >= 1 && REPLY <= ${#names[@]})) && [[ -n ${names[REPLY-1]} ]] || { echo "Отменено."; return 1; } PICK=${names[REPLY-1]%%$'\t'*} } n_site() { ask_num "1 – подобрать автоматически (сначала соседи по подсети), 2 – свой сайт [1]: " if [[ ${REPLY:-1} == 2 ]]; then ask_tty "Адрес сайта (например example.com): " [[ -n ${REPLY//[[:space:]]/} ]] || { echo "Отменено."; return 0; } sni_rotate "${REPLY//[[:space:]]/}" else sni_rotate --nearby fi } n_port() { local p; pick_proto || return 0; p=$PICK; ask_num "Новый порт: "; port_set "$p" "$REPLY"; } n_toggle() { local en pick_proto || return 0 en=$(api GET inbounds/list | jq -r --arg n "$PICK" '[.[] | select(.remark == $n)][0].enable // "none"') case $en in true) net_toggle off "$PICK" ;; false) net_toggle on "$PICK" ;; *) net_toggle on "$PICK" ;; esac } n_panel() { net_panel ask_num "1 – открывать по домену, 2 – только по IP, Enter – оставить: " case $REPLY in 1) net_panel domain ;; 2) net_panel ip ;; esac } n_fp() { local -a fps=(chrome firefox safari edge ios android qq 360 random randomized); local i for i in "${!fps[@]}"; do printf ' %2d %s\n' "$((i + 1))" "${fps[$i]}"; done ask_num "Номер: " [[ $REPLY =~ ^[0-9]+$ ]] && ((REPLY >= 1 && REPLY <= ${#fps[@]})) || { echo "Отменено."; return 0; } net_fp "${fps[REPLY-1]}" } menu_net() { local c while :; do echo box "Протоколы, порты и маскировка" "1. Показать протоколы и порты" "2. Сменить сайт маскировки" "3. Сменить порт протокола" "4. Выключить или включить протокол" "5. Сменить отпечаток клиента" "6. Второй REALITY на высоком порту" "7. Панель и подписка: по IP или по домену" "" "0. Назад" ask_num "Выбор [0-7]: "; c=$REPLY case $c in 1) run_action net_show; pause ;; 2) run_action n_site; pause ;; 3) run_action n_port; pause ;; 4) run_action n_toggle; pause ;; 5) run_action n_fp; pause ;; 6) run_action net_toggle on reality2; pause ;; 7) run_action n_panel; pause ;; 0 | "") return 0 ;; *) echo "Нет такого пункта." ;; esac done } menu_check() { local c while :; do echo box "Проверка сервера" "1. Быстрая проверка" "2. Глубокая (подключиться клиентом)" "3. Исправить безопасное" "" "0. Назад" ask_num "Выбор [0-3]: "; c=$REPLY case $c in 1) run_action cmd_check; pause ;; 2) run_action cmd_check --deep; pause ;; 3) run_action cmd_fix; pause ;; 0 | "") return 0 ;; *) echo "Нет такого пункта." ;; esac done } menu_auto() { local ans if auto_enabled; then ask_tty "Автообновление сейчас включено (ночью, только подписанные релизы). Выключить? [y/N] " [[ $REPLY =~ ^[yYдД]$ ]] && auto_off && say "Автообновление выключено. Обновляться вручную: пункт 4." else ask_tty "Автообновление сейчас выключено. Включить? [y/N] " [[ $REPLY =~ ^[yYдД]$ ]] && auto_on && say "Автообновление включено." fi return 0 } menu_main() { local c while :; do echo box "3X-UI_autoVPS $KIT_VERSION – управление сервером" "1. Пользователи" "2. Протоколы, порты и маскировка" "3. Проверка сервера" "" \ "4. Обновить kit и подписку" "5. Обновить панель 3X-UI" "6. Автообновление: включить или выключить" "" \ "7. Резервная копия" "8. Версии" "" "9. Удалить всё установленное" "10. Выход" \ "11. Панель 3X-UI (меню x-ui)" echo menu_status update_hint echo ask_num "Выбор [1-11]: "; c=$REPLY case $c in 1) menu_users ;; 2) menu_net ;; 3) menu_check ;; 4) run_action cmd_update; pause ;; 5) run_action panel_update; pause ;; 6) run_action menu_auto; pause ;; 7) run_action cmd_backup; pause ;; 8) run_action cmd_version; pause ;; 9) kit_local_uninstall && return 0 ;; # снос — и сразу выход: показывать меню после удаления незачем 10 | 0 | "") return 0 ;; 11) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;; *) echo "Нет такого пункта." ;; esac done } usage() { cat <