lightweight-server/ochenstarik-server-panel-warp-6.sh
ochenstarik-ui 9f06756cef
Some checks failed
Bash syntax / syntax (push) Failing after 11s
Bash syntax / syntax (pull_request) Failing after 2s
fix: close installer exposure and pin verified payloads
2026-10-23 12:33:19 +07:00

681 lines
28 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
set -Eeuo pipefail
readonly XUI_BINARY="/usr/local/x-ui/x-ui"
readonly XUI_DB="/etc/x-ui/x-ui.db"
readonly CLOUDFLARE_KEY_URL="https://pkg.cloudflareclient.com/pubkey.gpg"
readonly CLOUDFLARE_KEYRING="/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg"
readonly CLOUDFLARE_REPO="/etc/apt/sources.list.d/cloudflare-client.list"
readonly DEFAULT_PANEL_PORT="2053"
readonly DEFAULT_SUBSCRIPTION_PORT="2096"
readonly DEFAULT_WARP_PORT="40000"
readonly SSH_PORT_CONFIG="/etc/ochenstarik-server/ssh-port.conf"
readonly IP_FAMILY_CONFIG="/etc/ochenstarik-server/ip-family.conf"
readonly MANAGED_PORTS_CONFIG="/etc/ochenstarik-server/ufw-managed-ports.conf"
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SUPPLY_CHAIN_HELPER="${SCRIPT_DIR}/ochenstarik-server-supply-chain.sh"
TMP_DIR=""
XUI_GUARD_DIR=""
XUI_BOOTSTRAP_RELEASE_MARKER=""
log() { printf '[+] %s\n' "$*"; }
warn() { printf '[!] %s\n' "$*" >&2; }
die() { printf '[x] %s\n' "$*" >&2; exit 1; }
cleanup() {
if [[ -n "$TMP_DIR" && "$TMP_DIR" == /tmp/* && -d "$TMP_DIR" ]]; then
rm -rf -- "$TMP_DIR"
fi
}
trap cleanup EXIT
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Не найдена команда: $1"
}
is_valid_port() {
local port="$1"
[[ "$port" =~ ^[0-9]{1,5}$ ]] || return 1
(( 10#$port >= 1 && 10#$port <= 65535 ))
}
port_is_forbidden() {
local candidate="$1" forbidden
shift
for forbidden in "$@"; do
[[ "$candidate" != "$forbidden" ]] || return 0
done
return 1
}
choose_port() {
local variable_name="$1" label="$2" default_port="$3"
shift 3
local selected_port
local -a forbidden_ports=("$@")
while :; do
read -rp "${label} [${default_port}]: " selected_port
selected_port="${selected_port:-$default_port}"
if ! is_valid_port "$selected_port"; then
warn "Порт должен быть числом от 1 до 65535"
continue
fi
selected_port="$((10#$selected_port))"
if port_is_forbidden "$selected_port" "${forbidden_ports[@]}"; then
warn "Порт ${selected_port} уже зарезервирован другой службой"
continue
fi
printf -v "$variable_name" '%s' "$selected_port"
return 0
done
}
read_saved_ssh_port() {
local saved_port
SAVED_SSH_PORT=""
[[ -e "$SSH_PORT_CONFIG" ]] || return 0
[[ -f "$SSH_PORT_CONFIG" && ! -L "$SSH_PORT_CONFIG" ]] \
|| die "$SSH_PORT_CONFIG должен быть обычным файлом"
saved_port="$(sed -n 's/^SSH_PORT=//p' "$SSH_PORT_CONFIG" | head -n1 | tr -d '\r')"
is_valid_port "$saved_port" || die "Некорректный SSH-порт в $SSH_PORT_CONFIG"
SAVED_SSH_PORT="$((10#$saved_port))"
}
read_ip_mode() {
local mode=both
if [[ -e "$IP_FAMILY_CONFIG" ]]; then
[[ -f "$IP_FAMILY_CONFIG" && ! -L "$IP_FAMILY_CONFIG" ]] \
|| die "$IP_FAMILY_CONFIG должен быть обычным файлом"
mode="$(sed -n 's/^IP_MODE=//p' "$IP_FAMILY_CONFIG" | head -n1 | tr -d '\r')"
fi
case "$mode" in ipv4|ipv6|both) printf '%s' "$mode" ;; *) die "Некорректный IP_MODE: $mode" ;; esac
}
record_managed_ufw_rule() {
local rule="$1"
install -d -m 700 -o root -g root "$(dirname "$MANAGED_PORTS_CONFIG")"
[[ ! -L "$MANAGED_PORTS_CONFIG" ]] || die "$MANAGED_PORTS_CONFIG не должен быть символической ссылкой"
touch "$MANAGED_PORTS_CONFIG"
grep -Fqx -- "$rule" "$MANAGED_PORTS_CONFIG" || printf '%s\n' "$rule" >> "$MANAGED_PORTS_CONFIG"
chown root:root "$MANAGED_PORTS_CONFIG"
chmod 600 "$MANAGED_PORTS_CONFIG"
}
wait_for_tcp_listener() {
local port="$1" description="$2" attempt
for attempt in {1..20}; do
if ss -H -ltn "sport = :${port}" | grep -q .; then
log "${description} слушает TCP-порт ${port}"
return 0
fi
sleep 1
done
die "${description} не открыл TCP-порт ${port}"
}
verify_private_listener() {
local port="$1" description="$2" listeners address found=no
listeners="$(ss -H -ltn "sport = :${port}")"
[[ -n "$listeners" ]] || die "${description} не слушает TCP-порт ${port}"
while read -r address; do
[[ -n "$address" ]] || continue
[[ "$address" == "127.0.0.1:${port}" ]] \
|| die "${description} слушает внешний или IPv6-адрес ${address}; ожидался только 127.0.0.1:${port}"
found=yes
done < <(awk '{print $4}' <<< "$listeners")
[[ "$found" == yes ]] || die "Не удалось проверить loopback bind ${port}"
}
valid_subscription_path() {
local path="$1"
[[ -n "$path" && ${#path} -le 1024 && "$path" =~ ^/?[A-Za-z0-9._~-]+(/[A-Za-z0-9._~-]+)*/?$ ]] \
|| return 1
[[ "$path" != *'..'* && "$path" != *'//'* ]]
}
secure_public_subscription_path() {
local path="$1" component
component="${path#/}"
component="${component%/}"
[[ "$component" =~ ^[A-Za-z0-9_-]{32,}$ ]]
}
generate_subscription_path() {
local secret
secret="$(openssl rand -hex 24)" || die "Не удалось создать случайный путь подписки"
[[ "$secret" =~ ^[0-9a-f]{48}$ ]] || die "Генератор создал путь подписки неожиданного формата"
XUI_SUB_PATH="/${secret}/"
}
load_existing_subscription_path() {
local db="${1:-$XUI_DB}"
XUI_EXISTING_SUB_PATH=""
[[ -f "$db" && ! -L "$db" ]] || return 0
XUI_EXISTING_SUB_PATH="$(sqlite3 "$db" "SELECT value FROM settings WHERE key='subPath' LIMIT 1;" 2>/dev/null || true)"
valid_subscription_path "$XUI_EXISTING_SUB_PATH" || XUI_EXISTING_SUB_PATH=""
}
choose_public_access() {
local answer
printf '\nДоступ к панели 3x-ui:\n'
printf ' 1) Приватный режим: слушать только 127.0.0.1, доступ через SSH tunnel (рекомендуется)\n'
printf ' 2) Публичный режим: слушать все интерфейсы и открыть порт панели в UFW\n'
while :; do
read -rp 'Доступ к панели [1]: ' answer || answer=1
answer="${answer:-1}"
case "$answer" in
1) XUI_PUBLIC_ACCESS=no; XUI_WEB_LISTEN=127.0.0.1; return 0 ;;
2)
warn "Публичная панель доступна из Интернета. Используйте TLS, новые учётные данные и 2FA"
XUI_PUBLIC_ACCESS=yes
XUI_WEB_LISTEN=""
return 0
;;
*) warn "Введите 1 или 2" ;;
esac
done
}
choose_subscription_access() {
local answer rotate_answer existing_path="${XUI_EXISTING_SUB_PATH:-}"
printf '\nДоступ к подпискам 3x-ui (выбор не зависит от доступа к панели):\n'
printf ' 1) Приватный режим: включены только на 127.0.0.1 (рекомендуется)\n'
printf ' 2) Выключить сервис подписок\n'
printf ' 3) Публичный доступ: отдельный случайный секретный путь и открытый порт\n'
while :; do
read -rp 'Доступ к подпискам [1]: ' answer || answer=1
answer="${answer:-1}"
case "$answer" in
1)
XUI_SUBSCRIPTION_ACCESS=private
XUI_SUB_LISTEN=127.0.0.1
XUI_SUB_PATH="${existing_path:-/sub/}"
return 0
;;
2)
XUI_SUBSCRIPTION_ACCESS=disabled
XUI_SUB_LISTEN=127.0.0.1
XUI_SUB_PATH="${existing_path:-/sub/}"
return 0
;;
3)
command -v openssl >/dev/null 2>&1 || die "Для случайного пути подписки нужна команда openssl"
if [[ -n "$existing_path" ]] && secure_public_subscription_path "$existing_path"; then
warn "Найден действующий секретный путь подписки; его сохранение оставит существующие URL рабочими"
read -rp 'Создать новый путь и отключить старые URL? [y/N]: ' rotate_answer || rotate_answer=no
if [[ "$rotate_answer" == y || "$rotate_answer" == Y ]]; then
generate_subscription_path
else
XUI_SUB_PATH="$existing_path"
fi
elif [[ -n "$existing_path" ]]; then
warn "Текущий путь подписки не выглядит достаточно случайным. Ротация отключит существующие URL."
read -rp 'Создать новый криптографически случайный путь для публичного доступа? [y/N]: ' rotate_answer \
|| rotate_answer=no
if [[ "$rotate_answer" == y || "$rotate_answer" == Y ]]; then
generate_subscription_path
else
warn "Публичный доступ к подпискам не выбран; выберите приватный режим или отключение"
continue
fi
else
generate_subscription_path
fi
XUI_SUBSCRIPTION_ACCESS=public
XUI_SUB_LISTEN=""
return 0
;;
*) warn "Введите 1, 2 или 3" ;;
esac
done
}
choose_xui_source() {
local answer mirror_base
printf '\nИсточник проверенных установочных файлов 3x-ui и Xray:\n'
printf ' 1) GitHub (по умолчанию)\n'
printf ' 2) Forgejo: указать HTTPS base URL без учётных данных\n'
while :; do
read -rp 'Источник [1]: ' answer || answer=1
answer="${answer:-1}"
case "$answer" in
1) XUI_SUPPLY_SOURCE=github; XUI_SUPPLY_MIRROR=""; return 0 ;;
2)
read -rp 'Forgejo HTTPS base URL: ' mirror_base \
|| die "Forgejo URL не получен"
[[ -n "$mirror_base" ]] || die "Forgejo URL не может быть пустым"
XUI_SUPPLY_SOURCE=forgejo
XUI_SUPPLY_MIRROR="$mirror_base"
return 0
;;
*) warn "Введите 1 или 2" ;;
esac
done
}
allow_ufw_port() {
local port="$1" description="$2" mode
mode="$(read_ip_mode)"
log "UFW: открываю ${port}/tcp (${description})"
record_managed_ufw_rule "${port}/tcp"
if [[ "$mode" == ipv4 || "$mode" == both ]]; then
ufw allow from 0.0.0.0/0 to any port "$port" proto tcp
fi
if [[ "$mode" == ipv6 || "$mode" == both ]]; then
ufw allow from ::/0 to any port "$port" proto tcp
fi
}
verify_ufw_port() {
local port="$1" mode status
mode="$(read_ip_mode)"
status="$(LANG=C ufw status)"
if [[ "$mode" == ipv4 || "$mode" == both ]]; then
grep -Eq "^${port}/tcp[[:space:]]+ALLOW" <<< "$status" \
|| die "В UFW не найдено IPv4-правило для ${port}/tcp"
fi
if [[ "$mode" == ipv6 || "$mode" == both ]]; then
grep -Eq "^${port}/tcp \(v6\)[[:space:]]+ALLOW" <<< "$status" \
|| die "В UFW не найдено IPv6-правило для ${port}/tcp"
fi
}
set_xui_setting() {
local key="$1" value="$2"
sqlite3 "$XUI_DB" <<SQL
BEGIN IMMEDIATE;
UPDATE settings SET value = '${value}' WHERE key = '${key}';
INSERT INTO settings (key, value)
SELECT '${key}', '${value}'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = '${key}');
COMMIT;
SQL
}
set_xui_desired_listeners() {
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
XUI_DESIRED_WEB_LISTEN=""
else
XUI_DESIRED_WEB_LISTEN=127.0.0.1
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
XUI_DESIRED_SUB_LISTEN=""
else
XUI_DESIRED_SUB_LISTEN=127.0.0.1
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]; then
XUI_DESIRED_SUB_ENABLED=false
else
XUI_DESIRED_SUB_ENABLED=true
fi
}
create_xui_start_guard() {
local directory="$1" marker="$2" binary="$3" db="$4"
local real_systemctl="${5:-$(command -v systemctl)}"
local real_rc_service="${6:-$(command -v rc-service || true)}"
local real_sqlite3="${7:-$(command -v sqlite3)}"
local systemctl_q marker_q binary_q db_q rc_service_q sqlite3_q
[[ -x "$real_systemctl" ]] || die "systemctl недоступен для безопасной установки 3x-ui"
[[ -x "$real_sqlite3" ]] || die "sqlite3 недоступен для безопасной установки 3x-ui"
mkdir -m 700 -p -- "$directory"
printf -v systemctl_q '%q' "$real_systemctl"
printf -v marker_q '%q' "$marker"
printf -v binary_q '%q' "$binary"
printf -v db_q '%q' "$db"
printf -v rc_service_q '%q' "$real_rc_service"
printf -v sqlite3_q '%q' "$real_sqlite3"
cat > "$directory/systemctl" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
REAL_SYSTEMCTL=${systemctl_q}
RELEASE_MARKER=${marker_q}
XUI_BINARY=${binary_q}
XUI_DB=${db_q}
SQLITE3_BIN=${sqlite3_q}
service=""
case "\${1:-}" in
start|restart) service="\${2:-}" ;;
enable)
for argument in "\$@"; do
[[ "\$argument" != x-ui && "\$argument" != x-ui.service ]] || service=x-ui
done
;;
esac
if [[ ( "\$service" == x-ui || "\$service" == x-ui.service ) && ! -e "\$RELEASE_MARKER" ]]; then
[[ -x "\$XUI_BINARY" ]] || { printf '[x] x-ui binary missing before guarded service start\\n' >&2; exit 1; }
"\$XUI_BINARY" setting -listenIP 127.0.0.1 \\
|| { printf '[x] Refusing x-ui start: loopback bind could not be applied\\n' >&2; exit 1; }
[[ -f "\$XUI_DB" && ! -L "\$XUI_DB" ]] \\
|| { printf '[x] Refusing x-ui start: settings database is unavailable\\n' >&2; exit 1; }
"\$SQLITE3_BIN" "\$XUI_DB" <<'SQL' || { printf '[x] Refusing x-ui start: safe subscription bind could not be applied\\n' >&2; exit 1; }
BEGIN IMMEDIATE;
UPDATE settings SET value='127.0.0.1' WHERE key='subListen';
INSERT INTO settings (key,value) SELECT 'subListen','127.0.0.1'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subListen');
UPDATE settings SET value='false' WHERE key='subEnable';
INSERT INTO settings (key,value) SELECT 'subEnable','false'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subEnable');
COMMIT;
SQL
safe_sub_settings="\$("\$SQLITE3_BIN" "\$XUI_DB" "SELECT (SELECT value FROM settings WHERE key='subListen' LIMIT 1) || '|' || (SELECT value FROM settings WHERE key='subEnable' LIMIT 1);")"
[[ "\$safe_sub_settings" == '127.0.0.1|false' ]] \\
|| { printf '[x] Refusing x-ui start: safe subscription settings did not verify\\n' >&2; exit 1; }
fi
exec "\$REAL_SYSTEMCTL" "\$@"
EOF
chmod 700 "$directory/systemctl"
cat > "$directory/rc-service" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
REAL_RC_SERVICE=${rc_service_q}
RELEASE_MARKER=${marker_q}
XUI_BINARY=${binary_q}
XUI_DB=${db_q}
SQLITE3_BIN=${sqlite3_q}
if [[ "\${1:-}" == x-ui && ( "\${2:-}" == start || "\${2:-}" == restart ) \\
&& ! -e "\$RELEASE_MARKER" ]]; then
[[ -x "\$XUI_BINARY" ]] || { printf '[x] x-ui binary missing before guarded service start\\n' >&2; exit 1; }
"\$XUI_BINARY" setting -listenIP 127.0.0.1 \\
|| { printf '[x] Refusing x-ui start: loopback bind could not be applied\\n' >&2; exit 1; }
[[ -f "\$XUI_DB" && ! -L "\$XUI_DB" ]] \\
|| { printf '[x] Refusing x-ui start: settings database is unavailable\\n' >&2; exit 1; }
"\$SQLITE3_BIN" "\$XUI_DB" <<'SQL' || { printf '[x] Refusing x-ui start: safe subscription bind could not be applied\\n' >&2; exit 1; }
BEGIN IMMEDIATE;
UPDATE settings SET value='127.0.0.1' WHERE key='subListen';
INSERT INTO settings (key,value) SELECT 'subListen','127.0.0.1'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subListen');
UPDATE settings SET value='false' WHERE key='subEnable';
INSERT INTO settings (key,value) SELECT 'subEnable','false'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subEnable');
COMMIT;
SQL
safe_sub_settings="\$("\$SQLITE3_BIN" "\$XUI_DB" "SELECT (SELECT value FROM settings WHERE key='subListen' LIMIT 1) || '|' || (SELECT value FROM settings WHERE key='subEnable' LIMIT 1);")"
[[ "\$safe_sub_settings" == '127.0.0.1|false' ]] \\
|| { printf '[x] Refusing x-ui start: safe subscription settings did not verify\\n' >&2; exit 1; }
fi
[[ -x "\$REAL_RC_SERVICE" ]] || { printf '[x] rc-service is unavailable\\n' >&2; exit 127; }
exec "\$REAL_RC_SERVICE" "\$@"
EOF
chmod 700 "$directory/rc-service"
XUI_GUARD_DIR="$directory"
XUI_BOOTSTRAP_RELEASE_MARKER="$marker"
}
configure_xui_ports() {
local backup_file panel_value subscription_value subscription_listeners
# XUI_WEB_LISTEN and XUI_SUB_LISTEN are changed to safe loopback values
# during bootstrap. Recompute the requested final values from the explicit
# access choices before entering that protected phase.
set_xui_desired_listeners
[[ -x "$XUI_BINARY" ]] || die "После установки не найден $XUI_BINARY"
[[ -f "$XUI_DB" && ! -L "$XUI_DB" ]] \
|| die "Поддерживается стандартная SQLite-база 3x-ui: $XUI_DB"
backup_file="${XUI_DB}.bak.$(date +%F-%H%M%S-%N)"
cp -a -- "$XUI_DB" "$backup_file"
log "Резервная копия базы 3x-ui: ${backup_file}"
systemctl stop x-ui.service >/dev/null 2>&1 || true
if ! "$XUI_BINARY" setting -port "$PANEL_PORT"; then
cp -a -- "$backup_file" "$XUI_DB"
systemctl start x-ui.service >/dev/null 2>&1 || true
die "Не удалось изменить порт панели 3x-ui"
fi
# The guarded first start always uses loopback. Public panel access is
# enabled only after this protected listener has been observed.
if ! set_xui_setting webListen 127.0.0.1 \
|| ! set_xui_setting subPort "$SUBSCRIPTION_PORT" \
|| ! set_xui_setting subListen 127.0.0.1 \
|| ! set_xui_setting subPath "$XUI_SUB_PATH" \
|| ! set_xui_setting subEnable false; then
cp -a -- "$backup_file" "$XUI_DB"
systemctl start x-ui.service >/dev/null 2>&1 || true
die "Не удалось изменить порт подписок 3x-ui; база восстановлена"
fi
systemctl daemon-reload
systemctl enable x-ui.service
systemctl start x-ui.service
systemctl is-active --quiet x-ui.service || die "Служба x-ui не запущена"
panel_value="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webPort' LIMIT 1;")"
subscription_value="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subPort' LIMIT 1;")"
[[ "$panel_value" == "$PANEL_PORT" ]] \
|| die "В базе 3x-ui указан неожиданный порт панели: ${panel_value:-пусто}"
[[ "$subscription_value" == "$SUBSCRIPTION_PORT" ]] \
|| die "В базе 3x-ui указан неожиданный порт подписок: ${subscription_value:-пусто}"
XUI_WEB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webListen' LIMIT 1;")"
XUI_SUB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subListen' LIMIT 1;")"
[[ "$XUI_WEB_LISTEN" == 127.0.0.1 ]] \
|| die "Первый запуск 3x-ui остановлен: webListen не равен 127.0.0.1"
[[ "$XUI_SUB_LISTEN" == 127.0.0.1 ]] \
|| die "Первый запуск 3x-ui остановлен: subListen не равен 127.0.0.1"
XUI_WEB_PATH="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webBasePath' LIMIT 1;")"
[[ "$XUI_WEB_PATH" == /* ]] || XUI_WEB_PATH="/${XUI_WEB_PATH}"
[[ "$XUI_SUB_PATH" == /* ]] || XUI_SUB_PATH="/${XUI_SUB_PATH}"
wait_for_tcp_listener "$PANEL_PORT" "Панель 3x-ui"
verify_private_listener "$PANEL_PORT" "Панель 3x-ui"
subscription_listeners="$(ss -H -ltn "sport = :${SUBSCRIPTION_PORT}")"
if [[ -n "$subscription_listeners" ]]; then
verify_private_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
fi
systemctl stop x-ui.service
if ! set_xui_setting webListen "$XUI_DESIRED_WEB_LISTEN" \
|| ! set_xui_setting subListen "$XUI_DESIRED_SUB_LISTEN" \
|| ! set_xui_setting subEnable "$XUI_DESIRED_SUB_ENABLED"; then
die "Не удалось применить выбранный режим доступа 3x-ui"
fi
XUI_WEB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webListen' LIMIT 1;")"
XUI_SUB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subListen' LIMIT 1;")"
XUI_SUB_ENABLED="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subEnable' LIMIT 1;")"
[[ "$XUI_WEB_LISTEN" == "$XUI_DESIRED_WEB_LISTEN" ]] \
|| die "Неожиданный конечный webListen в базе 3x-ui"
[[ "$XUI_SUB_LISTEN" == "$XUI_DESIRED_SUB_LISTEN" ]] \
|| die "Неожиданный конечный subListen в базе 3x-ui"
[[ "$XUI_SUB_ENABLED" == "$XUI_DESIRED_SUB_ENABLED" ]] \
|| die "Неожиданное конечное состояние сервиса подписок в базе 3x-ui"
: > "$XUI_BOOTSTRAP_RELEASE_MARKER"
systemctl start x-ui.service
systemctl is-active --quiet x-ui.service || die "Служба x-ui не запустилась с выбранными настройками"
}
install_cloudflare_warp() {
local os_id version_codename architecture key_file keyring_file
os_id="$(sed -n 's/^ID=//p' /etc/os-release | head -n1 | tr -d '\"')"
version_codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release | head -n1 | tr -d '\"')"
architecture="$(dpkg --print-architecture)"
[[ "$architecture" == amd64 || "$architecture" == arm64 ]] \
|| die "Cloudflare WARP поддерживает здесь только amd64 и arm64; получено: $architecture"
case "${os_id}:${version_codename}" in
ubuntu:jammy|ubuntu:noble|ubuntu:resolute|debian:bookworm|debian:trixie)
;;
*)
die "Неподдерживаемая Cloudflare WARP система: ${os_id} ${version_codename}"
;;
esac
key_file="${TMP_DIR}/cloudflare-warp.gpg"
keyring_file="${TMP_DIR}/cloudflare-warp-archive-keyring.gpg"
curl --fail --silent --show-error --location \
--proto '=https' --tlsv1.2 "$CLOUDFLARE_KEY_URL" -o "$key_file"
gpg --batch --yes --dearmor --output "$keyring_file" "$key_file"
install -o root -g root -m 644 "$keyring_file" "$CLOUDFLARE_KEYRING"
printf 'deb [signed-by=%s] https://pkg.cloudflareclient.com/ %s main\n' \
"$CLOUDFLARE_KEYRING" "$version_codename" > "$CLOUDFLARE_REPO"
chmod 644 "$CLOUDFLARE_REPO"
apt-get update
apt-get install -y cloudflare-warp
systemctl enable --now warp-svc.service
require_command warp-cli
if ! warp-cli --accept-tos registration show >/dev/null 2>&1; then
warp-cli --accept-tos registration new
fi
warp-cli --accept-tos disconnect >/dev/null 2>&1 || true
warp-cli --accept-tos tunnel protocol set MASQUE
warp-cli --accept-tos mode proxy
warp-cli --accept-tos proxy port "$WARP_PORT"
warp-cli --accept-tos connect
}
verify_warp_proxy() {
local trace attempt
wait_for_tcp_listener "$WARP_PORT" "Cloudflare WARP local proxy"
trace=""
for attempt in {1..20}; do
trace="$(curl --fail --silent --show-error --max-time 15 \
--proxy "socks5h://127.0.0.1:${WARP_PORT}" \
https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null || true)"
grep -Fqx 'warp=on' <<< "$trace" && break
sleep 1
done
grep -Fqx 'warp=on' <<< "$trace" \
|| die "Проверка Cloudflare через локальный proxy не вернула warp=on"
log "Cloudflare WARP работает через 127.0.0.1:${WARP_PORT}"
}
[[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root"
for command_name in apt-get dpkg grep install sed systemctl tr; do
require_command "$command_name"
done
read_saved_ssh_port
forbidden_common=("22" "80" "443")
[[ -z "$SAVED_SSH_PORT" ]] || forbidden_common+=("$SAVED_SSH_PORT")
PANEL_PORT=""
SUBSCRIPTION_PORT=""
WARP_PORT=""
XUI_PUBLIC_ACCESS=no
XUI_SUBSCRIPTION_ACCESS=private
XUI_WEB_LISTEN=127.0.0.1
XUI_SUB_LISTEN=127.0.0.1
XUI_WEB_PATH="/"
XUI_SUB_PATH=""
XUI_EXISTING_SUB_PATH=""
choose_port PANEL_PORT "Порт панели 3x-ui" "$DEFAULT_PANEL_PORT" "${forbidden_common[@]}"
choose_port SUBSCRIPTION_PORT "Порт подписок 3x-ui" "$DEFAULT_SUBSCRIPTION_PORT" \
"${forbidden_common[@]}" "$PANEL_PORT"
choose_public_access
choose_xui_source
read -rp 'Установить Cloudflare WARP local proxy? [y/N]: ' WARP_ANSWER || WARP_ANSWER=no
if [[ "$WARP_ANSWER" == y || "$WARP_ANSWER" == Y ]]; then
choose_port WARP_PORT "Локальный proxy-порт WARP" "$DEFAULT_WARP_PORT" \
"${forbidden_common[@]}" "$PANEL_PORT" "$SUBSCRIPTION_PORT"
else
WARP_PORT=""
fi
export DEBIAN_FRONTEND=noninteractive
log "Установка системных зависимостей"
apt-get update
apt-get install -y ca-certificates curl gnupg iproute2 openssl sqlite3 ufw
for command_name in curl gpg openssl sqlite3 ss ufw; do
require_command "$command_name"
done
load_existing_subscription_path
choose_subscription_access
printf '\nБудут использованы порты:\n'
printf ' 3x-ui panel: %s/tcp\n' "$PANEL_PORT"
printf ' 3x-ui subscriptions: %s/tcp\n' "$SUBSCRIPTION_PORT"
if [[ -n "$WARP_PORT" ]]; then printf ' WARP local proxy: 127.0.0.1:%s/tcp\n' "$WARP_PORT"; else printf ' WARP: выключен\n'; fi
printf ' UFW: HTTPS 443 открыт; панель %s; подписки %s\n\n' \
"$([[ "$XUI_PUBLIC_ACCESS" == yes ]] && printf 'публично' || printf 'закрыта')" \
"$([[ "$XUI_SUBSCRIPTION_ACCESS" == public ]] && printf 'публично' || printf '%s' "$XUI_SUBSCRIPTION_ACCESS")"
TMP_DIR="$(mktemp -d)"
chmod 700 "$TMP_DIR"
[[ -f "$SUPPLY_CHAIN_HELPER" && ! -L "$SUPPLY_CHAIN_HELPER" ]] \
|| die "Не найден безопасный supply-chain helper: $SUPPLY_CHAIN_HELPER"
if [[ "$XUI_SUPPLY_SOURCE" == github ]]; then
bash "$SUPPLY_CHAIN_HELPER" prepare github "${TMP_DIR}/vendor"
else
bash "$SUPPLY_CHAIN_HELPER" prepare forgejo "${TMP_DIR}/vendor" "$XUI_SUPPLY_MIRROR"
fi
create_xui_start_guard "${TMP_DIR}/xui-start-guard" \
"${TMP_DIR}/release-xui-listener" "$XUI_BINARY" "$XUI_DB"
export PATH="${XUI_GUARD_DIR}:${PATH}"
log "Установка 3x-ui из проверенного релиза v3.10.2"
bash "$SUPPLY_CHAIN_HELPER" install-xui "${TMP_DIR}/vendor"
log "Настройка портов 3x-ui"
configure_xui_ports
if [[ -n "$WARP_PORT" ]]; then
log "Установка и настройка Cloudflare WARP"
install_cloudflare_warp
fi
log "Настройка UFW"
allow_ufw_port 443 "HTTPS"
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
allow_ufw_port "$PANEL_PORT" "публичная панель 3x-ui"
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
allow_ufw_port "$SUBSCRIPTION_PORT" "подписки 3x-ui"
else
log "UFW: порт подписок оставлен закрытым для публичного Интернета"
fi
ufw --force enable
verify_ufw_port 443
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
verify_ufw_port "$PANEL_PORT"
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
verify_ufw_port "$SUBSCRIPTION_PORT"
fi
wait_for_tcp_listener "$PANEL_PORT" "Панель 3x-ui"
if [[ "$XUI_PUBLIC_ACCESS" != yes ]]; then
verify_private_listener "$PANEL_PORT" "Панель 3x-ui"
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" != disabled ]]; then
wait_for_tcp_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
if [[ "$XUI_SUBSCRIPTION_ACCESS" != public ]]; then
verify_private_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
fi
fi
if [[ -n "$WARP_PORT" ]]; then verify_warp_proxy; fi
printf '\nУстановка завершена.\n'
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
printf 'Панель 3x-ui: http(s)://<server-ip>:%s%s\n' "$PANEL_PORT" "$XUI_WEB_PATH"
else
printf 'Панель 3x-ui: http://127.0.0.1:%s%s (только локально)\n' "$PANEL_PORT" "$XUI_WEB_PATH"
printf 'SSH tunnel: ssh -L %s:127.0.0.1:%s <user>@<server-ip>\n' "$PANEL_PORT" "$PANEL_PORT"
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]; then
printf 'Подписки 3x-ui: выключены\n'
elif [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
printf 'Подписки 3x-ui: порт %s/tcp открыт; случайный путь сохранён в базе 3x-ui и не выведен в журнал\n' "$SUBSCRIPTION_PORT"
else
printf 'Подписки 3x-ui: http(s)://127.0.0.1:%s%s (только локально)\n' "$SUBSCRIPTION_PORT" "$XUI_SUB_PATH"
fi
if [[ -n "$WARP_PORT" ]]; then
printf 'Локальный WARP proxy: socks5://127.0.0.1:%s\n' "$WARP_PORT"
printf '\nПорт WARP не открыт публично в UFW: локальный proxy не требует аутентификации.\n'
else
printf 'Cloudflare WARP не устанавливался.\n'
fi
printf 'Настройте TLS, сложный путь панели, новые учётные данные и двухфакторную аутентификацию в 3x-ui.\n'