w-ui.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/bash
# W-UI management script.
#
# Everything an operator does to the panel from a terminal lives here, laid
# out the way the classic menu script is: the same menu, the same numbers, the same
# answers, for a panel that runs WireGuard and OpenVPN instead of Xray.
# Anything that needs the database goes through the panel binary rather than
# reading SQLite from a shell, so the schema has exactly one implementation.
red='\033[0;31m'
green='\033[0;32m'
blue='\033[0;34m'
yellow='\033[0;33m'
cyan='\033[0;36m'
bold='\033[1m'
dim='\033[2m'
plain='\033[0m'
# The panel's own colour, for what is ours rather than a state.
brand='\033[38;5;203m'
# One glyph and one colour per kind of line, the same on every screen: a
# tick for done, a cross for failed, a bang for a warning, a faint line for
# what only matters when something has gone wrong.
function LOGD() {
echo -e " ${dim}· $*${plain}"
}
function LOGE() {
echo -e " ${red}✗ $*${plain}"
}
function LOGI() {
echo -e " ${green}✓${plain} $*"
}
function LOGW() {
echo -e " ${yellow}! $*${plain}"
}
# ── drawing ──────────────────────────────────────────────────────────────────
# The menu is a box, and a box is only a box when every line ends in the same
# column. Colour codes take no space on the screen but do in the string, so
# the padding is worked out on the text with the codes stripped.
BOX_W=54
visible_len() {
local t
t=$(printf '%b' "$1" | sed 's/\x1b\[[0-9;]*m//g')
printf '%s' "${#t}"
}
box_top() { printf ' ╭%s╮\n' "$(printf '─%.0s' $(seq 1 $BOX_W))"; }
box_mid() { printf ' ├%s┤\n' "$(printf '─%.0s' $(seq 1 $BOX_W))"; }
box_end() { printf ' ╰%s╯\n' "$(printf '─%.0s' $(seq 1 $BOX_W))"; }
# box_row TEXT: one line inside the box, padded to the border.
box_row() {
local text="$1" pad
pad=$((BOX_W - 2 - $(visible_len "$text")))
[[ $pad -lt 0 ]] && pad=0
printf ' │ %b%*s │\n' "$text" "$pad" ""
}
# box_item NUMBER LABEL: a numbered choice, the numbers in one column.
box_item() {
box_row "$(printf '%b%3s%b %s' "$cyan" "$1" "$plain" "$2")"
}
# box_section TITLE: a faint heading between groups of choices.
box_section() {
box_row "$(printf '%b%s%b' "$dim" "$1" "$plain")"
}
# box_kv LABEL VALUE: a state line, the value already coloured.
box_kv() {
box_row "$(printf '%-22s %b' "$1" "$2")"
}
# The name, the way the panel writes it, above every screen.
banner() {
local ver
ver=$(panel_version)
echo
echo -e " ${brand}${bold}W-UI${plain} ${dim}·${plain} WireGuard · OpenVPN · OpenConnect · IKEv2 · L2TP · PPTP ${dim}${ver}${plain}"
}
panel_version() {
if [[ -x "$BIN_PATH" ]]; then
"$BIN_PATH" version 2> /dev/null | head -1
fi
}
# menu_item NUMBER LABEL: one choice of a sub-menu, numbers in one column.
menu_item() {
echo -e " ${cyan}$(printf '%3s' "$1")${plain} $2"
}
# ask VAR "question" [default]: a prompt that looks the same everywhere.
prompt() {
if [[ -n "${2:-}" ]]; then
echo -ne " ${cyan}▸${plain} $1 ${dim}[$2]${plain}: "
else
echo -ne " ${cyan}▸${plain} $1: "
fi
}
# 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. 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
# Declare Variables
BIN_PATH=/usr/local/bin/wui
DATA_DIR=/var/lib/wui
CONF_DIR=/etc/wui
BACKUP_DIR=/var/backups/wui
ENV_FILE=$CONF_DIR/wui.env
CERT_ROOT=$CONF_DIR/certs
SERVICE=wui
SERVICE_USER=wui
REPO=AbolfazlTafakori/w-ui
REPO_RAW=https://raw.githubusercontent.com/${REPO}/main
log_folder="${WUI_LOG_FOLDER:=/var/log/wui}"
mkdir -p "${log_folder}"
iplimit_banned_log_path="${log_folder}/wui-banned.log"
# acme.sh lives under root's home regardless of who ran this: its renewal
# runs as root, and a script run under `sudo` often carries the calling
# user's HOME.
ACME_HOME="$( { getent passwd root 2>/dev/null || true; } | cut -d: -f6)"
ACME_HOME="${ACME_HOME:-/root}/.acme.sh"
acme() { "$ACME_HOME/acme.sh" --home "$ACME_HOME" "$@"; }
have() { command -v "$1" > /dev/null 2>&1; }
confirm() {
# A question nobody is there to answer is answered no. With input at
# end-of-file the default would otherwise be taken, and the default for
# "update?" is yes -- which is how a piped run once reinstalled a panel.
if [[ $# -gt 1 ]]; then
echo && prompt "$1" "$2" && read -r temp || return 1
if [[ "${temp}" == "" ]]; then
temp=$2
fi
else
prompt "$1" "y/n" && read -r temp || return 1
fi
if [[ "${temp}" == "y" || "${temp}" == "Y" ]]; then
return 0
else
return 1
fi
}
confirm_restart() {
confirm "Restart the panel, Attention: Restarting the panel does not disconnect customers" "y"
if [[ $? == 0 ]]; then
restart
else
show_menu
fi
}
before_show_menu() {
echo && echo -n -e " ${dim}Press Enter to return to the menu${plain} " && read -r temp
show_menu
}
# panel_cli runs the binary with the same configuration the service uses, so
# what it prints is what the panel actually sees.
panel_cli() {
(
set -a
# shellcheck disable=SC2034 # exported by set -a, for the panel binary
WUI_DATA_DIR="$DATA_DIR"
# shellcheck disable=SC2034
WUI_DB_SOURCE="$DATA_DIR/wui.db"
for kv in $(grep -oE 'WUI_[A-Z_]+=[^ ]+' "/etc/systemd/system/${SERVICE}.service" 2> /dev/null); do
# shellcheck disable=SC2163 # each word is NAME=value, exported as such
export "$kv"
done
# The database, whichever engine: the installer keeps the driver and
# the connection string here, root-only, and the unit reads the same.
# shellcheck source=/dev/null
[[ -f "$CONF_DIR/db.env" ]] && . "$CONF_DIR/db.env"
# shellcheck source=/dev/null
[[ -f "$ENV_FILE" ]] && . "$ENV_FILE"
set +a
# A backup or a restore writes files the panel later has to own --
# the archive it lists, the staged restore it applies at start -- so
# those run as the service's own user, with the environment kept.
if [[ "${1:-}" == backup ]] && id -u "$SERVICE_USER" > /dev/null 2>&1; then
runuser -u "$SERVICE_USER" --preserve-environment -- "$BIN_PATH" "$@"
else
"$BIN_PATH" "$@"
fi
)
}
# What the panel answers on, as the panel itself sees it.
setting_value() {
panel_cli setting show 2> /dev/null | grep -E "^$1: " | head -1 | cut -d' ' -f2-
}
install() {
bash <(curl -Ls "${REPO_RAW}/install.sh")
if [[ $? == 0 ]]; then
if [[ $# == 0 ]]; then
start
else
start 0
fi
fi
}
update() {
confirm "This function will update all W-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
bash <(curl -Ls "${REPO_RAW}/update.sh")
if [[ $? == 0 ]]; then
LOGI "Update is complete, Panel has automatically restarted "
before_show_menu
fi
}
update_dev() {
confirm "This will update W-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
# WUI_UPDATE_TAG tells update.sh to install the dev-latest pre-release
# instead of the latest stable tag.
WUI_UPDATE_TAG="dev-latest" bash <(curl -Ls "${REPO_RAW}/update.sh")
if [[ $? == 0 ]]; then
LOGI "Dev update is complete, Panel has automatically restarted "
before_show_menu
fi
}
replace_wui_script() {
local url="$1"
local use_if_modified_since="$2"
local temp_file="/usr/local/bin/w-ui-temp.$$"
rm -f "$temp_file"
if [[ "$use_if_modified_since" == "true" ]]; then
curl -fLRo "$temp_file" -z /usr/local/bin/w-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/local/bin/w-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/local/bin/w-ui
if [[ $? != 0 ]]; then
rm -f "$temp_file"
return 1
fi
chmod +x /usr/local/bin/w-ui
return 0
}
# The menu must match the installed panel, so update it from that release's
# tag; fall back to main only when no script is published for the version.
installed_script_url() {
local ver
ver=$("$BIN_PATH" version 2> /dev/null | tr -d '[:space:]')
ver="${ver#v}"
if [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && curl -fsIL -o /dev/null "https://raw.githubusercontent.com/${REPO}/v${ver}/w-ui.sh"; then
echo "https://raw.githubusercontent.com/${REPO}/v${ver}/w-ui.sh"
else
echo -e "${yellow}No w-ui.sh published for the installed version (${ver:-unknown}), using main${plain}" >&2
echo "${REPO_RAW}/w-ui.sh"
fi
}
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"
before_show_menu
return 0
fi
if replace_wui_script "$(installed_script_url)" "false"; then
echo -e "${green}Update successful. The menu has been replaced; run w-ui again.${plain}"
exit 0
else
echo -e "${red}Failed to update the menu.${plain}"
return 1
fi
}
legacy_version() {
echo -n "Enter the panel version (like 1.4.0):"
read -r tag_version
if [ -z "$tag_version" ]; then
echo "Panel version cannot be empty. Exiting."
exit 1
fi
tag_version="${tag_version#v}"
# Use the entered panel version in the download link
echo "Downloading and installing panel version $tag_version..."
bash <(curl -Ls "${REPO_RAW}/update.sh") "v${tag_version}"
}
# Function to handle the deletion of the script file
delete_script() {
rm "$0" # Remove the script file itself
exit 1
}
uninstall() {
confirm "Are you sure you want to uninstall the panel? Every tunnel it runs will also be removed!" "n"
if [[ $? != 0 ]]; then
if [[ $# == 0 ]]; then
show_menu
fi
return 0
fi
systemctl stop "$SERVICE"
systemctl disable "$SERVICE"
systemctl disable --now wui-cert-renew.timer 2> /dev/null
rm -f /etc/systemd/system/${SERVICE}.service /etc/systemd/system/wui-cert-renew.service /etc/systemd/system/wui-cert-renew.timer
systemctl daemon-reload
systemctl reset-failed
# Never the only copy: the database and every key go to root's home
# before they are deleted, so an uninstall typed on the wrong server is
# a mistake and not a disaster.
if [[ -d "$DATA_DIR" ]]; then
local keep
keep="/root/wui-last-copy-$(date +%Y%m%d-%H%M%S).tar.gz"
# The panel's own archive when it can make one -- it holds the
# database from either engine, restorable into either -- and a
# plain copy of the directory when it cannot.
local made=""
# Written where the service's user can write, then moved: the panel
# runs the backup as that user, and /root is not its to write in.
[[ -x "$BIN_PATH" ]] && made=$(panel_cli backup create --dir "$BACKUP_DIR" 2> /dev/null | tail -1)
if [[ -n "$made" && -f "$made" ]]; then
mv -f "$made" "$keep" && chmod 0600 "$keep" && LOGI "A copy of the database and keys is at ${keep}"
else
tar czf "$keep" --exclude='*/backups' -C / "${DATA_DIR#/}" "${CONF_DIR#/}" 2> /dev/null && chmod 0600 "$keep" && LOGI "A copy of the database and keys is at ${keep}"
fi
fi
rm -rf "${CONF_DIR:?}"/
rm -rf "${DATA_DIR:?}"/
rm -f "$BIN_PATH"
[[ -d /var/backups/wui ]] && LOGI "Scheduled backups were kept at /var/backups/wui"
echo ""
echo -e "Uninstalled Successfully.\n"
echo "If you need to install this panel again, you can use below command:"
echo -e "${green}bash <(curl -Ls ${REPO_RAW}/install.sh)${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
show_menu
return 0
fi
prompt "Please set the login username" "random" && read -r config_account
[[ -z $config_account ]] && config_account=$(gen_random_string 10)
prompt "Please set the login password" "random" && read -r config_password
[[ -z $config_password ]] && config_password=$(gen_random_string 18)
prompt "Do you want to disable currently configured two-factor authentication?" "y/n" && read -r twoFactorConfirm
if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then
panel_cli admin reset --username "${config_account}" --password "${config_password}" --quiet > /dev/null 2>&1
else
panel_cli admin reset --username "${config_account}" --password "${config_password}" --reset-two-factor --quiet > /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 W-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}"
prompt "Are you sure you want to reset the web base path?" "y/n" && read -r 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
panel_cli setting set --base-path "/${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
show_menu
return 0
fi
panel_cli setting reset
echo -e "All panel settings have been reset to default."
restart
}
# This server's address as the internet sees it, from the first echo service
# that answers.
detect_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 ip_address
for ip_address in "${URL_lists[@]}"; do
local response
response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
local http_code
http_code=$(echo "$response" | tail -n1)
local ip_result
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
echo "${ip_result}"
return 0
fi
done
return 1
}
ask_server_ip() {
local server_ip=""
while [[ -z "$server_ip" ]]; do
prompt "Please enter your server's public IPv4 address" && read -r 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
echo "$server_ip"
}
check_config() {
local info
info=$(panel_cli setting show)
if [[ $? != 0 ]]; then
LOGE "get current settings error, please check logs"
show_menu
return
fi
LOGI "${info}"
local db_driver db_source
db_driver=$(echo "$info" | grep -Eo 'dbDriver: .+' | awk '{print $2}')
db_source=$(echo "$info" | grep -Eo 'dbSource: .+' | awk '{print $2}')
if [[ "$db_driver" == postgres ]]; then
echo -e "${green}Database: PostgreSQL (${db_source%%\?*} — credentials in ${CONF_DIR}/db.env)${plain}" | sed -E 's|//[^:]+:[^@]+@|//wui:redacted@|'
else
echo -e "${green}Database: SQLite (${db_source:-$DATA_DIR/wui.db})${plain}"
fi
local existing_webBasePath
existing_webBasePath=$(echo "$info" | grep -Eo 'basePath: .+' | awk '{print $2}')
local existing_port
existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
local existing_cert
existing_cert=$(echo "$info" | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
local server_ip
server_ip=$(detect_server_ip)
if [[ -z "$server_ip" ]]; then
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
server_ip=$(ask_server_ip)
fi
if [[ -n "$existing_cert" ]]; then
local domain
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.
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}"
prompt "Generate SSL certificate for IP now?" "y/N" && read -r 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
panel_cli setting set --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
systemctl start "$SERVICE"
sleep 2
check_status
if [[ $? == 0 ]]; then
LOGI "w-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
systemctl stop "$SERVICE"
sleep 2
check_status
if [[ $? == 1 ]]; then
LOGI "w-ui stopped successfully; the tunnels keep serving customers"
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() {
systemctl restart "$SERVICE"
sleep 2
check_status
if [[ $? == 0 ]]; then
LOGI "w-ui 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
}
# Every tunnel brought up again from its stored configuration, without the
# panel itself going down: what "restart xray" is on the classic panel.
restart_tunnels() {
systemctl reload "$SERVICE"
LOGI "Tunnel restart signal sent successfully, Please check the log information to confirm whether the tunnels restarted successfully"
sleep 2
show_tunnel_status
if [[ $# == 0 ]]; then
before_show_menu
fi
}
status() {
systemctl status "$SERVICE" -l
if [[ $# == 0 ]]; then
before_show_menu
fi
}
enable() {
systemctl enable "$SERVICE"
if [[ $? == 0 ]]; then
LOGI "w-ui Set to boot automatically on startup successfully"
else
LOGE "w-ui Failed to set Autostart"
fi
if [[ $# == 0 ]]; then
before_show_menu
fi
}
disable() {
systemctl disable "$SERVICE"
if [[ $? == 0 ]]; then
LOGI "w-ui Autostart Cancelled successfully"
else
LOGE "w-ui Failed to cancel autostart"
fi
if [[ $# == 0 ]]; then
before_show_menu
fi
}
show_log() {
menu_item 1 "Debug Log"
menu_item 2 "Clear All logs"
menu_item 0 "Back to Main Menu"
prompt "Choose an option" && read -r choice
case "$choice" in
0)
show_menu
;;
1)
journalctl -u "$SERVICE" -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
}
bbr_menu() {
menu_item 1 "Enable BBR"
menu_item 2 "Disable BBR"
menu_item 0 "Back to Main Menu"
prompt "Choose an option" && read -r 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-w-ui.conf" ]; then
old_settings=$(head -1 /etc/sysctl.d/99-bbr-w-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
sysctl -w net.core.default_qdisc="${old_settings%:*}"
sysctl -w net.ipv4.tcp_congestion_control="${old_settings#*:}"
rm /etc/sysctl.d/99-bbr-w-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-w-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
sysctl -p /etc/sysctl.d/99-bbr-w-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() {
if replace_wui_script "$(installed_script_url)" "true"; then
LOGI "Upgrade script succeeded, Please rerun the script"
before_show_menu
else
echo ""
LOGE "Failed to download script, Please check whether the machine can connect Github"
before_show_menu
fi
}
# 0: running, 1: not running, 2: not installed
check_status() {
if [[ ! -f /etc/systemd/system/${SERVICE}.service ]]; then
return 2
fi
temp=$(systemctl status "$SERVICE" | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
if [[ "${temp}" == "running" ]]; then
return 0
else
return 1
fi
}
check_enabled() {
temp=$(systemctl is-enabled "$SERVICE")
if [[ "${temp}" == "enabled" ]]; then
return 0
else
return 1
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)
box_kv "Panel" "${green}● Running${plain}"
show_enable_status
;;
1)
box_kv "Panel" "${yellow}● Not running${plain}"
show_enable_status
;;
2)
box_kv "Panel" "${red}● Not installed${plain}"
;;
esac
show_tunnel_status
}
show_enable_status() {
check_enabled
if [[ $? == 0 ]]; then
box_kv "Start on boot" "${green}Yes${plain}"
else
box_kv "Start on boot" "${red}No${plain}"
fi
}
# show_tunnel_status is what "xray state" is on the classic panel: one line per tunnel
# the panel runs, WireGuard and OpenVPN alike.
show_tunnel_status() {
local any=0 dev seen=" "
if have wg; then
for dev in $(wg show interfaces 2> /dev/null); do
any=1; seen+="$dev "
box_kv "WireGuard ${dev}" "${dim}udp/$(wg show "$dev" listen-port 2> /dev/null)${plain} ${green}● Running${plain}"
done
fi
# AmneziaWG tunnels answer to awg, not wg.
if have awg; then
for dev in $(awg show interfaces 2> /dev/null); do
[[ "$seen" == *" $dev "* ]] && continue
any=1
box_kv "AmneziaWG ${dev}" "${dim}udp/$(awg show "$dev" listen-port 2> /dev/null)${plain} ${green}● Running${plain}"
done
fi
local conf
for conf in "$DATA_DIR"/openvpn/*/server.conf; do
[[ -f "$conf" ]] || continue
any=1
local name port proto
name=$(basename "$(dirname "$conf")")
port=$(grep -E '^port ' "$conf" | awk '{print $2}')
proto=$(grep -E '^proto ' "$conf" | awk '{print $2}')
if pgrep -f "openvpn.*${name}" > /dev/null 2>&1 || ip link show "$name" > /dev/null 2>&1; then
box_kv "OpenVPN ${name}" "${dim}${proto:-udp}/${port}${plain} ${green}● Running${plain}"
else
box_kv "OpenVPN ${name}" "${dim}${proto:-udp}/${port}${plain} ${red}● Not running${plain}"
fi
done
if [[ $any == 0 ]]; then
box_kv "Tunnels" "${dim}none running${plain}"
fi
}
firewall_menu() {
menu_item 1 "${green}Install${plain} Firewall"
menu_item 2 "Port List [numbered]"
menu_item 3 "${green}Open${plain} Ports"
menu_item 4 "${red}Delete${plain} Ports from List"
menu_item 5 "${green}Enable${plain} Firewall"
menu_item 6 "${red}Disable${plain} Firewall"
menu_item 7 "Firewall Status"
menu_item 0 "Back to Main Menu"
prompt "Choose an option" && read -r 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
local panel_port
panel_port=$(setting_value port)
[[ -n "$panel_port" ]] && ufw allow "${panel_port}/tcp" #webPort
# Every tunnel the panel runs, so enabling the firewall does not
# disconnect the customers on it.
local dev conf
for dev in $(wg show interfaces 2> /dev/null); do
ufw allow "$(wg show "$dev" listen-port 2> /dev/null)/udp"
done
for dev in $(awg show interfaces 2> /dev/null); do
ufw allow "$(awg show "$dev" listen-port 2> /dev/null)/udp"
done
for conf in "$DATA_DIR"/openvpn/*/server.conf; do
[[ -f "$conf" ]] || continue
ufw allow "$(grep -E '^port ' "$conf" | awk '{print $2}')/$(grep -E '^proto ' "$conf" | awk '{print $2}')"
done
# Enable the firewall
ufw --force enable
fi
}
open_ports() {
# Prompt the user to enter the ports they want to open
prompt "Enter the ports you want to open (e.g. 80,443,2053 or range 400-500)" && read -r 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"
prompt "Enter your choice" "1 or 2" && read -r choice
if [[ $choice -eq 1 ]]; then
# Deleting by rule numbers
prompt "Enter the rule numbers you want to delete (1, 2, etc.)" && read -r 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
prompt "Enter the ports you want to delete (e.g. 80,443,2053 or range 400-500)" && read -r 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
}
# The country lists the routing rules use ("geoip:ir"), kept as one file per
# country under the data directory and fetched from ipverse, which is where
# the panel itself fetches them.
update_geofiles() {
local geo_dir="$DATA_DIR/geoip"
local failed=0 f cc http_code
local any=0
for f in "$geo_dir"/*.txt; do
[[ -f "$f" ]] || continue
any=1
cc=$(basename "$f" .txt)
local temp_file="${f}.tmp.$$"
rm -f "$temp_file"
http_code=$(curl -sSfLo "$temp_file" -w '%{http_code}' \
"https://raw.githubusercontent.com/ipverse/country-ip-blocks/master/country/${cc}/ipv4-aggregated.txt")
if [[ $? -ne 0 ]]; then
echo -e "${red}${cc}: download failed${plain}"
rm -f "$temp_file"
failed=1
continue
fi
curl -sSfLo - "https://raw.githubusercontent.com/ipverse/country-ip-blocks/master/country/${cc}/ipv6-aggregated.txt" >> "$temp_file" 2> /dev/null
grep -v '^#' "$temp_file" | grep -v '^$' > "${temp_file}.clean" && mv -f "${temp_file}.clean" "$temp_file"
if [[ ! -s "$temp_file" ]]; then
echo -e "${red}${cc}: downloaded file is empty${plain}"
rm -f "$temp_file"
failed=1
elif cmp -s "$temp_file" "$f"; then
echo -e "${cc}: already up to date"
rm -f "$temp_file"
else
mv -f "$temp_file" "$f" && chown "$SERVICE_USER:$SERVICE_USER" "$f" 2> /dev/null
echo -e "${green}${cc}: updated${plain}"
geo_updated=1
fi
done
if [[ $any == 0 ]]; then
echo -e "${yellow}No country lists are in use yet; a rule with \"geoip:xx\" fetches one.${plain}"
fi
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() {
menu_item 1 "ipverse country lists (every country in use)"
menu_item 2 "Fetch one country now (e.g. ir, ru, cn)"
menu_item 0 "Back to Main Menu"
prompt "Choose an option" && read -r choice
case "$choice" in
0)
show_menu
;;
1)
run_geo_update "geo files" update_geofiles
;;
2)
prompt "Country code" && read -r cc
cc=$(echo "$cc" | tr 'A-Z' 'a-z' | tr -d '[:space:]')
if [[ ! "$cc" =~ ^[a-z]{2}$ ]]; then
echo -e "${red}A country is two letters, like ir.${plain}"
else
mkdir -p "$DATA_DIR/geoip"
touch "$DATA_DIR/geoip/${cc}.txt"
run_geo_update "geo files" update_geofiles
fi
;;
*)
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 [[ -x "$ACME_HOME/acme.sh" ]]; 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
# get.acme.sh turns its first argument "key=value" into "--key value"
# itself; handing it "--home" makes "----home", which acme.sh rejects.
curl -fsSL https://get.acme.sh -o /tmp/get-acme.sh && (HOME="${ACME_HOME%/.acme.sh}" sh /tmp/get-acme.sh "home=$ACME_HOME")
local rc=$?
rm -f /tmp/get-acme.sh
if [ $rc -ne 0 ] || [[ ! -x "$ACME_HOME/acme.sh" ]]; then
LOGE "Installation of acme.sh failed."
return 1
else
LOGI "Installation of acme.sh succeeded."
fi
ensure_renewal
return 0
}
# Renewal must happen with nobody watching. acme.sh renews from a cron
# entry -- which is nothing on the many small images that ship without a
# cron daemon -- so a systemd timer runs its check every six hours as well.
# Six hours, not a day: an IP certificate lives six days and is renewed at
# six, so a daily check could miss the window.
ensure_renewal() {
acme --install-cronjob > /dev/null 2>&1 || true
cat > /etc/systemd/system/wui-cert-renew.service <<UNIT
[Unit]
Description=W-UI certificate renewal (acme.sh)
After=network-online.target
[Service]
Type=oneshot
Environment=HOME=${ACME_HOME%/.acme.sh}
ExecStart=$ACME_HOME/acme.sh --cron --home $ACME_HOME
UNIT
cat > /etc/systemd/system/wui-cert-renew.timer <<UNIT
[Unit]
Description=W-UI certificate renewal check, every six hours
[Timer]
OnCalendar=*-*-* 00/6:00:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now wui-cert-renew.timer > /dev/null 2>&1 || true
}
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
}
# Certificate files the panel's own account can read. acme.sh keeps its
# state under root; the copies the panel serves live here.
cert_dir_for() {
echo "$CERT_ROOT/$1"
}
prepare_cert_dir() {
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$CERT_ROOT" 2> /dev/null || mkdir -p "$CERT_ROOT"
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$1" 2> /dev/null || mkdir -p "$1"
}
fix_cert_perms() {
chown "$SERVICE_USER:$SERVICE_USER" "$1"/privkey.pem "$1"/fullchain.pem 2> /dev/null
chmod 600 "$1"/privkey.pem 2> /dev/null
chmod 644 "$1"/fullchain.pem 2> /dev/null
}
# Hand the panel a certificate: what the classic `cert -webCert -webCertKey` is.
set_panel_cert() {
panel_cli setting set --cert "$1" --key "$2" > /dev/null 2>&1
# The subscription service on a port of its own serves the same
# certificate; left alone it would keep answering plain HTTP there.
local sub_port panel_port
sub_port=$(setting_value subPort)
panel_port=$(setting_value port)
if [[ -n "$sub_port" && "$sub_port" != "$panel_port" ]]; then
panel_cli setting set --sub-cert "$1" --sub-key "$2" > /dev/null 2>&1
fi
}
ssl_cert_issue_main() {
menu_item 1 "Get SSL (Domain)"
menu_item 2 "Revoke & Remove"
menu_item 3 "Force Renew"
menu_item 4 "Show Existing Domains"
menu_item 5 "Set Cert paths for the panel"
menu_item 6 "Get SSL for IP Address (6-day cert, auto-renews)"
menu_item 0 "Back to Main Menu"
prompt "Choose an option" && read -r choice
case "$choice" in
0)
show_menu
;;
1)
ssl_cert_issue
ssl_cert_issue_main
;;
2)
local domains
domains=$(find "$CERT_ROOT"/ -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"
prompt "Please enter a domain from the list to revoke and remove the certificate" && read -r domain
if echo "$domains" | grep -qw "$domain"; then
# The IP-cert flow (option 6) stores files under certs/ip, but acme.sh
# tracks the cert under the actual IP address(es). Resolve those so renewal
# state is torn down too; otherwise the renewal re-creates the deleted cert.
local acme_ids="${domain}"
if [[ "${domain}" == "ip" ]]; then
acme_ids=$(acme --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 --revoke -d "${id}" 2> /dev/null
acme --remove -d "${id}" 2> /dev/null
# --remove leaves the cert files on disk, so delete the state dirs (RSA + ECC).
rm -rf "$ACME_HOME/${id:?}" "$ACME_HOME/${id:?}_ecc"
done
# Delete the local certificate files for this domain.
rm -rf "${CERT_ROOT:?}/${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
existing_cert=$(setting_value cert)
if [[ "${existing_cert}" == "$CERT_ROOT/${domain}/"* ]]; then
panel_cli setting set --no-tls > /dev/null 2>&1
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
domains=$(find "$CERT_ROOT"/ -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"
prompt "Please enter a domain from the list to renew the SSL certificate" && read -r domain
if echo "$domains" | grep -qw "$domain"; then
local acme_ids="${domain}"
if [[ "${domain}" == "ip" ]]; then
acme_ids=$(acme --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
acme --renew -d ${id} --force
done
LOGI "Certificate forcefully renewed for domain: $domain"
else
echo "Invalid domain entered."
fi
fi
ssl_cert_issue_main
;;
4)
local domains
domains=$(find "$CERT_ROOT"/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
if [ -z "$domains" ]; then
echo "No certificates found under $CERT_ROOT."
else
echo "Existing domains and their paths:"
for domain in $domains; do
local cert_path="$CERT_ROOT/${domain}/fullchain.pem"
local key_path="$CERT_ROOT/${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}"
if command -v openssl > /dev/null 2>&1; then
echo -e "\tExpires: $(openssl x509 -in "${cert_path}" -noout -enddate 2> /dev/null | cut -d= -f2)"
fi
else
echo -e "Domain: ${domain} - Certificate or Key missing."
fi
done
fi
# The panel's configured certificate may live outside $CERT_ROOT
# (e.g. certbot under /etc/letsencrypt) — show it too.
local panel_cert
panel_cert=$(setting_value cert)
if [[ -n "${panel_cert}" && "${panel_cert}" != "$CERT_ROOT"/* ]]; then
echo -e "Panel certificate (custom path): ${panel_cert}"
if [[ -f "${panel_cert}" ]] && command -v openssl > /dev/null 2>&1; then
local panel_sans
panel_sans=$(openssl x509 -in "${panel_cert}" -noout -ext subjectAltName 2> /dev/null \
| grep -Eo '(DNS|IP Address):[^,[:space:]]+' | cut -d: -f2- | tr '\n' ' ')
[[ -n "${panel_sans}" ]] && echo -e "\tCovers: ${panel_sans}"
fi
fi
echo
systemctl list-timers wui-cert-renew.timer --no-pager 2> /dev/null | sed 's/^/ /'
ssl_cert_issue_main
;;
5)
menu_item 1 "Use a certificate from $CERT_ROOT"
menu_item 2 "Enter custom certificate file paths (e.g. certbot, /etc/letsencrypt/...)"
prompt "Choose an option" && read -r pathChoice
if [[ "$pathChoice" == "2" ]]; then
prompt "Certificate file path (fullchain)" && read -r webCertFile
prompt "Private key file path" && read -r webKeyFile
if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then
if ! sudo -u "$SERVICE_USER" test -r "${webKeyFile}" 2> /dev/null; then
LOGW "The panel runs as ${SERVICE_USER}, which cannot read ${webKeyFile}; giving it access"
setfacl -m "u:${SERVICE_USER}:r" "${webKeyFile}" "${webCertFile}" 2> /dev/null \
|| chgrp "$SERVICE_USER" "${webKeyFile}" "${webCertFile}" 2> /dev/null
chmod g+r "${webKeyFile}" "${webCertFile}" 2> /dev/null
fi
set_panel_cert "$webCertFile" "$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
domains=$(find "$CERT_ROOT"/ -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"
prompt "Please choose a domain to set the panel paths" && read -r domain
if echo "$domains" | grep -qw "$domain"; then
local webCertFile="$CERT_ROOT/${domain}/fullchain.pem"
local webKeyFile="$CERT_ROOT/${domain}/privkey.pem"
if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then
set_panel_cert "$webCertFile" "$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 the copies, silently serving a stale cert.
if [[ -x "$ACME_HOME/acme.sh" ]] && acme --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
acme --installcert --force -d "${domain}" \
--key-file "${webKeyFile}" \
--fullchain-file "${webCertFile}" \
--reloadcmd "w-ui restart" 2>&1 || true
fix_cert_perms "$CERT_ROOT/${domain}"
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
existing_webBasePath=$(setting_value basePath)
local existing_port
existing_port=$(setting_value port)
# Get server IP
local server_ip
server_ip=$(detect_server_ip)
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
[[ -z "$server_ip" ]] && server_ip=$(ask_server_ip)
LOGI "Issuing certificate for server IP: ${server_ip}"
# Ask for optional IPv6
local ipv6_addr=""
prompt "Do you have an IPv6 address to include? (leave empty to skip)" && read -r ipv6_addr
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
# check for acme.sh first
if [[ ! -x "$ACME_HOME/acme.sh" ]]; 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
install_socat
# Create certificate directory
certPath="$(cert_dir_for ip)"
prepare_cert_dir "$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=""
prompt "Port to use for ACME HTTP-01 listener" "80" && read -r 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=""
prompt "Enter another port for acme.sh standalone listener (leave empty to abort)" && read -r 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 - fixes ownership after renewal; the panel re-reads the
# files itself, so nothing has to restart.
local reloadCmd="chown ${SERVICE_USER}:${SERVICE_USER} ${certPath}/privkey.pem ${certPath}/fullchain.pem; chmod 600 ${certPath}/privkey.pem"
# issue the certificate for IP with shortlived profile. The authority is
# named here rather than made acme.sh's default: another project on this
# server may renew its own certificates with a different one.
acme --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_HOME/${server_ip:?}" "$ACME_HOME/${server_ip:?}_ecc" 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf "$ACME_HOME/${ipv6_addr:?}" "$ACME_HOME/${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 --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_HOME/${server_ip:?}" "$ACME_HOME/${server_ip:?}_ecc" 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf "$ACME_HOME/${ipv6_addr:?}" "$ACME_HOME/${ipv6_addr:?}_ecc" 2> /dev/null
rm -rf ${certPath} 2> /dev/null
return 1
fi
LOGI "Certificate files installed successfully"
# enable auto-renew
acme --upgrade --auto-upgrade > /dev/null 2>&1
ensure_renewal
fix_cert_perms "$certPath"
# Prompt user to set panel paths after successful certificate installation
local webCertFile="${certPath}/fullchain.pem"
local webKeyFile="${certPath}/privkey.pem"
prompt "Would you like to set this certificate for the panel?" "y/n" && read -r setPanel
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
set_panel_cert "$webCertFile" "$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, checked every 6 hours)"
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
existing_webBasePath=$(setting_value basePath)
local existing_port
existing_port=$(setting_value port)
# check for acme.sh first
if [[ ! -x "$ACME_HOME/acme.sh" ]]; 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
install_socat
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
prompt "Please enter your domain name" && read -r 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..."
# 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 --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
local acmeCertDir=""
if [[ -s "$ACME_HOME/${domain}_ecc/fullchain.cer" && -s "$ACME_HOME/${domain}_ecc/${domain}.key" ]]; then
acmeCertDir="$ACME_HOME/${domain}_ecc"
elif [[ -s "$ACME_HOME/${domain}/fullchain.cer" && -s "$ACME_HOME/${domain}/${domain}.key" ]]; then
acmeCertDir="$ACME_HOME/${domain}"
fi
if [[ -n "${acmeCertDir}" ]]; then
cert_exists=1
local certInfo
certInfo=$(acme --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_HOME/${domain:?}" "$ACME_HOME/${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="$(cert_dir_for "$domain")"
if [ ! -d "$certPath" ]; then
prepare_cert_dir "$certPath"
else
rm -rf "$certPath"
prepare_cert_dir "$certPath"
fi
# get the port number for the standalone server
local WebPort=80
prompt "Please choose which port to use" "80" && read -r 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
# Let's Encrypt for this certificate, not as acme.sh's default for
# every other project's on this server.
# shellcheck disable=SC2046 # the flag is one word or nothing at all
acme --issue -d "${domain}" $(acme_listen_flag) --standalone --server letsencrypt --httpport "${WebPort}" --force
if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs."
rm -rf "$ACME_HOME/${domain:?}" "$ACME_HOME/${domain:?}_ecc"
exit 1
else
LOGE "Issuing certificate succeeded, installing certificates..."
fi
else
LOGI "Using existing certificate, installing certificates..."
fi
reloadCmd="w-ui restart"
LOGI "Default --reloadcmd for ACME is: ${yellow}w-ui restart"
LOGI "This command will run on every certificate issue and renew."
prompt "Would you like to modify --reloadcmd for ACME?" "y/n" && read -r setReloadcmd
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; w-ui restart"
menu_item 2 "Input your own command"
menu_item 0 "Keep default reloadcmd"
prompt "Choose an option" && read -r choice
case "$choice" in
1)
LOGI "Reloadcmd is: systemctl reload nginx ; w-ui restart"
reloadCmd="systemctl reload nginx ; w-ui restart"
;;
2)
LOGD "It's recommended to put w-ui restart at the end, so it won't raise an error if other services fails"
prompt "Please enter your reloadcmd (example: systemctl reload nginx ; w-ui restart)" && read -r reloadCmd
LOGI "Your reloadcmd is: ${reloadCmd}"
;;
*)
LOGI "Keep default reloadcmd"
;;
esac
fi
# Whatever was chosen, the renewed files must stay readable by the panel.
reloadCmd="chown ${SERVICE_USER}:${SERVICE_USER} ${certPath}/privkey.pem ${certPath}/fullchain.pem; ${reloadCmd}"
# install the certificate
local installOutput=""
installOutput=$(acme --installcert --force -d ${domain} \
--key-file ${certPath}/privkey.pem \
--fullchain-file ${certPath}/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 "${certPath}/privkey.pem" && -f "${certPath}/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_HOME/${domain:?}" "$ACME_HOME/${domain:?}_ecc"
fi
exit 1
fi
# enable auto-renew
acme --upgrade --auto-upgrade
ensure_renewal
fix_cert_perms "$certPath"
if [ $? -ne 0 ]; then
LOGE "Auto renew failed, certificate details:"
ls -lah "$certPath"/*
exit 1
else
LOGI "Auto renew succeeded, certificate details:"
ls -lah "$certPath"/*
fi
# Prompt user to set panel paths after successful certificate installation
prompt "Would you like to set this certificate for the panel?" "y/n" && read -r setPanel
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
local webCertFile="${certPath}/fullchain.pem"
local webKeyFile="${certPath}/privkey.pem"
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
set_panel_cert "$webCertFile" "$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
existing_webBasePath=$(setting_value basePath)
local existing_port
existing_port=$(setting_value port)
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 [[ ! -x "$ACME_HOME/acme.sh" ]]; 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:"
prompt "Input your domain here" && read -r CF_Domain
if [[ -z "$CF_Domain" ]]; then
LOGE "No domain given; cancelled"
return 1
fi
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=""
prompt "Are you using a Cloudflare API Token or Global API Key? (t/g)" "t" && read -r 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:"
prompt "Input your key here" && read -r CF_GlobalKey
LOGD "Please set up the registered email:"
prompt "Input your email here" && read -r CF_AccountEmail
export CF_Key="${CF_GlobalKey}"
export CF_Email="${CF_AccountEmail}"
else
CF_ApiToken=""
LOGD "Please set the API Token:"
prompt "Input your token here" && read -r CF_ApiToken
export CF_Token="${CF_ApiToken}"
fi
# Issue the certificate using Cloudflare DNS, from Let's Encrypt -- named
# for this certificate rather than made acme.sh's default, which other
# projects on this server may rely on being something else.
acme --issue --dns dns_cf --server letsencrypt -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="$(cert_dir_for "$CF_Domain")"
if [ -d "$certPath" ]; then
rm -rf ${certPath}
fi
prepare_cert_dir "$certPath"
if [ $? -ne 0 ]; then
LOGE "Failed to create directory: ${certPath}"
exit 1
fi
reloadCmd="w-ui restart"
LOGI "Default --reloadcmd for ACME is: ${yellow}w-ui restart"
LOGI "This command will run on every certificate issue and renew."
prompt "Would you like to modify --reloadcmd for ACME?" "y/n" && read -r setReloadcmd
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; w-ui restart"
menu_item 2 "Input your own command"
menu_item 0 "Keep default reloadcmd"
prompt "Choose an option" && read -r choice
case "$choice" in
1)
LOGI "Reloadcmd is: systemctl reload nginx ; w-ui restart"
reloadCmd="systemctl reload nginx ; w-ui restart"
;;
2)
LOGD "It's recommended to put w-ui restart at the end, so it won't raise an error if other services fails"
prompt "Please enter your reloadcmd (example: systemctl reload nginx ; w-ui restart)" && read -r reloadCmd
LOGI "Your reloadcmd is: ${reloadCmd}"
;;
*)
LOGI "Keep default reloadcmd"
;;
esac
fi
reloadCmd="chown ${SERVICE_USER}:${SERVICE_USER} ${certPath}/privkey.pem ${certPath}/fullchain.pem; ${reloadCmd}"
acme --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 --upgrade --auto-upgrade
ensure_renewal
fix_cert_perms "$certPath"
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}/*
fi
# Prompt user to set panel paths after successful certificate installation
prompt "Would you like to set this certificate for the panel?" "y/n" && read -r setPanel
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
local webCertFile="${certPath}/fullchain.pem"
local webKeyFile="${certPath}/privkey.pem"
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
set_panel_cert "$webCertFile" "$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
# Ookla's licence and GDPR notice, accepted here: answered by hand they
# would end the run on a server nobody is typing at.
speedtest --accept-license --accept-gdpr
}
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)$"
}
# IP Limit: fail2ban watching the panel's own log for refused sign-ins, and
# banning the address that keeps trying. The device limit on a plan is
# enforced by the panel itself, so the jail here guards the sign-in form.
iplimit_main() {
echo -e "\n${green}\t1.${plain} Install Fail2ban and configure IP Limit"
menu_item 2 "Change Ban Duration"
menu_item 3 "Unban Everyone"
menu_item 4 "Ban Logs"
menu_item 5 "Ban an IP Address"
menu_item 6 "Unban an IP Address"
menu_item 7 "Real-Time Logs"
menu_item 8 "Service Status"
menu_item 9 "Service Restart"
menu_item 10 "Uninstall Fail2ban and IP Limit"
menu_item 0 "Back to Main Menu"
prompt "Choose an option" && read -r 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)
prompt "Please enter new Ban Duration in Minutes" "30" && read -r NUM
if [[ $NUM =~ ^[0-9]+$ ]]; then
create_iplimit_jails ${NUM}
systemctl restart fail2ban
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 w-ui-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)
prompt "Enter the IP address you want to ban" && read -r ban_ip
ip_validation
if [[ $ban_ip =~ $ipv4_regex || $ban_ip =~ $ipv6_regex ]]; then
fail2ban-client set w-ui-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)
prompt "Enter the IP address you want to unban" && read -r unban_ip
ip_validation
if [[ $unban_ip =~ $ipv4_regex || $unban_ip =~ $ipv6_regex ]]; then
fail2ban-client set w-ui-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)
systemctl restart fail2ban
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() {
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`, but the `nftables`
# package isn't pulled in as a dependency on most minimal images.
case "${release}" in
ubuntu | debian | armbian)
# Straight to the install: the package lists were refreshed
# minutes ago by the installer, and refreshing them again
# was most of the wait. Only a failed install refreshes and
# tries once more. Recommends are left out -- they pull in
# whois, iptables and a mail agent -- except python3-systemd,
# which the journal backend needs.
export DEBIAN_FRONTEND=noninteractive
if ! apt-get install -y -qq --no-install-recommends fail2ban nftables python3-systemd >/dev/null 2>&1; then
apt-get update -qq >/dev/null 2>&1
apt-get install -y -qq --no-install-recommends fail2ban nftables python3-systemd
fi
# fail2ban 1.0 on Python 3.12 needs asynchat, which left the
# standard library. Fetched from pip only when the server
# module really cannot be imported -- most images ship a
# patched package and skip this entirely.
if ! python3 -c 'import fail2ban.server.asyncserver' >/dev/null 2>&1; then
apt-get install -y -qq --no-install-recommends python3-pip >/dev/null 2>&1
python3 -m pip install -q pyasynchat --break-system-packages >/dev/null 2>&1 \
|| python3 -m pip install -q pyasynchat >/dev/null 2>&1
fi
;;
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
# Create the iplimit jail files
# we didn't pass the bantime here to use the default value
create_iplimit_jails
# Launching fail2ban
if ! systemctl is-active --quiet fail2ban; then
systemctl start fail2ban
else
systemctl restart fail2ban
fi
systemctl enable fail2ban
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.
install_iplimit() {
setup_fail2ban_iplimit
before_show_menu
}
remove_iplimit() {
menu_item 1 "Only remove IP Limit configurations"
menu_item 2 "Uninstall Fail2ban and IP Limit"
menu_item 0 "Back to Main Menu"
prompt "Choose an option" && read -r num
case "$num" in
1)
rm -f /etc/fail2ban/filter.d/w-ui-ipl.conf
rm -f /etc/fail2ban/action.d/w-ui-ipl.conf
rm -f /etc/fail2ban/jail.d/w-ui-ipl.conf
systemctl restart fail2ban
echo -e "${green}IP Limit removed successfully!${plain}\n"
before_show_menu
;;
2)
rm -rf /etc/fail2ban
systemctl stop fail2ban
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 ! systemctl is-active --quiet fail2ban; then
echo -e "${red}Fail2ban service is not running!${plain}\n"
return 1
fi
if [[ -f "$system_log" ]]; then
echo -e "${green}Recent system ban activities from fail2ban.log:${plain}"
grep "w-ui-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}W-UI-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 w-ui-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
# The panel logs to the journal, so the jail reads the journal.
cat << EOF > /etc/fail2ban/jail.d/w-ui-ipl.conf
[w-ui-ipl]
enabled=true
backend=systemd
journalmatch=_SYSTEMD_UNIT=${SERVICE}.service
filter=w-ui-ipl
action=w-ui-ipl
maxretry=5
findtime=10m
bantime=${bantime}m
EOF
# The panel's own line for a refused sign-in:
# level=WARN msg="failed sign-in" username=x ip=1.2.3.4 lockout=...
cat << EOF > /etc/fail2ban/filter.d/w-ui-ipl.conf
[Definition]
failregex = msg="failed sign-in".*\bip=<ADDR>\b
ignoreregex =
EOF
# Only the panel's port is closed to a banned address: a customer whose
# neighbour on a shared address guessed passwords keeps their tunnel, and
# the administrator keeps SSH.
local panel_port
panel_port=$(setting_value port)
[[ -n "${panel_port}" ]] || panel_port=2096
cat << EOF > /etc/fail2ban/action.d/w-ui-ipl.conf
[INCLUDES]
before = iptables-multiport.conf
[Definition]
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -m multiport --dports <port> -j f2b-<name>
actionstop = <iptables> -D <chain> -p <protocol> -m multiport --dports <port> -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> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
[Init]
name = default
chain = INPUT
port = ${panel_port}
protocol = tcp
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 [w-ui-ipl] config in jail file then remove it
if test -f "${file}" && grep -qw 'w-ui-ipl' ${file}; then
sed -i "/\[w-ui-ipl\]/,/^$/d" ${file}
echo -e "${yellow}Removing conflicts of [w-ui-ipl] in jail (${file})!${plain}\n"
fi
done
}
SSH_port_forwarding() {
local server_ip
server_ip=$(detect_server_ip)
if [[ -z "$server_ip" ]]; then
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
server_ip=$(ask_server_ip)
fi
local existing_webBasePath
existing_webBasePath=$(setting_value basePath)
local existing_port
existing_port=$(setting_value port)
local existing_listenIP
existing_listenIP=$(setting_value listenIP)
local existing_cert
existing_cert=$(setting_value cert)
local existing_key
existing_key=$(setting_value key)
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"
prompt "Choose an option" && read -r 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"
prompt "Select an option" "1 or 2" && read -r listen_choice
config_listenIP="127.0.0.1"
[[ "$listen_choice" == "2" ]] && prompt "Enter custom IP to listen on" && read -r config_listenIP
panel_cli setting set --listen "${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)
panel_cli setting set --listen 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 ──────────────────────────────────────────────────────────────
# The panel runs on SQLite or PostgreSQL; the installer sets either up, and
# this menu is the same work after the fact: install the server, move the
# data across, and the service controls. Moving is a backup and a restore:
# the archive carries a portable dump, so the same file lands in either.
# The installer's own functions, for the pieces it already knows how to do.
installer_lib() {
local lib
lib=$(mktemp) || return 1
curl -fsSL "${REPO_RAW}/install.sh" -o "$lib" || { rm -f "$lib"; return 1; }
printf '%s' "$lib"
}
db_driver_now() {
[[ -r "$CONF_DIR/db.env" ]] && sed -n 's/^WUI_DB_DRIVER=//p' "$CONF_DIR/db.env" | head -1 || echo sqlite
}
postgresql_installed() {
command -v psql > /dev/null 2>&1 && id postgres > /dev/null 2>&1
}
pg_unit() {
systemctl list-unit-files 2> /dev/null | awk '{print $1}' | grep -E '^postgresql(@.+|-[0-9]+)?\.service$' | head -1
}
postgresql_status() {
if ! postgresql_installed; then
echo -e "${yellow}PostgreSQL is not installed${plain}"
return
fi
echo -e "${green}PostgreSQL:${plain} $(psql --version 2> /dev/null)"
local u; u=$(pg_unit)
[[ -n "$u" ]] && systemctl status "$u" --no-pager -l 2> /dev/null | head -12
if runuser -u postgres -- pg_isready 2> /dev/null; then
echo -e "${green}port 5432: accepting connections${plain}"
else
echo -e "${red}port 5432: not accepting connections${plain}"
fi
echo -e "${green}panel database:${plain} $(db_driver_now)"
}
postgresql_start() { local u; u=$(pg_unit); [[ -n "$u" ]] && systemctl start "$u" && LOGI "PostgreSQL started" || LOGE "PostgreSQL is not installed"; }
postgresql_stop() { local u; u=$(pg_unit); [[ -n "$u" ]] && systemctl stop "$u" && LOGI "PostgreSQL stopped" || LOGE "PostgreSQL is not installed"; }
postgresql_restart() { local u; u=$(pg_unit); [[ -n "$u" ]] && systemctl restart "$u" && LOGI "PostgreSQL restarted" || LOGE "PostgreSQL is not installed"; }
postgresql_enable() { local u; u=$(pg_unit); [[ -n "$u" ]] && systemctl enable "$u" > /dev/null 2>&1 && LOGI "PostgreSQL will start on boot" || LOGE "PostgreSQL is not installed"; }
postgresql_log() {
# Debian's postgresql.service is a shell around postgresql@VER-main,
# which is where the server actually logs; and the server writes its
# own file too. The first of those with something to say.
local f
f=$(ls -t /var/log/postgresql/*.log 2> /dev/null | head -1)
if [[ -n "$f" ]]; then
tail -n 100 "$f"
return
fi
local inst
inst=$(systemctl list-units --type=service --all 2> /dev/null | awk '{print $1}' | grep -E '^postgresql(@.+|-[0-9]+)\.service$' | head -1)
[[ -n "$inst" ]] || inst=$(pg_unit)
[[ -n "$inst" ]] && journalctl -u "$inst" -n 100 --no-pager || LOGE "PostgreSQL is not installed"
}
# Install the server and the panel's database, exactly as the installer's
# own database step does it. The panel keeps running on what it has.
pg_install_server_action() {
local lib; lib=$(installer_lib) || { LOGE "could not fetch the installer"; return 1; }
(
# This script's install() would shadow coreutils' install(1), which
# the library uses for files and directories.
unset -f install
# shellcheck disable=SC1090
WUI_LIB_ONLY=1 source "$lib"
detect_os
# shellcheck disable=SC2034 # read by setup_database, sourced from install.sh
DB_DRIVER=postgres
# setup_database writes db.env, which would switch the panel; keep
# that file aside and put it back, so installing is not migrating.
[[ -r "$CONF_DIR/db.env" ]] && cp -p "$CONF_DIR/db.env" "$CONF_DIR/db.env.keep"
setup_database
mv -f "$CONF_DIR/db.env" "$CONF_DIR/db.env.postgres"
[[ -r "$CONF_DIR/db.env.keep" ]] && mv -f "$CONF_DIR/db.env.keep" "$CONF_DIR/db.env"
)
local rc=$?
rm -f "$lib"
if [[ $rc == 0 ]]; then
LOGI "PostgreSQL is installed; the panel's database and role exist. Choose 2 to move the data across."
else
LOGE "PostgreSQL install failed"
fi
return $rc
}
# Move the panel from one engine to the other: a backup on the engine it is
# on, the connection switched, and the archive restored into the new one.
# The dump inside the archive is what crosses; the old data stays where it
# was, untouched, in case of a change of mind.
migrate_engine() {
local to="$1" from; from=$(db_driver_now)
if [[ "$from" == "$to" ]]; then
LOGI "the panel is already on $to"
return 0
fi
if [[ "$to" == postgres && ! -r "$CONF_DIR/db.env.postgres" ]]; then
LOGE "PostgreSQL is not set up for the panel yet; choose 1 first"
return 1
fi
confirm "Move the panel's data from $from to $to? A backup is taken first, and the $from data is left in place." "n" || return 0
local archive
archive=$(panel_cli backup create --dir "$BACKUP_DIR" 2>&1 | tail -1)
[[ -f "$archive" ]] || { LOGE "backup failed: $archive"; return 1; }
LOGI "backup taken: $archive"
systemctl stop "$SERVICE" 2> /dev/null
cp -p "$CONF_DIR/db.env" "$CONF_DIR/db.env.$from"
if [[ "$to" == postgres ]]; then
cp -f "$CONF_DIR/db.env.postgres" "$CONF_DIR/db.env"
else
printf 'WUI_DB_DRIVER=sqlite\nWUI_DB_SOURCE=%s/wui.db\n' "$DATA_DIR" > "$CONF_DIR/db.env"
# A fresh file: the restore loads the dump into it.
rm -f "$DATA_DIR/wui.db" "$DATA_DIR/wui.db-wal" "$DATA_DIR/wui.db-shm"
fi
chmod 600 "$CONF_DIR/db.env"
local report
if report=$(panel_cli backup restore "$archive" --dir "$BACKUP_DIR" 2>&1); then
echo "$report" | sed 's/^/ /'
systemctl start "$SERVICE" 2> /dev/null
sleep 3
if systemctl is-active --quiet "$SERVICE" && journalctl -u "$SERVICE" --since "-1 min" --no-pager 2> /dev/null | grep -q "imported a portable backup\|restored from a backup"; then
LOGI "the panel is now on $to"
else
LOGE "the panel did not come up on $to; see: journalctl -u $SERVICE -n 50"
LOGE "to go back: cp $CONF_DIR/db.env.$from $CONF_DIR/db.env && systemctl restart $SERVICE"
fi
else
LOGE "restore failed: $report"
cp -f "$CONF_DIR/db.env.$from" "$CONF_DIR/db.env"
systemctl start "$SERVICE" 2> /dev/null
fi
}
postgresql_menu() {
echo -e "\n${green}\t1.${plain} ${green}Install${plain} PostgreSQL (server + wui database)"
menu_item 2 "Migrate SQLite ${green}->${plain} PostgreSQL"
menu_item 3 "Status (server & port 5432)"
menu_item 4 "${green}Start${plain} PostgreSQL"
menu_item 5 "${red}Stop${plain} PostgreSQL"
menu_item 6 "Restart PostgreSQL"
menu_item 7 "${green}Enable${plain} Autostart on boot"
menu_item 8 "View PostgreSQL Log"
menu_item 9 "Backup & Restore (either engine)"
menu_item 10 "Migrate PostgreSQL ${green}->${plain} SQLite"
menu_item 0 "Back to Main Menu"
echo -e " ${yellow}Panel database now: $(db_driver_now)${plain}"
prompt "Choose an option" && read -r choice
case "$choice" in
0) show_menu ;;
1) pg_install_server_action; postgresql_menu ;;
2) migrate_engine 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) backup_menu ;;
10) migrate_engine sqlite; postgresql_menu ;;
*)
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
postgresql_menu
;;
esac
}
# Backup & Restore: the archive carries the database from either engine.
backup_menu() {
echo -e "\n${green}\t1.${plain} Create a backup"
menu_item 2 "Restore from a backup"
menu_item 3 "List backups"
menu_item 0 "Back to Main Menu"
echo -e " ${yellow}The archive holds the database, every interface key and every${plain}"
echo -e " ${yellow}customer credential. Treat it like a password file.${plain}"
echo -e " ${yellow}It is the panel's own format: a backup from any W-UI version, on${plain}"
echo -e " ${yellow}SQLite or PostgreSQL, restores here.${plain}"
prompt "Choose an option" && read -r choice
case "$choice" in
0) show_menu ;;
1)
local out
out=$(panel_cli backup create --dir "$BACKUP_DIR" 2>&1 | tail -1)
if [[ -f "$out" ]]; then
LOGI "Backup written to $out"
else
LOGE "Backup failed: $out"
fi
backup_menu
;;
2)
echo && prompt "Path to the backup archive" && read -r archive
if [[ ! -f "$archive" ]]; then
LOGE "No such file"
backup_menu
return
fi
confirm "Restoring replaces every customer and key currently on this server (a copy of the current data is kept first)" "n"
if [[ $? != 0 ]]; then
backup_menu
return
fi
# Staged by the panel's own restore, then applied at the restart:
# the archive is checked, the current data is kept beside it, and
# this server's own addresses are put back over the archive's.
# The archive may sit somewhere only root can read, such as /root;
# handed to the service's user through a copy it can open.
local handoff report
install -d -m 700 -o "$SERVICE_USER" -g "$SERVICE_USER" "$BACKUP_DIR" 2> /dev/null
handoff=$(mktemp "$BACKUP_DIR/.handoff-XXXXXX") && cp -f "$archive" "$handoff" && chown "$SERVICE_USER" "$handoff"
if report=$(panel_cli backup restore "$handoff" --dir "$BACKUP_DIR" 2>&1); then
rm -f "$handoff"
echo "$report" | sed 's/^/ /'
systemctl restart "$SERVICE" 2> /dev/null
sleep 2
if systemctl is-active --quiet "$SERVICE"; then
LOGI "Restored; the panel is running on the archive's data"
else
LOGE "The panel did not come back; see: journalctl -u $SERVICE -n 50"
fi
else
rm -f "$handoff"
LOGE "Restore failed: $report"
fi
backup_menu
;;
3)
panel_cli backup list --dir "$BACKUP_DIR" 2> /dev/null | awk -F'\t' '{ printf " %s %.1f MB %s\n", $1, $2/1048576, $3 }'
[[ -n "$(panel_cli backup list --dir "$BACKUP_DIR" 2> /dev/null)" ]] || echo -e " ${yellow}No backups yet in $BACKUP_DIR${plain}"
backup_menu
;;
*)
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
backup_menu
;;
esac
}
show_usage() {
echo -e "┌────────────────────────────────────────────────────────────────┐
│ ${blue}w-ui control menu usages (subcommands):${plain} │
│ │
│ ${blue}w-ui${plain} - Admin Management Script │
│ ${blue}w-ui start${plain} - Start │
│ ${blue}w-ui stop${plain} - Stop │
│ ${blue}w-ui restart${plain} - Restart │
│ ${blue}w-ui restart-tunnels${plain} - Restart every tunnel │
│ ${blue}w-ui status${plain} - Current Status │
│ ${blue}w-ui settings${plain} - Current Settings │
│ ${blue}w-ui enable${plain} - Enable Autostart on OS Startup │
│ ${blue}w-ui disable${plain} - Disable Autostart on OS Startup │
│ ${blue}w-ui log${plain} - Check logs │
│ ${blue}w-ui banlog${plain} - Check Fail2ban ban logs │
│ ${blue}w-ui update${plain} - Update │
│ ${blue}w-ui update-dev${plain} - Update to Dev channel (latest) │
│ ${blue}w-ui update-all-geofiles${plain} - Update all geo files │
│ ${blue}w-ui backup${plain} - Backup & Restore │
│ ${blue}w-ui postgres${plain} - PostgreSQL Management │
│ ${blue}w-ui legacy${plain} - Legacy version │
│ ${blue}w-ui install${plain} - Install │
│ ${blue}w-ui uninstall${plain} - Uninstall │
└────────────────────────────────────────────────────────────────┘"
}
show_menu() {
banner
echo
box_top
box_item 0 "Exit"
box_mid
box_section "Install and update"
box_item 1 "Install"
box_item 2 "Update"
box_item 3 "Update to the dev channel (latest commit)"
box_item 4 "Update this menu"
box_item 5 "Install an older version"
box_item 6 "Uninstall"
box_mid
box_section "Access"
box_item 7 "Reset username and password"
box_item 8 "Reset the URL path"
box_item 9 "Reset settings"
box_item 10 "Change the port"
box_item 11 "Show current settings"
box_mid
box_section "Service"
box_item 12 "Start"
box_item 13 "Stop"
box_item 14 "Restart"
box_item 15 "Restart tunnels"
box_item 16 "Status"
box_item 17 "Logs"
box_item 18 "Enable start on boot"
box_item 19 "Disable start on boot"
box_mid
box_section "Server"
box_item 20 "SSL certificate"
box_item 21 "Cloudflare SSL certificate"
box_item 22 "Connection limit"
box_item 23 "Firewall"
box_item 24 "SSH port forwarding"
box_item 25 "PostgreSQL"
box_item 26 "Enable BBR"
box_item 27 "Update geo files"
box_item 28 "Speedtest by Ookla"
box_mid
show_status
box_end
echo
prompt "Select an option" "0-28" && read -r num || exit 0
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_tunnels
;;
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)
check_install && postgresql_menu
;;
26)
bbr_menu
;;
27)
update_geo
;;
28)
run_speedtest
;;
*)
LOGE "Please enter the correct number [0-28]"
;;
esac
}
if [[ $# -gt 0 ]]; then
case $1 in
"start")
check_install 0 && start 0
;;
"stop")
check_install 0 && stop 0
;;
"restart")
check_install 0 && restart 0
;;
"restart-tunnels")
check_install 0 && restart_tunnels 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_geofiles 0; then
[[ $geo_updated -eq 0 ]] || restart 0
fi
;;
"backup")
check_install 0 && backup_menu
;;
"postgres" | "postgresql")
check_install 0 && postgresql_menu
;;
*) show_usage ;;
esac
else
show_menu
fi