Ctrl K
ComunidadGuías…

w-ui.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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