3410 lines
210 KiB
Bash
3410 lines
210 KiB
Bash
#!/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.1.1"
|
||
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() { spin_stop; printf '%s\n' "${Y}!${N} $*" >&2; }
|
||
die() { spin_stop; printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
|
||
|
||
# --- Оформление вывода: ожидание и советы ---------------------------------------------------------
|
||
# Анимация ожидания: пока скрипт занят долгой работой без вывода (опрос соседей по подсети, установка пакетов),
|
||
# крутится индикатор и идут секунды, чтобы не казалось, что всё зависло. Без терминала (лог, pipe) – одна строка.
|
||
SPIN_PID=""
|
||
spin_start() { # текст
|
||
spin_stop
|
||
if [[ ! -t 2 ]]; then printf '%s…\n' "$1" >&2; return 0; fi
|
||
local msg=$1 start=$SECONDS parent=$$
|
||
(
|
||
local -a fr=('|' '/' '-' '\\') i=0
|
||
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in *[Uu][Tt][Ff]-8* | *[Uu][Tt][Ff]8*) fr=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) ;; esac
|
||
while kill -0 "$parent" 2>/dev/null; do
|
||
printf '\r %s %s %s с ' "${fr[i % ${#fr[@]}]}" "$msg" "$((SECONDS - start))" >&2
|
||
i=$((i + 1)); sleep 0.1
|
||
done
|
||
) &
|
||
SPIN_PID=$!
|
||
}
|
||
spin_stop() {
|
||
[[ -n $SPIN_PID ]] || return 0
|
||
kill "$SPIN_PID" 2>/dev/null || true
|
||
wait "$SPIN_PID" 2>/dev/null || true
|
||
SPIN_PID=""
|
||
[[ -t 2 ]] && printf '\r\033[K' >&2
|
||
return 0
|
||
}
|
||
# Блок «Что дальше»: советы и рекомендации отделены от хода работы рамкой и нумерацией. Пункты – обычные фразы,
|
||
# без команд: пользователя ведём в меню kit.
|
||
tips() { # заголовок, затем пункты
|
||
local title=$1 n=0 item row first
|
||
shift
|
||
printf '\n%s┌─%s %s%s%s\n' "$D" "$N" "$B" "$title" "$N"
|
||
for item in "$@"; do
|
||
n=$((n + 1)); first=yes
|
||
while IFS= read -r row; do
|
||
if [[ $first == yes ]]; then printf '%s│%s %s%d.%s %s\n' "$D" "$N" "$B" "$n" "$N" "$row"; first=no
|
||
else printf '%s│%s %s\n' "$D" "$N" "$row"; fi
|
||
done < <(if command -v python3 >/dev/null 2>&1; then python3 -c 'import sys,textwrap; t=sys.argv[1].replace(" → ","\u00a0→\u00a0"); print("\n".join(textwrap.wrap(t, 74, break_on_hyphens=False, break_long_words=False)).replace("\u00a0"," "))' "$item"; else printf '%s\n' "$item"; fi)
|
||
done
|
||
printf '%s└─%s\n' "$D" "$N"
|
||
}
|
||
|
||
# A-записи домена. Сначала публичный DNS (DoH): локальный резолвер сервера может помнить «записи нет» с тех пор, как её не было,
|
||
# и kit тогда спорит с тем, что вы видите у регистратора. Нет ответа от публичного DNS – берём системный.
|
||
dns_a() { # домен → IPv4 по одному в строке
|
||
local r
|
||
r=$(curl -fsS -m 6 -H 'accept: application/dns-json' "https://1.1.1.1/dns-query?name=$1&type=A" 2>/dev/null \
|
||
| grep -oE '"data":"[0-9]{1,3}(\.[0-9]{1,3}){3}"' | cut -d'"' -f4 | sort -u || true)
|
||
[[ -n $r ]] || r=$(getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u || true)
|
||
printf '%s' "$r"
|
||
}
|
||
|
||
[[ $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 @<(printf 'Authorization: Bearer %s\n' "$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 @<(printf 'Authorization: Bearer %s\n' "$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 @<(printf 'Authorization: Bearer %s\n' "$XUI_API_TOKEN") "$API/$2" || true)
|
||
else
|
||
out=$(curl -sSk -m 20 -w $'\n%{http_code}' -H @<(printf 'Authorization: Bearer %s\n' "$XUI_API_TOKEN") -H 'Content-Type: application/json' -X "$1" --data-binary @<(printf '%s' "${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 → Проверка сервера → Быстрая проверка"
|
||
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» с подпиской «<id>-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'; }
|
||
# Имя из одних точек («.», «..») не годится: оно уходит в путь запроса к панели (clients/update/имя).
|
||
valid_name() { [[ $1 =~ ^[A-Za-z0-9_.-]{1,32}$ && $1 =~ [^.] ]] || 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")
|
||
# После kit net site панель может взять в ссылку прежнее (льготное) имя сайта – ставим нынешнее.
|
||
[[ ! -f ${SNI_GRACE_SUB:-} ]] || out=$(grace_links <<<"$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
|
||
[[ ${3:-} == all ]] || echo "${D}Отдельные ссылки на каждый протокол: kit → Пользователи → Показать ссылки${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 → Пользователи → Показать ссылки"
|
||
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"
|
||
}
|
||
|
||
# Все ссылки на подключения одним экраном: подписка, отдельные протоколы, AmneziaWG и Telegram.
|
||
# Без имени – первый пользователь (после установки это тот, кого создал установщик).
|
||
cmd_links() { # [имя]
|
||
[[ $# -le 1 ]] || die "kit links [имя]"
|
||
local name=${1:-} n
|
||
if [[ -z $name ]]; then
|
||
name=$(clients | jq -r 'map(select(.subId != null and (.email | test("-awg[0-9]*$") | not)))[0].email // empty')
|
||
[[ -n $name ]] || die "Пользователей нет. Добавьте: kit → Пользователи → Добавить"
|
||
n=$(clients | jq -r '[.[] | select(.subId != null and (.email | test("-awg[0-9]*$") | not))] | length')
|
||
((n > 1)) && echo "${D}Показан пользователь $name. Ссылки других: kit → Пользователи → Показать ссылки${N}"
|
||
fi
|
||
cmd_link "$name" --all
|
||
}
|
||
|
||
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
|
||
[[ ${SINGLE:-no} == yes ]] && { echo "${D}Порт 443 – верный (в панели ссылка такая же). ${MTPROTO_INNER:-10445} – внутренний порт сервера, снаружи он закрыт:"; echo "если увидите его в старой ссылке, берите ссылку отсюда.${N}"; echo; }
|
||
command -v qrencode >/dev/null && qrencode -t ANSIUTF8 -m 1 "$(head -1 <<<"$out")"
|
||
return 0 ;;
|
||
"" | --all)
|
||
show_link "$name" "$sid" "${flag#--}"
|
||
# В режиме «только на сервере» показ уже содержит все ссылки.
|
||
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, не выйдет.
|
||
# Обычная смена ключа идёт релизом, подписанным старым. В 1.2.1 так не вышло: прежний ключ утерян, поэтому
|
||
# серверы на 1.2 и ниже переходят вручную (команда в README, kit.sh берётся с тега по HTTPS).
|
||
KIT_SIGNERS=(
|
||
# Наш шов: здесь только ключ форка (hacker07). Авторские ключи сняты намеренно — сервер обязан
|
||
# принимать обновления лишь из нашего канала (git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS).
|
||
"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 → Обновить kit и подписку${N}"
|
||
else
|
||
echo "${Y}↑ Доступна версия $latest${N} (у вас $KIT_VERSION). Обновить: ${B}kit → Обновить kit и подписку${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 → Автообновление${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 <<UNIT
|
||
[Unit]
|
||
Description=kit-sub: подписка с учётом приложения (3X-UI_autoVPS)
|
||
After=network-online.target x-ui.service
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
ExecStart=/usr/bin/python3 /usr/local/lib/kit-sub/kit_sub.py
|
||
Restart=on-failure
|
||
RestartSec=5
|
||
DynamicUser=yes
|
||
LoadCredential=config.json:/etc/kit-sub/config.json
|
||
${1:+LoadCredential=cert.pem:$1}
|
||
${2:+LoadCredential=key.pem:$2}
|
||
NoNewPrivileges=true
|
||
ProtectSystem=strict
|
||
ProtectHome=yes
|
||
PrivateTmp=true
|
||
PrivateDevices=true
|
||
ProtectProc=invisible
|
||
ProtectKernelTunables=true
|
||
ProtectKernelModules=true
|
||
ProtectControlGroups=true
|
||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||
MemoryMax=64M
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
UNIT
|
||
}
|
||
|
||
# Хвост журнала kit-sub для сообщения об откате: без адресов и пути подписки (ссылки и строка «слушает …»).
|
||
# Пустой журнал или все строки отфильтрованы – не ошибка: под set -e и pipefail откат иначе оборвался бы.
|
||
sub_journal() {
|
||
{ journalctl -u kit-sub -n 5 --no-pager -o cat 2>/dev/null || true; } | { grep -v -e 'https\?://' -e 'слушает' || true; } \
|
||
| tail -n 2 | tr '\n' ' ' | cut -c1-200
|
||
}
|
||
|
||
# Подписка отвечает? Берём настоящего пользователя и спрашиваем kit-sub изнутри сервера.
|
||
# Код ответа последней попытки остаётся в SUB_LAST_CODE (000 – соединения не было). С аргументом alive
|
||
# проверяем только, что служба жива и слушает порт (когда подписка не отвечала ещё до обновления).
|
||
SUB_LAST_CODE=""
|
||
sub_ok() {
|
||
local cfg=/etc/kit-sub/config.json port path scheme=http sid i code
|
||
port=$(jq -r '.port' "$cfg"); path=$(jq -r '.path' "$cfg")
|
||
[[ -n $(jq -r '.cert // empty' "$cfg") ]] && scheme=https
|
||
sid=$(clients 2>/dev/null | jq -r '[.[] | .subId // empty | select(. != "")][0] // empty' 2>/dev/null || true)
|
||
[[ ${1:-} == alive ]] && sid=""
|
||
SUB_LAST_CODE=""
|
||
for i in $(seq 1 10); do
|
||
if [[ -n $sid ]]; then
|
||
code=$(curl -sSk -m 5 -o /dev/null -w '%{http_code}' -A "Happ/1.0" "$scheme://127.0.0.1:$port$path$sid" 2>/dev/null || true)
|
||
SUB_LAST_CODE=${code:-000}
|
||
[[ $SUB_LAST_CODE =~ ^[23][0-9][0-9]$ ]] && 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
|
||
mt_needs && 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 <<UNIT
|
||
[Unit]
|
||
Description=3X-UI_autoVPS: автообновление kit и kit-sub (только подписанные релизы)
|
||
After=network-online.target
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=/usr/local/bin/kit update --unattended
|
||
StandardOutput=append:$KIT_UPDATE_LOG
|
||
StandardError=append:$KIT_UPDATE_LOG
|
||
UNIT
|
||
cat >/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 → Обновить kit и подписку. Включить снова: kit → Автообновление"; 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: проверяю обновления"
|
||
# Ночной запуск убирает прежние имена сайта, у которых закончился льготный срок (kit net site).
|
||
(grace_cleanup) || true
|
||
|
||
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 → Обновить kit и подписку${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
|
||
spin_start "Скачиваю релиз и проверяю подпись"
|
||
fetch_release "$latest" "$tmp" || { spin_stop; die "Сервер не тронут."; }
|
||
spin_stop
|
||
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
|
||
# Отвечала ли подписка ДО замены: если нет (например, у встроенной подписки панели включён TLS), откат ничего не даст.
|
||
local old_ok=yes sub_fail=no
|
||
sub_ok || old_ok=no
|
||
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 [[ $old_ok == no ]]; then
|
||
warn "Подписка не отвечала и до обновления (код ${SUB_LAST_CODE:-нет ответа}). Что с сервером: kit → Проверка сервера, там же «Исправить безопасное»"
|
||
if systemctl restart kit-sub && sleep 2 && sub_ok alive; then
|
||
say "Подписка kit-sub обновлена и запущена (отвечать она начнёт после исправления: kit → Проверка сервера → Исправить безопасное)"
|
||
else
|
||
sub_fail=yes
|
||
fi
|
||
elif systemctl restart kit-sub && sleep 2 && sub_ok; then
|
||
say "Подписка kit-sub обновлена и отвечает"
|
||
else
|
||
sub_fail=yes
|
||
fi
|
||
if [[ $sub_fail == yes ]]; then
|
||
local why="код ${SUB_LAST_CODE:-нет ответа}"
|
||
why+="; журнал: $(sub_journal)"
|
||
warn "Причина: $why"
|
||
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 → Автообновление, чтобы выключить"; }
|
||
|
||
# Общий лимит трафика: проверка раз в 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] → «имя<TAB>порт<TAB>сеть» включённых подключений, чей порт никто не слушает
|
||
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)"
|
||
}
|
||
|
||
# Подключения за nginx (слушают localhost): карта имён nginx должна совпадать с сайтами в подключениях
|
||
# (serverNames у REALITY и XHTTP, fakeTlsDomain у MTProto), а 443/tcp – кто-то слушать.
|
||
route_rows() { # «имя<TAB>порт» подключений за nginx
|
||
api GET inbounds/list 2>/dev/null | jq -r '.[] | select(.listen == "127.0.0.1")
|
||
| (.streamSettings | if type == "string" then fromjson else . end) as $st | (.settings | if type == "string" then fromjson else . end) as $se
|
||
| (if .remark == "REALITY" or .remark == "XHTTP" then $st.realitySettings.serverNames[0] elif .protocol == "mtproto" then $se.fakeTlsDomain else null end) as $n
|
||
| select($n != null) | [$n, .port] | @tsv' 2>/dev/null
|
||
}
|
||
|
||
check_routes() {
|
||
local conf=/etc/nginx/kit-stream.conf rows name port bad=0 seen=" " q
|
||
rows=$(route_rows) || return 0
|
||
[[ -n $rows ]] || return 0 # режим с отдельными портами
|
||
if [[ ! -f $conf ]]; then
|
||
c_bad "" "подключения ждут nginx, а его конфига нет (kit-stream.conf): запустите установщик ещё раз: bash 3x-ui.sh"; return 0
|
||
fi
|
||
while IFS=$'\t' read -r name port; do
|
||
[[ $name =~ $SNI_RE && $port =~ ^[0-9]+$ ]] || continue
|
||
# Два подключения с одним именем в карте идут одной строкой (первого из них).
|
||
if [[ $seen == *" $name "* ]]; then q=""; else q="127.0.0.1:$port;"; fi
|
||
seen+="$name "
|
||
if ! awk -v n="$name" -v p="$q" '$1 == n && (p == "" || $2 == p) {f = 1} END {exit !f}' "$conf"; then
|
||
bad=1; c_bad route "карта nginx не знает сайт $name (порт $port): клиенты уйдут на заглушку"
|
||
fi
|
||
done <<<"$rows"
|
||
# Старый процесс nginx работает и со сломанным конфигом, но первая же перезагрузка (или reload) его уронит.
|
||
if ! nginx -t >/dev/null 2>&1; then
|
||
bad=1; c_bad route "конфиг nginx не проходит проверку (nginx -t): сейчас работает прежний, но после перезагрузки nginx не поднимется"
|
||
fi
|
||
if ! inbound_listens 443 tcp; then bad=1; c_bad svc:nginx "порт 443/tcp никто не слушает: подключения за nginx недоступны"; fi
|
||
((bad)) || c_ok "карта nginx совпадает с сайтами подключений"
|
||
}
|
||
|
||
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 → Обновить kit и подписку"; 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 → Автообновление)"
|
||
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 → Протоколы, порты и маскировка → Поток vision у REALITY)"; 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 → Проверка сервера → Исправить безопасное вернёт проверенное)"
|
||
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 → Обновить панель 3X-UI"
|
||
fi
|
||
if [[ -s $KIT_LATEST ]] && newer "$(cat "$KIT_LATEST")" "$KIT_VERSION"; then
|
||
c_info "вышла версия $(cat "$KIT_LATEST"): kit → Обновить kit и подписку"
|
||
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=$(dns_a "$sni"); 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 → Проверка сервера → Исправить безопасное включит синхронизацию)"; 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 → Протоколы, порты и маскировка → Сменить сайт маскировки"
|
||
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 → Протоколы, порты и маскировка → Hysteria2: ответ чужим запросам"
|
||
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 → Протоколы, порты и маскировка → DNS в подписках (против утечек)"
|
||
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 → Протоколы, порты и маскировка → Выключить или включить протокол (режим «всё на 443» прячет их за сайтом)"
|
||
}
|
||
|
||
run_checks() {
|
||
CHECK_BAD=0; CHECK_WARN=0; CHECK_FIX=()
|
||
check_services; check_xray; check_routes; check_grace; check_mtproto; 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 → Пользователи → Добавить)"; 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 → Протоколы, порты и маскировка → Сменить сайт маскировки, журнал: journalctl -u x-ui -n 30)"; fi
|
||
done < <(collect_links "$sid" '^(vless://.*security=reality|hysteria2://)')
|
||
rm -rf "$tmp"
|
||
c_info "Проверка идёт с самого сервера: доступность из вашей сети она не покажет. Остальные протоколы проверены только по открытому порту."
|
||
}
|
||
|
||
# После успешной kit check --deep: сервер исправен, но проверка шла с него самого. Чем отличить сеть от клиента.
|
||
# Страницу по https отдаёт: «всё на 443» – nginx (сайт-заглушка), отдельные порты – kit-sub на порту подписки
|
||
# (на корень отвечает «404 Not Found», тоже ответ сервера). Без сертификата веб-страницы нет – проверяем SSH.
|
||
deep_hint() {
|
||
local addr=${LINK_HOST:-$HOST} url="" page="страница сайта" port
|
||
if [[ ${SINGLE:-no} == yes ]]; then
|
||
url="https://$addr/"
|
||
else
|
||
port=$(jq -r 'select((.cert // "") != "") | .port // empty' /etc/kit-sub/config.json 2>/dev/null || true)
|
||
[[ $port =~ ^[0-9]{1,5}$ ]] && { url="https://$addr:$port/"; page="страница «404 Not Found» (это ответ сервера, так и задумано)"; }
|
||
fi
|
||
echo
|
||
echo "Сервер в порядке, но эта проверка идёт с самого сервера и доступность из вашей сети не показывает."
|
||
if [[ -n $url ]]; then
|
||
echo "Проверьте с устройства, где не подключается: откройте в браузере ${B}$url${N} (предупреждение о сертификате можно пропустить)."
|
||
echo " Открылась $page – сервер доступен, смотрите клиент: точное время на телефоне, Hysteria2 вместо TCP-протокола, другое приложение."
|
||
echo " Не открылась – трафик из этой сети до сервера не доходит: попробуйте другую сеть или другой адрес сервера."
|
||
else
|
||
echo "Проверьте из той сети, где не подключается: ${B}ssh root@$addr${N}."
|
||
echo " Подключилось – сервер доступен, смотрите клиент: точное время на телефоне, Hysteria2 вместо TCP-протокола, другое приложение."
|
||
echo " Не подключилось – трафик из этой сети до сервера не доходит: попробуйте другую сеть или другой адрес сервера."
|
||
fi
|
||
}
|
||
|
||
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
|
||
# Прежние имена сайта с истёкшим льготным сроком (после kit net site) – единственное, что kit check убирает сам.
|
||
(grace_cleanup) || true
|
||
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)"
|
||
[[ $deep == no ]] || deep_hint
|
||
else
|
||
echo "${R}${B}Проблем: $CHECK_BAD.${N} Безопасные исправления: ${B}kit → Проверка сервера → Исправить безопасное${N}."
|
||
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 ;;
|
||
route) fix_route ;;
|
||
mtproto) say "Чиню ссылку и секреты MTProto"; (mt_sync) || warn "Панель не приняла правку MTProto: ссылку берите из kit → Пользователи → Показать ссылки → Telegram" ;;
|
||
esac
|
||
}
|
||
|
||
# Имена в карте nginx – по подключениям (строки ищем по внутреннему порту), с проверкой и откатом.
|
||
fix_route() {
|
||
local conf=/etc/nginx/kit-stream.conf name port cbak g
|
||
[[ -f $conf ]] || { warn "Нет $conf – запустите установщик ещё раз."; return 0; }
|
||
# Строки льготных имён (прежний сайт после kit net site) ведут на тот же порт – их не переименовываем.
|
||
g=" $(grace_rows | awk -F'\t' '{printf "%s ", $1}')"
|
||
cbak=$(mktemp); cp -p "$conf" "$cbak"
|
||
grep -q '^[[:space:]]*map_hash_bucket_size' "$conf" || { awk '{print} /^stream \{/ && !d {print " map_hash_bucket_size 512;"; d=1}' "$conf" >"$conf.n" && cat "$conf.n" >"$conf"; rm -f "$conf.n"; }
|
||
while IFS=$'\t' read -r name port; do
|
||
[[ $name =~ $SNI_RE && $port =~ ^[0-9]+$ ]] || continue
|
||
awk -v n="$name" -v p="127.0.0.1:$port;" -v g="$g" '$2 == p && $1 != "default" && !index(g, " " $1 " ") {printf " %s %s\n", n, p; next} {print}' "$conf" >"$conf.n" && cat "$conf.n" >"$conf"; rm -f "$conf.n"
|
||
done < <(route_rows)
|
||
say "Сверяю карту nginx с сайтами подключений"
|
||
if nginx -t >/dev/null 2>&1 && systemctl reload nginx; then rm -f "$cbak"
|
||
else cat "$cbak" >"$conf"; rm -f "$cbak"; nginx -t >/dev/null 2>&1 && systemctl reload nginx || true; warn "nginx не принял новую карту – вернул прежнюю."; fi
|
||
}
|
||
|
||
# На минимальном 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 → Проверка сервера → Быстрая проверка${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 @<(printf 'Authorization: Bearer %s\n' "$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 → Проверка сервера → Быстрая проверка."
|
||
fi
|
||
say "Обновляю панель 3X-UI $cur → $target (настройки, пользователи и ядро Xray сохраняются)"
|
||
tmp=$(mktemp -d)
|
||
# shellcheck disable=SC2064 # путь подставляем сразу
|
||
trap "rm -rf -- '$tmp'" EXIT
|
||
|
||
# 1. Архив и скрипт меню – только с совпавшей зашитой суммой.
|
||
spin_start "Скачиваю панель 3X-UI"
|
||
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. Сервер не тронут."
|
||
spin_stop
|
||
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 → Проверка сервера → Быстрая проверка${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
|
||
# Льготные имена сайта (после kit net site) в копию не берём: на новом сервере карта nginx собирается без них.
|
||
local -a gargs=()
|
||
mapfile -t gargs < <(grace_rows | awk -F'\t' '{print $2 ":" $1}')
|
||
if ((${#gargs[@]})); then
|
||
python3 - "$tmp/etc/x-ui/x-ui.db" "${gargs[@]}" <<'PY' || warn "Не удалось убрать из копии прежние имена сайта. Лучше так: kit → Протоколы, порты и маскировка → Убрать прежние имена сайта, затем kit → Резервная копия ещё раз."
|
||
import json, sqlite3, sys
|
||
db = sqlite3.connect(sys.argv[1])
|
||
for a in sys.argv[2:]:
|
||
i, n = a.split(":", 1)
|
||
row = db.execute("SELECT stream_settings FROM inbounds WHERE id = ?", (int(i),)).fetchone()
|
||
try:
|
||
s = json.loads(row[0])
|
||
names = s["realitySettings"]["serverNames"]
|
||
except Exception:
|
||
continue
|
||
if isinstance(names, list) and n in names[1:]:
|
||
s["realitySettings"]["serverNames"] = names[:1] + [x for x in names[1:] if x != n]
|
||
db.execute("UPDATE inbounds SET stream_settings = ? WHERE id = ?", (json.dumps(s, ensure_ascii=False, indent=2), int(i)))
|
||
db.commit(); db.close()
|
||
PY
|
||
fi
|
||
# 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 вид новый-сайт [льготные имена, JSON-массив] → тело для inbounds/update
|
||
api GET inbounds/list | jq -c --argjson id "$1" --arg k "$2" --arg n "$3" --argjson g "${4:-[]}" '.[] | 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] + ($g - [$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)}'
|
||
}
|
||
|
||
# --- льготный срок для прежнего сайта (REALITY, XHTTP, REALITY-2) ---
|
||
# После kit net site у приложений ещё старая подписка с прежним именем сайта. Прежнее имя SNI_GRACE_DAYS дней остаётся
|
||
# в serverNames подключения и в карте nginx (на тот же внутренний порт), потом kit убирает его сам. MTProto – без льгот.
|
||
# Состояние – SNI_GRACE (только root). Панель берёт имя для ссылки случайно из serverNames, поэтому kit-sub получает
|
||
# карту «прежнее имя → нынешнее» (SNI_GRACE_SUB, только имена сайтов) и в подписку отдаёт нынешнее.
|
||
SNI_GRACE=/etc/kit/sni-grace.json
|
||
SNI_GRACE_SUB=/etc/kit-sub/sni-grace.json
|
||
SNI_GRACE_DAYS=7
|
||
SNI_GRACE_MAX=3
|
||
|
||
grace_rows() { # → «имя<TAB>id<TAB>до» из файла состояния, только строки, прошедшие проверку
|
||
local name id until
|
||
[[ -f $SNI_GRACE ]] || return 0
|
||
while IFS=$'\t' read -r name id until; do
|
||
[[ ${#name} -le 253 && $name =~ $SNI_RE && $id =~ ^[0-9]{1,9}$ && $until =~ ^[0-9]{1,12}$ ]] && printf '%s\t%s\t%s\n' "$name" "$id" "$until"
|
||
done < <(jq -r '.[]? | select(type == "object") | [(.name // "" | tostring), (.inbound_id // "" | tostring), (.until // "" | tostring)] | @tsv' "$SNI_GRACE" 2>/dev/null || true)
|
||
return 0
|
||
}
|
||
|
||
# Строки на входе → без истёкших, чужих подключений и исключённых имён, без дублей, не больше SNI_GRACE_MAX на подключение (новые важнее).
|
||
grace_merge() { # сейчас «id id…» «имя имя…»
|
||
awk -F'\t' -v now="$1" -v ids=" $2 " -v skip=" $3 " 'NF == 3 && $3 + 0 > now + 0 && index(ids, " " $2 " ") && !index(skip, " " $1 " ") {
|
||
k = $1 "\t" $2; if (!(k in u) || $3 + 0 > u[k] + 0) u[k] = $3 }
|
||
END {for (k in u) print k "\t" u[k]}' | sort -t$'\t' -k2,2n -k3,3nr | awk -F'\t' -v max="$SNI_GRACE_MAX" '++c[$2] <= max'
|
||
}
|
||
|
||
grace_json() { # id < строки → JSON-массив льготных имён подключения
|
||
awk -F'\t' -v id="$1" '$2 == id {print $1}' | jq -R . | jq -sc .
|
||
}
|
||
|
||
grace_gone() { # «было» «стало» «имя имя…» → имена из «было», которых нет в «стало» и в списке (для карты nginx)
|
||
awk -F'\t' -v skip=" $3 " 'NR == FNR {if (NF == 3) k[$1] = 1; next} NF == 3 && !($1 in k) && !index(skip, " " $1 " ") && !s[$1]++ {print $1}' \
|
||
<(printf '%s\n' "$2") <(printf '%s\n' "$1")
|
||
}
|
||
|
||
map_drop() { # файл имя… – убирает из карты nginx строки с этими именами
|
||
local f=$1 t
|
||
shift
|
||
(($#)) || return 0
|
||
t=$(mktemp)
|
||
awk -v d=" $* " '$2 ~ /^127\.0\.0\.1:[0-9]+;$/ && index(d, " " $1 " ") {next} {print}' "$f" >"$t" && cat "$t" >"$f"
|
||
rm -f "$t"
|
||
}
|
||
|
||
# Запись атомарно (tmp + mv): состояние – 600, карта для kit-sub (имена сайтов, не секрет) – 644. Пустой список – файлы убираются.
|
||
grace_save() { # < строки
|
||
local rows tmp map
|
||
rows=$(awk -F'\t' 'NF == 3')
|
||
if [[ -z $rows ]]; then rm -f "$SNI_GRACE" "$SNI_GRACE_SUB"; return 0; fi
|
||
install -d -m 700 "${SNI_GRACE%/*}"
|
||
tmp=$(mktemp "${SNI_GRACE%/*}/.sni-grace.XXXXXX")
|
||
jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {name: .[0], inbound_id: (.[1] | tonumber), until: (.[2] | tonumber)})' <<<"$rows" >"$tmp" \
|
||
&& chmod 600 "$tmp" && mv -f "$tmp" "$SNI_GRACE" || { rm -f "$tmp"; return 1; }
|
||
[[ -d ${SNI_GRACE_SUB%/*} ]] || return 0
|
||
map=$(api GET inbounds/list | jq -c --argjson r "$(grace_to_json <<<"$rows")" '
|
||
[.[] | {id, p: ((.streamSettings | if type == "string" then (try fromjson catch {}) else . end).realitySettings.serverNames[0]? // "")}] as $ps
|
||
| reduce $r[] as $e ({}; (($ps | map(select(.id == $e.inbound_id))[0].p) // "") as $p
|
||
| if ($p | test("\\A[A-Za-z0-9.-]{1,253}\\z")) and $p != $e.name and (has($e.name) | not) then .[$e.name] = $p else . end)') || return 1
|
||
tmp=$(mktemp "${SNI_GRACE_SUB%/*}/.sni-grace.XXXXXX")
|
||
printf '%s\n' "$map" >"$tmp" && chmod 644 "$tmp" && mv -f "$tmp" "$SNI_GRACE_SUB" || { rm -f "$tmp"; return 1; }
|
||
}
|
||
grace_to_json() { jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {name: .[0], inbound_id: (.[1] | tonumber)})'; }
|
||
|
||
# Ссылки на входе: у REALITY льготное имя сайта → нынешнее (панель выбирает имя для ссылки случайно).
|
||
grace_links() {
|
||
local o n m=" "
|
||
while IFS=$'\t' read -r o n; do
|
||
[[ $o =~ $SNI_RE && $n =~ $SNI_RE ]] && m+="$o=$n "
|
||
done < <(jq -r 'to_entries[]? | [.key, .value] | @tsv' "$SNI_GRACE_SUB" 2>/dev/null || true)
|
||
awk -v m="$m" 'BEGIN {c = split(m, a, " "); for (i = 1; i <= c; i++) {split(a[i], p, "="); g[p[1]] = p[2]}}
|
||
/^vless:\/\// && /security=reality/ && match($0, /[?&]sni=[^&#]*/) {
|
||
s = substr($0, RSTART + 5, RLENGTH - 5); if (s in g) $0 = substr($0, 1, RSTART + 4) g[s] substr($0, RSTART + RLENGTH) }
|
||
{print}'
|
||
}
|
||
|
||
# Убирает льготные имена с истёкшим сроком (all – все сразу): из подключений, затем из карты nginx (nginx -t, откат при ошибке).
|
||
grace_cleanup() { # [all]
|
||
[[ -f $SNI_GRACE ]] || return 0
|
||
local now rows keep id kind remark sni ids="" prim="" d conf=/etc/nginx/kit-stream.conf cbak gone_names
|
||
local -a pids=() pnames=() ngx=()
|
||
now=$(date +%s); [[ ${1:-} != all ]] || now=999999999999
|
||
rows=$(grace_rows)
|
||
while IFS=$'\t' read -r id kind remark sni; do
|
||
[[ $kind == reality && $id =~ ^[0-9]+$ ]] && ids+="$id "
|
||
[[ -z $sni ]] || prim+="$sni "
|
||
done < <(sni_targets)
|
||
keep=$(grace_merge "$now" "$ids" "$prim" <<<"$rows")
|
||
# Что уходит: по подключениям (для serverNames) и именами (для карты nginx).
|
||
for id in $ids; do
|
||
d=$(awk -F'\t' 'NR == FNR {k[$1 "\t" $2] = 1; next} NF == 3 && !(($1 "\t" $2) in k) {print}' <(printf '%s\n' "$keep") <(printf '%s\n' "$rows") | grace_json "$id")
|
||
[[ $d != '[]' ]] || continue
|
||
pids+=("$id"); pnames+=("$d")
|
||
done
|
||
while IFS= read -r d; do [[ -z $d ]] || ngx+=("$d"); done < <(grace_gone "$rows" "$keep" "$prim")
|
||
if ((${#pids[@]} == 0 && ${#ngx[@]} == 0)); then
|
||
[[ ${1:-} != all ]] || { grace_save <<<"$keep"; say "Прежних имён сайта нет – убирать нечего."; }
|
||
return 0
|
||
fi
|
||
grace_unpatch() { # вернуть имена в подключения, уже изменённые до сбоя
|
||
local j
|
||
for ((j = 0; j < $1; j++)); do
|
||
inbound_patch "${pids[$j]}" '.streamSettings |= ((if type == "string" then fromjson else . end) | .realitySettings.serverNames |= (. + ($d - .)))' --argjson d "${pnames[$j]}" \
|
||
|| warn "Не удалось вернуть прежнее имя в подключение ${pids[$j]}."
|
||
done
|
||
}
|
||
local i
|
||
for ((i = 0; i < ${#pids[@]}; i++)); do
|
||
if ! inbound_patch "${pids[$i]}" '.streamSettings |= ((if type == "string" then fromjson else . end) | .realitySettings.serverNames |= (.[0:1] + (.[1:] - $d)))' --argjson d "${pnames[$i]}"; then
|
||
grace_unpatch "$i"; warn "Панель не приняла правку – прежние имена сайта пока остаются."; return 1
|
||
fi
|
||
done
|
||
if [[ ${SINGLE:-no} == yes && -f $conf ]] && ((${#ngx[@]})); then
|
||
cbak=$(mktemp); cp -p "$conf" "$cbak"
|
||
map_drop "$conf" "${ngx[@]}"
|
||
if ! nginx -t >/dev/null 2>&1 || ! systemctl reload nginx; then
|
||
cat "$cbak" >"$conf"; rm -f "$cbak"; nginx -t >/dev/null 2>&1 && systemctl reload nginx || true
|
||
grace_unpatch "${#pids[@]}"
|
||
warn "nginx не принял карту без прежних имён – всё оставил как было."; return 1
|
||
fi
|
||
rm -f "$cbak"
|
||
fi
|
||
grace_save <<<"$keep" || warn "Не удалось записать $SNI_GRACE."
|
||
gone_names=$(awk -F'\t' 'NR == FNR {k[$1] = 1; next} NF == 3 && !($1 in k) && !s[$1]++ {printf "%s%s", (n++ ? ", " : ""), $1}' <(printf '%s\n' "$keep") <(printf '%s\n' "$rows"))
|
||
[[ -z $gone_names ]] || say "Убрал прежние имена сайта маскировки: $gone_names"
|
||
}
|
||
|
||
check_grace() {
|
||
local name id until
|
||
while IFS=$'\t' read -r name id until; do
|
||
c_info "прежнее имя сайта $name ещё работает до $(date -d "@$until" '+%d.%m %H:%M' 2>/dev/null || echo "$until") (после kit → Протоколы, порты и маскировка → Сменить сайт маскировки, потом уберу сам; сейчас: kit → Протоколы, порты и маскировка → Убрать прежние имена сайта)"
|
||
done < <(grace_rows | sort -t$'\t' -k1,1 -u)
|
||
}
|
||
|
||
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 → Протоколы, порты и маскировка → Сменить сайт маскировки${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=()
|
||
local id kind remark sni until
|
||
# Занятые имена и чьи они: сайты всех подключений (и свой домен REALITY), льготные имена, строки карты nginx.
|
||
# Новое имя не должно совпасть ни с одним: дубль ключа nginx не примет, а общий ключ увёл бы чужое подключение.
|
||
local -A owner=() graceid=()
|
||
while IFS=$'\t' read -r id kind remark sni; do
|
||
[[ -n $sni ]] || continue
|
||
[[ $kind == self ]] && owner[$sni]=${owner[$sni]:-"$remark (свой домен)"}
|
||
[[ $kind == self ]] && continue
|
||
owner[$sni]=${owner[$sni]:-$remark}
|
||
ids+=("$id"); kinds+=("$kind"); remarks+=("$remark"); olds+=("$sni")
|
||
done < <(sni_targets)
|
||
((${#ids[@]})) || die "Нет подключений, у которых можно сменить сайт маскировки (при своём домене маскировка уже под ваш сайт)."
|
||
while IFS=$'\t' read -r sni id until; do
|
||
((until > $(date +%s))) || continue
|
||
[[ -n ${owner[$sni]:-} ]] || { owner[$sni]="прежнее имя сайта (льготный срок)"; graceid[$sni]=$id; }
|
||
done < <(grace_rows)
|
||
if [[ ${SINGLE:-no} == yes && -f /etc/nginx/kit-stream.conf ]]; then
|
||
while read -r sni; do
|
||
[[ $sni =~ $SNI_RE ]] && owner[$sni]=${owner[$sni]:-"другое подключение (карта nginx)"}
|
||
done < <(awk '$2 ~ /^127\.0\.0\.1:[0-9]+;$/ && $1 != "default" {print $1}' /etc/nginx/kit-stream.conf)
|
||
fi
|
||
sni_taken() { # имя индекс-подключения → 0, если имя занято (своё льготное имя подключения – свободно)
|
||
local a
|
||
for a in "${news[@]}"; do [[ $a == "$1" ]] && return 0; done
|
||
[[ -n ${owner[$1]:-} ]] || return 1
|
||
[[ ${graceid[$1]:-} != "${ids[$2]}" ]]
|
||
}
|
||
if [[ -n $site ]] && sni_taken "$site" 0; then
|
||
if [[ ${owner[$site]} == *"(свой домен)" ]]; then
|
||
die "Это имя уже использует ${owner[$site]}. XHTTP и MTProto нужны отдельные сайты: оставьте автоподбор (kit → Протоколы, порты и маскировка → Сменить сайт маскировки) или укажите другой."
|
||
fi
|
||
die "Это имя уже использует ${owner[$site]}. Каждому подключению нужен отдельный сайт: оставьте автоподбор (kit → Протоколы, порты и маскировка → Сменить сайт маскировки) или укажите другой."
|
||
fi
|
||
|
||
[[ -z $site ]] || { pool+=("$site"); }
|
||
if [[ $nearby == yes ]]; then
|
||
say "Ищу сайты в подсети сервера (около 250 коротких подключений к порту 443 соседних адресов)"
|
||
spin_start "Опрашиваю соседние адреса"
|
||
while read -r a; do [[ -n $a ]] && pool+=("$a"); done < <(nearby_sites)
|
||
spin_stop
|
||
if ((${#pool[@]} == 0)); then
|
||
warn "В подсети подходящего сайта не нашёл. Известный сайт на чужом IP подходит хуже остальных."
|
||
echo " Лучший выход – свой домен (установка с --domain) или другой сайт: kit → Протоколы, порты и маскировка → Сменить сайт маскировки"
|
||
if [[ -t 0 ]]; then
|
||
ask_tty " Взять запасной сайт из списка? [y/N] "
|
||
[[ $REPLY =~ ^[yYдД]$ ]] || { echo "Отменено."; return 0; }
|
||
fi
|
||
fi
|
||
fi
|
||
pool+=("${SNI_POOL[@]}")
|
||
|
||
local c i
|
||
for i in "${!ids[@]}"; do
|
||
for c in "${pool[@]}"; do
|
||
! sni_taken "$c" "$i" || 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 → Протоколы, порты и маскировка → Сменить сайт маскировки"
|
||
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 → Протоколы, порты и маскировка → Сменить сайт маскировки"
|
||
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
|
||
|
||
# Заодно убираем прежние имена, у которых закончился льготный срок.
|
||
(grace_cleanup) || true
|
||
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"
|
||
|
||
# Льготный срок: прежнее имя REALITY и XHTTP сервер ещё SNI_GRACE_DAYS дней принимает рядом с новым.
|
||
local now gb ga rids="" gadd="" j
|
||
now=$(date +%s)
|
||
gb=$(grace_rows)
|
||
for i in "${!ids[@]}"; do
|
||
[[ ${kinds[$i]} == reality ]] || continue
|
||
rids+="${ids[$i]} "
|
||
[[ ${olds[$i]} =~ $SNI_RE ]] && gadd+="${olds[$i]}"$'\t'"${ids[$i]}"$'\t'"$((now + SNI_GRACE_DAYS * 86400))"$'\n'
|
||
done
|
||
ga=$(printf '%s\n%s' "$gb" "$gadd" | grace_merge "$now" "$rids" "${news[*]}")
|
||
|
||
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]}" "$(grace_json "${ids[$j]}" <<<"$gb")")" >/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]}" "$(grace_json "${ids[$i]}" <<<"$ga")")
|
||
[[ -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
|
||
# MTProto: секреты клиентов – под новый сайт, иначе Telegram придёт со старым именем и попадёт на заглушку.
|
||
local mt_changed=no MT_QUIET=yes
|
||
for i in "${!ids[@]}"; do [[ ${kinds[$i]} == mtproto ]] && mt_changed=yes; done
|
||
if [[ $mt_changed == yes ]] && ! (mt_rekey); then
|
||
sni_revert; (mt_rekey) >/dev/null 2>&1 || true
|
||
die "Панель не приняла новые секреты MTProto. Всё возвращено как было."
|
||
fi
|
||
|
||
# Режим «всё на 443»: nginx различает подключения по имени сайта, обновляем его таблицу.
|
||
local conf=/etc/nginx/kit-stream.conf
|
||
if [[ ${SINGLE:-no} == yes && -f $conf ]]; then
|
||
local cbak tmp dup keep_old
|
||
cbak=$(mktemp); cp -p "$conf" "$cbak"; tmp=$(mktemp)
|
||
cp "$conf" "$tmp"
|
||
# Конфиги, собранные до 1.2.1, без этой директивы: длинное имя сайта роняет nginx.
|
||
if ! grep -q '^[[:space:]]*map_hash_bucket_size' "$tmp"; then
|
||
awk '{print} /^stream \{/ && !d {print " map_hash_bucket_size 512;"; d=1}' "$tmp" >"$tmp.n" && mv "$tmp.n" "$tmp"
|
||
fi
|
||
for i in "${!ids[@]}"; do
|
||
# Новое имя уже есть в карте (осталось от прошлых правок или льготное)? Убираем его строку: дубль ключа nginx не примет.
|
||
# REALITY и XHTTP: строка прежнего имени остаётся рядом с новой (льготный срок, тот же порт); MTProto – без неё.
|
||
# Прежнее имя у двух подключений сразу: его строку уже обработали, вторую не трогаем.
|
||
dup=no; for ((j = 0; j < i; j++)); do [[ ${olds[$j]} == "${olds[$i]}" ]] && dup=yes; done
|
||
[[ $dup == no ]] || continue
|
||
keep_old=""; [[ ${kinds[$i]:-} == reality ]] && keep_old=1
|
||
awk -v o="${olds[$i]}" -v n="${news[$i]}" -v k="$keep_old" 'NR == FNR {if ($1 == o && $2 ~ /^127\.0\.0\.1:[0-9]+;$/) h = 1; next}
|
||
h && $1 == n && $2 ~ /^127\.0\.0\.1:[0-9]+;$/ {next}
|
||
$1 == o && $2 ~ /^127\.0\.0\.1:[0-9]+;$/ {printf " %s %s\n", n, $2; if (k) print; next} {print}' "$tmp" "$tmp" >"$tmp.n" && mv "$tmp.n" "$tmp"
|
||
done
|
||
# Льготные имена, которые больше не нужны (срок, лимит, стали новыми), – из карты.
|
||
local -a gdrop=()
|
||
mapfile -t gdrop < <(grace_gone "$gb" "$ga" "${news[*]}")
|
||
map_drop "$tmp" "${gdrop[@]}"
|
||
cat "$tmp" >"$conf"; rm -f "$tmp"
|
||
local why=""
|
||
if ! why=$(nginx -t 2>&1) || ! why=$(systemctl reload nginx 2>&1); then
|
||
cat "$cbak" >"$conf"; rm -f "$cbak"
|
||
sni_revert; (mt_rekey) >/dev/null 2>&1 || true; nginx -t >/dev/null 2>&1 && systemctl reload nginx || true
|
||
# Причина – последние две строки ответа nginx (только печатные знаки): видно, дубль имени это или что-то другое.
|
||
warn "Ответ nginx: $(tail -n 2 <<<"$why" | tr -cd '[:print:]\n' | tr '\n' ' ' | cut -c1-300)"
|
||
die "nginx не принял новые имена. Всё возвращено как было."
|
||
fi
|
||
rm -f "$cbak"
|
||
fi
|
||
|
||
(grace_save <<<"$ga") || warn "Не удалось записать $SNI_GRACE: прежние имена уберите сами: kit → Протоколы, порты и маскировка → Убрать прежние имена сайта"
|
||
|
||
sleep 2
|
||
systemctl is-active -q x-ui || warn "Панель не отвечает – проверьте: systemctl status x-ui"
|
||
say "Готово. Сайт маскировки сменён."
|
||
local -a tp=()
|
||
if [[ -n $rids ]]; then
|
||
tp+=("Обновите подписку в приложениях (Happ, Karing, v2rayN, Hiddify, Nekobox): в них пока старое имя сайта. Старое имя работает $SNI_GRACE_DAYS дней, потом уберётся само. Убрать сразу: kit → Протоколы, порты и маскировка → Убрать прежние имена сайта.")
|
||
else
|
||
tp+=("Подписка у клиентов обновится сама при следующем обновлении в приложении.")
|
||
fi
|
||
tp+=("Ссылки REALITY, XHTTP и MTProto, сохранённые вручную, замените на новые: kit → Пользователи → Показать ссылки.")
|
||
if [[ ${mt_changed:-no} == yes && ${SINGLE:-no} == yes ]]; then
|
||
tp+=("Telegram: в ссылке MTProto зашит сайт, поэтому прежние ссылки tg:// перестали работать у всех. Разошлите новые: kit → Пользователи → Показать ссылки → Telegram.")
|
||
fi
|
||
[[ $nearby == no ]] || tp+=("Это чужие сайты из вашей подсети: откройте их в браузере и убедитесь, что на них нет ничего неприятного. Не подошли – подберите другой: kit → Протоколы, порты и маскировка → Сменить сайт маскировки.")
|
||
tips "Что дальше" "${tp[@]}"
|
||
}
|
||
|
||
# ---------- MTProto (прокси для Telegram) ----------
|
||
|
||
# Секрет FakeTLS: «ee» + 16 байт ключа + имя сайта в hex. Telegram шлёт это имя в SNI, nginx по нему выбирает
|
||
# маршрут, а mtg сверяет его с секретом (проверено: чужое имя при верном ключе mtg не принимает). Поэтому имя
|
||
# в секрете каждого клиента должно совпадать с сайтом подключения, иначе ссылка уходит на заглушку.
|
||
mt_row() { jq -c '[.[] | select(.protocol == "mtproto")][0] // empty' <<<"$1"; } # список подключений → MTProto
|
||
mt_hex() { printf %s "$1" | od -An -v -tx1 | tr -d ' \n'; }
|
||
mt_stale() { # строка MTProto → «email<TAB>секрет» клиентов, у которых в секрете другой сайт
|
||
local dom want email sec
|
||
dom=$(jq -r '(.settings | if type == "string" then fromjson else . end).fakeTlsDomain // empty' <<<"$1")
|
||
[[ $dom =~ $SNI_RE ]] || return 0
|
||
want=$(mt_hex "$dom")
|
||
while IFS=$'\t' read -r email sec; do
|
||
[[ $email =~ ^[A-Za-z0-9_.@-]{1,64}$ && $email =~ [^.] && $sec =~ ^ee[0-9a-f]{32}([0-9a-f]{2})+$ && ${sec:34} != "$want" ]] && printf '%s\t%s\n' "$email" "$sec"
|
||
done < <(jq -r '(.settings | if type == "string" then fromjson else . end).clients[]? | [.email // "", .secret // ""] | @tsv' <<<"$1")
|
||
return 0
|
||
}
|
||
mt_host_ok() { # группы hosts подключения (JSON) → 0, если ссылка ведёт на адрес сервера и порт 443
|
||
jq -e --arg a "${LINK_HOST:-$HOST}:443" 'type == "array" and any(.[]; any(.hosts[]?; . == $a))' <<<"$1" >/dev/null 2>&1
|
||
}
|
||
|
||
# Секреты клиентов – под нынешний сайт подключения (ключ прежний). Только «всё на 443»: с отдельным портом
|
||
# nginx имя не выбирает, и прежние ссылки продолжают работать.
|
||
mt_rekey() {
|
||
local row email sec rec n=0 dom
|
||
[[ ${SINGLE:-no} == yes ]] || return 0
|
||
row=$(mt_row "$(api GET inbounds/list)"); [[ -n $row && $(jq -r '.listen' <<<"$row") == 127.0.0.1 ]] || return 0
|
||
dom=$(jq -r '(.settings | if type == "string" then fromjson else . end).fakeTlsDomain' <<<"$row")
|
||
while IFS=$'\t' read -r email sec; do
|
||
[[ -n $email ]] || continue
|
||
rec=$(client "$email"); [[ -n $rec ]] || continue
|
||
api POST "clients/update/$email" "$(jq -c --arg s "${sec:0:34}$(mt_hex "$dom")" '{email, subId, totalGB, expiryTime, limitIp, enable, comment, secret: $s}' <<<"$rec")" >/dev/null
|
||
n=$((n + 1))
|
||
done < <(mt_stale "$row")
|
||
((n == 0)) || say "MTProto: у $n польз. секрет выпущен под сайт $dom (ключ прежний)"
|
||
((n == 0)) || [[ ${MT_QUIET:-no} == yes ]] || echo " Прежние ссылки tg:// больше не подойдут – новые: kit → Пользователи → Показать ссылки → Telegram"
|
||
}
|
||
|
||
# Ссылка MTProto в панели (всё на 443). Панель 3.9 externalProxy у mtproto не хранит, адрес и порт ссылки берёт
|
||
# из таблицы hosts: без строки там ссылка ведёт на localhost:10445. Добавляем адрес сервера с портом 443 через API.
|
||
# restore – из копии: группы hosts MTProto с чужим адресом или портом удаляем, адрес из архива не берём.
|
||
mt_sync() { # [restore]
|
||
local mode=${1:-} row id addr groups g
|
||
[[ ${SINGLE:-no} == yes ]] || return 0
|
||
row=$(mt_row "$(api GET inbounds/list)"); [[ -n $row && $(jq -r '.listen' <<<"$row") == 127.0.0.1 ]] || return 0
|
||
id=$(jq -r '.id' <<<"$row"); addr=${LINK_HOST:-$HOST}
|
||
[[ $id =~ ^[0-9]+$ && $addr =~ ^[A-Za-z0-9.-]{1,253}$ ]] || return 1
|
||
groups=$(api GET "hosts/byInbound/$id")
|
||
if [[ $mode == restore ]]; then
|
||
for g in $(jq -r --arg a "$addr:443" --arg h "${HOST:-}:443" '.[]? | select(.port != 443 or any(.hosts[]?; . != $a and . != $h)) | .groupId' <<<"$groups"); do
|
||
[[ $g =~ ^[A-Za-z0-9_-]{1,64}$ ]] && api POST "hosts/del/$g" >/dev/null
|
||
done
|
||
groups=$(api GET "hosts/byInbound/$id")
|
||
fi
|
||
if ! mt_host_ok "$groups"; then
|
||
api POST hosts/add "$(jq -nc --argjson i "$id" --arg h "$addr:443" '{inboundIds: [$i], hosts: [$h], remark: "kit", security: "same", port: 443}')" >/dev/null
|
||
say "MTProto: ссылка в панели ведёт на $addr:443"
|
||
fi
|
||
mt_rekey
|
||
}
|
||
|
||
# Нужна ли правка MTProto (для needs_migration): одно-два обращения к API панели; ошибка – «не нужна».
|
||
mt_needs() {
|
||
[[ ${SINGLE:-no} == yes ]] || return 1
|
||
(
|
||
row=$(mt_row "$(api GET inbounds/list)"); [[ -n $row && $(jq -r '.listen' <<<"$row") == 127.0.0.1 ]] || exit 1
|
||
[[ -z $(mt_stale "$row") ]] || exit 0
|
||
groups=$(api GET "hosts/byInbound/$(jq -r '.id' <<<"$row")")
|
||
! mt_host_ok "$groups"
|
||
) 2>/dev/null
|
||
}
|
||
|
||
telegram_reachable() {
|
||
local ip
|
||
for ip in 149.154.167.51 149.154.175.50 91.108.56.130; do
|
||
timeout 5 bash -c "</dev/tcp/$ip/443" 2>/dev/null && return 0
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Рукопожатие FakeTLS, как у Telegram: ClientHello с подписью ключом секрета и сайтом из секрета в SNI.
|
||
# mtg подписывает ServerHello тем же ключом, сайт-заглушка – нет. Секрет – только через stdin (аргументы видно в ps).
|
||
# Код: 0 – ответил mtg, 2 – ответил кто-то другой, 3 – нет ответа, 4 – секрет не FakeTLS.
|
||
mt_shake() { # адрес порт <<< секрет
|
||
local script
|
||
IFS= read -r -d '' script <<'PY' || true
|
||
import hashlib, hmac, os, socket, struct, sys, time
|
||
host, port = sys.argv[1], int(sys.argv[2])
|
||
try:
|
||
raw = bytes.fromhex(sys.stdin.readline().strip())
|
||
assert raw[0] == 0xEE and len(raw) > 17
|
||
key, domain = raw[1:17], raw[17:].decode("ascii")
|
||
except Exception:
|
||
sys.exit(4)
|
||
def ext(t, d): return struct.pack(">HH", t, len(d)) + d
|
||
d = domain.encode()
|
||
exts = (ext(0, struct.pack(">HBH", len(d) + 3, 0, len(d)) + d) + ext(23, b"") + ext(65281, b"\x00")
|
||
+ ext(10, bytes.fromhex("0006001d00170018")) + ext(11, b"\x01\x00") + ext(35, b"")
|
||
+ ext(16, b"\x00\x0c\x02h2\x08http/1.1") + ext(13, bytes.fromhex("0012040308040401050308050501080606010201"))
|
||
+ ext(51, struct.pack(">HHH", 36, 0x1d, 32) + os.urandom(32)) + ext(45, b"\x01\x01") + ext(43, b"\x04\x03\x04\x03\x03"))
|
||
ciphers = bytes.fromhex("130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f0035")
|
||
body = b"\x03\x03" + b"\x00" * 32 + b"\x20" + os.urandom(32) + struct.pack(">H", len(ciphers)) + ciphers + b"\x01\x00"
|
||
pad = 517 - (5 + 4 + len(body) + 2 + len(exts)) - 4
|
||
if pad >= 0:
|
||
exts += ext(21, b"\x00" * pad)
|
||
body += struct.pack(">H", len(exts)) + exts
|
||
hs = b"\x01" + struct.pack(">I", len(body))[1:] + body
|
||
hello = bytearray(b"\x16\x03\x01" + struct.pack(">H", len(hs)) + hs)
|
||
dig = bytearray(hmac.new(key, bytes(hello), hashlib.sha256).digest())
|
||
for i, b in enumerate(struct.pack("<I", int(time.time()))):
|
||
dig[28 + i] ^= b
|
||
hello[11:43] = dig
|
||
def recv(s, n):
|
||
b = b""
|
||
while len(b) < n:
|
||
c = s.recv(n - len(b))
|
||
if not c:
|
||
raise EOFError
|
||
b += c
|
||
return b
|
||
recs = []
|
||
try:
|
||
s = socket.create_connection((host, port), timeout=8)
|
||
s.sendall(bytes(hello))
|
||
for _ in range(3):
|
||
h = recv(s, 5)
|
||
recs.append((h[0], recv(s, struct.unpack(">H", h[3:5])[0])))
|
||
if recs[0][0] != 0x16:
|
||
break
|
||
s.close()
|
||
except (OSError, EOFError):
|
||
sys.exit(2 if recs else 3)
|
||
if len(recs) < 3 or recs[0][0] != 0x16 or len(recs[0][1]) < 38:
|
||
sys.exit(2)
|
||
resp = bytearray(b"".join(bytes([t]) + b"\x03\x03" + struct.pack(">H", len(p)) + p for t, p in recs))
|
||
got = bytes(resp[11:43]); resp[11:43] = b"\x00" * 32
|
||
sys.exit(0 if hmac.compare_digest(got, hmac.new(key, bytes(dig) + bytes(resp), hashlib.sha256).digest()) else 2)
|
||
PY
|
||
python3 -c "$script" "$1" "$2"
|
||
}
|
||
|
||
# MTProto: процесс mtg, ссылка в панели и сайт в секретах (всё на 443), доступ к Telegram; --deep – рукопожатие.
|
||
check_mtproto() {
|
||
local list row pid groups stale port dom sec rc
|
||
systemctl is-active -q x-ui || return 0 # про службу сказано выше
|
||
list=$(api GET inbounds/list 2>/dev/null) || return 0
|
||
row=$(mt_row "$list"); [[ -n $row ]] || return 0
|
||
[[ $(jq -r '.enable' <<<"$row") == true ]] || { c_info "MTProto выключен: kit → Протоколы, порты и маскировка → Выключить или включить протокол"; return 0; }
|
||
pid=$(systemctl show -p MainPID --value x-ui 2>/dev/null || true)
|
||
if [[ $pid =~ ^[1-9][0-9]*$ ]] && pgrep -P "$pid" '^mtg-linux-' >/dev/null 2>&1; then c_ok "MTProto: процесс mtg запущен"
|
||
else c_bad svc:x-ui "MTProto: процесс mtg не запущен (панель обычно поднимает его сама за полминуты; журнал: tail -n 30 /var/log/x-ui/3xui.log)"; fi
|
||
port=$(jq -r '.port' <<<"$row")
|
||
if [[ ${SINGLE:-no} == yes && $(jq -r '.listen' <<<"$row") == 127.0.0.1 ]]; then
|
||
port=443
|
||
if groups=$( (api GET "hosts/byInbound/$(jq -r '.id' <<<"$row")") 2>/dev/null); then
|
||
if mt_host_ok "$groups"; then c_ok "MTProto: ссылка в панели ведёт на порт 443"
|
||
else c_bad mtproto "MTProto: ссылка в панели ведёт не на ${LINK_HOST:-$HOST}:443, а на внутренний порт ${MTPROTO_INNER:-10445} или чужой адрес (kit → Проверка сервера → Исправить безопасное добавит верный)"; fi
|
||
fi
|
||
stale=$(mt_stale "$row" | grep -c . || true)
|
||
if ((stale > 0)); then c_bad mtproto "MTProto: у $stale польз. в секрете прежний сайт маскировки – nginx его не знает, ссылки tg:// не работают (kit → Проверка сервера → Исправить безопасное выпустит секреты под нынешний сайт)"; fi
|
||
fi
|
||
telegram_reachable || c_warn "MTProto: с сервера не видно серверов Telegram – прокси для Telegram работать не будет (уточните у хостера)"
|
||
[[ $CHECK_DEEP == yes && $port =~ ^[0-9]+$ ]] || return 0
|
||
command -v python3 >/dev/null || return 0
|
||
# По одному включённому клиенту на каждый сайт в секретах.
|
||
while IFS=$'\t' read -r dom sec; do
|
||
[[ $dom =~ ^[0-9a-f]+$ ]] || continue
|
||
dom=$(printf '%s' "$dom" | sed 's/../\\x&/g'); dom=$(printf '%b' "$dom")
|
||
[[ $dom =~ $SNI_RE ]] || continue
|
||
rc=0; mt_shake 127.0.0.1 "$port" <<<"$sec" || rc=$?
|
||
case $rc in
|
||
0) c_ok "MTProto (сайт $dom): mtg ответил на рукопожатие FakeTLS, как Telegram-клиенту" ;;
|
||
2) c_bad "" "MTProto (сайт $dom): на рукопожатие ответил не mtg, а сайт-заглушка – ссылки с этим сайтом не работают" ;;
|
||
3) c_bad "" "MTProto (сайт $dom): порт $port не ответил на рукопожатие" ;;
|
||
*) c_warn "MTProto (сайт $dom): рукопожатие проверить не удалось" ;;
|
||
esac
|
||
done < <(jq -r '[(.settings | if type == "string" then fromjson else . end).clients[]? | select(.enable != false) | (.secret // "")
|
||
| select(test("^ee[0-9a-f]{34,}$")) | {d: .[34:], s: .}] | unique_by(.d) | .[] | [.d, .s] | @tsv' <<<"$row")
|
||
}
|
||
|
||
# ---------- порты подключений: 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 → Протоколы, порты и маскировка → Сменить порт протокола${N}"
|
||
}
|
||
|
||
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 → Протоколы, порты и маскировка → Показать протоколы и порты"
|
||
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 → Проверка сервера → Глубокая."; fi
|
||
echo "Подписка обновит ссылки сама. Сохранённые вручную ссылки на $name нужно заменить: ${B}kit → Пользователи → Показать ссылки${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 → Протоколы, порты и маскировка → Показать протоколы и порты, смена: → Сменить порт протокола"
|
||
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 → Проверка сервера → Глубокая${N}. Ссылки: ${B}kit → Пользователи → Показать ссылки${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='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Welcome</title></head><body style="font-family:sans-serif;text-align:center;margin-top:20vh"><h1>Welcome</h1><p>This site is being updated. Please check back soon.</p></body></html>'
|
||
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 – настройка панели)$(mh ". Выключить: kit → Протоколы, порты и маскировка → DNS в подписках (против утечек)")"
|
||
else echo "DNS в подписке Xray JSON: обычный UDP 8.8.8.8 (по умолчанию панели)$(mh ". Включить DoH: kit → Протоколы, порты и маскировка → DNS в подписках (против утечек)")"; 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 → Проверка сервера → Быстрая проверка."
|
||
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/<base64> с 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 → Протоколы, порты и маскировка → Раздельная маршрутизация)."; 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 → Проверка сервера → Быстрая проверка."
|
||
}
|
||
|
||
# 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$(mh ". Включить, выключить и проверить: kit → Протоколы, порты и маскировка → Раздельная маршрутизация")"
|
||
echo "Mihomo-приложения берут список из подписки сами; Happ и JSON-подписка Xray – из панели (после правки файла примените список: kit → Протоколы, порты и маскировка → Раздельная маршрутизация)."
|
||
else
|
||
echo "Раздельная маршрутизация выключена: через VPN идёт весь трафик$(mh ". Включить: kit → Протоколы, порты и маскировка → Раздельная маршрутизация")"
|
||
fi ;;
|
||
on)
|
||
[[ -f /usr/local/lib/kit-sub/kit_sub.py ]] || die "Нужна подписка kit-sub (обновите kit: kit → Обновить kit и подписку)."
|
||
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 "Список правится в файле $SPLIT_FILE; проверить его: kit → Протоколы, порты и маскировка → Раздельная маршрутизация."
|
||
echo "Приложения на Mihomo подхватят при обновлении подписки; после правки списка для Happ и Xray: kit → Протоколы, порты и маскировка → Раздельная маршрутизация." ;;
|
||
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 → Протоколы, порты и маскировка → Раздельная маршрутизация)"
|
||
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 → Протоколы, порты и маскировка → Раздельная маршрутизация)"
|
||
python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules >/dev/null 2>&1 || die "Список не применится: сначала kit → Протоколы, порты и маскировка → Раздельная маршрутизация."
|
||
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$(mh " (вернуть на IP: kit → Протоколы, порты и маскировка → Панель и подписка: по IP или по домену)")"
|
||
else echo "Панель и подписка открываются по IP ${HOST:-?}; домен $domain – сайт для маскировки$(mh " (открывать и по домену: kit → Протоколы, порты и маскировка → Панель и подписка: по IP или по домену)")"; 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
|
||
dns_a "$domain" | 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 → Пользователи → Показать ссылки)"
|
||
echo "По IP они тоже работают$(mh ". Вернуть как было: kit → Протоколы, порты и маскировка → Панель и подписка: по 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)" "$(mh "включить: kit → Протоколы, порты и маскировка → Второй REALITY на высоком порту")"
|
||
echo
|
||
echo "Отпечаток клиента: ${fpl:-?}$(mh " (сменить: kit → Протоколы, порты и маскировка → Сменить отпечаток клиента)")"
|
||
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 отвечает на посторонний запрос страницей сайта$(mh " (выключить: kit → Протоколы, порты и маскировка → Hysteria2: ответ чужим запросам)")"
|
||
else echo "Hysteria2 без маскировки под сайт$(mh " (включить: kit → Протоколы, порты и маскировка → Hysteria2: ответ чужим запросам)")"; 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 → Протоколы, порты и маскировка → Показать протоколы и порты"
|
||
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 → Протоколы, порты и маскировка → Выключить или включить протокол"; 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 закрыт$(mh ". Вернуть: kit → Протоколы, порты и маскировка → Выключить или включить протокол")"; else say "$name выключен. Вернуть: kit → Протоколы, порты и маскировка → Выключить или включить протокол"; 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 → Пользователи → Показать ссылки)."
|
||
[[ ${KIT_IN_MENU:-} == 1 ]] || echo "Вернуть прежний: kit → Протоколы, порты и маскировка → Сменить отпечаток клиента."
|
||
}
|
||
|
||
cmd_net() {
|
||
local sub=${1:-}
|
||
[[ -z $sub ]] || shift
|
||
case ${sub,,} in
|
||
"") net_show ;;
|
||
site)
|
||
if [[ ${1:-} == cleanup ]]; then
|
||
[[ -f $SNI_GRACE ]] || { say "Прежних имён сайта нет – убирать нечего."; return 0; }
|
||
grace_cleanup all; return $?
|
||
fi
|
||
[[ $# -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 [cleanup] | 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/tty || exit 0; }
|
||
ask_num() { ask_tty "$1"; REPLY=${REPLY//[[:space:]]/}; }
|
||
pause() { read -r -p "${D}Enter – в меню${N} " _ </dev/tty || exit 0; }
|
||
# Подсказка «как сделать через меню» нужна в командной строке, но лишняя, когда человек уже в меню.
|
||
mh() { [[ ${KIT_IN_MENU:-} == 1 ]] || printf '%s' "$*"; }
|
||
run_action() { ( export KIT_IN_MENU=1; "$@" ) || true; }
|
||
|
||
menu_status() {
|
||
local sni users st auto
|
||
sni=$(sni_targets 2>/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]}"
|
||
}
|
||
|
||
# Пункты меню «Протоколы, порты и маскировка»: вопросы вместо аргументов команд.
|
||
n_split() {
|
||
net_split
|
||
echo
|
||
ask_num "1 – включить, 2 – выключить, 3 – проверить список, 4 – применить правки списка, Enter – назад: "
|
||
case $REPLY in 1) net_split on ;; 2) net_split off ;; 3) net_split check ;; 4) net_split apply ;; esac
|
||
}
|
||
n_dns() {
|
||
net_dns
|
||
echo
|
||
ask_num "1 – включить, 2 – выключить, Enter – оставить как есть: "
|
||
case $REPLY in 1) net_dns on ;; 2) net_dns off ;; esac
|
||
}
|
||
n_masq() {
|
||
echo "Hysteria2 может отвечать на чужие запросы страницей обычного сайта, а не ошибкой."
|
||
ask_num "1 – включить, 2 – выключить, Enter – оставить как есть: "
|
||
case $REPLY in 1) hy_masq on ;; 2) hy_masq off ;; esac
|
||
}
|
||
n_cleanup() {
|
||
[[ -f $SNI_GRACE ]] || { say "Прежних имён сайта нет – убирать нечего."; return 0; }
|
||
grace_cleanup all
|
||
}
|
||
|
||
n_vision() {
|
||
echo "Поток xtls-rprx-vision ускоряет REALITY и нужен большинству клиентов."
|
||
ask_num "1 – включить у всех пользователей, 2 – выключить, Enter – оставить: "
|
||
case $REPLY in 1) cmd_vision --all ;; 2) cmd_vision --all off ;; esac
|
||
}
|
||
|
||
menu_net() {
|
||
local c
|
||
while :; do
|
||
echo
|
||
box "Протоколы, порты и маскировка" "1. Показать протоколы и порты" "2. Сменить сайт маскировки" "3. Сменить порт протокола" "4. Выключить или включить протокол" "5. Сменить отпечаток клиента" "6. Второй REALITY на высоком порту" "7. Панель и подписка: по IP или по домену" "8. Раздельная маршрутизация" "9. DNS в подписках (против утечек)" "10. Hysteria2: ответ чужим запросам" "11. Убрать прежние имена сайта" "12. Поток vision у REALITY" "" "0. Назад"
|
||
ask_num "Выбор [0-12]: "; 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 ;;
|
||
8) run_action n_split; pause ;;
|
||
9) run_action n_dns; pause ;;
|
||
10) run_action n_masq; pause ;;
|
||
11) run_action n_cleanup; pause ;;
|
||
12) run_action n_vision; 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. Панель 3X-UI (меню x-ui)" "10. Удалить KIT с сервера" "0. Выход"
|
||
echo
|
||
menu_status
|
||
update_hint
|
||
echo
|
||
ask_num "Выбор [0-10]: "; 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) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;
|
||
10) run_action cmd_uninstall; pause ;;
|
||
0 | "") return 0 ;;
|
||
*) echo "Нет такого пункта." ;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
# ---------- полное удаление ----------
|
||
|
||
# Порты, которые KIT открывал в ufw: подключения и панель из базы панели, подписка из конфига.
|
||
uninstall_ports() {
|
||
python3 - <<'PY' 2>/dev/null || true
|
||
import json, sqlite3
|
||
out = set()
|
||
try:
|
||
db = sqlite3.connect("file:/etc/x-ui/x-ui.db?mode=ro", uri=True)
|
||
out.update(str(r[0]) for r in db.execute("SELECT port FROM inbounds"))
|
||
out.update(r[0] for r in db.execute("SELECT value FROM settings WHERE key IN ('webPort', 'subPort')"))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
out.add(str(json.load(open("/etc/kit-sub/config.json")).get("port", "")))
|
||
except Exception:
|
||
pass
|
||
print("\n".join(sorted(p for p in out if p.isdigit())))
|
||
PY
|
||
}
|
||
|
||
cmd_uninstall() { # [--yes] [--no-backup] [--dry-run]
|
||
local yes=no backup=ask dry=no a
|
||
for a in "$@"; do
|
||
case $a in
|
||
--yes | -y) yes=yes ;;
|
||
--no-backup) backup=no ;;
|
||
--dry-run) dry=yes ;;
|
||
*) die "kit uninstall [--yes] [--no-backup] [--dry-run]" ;;
|
||
esac
|
||
done
|
||
local -a units=(x-ui kit-sub kit-limit.timer kit-limit.service kit-update.timer kit-update.service)
|
||
local -a paths=(/usr/local/x-ui /etc/x-ui /var/log/x-ui /usr/local/lib/kit-sub /etc/kit-sub /etc/kit /var/www/kit
|
||
/etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf /etc/cron.d/kit-xui-menu /etc/cron.d/kit-sub-cert /etc/cron.d/kit-nginx-reload
|
||
/root/cert/ip /root/cert/custom /root/cert/self /root/cert/domain /root/3x-ui.txt /usr/bin/x-ui
|
||
/etc/fail2ban/jail.d/3x-ipl.conf /etc/fail2ban/jail.d/3x-ipl-backend.conf /etc/fail2ban/filter.d/3x-ipl.conf /etc/fail2ban/action.d/3x-ipl.conf
|
||
/var/log/3x-ui-install.log /var/log/kit-install-check.log /var/log/kit-domain-cert.log /var/log/kit-update.log)
|
||
local -a ports=()
|
||
mapfile -t ports < <(uninstall_ports)
|
||
|
||
echo
|
||
echo "${B}Полное удаление 3X-UI_autoVPS${N}"
|
||
echo "Будет остановлено и удалено: панель 3X-UI с базой (все пользователи и ключи), подписка, таймеры, настройки nginx от KIT,"
|
||
echo "сертификаты в /root/cert, логи установки, команда kit. Сделать это обратно нельзя."
|
||
echo "Останутся: пакеты (nginx, fail2ban, jq и др.), ваш SSH и правила ufw для SSH."
|
||
[[ $dry == no ]] || echo "${Y}Пробный прогон: ничего не удаляется.${N}"
|
||
echo
|
||
if [[ $dry == yes ]]; then
|
||
printf ' сервисы: %s\n' "${units[*]}"
|
||
printf ' каталоги: %s\n' "${paths[*]}"
|
||
printf ' ufw: %s\n' "${ports[*]:-нет}"
|
||
return 0
|
||
fi
|
||
|
||
if [[ $yes == no ]]; then
|
||
[[ -t 0 && -t 1 ]] || die "Без терминала нужен флаг --yes."
|
||
ask_tty "Для подтверждения введите слово «удалить»: " || return 1
|
||
a=${REPLY//[[:space:]]/}; a=${a,,}
|
||
[[ $a == "удалить" || $a == "delete" ]] || { echo "Отменено, ничего не тронуто."; return 0; }
|
||
if [[ $backup == ask ]]; then
|
||
ask_tty "Сделать резервную копию перед удалением? Она останется в /root. [Y/n] " || REPLY=""
|
||
[[ ${REPLY,,} == n* || ${REPLY,,} == н* ]] && backup=no || backup=yes
|
||
fi
|
||
fi
|
||
if [[ $backup != no ]]; then
|
||
(cmd_backup) || die "Копию сделать не удалось, поэтому ничего не удалил. Повторите или запустите с --no-backup."
|
||
fi
|
||
|
||
local u p sshp
|
||
say "Останавливаю сервисы"
|
||
for u in "${units[@]}"; do systemctl disable --now "$u" >/dev/null 2>&1 || true; done
|
||
pkill -f '/usr/local/x-ui/' >/dev/null 2>&1 || true
|
||
rm -f /etc/systemd/system/x-ui.service /etc/systemd/system/kit-*.service /etc/systemd/system/kit-*.timer
|
||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||
|
||
say "Закрываю порты в ufw"
|
||
sshp=$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' | sort -u)
|
||
if command -v ufw >/dev/null 2>&1; then
|
||
for p in "${ports[@]}"; do
|
||
[[ $p == 22 || " $sshp " == *" $p "* ]] && continue
|
||
ufw delete allow "$p" >/dev/null 2>&1 || true
|
||
ufw delete allow "$p/tcp" >/dev/null 2>&1 || true
|
||
ufw delete allow "$p/udp" >/dev/null 2>&1 || true
|
||
done
|
||
fi
|
||
|
||
say "Убираю nginx от KIT"
|
||
sed -i '/kit-stream\.conf/d' /etc/nginx/nginx.conf 2>/dev/null || true
|
||
rm -f /etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf
|
||
if command -v nginx >/dev/null 2>&1; then
|
||
if [[ -z $(find /etc/nginx/conf.d -mindepth 1 -name '*.conf' 2>/dev/null) && -z $(find /etc/nginx/sites-enabled -mindepth 1 ! -name default 2>/dev/null) ]]; then
|
||
systemctl disable --now nginx >/dev/null 2>&1 || true
|
||
elif nginx -t >/dev/null 2>&1; then
|
||
systemctl reload nginx >/dev/null 2>&1 || true
|
||
else
|
||
warn "nginx не принял конфиг после удаления наших файлов: посмотрите nginx -t."
|
||
fi
|
||
fi
|
||
|
||
# Порт 80 установщик открывал ради сертификата: если после уборки на нём никто не слушает, закрываем и его.
|
||
if command -v ufw >/dev/null 2>&1 && [[ " $sshp " != *" 80 "* ]] && ! ss -H -ltn 'sport = :80' 2>/dev/null | grep -q .; then
|
||
ufw delete allow 80/tcp >/dev/null 2>&1 || true
|
||
ufw delete allow 80 >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
say "Удаляю файлы и сертификаты"
|
||
# acme.sh убираем, только если там нет чужих сертификатов.
|
||
if [[ -x /root/.acme.sh/acme.sh ]]; then
|
||
if [[ -z $(/root/.acme.sh/acme.sh --list 2>/dev/null | awk 'NR > 1 && $1 !~ /^[0-9.]+$/ {print $1}' | grep -vxF -e "${HOST:-}" -e "${LINK_HOST:-}" | grep . || true) ]]; then
|
||
/root/.acme.sh/acme.sh --uninstall >/dev/null 2>&1 || true
|
||
rm -rf /root/.acme.sh
|
||
else
|
||
warn "В acme.sh есть другие сертификаты, его оставил."
|
||
fi
|
||
fi
|
||
rm -rf -- /root/x-ui-before-update-* /root/x-ui-before-sni-*
|
||
for p in "${paths[@]}"; do rm -rf -- "$p"; done
|
||
# /root/cert мог содержать и чужие сертификаты: убираем только наши подкаталоги, а саму папку – если опустела.
|
||
rmdir /root/cert 2>/dev/null || true
|
||
# Правила fail2ban от 3X-UI (3x-ipl): без файлов джейл остался бы загруженным до перезапуска службы.
|
||
if command -v fail2ban-client >/dev/null 2>&1 && systemctl is-active --quiet fail2ban 2>/dev/null; then
|
||
fail2ban-client reload >/dev/null 2>&1 || systemctl reload fail2ban >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
echo
|
||
say "Готово: KIT удалён. Резервные копии (kit-backup-*.tar.gz в /root), если вы их делали, не тронуты: храните как пароль или удалите."
|
||
echo "Пакеты остались; убрать их: apt purge nginx jq qrencode socat fail2ban. Перезагрузка не нужна."
|
||
rm -f /usr/local/bin/kit
|
||
}
|
||
|
||
usage() {
|
||
cat <<EOF
|
||
${B}3X-UI_autoVPS $KIT_VERSION${N} – команды (то же самое есть в меню: просто ${B}kit${N})
|
||
|
||
kit меню
|
||
kit user … add, list, link, limit, on, off, del
|
||
kit links [имя] все ссылки на подключения (подписка, протоколы, Telegram)
|
||
kit net … протоколы, порты, сайт маскировки, отпечаток
|
||
kit check [--deep|--fix] проверка сервера
|
||
kit update [--panel] обновление (--auto и --manual – автообновление)
|
||
kit backup копия сервера
|
||
kit uninstall полное удаление (спросит подтверждение; --dry-run – только показать)
|
||
kit version версии
|
||
EOF
|
||
}
|
||
|
||
# Регистр и «users» вместо «user» не должны ломать команду; имя пользователя (третье слово) не трогаем.
|
||
cmd_key="${1:-} ${2:-}"; cmd_key=${cmd_key,,}; cmd_key=${cmd_key/users /user }
|
||
case "$cmd_key" in
|
||
" ") if [[ -t 0 && -t 1 ]]; then menu_main; else usage; fi ;;
|
||
"user add") shift 2; cmd_add "$@"; update_hint ;;
|
||
"user list") cmd_list; update_hint ;;
|
||
"user link") shift 2; cmd_link "$@" ;;
|
||
"links "*) shift; cmd_links "$@" ;;
|
||
"user limit") shift 2; cmd_limit "$@" ;;
|
||
"user off") cmd_toggle "${3:-}" false ;;
|
||
"user on") cmd_toggle "${3:-}" true ;;
|
||
"user del") shift 2; cmd_del "$@" ;;
|
||
"user enforce") shift 2; cmd_enforce "$@" ;;
|
||
"user vision") shift 2; cmd_vision "$@" ;;
|
||
"net ") shift; cmd_net; update_hint ;;
|
||
"net "*) shift; cmd_net "$@" ;;
|
||
"sni rotate") shift 2; sni_rotate "$@" ;;
|
||
"sni "*) [[ -z ${2:-} ]] || die "Команда: kit net site"; sni_show ;;
|
||
"port set") shift 2; port_set "$@" ;;
|
||
"port "*) [[ -z ${2:-} ]] || die "Команда: kit net port имя порт"; net_show ;;
|
||
"reality add") shift 2; reality_add "$@" ;;
|
||
"panel update") shift 2; panel_update "$@" ;;
|
||
"__limit-timer on") limit_timer_on ;;
|
||
"__xver "*) # служебная: xver у всех REALITY со своим доменом (возврат после неудачного --restore)
|
||
[[ ${2:-} =~ ^[01]$ ]] || exit 1
|
||
while read -r _id; do
|
||
inbound_patch "$_id" '.streamSettings |= ((if type == "string" then fromjson else . end) | .realitySettings.xver = $x)' --argjson x "$2" || true
|
||
done < <(sni_targets | awk -F'\t' '$2 == "self" {print $1}') ;;
|
||
"__links "*) links_block "${2:-}" "${3:-}" ;;
|
||
"__subextra "*) # служебная: её зовёт kit update уже новой версией, поэтому заодно догоняем таймер лимита (старый kit его не ставит)
|
||
limit_timer_enabled || limit_timer_on >/dev/null 2>&1 || true
|
||
(mt_sync) >/dev/null 2>&1 || true # ссылка MTProto в панели и секреты под нынешний сайт (1.2.1)
|
||
(grace_cleanup) >/dev/null 2>&1 || true
|
||
sub_extra_sync ;;
|
||
"__mtproto "*) mt_sync "${2:-}" ;;
|
||
"__applinks "*) app_links "${2:-}" ;;
|
||
"update "*) shift; cmd_update "$@" ;;
|
||
"backup "*) cmd_backup ;;
|
||
"uninstall"*) shift; cmd_uninstall "$@" ;;
|
||
"check "*) shift; cmd_check "$@" ;;
|
||
"fix "*) shift; cmd_fix "$@" ;;
|
||
"version "*|"--version "*|"-v "*) cmd_version ;;
|
||
*) usage; update_hint ;;
|
||
esac
|