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] 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