diff --git a/install.sh b/install.sh
index c737a34bc9..4c5e8397d3 100644
--- a/install.sh
+++ b/install.sh
@@ -1351,15 +1351,58 @@ EOF
fi
${xui_folder}/x-ui migrate
+ echo ""
+ echo -e "${green}═══════════════════════════════════════════${plain}"
+ echo -e "${green} Fail2ban / IP Limit Configuration ${plain}"
+ echo -e "${green}═══════════════════════════════════════════${plain}"
+ echo -e "1) iptables (legacy, recommended default)"
+ echo -e "2) nftables (modern)"
+ echo -e "3) Skip (IP Limit will be disabled)"
+
+ local fb_choice="1"
+ if [[ "$NONINTERACTIVE" == "1" ]]; then
+ case "${XUI_FAIL2BAN_BACKEND:-iptables}" in
+ nft|nftables) fb_choice="2" ;;
+ none|skip) fb_choice="3" ;;
+ *) fb_choice="1" ;;
+ esac
+ else
+ read -rp "Choose [1]: " fb_choice
+ fb_choice="${fb_choice:-1}"
+ fi
+
+ case "$fb_choice" in
+ 2) export XUI_FAIL2BAN_BACKEND="nftables" ;;
+ 3) export XUI_FAIL2BAN_BACKEND="none" ;;
+ *) export XUI_FAIL2BAN_BACKEND="iptables" ;;
+ esac
+
+ echo -e "${green}Selected backend: ${XUI_FAIL2BAN_BACKEND}${plain}"
+
+ # Persist backend choice for x-ui.sh menu operations
+ mkdir -p /etc/x-ui
+ echo "$XUI_FAIL2BAN_BACKEND" > /etc/x-ui/fail2ban_backend
+ chmod 600 /etc/x-ui/fail2ban_backend
+
}
-# setup_fail2ban auto-installs and configures fail2ban for the IP Limit feature
-# by invoking the freshly installed x-ui CLI. IP Limit is load-bearing on
-# fail2ban (without it the panel disables the limitIp field and zeroes existing
-# limits), so a fresh install should make it work out of the box, just like the
-# Docker image already does. Non-fatal by design: a fail2ban failure must never
-# abort the panel install.
+# setup_fail2ban auto-installs and configures fail2ban + IP Limit during installation.
+# It respects the XUI_FAIL2BAN_BACKEND environment variable (set in config_after_install):
+# - "iptables" (default): uses legacy iptables backend
+# - "nftables": uses modern nftables backend
+# - "none": completely skips Fail2ban/IP Limit setup
+#
+# IP Limit feature depends on fail2ban. Without it the panel will disable the
+# limitIp field and zero existing limits. This function is non-fatal by design —
+# a failure here must never abort the overall panel installation.
+#
+# Called automatically after a fresh install (and via `x-ui setup-fail2ban`).
setup_fail2ban() {
+ if [[ "${XUI_FAIL2BAN_BACKEND}" == "none" ]]; then
+ echo -e "${yellow}Fail2ban/IP Limit skipped by user choice.${plain}"
+ return 0
+ fi
+
if [[ -n "${XUI_ENABLE_FAIL2BAN+x}" && "${XUI_ENABLE_FAIL2BAN}" != "true" ]]; then
echo -e "${yellow}XUI_ENABLE_FAIL2BAN=${XUI_ENABLE_FAIL2BAN}, skipping Fail2ban auto-setup.${plain}"
return 0
@@ -1374,7 +1417,7 @@ setup_fail2ban() {
if /usr/bin/x-ui setup-fail2ban; then
echo -e "${green}Fail2ban setup complete.${plain}"
else
- echo -e "${yellow}Fail2ban setup did not finish; IP Limit stays disabled until you run 'x-ui' and open the IP Limit menu. Continuing.${plain}"
+ echo -e "${yellow}Fail2ban setup did not finish; IP Limit stays disabled until you run 'x-ui' menu.${plain}"
fi
return 0
}
@@ -1682,4 +1725,4 @@ install_x-ui() {
echo -e "${green}Running...${plain}"
install_base
-install_x-ui $1
+install_x-ui $1
\ No newline at end of file
diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go
index 7e97d8e0c3..1458c530b4 100644
--- a/internal/web/service/xray.go
+++ b/internal/web/service/xray.go
@@ -158,15 +158,28 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
var finalClients []any
var wgPeers []any
+ // Track processed emails to avoid duplicates in the final clients list.
+ processed := make(map[string]bool)
+
for i := range dbClients {
c := dbClients[i]
+
+ if processed[c.Email] {
+ continue
+ }
+
if enable, exists := enableMap[c.Email]; exists && !enable {
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email)
+ processed[c.Email] = true
continue
}
+
if !c.Enable {
continue
}
+
+ processed[c.Email] = true
+
flow := c.Flow
if flow == "xtls-rprx-vision-udp443" {
flow = "xtls-rprx-vision"
diff --git a/x-ui.sh b/x-ui.sh
index 9dcf27c0ad..eabcd15fd4 100644
--- a/x-ui.sh
+++ b/x-ui.sh
@@ -2255,119 +2255,91 @@ iplimit_main() {
}
setup_fail2ban_iplimit() {
- # Honor the same toggle the panel uses (isFail2BanEnabled): enabled when the
- # var is unset or exactly "true"; any other explicit value means the operator
- # opted out, so do nothing rather than install a fail2ban the panel ignores.
+ # XUI_FAIL2BAN_BACKEND is set in install.sh during initial setup.
+ # Possible values: iptables (default), nftables, none
+ local backend="${XUI_FAIL2BAN_BACKEND:-iptables}"
+
+ if [[ "$backend" == "none" ]]; then
+ echo -e "${yellow}Fail2ban/IP Limit skipped by user choice.${plain}\n"
+ return 0
+ fi
+
if [[ -n "${XUI_ENABLE_FAIL2BAN+x}" && "${XUI_ENABLE_FAIL2BAN}" != "true" ]]; then
echo -e "${yellow}XUI_ENABLE_FAIL2BAN=${XUI_ENABLE_FAIL2BAN}, skipping Fail2ban setup.${plain}\n"
return 0
fi
+ echo -e "${green}Setting up Fail2ban (backend: ${backend})...${plain}\n"
+
+ # Install fail2ban if missing
if ! command -v fail2ban-client &> /dev/null; then
- echo -e "${green}Fail2ban is not installed. Installing now...!${plain}\n"
-
- # Install fail2ban together with nftables. Recent fail2ban packages
- # default to `banaction = nftables-multiport` in /etc/fail2ban/jail.conf,
- # but the `nftables` package isn't pulled in as a dependency on most
- # minimal server images (Debian 12+, Ubuntu 24+, fresh RHEL-family).
- # Without `nft` in PATH the default sshd jail fails to ban with
- # stderr: '/bin/sh: 1: nft: not found'
- # even though our own 3x-ipl jail uses iptables. Bundling the binary
- # at install time prevents that confusing log spam for new installs.
+ echo -e "${green}Fail2ban is not installed. Installing now...${plain}"
+
case "${release}" in
- ubuntu)
+ ubuntu|debian|armbian)
apt-get update
- if [[ "${os_version}" -ge 2400 ]]; then
- apt-get install python3-pip -y
- python3 -m pip install pyasynchat --break-system-packages
- fi
- apt-get install fail2ban nftables -y
- ;;
- debian)
- apt-get update
- if [ "$os_version" -ge 12 ]; then
- apt-get install -y python3-systemd
- fi
+ # Always install nftables (recent fail2ban defaults to nftables-multiport)
apt-get install -y fail2ban nftables
;;
- armbian)
- apt-get update && apt-get install fail2ban nftables -y
+ fedora)
+ # Fedora has fail2ban in main repos
+ dnf install -y fail2ban nftables
;;
- 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
+ amzn|virtuozzo|rhel|almalinux|rocky|ol)
+ # RHEL-family: enable EPEL, use dnf (dnf available on all these)
+ dnf install -y epel-release
+ dnf install -y 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
+ # CentOS 7 doesn't have dnf, use yum and enable EPEL
+ yum install -y epel-release
+ yum install -y fail2ban nftables
;;
- arch | manjaro | parch)
+ 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"
+ echo -e "${red}Unsupported OS. Please install fail2ban manually.${plain}"
return 1
;;
esac
if ! command -v fail2ban-client &> /dev/null; then
- echo -e "${red}Fail2ban installation failed.${plain}\n"
+ echo -e "${red}Fail2ban installation failed.${plain}"
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"
+ # Remove old conflicting 3x-ipl configs (safe on reinstall)
+ echo -e "${yellow}Preparing fresh IP Limit configuration...${plain}"
+ rm -f /etc/fail2ban/jail.d/3x-ipl.conf 2>/dev/null
+ rm -f /etc/fail2ban/action.d/3x-ipl.conf 2>/dev/null
+ rm -f /etc/fail2ban/filter.d/3x-ipl.conf 2>/dev/null
- # make sure there's no conflict for jail files
- iplimit_remove_conflicts
+ # Ensure log files exist
+ touch "${iplimit_banned_log_path}" "${iplimit_log_path}" 2>/dev/null || true
- # Check if log file exists
- if ! test -f "${iplimit_banned_log_path}"; then
- touch ${iplimit_banned_log_path}
- fi
+ # Create configuration with selected backend
+ create_iplimit_jails 30 "$backend"
- # Check if service log file exists so fail2ban won't return error
- if ! test -f "${iplimit_log_path}"; then
- touch ${iplimit_log_path}
- fi
+ # Persist chosen backend for future menu operations
+ mkdir -p /etc/x-ui
+ echo "$backend" > /etc/x-ui/fail2ban_backend
+ chmod 600 /etc/x-ui/fail2ban_backend
- # Create the iplimit jail files
- # we didn't pass the bantime here to use the default value
- create_iplimit_jails
-
- # Launching fail2ban
+ # Restart service
if [[ $release == "alpine" ]]; then
- if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
- rc-service fail2ban start
- else
- rc-service fail2ban restart
- fi
+ rc-service fail2ban restart || rc-service fail2ban start
rc-update add fail2ban
else
- if ! systemctl is-active --quiet fail2ban; then
- systemctl start fail2ban
- else
- systemctl restart fail2ban
- fi
+ systemctl restart fail2ban || systemctl start fail2ban
systemctl enable fail2ban
fi
- echo -e "${green}IP Limit installed and configured successfully!${plain}\n"
+ echo -e "${green}IP Limit installed and configured successfully (backend: ${backend})!${plain}\n"
return 0
}
@@ -2488,15 +2460,23 @@ show_banlog() {
create_iplimit_jails() {
# Use default bantime if not passed => 30 minutes
local bantime="${1:-30}"
+ # backend can be "iptables" (default) or "nftables"
+ local backend="${2:-iptables}"
+
+ # Load persisted backend choice from prior install (if not passed as argument)
+ if [[ $# -lt 2 && -f /etc/x-ui/fail2ban_backend ]]; then
+ backend=$(cat /etc/x-ui/fail2ban_backend)
+ fi
- # Uncomment 'allowipv6 = auto' in fail2ban.conf
+ # Common configuration
sed -i 's/#allowipv6 = auto/allowipv6 = auto/g' /etc/fail2ban/fail2ban.conf
- # On Debian 12+ and Ubuntu 22.04+ fail2ban's default backend should be changed to systemd
+ # On Debian 12+ and Ubuntu 22.04+ change default backend to systemd
if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]]; then
sed -i '0,/action =/s/backend = auto/backend = systemd/' /etc/fail2ban/jail.conf
fi
+ # Jail configuration (same for both backends)
cat << EOF > /etc/fail2ban/jail.d/3x-ipl.conf
[3x-ipl]
enabled=true
@@ -2509,6 +2489,7 @@ findtime=32
bantime=${bantime}m
EOF
+ # Filter definition
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
[Definition]
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
@@ -2516,10 +2497,7 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnect
ignoreregex =
EOF
- # Ports to exempt from the ban so an over-limit proxy client can never lock
- # the administrator out of SSH or the panel. The ban still covers every other
- # TCP and UDP port (including all Xray inbounds, e.g. UDP-based Hysteria2), so
- # IP-limit keeps working for inbounds added later without regenerating these files.
+ # Ports to exempt from the ban (SSH + panel port)
local ssh_ports
ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
[[ -z "${ssh_ports}" ]] && ssh_ports="22"
@@ -2528,7 +2506,35 @@ EOF
local exempt_ports="${ssh_ports}"
[[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
- cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
+ # === Backend-specific action configuration ===
+ if [[ "$backend" == "nftables" ]]; then
+ cat << 'EOF' > /etc/fail2ban/action.d/3x-ipl.conf
+[INCLUDES]
+before = nftables-common.conf
+
+[Definition]
+actionban = nft add element ip f2b 3x-ipl { }
+ echo "$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = [IP] = banned." >> /var/log/fail2ban-iplimit.log
+
+actionunban = nft delete element ip f2b 3x-ipl { }
+ echo "$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = [IP] = unbanned." >> /var/log/fail2ban-iplimit.log
+
+actionflush = nft flush set ip f2b 3x-ipl
+
+actionstart =
+ nft add table ip f2b 2>/dev/null || true
+ nft add set ip f2b 3x-ipl { type ipv4_addr; } 2>/dev/null || true
+ nft add chain ip f2b 3x-ipl { type filter hook input priority 0; } 2>/dev/null || true
+ nft add rule ip f2b 3x-ipl ip saddr @3x-ipl drop
+
+actionstop =
+ nft delete chain ip f2b 3x-ipl 2>/dev/null || true
+ nft delete set ip f2b 3x-ipl 2>/dev/null || true
+ nft delete table ip f2b 2>/dev/null || true
+EOF
+ else
+ # iptables — original behavior preserved
+ cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
[INCLUDES]
before = iptables-allports.conf
@@ -2556,23 +2562,9 @@ name = default
chain = INPUT
exemptports = ${exempt_ports}
EOF
+ fi
- echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
-}
-
-iplimit_remove_conflicts() {
- local jail_files=(
- /etc/fail2ban/jail.conf
- /etc/fail2ban/jail.local
- )
-
- for file in "${jail_files[@]}"; do
- # Check for [3x-ipl] config in jail file then remove it
- if test -f "${file}" && grep -qw '3x-ipl' ${file}; then
- sed -i "/\[3x-ipl\]/,/^$/d" ${file}
- echo -e "${yellow}Removing conflicts of [3x-ipl] in jail (${file})!${plain}\n"
- fi
- done
+ echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes (backend: ${backend}).${plain}"
}
SSH_port_forwarding() {
@@ -3606,4 +3598,4 @@ if [[ $# > 0 ]]; then
esac
else
show_menu
-fi
+fi
\ No newline at end of file