From c52963ac7e0045f33ee9bda435f167ffc2d57525 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 10:46:30 +0000
Subject: [PATCH 1/2] Initial plan
From 50550f0d4b7890b474ed8ade5278419b487f5213 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 10:59:32 +0000
Subject: [PATCH 2/2] Restore IP limitation feature to v2.8.5 behavior
Agent-Logs-Url: https://github.com/xAlokyx/3x-ui/sessions/0e9b200a-ecf1-45c9-8552-a13819e14e78
Co-authored-by: xAlokyx <234771438+xAlokyx@users.noreply.github.com>
---
web/controller/inbound.go | 32 -------
web/job/check_client_ip_job.go | 148 +++++++++------------------------
web/service/inbound.go | 37 ---------
web/service/tgbot.go | 35 +-------
x-ui.sh | 2 +-
5 files changed, 41 insertions(+), 213 deletions(-)
diff --git a/web/controller/inbound.go b/web/controller/inbound.go
index b012ec95..8317de31 100644
--- a/web/controller/inbound.go
+++ b/web/controller/inbound.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"strconv"
- "time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service"
@@ -194,37 +193,6 @@ func (a *InboundController) getClientIps(c *gin.Context) {
return
}
- // Prefer returning a normalized string list for consistent UI rendering
- type ipWithTimestamp struct {
- IP string `json:"ip"`
- Timestamp int64 `json:"timestamp"`
- }
-
- var ipsWithTime []ipWithTimestamp
- if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
- formatted := make([]string, 0, len(ipsWithTime))
- for _, item := range ipsWithTime {
- if item.IP == "" {
- continue
- }
- if item.Timestamp > 0 {
- ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
- formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
- continue
- }
- formatted = append(formatted, item.IP)
- }
- jsonObj(c, formatted, nil)
- return
- }
-
- var oldIps []string
- if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
- jsonObj(c, oldIps, nil)
- return
- }
-
- // If parsing fails, return as string
jsonObj(c, ips, nil)
}
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index cbc352dc..e783a6df 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -18,12 +18,6 @@ import (
"github.com/mhsanaei/3x-ui/v2/xray"
)
-// IPWithTimestamp tracks an IP address with its last seen timestamp
-type IPWithTimestamp struct {
- IP string `json:"ip"`
- Timestamp int64 `json:"timestamp"`
-}
-
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct {
lastClear int64
@@ -125,14 +119,12 @@ func (j *CheckClientIpJob) processLogFile() bool {
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
emailRegex := regexp.MustCompile(`email: (.+)$`)
- timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
accessLogPath, _ := xray.GetAccessLogPath()
file, _ := os.Open(accessLogPath)
defer file.Close()
- // Track IPs with their last seen timestamp
- inboundClientIps := make(map[string]map[string]int64, 100)
+ inboundClientIps := make(map[string]map[string]struct{}, 100)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
@@ -155,45 +147,28 @@ func (j *CheckClientIpJob) processLogFile() bool {
}
email := emailMatches[1]
- // Extract timestamp from log line
- var timestamp int64
- timestampMatches := timestampRegex.FindStringSubmatch(line)
- if len(timestampMatches) >= 2 {
- t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1])
- if err == nil {
- timestamp = t.Unix()
- } else {
- timestamp = time.Now().Unix()
- }
- } else {
- timestamp = time.Now().Unix()
- }
-
if _, exists := inboundClientIps[email]; !exists {
- inboundClientIps[email] = make(map[string]int64)
- }
- // Update timestamp - keep the latest
- if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
- inboundClientIps[email][ip] = timestamp
+ inboundClientIps[email] = make(map[string]struct{})
}
+ inboundClientIps[email][ip] = struct{}{}
}
shouldCleanLog := false
- for email, ipTimestamps := range inboundClientIps {
+ for email, uniqueIps := range inboundClientIps {
- // Convert to IPWithTimestamp slice
- ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
- for ip, timestamp := range ipTimestamps {
- ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
+ ips := make([]string, 0, len(uniqueIps))
+ for ip := range uniqueIps {
+ ips = append(ips, ip)
}
+ sort.Strings(ips)
clientIpsRecord, err := j.getInboundClientIps(email)
if err != nil {
- j.addInboundClientIps(email, ipsWithTime)
+ j.addInboundClientIps(email, ips)
continue
}
- shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
+ shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ips) || shouldCleanLog
}
return shouldCleanLog
@@ -238,9 +213,9 @@ func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.Inbou
return InboundClientIps, nil
}
-func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
+func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string) error {
inboundClientIps := &model.InboundClientIps{}
- jsonIps, err := json.Marshal(ipsWithTime)
+ jsonIps, err := json.Marshal(ips)
j.checkError(err)
inboundClientIps.ClientEmail = clientEmail
@@ -264,8 +239,16 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
return nil
}
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
- // Get the inbound configuration
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, ips []string) bool {
+ jsonIps, err := json.Marshal(ips)
+ if err != nil {
+ logger.Error("failed to marshal IPs to JSON:", err)
+ return false
+ }
+
+ inboundClientIps.ClientEmail = clientEmail
+ inboundClientIps.Ips = string(jsonIps)
+
inbound, err := j.getInboundByEmail(clientEmail)
if err != nil {
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
@@ -280,58 +263,9 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"]
-
- // Find the client's IP limit
- var limitIp int
- var clientFound bool
- for _, client := range clients {
- if client.Email == clientEmail {
- limitIp = client.LimitIP
- clientFound = true
- break
- }
- }
-
- if !clientFound || limitIp <= 0 || !inbound.Enable {
- // No limit or inbound disabled, just update and return
- jsonIps, _ := json.Marshal(newIpsWithTime)
- inboundClientIps.Ips = string(jsonIps)
- db := database.GetDB()
- db.Save(inboundClientIps)
- return false
- }
-
- // Parse old IPs from database
- var oldIpsWithTime []IPWithTimestamp
- if inboundClientIps.Ips != "" {
- json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
- }
-
- // Merge old and new IPs, keeping the latest timestamp for each IP
- ipMap := make(map[string]int64)
- for _, ipTime := range oldIpsWithTime {
- ipMap[ipTime.IP] = ipTime.Timestamp
- }
- for _, ipTime := range newIpsWithTime {
- if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
- ipMap[ipTime.IP] = ipTime.Timestamp
- }
- }
-
- // Convert back to slice and sort by timestamp (oldest first)
- // This ensures we always protect the original/current connections and ban new excess ones.
- allIps := make([]IPWithTimestamp, 0, len(ipMap))
- for ip, timestamp := range ipMap {
- allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
- }
- sort.Slice(allIps, func(i, j int) bool {
- return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest first)
- })
-
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)
@@ -341,27 +275,27 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.SetOutput(logIpFile)
log.SetFlags(log.LstdFlags)
- // Check if we exceed the limit
- if len(allIps) > limitIp {
- shouldCleanLog = true
+ for _, client := range clients {
+ if client.Email == clientEmail {
+ limitIp := client.LimitIP
- // Keep the oldest IPs (currently active connections) and ban the new excess ones.
- keptIps := allIps[:limitIp]
- bannedIps := allIps[limitIp:]
+ if limitIp > 0 && inbound.Enable {
+ shouldCleanLog = true
- // Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
- for _, ipTime := range bannedIps {
- j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
- log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
+ if limitIp < len(ips) {
+ j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...)
+ for i := limitIp; i < len(ips); i++ {
+ log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i])
+ }
+ }
+ }
}
+ }
- // Update database with only the currently active (kept) IPs
- jsonIps, _ := json.Marshal(keptIps)
- inboundClientIps.Ips = string(jsonIps)
- } else {
- // Under limit, save all IPs
- jsonIps, _ := json.Marshal(allIps)
- inboundClientIps.Ips = string(jsonIps)
+ sort.Strings(j.disAllowedIps)
+
+ if len(j.disAllowedIps) > 0 {
+ logger.Debug("disAllowedIps:", j.disAllowedIps)
}
db := database.GetDB()
@@ -371,10 +305,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
return false
}
- if len(j.disAllowedIps) > 0 {
- logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
- }
-
return shouldCleanLog
}
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 8a3a4ae2..be86c72a 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -2140,43 +2140,6 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
if err != nil {
return "", err
}
-
- if InboundClientIps.Ips == "" {
- return "", nil
- }
-
- // Try to parse as new format (with timestamps)
- type IPWithTimestamp struct {
- IP string `json:"ip"`
- Timestamp int64 `json:"timestamp"`
- }
-
- var ipsWithTime []IPWithTimestamp
- err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
-
- // If successfully parsed as new format, return with timestamps
- if err == nil && len(ipsWithTime) > 0 {
- return InboundClientIps.Ips, nil
- }
-
- // Otherwise, assume it's old format (simple string array)
- // Try to parse as simple array and convert to new format
- var oldIps []string
- err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
- if err == nil && len(oldIps) > 0 {
- // Convert old format to new format with current timestamp
- newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
- for i, ip := range oldIps {
- newIpsWithTime[i] = IPWithTimestamp{
- IP: ip,
- Timestamp: time.Now().Unix(),
- }
- }
- result, _ := json.Marshal(newIpsWithTime)
- return string(result), nil
- }
-
- // Return as-is if parsing fails
return InboundClientIps.Ips, nil
}
diff --git a/web/service/tgbot.go b/web/service/tgbot.go
index 1649f2ed..f2c1de5b 100644
--- a/web/service/tgbot.go
+++ b/web/service/tgbot.go
@@ -5,7 +5,6 @@ import (
"crypto/rand"
"embed"
"encoding/base64"
- "encoding/json"
"errors"
"fmt"
"html"
@@ -3154,41 +3153,9 @@ func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
ips = t.I18nBot("tgbot.noIpRecord")
}
- formattedIps := ips
- if err == nil && len(ips) > 0 {
- type ipWithTimestamp struct {
- IP string `json:"ip"`
- Timestamp int64 `json:"timestamp"`
- }
-
- var ipsWithTime []ipWithTimestamp
- if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
- lines := make([]string, 0, len(ipsWithTime))
- for _, item := range ipsWithTime {
- if item.IP == "" {
- continue
- }
- if item.Timestamp > 0 {
- ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
- lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
- continue
- }
- lines = append(lines, item.IP)
- }
- if len(lines) > 0 {
- formattedIps = strings.Join(lines, "\n")
- }
- } else {
- var oldIps []string
- if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
- formattedIps = strings.Join(oldIps, "\n")
- }
- }
- }
-
output := ""
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
- output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
+ output += t.I18nBot("tgbot.messages.ips", "IPs=="+ips)
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
inlineKeyboard := tu.InlineKeyboard(
diff --git a/x-ui.sh b/x-ui.sh
index e26dcce2..0a770193 100644
--- a/x-ui.sh
+++ b/x-ui.sh
@@ -2013,7 +2013,7 @@ EOF
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
[Definition]
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
-failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+
+failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*SRC\s*=\s*
ignoreregex =
EOF