diff --git a/.gitignore b/.gitignore
index 8fa4eeb0..1761ece2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Ignore editor and IDE settings
.idea/
.vscode/
+.claude/
.cache/
.sync*
diff --git a/DockerEntrypoint.sh b/DockerEntrypoint.sh
index 7511d2ea..4479a4c5 100644
--- a/DockerEntrypoint.sh
+++ b/DockerEntrypoint.sh
@@ -1,7 +1,61 @@
#!/bin/sh
-# Start fail2ban
-[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
+# Start fail2ban with the 3x-ipl jail
+if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
+ LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
+ mkdir -p "$LOG_FOLDER"
+ touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
+
+ mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
+
+ cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
+[3x-ipl]
+enabled=true
+backend=auto
+filter=3x-ipl
+action=3x-ipl
+logpath=$LOG_FOLDER/3xipl.log
+maxretry=1
+findtime=32
+bantime=30m
+EOF
+
+ cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
+[Definition]
+datepattern = ^%Y/%m/%d %H:%M:%S
+failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+
+ignoreregex =
+EOF
+
+ cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
+[INCLUDES]
+before = iptables-allports.conf
+
+[Definition]
+actionstart = -N f2b-
+ -A f2b- -j
+ -I -p -j f2b-
+
+actionstop = -D -p -j f2b-
+
+ -X f2b-
+
+actioncheck = -n -L | grep -q 'f2b-[ \t]'
+
+actionban = -I f2b- 1 -s -j
+ echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = [IP] = banned for seconds." >> $LOG_FOLDER/3xipl-banned.log
+
+actionunban = -D f2b- -s -j
+ echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = [IP] = unbanned." >> $LOG_FOLDER/3xipl-banned.log
+
+[Init]
+name = default
+protocol = tcp
+chain = INPUT
+EOF
+
+ fail2ban-client -x start
+fi
# Run x-ui
exec /app/x-ui
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index 7f0ac2cf..e16cced2 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -403,16 +403,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
shouldCleanLog := false
j.disAllowedIps = []string{}
- // Open log file
- logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
- if err != nil {
- logger.Errorf("failed to open IP limit log file: %s", err)
- return false
- }
- defer logIpFile.Close()
- log.SetOutput(logIpFile)
- log.SetFlags(log.LstdFlags)
-
// historical db-only ips are excluded from this count on purpose.
var keptLive []IPWithTimestamp
if len(liveIps) > limitIp {
@@ -422,13 +412,25 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
keptLive = liveIps[:limitIp]
bannedLive := liveIps[limitIp:]
+ // Open log file only when a ban entry needs to be written.
+ // Use a local logger to avoid mutating the global log.* state,
+ // which would redirect all standard-library logging to this file
+ // and leave a dangling closed-file handle after the defer fires.
+ logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ logger.Errorf("failed to open IP limit log file: %s", err)
+ return false
+ }
+ defer logIpFile.Close()
+ ipLogger := log.New(logIpFile, "", log.LstdFlags)
+
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
// filter.d/3x-ipl.conf with
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+
// don't change the wording.
for _, ipTime := range bannedLive {
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
- log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
+ ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
// force xray to drop existing connections from banned ips
diff --git a/x-ui.sh b/x-ui.sh
index 73e99195..31ecc683 100644
--- a/x-ui.sh
+++ b/x-ui.sh
@@ -2034,14 +2034,14 @@ backend=auto
filter=3x-ipl
action=3x-ipl
logpath=${iplimit_log_path}
-maxretry=2
+maxretry=1
findtime=32
bantime=${bantime}m
EOF
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
[Definition]
-datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
+datepattern = ^%Y/%m/%d %H:%M:%S
failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex =
EOF
@@ -2062,10 +2062,10 @@ actionstop = -D -p -j f2b-
actioncheck = -n -L | grep -q 'f2b-[ \t]'
actionban = -I f2b- 1 -s -j
- echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = [IP] = banned for seconds." >> ${iplimit_banned_log_path}
+ echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = [IP] = banned for seconds." >> ${iplimit_banned_log_path}
actionunban = -D f2b- -s -j
- echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = [IP] = unbanned." >> ${iplimit_banned_log_path}
+ echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = [IP] = unbanned." >> ${iplimit_banned_log_path}
[Init]
name = default