681 lines
28 KiB
Bash
Executable file
681 lines
28 KiB
Bash
Executable file
#!/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'
|