Files
3X-UI_autoVPS/scripts/3x-ui.sh
T

2251 lines
144 KiB
Bash
Executable File
Raw Permalink Blame History

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