- install-master.sh/install-agent.sh: 二进制下载源由已封账号 aaaamoooo/3x-ui-diy 改指本仓 Gitea Releases (tag 沿用 v3.7.0-agent.1); README 同步撤下'暂不可用'标注 - install.sh/update.sh: 未被 fork 部署链引用, 整文件替换为中英双语短桩 (exit 1), 防旧习惯调用时静默装成/更新成上游原版 - x-ui.sh: 保留全部管理功能, 仅中和 install/update/update_dev/update_menu/ legacy_version/update_shell 的上游下载分支与 uninstall 打印的上游命令, 一律改为提示本仓 Gitea Releases 地址后退出该分支 - panel.go: 面板自更新运行时耦合处理 — panelUpdaterURL 与版本检查 API 由 MHSanaei GitHub 改指本仓 Gitea (raw update.sh + /api/v1 releases, 响应字段 tag_name/body/target_commitish 与原解析结构一致); 下载到的将是本仓 update.sh 短桩, 面板更新会安全失败而非覆盖成原版 - deploy/cloud-init: curl 上游 install.sh 改为本仓 install-master.sh (Gitea raw); 头部注明 XUI_* 非交互旋钮与 install-result.env 凭据回显在 fork 安装脚本下无等价物
3617 lines
133 KiB
Bash
3617 lines
133 KiB
Bash
#!/bin/bash
|
||
|
||
red='\033[0;31m'
|
||
green='\033[0;32m'
|
||
blue='\033[0;34m'
|
||
yellow='\033[0;33m'
|
||
plain='\033[0m'
|
||
|
||
#Add some basic function here
|
||
function LOGD() {
|
||
echo -e "${yellow}[DEG] $* ${plain}"
|
||
}
|
||
|
||
function LOGE() {
|
||
echo -e "${red}[ERR] $* ${plain}"
|
||
}
|
||
|
||
function LOGI() {
|
||
echo -e "${green}[INF] $* ${plain}"
|
||
}
|
||
|
||
# Port helpers: detect listener and owning process (best effort)
|
||
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 {exit 0} END {exit 1}'
|
||
return
|
||
fi
|
||
if command -v netstat > /dev/null 2>&1; then
|
||
netstat -lnt 2> /dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 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
|
||
}
|
||
|
||
# Simple helpers for domain/IP validation
|
||
is_ipv4() {
|
||
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1
|
||
}
|
||
is_ipv6() {
|
||
[[ "$1" =~ : ]] && return 0 || return 1
|
||
}
|
||
is_ip() {
|
||
is_ipv4 "$1" || is_ipv6 "$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
|
||
}
|
||
|
||
# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it
|
||
# v6-only, which breaks HTTP-01 validation when the domain's A record points
|
||
# at this host's IPv4 (#4994). Only force IPv6 when the host has no global
|
||
# IPv4 address at all.
|
||
acme_listen_flag() {
|
||
if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then
|
||
echo ""
|
||
else
|
||
echo "--listen-v6"
|
||
fi
|
||
}
|
||
|
||
# check root
|
||
[[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1
|
||
|
||
# Check OS and set release variable
|
||
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 "Failed to check the system OS, please contact the author!" >&2
|
||
exit 1
|
||
fi
|
||
echo "The OS release is: $release"
|
||
|
||
os_version=""
|
||
os_version=$(grep "^VERSION_ID" /etc/os-release | cut -d '=' -f2 | tr -d '"' | tr -d '.')
|
||
|
||
running_in_docker="false"
|
||
if [[ -f /.dockerenv ]] || [[ "${XUI_IN_DOCKER}" == "true" ]]; then
|
||
running_in_docker="true"
|
||
fi
|
||
|
||
# Declare Variables
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
xui_folder="${XUI_MAIN_FOLDER:=/app}"
|
||
else
|
||
xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
|
||
fi
|
||
xui_service="${XUI_SERVICE:=/etc/systemd/system}"
|
||
log_folder="${XUI_LOG_FOLDER:=/var/log/x-ui}"
|
||
mkdir -p "${log_folder}"
|
||
iplimit_log_path="${log_folder}/3xipl.log"
|
||
iplimit_banned_log_path="${log_folder}/3xipl-banned.log"
|
||
|
||
confirm() {
|
||
if [[ $# > 1 ]]; then
|
||
echo && read -rp "$1 [Default $2]: " temp
|
||
if [[ "${temp}" == "" ]]; then
|
||
temp=$2
|
||
fi
|
||
else
|
||
read -rp "$1 [y/n]: " temp
|
||
fi
|
||
if [[ "${temp}" == "y" || "${temp}" == "Y" ]]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
confirm_restart() {
|
||
confirm "Restart the panel, Attention: Restarting the panel will also restart xray" "y"
|
||
if [[ $? == 0 ]]; then
|
||
restart
|
||
else
|
||
show_menu
|
||
fi
|
||
}
|
||
|
||
before_show_menu() {
|
||
echo && echo -n -e "${yellow}Press enter to return to the main menu: ${plain}" && read -r temp
|
||
show_menu
|
||
}
|
||
|
||
install() {
|
||
# Fork build (3x-ui-agent): the upstream one-click installer is disabled —
|
||
# it would install the stock panel over this fork.
|
||
# 本 fork 已禁用上游一键安装(会装成原版)。请从本仓 Releases 手动下载部署:
|
||
echo -e "${yellow}This fork does not provide the upstream one-click installer (it installs the stock panel).${plain}"
|
||
echo -e "${yellow}Please download the binary manually from this fork's Releases:${plain}"
|
||
echo -e "${green}https://git.881218.xyz/rogue/3x-ui-agent/releases${plain}"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
update() {
|
||
confirm "This function will update all x-ui components to the latest version, and the data will not be lost. Do you want to continue?" "y"
|
||
if [[ $? != 0 ]]; then
|
||
LOGE "Cancelled"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
# Fork build (3x-ui-agent): the upstream updater is disabled — it would
|
||
# overwrite this fork with the stock panel.
|
||
# 本 fork 已禁用上游更新(会覆盖成原版)。请手动从本仓 Releases 下载新版二进制替换:
|
||
echo -e "${yellow}This fork cannot be updated via the upstream updater (it would overwrite the fork with the stock panel).${plain}"
|
||
echo -e "${yellow}Please download the new binary manually from this fork's Releases and replace it:${plain}"
|
||
echo -e "${green}https://git.881218.xyz/rogue/3x-ui-agent/releases${plain}"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
update_dev() {
|
||
confirm "This will update x-ui to the latest DEV commit (the rolling 'dev-latest' build, not a stable release). Your data is preserved. Continue?" "y"
|
||
if [[ $? != 0 ]]; then
|
||
LOGE "Cancelled"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
# Fork build (3x-ui-agent): there is no upstream dev channel for this
|
||
# fork, and the upstream updater would overwrite it with the stock panel.
|
||
# 本 fork 无上游 dev 更新通道。请手动从本仓 Releases 下载新版二进制替换:
|
||
echo -e "${yellow}This fork has no upstream dev-channel update (the upstream updater would overwrite the fork).${plain}"
|
||
echo -e "${yellow}Please download the new binary manually from this fork's Releases and replace it:${plain}"
|
||
echo -e "${green}https://git.881218.xyz/rogue/3x-ui-agent/releases${plain}"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
replace_xui_script() {
|
||
local url="$1"
|
||
local use_if_modified_since="$2"
|
||
local temp_file="/usr/bin/x-ui-temp.$$"
|
||
|
||
rm -f "$temp_file"
|
||
if [[ "$use_if_modified_since" == "true" ]]; then
|
||
curl -fLRo "$temp_file" -z /usr/bin/x-ui "$url"
|
||
else
|
||
curl -fLRo "$temp_file" "$url"
|
||
fi
|
||
if [[ $? != 0 ]]; then
|
||
rm -f "$temp_file"
|
||
return 1
|
||
fi
|
||
|
||
if [[ ! -s "$temp_file" ]]; then
|
||
rm -f "$temp_file"
|
||
# -z above means "not modified since /usr/bin/x-ui" rather than a
|
||
# real failure, so an empty download here is success, not an error.
|
||
[[ "$use_if_modified_since" == "true" ]] && return 0
|
||
return 1
|
||
fi
|
||
|
||
mv -f "$temp_file" /usr/bin/x-ui
|
||
if [[ $? != 0 ]]; then
|
||
rm -f "$temp_file"
|
||
return 1
|
||
fi
|
||
# The move already landed the new script; a transient chmod failure here
|
||
# shouldn't make callers think the whole replace failed.
|
||
chmod +x /usr/bin/x-ui
|
||
return 0
|
||
}
|
||
|
||
update_menu() {
|
||
echo -e "${yellow}Updating Menu${plain}"
|
||
confirm "This function will update the menu to the latest changes." "y"
|
||
if [[ $? != 0 ]]; then
|
||
LOGE "Cancelled"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# Fork build (3x-ui-agent): the menu script is maintained in this fork's
|
||
# own repository; replacing it from upstream is disabled.
|
||
# 本 fork 的菜单脚本随本仓维护,已禁用从上游自替换:
|
||
echo -e "${yellow}This fork's menu script is maintained in its own repository; upstream self-update is disabled.${plain}"
|
||
echo -e "${green}https://git.881218.xyz/rogue/3x-ui-agent${plain}"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
legacy_version() {
|
||
# Fork build (3x-ui-agent): installing upstream legacy versions is
|
||
# disabled — it would replace this fork with the stock panel.
|
||
# 本 fork 已禁用安装上游旧版本(会装成原版)。本 fork 的可用构建见 Releases:
|
||
echo -e "${yellow}Installing upstream legacy versions is disabled in this fork.${plain}"
|
||
echo -e "${yellow}For available builds of this fork, see its Releases:${plain}"
|
||
echo -e "${green}https://git.881218.xyz/rogue/3x-ui-agent/releases${plain}"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Function to handle the deletion of the script file
|
||
delete_script() {
|
||
rm "$0" # Remove the script file itself
|
||
exit 1
|
||
}
|
||
|
||
xui_env_file_path() {
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
echo "/etc/default/x-ui"
|
||
;;
|
||
arch | manjaro | parch | alpine)
|
||
echo "/etc/conf.d/x-ui"
|
||
;;
|
||
*)
|
||
echo "/etc/sysconfig/x-ui"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
uninstall() {
|
||
confirm "Are you sure you want to uninstall the panel? xray will also uninstalled!" "n"
|
||
if [[ $? != 0 ]]; then
|
||
if [[ $# == 0 ]]; then
|
||
show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service x-ui stop
|
||
rc-update del x-ui
|
||
rm /etc/init.d/x-ui -f
|
||
else
|
||
systemctl stop x-ui
|
||
systemctl disable x-ui
|
||
rm ${xui_service}/x-ui.service -f
|
||
systemctl daemon-reload
|
||
systemctl reset-failed
|
||
fi
|
||
|
||
local panel_used_postgres="false"
|
||
local db_env_file
|
||
db_env_file="$(xui_env_file_path)"
|
||
if [[ -r "$db_env_file" ]] && grep -q '^XUI_DB_TYPE=postgres' "$db_env_file"; then
|
||
panel_used_postgres="true"
|
||
fi
|
||
|
||
rm /etc/x-ui/ -rf
|
||
rm ${xui_folder}/ -rf
|
||
rm -f "$db_env_file"
|
||
|
||
if [[ "$panel_used_postgres" == "true" ]] && postgresql_installed; then
|
||
purge_postgresql
|
||
fi
|
||
|
||
echo ""
|
||
echo -e "Uninstalled Successfully.\n"
|
||
echo "If you need to install this fork again, download the binary manually from its Releases page:"
|
||
echo -e "${green}https://git.881218.xyz/rogue/3x-ui-agent/releases${plain}"
|
||
echo ""
|
||
# Trap the SIGTERM signal
|
||
trap delete_script SIGTERM
|
||
delete_script
|
||
}
|
||
|
||
reset_user() {
|
||
confirm "Are you sure to reset the username and password of the panel?" "n"
|
||
if [[ $? != 0 ]]; then
|
||
if [[ $# == 0 ]]; then
|
||
show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
read -rp "Please set the login username [default is a random username]: " config_account
|
||
[[ -z $config_account ]] && config_account=$(gen_random_string 10)
|
||
read -rp "Please set the login password [default is a random password]: " config_password
|
||
[[ -z $config_password ]] && config_password=$(gen_random_string 18)
|
||
|
||
read -rp "Do you want to disable currently configured two-factor authentication? (y/n): " twoFactorConfirm
|
||
if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then
|
||
${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" > /dev/null 2>&1
|
||
else
|
||
${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" -resetTwoFactor=true > /dev/null 2>&1
|
||
echo -e "Two factor authentication has been disabled."
|
||
fi
|
||
|
||
echo -e "Panel login username has been reset to: ${green} ${config_account} ${plain}"
|
||
echo -e "Panel login password has been reset to: ${green} ${config_password} ${plain}"
|
||
echo -e "${green} Please use the new login username and password to access the X-UI panel. Also remember them! ${plain}"
|
||
confirm_restart
|
||
}
|
||
|
||
gen_random_string() {
|
||
local length="$1"
|
||
openssl rand -base64 $((length * 2)) \
|
||
| tr -dc 'a-zA-Z0-9' \
|
||
| head -c "$length"
|
||
}
|
||
|
||
reset_webbasepath() {
|
||
echo -e "${yellow}Resetting Web Base Path${plain}"
|
||
|
||
read -rp "Are you sure you want to reset the web base path? (y/n): " confirm
|
||
if [[ $confirm != "y" && $confirm != "Y" ]]; then
|
||
echo -e "${yellow}Operation canceled.${plain}"
|
||
return
|
||
fi
|
||
|
||
config_webBasePath=$(gen_random_string 18)
|
||
|
||
# Apply the new web base path setting
|
||
${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" > /dev/null 2>&1
|
||
|
||
echo -e "Web base path has been reset to: ${green}${config_webBasePath}${plain}"
|
||
echo -e "${green}Please use the new web base path to access the panel.${plain}"
|
||
restart
|
||
}
|
||
|
||
reset_config() {
|
||
confirm "Are you sure you want to reset all panel settings, Account data will not be lost, Username and password will not change" "n"
|
||
if [[ $? != 0 ]]; then
|
||
if [[ $# == 0 ]]; then
|
||
show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
${xui_folder}/x-ui setting -reset
|
||
echo -e "All panel settings have been reset to default."
|
||
restart
|
||
}
|
||
|
||
check_config() {
|
||
local info=$(${xui_folder}/x-ui setting -show true)
|
||
if [[ $? != 0 ]]; then
|
||
LOGE "get current settings error, please check logs"
|
||
show_menu
|
||
return
|
||
fi
|
||
LOGI "${info}"
|
||
|
||
local db_env_file
|
||
db_env_file="$(xui_env_file_path)"
|
||
if [[ -r "$db_env_file" ]] && grep -q '^XUI_DB_TYPE=postgres' "$db_env_file"; then
|
||
local dsn
|
||
dsn="$(grep -E '^XUI_DB_DSN=' "$db_env_file" | head -1 | cut -d= -f2-)"
|
||
local dsn_safe
|
||
dsn_safe="$(echo "$dsn" | sed -E 's|(://[^:/@]+:)[^@]+@|\1****@|')"
|
||
echo -e "${green}Database: PostgreSQL — ${dsn_safe}${plain}"
|
||
else
|
||
echo -e "${green}Database: SQLite (/etc/x-ui/x-ui.db)${plain}"
|
||
fi
|
||
|
||
local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||
local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
|
||
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||
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" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
server_ip="${ip_result}"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ -z "$server_ip" ]]; then
|
||
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
|
||
while [[ -z "$server_ip" ]]; do
|
||
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||
server_ip="${server_ip// /}"
|
||
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
|
||
server_ip=""
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [[ -n "$existing_cert" ]]; then
|
||
local domain=$(basename "$(dirname "$existing_cert")")
|
||
# The cert folder name is only the certificate's first domain. A
|
||
# multidomain (SAN) certificate may be served under any name it covers,
|
||
# so read the real names from the certificate itself (#5070).
|
||
local cert_sans=""
|
||
if [[ -f "$existing_cert" ]] && command -v openssl > /dev/null 2>&1; then
|
||
cert_sans=$(openssl x509 -in "$existing_cert" -noout -ext subjectAltName 2> /dev/null \
|
||
| grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2)
|
||
if [[ -n "$cert_sans" ]] && ! echo "$cert_sans" | grep -qx "$domain"; then
|
||
domain=$(echo "$cert_sans" | head -n1)
|
||
fi
|
||
fi
|
||
|
||
if [[ "$domain" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||
echo -e "${green}Access URL: https://${domain}:${existing_port}${existing_webBasePath}${plain}"
|
||
else
|
||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||
fi
|
||
if [[ -n "$cert_sans" && $(echo "$cert_sans" | wc -l) -gt 1 ]]; then
|
||
echo -e "${yellow}The certificate also covers:${plain} $(echo "$cert_sans" | grep -vx "$domain" | tr '\n' ' ')"
|
||
fi
|
||
else
|
||
echo -e "${red}⚠ WARNING: No SSL certificate configured!${plain}"
|
||
echo -e "${yellow}You can get a Let's Encrypt certificate for your IP address (valid ~6 days, auto-renews).${plain}"
|
||
read -rp "Generate SSL certificate for IP now? [y/N]: " gen_ssl
|
||
if [[ "$gen_ssl" == "y" || "$gen_ssl" == "Y" ]]; then
|
||
stop 0 > /dev/null 2>&1
|
||
ssl_cert_issue_for_ip
|
||
if [[ $? -eq 0 ]]; then
|
||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||
# ssl_cert_issue_for_ip already restarts the panel, but ensure it's running
|
||
start 0 > /dev/null 2>&1
|
||
else
|
||
LOGE "IP certificate setup failed."
|
||
echo -e "${yellow}You can try again via main menu option 20 (SSL Certificate Management).${plain}"
|
||
start 0 > /dev/null 2>&1
|
||
fi
|
||
else
|
||
echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||
echo -e "${yellow}For security, please configure SSL certificate using main menu option 20 (SSL Certificate Management)${plain}"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
set_port() {
|
||
echo -n "Enter port number[1-65535]: "
|
||
read -r port
|
||
if [[ -z "${port}" ]]; then
|
||
LOGD "Cancelled"
|
||
before_show_menu
|
||
else
|
||
${xui_folder}/x-ui setting -port ${port}
|
||
echo -e "The port is set, Please restart the panel now, and use the new port ${green}${port}${plain} to access web panel"
|
||
confirm_restart
|
||
fi
|
||
}
|
||
|
||
start() {
|
||
check_status
|
||
if [[ $? == 0 ]]; then
|
||
echo ""
|
||
LOGI "Panel is running, No need to start again, If you need to restart, please select restart"
|
||
else
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
LOGE "Panel process is not running inside this container."
|
||
LOGI "In Docker the panel is the container's main process. Restart the container to bring it back up:"
|
||
LOGI " docker restart <container_name>"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service x-ui start
|
||
else
|
||
systemctl start x-ui
|
||
fi
|
||
sleep 2
|
||
check_status
|
||
if [[ $? == 0 ]]; then
|
||
LOGI "x-ui Started Successfully"
|
||
else
|
||
LOGE "panel Failed to start, Probably because it takes longer than two seconds to start, Please check the log information later"
|
||
fi
|
||
fi
|
||
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
}
|
||
|
||
stop() {
|
||
check_status
|
||
if [[ $? == 1 ]]; then
|
||
echo ""
|
||
LOGI "Panel stopped, No need to stop again!"
|
||
else
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
LOGI "In Docker the panel runs as the container's main process."
|
||
LOGI "To stop it, stop the container from the host:"
|
||
LOGI " docker stop <container_name>"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service x-ui stop
|
||
else
|
||
systemctl stop x-ui
|
||
fi
|
||
sleep 2
|
||
check_status
|
||
if [[ $? == 1 ]]; then
|
||
LOGI "x-ui and xray stopped successfully"
|
||
else
|
||
LOGE "Panel stop failed, Probably because the stop time exceeds two seconds, Please check the log information later"
|
||
fi
|
||
fi
|
||
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
}
|
||
|
||
restart() {
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
if signal_xui HUP; then
|
||
sleep 1
|
||
signal_xui USR1
|
||
LOGI "Restart signal sent to the panel and xray-core."
|
||
else
|
||
LOGE "Could not find the running panel process to signal."
|
||
fi
|
||
sleep 2
|
||
check_status
|
||
if [[ $? == 0 ]]; then
|
||
LOGI "x-ui and xray Restarted successfully"
|
||
else
|
||
LOGE "Panel restart failed, Please check the log information later"
|
||
fi
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service x-ui restart
|
||
else
|
||
systemctl restart x-ui
|
||
fi
|
||
sleep 2
|
||
check_status
|
||
if [[ $? == 0 ]]; then
|
||
LOGI "x-ui and xray Restarted successfully"
|
||
else
|
||
LOGE "Panel restart failed, Probably because it takes longer than two seconds to start, Please check the log information later"
|
||
fi
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
}
|
||
|
||
restart_xray() {
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
if signal_xui USR1; then
|
||
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
|
||
else
|
||
LOGE "Could not find the running panel process to signal."
|
||
fi
|
||
sleep 2
|
||
show_xray_status
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service x-ui reload
|
||
else
|
||
systemctl reload x-ui
|
||
fi
|
||
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
|
||
sleep 2
|
||
show_xray_status
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
}
|
||
|
||
status() {
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
show_status
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service x-ui status
|
||
else
|
||
systemctl status x-ui -l
|
||
fi
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
}
|
||
|
||
enable() {
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
LOGI "Autostart is controlled by the Docker restart policy (e.g. 'restart: unless-stopped' in docker-compose.yml)."
|
||
LOGI "There is no service to enable inside the container."
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-update add x-ui default
|
||
else
|
||
systemctl enable x-ui
|
||
fi
|
||
if [[ $? == 0 ]]; then
|
||
LOGI "x-ui Set to boot automatically on startup successfully"
|
||
else
|
||
LOGE "x-ui Failed to set Autostart"
|
||
fi
|
||
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
}
|
||
|
||
disable() {
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
LOGI "Autostart is controlled by the Docker restart policy (e.g. 'restart: unless-stopped' in docker-compose.yml)."
|
||
LOGI "Set 'restart: no' for the container on the host to disable autostart."
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 0
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-update del x-ui
|
||
else
|
||
systemctl disable x-ui
|
||
fi
|
||
if [[ $? == 0 ]]; then
|
||
LOGI "x-ui Autostart Cancelled successfully"
|
||
else
|
||
LOGE "x-ui Failed to cancel autostart"
|
||
fi
|
||
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
}
|
||
|
||
show_log() {
|
||
if [[ $release == "alpine" ]]; then
|
||
echo -e "${green}\t1.${plain} Debug Log"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " choice
|
||
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
grep -F 'x-ui[' /var/log/messages
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
show_log
|
||
;;
|
||
esac
|
||
else
|
||
echo -e "${green}\t1.${plain} Debug Log"
|
||
echo -e "${green}\t2.${plain} Clear All logs"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " choice
|
||
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
journalctl -u x-ui -e --no-pager -f -p debug
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
;;
|
||
2)
|
||
sudo journalctl --rotate
|
||
sudo journalctl --vacuum-time=1s
|
||
echo "All Logs cleared."
|
||
restart
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
show_log
|
||
;;
|
||
esac
|
||
fi
|
||
}
|
||
|
||
bbr_menu() {
|
||
echo -e "${green}\t1.${plain} Enable BBR"
|
||
echo -e "${green}\t2.${plain} Disable BBR"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " choice
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
enable_bbr
|
||
bbr_menu
|
||
;;
|
||
2)
|
||
disable_bbr
|
||
bbr_menu
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
bbr_menu
|
||
;;
|
||
esac
|
||
}
|
||
|
||
disable_bbr() {
|
||
|
||
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) != "bbr" ]] || [[ ! $(sysctl -n net.core.default_qdisc) =~ ^(fq|cake)$ ]]; then
|
||
echo -e "${yellow}BBR is not currently enabled.${plain}"
|
||
before_show_menu
|
||
fi
|
||
|
||
if [ -f "/etc/sysctl.d/99-bbr-x-ui.conf" ]; then
|
||
old_settings=$(head -1 /etc/sysctl.d/99-bbr-x-ui.conf | tr -d '#')
|
||
# sysctl -w already restores the live values, so no `sysctl --system`
|
||
# afterwards — it would re-apply every sysctl file on the host and
|
||
# surface unrelated errors from the distro's own defaults (see issue #5160)
|
||
sysctl -w net.core.default_qdisc="${old_settings%:*}"
|
||
sysctl -w net.ipv4.tcp_congestion_control="${old_settings#*:}"
|
||
rm /etc/sysctl.d/99-bbr-x-ui.conf
|
||
else
|
||
# Replace BBR with CUBIC configurations
|
||
if [ -f "/etc/sysctl.conf" ]; then
|
||
sed -i 's/net.core.default_qdisc=fq/net.core.default_qdisc=pfifo_fast/' /etc/sysctl.conf
|
||
sed -i 's/net.ipv4.tcp_congestion_control=bbr/net.ipv4.tcp_congestion_control=cubic/' /etc/sysctl.conf
|
||
sysctl -p
|
||
fi
|
||
fi
|
||
|
||
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) != "bbr" ]]; then
|
||
echo -e "${green}BBR has been replaced with CUBIC successfully.${plain}"
|
||
else
|
||
echo -e "${red}Failed to replace BBR with CUBIC. Please check your system configuration.${plain}"
|
||
fi
|
||
}
|
||
|
||
enable_bbr() {
|
||
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" ]] && [[ $(sysctl -n net.core.default_qdisc) =~ ^(fq|cake)$ ]]; then
|
||
echo -e "${green}BBR is already enabled!${plain}"
|
||
before_show_menu
|
||
fi
|
||
|
||
# Enable BBR
|
||
if [ -d "/etc/sysctl.d/" ]; then
|
||
{
|
||
echo "#$(sysctl -n net.core.default_qdisc):$(sysctl -n net.ipv4.tcp_congestion_control)"
|
||
echo "net.core.default_qdisc = fq"
|
||
echo "net.ipv4.tcp_congestion_control = bbr"
|
||
} > "/etc/sysctl.d/99-bbr-x-ui.conf"
|
||
if [ -f "/etc/sysctl.conf" ]; then
|
||
# Backup old settings from sysctl.conf, if any
|
||
sed -i 's/^net.core.default_qdisc/# &/' /etc/sysctl.conf
|
||
sed -i 's/^net.ipv4.tcp_congestion_control/# &/' /etc/sysctl.conf
|
||
fi
|
||
# Apply only our config file; `sysctl --system` would re-apply every
|
||
# sysctl file on the host and surface unrelated errors from the distro's
|
||
# own defaults (see issue #5160)
|
||
sysctl -p /etc/sysctl.d/99-bbr-x-ui.conf
|
||
else
|
||
sed -i '/net.core.default_qdisc/d' /etc/sysctl.conf
|
||
sed -i '/net.ipv4.tcp_congestion_control/d' /etc/sysctl.conf
|
||
echo "net.core.default_qdisc=fq" | tee -a /etc/sysctl.conf
|
||
echo "net.ipv4.tcp_congestion_control=bbr" | tee -a /etc/sysctl.conf
|
||
sysctl -p
|
||
fi
|
||
|
||
# Verify that BBR is enabled
|
||
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" ]]; then
|
||
echo -e "${green}BBR has been enabled successfully.${plain}"
|
||
else
|
||
echo -e "${red}Failed to enable BBR. Please check your system configuration.${plain}"
|
||
fi
|
||
}
|
||
|
||
update_shell() {
|
||
# Fork build (3x-ui-agent): upstream self-update of this script is
|
||
# disabled; the script is maintained in this fork's own repository.
|
||
# 本 fork 已禁用脚本自更新(下载源为上游)。脚本随本仓维护:
|
||
echo -e "${yellow}Self-update is disabled in this fork; the script is maintained in its own repository:${plain}"
|
||
echo -e "${green}https://git.881218.xyz/rogue/3x-ui-agent${plain}"
|
||
before_show_menu
|
||
}
|
||
|
||
xui_pid() {
|
||
ps -ef 2> /dev/null | grep -F "${xui_folder}/x-ui" | grep -v grep | awk 'NR==1 {print $1}'
|
||
}
|
||
|
||
signal_xui() {
|
||
local sig="$1" pid
|
||
pid="$(xui_pid)"
|
||
if [[ -z "${pid}" ]]; then
|
||
return 1
|
||
fi
|
||
kill -"${sig}" "${pid}" 2> /dev/null
|
||
}
|
||
|
||
# 0: running, 1: not running, 2: not installed
|
||
check_status() {
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
if [[ ! -x "${xui_folder}/x-ui" ]]; then
|
||
return 2
|
||
fi
|
||
if [[ -n "$(xui_pid)" ]]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
fi
|
||
if [[ $release == "alpine" ]]; then
|
||
if [[ ! -f /etc/init.d/x-ui ]]; then
|
||
return 2
|
||
fi
|
||
if [[ $(rc-service x-ui status | grep -F 'status: started' -c) == 1 ]]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
else
|
||
if [[ ! -f ${xui_service}/x-ui.service ]]; then
|
||
return 2
|
||
fi
|
||
temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
|
||
if [[ "${temp}" == "running" ]]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
fi
|
||
}
|
||
|
||
check_enabled() {
|
||
if [[ $release == "alpine" ]]; then
|
||
if [[ $(rc-update show | grep -F 'x-ui' | grep default -c) == 1 ]]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
else
|
||
temp=$(systemctl is-enabled x-ui)
|
||
if [[ "${temp}" == "enabled" ]]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
fi
|
||
}
|
||
|
||
check_uninstall() {
|
||
check_status
|
||
if [[ $? != 2 ]]; then
|
||
echo ""
|
||
LOGE "Panel installed, Please do not reinstall"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 1
|
||
else
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
check_install() {
|
||
check_status
|
||
if [[ $? == 2 ]]; then
|
||
echo ""
|
||
LOGE "Please install the panel first"
|
||
if [[ $# == 0 ]]; then
|
||
before_show_menu
|
||
fi
|
||
return 1
|
||
else
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
show_status() {
|
||
check_status
|
||
case $? in
|
||
0)
|
||
echo -e "Panel state: ${green}Running${plain}"
|
||
show_enable_status
|
||
;;
|
||
1)
|
||
echo -e "Panel state: ${yellow}Not Running${plain}"
|
||
show_enable_status
|
||
;;
|
||
2)
|
||
echo -e "Panel state: ${red}Not Installed${plain}"
|
||
;;
|
||
esac
|
||
show_xray_status
|
||
show_mtproto_status
|
||
}
|
||
|
||
show_enable_status() {
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
echo -e "Start automatically: ${green}Managed by Docker${plain}"
|
||
return
|
||
fi
|
||
check_enabled
|
||
if [[ $? == 0 ]]; then
|
||
echo -e "Start automatically: ${green}Yes${plain}"
|
||
else
|
||
echo -e "Start automatically: ${red}No${plain}"
|
||
fi
|
||
}
|
||
|
||
check_xray_status() {
|
||
count=$(ps -ef | grep "xray-linux" | grep -v "grep" | wc -l)
|
||
if [[ count -ne 0 ]]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
show_xray_status() {
|
||
check_xray_status
|
||
if [[ $? == 0 ]]; then
|
||
echo -e "xray state: ${green}Running${plain}"
|
||
else
|
||
echo -e "xray state: ${red}Not Running${plain}"
|
||
fi
|
||
}
|
||
|
||
# show_mtproto_status reports each mtproto inbound's mtg sidecar (one process per
|
||
# inbound, run outside xray). Silent when no mtproto inbound is configured.
|
||
show_mtproto_status() {
|
||
local cfg_dir="${xui_folder}/bin/mtproto"
|
||
local cfgs=()
|
||
if [[ -d "${cfg_dir}" ]]; then
|
||
for f in "${cfg_dir}"/mtg-*.toml; do
|
||
[[ -e "$f" ]] && cfgs+=("$f")
|
||
done
|
||
fi
|
||
[[ ${#cfgs[@]} -eq 0 ]] && return
|
||
|
||
local running
|
||
running=$(ps -ef | grep "mtg-linux" | grep -v "grep" | grep -oE 'mtg-[0-9]+\.toml')
|
||
for f in "${cfgs[@]}"; do
|
||
local name id bind
|
||
name=$(basename "$f")
|
||
id=$(echo "${name}" | sed -E 's/mtg-([0-9]+)\.toml/\1/')
|
||
bind=$(grep -E '^[[:space:]]*bind-to' "$f" | head -1 | cut -d'"' -f2)
|
||
if echo "${running}" | grep -qx "${name}"; then
|
||
echo -e "mtproto inbound ${id} (${bind}): ${green}Running${plain}"
|
||
else
|
||
echo -e "mtproto inbound ${id} (${bind}): ${red}Not Running${plain}"
|
||
fi
|
||
done
|
||
}
|
||
|
||
firewall_menu() {
|
||
echo -e "${green}\t1.${plain} ${green}Install${plain} Firewall"
|
||
echo -e "${green}\t2.${plain} Port List [numbered]"
|
||
echo -e "${green}\t3.${plain} ${green}Open${plain} Ports"
|
||
echo -e "${green}\t4.${plain} ${red}Delete${plain} Ports from List"
|
||
echo -e "${green}\t5.${plain} ${green}Enable${plain} Firewall"
|
||
echo -e "${green}\t6.${plain} ${red}Disable${plain} Firewall"
|
||
echo -e "${green}\t7.${plain} Firewall Status"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " choice
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
install_firewall
|
||
firewall_menu
|
||
;;
|
||
2)
|
||
ufw status numbered
|
||
firewall_menu
|
||
;;
|
||
3)
|
||
open_ports
|
||
firewall_menu
|
||
;;
|
||
4)
|
||
delete_ports
|
||
firewall_menu
|
||
;;
|
||
5)
|
||
ufw enable
|
||
firewall_menu
|
||
;;
|
||
6)
|
||
ufw disable
|
||
firewall_menu
|
||
;;
|
||
7)
|
||
ufw status verbose
|
||
firewall_menu
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
firewall_menu
|
||
;;
|
||
esac
|
||
}
|
||
|
||
install_firewall() {
|
||
if ! command -v ufw &> /dev/null; then
|
||
echo "ufw firewall is not installed. Installing now..."
|
||
apt-get update
|
||
apt-get install -y ufw
|
||
else
|
||
echo "ufw firewall is already installed"
|
||
fi
|
||
|
||
# Check if the firewall is inactive
|
||
if ufw status | grep -q "Status: active"; then
|
||
echo "Firewall is already active"
|
||
else
|
||
echo "Activating firewall..."
|
||
# Open the necessary ports
|
||
ufw allow ssh
|
||
ufw allow http
|
||
ufw allow https
|
||
ufw allow 2053/tcp #webPort
|
||
ufw allow 2096/tcp #subport
|
||
|
||
# Enable the firewall
|
||
ufw --force enable
|
||
fi
|
||
}
|
||
|
||
open_ports() {
|
||
# Prompt the user to enter the ports they want to open
|
||
read -rp "Enter the ports you want to open (e.g. 80,443,2053 or range 400-500): " ports
|
||
|
||
# Check if the input is valid
|
||
if ! [[ $ports =~ ^([0-9]+|[0-9]+-[0-9]+)(,([0-9]+|[0-9]+-[0-9]+))*$ ]]; then
|
||
echo "Error: Invalid input. Please enter a comma-separated list of ports or a range of ports (e.g. 80,443,2053 or 400-500)." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Open the specified ports using ufw
|
||
IFS=',' read -ra PORT_LIST <<< "$ports"
|
||
for port in "${PORT_LIST[@]}"; do
|
||
if [[ $port == *-* ]]; then
|
||
# Split the range into start and end ports
|
||
start_port=$(echo $port | cut -d'-' -f1)
|
||
end_port=$(echo $port | cut -d'-' -f2)
|
||
# Open the port range
|
||
ufw allow $start_port:$end_port/tcp
|
||
ufw allow $start_port:$end_port/udp
|
||
else
|
||
# Open the single port
|
||
ufw allow "$port"
|
||
fi
|
||
done
|
||
|
||
# Confirm that the ports are opened
|
||
echo "Opened the specified ports:"
|
||
for port in "${PORT_LIST[@]}"; do
|
||
if [[ $port == *-* ]]; then
|
||
start_port=$(echo $port | cut -d'-' -f1)
|
||
end_port=$(echo $port | cut -d'-' -f2)
|
||
# Check if the port range has been successfully opened
|
||
(ufw status | grep -q "$start_port:$end_port") && echo "$start_port-$end_port"
|
||
else
|
||
# Check if the individual port has been successfully opened
|
||
(ufw status | grep -q "$port") && echo "$port"
|
||
fi
|
||
done
|
||
}
|
||
|
||
delete_ports() {
|
||
# Display current rules with numbers
|
||
echo "Current UFW rules:"
|
||
ufw status numbered
|
||
|
||
# Ask the user how they want to delete rules
|
||
echo "Do you want to delete rules by:"
|
||
echo "1) Rule numbers"
|
||
echo "2) Ports"
|
||
read -rp "Enter your choice (1 or 2): " choice
|
||
|
||
if [[ $choice -eq 1 ]]; then
|
||
# Deleting by rule numbers
|
||
read -rp "Enter the rule numbers you want to delete (1, 2, etc.): " rule_numbers
|
||
|
||
# Validate the input
|
||
if ! [[ $rule_numbers =~ ^([0-9]+)(,[0-9]+)*$ ]]; then
|
||
echo "Error: Invalid input. Please enter a comma-separated list of rule numbers." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Split numbers into an array
|
||
IFS=',' read -ra RULE_NUMBERS <<< "$rule_numbers"
|
||
for rule_number in "${RULE_NUMBERS[@]}"; do
|
||
# Delete the rule by number
|
||
ufw delete "$rule_number" || echo "Failed to delete rule number $rule_number"
|
||
done
|
||
|
||
echo "Selected rules have been deleted."
|
||
|
||
elif [[ $choice -eq 2 ]]; then
|
||
# Deleting by ports
|
||
read -rp "Enter the ports you want to delete (e.g. 80,443,2053 or range 400-500): " ports
|
||
|
||
# Validate the input
|
||
if ! [[ $ports =~ ^([0-9]+|[0-9]+-[0-9]+)(,([0-9]+|[0-9]+-[0-9]+))*$ ]]; then
|
||
echo "Error: Invalid input. Please enter a comma-separated list of ports or a range of ports (e.g. 80,443,2053 or 400-500)." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Split ports into an array
|
||
IFS=',' read -ra PORT_LIST <<< "$ports"
|
||
for port in "${PORT_LIST[@]}"; do
|
||
if [[ $port == *-* ]]; then
|
||
# Split the port range
|
||
start_port=$(echo $port | cut -d'-' -f1)
|
||
end_port=$(echo $port | cut -d'-' -f2)
|
||
# Delete the port range
|
||
ufw delete allow $start_port:$end_port/tcp
|
||
ufw delete allow $start_port:$end_port/udp
|
||
else
|
||
# Delete a single port
|
||
ufw delete allow "$port"
|
||
fi
|
||
done
|
||
|
||
# Confirmation of deletion
|
||
echo "Deleted the specified ports:"
|
||
for port in "${PORT_LIST[@]}"; do
|
||
if [[ $port == *-* ]]; then
|
||
start_port=$(echo $port | cut -d'-' -f1)
|
||
end_port=$(echo $port | cut -d'-' -f2)
|
||
# Check if the port range has been deleted
|
||
(ufw status | grep -q "$start_port:$end_port") || echo "$start_port-$end_port"
|
||
else
|
||
# Check if the individual port has been deleted
|
||
(ufw status | grep -q "$port") || echo "$port"
|
||
fi
|
||
done
|
||
else
|
||
echo "${red}Error:${plain} Invalid choice. Please enter 1 or 2." >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
update_all_geofiles() {
|
||
local failed=0
|
||
update_geofiles "main" || failed=1
|
||
update_geofiles "IR" || failed=1
|
||
update_geofiles "RU" || failed=1
|
||
return $failed
|
||
}
|
||
|
||
update_geofiles() {
|
||
case "${1}" in
|
||
"main")
|
||
dat_files=(geoip geosite)
|
||
dat_source="Loyalsoldier/v2ray-rules-dat"
|
||
;;
|
||
"IR")
|
||
dat_files=(geoip_IR geosite_IR)
|
||
dat_source="chocolate4u/Iran-v2ray-rules"
|
||
;;
|
||
"RU")
|
||
dat_files=(geoip_RU geosite_RU)
|
||
dat_source="runetfreedom/russia-v2ray-rules-dat"
|
||
;;
|
||
*)
|
||
echo -e "${red}update_geofiles: unknown dataset '${1}'${plain}"
|
||
return 1
|
||
;;
|
||
esac
|
||
local failed=0 http_code
|
||
for dat in "${dat_files[@]}"; do
|
||
# Remove suffix for remote filename (e.g., geoip_IR -> geoip)
|
||
remote_file="${dat%%_*}"
|
||
local dest="${xui_folder}/bin/${dat}.dat"
|
||
local temp_file="${dest}.tmp.$$"
|
||
rm -f "$temp_file"
|
||
# -z (against the live file, not the temp file) skips the download
|
||
# (server answers 304) when the local copy is already current.
|
||
http_code=$(curl -sSfLRo "$temp_file" -z "$dest" -w '%{http_code}' \
|
||
https://github.com/${dat_source}/releases/latest/download/${remote_file}.dat)
|
||
if [[ $? -ne 0 ]]; then
|
||
echo -e "${red}${dat}.dat: download failed${plain}"
|
||
rm -f "$temp_file"
|
||
failed=1
|
||
elif [[ "$http_code" == "304" ]]; then
|
||
echo -e "${dat}.dat: already up to date"
|
||
rm -f "$temp_file"
|
||
elif [[ ! -s "$temp_file" ]]; then
|
||
echo -e "${red}${dat}.dat: downloaded file is empty${plain}"
|
||
rm -f "$temp_file"
|
||
failed=1
|
||
else
|
||
mv -f "$temp_file" "$dest"
|
||
if [[ $? -ne 0 ]]; then
|
||
echo -e "${red}${dat}.dat: failed to install${plain}"
|
||
rm -f "$temp_file"
|
||
failed=1
|
||
else
|
||
echo -e "${green}${dat}.dat: updated${plain}"
|
||
geo_updated=1
|
||
fi
|
||
fi
|
||
done
|
||
return $failed
|
||
}
|
||
|
||
run_geo_update() {
|
||
local name="$1"
|
||
shift
|
||
geo_updated=0
|
||
"$@"
|
||
if [[ $? -ne 0 ]]; then
|
||
echo -e "${red}Some ${name} could not be updated. Check the errors above.${plain}"
|
||
elif [[ $geo_updated -eq 1 ]]; then
|
||
echo -e "${green}${name} have been updated successfully!${plain}"
|
||
restart
|
||
else
|
||
echo -e "${green}${name} are already up to date, restart is not needed.${plain}"
|
||
fi
|
||
}
|
||
|
||
update_geo() {
|
||
echo -e "${green}\t1.${plain} Loyalsoldier (geoip.dat, geosite.dat)"
|
||
echo -e "${green}\t2.${plain} chocolate4u (geoip_IR.dat, geosite_IR.dat)"
|
||
echo -e "${green}\t3.${plain} runetfreedom (geoip_RU.dat, geosite_RU.dat)"
|
||
echo -e "${green}\t4.${plain} All"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " choice
|
||
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
run_geo_update "Loyalsoldier datasets" update_geofiles "main"
|
||
;;
|
||
2)
|
||
run_geo_update "chocolate4u datasets" update_geofiles "IR"
|
||
;;
|
||
3)
|
||
run_geo_update "runetfreedom datasets" update_geofiles "RU"
|
||
;;
|
||
4)
|
||
run_geo_update "geo files" update_all_geofiles
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
update_geo
|
||
;;
|
||
esac
|
||
|
||
before_show_menu
|
||
}
|
||
|
||
install_acme() {
|
||
# Check if acme.sh is already installed
|
||
if command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||
LOGI "acme.sh is already installed."
|
||
return 0
|
||
fi
|
||
|
||
LOGI "Installing acme.sh..."
|
||
cd ~ || return 1 # Ensure you can change to the home directory
|
||
|
||
curl -s https://get.acme.sh | sh
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Installation of acme.sh failed."
|
||
return 1
|
||
else
|
||
LOGI "Installation of acme.sh succeeded."
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
ssl_cert_issue_main() {
|
||
echo -e "${green}\t1.${plain} Get SSL (Domain)"
|
||
echo -e "${green}\t2.${plain} Revoke & Remove"
|
||
echo -e "${green}\t3.${plain} Force Renew"
|
||
echo -e "${green}\t4.${plain} Show Existing Domains"
|
||
echo -e "${green}\t5.${plain} Set Cert paths for the panel"
|
||
echo -e "${green}\t6.${plain} Get SSL for IP Address (6-day cert, auto-renews)"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
|
||
read -rp "Choose an option: " choice
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
ssl_cert_issue
|
||
ssl_cert_issue_main
|
||
;;
|
||
2)
|
||
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
||
if [ -z "$domains" ]; then
|
||
echo "No certificates found to revoke."
|
||
else
|
||
echo "Existing domains:"
|
||
echo "$domains"
|
||
read -rp "Please enter a domain from the list to revoke and remove the certificate: " domain
|
||
if echo "$domains" | grep -qw "$domain"; then
|
||
# The IP-cert flow (option 6) stores files under /root/cert/ip, but acme.sh
|
||
# tracks the cert under the actual IP address(es). Resolve those so renewal
|
||
# state is torn down too; otherwise the acme.sh cron re-creates the deleted cert.
|
||
local acme_ids="${domain}"
|
||
if [[ "${domain}" == "ip" ]]; then
|
||
acme_ids=$(~/.acme.sh/acme.sh --list 2> /dev/null | awk 'NR>1 {print $1}' | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$|:')
|
||
fi
|
||
for id in ${acme_ids}; do
|
||
# Best-effort revoke at the CA, then drop acme.sh renewal tracking.
|
||
~/.acme.sh/acme.sh --revoke -d "${id}" 2> /dev/null
|
||
~/.acme.sh/acme.sh --remove -d "${id}" 2> /dev/null
|
||
# --remove leaves the cert files on disk, so delete the state dirs (RSA + ECC).
|
||
rm -rf ~/.acme.sh/"${id}" ~/.acme.sh/"${id}_ecc"
|
||
done
|
||
# Delete the local certificate files for this domain.
|
||
rm -rf "/root/cert/${domain}"
|
||
LOGI "Certificate revoked and removed for domain: ${domain}"
|
||
|
||
# If the panel currently serves this domain's cert, clear the stored paths
|
||
# so it stops loading the now-deleted files, then restart.
|
||
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||
if [[ "${existing_cert}" == "/root/cert/${domain}/"* ]]; then
|
||
${xui_folder}/x-ui cert -reset
|
||
LOGI "Cleared panel certificate paths referencing ${domain}; restarting panel."
|
||
restart
|
||
fi
|
||
else
|
||
echo "Invalid domain entered."
|
||
fi
|
||
fi
|
||
ssl_cert_issue_main
|
||
;;
|
||
3)
|
||
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
||
if [ -z "$domains" ]; then
|
||
echo "No certificates found to renew."
|
||
else
|
||
echo "Existing domains:"
|
||
echo "$domains"
|
||
read -rp "Please enter a domain from the list to renew the SSL certificate: " domain
|
||
if echo "$domains" | grep -qw "$domain"; then
|
||
~/.acme.sh/acme.sh --renew -d ${domain} --force
|
||
LOGI "Certificate forcefully renewed for domain: $domain"
|
||
else
|
||
echo "Invalid domain entered."
|
||
fi
|
||
fi
|
||
ssl_cert_issue_main
|
||
;;
|
||
4)
|
||
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
||
if [ -z "$domains" ]; then
|
||
echo "No certificates found under /root/cert."
|
||
else
|
||
echo "Existing domains and their paths:"
|
||
for domain in $domains; do
|
||
local cert_path="/root/cert/${domain}/fullchain.pem"
|
||
local key_path="/root/cert/${domain}/privkey.pem"
|
||
if [[ -f "${cert_path}" && -f "${key_path}" ]]; then
|
||
echo -e "Domain: ${domain}"
|
||
echo -e "\tCertificate Path: ${cert_path}"
|
||
echo -e "\tPrivate Key Path: ${key_path}"
|
||
else
|
||
echo -e "Domain: ${domain} - Certificate or Key missing."
|
||
fi
|
||
done
|
||
fi
|
||
# The panel's configured certificate may live outside /root/cert
|
||
# (e.g. certbot under /etc/letsencrypt) — show it too (#5070).
|
||
local panel_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||
if [[ -n "${panel_cert}" && "${panel_cert}" != /root/cert/* ]]; then
|
||
echo -e "Panel certificate (custom path): ${panel_cert}"
|
||
if [[ -f "${panel_cert}" ]] && command -v openssl > /dev/null 2>&1; then
|
||
local panel_sans=$(openssl x509 -in "${panel_cert}" -noout -ext subjectAltName 2> /dev/null \
|
||
| grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2 | tr '\n' ' ')
|
||
[[ -n "${panel_sans}" ]] && echo -e "\tCovers: ${panel_sans}"
|
||
fi
|
||
fi
|
||
ssl_cert_issue_main
|
||
;;
|
||
5)
|
||
echo -e "${green}\t1.${plain} Use a certificate from /root/cert"
|
||
echo -e "${green}\t2.${plain} Enter custom certificate file paths (e.g. certbot, /etc/letsencrypt/...)"
|
||
read -rp "Choose an option: " pathChoice
|
||
if [[ "$pathChoice" == "2" ]]; then
|
||
read -rp "Certificate file path (fullchain): " webCertFile
|
||
read -rp "Private key file path: " webKeyFile
|
||
if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then
|
||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||
echo "Panel certificate paths set:"
|
||
echo " - Certificate File: $webCertFile"
|
||
echo " - Private Key File: $webKeyFile"
|
||
restart
|
||
else
|
||
echo "Certificate or private key file not found."
|
||
fi
|
||
ssl_cert_issue_main
|
||
return
|
||
fi
|
||
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
||
if [ -z "$domains" ]; then
|
||
echo "No certificates found."
|
||
else
|
||
echo "Available domains:"
|
||
echo "$domains"
|
||
read -rp "Please choose a domain to set the panel paths: " domain
|
||
|
||
if echo "$domains" | grep -qw "$domain"; 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"
|
||
echo "Panel paths set for domain: $domain"
|
||
echo " - Certificate File: $webCertFile"
|
||
echo " - Private Key File: $webKeyFile"
|
||
# Register the acme.sh install-cert hook so auto-renewal copies the
|
||
# renewed cert to these paths and reloads the panel. Without it acme.sh
|
||
# renews but never updates /root/cert, silently serving a stale cert.
|
||
if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||
~/.acme.sh/acme.sh --installcert --force -d "${domain}" \
|
||
--key-file "${webKeyFile}" \
|
||
--fullchain-file "${webCertFile}" \
|
||
--reloadcmd "x-ui restart" 2>&1 || true
|
||
echo "Registered acme.sh auto-renewal hook for ${domain}."
|
||
fi
|
||
restart
|
||
else
|
||
echo "Certificate or private key not found for domain: $domain."
|
||
fi
|
||
else
|
||
echo "Invalid domain entered."
|
||
fi
|
||
fi
|
||
ssl_cert_issue_main
|
||
;;
|
||
6)
|
||
echo -e "${yellow}Let's Encrypt SSL Certificate for IP Address${plain}"
|
||
echo -e "This will obtain a certificate for your server's IP using the shortlived profile."
|
||
echo -e "${yellow}Certificate valid for ~6 days, auto-renews via acme.sh cron job.${plain}"
|
||
echo -e "${yellow}Port 80 must be open and accessible from the internet.${plain}"
|
||
confirm "Do you want to proceed?" "y"
|
||
if [[ $? == 0 ]]; then
|
||
ssl_cert_issue_for_ip
|
||
fi
|
||
ssl_cert_issue_main
|
||
;;
|
||
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
ssl_cert_issue_main
|
||
;;
|
||
esac
|
||
}
|
||
|
||
ssl_cert_issue_for_ip() {
|
||
LOGI "Starting automatic SSL certificate generation for server IP..."
|
||
LOGI "Using Let's Encrypt shortlived profile (~6 days validity, auto-renews)"
|
||
|
||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||
|
||
# Get server IP
|
||
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" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
server_ip="${ip_result}"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ -n "$server_ip" ]]; then
|
||
LOGI "Server IP detected: ${server_ip}"
|
||
if ! confirm "Is ${server_ip} the correct incoming public IPv4 address for this server?" "y"; then
|
||
server_ip=""
|
||
fi
|
||
else
|
||
LOGI "Could not auto-detect server IP from any provider."
|
||
fi
|
||
|
||
while [[ -z "$server_ip" ]]; do
|
||
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||
server_ip="${server_ip// /}"
|
||
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
LOGE "Invalid IPv4 address. Please try again."
|
||
server_ip=""
|
||
fi
|
||
done
|
||
|
||
LOGI "Issuing certificate for server IP: ${server_ip}"
|
||
|
||
# Ask for optional IPv6
|
||
local ipv6_addr=""
|
||
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
|
||
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
|
||
|
||
# check for acme.sh first
|
||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||
LOGI "acme.sh not found, installing..."
|
||
install_acme
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Failed to install acme.sh"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
# install socat
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
apt-get update > /dev/null 2>&1 && apt-get install socat -y > /dev/null 2>&1
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||
dnf makecache -y > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
||
;;
|
||
centos)
|
||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||
yum makecache -y > /dev/null 2>&1 && yum -y install socat > /dev/null 2>&1
|
||
else
|
||
dnf makecache -y > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
||
fi
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Sy --noconfirm socat > /dev/null 2>&1
|
||
;;
|
||
opensuse-tumbleweed | opensuse-leap)
|
||
zypper refresh > /dev/null 2>&1 && zypper -q install -y socat > /dev/null 2>&1
|
||
;;
|
||
alpine)
|
||
apk add socat curl openssl > /dev/null 2>&1
|
||
;;
|
||
*)
|
||
LOGW "Unsupported OS for automatic socat installation"
|
||
;;
|
||
esac
|
||
|
||
# Create certificate directory
|
||
certPath="/root/cert/ip"
|
||
mkdir -p "$certPath"
|
||
|
||
# Build domain arguments
|
||
local domain_args="-d ${server_ip}"
|
||
if [[ -n "$ipv6_addr" ]] && is_ipv6 "$ipv6_addr"; then
|
||
domain_args="${domain_args} -d ${ipv6_addr}"
|
||
LOGI "Including IPv6 address: ${ipv6_addr}"
|
||
fi
|
||
|
||
# Choose port for HTTP-01 listener (default 80, allow override)
|
||
local WebPort=""
|
||
read -rp "Port to use for ACME HTTP-01 listener (default 80): " WebPort
|
||
WebPort="${WebPort:-80}"
|
||
if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then
|
||
LOGE "Invalid port provided. Falling back to 80."
|
||
WebPort=80
|
||
fi
|
||
LOGI "Using port ${WebPort} to issue certificate for IP: ${server_ip}"
|
||
if [[ "${WebPort}" -ne 80 ]]; then
|
||
LOGI "Reminder: Let's Encrypt still reaches port 80; forward external port 80 to ${WebPort} for validation."
|
||
fi
|
||
|
||
while true; do
|
||
if is_port_in_use "${WebPort}"; then
|
||
LOGI "Port ${WebPort} is currently in use."
|
||
|
||
local alt_port=""
|
||
read -rp "Enter another port for acme.sh standalone listener (leave empty to abort): " alt_port
|
||
alt_port="${alt_port// /}"
|
||
if [[ -z "${alt_port}" ]]; then
|
||
LOGE "Port ${WebPort} is busy; cannot proceed with issuance."
|
||
return 1
|
||
fi
|
||
if ! [[ "${alt_port}" =~ ^[0-9]+$ ]] || ((alt_port < 1 || alt_port > 65535)); then
|
||
LOGE "Invalid port provided."
|
||
return 1
|
||
fi
|
||
WebPort="${alt_port}"
|
||
continue
|
||
else
|
||
LOGI "Port ${WebPort} is free and ready for standalone validation."
|
||
break
|
||
fi
|
||
done
|
||
|
||
# Reload command - restarts panel after renewal
|
||
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null"
|
||
|
||
# issue the certificate for IP with shortlived profile
|
||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||
~/.acme.sh/acme.sh --issue \
|
||
${domain_args} \
|
||
--standalone \
|
||
--server letsencrypt \
|
||
--certificate-profile shortlived \
|
||
--days 6 \
|
||
--httpport ${WebPort} \
|
||
--force
|
||
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Failed to issue certificate for IP: ${server_ip}"
|
||
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
|
||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
||
rm -rf ${certPath} 2> /dev/null
|
||
return 1
|
||
else
|
||
LOGI "Certificate issued successfully for IP: ${server_ip}"
|
||
fi
|
||
|
||
# Install the certificate
|
||
# Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
|
||
# but the cert files are still installed. We check for files instead of exit code.
|
||
~/.acme.sh/acme.sh --installcert --force -d ${server_ip} \
|
||
--key-file "${certPath}/privkey.pem" \
|
||
--fullchain-file "${certPath}/fullchain.pem" \
|
||
--reloadcmd "${reloadCmd}" 2>&1 || true
|
||
|
||
# Verify certificate files exist (don't rely on exit code - reloadcmd failure causes non-zero)
|
||
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
|
||
LOGE "Certificate files not found after installation"
|
||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
||
rm -rf ${certPath} 2> /dev/null
|
||
return 1
|
||
fi
|
||
|
||
LOGI "Certificate files installed successfully"
|
||
|
||
# enable auto-renew
|
||
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
|
||
chmod 600 $certPath/privkey.pem 2> /dev/null
|
||
chmod 644 $certPath/fullchain.pem 2> /dev/null
|
||
|
||
# Prompt user to set panel paths after successful certificate installation
|
||
local webCertFile="${certPath}/fullchain.pem"
|
||
local webKeyFile="${certPath}/privkey.pem"
|
||
|
||
read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
|
||
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
|
||
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||
LOGI "Panel paths set for IP: $server_ip"
|
||
LOGI " - Certificate File: $webCertFile"
|
||
LOGI " - Private Key File: $webKeyFile"
|
||
LOGI " - Validity: ~6 days (auto-renews via acme.sh cron)"
|
||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||
LOGI "Panel will restart to apply SSL certificate..."
|
||
restart
|
||
else
|
||
LOGE "Error: Certificate or private key file not found for IP: $server_ip."
|
||
return 1
|
||
fi
|
||
else
|
||
LOGI "Skipping panel path setting."
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
ssl_cert_issue() {
|
||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||
# check for acme.sh first
|
||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||
echo "acme.sh could not be found. we will install it"
|
||
install_acme
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "install acme failed, please check logs"
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
# install socat
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
apt-get update > /dev/null 2>&1 && apt-get install socat -y > /dev/null 2>&1
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||
dnf makecache -y > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
||
;;
|
||
centos)
|
||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||
yum makecache -y > /dev/null 2>&1 && yum -y install socat > /dev/null 2>&1
|
||
else
|
||
dnf makecache -y > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
||
fi
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Sy --noconfirm socat > /dev/null 2>&1
|
||
;;
|
||
opensuse-tumbleweed | opensuse-leap)
|
||
zypper refresh > /dev/null 2>&1 && zypper -q install -y socat > /dev/null 2>&1
|
||
;;
|
||
alpine)
|
||
apk add socat curl openssl > /dev/null 2>&1
|
||
;;
|
||
*)
|
||
LOGW "Unsupported OS for automatic socat installation"
|
||
;;
|
||
esac
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "install socat failed, please check logs"
|
||
exit 1
|
||
else
|
||
LOGI "install socat succeed..."
|
||
fi
|
||
|
||
# get the domain here, and we need to verify it
|
||
local domain=""
|
||
while true; do
|
||
read -rp "Please enter your domain name: " domain
|
||
domain="${domain// /}" # Trim whitespace
|
||
|
||
if [[ -z "$domain" ]]; then
|
||
LOGE "Domain name cannot be empty. Please try again."
|
||
continue
|
||
fi
|
||
|
||
if ! is_domain "$domain"; then
|
||
LOGE "Invalid domain format: ${domain}. Please enter a valid domain name."
|
||
continue
|
||
fi
|
||
|
||
break
|
||
done
|
||
LOGD "Your domain is: ${domain}, checking it..."
|
||
SSL_ISSUED_DOMAIN="${domain}"
|
||
|
||
# detect existing certificate and reuse it only if its files are actually
|
||
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
|
||
# certs under ${domain}; a failed issuance can leave a domain entry in --list
|
||
# with no usable cert files, which must not be reused (it produces a 0-byte
|
||
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
|
||
local cert_exists=0
|
||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||
local acmeCertDir=""
|
||
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
|
||
acmeCertDir=~/.acme.sh/${domain}_ecc
|
||
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
|
||
acmeCertDir=~/.acme.sh/${domain}
|
||
fi
|
||
if [[ -n "${acmeCertDir}" ]]; then
|
||
cert_exists=1
|
||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||
LOGI "Existing certificate found for ${domain}, will reuse it."
|
||
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
||
else
|
||
LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
|
||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||
fi
|
||
fi
|
||
if [[ ${cert_exists} -eq 0 ]]; then
|
||
LOGI "Your domain is ready for issuing certificates now..."
|
||
fi
|
||
|
||
# create a directory for the certificate
|
||
certPath="/root/cert/${domain}"
|
||
if [ ! -d "$certPath" ]; then
|
||
mkdir -p "$certPath"
|
||
else
|
||
rm -rf "$certPath"
|
||
mkdir -p "$certPath"
|
||
fi
|
||
|
||
# get the port number for the standalone server
|
||
local WebPort=80
|
||
read -rp "Please choose which port to use (default is 80): " WebPort
|
||
if [[ -z ${WebPort} ]]; then
|
||
WebPort=80
|
||
elif [[ ! ${WebPort} =~ ^[1-9][0-9]*$ || ${WebPort} -gt 65535 ]]; then
|
||
LOGE "Your input ${WebPort} is invalid, will use default port 80."
|
||
WebPort=80
|
||
fi
|
||
LOGI "Will use port: ${WebPort} to issue certificates. Please make sure this port is open."
|
||
|
||
if [[ ${cert_exists} -eq 0 ]]; then
|
||
# issue the certificate
|
||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||
~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Issuing certificate failed, please check logs."
|
||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||
exit 1
|
||
else
|
||
LOGE "Issuing certificate succeeded, installing certificates..."
|
||
fi
|
||
else
|
||
LOGI "Using existing certificate, installing certificates..."
|
||
fi
|
||
|
||
reloadCmd="x-ui restart"
|
||
|
||
LOGI "Default --reloadcmd for ACME is: ${yellow}x-ui restart"
|
||
LOGI "This command will run on every certificate issue and renew."
|
||
read -rp "Would you like to modify --reloadcmd for ACME? (y/n): " setReloadcmd
|
||
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
|
||
echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; x-ui restart"
|
||
echo -e "${green}\t2.${plain} Input your own command"
|
||
echo -e "${green}\t0.${plain} Keep default reloadcmd"
|
||
read -rp "Choose an option: " choice
|
||
case "$choice" in
|
||
1)
|
||
LOGI "Reloadcmd is: systemctl reload nginx ; x-ui restart"
|
||
reloadCmd="systemctl reload nginx ; x-ui restart"
|
||
;;
|
||
2)
|
||
LOGD "It's recommended to put x-ui restart at the end, so it won't raise an error if other services fails"
|
||
read -rp "Please enter your reloadcmd (example: systemctl reload nginx ; x-ui restart): " reloadCmd
|
||
LOGI "Your reloadcmd is: ${reloadCmd}"
|
||
;;
|
||
*)
|
||
LOGI "Keep default reloadcmd"
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# install the certificate
|
||
local installOutput=""
|
||
installOutput=$(~/.acme.sh/acme.sh --installcert --force -d ${domain} \
|
||
--key-file /root/cert/${domain}/privkey.pem \
|
||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
||
local installRc=$?
|
||
echo "${installOutput}"
|
||
|
||
local installWroteFiles=0
|
||
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
|
||
installWroteFiles=1
|
||
fi
|
||
|
||
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && (${installRc} -eq 0 || ${installWroteFiles} -eq 1) ]]; then
|
||
LOGI "Installing certificate succeeded, enabling auto renew..."
|
||
else
|
||
LOGE "Installing certificate failed, exiting."
|
||
if [[ ${cert_exists} -eq 0 ]]; then
|
||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||
fi
|
||
exit 1
|
||
fi
|
||
|
||
# enable auto-renew
|
||
~/.acme.sh/acme.sh --upgrade --auto-upgrade
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Auto renew failed, certificate details:"
|
||
ls -lah cert/*
|
||
chmod 600 $certPath/privkey.pem
|
||
chmod 644 $certPath/fullchain.pem
|
||
exit 1
|
||
else
|
||
LOGI "Auto renew succeeded, certificate details:"
|
||
ls -lah cert/*
|
||
chmod 600 $certPath/privkey.pem
|
||
chmod 644 $certPath/fullchain.pem
|
||
fi
|
||
|
||
# Prompt user to set panel paths after successful certificate installation
|
||
read -rp "Would you like to set this certificate for the panel? (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"
|
||
LOGI "Panel paths set for domain: $domain"
|
||
LOGI " - Certificate File: $webCertFile"
|
||
LOGI " - Private Key File: $webKeyFile"
|
||
echo -e "${green}Access URL: https://${domain}:${existing_port}${existing_webBasePath}${plain}"
|
||
restart
|
||
else
|
||
LOGE "Error: Certificate or private key file not found for domain: $domain."
|
||
fi
|
||
else
|
||
LOGI "Skipping panel path setting."
|
||
fi
|
||
}
|
||
|
||
ssl_cert_issue_CF() {
|
||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||
LOGI "****** Instructions for Use ******"
|
||
LOGI "Follow the steps below to complete the process:"
|
||
LOGI "1. A Cloudflare API Token (recommended, scoped to Zone:DNS:Edit) or the Global API Key + registered email."
|
||
LOGI "2. The Domain Name."
|
||
LOGI "3. Once the certificate is issued, you will be prompted to set the certificate for the panel (optional)."
|
||
LOGI "4. The script also supports automatic renewal of the SSL certificate after installation."
|
||
|
||
confirm "Do you confirm the information and wish to proceed? [y/n]" "y"
|
||
|
||
if [ $? -eq 0 ]; then
|
||
# Check for acme.sh first
|
||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||
echo "acme.sh could not be found. We will install it."
|
||
install_acme
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Install acme failed, please check logs."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
CF_Domain=""
|
||
|
||
LOGD "Please set a domain name:"
|
||
read -rp "Input your domain here: " CF_Domain
|
||
LOGD "Your domain name is set to: ${CF_Domain}"
|
||
|
||
# Cloudflare API credentials: an API Token (recommended, scoped to a
|
||
# single zone) or the account-wide Global API Key. acme.sh reads
|
||
# CF_Token for tokens, or CF_Key + CF_Email for the Global Key.
|
||
CF_KeyType=""
|
||
read -rp "Are you using a Cloudflare API Token or Global API Key? (t/g) [Default t]: " CF_KeyType
|
||
CF_KeyType=${CF_KeyType:-t}
|
||
|
||
if [[ "$CF_KeyType" == "g" || "$CF_KeyType" == "G" ]]; then
|
||
CF_GlobalKey=""
|
||
CF_AccountEmail=""
|
||
LOGD "Please set the Global API Key:"
|
||
read -rp "Input your key here: " CF_GlobalKey
|
||
LOGD "Please set up the registered email:"
|
||
read -rp "Input your email here: " CF_AccountEmail
|
||
export CF_Key="${CF_GlobalKey}"
|
||
export CF_Email="${CF_AccountEmail}"
|
||
else
|
||
CF_ApiToken=""
|
||
LOGD "Please set the API Token:"
|
||
read -rp "Input your token here: " CF_ApiToken
|
||
export CF_Token="${CF_ApiToken}"
|
||
fi
|
||
|
||
# Set the default CA to Let's Encrypt
|
||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Default CA, Let'sEncrypt fail, script exiting..."
|
||
exit 1
|
||
fi
|
||
|
||
# Issue the certificate using Cloudflare DNS
|
||
~/.acme.sh/acme.sh --issue --dns dns_cf -d ${CF_Domain} -d *.${CF_Domain} --log --force
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Certificate issuance failed, script exiting..."
|
||
exit 1
|
||
else
|
||
LOGI "Certificate issued successfully, Installing..."
|
||
fi
|
||
|
||
# Install the certificate
|
||
certPath="/root/cert/${CF_Domain}"
|
||
if [ -d "$certPath" ]; then
|
||
rm -rf ${certPath}
|
||
fi
|
||
|
||
mkdir -p ${certPath}
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Failed to create directory: ${certPath}"
|
||
exit 1
|
||
fi
|
||
|
||
reloadCmd="x-ui restart"
|
||
|
||
LOGI "Default --reloadcmd for ACME is: ${yellow}x-ui restart"
|
||
LOGI "This command will run on every certificate issue and renew."
|
||
read -rp "Would you like to modify --reloadcmd for ACME? (y/n): " setReloadcmd
|
||
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
|
||
echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; x-ui restart"
|
||
echo -e "${green}\t2.${plain} Input your own command"
|
||
echo -e "${green}\t0.${plain} Keep default reloadcmd"
|
||
read -rp "Choose an option: " choice
|
||
case "$choice" in
|
||
1)
|
||
LOGI "Reloadcmd is: systemctl reload nginx ; x-ui restart"
|
||
reloadCmd="systemctl reload nginx ; x-ui restart"
|
||
;;
|
||
2)
|
||
LOGD "It's recommended to put x-ui restart at the end, so it won't raise an error if other services fails"
|
||
read -rp "Please enter your reloadcmd (example: systemctl reload nginx ; x-ui restart): " reloadCmd
|
||
LOGI "Your reloadcmd is: ${reloadCmd}"
|
||
;;
|
||
*)
|
||
LOGI "Keep default reloadcmd"
|
||
;;
|
||
esac
|
||
fi
|
||
~/.acme.sh/acme.sh --installcert --force -d ${CF_Domain} -d *.${CF_Domain} \
|
||
--key-file ${certPath}/privkey.pem \
|
||
--fullchain-file ${certPath}/fullchain.pem --reloadcmd "${reloadCmd}"
|
||
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Certificate installation failed, script exiting..."
|
||
exit 1
|
||
else
|
||
LOGI "Certificate installed successfully, Turning on automatic updates..."
|
||
fi
|
||
|
||
# Enable auto-update
|
||
~/.acme.sh/acme.sh --upgrade --auto-upgrade
|
||
if [ $? -ne 0 ]; then
|
||
LOGE "Auto update setup failed, script exiting..."
|
||
exit 1
|
||
else
|
||
LOGI "The certificate is installed and auto-renewal is turned on. Specific information is as follows:"
|
||
ls -lah ${certPath}/*
|
||
chmod 600 ${certPath}/privkey.pem
|
||
chmod 644 ${certPath}/fullchain.pem
|
||
fi
|
||
|
||
# Prompt user to set panel paths after successful certificate installation
|
||
read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
|
||
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
|
||
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"
|
||
LOGI "Panel paths set for domain: $CF_Domain"
|
||
LOGI " - Certificate File: $webCertFile"
|
||
LOGI " - Private Key File: $webKeyFile"
|
||
echo -e "${green}Access URL: https://${CF_Domain}:${existing_port}${existing_webBasePath}${plain}"
|
||
restart
|
||
else
|
||
LOGE "Error: Certificate or private key file not found for domain: $CF_Domain."
|
||
fi
|
||
else
|
||
LOGI "Skipping panel path setting."
|
||
fi
|
||
else
|
||
show_menu
|
||
fi
|
||
}
|
||
|
||
run_speedtest() {
|
||
# Check if Speedtest is already installed
|
||
if ! command -v speedtest &> /dev/null; then
|
||
# If not installed, determine installation method
|
||
if command -v snap &> /dev/null; then
|
||
# Use snap to install Speedtest
|
||
echo "Installing Speedtest using snap..."
|
||
snap install speedtest
|
||
else
|
||
# Fallback to using package managers
|
||
local pkg_manager=""
|
||
local speedtest_install_script=""
|
||
|
||
if command -v dnf &> /dev/null; then
|
||
pkg_manager="dnf"
|
||
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.rpm.sh"
|
||
elif command -v yum &> /dev/null; then
|
||
pkg_manager="yum"
|
||
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.rpm.sh"
|
||
elif command -v apt-get &> /dev/null; then
|
||
pkg_manager="apt-get"
|
||
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.deb.sh"
|
||
elif command -v apt &> /dev/null; then
|
||
pkg_manager="apt"
|
||
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.deb.sh"
|
||
fi
|
||
|
||
if [[ -z $pkg_manager ]]; then
|
||
echo "Error: Package manager not found. You may need to install Speedtest manually."
|
||
return 1
|
||
else
|
||
echo "Installing Speedtest using $pkg_manager..."
|
||
curl -s $speedtest_install_script | bash
|
||
$pkg_manager install -y speedtest
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
speedtest
|
||
}
|
||
|
||
ip_validation() {
|
||
ipv6_regex="^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$"
|
||
ipv4_regex="^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)$"
|
||
}
|
||
|
||
iplimit_main() {
|
||
echo -e "\n${green}\t1.${plain} Install Fail2ban and configure IP Limit"
|
||
echo -e "${green}\t2.${plain} Change Ban Duration"
|
||
echo -e "${green}\t3.${plain} Unban Everyone"
|
||
echo -e "${green}\t4.${plain} Ban Logs"
|
||
echo -e "${green}\t5.${plain} Ban an IP Address"
|
||
echo -e "${green}\t6.${plain} Unban an IP Address"
|
||
echo -e "${green}\t7.${plain} Real-Time Logs"
|
||
echo -e "${green}\t8.${plain} Service Status"
|
||
echo -e "${green}\t9.${plain} Service Restart"
|
||
echo -e "${green}\t10.${plain} Uninstall Fail2ban and IP Limit"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " choice
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
confirm "Proceed with installation of Fail2ban & IP Limit?" "y"
|
||
if [[ $? == 0 ]]; then
|
||
install_iplimit
|
||
else
|
||
iplimit_main
|
||
fi
|
||
;;
|
||
2)
|
||
read -rp "Please enter new Ban Duration in Minutes [default 30]: " NUM
|
||
if [[ $NUM =~ ^[0-9]+$ ]]; then
|
||
create_iplimit_jails ${NUM}
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service fail2ban restart
|
||
else
|
||
systemctl restart fail2ban
|
||
fi
|
||
else
|
||
echo -e "${red}${NUM} is not a number! Please, try again.${plain}"
|
||
fi
|
||
iplimit_main
|
||
;;
|
||
3)
|
||
confirm "Proceed with Unbanning everyone from IP Limit jail?" "y"
|
||
if [[ $? == 0 ]]; then
|
||
fail2ban-client reload --restart --unban 3x-ipl
|
||
truncate -s 0 "${iplimit_banned_log_path}"
|
||
echo -e "${green}All users Unbanned successfully.${plain}"
|
||
iplimit_main
|
||
else
|
||
echo -e "${yellow}Cancelled.${plain}"
|
||
fi
|
||
iplimit_main
|
||
;;
|
||
4)
|
||
show_banlog
|
||
iplimit_main
|
||
;;
|
||
5)
|
||
read -rp "Enter the IP address you want to ban: " ban_ip
|
||
ip_validation
|
||
if [[ $ban_ip =~ $ipv4_regex || $ban_ip =~ $ipv6_regex ]]; then
|
||
fail2ban-client set 3x-ipl banip "$ban_ip"
|
||
echo -e "${green}IP Address ${ban_ip} has been banned successfully.${plain}"
|
||
else
|
||
echo -e "${red}Invalid IP address format! Please try again.${plain}"
|
||
fi
|
||
iplimit_main
|
||
;;
|
||
6)
|
||
read -rp "Enter the IP address you want to unban: " unban_ip
|
||
ip_validation
|
||
if [[ $unban_ip =~ $ipv4_regex || $unban_ip =~ $ipv6_regex ]]; then
|
||
fail2ban-client set 3x-ipl unbanip "$unban_ip"
|
||
echo -e "${green}IP Address ${unban_ip} has been unbanned successfully.${plain}"
|
||
else
|
||
echo -e "${red}Invalid IP address format! Please try again.${plain}"
|
||
fi
|
||
iplimit_main
|
||
;;
|
||
7)
|
||
tail -f /var/log/fail2ban.log
|
||
iplimit_main
|
||
;;
|
||
8)
|
||
service fail2ban status
|
||
iplimit_main
|
||
;;
|
||
9)
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service fail2ban restart
|
||
else
|
||
systemctl restart fail2ban
|
||
fi
|
||
iplimit_main
|
||
;;
|
||
10)
|
||
remove_iplimit
|
||
iplimit_main
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
iplimit_main
|
||
;;
|
||
esac
|
||
}
|
||
|
||
setup_fail2ban_iplimit() {
|
||
# Honor the same toggle the panel uses (isFail2BanEnabled): enabled when the
|
||
# var is unset or exactly "true"; any other explicit value means the operator
|
||
# opted out, so do nothing rather than install a fail2ban the panel ignores.
|
||
if [[ -n "${XUI_ENABLE_FAIL2BAN+x}" && "${XUI_ENABLE_FAIL2BAN}" != "true" ]]; then
|
||
echo -e "${yellow}XUI_ENABLE_FAIL2BAN=${XUI_ENABLE_FAIL2BAN}, skipping Fail2ban setup.${plain}\n"
|
||
return 0
|
||
fi
|
||
|
||
if ! command -v fail2ban-client &> /dev/null; then
|
||
echo -e "${green}Fail2ban is not installed. Installing now...!${plain}\n"
|
||
|
||
# Install fail2ban together with nftables. Recent fail2ban packages
|
||
# default to `banaction = nftables-multiport` in /etc/fail2ban/jail.conf,
|
||
# but the `nftables` package isn't pulled in as a dependency on most
|
||
# minimal server images (Debian 12+, Ubuntu 24+, fresh RHEL-family).
|
||
# Without `nft` in PATH the default sshd jail fails to ban with
|
||
# stderr: '/bin/sh: 1: nft: not found'
|
||
# even though our own 3x-ipl jail uses iptables. Bundling the binary
|
||
# at install time prevents that confusing log spam for new installs.
|
||
case "${release}" in
|
||
ubuntu)
|
||
apt-get update
|
||
if [[ "${os_version}" -ge 2400 ]]; then
|
||
apt-get install python3-pip -y
|
||
python3 -m pip install pyasynchat --break-system-packages
|
||
fi
|
||
apt-get install fail2ban nftables -y
|
||
;;
|
||
debian)
|
||
apt-get update
|
||
if [ "$os_version" -ge 12 ]; then
|
||
apt-get install -y python3-systemd
|
||
fi
|
||
apt-get install -y fail2ban nftables
|
||
;;
|
||
armbian)
|
||
apt-get update && apt-get install fail2ban nftables -y
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||
if [[ "${release}" != "fedora" ]] && ! dnf repolist enabled 2> /dev/null | grep -qiw epel; then
|
||
dnf install -y epel-release \
|
||
|| dnf install -y "https://dl.fedoraproject.org/pub/epel/epel-release-latest-$(rpm -E %rhel).noarch.rpm" \
|
||
|| echo -e "${yellow}Could not enable the EPEL repository; fail2ban is only available from EPEL on this distro.${plain}"
|
||
fi
|
||
dnf makecache -y && dnf -y install fail2ban nftables
|
||
;;
|
||
centos)
|
||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||
yum makecache -y && yum install epel-release -y
|
||
yum -y install fail2ban nftables
|
||
else
|
||
dnf makecache -y && dnf -y install fail2ban nftables
|
||
fi
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Sy --noconfirm fail2ban nftables
|
||
;;
|
||
alpine)
|
||
apk add fail2ban nftables
|
||
;;
|
||
*)
|
||
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
if ! command -v fail2ban-client &> /dev/null; then
|
||
echo -e "${red}Fail2ban installation failed.${plain}\n"
|
||
return 1
|
||
fi
|
||
|
||
echo -e "${green}Fail2ban installed successfully!${plain}\n"
|
||
else
|
||
echo -e "${yellow}Fail2ban is already installed.${plain}\n"
|
||
fi
|
||
|
||
echo -e "${green}Configuring IP Limit...${plain}\n"
|
||
|
||
# make sure there's no conflict for jail files
|
||
iplimit_remove_conflicts
|
||
|
||
# Check if log file exists
|
||
if ! test -f "${iplimit_banned_log_path}"; then
|
||
touch ${iplimit_banned_log_path}
|
||
fi
|
||
|
||
# Check if service log file exists so fail2ban won't return error
|
||
if ! test -f "${iplimit_log_path}"; then
|
||
touch ${iplimit_log_path}
|
||
fi
|
||
|
||
# Create the iplimit jail files
|
||
# we didn't pass the bantime here to use the default value
|
||
create_iplimit_jails
|
||
|
||
# Launching fail2ban
|
||
if [[ $release == "alpine" ]]; then
|
||
if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
|
||
rc-service fail2ban start
|
||
else
|
||
rc-service fail2ban restart
|
||
fi
|
||
rc-update add fail2ban
|
||
else
|
||
if ! systemctl is-active --quiet fail2ban; then
|
||
systemctl start fail2ban
|
||
else
|
||
systemctl restart fail2ban
|
||
fi
|
||
systemctl enable fail2ban
|
||
fi
|
||
|
||
echo -e "${green}IP Limit installed and configured successfully!${plain}\n"
|
||
return 0
|
||
}
|
||
|
||
# install_iplimit is the interactive (menu) entry point: it runs the shared
|
||
# setup and then returns to the menu. The non-interactive installer path uses
|
||
# setup_fail2ban_iplimit directly via `x-ui setup-fail2ban`.
|
||
install_iplimit() {
|
||
setup_fail2ban_iplimit
|
||
before_show_menu
|
||
}
|
||
|
||
remove_iplimit() {
|
||
echo -e "${green}\t1.${plain} Only remove IP Limit configurations"
|
||
echo -e "${green}\t2.${plain} Uninstall Fail2ban and IP Limit"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " num
|
||
case "$num" in
|
||
1)
|
||
rm -f /etc/fail2ban/filter.d/3x-ipl.conf
|
||
rm -f /etc/fail2ban/action.d/3x-ipl.conf
|
||
rm -f /etc/fail2ban/jail.d/3x-ipl.conf
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service fail2ban restart
|
||
else
|
||
systemctl restart fail2ban
|
||
fi
|
||
echo -e "${green}IP Limit removed successfully!${plain}\n"
|
||
before_show_menu
|
||
;;
|
||
2)
|
||
rm -rf /etc/fail2ban
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service fail2ban stop
|
||
else
|
||
systemctl stop fail2ban
|
||
fi
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
apt-get remove -y fail2ban
|
||
apt-get purge -y fail2ban -y
|
||
apt-get autoremove -y
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||
dnf remove fail2ban -y
|
||
dnf autoremove -y
|
||
;;
|
||
centos)
|
||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||
yum remove fail2ban -y
|
||
yum autoremove -y
|
||
else
|
||
dnf remove fail2ban -y
|
||
dnf autoremove -y
|
||
fi
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Rns --noconfirm fail2ban
|
||
;;
|
||
alpine)
|
||
apk del fail2ban
|
||
;;
|
||
*)
|
||
echo -e "${red}Unsupported operating system. Please uninstall Fail2ban manually.${plain}\n"
|
||
exit 1
|
||
;;
|
||
esac
|
||
echo -e "${green}Fail2ban and IP Limit removed successfully!${plain}\n"
|
||
before_show_menu
|
||
;;
|
||
0)
|
||
show_menu
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
remove_iplimit
|
||
;;
|
||
esac
|
||
}
|
||
|
||
show_banlog() {
|
||
local system_log="/var/log/fail2ban.log"
|
||
|
||
echo -e "${green}Checking ban logs...${plain}\n"
|
||
|
||
if [[ $release == "alpine" ]]; then
|
||
if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
|
||
echo -e "${red}Fail2ban service is not running!${plain}\n"
|
||
return 1
|
||
fi
|
||
else
|
||
if ! systemctl is-active --quiet fail2ban; then
|
||
echo -e "${red}Fail2ban service is not running!${plain}\n"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
if [[ -f "$system_log" ]]; then
|
||
echo -e "${green}Recent system ban activities from fail2ban.log:${plain}"
|
||
grep "3x-ipl" "$system_log" | grep -E "Ban|Unban" | tail -n 10 || echo -e "${yellow}No recent system ban activities found${plain}"
|
||
echo ""
|
||
fi
|
||
|
||
if [[ -f "${iplimit_banned_log_path}" ]]; then
|
||
echo -e "${green}3X-IPL ban log entries:${plain}"
|
||
if [[ -s "${iplimit_banned_log_path}" ]]; then
|
||
grep -v "INIT" "${iplimit_banned_log_path}" | tail -n 10 || echo -e "${yellow}No ban entries found${plain}"
|
||
else
|
||
echo -e "${yellow}Ban log file is empty${plain}"
|
||
fi
|
||
else
|
||
echo -e "${red}Ban log file not found at: ${iplimit_banned_log_path}${plain}"
|
||
fi
|
||
|
||
echo -e "\n${green}Current jail status:${plain}"
|
||
fail2ban-client status 3x-ipl || echo -e "${yellow}Unable to get jail status${plain}"
|
||
}
|
||
|
||
create_iplimit_jails() {
|
||
# Use default bantime if not passed => 30 minutes
|
||
local bantime="${1:-30}"
|
||
|
||
# Uncomment 'allowipv6 = auto' in fail2ban.conf
|
||
sed -i 's/#allowipv6 = auto/allowipv6 = auto/g' /etc/fail2ban/fail2ban.conf
|
||
|
||
# On Debian 12+ and Ubuntu 22.04+ fail2ban's default backend should be changed to systemd
|
||
if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]]; then
|
||
sed -i '0,/action =/s/backend = auto/backend = systemd/' /etc/fail2ban/jail.conf
|
||
fi
|
||
|
||
cat << EOF > /etc/fail2ban/jail.d/3x-ipl.conf
|
||
[3x-ipl]
|
||
enabled=true
|
||
backend=auto
|
||
filter=3x-ipl
|
||
action=3x-ipl
|
||
logpath=${iplimit_log_path}
|
||
maxretry=1
|
||
findtime=32
|
||
bantime=${bantime}m
|
||
EOF
|
||
|
||
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
||
[Definition]
|
||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||
ignoreregex =
|
||
EOF
|
||
|
||
# Ports to exempt from the ban so an over-limit proxy client can never lock
|
||
# the administrator out of SSH or the panel. The ban still covers every other
|
||
# TCP and UDP port (including all Xray inbounds, e.g. UDP-based Hysteria2), so
|
||
# IP-limit keeps working for inbounds added later without regenerating these files.
|
||
local ssh_ports
|
||
ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
|
||
[[ -z "${ssh_ports}" ]] && ssh_ports="22"
|
||
local panel_port
|
||
panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
|
||
local exempt_ports="${ssh_ports}"
|
||
[[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
|
||
|
||
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
|
||
[INCLUDES]
|
||
before = iptables-allports.conf
|
||
|
||
[Definition]
|
||
actionstart = <iptables> -N f2b-<name>
|
||
<iptables> -A f2b-<name> -j <returntype>
|
||
<iptables> -I <chain> -j f2b-<name>
|
||
|
||
actionstop = <iptables> -D <chain> -j f2b-<name>
|
||
<actionflush>
|
||
<iptables> -X f2b-<name>
|
||
|
||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||
|
||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
|
||
<iptables> -I f2b-<name> 1 -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
|
||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||
|
||
actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
|
||
<iptables> -D f2b-<name> -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
|
||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||
|
||
[Init]
|
||
name = default
|
||
chain = INPUT
|
||
exemptports = ${exempt_ports}
|
||
EOF
|
||
|
||
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
|
||
}
|
||
|
||
iplimit_remove_conflicts() {
|
||
local jail_files=(
|
||
/etc/fail2ban/jail.conf
|
||
/etc/fail2ban/jail.local
|
||
)
|
||
|
||
for file in "${jail_files[@]}"; do
|
||
# Check for [3x-ipl] config in jail file then remove it
|
||
if test -f "${file}" && grep -qw '3x-ipl' ${file}; then
|
||
sed -i "/\[3x-ipl\]/,/^$/d" ${file}
|
||
echo -e "${yellow}Removing conflicts of [3x-ipl] in jail (${file})!${plain}\n"
|
||
fi
|
||
done
|
||
}
|
||
|
||
SSH_port_forwarding() {
|
||
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" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
server_ip="${ip_result}"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ -z "$server_ip" ]]; then
|
||
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
|
||
while [[ -z "$server_ip" ]]; do
|
||
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||
server_ip="${server_ip// /}"
|
||
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
|
||
server_ip=""
|
||
fi
|
||
done
|
||
fi
|
||
|
||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||
local existing_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')
|
||
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'cert: .+' | awk '{print $2}')
|
||
local existing_key=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'key: .+' | awk '{print $2}')
|
||
|
||
local config_listenIP=""
|
||
local listen_choice=""
|
||
|
||
if [[ -n "$existing_cert" && -n "$existing_key" ]]; then
|
||
echo -e "${green}Panel is secure with SSL.${plain}"
|
||
before_show_menu
|
||
fi
|
||
if [[ -z "$existing_cert" && -z "$existing_key" && (-z "$existing_listenIP" || "$existing_listenIP" == "0.0.0.0") ]]; then
|
||
echo -e "\n${red}Warning: No Cert and Key found! The panel is not secure.${plain}"
|
||
echo "Please obtain a certificate or set up SSH port forwarding."
|
||
fi
|
||
|
||
if [[ -n "$existing_listenIP" && "$existing_listenIP" != "0.0.0.0" && (-z "$existing_cert" && -z "$existing_key") ]]; then
|
||
echo -e "\n${green}Current SSH Port Forwarding Configuration:${plain}"
|
||
echo -e "Standard SSH command:"
|
||
echo -e "${yellow}ssh -L 2222:${existing_listenIP}:${existing_port} root@${server_ip}${plain}"
|
||
echo -e "\nIf using SSH key:"
|
||
echo -e "${yellow}ssh -i <sshkeypath> -L 2222:${existing_listenIP}:${existing_port} root@${server_ip}${plain}"
|
||
echo -e "\nAfter connecting, access the panel at:"
|
||
echo -e "${yellow}http://localhost:2222${existing_webBasePath}${plain}"
|
||
fi
|
||
|
||
echo -e "\nChoose an option:"
|
||
echo -e "${green}1.${plain} Set listen IP"
|
||
echo -e "${green}2.${plain} Clear listen IP"
|
||
echo -e "${green}0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " num
|
||
|
||
case "$num" in
|
||
1)
|
||
if [[ -z "$existing_listenIP" || "$existing_listenIP" == "0.0.0.0" ]]; then
|
||
echo -e "\nNo listenIP configured. Choose an option:"
|
||
echo -e "1. Use default IP (127.0.0.1)"
|
||
echo -e "2. Set a custom IP"
|
||
read -rp "Select an option (1 or 2): " listen_choice
|
||
|
||
config_listenIP="127.0.0.1"
|
||
[[ "$listen_choice" == "2" ]] && read -rp "Enter custom IP to listen on: " config_listenIP
|
||
|
||
${xui_folder}/x-ui setting -listenIP "${config_listenIP}" > /dev/null 2>&1
|
||
echo -e "${green}listen IP has been set to ${config_listenIP}.${plain}"
|
||
echo -e "\n${green}SSH Port Forwarding Configuration:${plain}"
|
||
echo -e "Standard SSH command:"
|
||
echo -e "${yellow}ssh -L 2222:${config_listenIP}:${existing_port} root@${server_ip}${plain}"
|
||
echo -e "\nIf using SSH key:"
|
||
echo -e "${yellow}ssh -i <sshkeypath> -L 2222:${config_listenIP}:${existing_port} root@${server_ip}${plain}"
|
||
echo -e "\nAfter connecting, access the panel at:"
|
||
echo -e "${yellow}http://localhost:2222${existing_webBasePath}${plain}"
|
||
restart
|
||
else
|
||
config_listenIP="${existing_listenIP}"
|
||
echo -e "${green}Current listen IP is already set to ${config_listenIP}.${plain}"
|
||
fi
|
||
;;
|
||
2)
|
||
${xui_folder}/x-ui setting -listenIP 0.0.0.0 > /dev/null 2>&1
|
||
echo -e "${green}Listen IP has been cleared.${plain}"
|
||
restart
|
||
;;
|
||
0)
|
||
show_menu
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
SSH_port_forwarding
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# PostgreSQL service management (for panels configured with XUI_DB_TYPE=postgres).
|
||
|
||
postgresql_installed() {
|
||
command -v pg_lsclusters > /dev/null 2>&1 || command -v psql > /dev/null 2>&1 || command -v postgres > /dev/null 2>&1
|
||
}
|
||
|
||
# Prints "VER CLUSTER" of the first configured cluster on Debian-style installs (e.g. "16 main").
|
||
pg_cluster_info() {
|
||
if command -v pg_lsclusters > /dev/null 2>&1; then
|
||
pg_lsclusters 2> /dev/null | awk '$1 ~ /^[0-9]+$/ {print $1, $2; exit}'
|
||
fi
|
||
}
|
||
|
||
# Resolves the systemd unit used to manage the PostgreSQL server.
|
||
pg_systemd_unit() {
|
||
local info ver cluster
|
||
info="$(pg_cluster_info)"
|
||
if [[ -n "$info" ]]; then
|
||
ver="${info%% *}"
|
||
cluster="${info##* }"
|
||
echo "postgresql@${ver}-${cluster}"
|
||
else
|
||
echo "postgresql"
|
||
fi
|
||
}
|
||
|
||
postgresql_status() {
|
||
if ! postgresql_installed; then
|
||
LOGE "PostgreSQL does not appear to be installed on this system."
|
||
return 1
|
||
fi
|
||
if command -v pg_lsclusters > /dev/null 2>&1; then
|
||
pg_lsclusters
|
||
else
|
||
systemctl status "$(pg_systemd_unit)" --no-pager
|
||
fi
|
||
echo ""
|
||
if command -v ss > /dev/null 2>&1; then
|
||
local listening
|
||
listening=$(ss -ltnp 2> /dev/null | grep ':5432')
|
||
if [[ -n "$listening" ]]; then
|
||
echo -e "${green}PostgreSQL is listening on port 5432:${plain}"
|
||
echo "$listening"
|
||
else
|
||
echo -e "${red}Nothing is listening on port 5432 - the database is not running.${plain}"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
postgresql_start() {
|
||
pg_require_installed || return 1
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service postgresql start
|
||
else
|
||
systemctl start "$(pg_systemd_unit)"
|
||
fi
|
||
sleep 1
|
||
postgresql_status
|
||
}
|
||
|
||
postgresql_stop() {
|
||
pg_require_installed || return 1
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service postgresql stop
|
||
else
|
||
systemctl stop "$(pg_systemd_unit)"
|
||
fi
|
||
LOGI "PostgreSQL stop signal sent."
|
||
}
|
||
|
||
postgresql_restart() {
|
||
pg_require_installed || return 1
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service postgresql restart
|
||
else
|
||
systemctl restart "$(pg_systemd_unit)"
|
||
fi
|
||
sleep 1
|
||
postgresql_status
|
||
}
|
||
|
||
postgresql_enable() {
|
||
pg_require_installed || return 1
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-update add postgresql default
|
||
else
|
||
systemctl enable "$(pg_systemd_unit)"
|
||
fi
|
||
if [[ $? == 0 ]]; then
|
||
LOGI "PostgreSQL set to start automatically on boot."
|
||
else
|
||
LOGE "Failed to enable PostgreSQL autostart."
|
||
fi
|
||
}
|
||
|
||
postgresql_log() {
|
||
pg_require_installed || return 1
|
||
local info ver cluster logfile
|
||
info="$(pg_cluster_info)"
|
||
if [[ -n "$info" ]]; then
|
||
ver="${info%% *}"
|
||
cluster="${info##* }"
|
||
logfile="/var/log/postgresql/postgresql-${ver}-${cluster}.log"
|
||
fi
|
||
if [[ -n "$logfile" && -f "$logfile" ]]; then
|
||
tail -n 40 "$logfile"
|
||
elif command -v journalctl > /dev/null 2>&1; then
|
||
journalctl -u "$(pg_systemd_unit)" -n 40 --no-pager
|
||
else
|
||
LOGE "No PostgreSQL log found."
|
||
fi
|
||
}
|
||
|
||
pg_require_installed() {
|
||
if ! postgresql_installed; then
|
||
LOGE "PostgreSQL is not installed. Use option 1 (Install PostgreSQL) in this menu first."
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Completely removes the PostgreSQL server and ALL of its databases from the system.
|
||
# Gated behind an explicit confirmation because this is system-wide and irreversible:
|
||
# any other application sharing this PostgreSQL instance loses its data too. Mirrors the
|
||
# package names used by pg_install_local() so the right packages are removed per distro.
|
||
purge_postgresql() {
|
||
echo ""
|
||
echo -e "${yellow}This panel was using PostgreSQL.${plain}"
|
||
echo -e "${red}WARNING:${plain} purging removes the PostgreSQL server and ${red}ALL${plain} of its databases on"
|
||
echo -e "this machine, including any used by other applications. This cannot be undone."
|
||
confirm "Also purge PostgreSQL and delete all of its data?" "n"
|
||
if [[ $? != 0 ]]; then
|
||
LOGI "Left PostgreSQL installed; its data was not removed."
|
||
return 0
|
||
fi
|
||
|
||
if [[ $release == "alpine" ]]; then
|
||
rc-service postgresql stop 2> /dev/null
|
||
rc-update del postgresql 2> /dev/null
|
||
else
|
||
systemctl stop "$(pg_systemd_unit)" 2> /dev/null
|
||
systemctl disable "$(pg_systemd_unit)" 2> /dev/null
|
||
fi
|
||
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
apt-get -y --purge remove 'postgresql*'
|
||
apt-get -y autoremove --purge
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||
dnf remove -y postgresql postgresql-server postgresql-contrib
|
||
;;
|
||
centos)
|
||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||
yum remove -y postgresql postgresql-server postgresql-contrib
|
||
else
|
||
dnf remove -y postgresql postgresql-server postgresql-contrib
|
||
fi
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Rns --noconfirm postgresql
|
||
;;
|
||
opensuse-tumbleweed | opensuse-leap)
|
||
zypper -q remove -y postgresql postgresql-server postgresql-contrib
|
||
;;
|
||
alpine)
|
||
apk del postgresql postgresql-contrib postgresql-client
|
||
;;
|
||
*)
|
||
LOGE "Unsupported distro for automatic PostgreSQL purge: ${release}. Remove it manually."
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
rm -rf /var/lib/postgresql /var/lib/pgsql /var/lib/postgres /etc/postgresql
|
||
LOGI "PostgreSQL has been purged."
|
||
}
|
||
|
||
# RHEL-family initdb writes pg_hba.conf host rules with ident auth, which
|
||
# compares the OS username against the Postgres role and always rejects the
|
||
# randomly generated panel role over TCP (#5806). Prepend password-auth rules
|
||
# scoped to the panel database; first match wins, and md5 also accepts
|
||
# scram-sha-256-stored verifiers, so this works on every supported distro.
|
||
# Mirrors pg_ensure_hba_password_auth() from install.sh.
|
||
pg_ensure_hba_password_auth() {
|
||
local pg_db="$1"
|
||
local hba_file
|
||
hba_file=$(sudo -u postgres psql -tAc 'SHOW hba_file' 2> /dev/null | tr -d '[:space:]')
|
||
[[ -n "${hba_file}" && -f "${hba_file}" ]] || return 0
|
||
grep -Eq "^host[[:space:]]+${pg_db}[[:space:]]" "${hba_file}" && return 0
|
||
local tmp
|
||
tmp=$(mktemp) || return 1
|
||
{
|
||
echo "# Added by 3x-ui: allow password logins for the panel database."
|
||
echo "host ${pg_db} all 127.0.0.1/32 md5"
|
||
echo "host ${pg_db} all ::1/128 md5"
|
||
cat "${hba_file}"
|
||
} > "${tmp}" || {
|
||
rm -f "${tmp}"
|
||
return 1
|
||
}
|
||
cat "${tmp}" > "${hba_file}" || {
|
||
rm -f "${tmp}"
|
||
return 1
|
||
}
|
||
rm -f "${tmp}"
|
||
sudo -u postgres psql -tAc 'SELECT pg_reload_conf()' > /dev/null 2>&1 || true
|
||
}
|
||
|
||
# Installs a local PostgreSQL server and creates a dedicated xui user/database.
|
||
# Progress goes to stderr; on success the connection DSN is printed to stdout so
|
||
# callers can capture it. Mirrors install_postgres_local() from install.sh, so the
|
||
# panel can be set up without re-running the remote install script.
|
||
pg_install_local() {
|
||
local pg_user pg_pass pg_db pg_host pg_port
|
||
pg_pass=$(gen_random_string 24)
|
||
pg_db="xui"
|
||
pg_host="127.0.0.1"
|
||
pg_port="5432"
|
||
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
apt-get update >&2 && apt-get install -y -q postgresql >&2 || return 1
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||
dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
|
||
[[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
|
||
;;
|
||
centos)
|
||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||
yum install -y postgresql-server postgresql-contrib >&2 || return 1
|
||
else
|
||
dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
|
||
fi
|
||
[[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Sy --noconfirm postgresql >&2 || return 1
|
||
if [[ ! -f /var/lib/postgres/data/PG_VERSION ]]; then
|
||
sudo -u postgres initdb -D /var/lib/postgres/data >&2 || return 1
|
||
fi
|
||
;;
|
||
opensuse-tumbleweed | opensuse-leap)
|
||
zypper -q install -y postgresql-server postgresql-contrib >&2 || return 1
|
||
if [[ ! -f /var/lib/pgsql/data/PG_VERSION ]]; then
|
||
install -d -o postgres -g postgres -m 700 /var/lib/pgsql/data >&2 || return 1
|
||
su - postgres -c "initdb -D /var/lib/pgsql/data" >&2 || return 1
|
||
fi
|
||
;;
|
||
alpine)
|
||
apk add --no-cache postgresql postgresql-contrib >&2 || return 1
|
||
if [[ ! -f /var/lib/postgresql/data/PG_VERSION ]]; then
|
||
/etc/init.d/postgresql setup >&2 || return 1
|
||
fi
|
||
rc-update add postgresql default >&2 2> /dev/null || true
|
||
rc-service postgresql start >&2 || return 1
|
||
;;
|
||
*)
|
||
echo -e "${red}Unsupported distro for automatic PostgreSQL install: ${release}${plain}" >&2
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
if [[ "${release}" != "alpine" ]]; then
|
||
systemctl enable --now postgresql >&2 || return 1
|
||
fi
|
||
|
||
local i
|
||
for i in 1 2 3 4 5; do
|
||
sudo -u postgres psql -tAc 'SELECT 1' > /dev/null 2>&1 && break
|
||
sleep 1
|
||
done
|
||
|
||
local existing_owner=""
|
||
existing_owner=$(sudo -u postgres psql -tAc \
|
||
"SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_database WHERE datname='${pg_db}'" 2> /dev/null \
|
||
| tr -d '[:space:]')
|
||
if [[ -n "${existing_owner}" && "${existing_owner}" != "postgres" ]]; then
|
||
pg_user="${existing_owner}"
|
||
else
|
||
pg_user=$(gen_random_string 8)
|
||
fi
|
||
|
||
sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${pg_user}'" 2> /dev/null \
|
||
| grep -q 1 \
|
||
|| sudo -u postgres psql -c "CREATE USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
|
||
|
||
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${pg_db}'" 2> /dev/null \
|
||
| grep -q 1 \
|
||
|| sudo -u postgres psql -c "CREATE DATABASE \"${pg_db}\" OWNER \"${pg_user}\";" >&2 || return 1
|
||
|
||
sudo -u postgres psql -c "ALTER USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
|
||
|
||
pg_ensure_hba_password_auth "${pg_db}" \
|
||
|| echo -e "${yellow}Warning: could not update pg_hba.conf; PostgreSQL may reject the panel's TCP login (ident auth).${plain}" >&2
|
||
|
||
local pg_pass_enc
|
||
pg_pass_enc=$(printf '%s' "${pg_pass}" | sed -e 's/%/%25/g' -e 's/:/%3A/g' -e 's/@/%40/g' -e 's|/|%2F|g' -e 's/?/%3F/g' -e 's/#/%23/g')
|
||
|
||
echo "postgres://${pg_user}:${pg_pass_enc}@${pg_host}:${pg_port}/${pg_db}?sslmode=disable"
|
||
return 0
|
||
}
|
||
|
||
# Installs the PostgreSQL client tools (pg_dump/pg_restore) used by in-panel backup.
|
||
pg_ensure_client() {
|
||
if command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
echo -e "${yellow}Installing PostgreSQL client tools (pg_dump/pg_restore)...${plain}" >&2
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
apt-get update >&2 && apt-get install -y -q postgresql-client >&2 || return 1
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||
dnf install -y -q postgresql >&2 || return 1
|
||
;;
|
||
centos)
|
||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||
yum install -y postgresql >&2 || return 1
|
||
else
|
||
dnf install -y -q postgresql >&2 || return 1
|
||
fi
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Sy --noconfirm postgresql >&2 || return 1
|
||
;;
|
||
opensuse-tumbleweed | opensuse-leap)
|
||
zypper -q install -y postgresql >&2 || return 1
|
||
;;
|
||
alpine)
|
||
apk add --no-cache postgresql-client >&2 || return 1
|
||
;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1
|
||
}
|
||
|
||
# Prints the major version of the installed pg_restore, or nothing when absent.
|
||
pg_client_major() {
|
||
command -v pg_restore > /dev/null 2>&1 || return 1
|
||
pg_restore --version 2> /dev/null | grep -oE '[0-9]+' | head -n 1
|
||
}
|
||
|
||
# Installs or upgrades the PostgreSQL client tools (pg_dump/pg_restore) so their
|
||
# major version is at least $1 (e.g. 17); with no argument any installed version
|
||
# is accepted. Falls back to the official PostgreSQL package repository when the
|
||
# distribution one is too old. Restoring a panel backup made by a newer pg_dump
|
||
# needs this: x-ui pgclient <major>
|
||
pg_upgrade_client() {
|
||
local want="$1" have
|
||
if [[ -n "$want" && ! "$want" =~ ^[0-9]+$ ]]; then
|
||
LOGE "Invalid PostgreSQL major version '${want}' (expected a number like 17)."
|
||
return 1
|
||
fi
|
||
have=$(pg_client_major)
|
||
if [[ -n "$have" ]]; then
|
||
if [[ -z "$want" || "$have" -ge "$want" ]]; then
|
||
LOGI "PostgreSQL client tools are already installed (version ${have})."
|
||
return 0
|
||
fi
|
||
LOGI "Installed PostgreSQL client tools are version ${have}; version ${want} or newer is required."
|
||
fi
|
||
if [[ "${running_in_docker}" == "true" ]]; then
|
||
LOGI "Note: packages installed inside the container are lost when the container is recreated."
|
||
fi
|
||
case "${release}" in
|
||
ubuntu | debian | armbian)
|
||
apt-get update >&2 || return 1
|
||
if [[ -z "$want" ]]; then
|
||
apt-get install -y -q postgresql-client >&2 || return 1
|
||
elif ! apt-get install -y -q "postgresql-client-${want}" >&2; then
|
||
LOGI "postgresql-client-${want} is not in the distribution repositories; adding the official PostgreSQL apt repository..."
|
||
apt-get install -y -q postgresql-common ca-certificates >&2 || return 1
|
||
/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y >&2 || return 1
|
||
apt-get install -y -q "postgresql-client-${want}" >&2 || return 1
|
||
fi
|
||
;;
|
||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos)
|
||
local pkg_mgr="dnf"
|
||
command -v dnf > /dev/null 2>&1 || pkg_mgr="yum"
|
||
if [[ -z "$want" ]]; then
|
||
"$pkg_mgr" install -y -q postgresql >&2 || return 1
|
||
elif ! "$pkg_mgr" install -y -q "postgresql${want}" >&2; then
|
||
local elver
|
||
elver=$(rpm -E %rhel 2> /dev/null)
|
||
if [[ ! "$elver" =~ ^[0-9]+$ ]]; then
|
||
LOGE "Could not determine the Enterprise Linux release; install the PostgreSQL ${want} client tools manually."
|
||
return 1
|
||
fi
|
||
LOGI "postgresql${want} is not in the enabled repositories; adding the official PostgreSQL yum repository..."
|
||
"$pkg_mgr" install -y "https://download.postgresql.org/pub/repos/yum/reporpms/EL-${elver}-$(uname -m)/pgdg-redhat-repo-latest.noarch.rpm" >&2 || return 1
|
||
if [[ "$pkg_mgr" == "dnf" ]]; then
|
||
dnf -qy module disable postgresql >&2 || true
|
||
fi
|
||
"$pkg_mgr" install -y -q "postgresql${want}" >&2 || return 1
|
||
fi
|
||
;;
|
||
arch | manjaro | parch)
|
||
pacman -Sy --noconfirm postgresql >&2 || return 1
|
||
;;
|
||
opensuse-tumbleweed | opensuse-leap)
|
||
if [[ -z "$want" ]] || ! zypper -q install -y "postgresql${want}" >&2; then
|
||
zypper -q install -y postgresql >&2 || return 1
|
||
fi
|
||
;;
|
||
alpine)
|
||
if [[ -z "$want" ]] || ! apk add --no-cache "postgresql${want}-client" >&2; then
|
||
apk add --no-cache postgresql-client >&2 || return 1
|
||
fi
|
||
;;
|
||
*)
|
||
LOGE "Unsupported OS '${release}'; install the PostgreSQL client tools manually."
|
||
return 1
|
||
;;
|
||
esac
|
||
hash -r 2> /dev/null
|
||
have=$(pg_client_major)
|
||
if [[ -n "$want" && ( -z "$have" || "$have" -lt "$want" ) && -x "/usr/pgsql-${want}/bin/pg_restore" ]]; then
|
||
ln -sf "/usr/pgsql-${want}/bin/pg_dump" /usr/local/bin/pg_dump
|
||
ln -sf "/usr/pgsql-${want}/bin/pg_restore" /usr/local/bin/pg_restore
|
||
hash -r 2> /dev/null
|
||
have=$(pg_client_major)
|
||
fi
|
||
if [[ -z "$have" ]]; then
|
||
LOGE "pg_dump/pg_restore are still unavailable after installation."
|
||
return 1
|
||
fi
|
||
if [[ -n "$want" && "$have" -lt "$want" ]]; then
|
||
LOGE "PostgreSQL client tools are version ${have} after installation but ${want} or newer is required; install them manually."
|
||
return 1
|
||
fi
|
||
LOGI "PostgreSQL client tools are ready (version ${have})."
|
||
return 0
|
||
}
|
||
|
||
# Writes XUI_DB_TYPE/XUI_DB_DSN into the service env file, preserving other entries.
|
||
pg_write_env() {
|
||
local dsn="$1" envfile
|
||
envfile="$(xui_env_file_path)"
|
||
install -d -m 755 "$(dirname "$envfile")"
|
||
touch "$envfile"
|
||
sed -i '/^XUI_DB_TYPE=/d; /^XUI_DB_DSN=/d' "$envfile"
|
||
{
|
||
echo "XUI_DB_TYPE=postgres"
|
||
echo "XUI_DB_DSN=${dsn}"
|
||
} >> "$envfile"
|
||
chmod 600 "$envfile"
|
||
}
|
||
|
||
pg_install_server_action() {
|
||
if postgresql_installed; then
|
||
LOGI "PostgreSQL already appears to be installed on this system."
|
||
confirm "Run setup anyway (ensures the xui database/user exist)?" "n" || return 0
|
||
fi
|
||
LOGI "Installing PostgreSQL server and creating a dedicated user/database..."
|
||
local dsn
|
||
dsn=$(pg_install_local)
|
||
if [[ $? -ne 0 || -z "$dsn" ]]; then
|
||
LOGE "PostgreSQL installation failed."
|
||
return 1
|
||
fi
|
||
PG_LAST_DSN="$dsn"
|
||
pg_ensure_client || LOGE "Could not install pg_dump/pg_restore (panel DB backup may be unavailable)."
|
||
echo ""
|
||
LOGI "PostgreSQL is installed and ready."
|
||
echo -e "${green}Connection DSN:${plain} ${dsn}"
|
||
echo -e "${yellow}Use option 2 to migrate your SQLite data and switch the panel to PostgreSQL.${plain}"
|
||
}
|
||
|
||
# Copies the current SQLite data into PostgreSQL, then switches the panel over.
|
||
migrate_to_postgres() {
|
||
if [[ ! -x "${xui_folder}/x-ui" ]]; then
|
||
LOGE "x-ui is not installed."
|
||
return 1
|
||
fi
|
||
echo ""
|
||
echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}"
|
||
echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}"
|
||
echo -e "${red}Any existing panel tables in the destination will be cleared and overwritten.${plain}"
|
||
confirm "Continue?" "n" || return 0
|
||
|
||
local dsn="" pg_mode
|
||
if [[ -n "$PG_LAST_DSN" ]]; then
|
||
echo -e "A PostgreSQL database was created in this session:"
|
||
echo -e " ${green}${PG_LAST_DSN}${plain}"
|
||
confirm "Migrate into this database?" "y" && dsn="$PG_LAST_DSN"
|
||
fi
|
||
|
||
if [[ -z "$dsn" ]]; then
|
||
echo ""
|
||
echo -e "${green}\t1.${plain} Install PostgreSQL locally and create a dedicated user/db (recommended)"
|
||
echo -e "${green}\t2.${plain} Use an existing PostgreSQL server (enter DSN)"
|
||
read -rp "Choose [1]: " pg_mode
|
||
pg_mode="${pg_mode:-1}"
|
||
if [[ "$pg_mode" == "2" ]]; then
|
||
while [[ -z "$dsn" ]]; do
|
||
read -rp "Enter PostgreSQL DSN (postgres://user:pass@host:port/dbname?sslmode=disable): " dsn
|
||
dsn="${dsn// /}"
|
||
done
|
||
else
|
||
LOGI "Installing PostgreSQL locally (this may take a moment)..."
|
||
dsn=$(pg_install_local)
|
||
if [[ $? -ne 0 || -z "$dsn" ]]; then
|
||
LOGE "PostgreSQL installation failed. Aborting migration."
|
||
return 1
|
||
fi
|
||
PG_LAST_DSN="$dsn"
|
||
fi
|
||
fi
|
||
|
||
pg_ensure_client || LOGE "Could not install pg_dump/pg_restore (in-panel DB backup/restore may be unavailable)."
|
||
|
||
LOGI "Stopping panel to take a consistent snapshot..."
|
||
stop 0 > /dev/null 2>&1
|
||
|
||
echo ""
|
||
LOGI "Migrating data into PostgreSQL..."
|
||
if ! ${xui_folder}/x-ui migrate-db --dsn "$dsn"; then
|
||
LOGE "Migration failed. The panel was NOT switched to PostgreSQL."
|
||
start 0 > /dev/null 2>&1
|
||
return 1
|
||
fi
|
||
|
||
pg_write_env "$dsn"
|
||
LOGI "Wrote database settings to $(xui_env_file_path) (XUI_DB_TYPE=postgres)."
|
||
LOGI "Restarting panel on PostgreSQL..."
|
||
restart 0
|
||
sleep 1
|
||
if check_status; then
|
||
LOGI "Migration complete. The panel is now running on PostgreSQL."
|
||
else
|
||
LOGE "Panel did not come up. Check logs (main menu option 17). Your SQLite data is left intact."
|
||
fi
|
||
}
|
||
|
||
postgresql_menu() {
|
||
echo -e "${green}\t1.${plain} ${green}Install${plain} PostgreSQL (server + client + xui db)"
|
||
echo -e "${green}\t2.${plain} Migrate SQLite ${green}->${plain} PostgreSQL"
|
||
echo -e "${green}\t3.${plain} Status (clusters & port 5432)"
|
||
echo -e "${green}\t4.${plain} ${green}Start${plain} PostgreSQL"
|
||
echo -e "${green}\t5.${plain} ${red}Stop${plain} PostgreSQL"
|
||
echo -e "${green}\t6.${plain} Restart PostgreSQL"
|
||
echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
|
||
echo -e "${green}\t8.${plain} View PostgreSQL Log"
|
||
echo -e "${green}\t9.${plain} Convert SQLite ${green}.db <-> .dump${plain}"
|
||
echo -e "${green}\t10.${plain} Install/Upgrade client tools (pg_dump/pg_restore)"
|
||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||
read -rp "Choose an option: " choice
|
||
case "$choice" in
|
||
0)
|
||
show_menu
|
||
;;
|
||
1)
|
||
pg_install_server_action
|
||
postgresql_menu
|
||
;;
|
||
2)
|
||
migrate_to_postgres
|
||
postgresql_menu
|
||
;;
|
||
3)
|
||
postgresql_status
|
||
postgresql_menu
|
||
;;
|
||
4)
|
||
postgresql_start
|
||
postgresql_menu
|
||
;;
|
||
5)
|
||
postgresql_stop
|
||
postgresql_menu
|
||
;;
|
||
6)
|
||
postgresql_restart
|
||
postgresql_menu
|
||
;;
|
||
7)
|
||
postgresql_enable
|
||
postgresql_menu
|
||
;;
|
||
8)
|
||
postgresql_log
|
||
postgresql_menu
|
||
;;
|
||
9)
|
||
migrate_db_prompt
|
||
postgresql_menu
|
||
;;
|
||
10)
|
||
read -rp "Required PostgreSQL major version (empty = any): " pg_client_ver
|
||
pg_upgrade_client "$pg_client_ver"
|
||
postgresql_menu
|
||
;;
|
||
*)
|
||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||
postgresql_menu
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# Convert between the panel's SQLite database and a portable .dump (SQL text)
|
||
# file using the bundled x-ui binary. With no arguments it dumps the installed
|
||
# panel database; an optional second argument overrides the output path.
|
||
# x-ui migrateDB [file.db|file.dump] [output]
|
||
migrate_db() {
|
||
local input="$1" output="$2"
|
||
local default_db="/etc/x-ui/x-ui.db"
|
||
local bin="${xui_folder}/x-ui"
|
||
|
||
[[ -z "$input" ]] && input="$default_db"
|
||
|
||
if [[ ! -x "$bin" ]]; then
|
||
LOGE "x-ui binary not found at ${bin}. Is the panel installed?"
|
||
return 1
|
||
fi
|
||
|
||
if ! "$bin" migrate-db -h 2>&1 | grep -q -- '-dump'; then
|
||
LOGE "This x-ui build does not support .db <-> .dump conversion yet."
|
||
LOGE "Update the panel first (x-ui update) to a version with 'migrate-db --dump/--restore'."
|
||
return 1
|
||
fi
|
||
|
||
if [[ ! -f "$input" ]]; then
|
||
LOGE "Input file not found: ${input}"
|
||
echo -e "Usage: ${green}x-ui migrateDB [file.db|file.dump] [output]${plain}"
|
||
return 1
|
||
fi
|
||
|
||
local mode
|
||
case "$input" in
|
||
*.db | *.sqlite | *.sqlite3)
|
||
mode="dump"
|
||
;;
|
||
*.dump | *.sql)
|
||
mode="restore"
|
||
;;
|
||
*)
|
||
if head -c 16 "$input" | grep -q "SQLite format 3"; then
|
||
mode="dump"
|
||
else
|
||
mode="restore"
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
if [[ "$mode" == "dump" ]]; then
|
||
[[ -z "$output" ]] && output="${input%.*}.dump"
|
||
if [[ -f "$output" ]]; then
|
||
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
|
||
fi
|
||
LOGI "Dumping SQLite database to SQL text:"
|
||
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
|
||
if "$bin" migrate-db --src "$input" --dump "$output"; then
|
||
LOGI "Done. Wrote ${output}."
|
||
else
|
||
LOGE "Dump failed."
|
||
return 1
|
||
fi
|
||
else
|
||
[[ -z "$output" ]] && output="${input%.*}.db"
|
||
if [[ "$output" == "$default_db" ]] && check_status > /dev/null 2>&1; then
|
||
LOGE "Refusing to restore into the live database (${default_db}) while x-ui is running."
|
||
LOGE "Stop the panel first (x-ui stop) or choose a different output path."
|
||
return 1
|
||
fi
|
||
if [[ -f "$output" ]]; then
|
||
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
|
||
rm -f "$output"
|
||
fi
|
||
LOGI "Rebuilding SQLite database from SQL text:"
|
||
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
|
||
if "$bin" migrate-db --restore "$input" --out "$output"; then
|
||
LOGI "Done. Created ${output}."
|
||
else
|
||
LOGE "Restore failed."
|
||
rm -f "$output"
|
||
return 1
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# Interactive wrapper around migrate_db for the menu: prompts for the paths and
|
||
# lets migrate_db auto-detect the direction.
|
||
migrate_db_prompt() {
|
||
local default_db="/etc/x-ui/x-ui.db"
|
||
local input output
|
||
echo -e "Convert between a SQLite ${green}.db${plain} and a portable ${green}.dump${plain} (direction auto-detected)."
|
||
read -rp "Input file [${default_db}]: " input
|
||
input="${input:-$default_db}"
|
||
read -rp "Output file (leave empty to auto-name next to input): " output
|
||
migrate_db "$input" "$output"
|
||
}
|
||
|
||
show_usage() {
|
||
echo -e "┌────────────────────────────────────────────────────────────────┐
|
||
│ ${blue}x-ui control menu usages (subcommands):${plain} │
|
||
│ │
|
||
│ ${blue}x-ui${plain} - Admin Management Script │
|
||
│ ${blue}x-ui start${plain} - Start │
|
||
│ ${blue}x-ui stop${plain} - Stop │
|
||
│ ${blue}x-ui restart${plain} - Restart │
|
||
| ${blue}x-ui restart-xray${plain} - Restart Xray │
|
||
│ ${blue}x-ui status${plain} - Current Status │
|
||
│ ${blue}x-ui settings${plain} - Current Settings │
|
||
│ ${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
|
||
│ ${blue}x-ui disable${plain} - Disable Autostart on OS Startup │
|
||
│ ${blue}x-ui log${plain} - Check logs │
|
||
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
|
||
│ ${blue}x-ui update${plain} - Update │
|
||
│ ${blue}x-ui update-dev${plain} - Update to Dev channel (latest) │
|
||
│ ${blue}x-ui update-all-geofiles${plain} - Update all geo files │
|
||
│ ${blue}x-ui migrateDB [file]${plain} - Convert .db <-> .dump (SQLite) │
|
||
│ ${blue}x-ui pgclient [ver]${plain} - Upgrade pg_dump/pg_restore tools │
|
||
│ ${blue}x-ui legacy${plain} - Legacy version │
|
||
│ ${blue}x-ui install${plain} - Install │
|
||
│ ${blue}x-ui uninstall${plain} - Uninstall │
|
||
└────────────────────────────────────────────────────────────────┘"
|
||
}
|
||
|
||
show_menu() {
|
||
echo -e "
|
||
╔────────────────────────────────────────────────╗
|
||
│ ${green}3X-UI Panel Management Script${plain} │
|
||
│ ${green}0.${plain} Exit Script │
|
||
│────────────────────────────────────────────────│
|
||
│ ${green}1.${plain} Install │
|
||
│ ${green}2.${plain} Update │
|
||
│ ${green}3.${plain} Update to Dev Channel (latest commit) │
|
||
│ ${green}4.${plain} Update Menu │
|
||
│ ${green}5.${plain} Legacy Version │
|
||
│ ${green}6.${plain} Uninstall │
|
||
│────────────────────────────────────────────────│
|
||
│ ${green}7.${plain} Reset Username & Password │
|
||
│ ${green}8.${plain} Reset Web Base Path │
|
||
│ ${green}9.${plain} Reset Settings │
|
||
│ ${green}10.${plain} Change Port │
|
||
│ ${green}11.${plain} View Current Settings │
|
||
│────────────────────────────────────────────────│
|
||
│ ${green}12.${plain} Start │
|
||
│ ${green}13.${plain} Stop │
|
||
│ ${green}14.${plain} Restart │
|
||
| ${green}15.${plain} Restart Xray │
|
||
│ ${green}16.${plain} Check Status │
|
||
│ ${green}17.${plain} Logs Management │
|
||
│────────────────────────────────────────────────│
|
||
│ ${green}18.${plain} Enable Autostart │
|
||
│ ${green}19.${plain} Disable Autostart │
|
||
│────────────────────────────────────────────────│
|
||
│ ${green}20.${plain} SSL Certificate Management │
|
||
│ ${green}21.${plain} Cloudflare SSL Certificate │
|
||
│ ${green}22.${plain} IP Limit Management │
|
||
│ ${green}23.${plain} Firewall Management │
|
||
│ ${green}24.${plain} SSH Port Forwarding Management │
|
||
│ ${green}25.${plain} PostgreSQL Management │
|
||
│────────────────────────────────────────────────│
|
||
│ ${green}26.${plain} Enable BBR │
|
||
│ ${green}27.${plain} Update Geo Files │
|
||
│ ${green}28.${plain} Speedtest by Ookla │
|
||
╚────────────────────────────────────────────────╝
|
||
"
|
||
show_status
|
||
echo && read -rp "Please enter your selection [0-28]: " num
|
||
|
||
case "${num}" in
|
||
0)
|
||
exit 0
|
||
;;
|
||
1)
|
||
check_uninstall && install
|
||
;;
|
||
2)
|
||
check_install && update
|
||
;;
|
||
3)
|
||
check_install && update_dev
|
||
;;
|
||
4)
|
||
check_install && update_menu
|
||
;;
|
||
5)
|
||
check_install && legacy_version
|
||
;;
|
||
6)
|
||
check_install && uninstall
|
||
;;
|
||
7)
|
||
check_install && reset_user
|
||
;;
|
||
8)
|
||
check_install && reset_webbasepath
|
||
;;
|
||
9)
|
||
check_install && reset_config
|
||
;;
|
||
10)
|
||
check_install && set_port
|
||
;;
|
||
11)
|
||
check_install && check_config
|
||
;;
|
||
12)
|
||
check_install && start
|
||
;;
|
||
13)
|
||
check_install && stop
|
||
;;
|
||
14)
|
||
check_install && restart
|
||
;;
|
||
15)
|
||
check_install && restart_xray
|
||
;;
|
||
16)
|
||
check_install && status
|
||
;;
|
||
17)
|
||
check_install && show_log
|
||
;;
|
||
18)
|
||
check_install && enable
|
||
;;
|
||
19)
|
||
check_install && disable
|
||
;;
|
||
20)
|
||
ssl_cert_issue_main
|
||
;;
|
||
21)
|
||
ssl_cert_issue_CF
|
||
;;
|
||
22)
|
||
iplimit_main
|
||
;;
|
||
23)
|
||
firewall_menu
|
||
;;
|
||
24)
|
||
SSH_port_forwarding
|
||
;;
|
||
25)
|
||
postgresql_menu
|
||
;;
|
||
26)
|
||
bbr_menu
|
||
;;
|
||
27)
|
||
update_geo
|
||
;;
|
||
28)
|
||
run_speedtest
|
||
;;
|
||
*)
|
||
LOGE "Please enter the correct number [0-28]"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
if [[ $# > 0 ]]; then
|
||
case $1 in
|
||
"start")
|
||
check_install 0 && start 0
|
||
;;
|
||
"stop")
|
||
check_install 0 && stop 0
|
||
;;
|
||
"restart")
|
||
check_install 0 && restart 0
|
||
;;
|
||
"restart-xray")
|
||
check_install 0 && restart_xray 0
|
||
;;
|
||
"status")
|
||
check_install 0 && status 0
|
||
;;
|
||
"settings")
|
||
check_install 0 && check_config 0
|
||
;;
|
||
"enable")
|
||
check_install 0 && enable 0
|
||
;;
|
||
"disable")
|
||
check_install 0 && disable 0
|
||
;;
|
||
"log")
|
||
check_install 0 && show_log 0
|
||
;;
|
||
"banlog")
|
||
check_install 0 && show_banlog 0
|
||
;;
|
||
"setup-fail2ban")
|
||
setup_fail2ban_iplimit
|
||
;;
|
||
"update")
|
||
check_install 0 && update 0
|
||
;;
|
||
"update-dev")
|
||
check_install 0 && update_dev 0
|
||
;;
|
||
"legacy")
|
||
check_install 0 && legacy_version 0
|
||
;;
|
||
"install")
|
||
check_uninstall 0 && install 0
|
||
;;
|
||
"uninstall")
|
||
check_install 0 && uninstall 0
|
||
;;
|
||
"update-all-geofiles")
|
||
geo_updated=0
|
||
if check_install 0 && update_all_geofiles 0; then
|
||
[[ $geo_updated -eq 0 ]] || restart 0
|
||
fi
|
||
;;
|
||
"migrateDB")
|
||
migrate_db "$2" "$3"
|
||
;;
|
||
"pgclient")
|
||
pg_upgrade_client "$2"
|
||
;;
|
||
*) show_usage ;;
|
||
esac
|
||
else
|
||
show_menu
|
||
fi
|