3x-ui/install.sh
root b53703f1a1 fix: add -settingStatus CLI flag to reduce DB inits during install/update
Worker nodes with remote MariaDB experienced slow configuration because
config_after_update() and config_after_install() made 5-8 separate
x-ui setting CLI calls, each spawning a new Go process and re-initing
the DB connection. Add a single -settingStatus flag that returns all
needed info in one call, reducing DB inits from 5-6 down to 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 15:05:11 +08:00

1830 lines
68 KiB
Bash
Raw 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.

#!/bin/bash
red='\033[0;31m'
green='\033[0;32m'
blue='\033[0;34m'
yellow='\033[0;33m'
plain='\033[0m'
xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
xui_service="${XUI_SERVICE:=/etc/systemd/system}"
# 检查 root 权限
[[ $EUID -ne 0 ]] && echo -e "${red}错误:${plain} 请使用 root 权限运行此脚本 \n " && exit 1
# 检查操作系统并设置发行版变量
if [[ -f /etc/os-release ]]; then
source /etc/os-release
release=$ID
elif [[ -f /usr/lib/os-release ]]; then
source /usr/lib/os-release
release=$ID
else
echo "无法识别操作系统,请联系作者!" >&2
exit 1
fi
echo "操作系统版本:$release"
arch() {
case "$(uname -m)" in
x86_64 | x64 | amd64) echo 'amd64' ;;
i*86 | x86) echo '386' ;;
armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;;
armv7* | armv7 | arm) echo 'armv7' ;;
armv6* | armv6) echo 'armv6' ;;
armv5* | armv5) echo 'armv5' ;;
s390x) echo 's390x' ;;
*) echo -e "${green}不支持的 CPU 架构!${plain}" && rm -f install.sh && exit 1 ;;
esac
}
echo "架构:$(arch)"
# 基本辅助函数
is_ipv4() {
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1
}
is_ipv6() {
[[ "$1" =~ : ]] && return 0 || return 1
}
is_domain() {
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
}
# 端口检测辅助函数
is_port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {found=1} END {exit(found ? 0 : 1)}'
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {found=1} END {exit(found ? 0 : 1)}'
return
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
fi
return 1
}
install_base() {
case "${release}" in
ubuntu | debian | armbian)
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl
else
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
fi
;;
arch | manjaro | parch)
pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
;;
opensuse-tumbleweed | opensuse-leap)
zypper refresh && zypper -q install -y cronie curl tar timezone socat ca-certificates openssl
;;
alpine)
apk update && apk add dcron curl tar tzdata socat ca-certificates openssl
;;
*)
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
;;
esac
}
ensure_cron_running() {
if [[ "$release" == "alpine" ]]; then
rc-service dcron start 2>/dev/null || true
rc-update add dcron 2>/dev/null || true
elif command -v systemctl >/dev/null 2>&1; then
if systemctl list-unit-files 2>/dev/null | grep -qE '^(crond|cron)\.service$'; then
systemctl enable crond 2>/dev/null || systemctl enable cron 2>/dev/null || true
systemctl start crond 2>/dev/null || systemctl start cron 2>/dev/null || true
fi
fi
}
has_mariadb_cli() {
command -v mariadb >/dev/null 2>&1 || command -v mysql >/dev/null 2>&1
}
has_local_mariadb_service() {
if command -v systemctl >/dev/null 2>&1; then
# Also verify the server package is actually installed (not just a stale service file)
if systemctl list-unit-files 2>/dev/null | grep -qE '^(mariadb|mysql)\.service$'; then
case "${release}" in
ubuntu | debian | armbian | linuxmint)
dpkg -s mariadb-server >/dev/null 2>&1 && return 0
# Package missing but service file exists — stale state
return 1
;;
centos | rhel | almalinux | rocky | ol | alinux | amzn | fedora)
rpm -q mariadb-server >/dev/null 2>&1 && return 0
return 1
;;
*)
return 0
;;
esac
fi
fi
[[ -f /etc/init.d/mariadb ]]
}
mariadb_cli_bin() {
if command -v mariadb >/dev/null 2>&1; then
command -v mariadb
return 0
fi
if command -v mysql >/dev/null 2>&1; then
command -v mysql
return 0
fi
return 1
}
install_mariadb_client() {
echo -e "${green}正在安装 MariaDB 客户端...${plain}"
case "${release}" in
ubuntu | debian | armbian | linuxmint)
apt-get update -y && apt-get install -y mariadb-client
;;
fedora)
dnf install -y mariadb
;;
centos | rhel | almalinux | rocky | ol | alinux | amzn)
if command -v dnf >/dev/null 2>&1; then
dnf install -y mariadb
else
yum install -y mariadb
fi
;;
arch | manjaro | parch)
pacman -Sy --noconfirm mariadb-clients >/dev/null 2>&1 || pacman -Sy --noconfirm mariadb
;;
opensuse-tumbleweed | opensuse-leap | sles)
zypper install -y mariadb-client
;;
alpine)
apk add mariadb-client
;;
*)
apt-get update -y && apt-get install -y mariadb-client
;;
esac
}
install_local_mariadb_server() {
echo -e "${green}正在安装本地 MariaDB...${plain}"
case "${release}" in
ubuntu | debian | armbian | linuxmint)
apt-get update -y && apt-get install -y mariadb-server mariadb-client
;;
fedora)
dnf install -y mariadb-server mariadb
;;
centos | rhel | almalinux | rocky | ol | alinux | amzn)
if command -v dnf >/dev/null 2>&1; then
dnf install -y mariadb-server mariadb
else
yum install -y mariadb-server mariadb
fi
;;
arch | manjaro | parch)
pacman -Sy --noconfirm mariadb
mariadb-install-db --user=mysql --basedir=/usr --datadir=/var/lib/mysql >/dev/null 2>&1 || true
;;
opensuse-tumbleweed | opensuse-leap | sles)
zypper install -y mariadb-server mariadb-client
;;
alpine)
apk add mariadb mariadb-client
mariadb-install-db --user=mysql --basedir=/usr --datadir=/var/lib/mysql >/dev/null 2>&1 || true
;;
*)
echo -e "${red}不支持的发行版: ${release},请手动安装 MariaDB${plain}"
return 1
;;
esac
}
start_mariadb_service() {
local svc_name=""
local output=""
if command -v systemctl >/dev/null 2>&1; then
if systemctl list-unit-files 2>/dev/null | grep -q "^mariadb.service"; then
svc_name="mariadb"
elif systemctl list-unit-files 2>/dev/null | grep -q "^mysql.service"; then
svc_name="mysql"
fi
fi
if [ -n "$svc_name" ]; then
systemctl start "$svc_name" 2>/dev/null || true
systemctl enable "$svc_name" 2>/dev/null || true
return 0
fi
if [[ $release == "alpine" ]]; then
rc-service mariadb start 2>/dev/null
rc-update add mariadb 2>/dev/null
return $?
fi
return 1
}
ensure_mariadb_client_ready() {
if has_mariadb_cli; then
return 0
fi
install_mariadb_client || return 1
has_mariadb_cli
}
ensure_local_mariadb_ready() {
if ! has_local_mariadb_service; then
install_local_mariadb_server || return 1
LOCAL_MARIADB_JUST_INSTALLED="1"
fi
ensure_mariadb_client_ready || return 1
start_mariadb_service || true
return 0
}
test_mariadb_server_connection() {
local host="$1" port="$2" user="$3" pass="$4"
local bin
local -a cmd
local err_output
bin=$(mariadb_cli_bin) || return 1
cmd=("$bin" -h "$host" -P "$port" -u "$user")
if [[ -n "$pass" ]]; then
cmd+=("-p$pass")
fi
cmd+=(-e "SELECT 1;")
err_output=$("${cmd[@]}" 2>&1)
local rc=$?
if [[ $rc -ne 0 ]]; then
echo -e "${red}MariaDB 连接失败: ${err_output}${plain}" >&2
return 1
fi
}
test_mariadb_database_connection() {
local host="$1" port="$2" dbname="$3" user="$4" pass="$5"
local bin
local -a cmd
local err_output
bin=$(mariadb_cli_bin) || return 1
cmd=("$bin" -h "$host" -P "$port" -u "$user" -D "$dbname")
if [[ -n "$pass" ]]; then
cmd+=("-p$pass")
fi
cmd+=(-e "SELECT 1;")
err_output=$("${cmd[@]}" 2>&1)
local rc=$?
if [[ $rc -ne 0 ]]; then
echo -e "${red}MariaDB 连接失败: ${err_output}${plain}" >&2
return 1
fi
}
is_safe_mariadb_identifier() {
[[ "$1" =~ ^[A-Za-z0-9_.-]+$ ]]
}
escape_sql_string() {
printf "%s" "$1" | sed "s/'/''/g"
}
validate_tcp_port() {
local port="$1"
[[ "$port" =~ ^[0-9]+$ ]] && ((port >= 1 && port <= 65535))
}
mariadb_server_override_path() {
local dir=""
for dir in /etc/mysql/mariadb.conf.d /etc/mysql/conf.d /etc/my.cnf.d /etc/mariadb.conf.d; do
if [ -d "$dir" ]; then
echo "${dir}/60-x-ui.cnf"
return 0
fi
done
echo "/etc/my.cnf"
}
mariadb_server_config_candidates() {
local override_path
override_path=$(mariadb_server_override_path)
local path=""
for path in \
/etc/mysql/mariadb.conf.d/50-server.cnf \
/etc/mysql/mariadb.cnf \
/etc/mysql/my.cnf \
/etc/mysql/conf.d/mysql.cnf \
/etc/my.cnf.d/mariadb-server.cnf \
/etc/my.cnf.d/server.cnf \
/etc/mariadb.conf.d/50-server.cnf \
/etc/my.cnf; do
if [ -f "$path" ] && [ "$path" != "$override_path" ]; then
echo "$path"
fi
done
echo "$override_path"
}
ensure_mariadb_override_file() {
local path
path=$(mariadb_server_override_path)
mkdir -p "$(dirname "$path")"
if [ ! -f "$path" ]; then
printf "[mysqld]\n" >"$path"
elif ! grep -q '^\[mysqld\]' "$path" 2>/dev/null; then
printf "\n[mysqld]\n" >>"$path"
fi
echo "$path"
}
upsert_mariadb_mysqld_option() {
local file="$1"
local key="$2"
local value="$3"
local tmp_file
tmp_file=$(mktemp)
awk -v key="$key" -v value="$value" '
BEGIN {
in_section = 0
section_seen = 0
key_written = 0
}
/^\[.*\][[:space:]]*$/ {
if (in_section && !key_written) {
print key " = " value
key_written = 1
}
if ($0 == "[mysqld]") {
in_section = 1
section_seen = 1
} else {
in_section = 0
}
print
next
}
{
if (in_section && $0 ~ "^[[:space:]]*[#;]?[[:space:]]*" key "[[:space:]]*=") {
if (!key_written) {
print key " = " value
key_written = 1
}
next
}
print
}
END {
if (!section_seen) {
print "[mysqld]"
}
if (!key_written) {
print key " = " value
}
}' "$file" >"$tmp_file"
cat "$tmp_file" >"$file"
rm -f "$tmp_file"
}
disable_mariadb_skip_networking() {
local file=""
local tmp_file=""
while IFS= read -r file; do
[ -f "$file" ] || continue
tmp_file=$(mktemp)
awk '
{
if ($0 ~ /^[[:space:]]*skip-networking([[:space:]]*=[[:space:]]*.*)?[[:space:]]*$/) {
print "# x-ui disabled skip-networking to keep managed MariaDB networking available"
next
}
print
}' "$file" >"$tmp_file"
cat "$tmp_file" >"$file"
rm -f "$tmp_file"
done < <(mariadb_server_config_candidates)
}
restart_mariadb_service() {
local svc_name=""
local output=""
if command -v systemctl >/dev/null 2>&1; then
if systemctl list-unit-files 2>/dev/null | grep -q "^mariadb.service"; then
svc_name="mariadb"
elif systemctl list-unit-files 2>/dev/null | grep -q "^mysql.service"; then
svc_name="mysql"
fi
fi
if [ -n "$svc_name" ]; then
output=$(systemctl restart "$svc_name" 2>&1) || {
echo -e "${red}systemctl restart $svc_name 失败:${plain}" >&2
echo "$output" >&2
echo -e "${yellow}systemctl status $svc_name:${plain}" >&2
systemctl status "$svc_name" --no-pager -l 2>&1 | head -20 >&2
return 1
}
return 0
fi
if [[ $release == "alpine" ]]; then
output=$(rc-service mariadb restart 2>&1) || {
echo -e "${red}rc-service mariadb restart 失败:${plain}" >&2
echo "$output" >&2
return 1
}
return $?
fi
start_mariadb_service
}
configure_local_mariadb_server_network() {
local db_port="$1"
local bind_address="$2"
local override_file=""
if ! validate_tcp_port "$db_port"; then
echo -e "${red}MariaDB 端口无效,请输入 1-65535 之间的数字${plain}"
return 1
fi
override_file=$(ensure_mariadb_override_file) || return 1
upsert_mariadb_mysqld_option "$override_file" "port" "$db_port"
upsert_mariadb_mysqld_option "$override_file" "bind-address" "$bind_address"
disable_mariadb_skip_networking
if ! restart_mariadb_service; then
echo -e "${red}重启 MariaDB 失败,请检查配置文件${plain}"
return 1
fi
return 0
}
LOCAL_MARIADB_ADMIN_MODE=""
LOCAL_MARIADB_ADMIN_USER=""
LOCAL_MARIADB_ADMIN_PASS=""
LOCAL_MARIADB_ADMIN_PORT="3306"
LOCAL_MARIADB_JUST_INSTALLED="0"
try_local_mariadb_socket_admin() {
local bin
bin=$(mariadb_cli_bin) || return 1
"$bin" -e "SELECT 1;" >/dev/null 2>&1 || "$bin" -uroot -e "SELECT 1;" >/dev/null 2>&1
}
ensure_local_mariadb_admin_access() {
local port="${1:-3306}"
local i
LOCAL_MARIADB_ADMIN_PORT="$port"
# Fresh installs may need a few seconds before local socket auth is ready.
for ((i = 0; i < 10; i++)); do
if try_local_mariadb_socket_admin; then
LOCAL_MARIADB_ADMIN_MODE="socket"
return 0
fi
sleep 1
done
# Common default on fresh installs: root can connect via TCP without password.
if test_mariadb_server_connection "127.0.0.1" "$port" "root" ""; then
LOCAL_MARIADB_ADMIN_MODE="password"
LOCAL_MARIADB_ADMIN_USER="root"
LOCAL_MARIADB_ADMIN_PASS=""
if [[ "$LOCAL_MARIADB_JUST_INSTALLED" == "1" ]]; then
echo -e "${green}检测到新安装 MariaDB已自动使用 root 免密权限初始化数据库。${plain}"
fi
return 0
fi
local admin_user admin_pass
echo -e "${yellow}无法通过 root socket 直接连接本地 MariaDB请输入管理员账号信息。${plain}"
read -rp "MariaDB 管理员用户名 [root]: " admin_user
admin_user="${admin_user:-root}"
read -rsp "MariaDB 管理员密码(可留空): " admin_pass
echo
if ! test_mariadb_server_connection "127.0.0.1" "$port" "$admin_user" "$admin_pass"; then
echo -e "${red}管理员账号连接失败${plain}"
return 1
fi
LOCAL_MARIADB_ADMIN_MODE="password"
LOCAL_MARIADB_ADMIN_USER="$admin_user"
LOCAL_MARIADB_ADMIN_PASS="$admin_pass"
}
run_local_mariadb_admin_sql() {
local sql="$1"
local bin
local -a cmd
bin=$(mariadb_cli_bin) || return 1
case "$LOCAL_MARIADB_ADMIN_MODE" in
socket)
"$bin" -e "$sql" >/dev/null 2>&1 || "$bin" -uroot -e "$sql" >/dev/null 2>&1
;;
password)
cmd=("$bin" -h "127.0.0.1" -P "$LOCAL_MARIADB_ADMIN_PORT" -u "$LOCAL_MARIADB_ADMIN_USER")
if [[ -n "$LOCAL_MARIADB_ADMIN_PASS" ]]; then
cmd+=("-p$LOCAL_MARIADB_ADMIN_PASS")
fi
cmd+=(-e "$sql")
"${cmd[@]}" >/dev/null 2>&1
;;
*)
return 1
;;
esac
}
ensure_mariadb_database_and_user() {
local dbname="$1" dbuser="$2" dbpass="$3"
local escaped_pass
local sql=""
local account_host=""
if ! is_safe_mariadb_identifier "$dbname"; then
echo -e "${red}业务数据库名仅支持字母、数字、点、下划线和连字符${plain}"
return 1
fi
if ! is_safe_mariadb_identifier "$dbuser"; then
echo -e "${red}业务用户名仅支持字母、数字、点、下划线和连字符${plain}"
return 1
fi
escaped_pass=$(escape_sql_string "$dbpass")
sql="CREATE DATABASE IF NOT EXISTS \`${dbname}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
for account_host in "localhost" "127.0.0.1" "::1"; do
sql="${sql} CREATE USER IF NOT EXISTS '${dbuser}'@'${account_host}' IDENTIFIED BY '${escaped_pass}';"
sql="${sql} ALTER USER '${dbuser}'@'${account_host}' IDENTIFIED BY '${escaped_pass}';"
sql="${sql} GRANT ALL PRIVILEGES ON \`${dbname}\`.* TO '${dbuser}'@'${account_host}';"
done
sql="${sql} FLUSH PRIVILEGES;"
echo -e "${green}正在确保本地 MariaDB 的业务库和业务账号存在...${plain}"
run_local_mariadb_admin_sql "$sql"
}
gen_random_string() {
local length="$1"
openssl rand -base64 $(( length * 2 )) \
| tr -dc 'a-zA-Z0-9' \
| head -c "$length"
}
is_safe_install_path() {
local target="$1"
local resolved_target
[[ -n "$target" ]] || return 1
resolved_target=$(readlink -f "$target" 2>/dev/null || echo "$target")
case "$resolved_target" in
"/" | "/usr" | "/usr/" | "/usr/local" | "/usr/local/" | "/etc" | "/etc/")
return 1
;;
esac
return 0
}
save_panel_domain() {
local domain="$1"
if [[ -z "$domain" ]]; then
return 0
fi
${xui_folder}/x-ui setting -webDomain "${domain}" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${red}保存面板域名失败:${domain}${plain}"
return 1
fi
local saved_domain
saved_domain=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'webDomain: .+' | awk '{print $2}' | tr -d '[:space:]')
if [[ "${saved_domain}" != "${domain}" ]]; then
echo -e "${red}面板域名未写入配置文件:期望 ${domain},实际 ${saved_domain:-}${plain}"
return 1
fi
return 0
}
verify_panel_cert_paths() {
local expected_cert="$1"
local expected_key="$2"
local current_cert current_key
current_cert=$(${xui_folder}/x-ui setting -getCert true 2>/dev/null | grep '^cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
current_key=$(${xui_folder}/x-ui setting -getCert true 2>/dev/null | grep '^key:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
if [[ "${current_cert}" != "${expected_cert}" || "${current_key}" != "${expected_key}" ]]; then
echo -e "${red}证书路径未写入配置文件${plain}"
echo -e "${yellow}期望证书:${expected_cert}${plain}"
echo -e "${yellow}实际证书:${current_cert:-}${plain}"
echo -e "${yellow}期望私钥:${expected_key}${plain}"
echo -e "${yellow}实际私钥:${current_key:-}${plain}"
return 1
fi
return 0
}
install_acme() {
local previous_dir
local install_status
echo -e "${green}正在安装 acme.sh 用于 SSL 证书管理...${plain}"
previous_dir=$(pwd)
cd ~ || return 1
curl -s https://get.acme.sh | sh >/dev/null 2>&1
install_status=$?
cd "$previous_dir" >/dev/null 2>&1 || true
if [ $install_status -ne 0 ]; then
echo -e "${red}安装 acme.sh 失败${plain}"
return 1
else
echo -e "${green}acme.sh 安装成功${plain}"
fi
return 0
}
# 签发 Let's Encrypt IP 证书(短期配置文件,约 6 天有效期)
# 需要 acme.sh 且 80 端口开放用于 HTTP-01 验证
setup_ip_certificate() {
local ipv4="$1"
local ipv6="$2" # 可选
echo -e "${green}正在配置 Let's Encrypt IP 证书(短期配置文件)...${plain}"
echo -e "${yellow}注意IP 证书有效期约 6 天,将自动续期。${plain}"
echo -e "${yellow}默认监听 80 端口。如选择其他端口,请确保外部 80 端口转发到该端口。${plain}"
# 检查 acme.sh
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
install_acme
if [ $? -ne 0 ]; then
echo -e "${red}安装 acme.sh 失败${plain}"
return 1
fi
fi
# 验证 IP 地址
if [[ -z "$ipv4" ]]; then
echo -e "${red}需要提供 IPv4 地址${plain}"
return 1
fi
if ! is_ipv4 "$ipv4"; then
echo -e "${red}无效的 IPv4 地址:$ipv4${plain}"
return 1
fi
# 创建证书目录
local certDir="/root/cert/ip"
mkdir -p "$certDir"
# 构建域名参数
local domain_args="-d ${ipv4}"
if [[ -n "$ipv6" ]] && is_ipv6 "$ipv6"; then
domain_args="${domain_args} -d ${ipv6}"
echo -e "${green}包含 IPv6 地址:${ipv6}${plain}"
fi
# 设置重载命令用于自动续期(添加 || true 以防首次安装时失败)
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true"
# 选择 HTTP-01 监听端口(默认 80可自定义
local WebPort=""
read -rp "用于 ACME HTTP-01 监听的端口(默认 80" WebPort
WebPort="${WebPort:-80}"
if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then
echo -e "${red}无效端口,回退到 80。${plain}"
WebPort=80
fi
echo -e "${green}使用端口 ${WebPort} 进行独立验证。${plain}"
if [[ "${WebPort}" -ne 80 ]]; then
echo -e "${yellow}提醒Let's Encrypt 仍然连接 80 端口;请将外部 80 端口转发到 ${WebPort}${plain}"
fi
# 确保所选端口可用
while true; do
if is_port_in_use "${WebPort}"; then
echo -e "${yellow}端口 ${WebPort} 已被占用。${plain}"
local alt_port=""
read -rp "请输入另一个端口供 acme.sh 独立监听(留空取消):" alt_port
alt_port="${alt_port// /}"
if [[ -z "${alt_port}" ]]; then
echo -e "${red}端口 ${WebPort} 被占用,无法继续。${plain}"
return 1
fi
if ! [[ "${alt_port}" =~ ^[0-9]+$ ]] || ((alt_port < 1 || alt_port > 65535)); then
echo -e "${red}无效端口。${plain}"
return 1
fi
WebPort="${alt_port}"
continue
else
echo -e "${green}端口 ${WebPort} 空闲,可以进行独立验证。${plain}"
break
fi
done
# 使用短期配置文件签发证书
echo -e "${green}正在为 ${ipv4} 签发 IP 证书...${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --issue \
${domain_args} \
--standalone \
--server letsencrypt \
--certificate-profile shortlived \
--days 6 \
--httpport ${WebPort} \
--force
if [ $? -ne 0 ]; then
echo -e "${red}签发 IP 证书失败${plain}"
echo -e "${yellow}请确保端口 ${WebPort} 可达(或从外部 80 端口转发)${plain}"
# 清理 acme.sh 数据IPv4 和 IPv6
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
rm -rf ${certDir} 2>/dev/null
return 1
fi
echo -e "${green}证书签发成功,正在安装...${plain}"
# 安装证书
# 注意acme.sh 可能在 reloadcmd 失败时报告 "Reload error" 并返回非零退出码,
# 但证书文件仍然已安装。我们通过检查文件而非退出码来判断。
~/.acme.sh/acme.sh --installcert -d ${ipv4} \
--key-file "${certDir}/privkey.pem" \
--fullchain-file "${certDir}/fullchain.pem" \
--reloadcmd "${reloadCmd}" 2>&1 || true
# 验证证书文件存在(不依赖退出码 - reloadcmd 失败会导致非零)
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}安装后未找到证书文件${plain}"
# 清理 acme.sh 数据IPv4 和 IPv6
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
rm -rf ${certDir} 2>/dev/null
return 1
fi
echo -e "${green}证书文件安装成功${plain}"
# 启用 acme.sh 自动升级(确保 cron 任务运行)
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
# 安全权限:私钥仅所有者可读
chmod 600 ${certDir}/privkey.pem 2>/dev/null
chmod 644 ${certDir}/fullchain.pem 2>/dev/null
# 为面板配置证书路径
echo -e "${green}正在为面板设置证书路径...${plain}"
if ! "${xui_folder}/x-ui" cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" >/dev/null 2>&1; then
echo -e "${red}无法自动设置证书路径${plain}"
echo -e "${yellow}证书文件位于:${plain}"
echo -e " 证书:${certDir}/fullchain.pem"
echo -e " 密钥:${certDir}/privkey.pem"
return 1
fi
if ! verify_panel_cert_paths "${certDir}/fullchain.pem" "${certDir}/privkey.pem"; then
return 1
fi
echo -e "${green}证书路径配置成功${plain}"
echo -e "${green}IP 证书安装并配置成功!${plain}"
echo -e "${green}证书有效期约 6 天,通过 acme.sh cron 任务自动续期。${plain}"
echo -e "${yellow}acme.sh 将在证书到期前自动续期并重载 x-ui。${plain}"
return 0
}
# 综合手动 SSL 证书签发(通过 acme.sh
ssl_cert_issue() {
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##')
local existing_port=$(${xui_folder}/x-ui setting -show true | grep '^port:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
# 检查 acme.sh
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
echo "未找到 acme.sh正在安装..."
if ! install_acme; then
echo -e "${red}安装 acme.sh 失败${plain}"
return 1
fi
fi
# 获取域名并验证
local domain=""
while true; do
read -rp "请输入您的域名:" domain
domain="${domain// /}" # 去除空格
if [[ -z "$domain" ]]; then
echo -e "${red}域名不能为空,请重试。${plain}"
continue
fi
if ! is_domain "$domain"; then
echo -e "${red}无效的域名格式:${domain},请输入有效的域名。${plain}"
continue
fi
break
done
echo -e "${green}您的域名是:${domain},正在检查...${plain}"
# 检查是否已存在证书
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk 'NR>1 {print $1}' | grep -Fxq "${domain}"; then
local certInfo=$(~/.acme.sh/acme.sh --list)
echo -e "${red}系统已有该域名的证书,无法重复签发。${plain}"
echo -e "${yellow}当前证书信息:${plain}"
echo "$certInfo"
return 1
else
echo -e "${green}您的域名已准备好签发证书...${plain}"
fi
# 创建证书目录
certPath="/root/cert/${domain}"
if [ ! -d "$certPath" ]; then
mkdir -p "$certPath"
else
rm -rf "$certPath"
mkdir -p "$certPath"
fi
# 获取独立服务器端口号
local WebPort=80
read -rp "请选择要使用的端口(默认 80" WebPort
WebPort="${WebPort// /}"
WebPort="${WebPort:-80}"
if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then
echo -e "${yellow}输入 ${WebPort} 无效,将使用默认端口 80。${plain}"
WebPort=80
fi
echo -e "${green}将使用端口:${WebPort} 签发证书。请确保此端口已开放。${plain}"
# 临时停止面板
echo -e "${yellow}正在临时停止面板...${plain}"
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
# 签发证书
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
echo -e "${red}签发证书失败,请查看日志。${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
else
echo -e "${green}证书签发成功,正在安装证书...${plain}"
fi
# 设置重载命令
reloadCmd="systemctl restart x-ui || rc-service x-ui restart"
echo -e "${green}ACME 默认 --reloadcmd 为:${yellow}systemctl restart x-ui || rc-service x-ui restart${plain}"
echo -e "${green}此命令将在每次签发和续期证书时运行。${plain}"
read -rp "是否要修改 ACME 的 --reloadcmd(y/n)" setReloadcmd
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
echo -e "\n${green}\t1.${plain} 预设systemctl reload nginx ; systemctl restart x-ui"
echo -e "${green}\t2.${plain} 输入自定义命令"
echo -e "${green}\t0.${plain} 保持默认 reloadcmd"
read -rp "请选择:" choice
case "$choice" in
1)
echo -e "${green}Reloadcmd 设为systemctl reload nginx ; systemctl restart x-ui${plain}"
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
;;
2)
echo -e "${yellow}建议将 x-ui restart 放在最后,这样即使其他服务失败也不会报错${plain}"
read -rp "请输入自定义的 reloadcmd" reloadCmd
echo -e "${green}Reloadcmd 设为:${reloadCmd}${plain}"
;;
*)
echo -e "${green}保持默认 reloadcmd${plain}"
;;
esac
fi
# 安装证书
~/.acme.sh/acme.sh --installcert -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
if [ $? -ne 0 ]; then
echo -e "${red}安装证书失败,退出。${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
else
echo -e "${green}安装证书成功,正在启用自动续期...${plain}"
fi
# 启用自动续期
~/.acme.sh/acme.sh --upgrade --auto-upgrade
if [ $? -ne 0 ]; then
echo -e "${yellow}自动续期设置有问题,证书详情:${plain}"
ls -lah /root/cert/${domain}/
chmod 600 $certPath/privkey.pem 2>/dev/null
chmod 644 $certPath/fullchain.pem 2>/dev/null
else
echo -e "${green}自动续期设置成功,证书详情:${plain}"
ls -lah /root/cert/${domain}/
chmod 600 $certPath/privkey.pem 2>/dev/null
chmod 644 $certPath/fullchain.pem 2>/dev/null
fi
# 证书安装成功后提示用户为面板设置证书路径
read -rp "是否要为面板设置此证书?(y/n)" setPanel
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
local webCertFile="/root/cert/${domain}/fullchain.pem"
local webKeyFile="/root/cert/${domain}/privkey.pem"
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
if ! verify_panel_cert_paths "$webCertFile" "$webKeyFile"; then
echo -e "${red}错误:证书路径未写入配置文件。${plain}"
return 1
fi
if ! save_panel_domain "$domain"; then
return 1
fi
echo -e "${green}面板证书路径已设置${plain}"
echo -e "${green}证书文件:$webCertFile${plain}"
echo -e "${green}私钥文件:$webKeyFile${plain}"
echo ""
echo -e "${green}访问地址https://${domain}:${existing_port}/${existing_webBasePath}${plain}"
echo -e "${yellow}面板将重启以应用 SSL 证书...${plain}"
systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null
SSL_HOST="${domain}"
else
echo -e "${red}错误:未找到域名 ${domain} 的证书或私钥文件。${plain}"
return 1
fi
else
echo -e "${yellow}未将证书应用到面板SSL 配置未完成。${plain}"
return 1
fi
return 0
}
# 可复用的交互式 SSL 设置(域名或 IP
# 设置全局 `SSL_HOST` 以供访问地址使用
prompt_and_setup_ssl() {
local panel_port="$1"
local web_base_path="$2" # 预期不含前导斜杠
local server_ip="$3"
local ssl_choice=""
SSL_HOST=""
echo -e "${yellow}选择 SSL 证书配置方式:${plain}"
echo -e "${green}1.${plain} Let's Encrypt 域名证书90 天有效期,自动续期)"
echo -e "${green}2.${plain} Let's Encrypt IP 证书6 天有效期,自动续期)"
echo -e "${green}3.${plain} 自定义 SSL 证书(指定已有文件路径)"
echo -e "${green}4.${plain} Cloudflare SSL 证书通配符证书DNS 验证)"
echo -e "${blue}注意:${plain} 选项 1 和 2 需要开放 80 端口。选项 3 需要手动指定路径。选项 4 需要 Cloudflare API 密钥。"
read -rp "请选择(默认 2 使用 IP" ssl_choice
ssl_choice="${ssl_choice// /}" # 去除空格
# 除 1/3/4 外,其余输入均视为 2IP 证书)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
ssl_choice="2"
fi
case "$ssl_choice" in
1)
# 用户选择 Let's Encrypt 域名选项
echo -e "${green}使用 Let's Encrypt 域名证书...${plain}"
if ssl_cert_issue; then
echo -e "${green}✓ SSL 证书配置成功,域名:${SSL_HOST}${plain}"
else
echo -e "${red}✗ 域名证书配置失败。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
;;
2)
# 用户选择 Let's Encrypt IP 证书选项
echo -e "${green}使用 Let's Encrypt IP 证书(短期配置文件)...${plain}"
if [[ -z "${server_ip}" ]]; then
local manual_ipv4=""
echo -e "${yellow}未能自动检测到服务器公网 IPv4。${plain}"
while true; do
read -rp "请输入服务器公网 IPv4留空取消" manual_ipv4
manual_ipv4="${manual_ipv4// /}"
if [[ -z "${manual_ipv4}" ]]; then
echo -e "${red}未提供公网 IPv4无法继续 IP 证书配置。${plain}"
return 1
fi
if is_ipv4 "${manual_ipv4}"; then
server_ip="${manual_ipv4}"
break
fi
echo -e "${red}无效的 IPv4 地址:${manual_ipv4}${plain}"
done
fi
# 询问可选的 IPv6
local ipv6_addr=""
read -rp "是否包含 IPv6 地址?(留空跳过):" ipv6_addr
ipv6_addr="${ipv6_addr// /}" # 去除空格
# 停止面板(需要 80 端口)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop >/dev/null 2>&1
else
systemctl stop x-ui >/dev/null 2>&1
fi
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
if [ $? -eq 0 ]; then
SSL_HOST="${server_ip}"
echo -e "${green}✓ Let's Encrypt IP 证书配置成功${plain}"
else
echo -e "${red}✗ IP 证书配置失败。请检查 80 端口是否已开放。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
;;
3)
# 用户选择自定义路径(用户提供)
echo -e "${green}使用自定义已有证书...${plain}"
local custom_cert=""
local custom_key=""
local custom_domain=""
# 3.1 请求域名以组成面板 URL
read -rp "请输入证书对应的域名:" custom_domain
custom_domain="${custom_domain// /}" # 去除空格
# 3.2 证书路径循环
while true; do
read -rp "输入证书路径(关键字:.crt / fullchain" custom_cert
# 去除引号
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
break
elif [[ ! -f "$custom_cert" ]]; then
echo -e "${red}错误:文件不存在!请重试。${plain}"
elif [[ ! -r "$custom_cert" ]]; then
echo -e "${red}错误:文件存在但不可读(请检查权限)!${plain}"
else
echo -e "${red}错误:文件为空!${plain}"
fi
done
# 3.3 私钥路径循环
while true; do
read -rp "输入私钥路径(关键字:.key / privatekey" custom_key
# 去除引号
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
break
elif [[ ! -f "$custom_key" ]]; then
echo -e "${red}错误:文件不存在!请重试。${plain}"
elif [[ ! -r "$custom_key" ]]; then
echo -e "${red}错误:文件存在但不可读(请检查权限)!${plain}"
else
echo -e "${red}错误:文件为空!${plain}"
fi
done
# 3.4 通过 x-ui 二进制文件应用设置
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" >/dev/null 2>&1
if ! verify_panel_cert_paths "$custom_cert" "$custom_key"; then
SSL_HOST="${server_ip}"
return 1
fi
# 设置 SSL_HOST 用于组成面板 URL
if [[ -n "$custom_domain" ]]; then
if ! save_panel_domain "$custom_domain"; then
SSL_HOST="${server_ip}"
return 1
fi
SSL_HOST="$custom_domain"
else
SSL_HOST="${server_ip}"
fi
echo -e "${green}✓ 自定义证书路径已应用。${plain}"
echo -e "${yellow}注意:您需要自行管理这些文件的续期。${plain}"
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
;;
4)
# Cloudflare SSL 证书通配符DNS 验证)
echo -e "${green}使用 Cloudflare SSL 证书...${plain}"
echo -e "${yellow}需要以下信息:${plain}"
echo -e " 1. Cloudflare 注册邮箱"
echo -e " 2. Cloudflare 全局 API 密钥"
echo -e " 3. 域名(证书将包含主域名和通配符 *.域名)"
local cf_domain=""
local cf_key=""
local cf_email=""
read -rp "请输入域名:" cf_domain
cf_domain="${cf_domain// /}"
if [[ -z "$cf_domain" ]]; then
echo -e "${red}域名不能为空,跳过 SSL 配置。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
if ! is_domain "$cf_domain"; then
echo -e "${red}无效的域名格式:${cf_domain}${plain}"
SSL_HOST="${server_ip}"
return 1
fi
read -rsp "请输入 Cloudflare 全局 API 密钥:" cf_key
echo
cf_key="${cf_key// /}"
if [[ -z "$cf_key" ]]; then
echo -e "${red}API 密钥不能为空,跳过 SSL 配置。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
read -rp "请输入 Cloudflare 注册邮箱:" cf_email
cf_email="${cf_email// /}"
if [[ -z "$cf_email" ]]; then
echo -e "${red}邮箱不能为空,跳过 SSL 配置。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
# 确保 acme.sh 已安装
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
echo -e "${yellow}未找到 acme.sh正在安装...${plain}"
install_acme
if [ $? -ne 0 ]; then
echo -e "${red}安装 acme.sh 失败,跳过 SSL 配置。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
fi
# 设置 Let's Encrypt 为默认 CA
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
if [ $? -ne 0 ]; then
echo -e "${red}设置默认 CA 失败,跳过 SSL 配置。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
# 导出 Cloudflare 凭证
export CF_Key="${cf_key}"
export CF_Email="${cf_email}"
# 使用 Cloudflare DNS 签发证书(通配符 + 主域名)
echo -e "${yellow}正在通过 Cloudflare DNS 签发证书...${plain}"
~/.acme.sh/acme.sh --issue --dns dns_cf -d "${cf_domain}" -d "*.${cf_domain}" --log --force
if [ $? -ne 0 ]; then
unset CF_Key CF_Email
echo -e "${red}证书签发失败,请检查 Cloudflare API 密钥和域名是否正确。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
# 安装证书
local certPath="/root/cert/${cf_domain}"
rm -rf "${certPath}"
mkdir -p "${certPath}"
if [ $? -ne 0 ]; then
unset CF_Key CF_Email
echo -e "${red}创建目录失败:${certPath}${plain}"
SSL_HOST="${server_ip}"
return 1
fi
local reloadCmd="x-ui restart"
~/.acme.sh/acme.sh --installcert -d "${cf_domain}" -d "*.${cf_domain}" \
--key-file "${certPath}/privkey.pem" \
--fullchain-file "${certPath}/fullchain.pem" --reloadcmd "${reloadCmd}"
if [ $? -ne 0 ]; then
unset CF_Key CF_Email
echo -e "${red}证书安装失败。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
# 启用自动续期
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
chmod 600 "${certPath}/privkey.pem"
chmod 644 "${certPath}/fullchain.pem"
echo -e "${green}✓ Cloudflare SSL 证书签发并安装成功。${plain}"
# 为面板设置证书
local webCertFile="${certPath}/fullchain.pem"
local webKeyFile="${certPath}/privkey.pem"
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1
if ! verify_panel_cert_paths "$webCertFile" "$webKeyFile"; then
unset CF_Key CF_Email
SSL_HOST="${server_ip}"
return 1
fi
if ! save_panel_domain "$cf_domain"; then
unset CF_Key CF_Email
SSL_HOST="${server_ip}"
return 1
fi
echo -e "${green}✓ 面板证书已设置。${plain}"
else
unset CF_Key CF_Email
echo -e "${red}未找到证书或私钥文件。${plain}"
SSL_HOST="${server_ip}"
return 1
fi
unset CF_Key CF_Email
SSL_HOST="${cf_domain}"
echo -e "${green}✓ Cloudflare SSL 证书配置完成。${plain}"
echo -e "${yellow}注意:证书支持自动续期,无需手动管理。${plain}"
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
;;
*)
echo -e "${red}无效选项。跳过 SSL 配置。${plain}"
SSL_HOST="${server_ip}"
return 1
;;
esac
return 0
}
config_after_install() {
# 检测 x-ui 是否已安装:优先检查 x-ui.json设置文件其次检查 x-ui.db
local db_folder="${XUI_DB_FOLDER:-/etc/x-ui}"
local is_fresh_install=false
if [[ ! -f "${db_folder}/x-ui.json" && ! -f "${db_folder}/x-ui.db" ]]; then
is_fresh_install=true
fi
# Single CLI call for all settings (1 DB init instead of 3)
local settings_output=$(${xui_folder}/x-ui setting -settingStatus true 2>/dev/null)
local existing_webBasePath=$(echo "$settings_output" | grep '^webBasePath:' | cut -d: -f2-)
local existing_port=$(echo "$settings_output" | grep '^port:' | cut -d: -f2-)
local existing_cert=$(echo "$settings_output" | grep '^certFile:' | cut -d: -f2-)
local URL_lists=(
"https://api4.ipify.org"
"https://ipv4.icanhazip.com"
"https://v4.api.ipinfo.io/ip"
"https://ipv4.myexternalip.com/raw"
"https://4.ident.me"
"https://check-host.net/ip"
)
local server_ip=""
for ip_address in "${URL_lists[@]}"; do
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break
fi
done
if [[ "$is_fresh_install" == "true" ]]; then
# 全新安装:用户输入或随机生成凭据
echo -e "${yellow}设置面板凭据(输入 rd 或留空将自动生成):${plain}"
read -rp "请输入用户名 [默认 admin]" config_username
config_username="${config_username// /}"
if [[ -z "$config_username" || "$config_username" == "rd" ]]; then
config_username="admin"
fi
read -rp "请输入密码 [默认随机生成]" config_password
config_password="${config_password// /}"
if [[ -z "$config_password" || "$config_password" == "rd" ]]; then
config_password=$(gen_random_string 18)
echo -e "${green}已生成随机密码:${config_password}${plain}"
fi
read -rp "请输入 Web 路径(不含前导 /" config_webBasePath
config_webBasePath="${config_webBasePath// /}"
config_webBasePath="${config_webBasePath#/}" # 去除前导斜杠
if [[ -z "$config_webBasePath" || "$config_webBasePath" == "rd" ]]; then
config_webBasePath=$(gen_random_string 18)
echo -e "${green}已生成随机 Web 路径:${config_webBasePath}${plain}"
fi
while true; do
read -rp "请输入面板端口(留空将随机生成):" config_port
config_port="${config_port// /}"
if [[ -z "${config_port}" ]]; then
config_port=$(shuf -i 1024-62000 -n 1)
echo -e "${yellow}已生成随机端口:${config_port}${plain}"
break
fi
if ! [[ "${config_port}" =~ ^[0-9]+$ ]] || ((config_port < 1 || config_port > 65535)); then
echo -e "${red}无效端口,请输入 1-65535 之间的数字。${plain}"
continue
fi
echo -e "${yellow}您的面板端口为:${config_port}${plain}"
break
done
read -rp "Database type [mariadb]: " db_type
db_type=$(echo "${db_type:-mariadb}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [[ "${db_type}" != "mariadb" && "${db_type}" != "sqlite" ]]; then
echo -e "${yellow}无效的数据库类型,回退到 mariadb${plain}"
db_type="mariadb"
fi
if [[ "${db_type}" == "mariadb" ]]; then
local mariadb_mode_choice mariadb_mode db_host db_port db_user db_pass db_name
read -rp "MariaDB 部署位置 [1=本地 MariaDB, 2=远程 MariaDB默认 1]: " mariadb_mode_choice
case "${mariadb_mode_choice:-1}" in
2)
mariadb_mode="remote"
;;
*)
mariadb_mode="local"
;;
esac
if [[ "${mariadb_mode}" == "remote" ]]; then
read -rp "远程 MariaDB host [127.0.0.1]: " db_host
read -rp "远程 MariaDB port [3306]: " db_port
read -rp "业务数据库名 [3xui]: " db_name
read -rp "业务用户名: " db_user
read -rsp "业务密码: " db_pass
echo
db_host="${db_host:-127.0.0.1}"
db_port="${db_port:-3306}"
db_name="${db_name:-3xui}"
while ! [[ "${db_port}" =~ ^[0-9]+$ ]] || ((db_port < 1 || db_port > 65535)); do
echo -e "${red}远程 MariaDB 端口无效,请输入 1-65535 之间的数字${plain}"
read -rp "远程 MariaDB port [3306]: " db_port
db_port="${db_port:-3306}"
done
if [[ -z "$db_user" || -z "$db_pass" ]]; then
echo -e "${red}远程 MariaDB 的业务用户名和业务密码不能为空${plain}"
return 1
fi
ensure_mariadb_client_ready || {
echo -e "${red}安装 MariaDB 客户端失败${plain}"
return 1
}
echo -e "${green}正在验证远程 MariaDB 业务连接...${plain}"
if ! test_mariadb_database_connection "$db_host" "$db_port" "$db_name" "$db_user" "$db_pass"; then
echo -e "${red}无法使用输入的远程 MariaDB 信息连接到业务数据库${plain}"
return 1
fi
else
db_host="127.0.0.1"
read -rp "本地 MariaDB port [3306]: " db_port
read -rp "业务数据库名 [3xui]: " db_name
read -rp "业务用户名: " db_user
read -rsp "业务密码: " db_pass
echo
db_port="${db_port:-3306}"
db_name="${db_name:-3xui}"
if ! validate_tcp_port "$db_port"; then
echo -e "${red}本地 MariaDB 端口无效,请输入 1-65535 之间的数字${plain}"
return 1
fi
if [[ -z "$db_user" || -z "$db_pass" ]]; then
echo -e "${red}本地 MariaDB 的业务用户名和业务密码不能为空${plain}"
return 1
fi
ensure_local_mariadb_ready || {
echo -e "${red}准备本地 MariaDB 失败${plain}"
return 1
}
configure_local_mariadb_server_network "$db_port" "127.0.0.1" || return 1
ensure_local_mariadb_admin_access "$db_port" || return 1
ensure_mariadb_database_and_user "$db_name" "$db_user" "$db_pass" || {
echo -e "${red}创建本地 MariaDB 业务库或业务账号失败${plain}"
return 1
}
echo -e "${green}正在验证本地 MariaDB 业务连接...${plain}"
if ! test_mariadb_database_connection "$db_host" "$db_port" "$db_name" "$db_user" "$db_pass"; then
echo -e "${red}无法使用创建后的本地 MariaDB 业务账号连接数据库${plain}"
return 1
fi
fi
if ! XUI_DB_PASSWORD="$db_pass" ${xui_folder}/x-ui setting \
-dbType "${db_type}" \
-dbHost "${db_host}" \
-dbPort "${db_port}" \
-dbUser "$db_user" \
-dbName "${db_name}" >/dev/null 2>&1; then
echo -e "${red}写入 MariaDB 配置失败${plain}"
return 1
fi
elif ! ${xui_folder}/x-ui setting -dbType "${db_type}" >/dev/null 2>&1; then
echo -e "${red}写入数据库类型失败${plain}"
return 1
fi
read -rp "Node role [master]: " node_role
node_role=$(echo "${node_role:-master}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [[ "${node_role}" != "master" && "${node_role}" != "worker" ]]; then
echo -e "${yellow}无效的节点角色,回退到 master${plain}"
node_role="master"
fi
read -rp "Sync interval [30]: " sync_interval
sync_interval="${sync_interval:-30}"
while ! [[ "${sync_interval}" =~ ^[1-9][0-9]*$ ]]; do
echo -e "${yellow}同步间隔必须为正整数${plain}"
read -rp "Sync interval [30]: " sync_interval
sync_interval="${sync_interval:-30}"
done
read -rp "Traffic flush interval [10]: " traffic_flush_interval
traffic_flush_interval="${traffic_flush_interval:-10}"
while ! [[ "${traffic_flush_interval}" =~ ^[1-9][0-9]*$ ]]; do
echo -e "${yellow}流量回刷间隔必须为正整数${plain}"
read -rp "Traffic flush interval [10]: " traffic_flush_interval
traffic_flush_interval="${traffic_flush_interval:-10}"
done
if [[ "${node_role}" == "worker" && "${db_type}" != "mariadb" ]]; then
echo -e "${yellow}worker 节点要求使用 MariaDB回退到 master${plain}"
node_role="master"
fi
if [[ "${node_role}" == "worker" ]]; then
read -rp "Node ID: " node_id
node_id="${node_id// /}"
while [[ -z "${node_id}" ]]; do
echo -e "${yellow}worker 节点必须提供 Node ID${plain}"
read -rp "Node ID: " node_id
node_id="${node_id// /}"
done
if ! ${xui_folder}/x-ui setting -nodeRole worker -nodeId "$node_id" -syncInterval "${sync_interval}" -trafficFlushInterval "${traffic_flush_interval}" >/dev/null 2>&1; then
echo -e "${red}写入 worker 节点配置失败${plain}"
return 1
fi
else
if ! ${xui_folder}/x-ui setting -nodeRole master -nodeId "" -syncInterval "${sync_interval}" -trafficFlushInterval "${traffic_flush_interval}" >/dev/null 2>&1; then
echo -e "${red}写入 master 节点配置失败${plain}"
return 1
fi
fi
if ! ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" >/dev/null 2>&1; then
echo -e "${red}写入面板基础配置失败,请检查数据库配置${plain}"
return 1
fi
local saved_port
local verify_output=$(${xui_folder}/x-ui setting -settingStatus true 2>/dev/null)
saved_port=$(echo "$verify_output" | grep '^port:' | cut -d: -f2-)
if [[ "${saved_port}" != "${config_port}" ]]; then
echo -e "${red}端口未写入配置文件:期望 ${config_port},实际 ${saved_port:-}${plain}"
return 1
fi
config_port="${saved_port}"
echo ""
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${green} SSL 证书配置(必需) ${plain}"
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${yellow}出于安全考虑,所有面板都需要配置 SSL 证书。${plain}"
echo -e "${yellow}Let's Encrypt 现已支持域名和 IP 地址!${plain}"
echo ""
if ! prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"; then
echo -e "${red}SSL 配置失败,安装终止。${plain}"
return 1
fi
local access_scheme="https"
local access_host="${SSL_HOST:-${server_ip:-localhost}}"
# 显示最终凭据和访问信息
echo ""
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${green} 面板安装完成! ${plain}"
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${green}用户名: ${config_username}${plain}"
echo -e "${green}密码: ${config_password}${plain}"
echo -e "${green}端口: ${config_port}${plain}"
echo -e "${green}Web路径 ${config_webBasePath}${plain}"
echo -e "${green}访问地址: ${access_scheme}://${access_host}:${config_port}/${config_webBasePath}${plain}"
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${yellow}⚠ 重要:请安全保存这些凭据!${plain}"
echo -e "${yellow}⚠ SSL 证书:已启用并配置${plain}"
else
# 已有安装(存在 x-ui.json 或 x-ui.db保留所有配置不重新输入
local config_webBasePath="${existing_webBasePath}"
local existing_webDomain=$(echo "$settings_output" | grep '^webDomain:' | cut -d: -f2-)
if [[ ${#config_webBasePath} -lt 4 ]]; then
config_webBasePath=$(gen_random_string 18)
echo -e "${yellow}WebBasePath 缺失或过短,正在生成新的...${plain}"
${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}"
echo -e "${green}新 WebBasePath${config_webBasePath}${plain}"
fi
if [[ -n "${existing_cert}" ]]; then
echo -e "${green}SSL 证书已配置。${plain}"
fi
local final_host="${server_ip}"
if [[ -n "${existing_webDomain}" ]]; then
final_host="${existing_webDomain}"
fi
echo -e "${green}访问地址https://${final_host}:${existing_port}/${config_webBasePath}${plain}"
fi
if command -v timeout >/dev/null 2>&1; then
if ! timeout 30 ${xui_folder}/x-ui migrate; then
echo -e "${yellow}数据库迁移未在 30 秒内完成或执行失败,已跳过阻塞,安装继续。${plain}"
echo -e "${yellow}可在安装后手动执行:${xui_folder}/x-ui migrate${plain}"
fi
elif ! ${xui_folder}/x-ui migrate; then
echo -e "${yellow}数据库迁移执行失败,安装继续。${plain}"
echo -e "${yellow}可在安装后手动执行:${xui_folder}/x-ui migrate${plain}"
fi
}
get_releases() {
local releases_json
releases_json=$(curl -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases")
if [[ -z "$releases_json" ]]; then
echo -e "${yellow}正在尝试通过 IPv4 获取版本...${plain}"
releases_json=$(curl -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases")
if [[ -z "$releases_json" ]]; then
echo -e "${red}获取 x-ui 版本失败,可能是 GitHub API 限制,请稍后重试${plain}"
exit 1
fi
fi
# Parse first non-prerelease tag_name
latest_stable=$(echo "$releases_json" | tr '{' '\n' | grep -B5 '"prerelease": false' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
# Parse first prerelease tag_name
latest_prerelease=$(echo "$releases_json" | tr '{' '\n' | grep -B5 '"prerelease": true' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
if [[ -z "$latest_stable" && -z "$latest_prerelease" ]]; then
echo -e "${red}获取 x-ui 版本失败${plain}"
exit 1
fi
}
select_version() {
if [[ -n "$latest_stable" && -n "$latest_prerelease" ]]; then
echo ""
echo -e "${green}请选择要安装的版本:${plain}"
echo -e "${green}1)${plain} 最新稳定版: ${latest_stable}"
echo -e "${green}2)${plain} 最新预发布版: ${latest_prerelease}"
read -rp "请输入选择 [1-2]: " version_choice
while [[ "$version_choice" != "1" && "$version_choice" != "2" ]]; do
read -rp "无效输入,请重新输入 [1-2]: " version_choice
done
if [[ "$version_choice" == "1" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
elif [[ -n "$latest_stable" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
}
install_x-ui() {
cd ${xui_folder%/x-ui}/
# 下载资源
if [ $# == 0 ]; then
get_releases
select_version
echo -e "获取到 x-ui 版本:${tag_version},开始安装..."
curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/Sora39831/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
if [[ $? -ne 0 ]]; then
echo -e "${red}下载 x-ui 失败,请确保服务器可以访问 GitHub${plain}"
exit 1
fi
else
tag_version=$1
tag_version_numeric=${tag_version#v}
min_version="2.3.5"
if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
echo -e "${red}请使用更新的版本(至少 v2.3.5)。安装已取消。${plain}"
exit 1
fi
url="https://github.com/Sora39831/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
echo -e "开始安装 x-ui $1"
curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz ${url}
if [[ $? -ne 0 ]]; then
echo -e "${red}下载 x-ui $1 失败,请检查版本是否存在${plain}"
exit 1
fi
fi
curl -4fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/Sora39831/3x-ui/main/x-ui.sh
if [[ $? -ne 0 ]]; then
echo -e "${red}下载 x-ui.sh 失败${plain}"
exit 1
fi
# 停止 x-ui 服务并删除旧资源
if [[ -e "${xui_folder}/" ]]; then
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui
fi
if ! is_safe_install_path "${xui_folder}"; then
echo -e "${red}拒绝删除危险安装目录:${xui_folder}${plain}"
exit 1
fi
rm -rf "${xui_folder}/"
fi
# 解压资源并设置权限
tar zxvf x-ui-linux-$(arch).tar.gz
rm x-ui-linux-$(arch).tar.gz -f
cd x-ui
chmod +x x-ui
chmod +x x-ui.sh
# 检查系统架构并重命名文件
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
mv bin/xray-linux-$(arch) bin/xray-linux-arm
chmod +x bin/xray-linux-arm
fi
chmod +x x-ui bin/xray-linux-$(arch)
# 更新 x-ui 命令行工具并设置权限
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
chmod +x /usr/bin/x-ui
mkdir -p /var/log/x-ui
config_after_install || exit 1
# Etckeeper 兼容性
if [ -d "/etc/.git" ]; then
if [ -f "/etc/.gitignore" ]; then
if ! grep -q "x-ui/x-ui.db" "/etc/.gitignore"; then
echo "" >> "/etc/.gitignore"
echo "x-ui/x-ui.db" >> "/etc/.gitignore"
echo -e "${green}已将 x-ui.db 添加到 /etc/.gitignore 以支持 etckeeper${plain}"
fi
else
echo "x-ui/x-ui.db" > "/etc/.gitignore"
echo -e "${green}已创建 /etc/.gitignore 并添加 x-ui.db 以支持 etckeeper${plain}"
fi
fi
if [[ $release == "alpine" ]]; then
curl -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/Sora39831/3x-ui/main/x-ui.rc
if [[ $? -ne 0 ]]; then
echo -e "${red}下载 x-ui.rc 失败${plain}"
exit 1
fi
chmod +x /etc/init.d/x-ui
rc-update add x-ui
rc-service x-ui start
else
# 安装 systemd 服务文件
service_installed=false
if [ -f "x-ui.service" ]; then
echo -e "${green}在解压文件中找到 x-ui.service正在安装...${plain}"
cp -f x-ui.service ${xui_service}/ >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
if [ "$service_installed" = false ]; then
case "${release}" in
ubuntu | debian | armbian)
if [ -f "x-ui.service.debian" ]; then
echo -e "${green}在解压文件中找到 x-ui.service.debian正在安装...${plain}"
cp -f x-ui.service.debian ${xui_service}/x-ui.service >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then
echo -e "${green}在解压文件中找到 x-ui.service.arch正在安装...${plain}"
cp -f x-ui.service.arch ${xui_service}/x-ui.service >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
*)
if [ -f "x-ui.service.rhel" ]; then
echo -e "${green}在解压文件中找到 x-ui.service.rhel正在安装...${plain}"
cp -f x-ui.service.rhel ${xui_service}/x-ui.service >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
esac
fi
# 如果 tar.gz 中未找到服务文件,从 GitHub 下载
if [ "$service_installed" = false ]; then
echo -e "${yellow}tar.gz 中未找到服务文件,正在从 GitHub 下载...${plain}"
case "${release}" in
ubuntu | debian | armbian)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/Sora39831/3x-ui/main/x-ui.service.debian >/dev/null 2>&1
;;
arch | manjaro | parch)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/Sora39831/3x-ui/main/x-ui.service.arch >/dev/null 2>&1
;;
*)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/Sora39831/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1
;;
esac
if [[ $? -ne 0 ]]; then
echo -e "${red}从 GitHub 安装 x-ui.service 失败${plain}"
exit 1
fi
service_installed=true
fi
if [ "$service_installed" = true ]; then
echo -e "${green}正在配置 systemd 服务...${plain}"
chown root:root ${xui_service}/x-ui.service >/dev/null 2>&1
chmod 644 ${xui_service}/x-ui.service >/dev/null 2>&1
systemctl daemon-reload
systemctl enable x-ui
systemctl restart x-ui
else
echo -e "${red}安装 x-ui.service 文件失败${plain}"
exit 1
fi
fi
echo -e "${green}x-ui ${tag_version}${plain} 安装完成,正在运行..."
echo -e ""
echo -e "┌───────────────────────────────────────────────────────┐
${blue}x-ui 管理菜单用法(子命令):${plain}
│ │
${blue}x-ui${plain} - 管理脚本 │
${blue}x-ui start${plain} - 启动 │
${blue}x-ui stop${plain} - 停止 │
${blue}x-ui restart${plain} - 重启 │
${blue}x-ui status${plain} - 查看状态 │
${blue}x-ui settings${plain} - 查看当前设置 │
${blue}x-ui enable${plain} - 设置开机自启 │
${blue}x-ui disable${plain} - 取消开机自启 │
${blue}x-ui log${plain} - 查看日志 │
${blue}x-ui banlog${plain} - 查看 Fail2ban 封禁日志 │
${blue}x-ui update${plain} - 更新 │
${blue}x-ui legacy${plain} - 安装旧版本 │
${blue}x-ui install${plain} - 安装 │
${blue}x-ui uninstall${plain} - 卸载 │
└───────────────────────────────────────────────────────┘"
}
echo -e "${green}正在执行...${plain}"
install_base
ensure_cron_running
install_x-ui $1