From 5a6fb407a69c766444fd4553b01b0c92eeae24ea Mon Sep 17 00:00:00 2001 From: HamidReza Date: Fri, 13 Mar 2026 13:45:00 +0330 Subject: [PATCH 01/10] fix: Ban new IPs with fail2ban instead of disconnected the client. --- web/job/check_client_ip_job.go | 80 +++++----------------------------- 1 file changed, 10 insertions(+), 70 deletions(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index d3c1a1d1..1588ba3c 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -319,13 +319,14 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun } } - // Convert back to slice and sort by timestamp (newest first) + // 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 // Descending order (newest first) + return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest first) }) shouldCleanLog := false @@ -345,23 +346,17 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun if len(allIps) > limitIp { shouldCleanLog = true - // Keep only the newest IPs (up to limitIp) + // Keep the oldest IPs (currently active connections) and ban the new excess ones. keptIps := allIps[:limitIp] - disconnectedIps := allIps[limitIp:] + bannedIps := allIps[limitIp:] - // Log the disconnected IPs (old ones) - for _, ipTime := range disconnectedIps { + // Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || SRC = Y + 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) + log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ipTime.IP) } - // Actually disconnect old IPs by temporarily removing and re-adding user - // This forces Xray to drop existing connections from old IPs - if len(disconnectedIps) > 0 { - j.disconnectClientTemporarily(inbound, clientEmail, clients) - } - - // Update database with only the newest IPs + // Update database with only the currently active (kept) IPs jsonIps, _ := json.Marshal(keptIps) inboundClientIps.Ips = string(jsonIps) } else { @@ -378,67 +373,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun } if len(j.disAllowedIps) > 0 { - logger.Infof("[LIMIT_IP] Client %s: Kept %d newest IPs, disconnected %d old IPs", clientEmail, limitIp, len(j.disAllowedIps)) + logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps)) } return shouldCleanLog } -// disconnectClientTemporarily removes and re-adds a client to force disconnect old connections -func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) { - var xrayAPI xray.XrayAPI - - // Get panel settings for API port - db := database.GetDB() - var apiPort int - var apiPortSetting model.Setting - if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil { - apiPort, _ = strconv.Atoi(apiPortSetting.Value) - } - - if apiPort == 0 { - apiPort = 10085 // Default API port - } - - err := xrayAPI.Init(apiPort) - if err != nil { - logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err) - return - } - defer xrayAPI.Close() - - // Find the client config - var clientConfig map[string]any - for _, client := range clients { - if client.Email == clientEmail { - // Convert client to map for API - clientBytes, _ := json.Marshal(client) - json.Unmarshal(clientBytes, &clientConfig) - break - } - } - - if clientConfig == nil { - return - } - - // Remove user to disconnect all connections - err = xrayAPI.RemoveUser(inbound.Tag, clientEmail) - if err != nil { - logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err) - return - } - - // Wait a moment for disconnection to take effect - time.Sleep(100 * time.Millisecond) - - // Re-add user to allow new connections - err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig) - if err != nil { - logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err) - } -} - func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) { db := database.GetDB() inbound := &model.Inbound{} From 0b1d27bf03a228e868a3992f65d56e9a8607d537 Mon Sep 17 00:00:00 2001 From: HamidReza Date: Fri, 13 Mar 2026 13:56:54 +0330 Subject: [PATCH 02/10] fix: Remove unused strconv import --- web/job/check_client_ip_job.go | 1 - 1 file changed, 1 deletion(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 1588ba3c..81a02e2d 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -10,7 +10,6 @@ import ( "regexp" "runtime" "sort" - "strconv" "time" "github.com/mhsanaei/3x-ui/v2/database" From 537f8d4f8c399f7e5182673fa90cabf44f715461 Mon Sep 17 00:00:00 2001 From: HamidReza Date: Fri, 13 Mar 2026 14:06:08 +0330 Subject: [PATCH 03/10] fix: Revert log fail2ban format --- web/job/check_client_ip_job.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 81a02e2d..cbc352dc 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -349,10 +349,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun keptIps := allIps[:limitIp] bannedIps := allIps[limitIp:] - // Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || SRC = Y + // 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 || SRC = %s", clientEmail, ipTime.IP) + log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp) } // Update database with only the currently active (kept) IPs From 8b6ff269b654093557c1598e8a20922847f5878d Mon Sep 17 00:00:00 2001 From: HamidReza Date: Wed, 18 Mar 2026 23:53:14 +0330 Subject: [PATCH 04/10] fix: Disconnect the client to remove the banned IPs connections --- web/job/check_client_ip_job.go | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index cbc352dc..d5ea7b2d 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -355,6 +355,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp) } + // Actually disconnect banned IPs by temporarily removing and re-adding user + // This forces Xray to drop existing connections from banned IPs + if len(bannedIps) > 0 { + j.disconnectClientTemporarily(inbound, clientEmail, clients) + } + // Update database with only the currently active (kept) IPs jsonIps, _ := json.Marshal(keptIps) inboundClientIps.Ips = string(jsonIps) @@ -378,6 +384,62 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun return shouldCleanLog } +// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections +func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) { + var xrayAPI xray.XrayAPI + + // Get panel settings for API port + db := database.GetDB() + var apiPort int + var apiPortSetting model.Setting + if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil { + apiPort, _ = strconv.Atoi(apiPortSetting.Value) + } + + if apiPort == 0 { + apiPort = 10085 // Default API port + } + + err := xrayAPI.Init(apiPort) + if err != nil { + logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err) + return + } + defer xrayAPI.Close() + + // Find the client config + var clientConfig map[string]any + for _, client := range clients { + if client.Email == clientEmail { + // Convert client to map for API + clientBytes, _ := json.Marshal(client) + json.Unmarshal(clientBytes, &clientConfig) + break + } + } + + if clientConfig == nil { + return + } + + // Remove user to disconnect all connections + err = xrayAPI.RemoveUser(inbound.Tag, clientEmail) + if err != nil { + logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err) + return + } + + // Wait a moment for disconnection to take effect + time.Sleep(100 * time.Millisecond) + + // Re-add user to allow new connections + err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig) + if err != nil { + logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err) + } +} + + func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) { db := database.GetDB() inbound := &model.Inbound{} From aed05fa7f062ef92c8b27d1d4ba5b6ba4d4d9090 Mon Sep 17 00:00:00 2001 From: HamidReza Date: Wed, 18 Mar 2026 23:53:49 +0330 Subject: [PATCH 05/10] fix: Fix getting the xray inbound api port --- web/job/check_client_ip_job.go | 57 +++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index d5ea7b2d..eb9b6e4c 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -3,6 +3,7 @@ package job import ( "bufio" "encoding/json" + "errors" "io" "log" "os" @@ -32,6 +33,8 @@ type CheckClientIpJob struct { var job *CheckClientIpJob +const defaultXrayAPIPort = 62789 + // NewCheckClientIpJob creates a new client IP monitoring job instance. func NewCheckClientIpJob() *CheckClientIpJob { job = new(CheckClientIpJob) @@ -387,18 +390,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun // disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) { var xrayAPI xray.XrayAPI - - // Get panel settings for API port - db := database.GetDB() - var apiPort int - var apiPortSetting model.Setting - if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil { - apiPort, _ = strconv.Atoi(apiPortSetting.Value) - } - - if apiPort == 0 { - apiPort = 10085 // Default API port - } + apiPort := j.resolveXrayAPIPort() err := xrayAPI.Init(apiPort) if err != nil { @@ -439,6 +431,47 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c } } +// resolveXrayAPIPort returns the API inbound port from running config, then template config, then default. +func (j *CheckClientIpJob) resolveXrayAPIPort() int { + if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil { + return port + } + + db := database.GetDB() + var template model.Setting + if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil { + if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil { + return port + } + } + + return defaultXrayAPIPort +} + +func getAPIPortFromConfigPath(configPath string) (int, error) { + configData, err := os.ReadFile(configPath) + if err != nil { + return 0, err + } + + return getAPIPortFromConfigData(configData) +} + +func getAPIPortFromConfigData(configData []byte) (int, error) { + xrayConfig := &xray.Config{} + if err := json.Unmarshal(configData, xrayConfig); err != nil { + return 0, err + } + + for _, inboundConfig := range xrayConfig.InboundConfigs { + if inboundConfig.Tag == "api" && inboundConfig.Port > 0 { + return inboundConfig.Port, nil + } + } + + return 0, errors.New("api inbound port not found") +} + func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) { db := database.GetDB() From 334e1038e9183c31e7460cfbfaa29af470fb389b Mon Sep 17 00:00:00 2001 From: HamidReza Date: Thu, 19 Mar 2026 00:02:58 +0330 Subject: [PATCH 06/10] fix: Run go formatter --- web/job/check_client_ip_job.go | 1 - 1 file changed, 1 deletion(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index eb9b6e4c..d9aecec1 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -472,7 +472,6 @@ func getAPIPortFromConfigData(configData []byte) (int, error) { return 0, errors.New("api inbound port not found") } - func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) { db := database.GetDB() inbound := &model.Inbound{} From e718679f2624db02fd8b6a0b2a2cda13dc55f40d Mon Sep 17 00:00:00 2001 From: HamidReza Date: Thu, 19 Mar 2026 00:09:46 +0330 Subject: [PATCH 07/10] fix: Disconnect only the supported protocols client --- web/job/check_client_ip_job.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index d9aecec1..19c22e6d 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -414,6 +414,16 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c return } + // Only perform remove/re-add for protocols supported by XrayAPI.AddUser + protocol := string(inbound.Protocol) + switch protocol { + case "vmess", "vless", "trojan", "shadowsocks": + // supported protocols, continue + default: + logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag) + return + } + // Remove user to disconnect all connections err = xrayAPI.RemoveUser(inbound.Tag, clientEmail) if err != nil { @@ -425,7 +435,7 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c time.Sleep(100 * time.Millisecond) // Re-add user to allow new connections - err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig) + err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig) if err != nil { logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err) } From aaabeeb33c802c2ea679afbfa2208d26dc6f7f6f Mon Sep 17 00:00:00 2001 From: HamidReza Date: Thu, 19 Mar 2026 00:13:15 +0330 Subject: [PATCH 08/10] fix: Ensure the required "cipher" field is present in the shadowsocks protocol --- web/job/check_client_ip_job.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 19c22e6d..d55050a0 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -424,6 +424,19 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c return } + // For Shadowsocks, ensure the required "cipher" field is present by + // reading it from the inbound settings (e.g., settings["method"]). + if string(inbound.Protocol) == "shadowsocks" { + var inboundSettings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil { + logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err) + } else { + if method, ok := inboundSettings["method"].(string); ok && method != "" { + clientConfig["cipher"] = method + } + } + } + // Remove user to disconnect all connections err = xrayAPI.RemoveUser(inbound.Tag, clientEmail) if err != nil { From d34e95eba7d9ba36866e9d24ff90f97c5c8e3921 Mon Sep 17 00:00:00 2001 From: HamidReza Date: Thu, 19 Mar 2026 00:17:30 +0330 Subject: [PATCH 09/10] fix: Log the errors in the resolveXrayAPIPort function --- web/job/check_client_ip_job.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index d55050a0..9ef242e1 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -456,17 +456,33 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c // resolveXrayAPIPort returns the API inbound port from running config, then template config, then default. func (j *CheckClientIpJob) resolveXrayAPIPort() int { + var configErr error + var templateErr error + if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil { return port - } + } else { + configErr = err + } db := database.GetDB() var template model.Setting if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil { if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil { return port - } - } + } else { + templateErr = parseErr + } + } else { + templateErr = err + } + + logger.Warningf( + "[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)", + defaultXrayAPIPort, + configErr, + templateErr, + ) return defaultXrayAPIPort } From bc73ce3dde7cc2bde9cf12210d954540c8de661a Mon Sep 17 00:00:00 2001 From: HamidReza Date: Thu, 19 Mar 2026 00:17:59 +0330 Subject: [PATCH 10/10] fix: Run go formatter --- web/job/check_client_ip_job.go | 62 +++++++++++++++++----------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 9ef242e1..312a8eee 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -415,27 +415,27 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c } // Only perform remove/re-add for protocols supported by XrayAPI.AddUser - protocol := string(inbound.Protocol) - switch protocol { - case "vmess", "vless", "trojan", "shadowsocks": - // supported protocols, continue - default: - logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag) - return - } + protocol := string(inbound.Protocol) + switch protocol { + case "vmess", "vless", "trojan", "shadowsocks": + // supported protocols, continue + default: + logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag) + return + } // For Shadowsocks, ensure the required "cipher" field is present by - // reading it from the inbound settings (e.g., settings["method"]). - if string(inbound.Protocol) == "shadowsocks" { - var inboundSettings map[string]any - if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil { - logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err) - } else { - if method, ok := inboundSettings["method"].(string); ok && method != "" { - clientConfig["cipher"] = method - } - } - } + // reading it from the inbound settings (e.g., settings["method"]). + if string(inbound.Protocol) == "shadowsocks" { + var inboundSettings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil { + logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err) + } else { + if method, ok := inboundSettings["method"].(string); ok && method != "" { + clientConfig["cipher"] = method + } + } + } // Remove user to disconnect all connections err = xrayAPI.RemoveUser(inbound.Tag, clientEmail) @@ -457,13 +457,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c // resolveXrayAPIPort returns the API inbound port from running config, then template config, then default. func (j *CheckClientIpJob) resolveXrayAPIPort() int { var configErr error - var templateErr error + var templateErr error if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil { return port } else { - configErr = err - } + configErr = err + } db := database.GetDB() var template model.Setting @@ -471,18 +471,18 @@ func (j *CheckClientIpJob) resolveXrayAPIPort() int { if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil { return port } else { - templateErr = parseErr - } + templateErr = parseErr + } } else { - templateErr = err - } + templateErr = err + } logger.Warningf( - "[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)", - defaultXrayAPIPort, - configErr, - templateErr, - ) + "[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)", + defaultXrayAPIPort, + configErr, + templateErr, + ) return defaultXrayAPIPort }