From 4a1dac89b643f4ac27ddc9b9aa680b5d4441d1fc Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Sun, 5 Apr 2026 04:40:24 +0800
Subject: [PATCH 1/9] fix(inbounds): avoid undefined filterEmailOption in
client form
---
web/html/form/client.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/html/form/client.html b/web/html/form/client.html
index 3cc13b89..cf99e3b3 100644
--- a/web/html/form/client.html
+++ b/web/html/form/client.html
@@ -18,7 +18,7 @@
mode="combobox"
show-search
allow-clear
- :filter-option="filterEmailOption"
+ :filter-option="(input, option) => String(option.componentOptions?.propsData?.value || '').toLowerCase().includes(String(input || '').toLowerCase())"
:dropdown-class-name="themeSwitcher.currentTheme"
:not-found-content="emailOptions.length === 0 ? null : undefined"
:placeholder='{{ printf "%q" (i18n "pages.inbounds.emailSelectPlaceholder") }}'>
From 83b61d9da4c4c7c82f5e6de672f89f501cf4bdac Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 10:46:48 +0800
Subject: [PATCH 2/9] feat(limit): add inbound device-limit enforcement with
safe unban flow
- add inbound deviceLimit model/frontend fields and translations
- add CheckDeviceLimitJob with observation window and xray API ban/unban
- prevent job re-entrancy and restore users when limit is disabled
- reduce lock scope via snapshots to avoid blocking log parsing
---
database/model/model.go | 1 +
web/assets/js/model/dbinbound.js | 3 +-
web/html/form/inbound.html | 16 +-
web/html/inbounds.html | 8 +
web/job/check_device_limit_job.go | 345 +++++++++++++++++++++++++++
web/service/inbound.go | 1 +
web/service/xray.go | 8 +
web/translation/translate.en_US.toml | 2 +
web/translation/translate.zh_CN.toml | 2 +
web/web.go | 2 +
10 files changed, 386 insertions(+), 2 deletions(-)
create mode 100644 web/job/check_device_limit_job.go
diff --git a/database/model/model.go b/database/model/model.go
index 1865d1a9..98252f62 100644
--- a/database/model/model.go
+++ b/database/model/model.go
@@ -42,6 +42,7 @@ type Inbound struct {
Remark string `json:"remark" form:"remark"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
+ DeviceLimit int `json:"deviceLimit" form:"deviceLimit" gorm:"column:device_limit;default:0"` // Active device/IP limit by inbound
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
diff --git a/web/assets/js/model/dbinbound.js b/web/assets/js/model/dbinbound.js
index befc618e..ea20d855 100644
--- a/web/assets/js/model/dbinbound.js
+++ b/web/assets/js/model/dbinbound.js
@@ -10,6 +10,7 @@ class DBInbound {
this.remark = "";
this.enable = true;
this.expiryTime = 0;
+ this.deviceLimit = 0;
this.trafficReset = "never";
this.lastTrafficResetTime = 0;
@@ -148,4 +149,4 @@ class DBInbound {
const inbound = this.toInbound();
return inbound.genInboundLinks(this.remark, remarkModel);
}
-}
\ No newline at end of file
+}
diff --git a/web/html/form/inbound.html b/web/html/form/inbound.html
index 8b59dc28..dcce4e0f 100644
--- a/web/html/form/inbound.html
+++ b/web/html/form/inbound.html
@@ -49,6 +49,20 @@
:min="0">
+
+
+
+
+ {{ i18n "pages.inbounds.deviceLimitDesc" }}
+
+ {{ i18n "pages.inbounds.deviceLimit" }}
+
+
+
+
+
+
@@ -170,4 +184,4 @@
-{{end}}
\ No newline at end of file
+{{end}}
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index f0298a8a..fda4aca2 100644
--- a/web/html/inbounds.html
+++ b/web/html/inbounds.html
@@ -645,6 +645,11 @@
align: 'left',
width: 50,
scopedSlots: { customRender: 'clients' },
+ }, {
+ title: '{{ i18n "pages.inbounds.deviceLimit" }}',
+ align: 'center',
+ width: 45,
+ dataIndex: "deviceLimit",
}, {
title: '{{ i18n "pages.inbounds.traffic" }}',
align: 'center',
@@ -1030,6 +1035,7 @@
remark: dbInbound.remark + " - Cloned",
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
+ deviceLimit: dbInbound.deviceLimit,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
@@ -1076,6 +1082,7 @@
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
+ deviceLimit: dbInbound.deviceLimit,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
@@ -1101,6 +1108,7 @@
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
+ deviceLimit: dbInbound.deviceLimit,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
diff --git a/web/job/check_device_limit_job.go b/web/job/check_device_limit_job.go
new file mode 100644
index 00000000..b3ae68a2
--- /dev/null
+++ b/web/job/check_device_limit_job.go
@@ -0,0 +1,345 @@
+package job
+
+import (
+ "bufio"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v2/database"
+ "github.com/mhsanaei/3x-ui/v2/database/model"
+ "github.com/mhsanaei/3x-ui/v2/logger"
+ "github.com/mhsanaei/3x-ui/v2/web/service"
+ "github.com/mhsanaei/3x-ui/v2/xray"
+)
+
+var (
+ activeClientIPs = make(map[string]map[string]time.Time)
+ activeClientsLock sync.RWMutex
+
+ clientStatus = make(map[string]bool)
+ clientStatusMu sync.RWMutex
+)
+
+type CheckDeviceLimitJob struct {
+ inboundService service.InboundService
+ xrayService *service.XrayService
+ xrayAPI xray.XrayAPI
+ lastPosition int64
+
+ violationStartTime map[string]time.Time
+ violationMu sync.Mutex
+ running atomic.Bool
+}
+
+type deviceInboundInfo struct {
+ Limit int
+ Tag string
+ Protocol model.Protocol
+ Enable bool
+}
+
+func NewCheckDeviceLimitJob(xrayService *service.XrayService) *CheckDeviceLimitJob {
+ return &CheckDeviceLimitJob{
+ xrayService: xrayService,
+ violationStartTime: make(map[string]time.Time),
+ }
+}
+
+func (j *CheckDeviceLimitJob) Run() {
+ // Avoid concurrent re-entrancy when previous run is still processing.
+ if !j.running.CompareAndSwap(false, true) {
+ return
+ }
+ defer j.running.Store(false)
+
+ if j.xrayService == nil || !j.xrayService.IsXrayRunning() {
+ return
+ }
+ j.cleanupExpiredIPs()
+ j.parseAccessLog()
+ j.checkAllClientsLimit()
+}
+
+func (j *CheckDeviceLimitJob) cleanupExpiredIPs() {
+ activeClientsLock.Lock()
+ defer activeClientsLock.Unlock()
+
+ now := time.Now()
+ const activeTTL = 3 * time.Minute
+ for email, ips := range activeClientIPs {
+ for ip, lastSeen := range ips {
+ if now.Sub(lastSeen) > activeTTL {
+ delete(activeClientIPs[email], ip)
+ }
+ }
+ if len(activeClientIPs[email]) == 0 {
+ delete(activeClientIPs, email)
+ }
+ }
+}
+
+func (j *CheckDeviceLimitJob) parseAccessLog() {
+ logPath, err := xray.GetAccessLogPath()
+ if err != nil || logPath == "none" || logPath == "" {
+ return
+ }
+
+ file, err := os.Open(logPath)
+ if err != nil {
+ return
+ }
+ defer file.Close()
+
+ if _, err = file.Seek(j.lastPosition, 0); err != nil {
+ return
+ }
+
+ scanner := bufio.NewScanner(file)
+ emailRegex := regexp.MustCompile(`email: ([^ ]+)`)
+ ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
+
+ activeClientsLock.Lock()
+ defer activeClientsLock.Unlock()
+
+ now := time.Now()
+ for scanner.Scan() {
+ line := scanner.Text()
+ emailMatch := emailRegex.FindStringSubmatch(line)
+ ipMatch := ipRegex.FindStringSubmatch(line)
+ if len(emailMatch) <= 1 || len(ipMatch) <= 1 {
+ continue
+ }
+
+ email := emailMatch[1]
+ ip := ipMatch[1]
+ if ip == "127.0.0.1" || ip == "::1" {
+ continue
+ }
+
+ if _, ok := activeClientIPs[email]; !ok {
+ activeClientIPs[email] = make(map[string]time.Time)
+ }
+ activeClientIPs[email][ip] = now
+ }
+
+ currentPosition, err := file.Seek(0, io.SeekEnd)
+ if err != nil {
+ return
+ }
+ if currentPosition < j.lastPosition {
+ j.lastPosition = 0
+ return
+ }
+ j.lastPosition = currentPosition
+}
+
+func (j *CheckDeviceLimitJob) checkAllClientsLimit() {
+ db := database.GetDB()
+ var allInbounds []*model.Inbound
+ if err := db.Find(&allInbounds).Error; err != nil || len(allInbounds) == 0 {
+ return
+ }
+
+ apiPort := j.xrayService.GetAPIPort()
+ if apiPort == 0 {
+ return
+ }
+ if err := j.xrayAPI.Init(apiPort); err != nil {
+ return
+ }
+ defer j.xrayAPI.Close()
+
+ inboundInfoMap := make(map[int]deviceInboundInfo, len(allInbounds))
+ for _, inbound := range allInbounds {
+ inboundInfoMap[inbound.Id] = deviceInboundInfo{
+ Limit: inbound.DeviceLimit,
+ Tag: inbound.Tag,
+ Protocol: inbound.Protocol,
+ Enable: inbound.Enable,
+ }
+ }
+
+ activeCounts := make(map[string]int)
+ activeClientsLock.RLock()
+ for email, ips := range activeClientIPs {
+ activeCounts[email] = len(ips)
+ }
+ activeClientsLock.RUnlock()
+
+ bannedSnapshot := make(map[string]bool)
+ clientStatusMu.RLock()
+ for email, banned := range clientStatus {
+ if banned {
+ bannedSnapshot[email] = true
+ }
+ }
+ clientStatusMu.RUnlock()
+
+ for email, activeIPCount := range activeCounts {
+ traffic, err := j.inboundService.GetClientTrafficByEmail(email)
+ if err != nil || traffic == nil {
+ continue
+ }
+
+ info, ok := inboundInfoMap[traffic.InboundId]
+ if !ok {
+ continue
+ }
+
+ isBanned := j.isClientBanned(email)
+ enforcementEnabled := info.Enable && info.Limit > 0
+
+ // If this client was previously banned but device-limit is now disabled
+ // (or inbound is disabled), immediately restore access.
+ if isBanned && !enforcementEnabled {
+ j.violationMu.Lock()
+ delete(j.violationStartTime, email)
+ j.violationMu.Unlock()
+ j.unbanUser(email, activeIPCount, info)
+ continue
+ }
+
+ if !enforcementEnabled {
+ continue
+ }
+
+ if activeIPCount > info.Limit && !isBanned {
+ j.violationMu.Lock()
+ startTime, exists := j.violationStartTime[email]
+ if !exists {
+ j.violationStartTime[email] = time.Now()
+ j.violationMu.Unlock()
+ continue
+ }
+ if time.Since(startTime) < 3*time.Minute {
+ j.violationMu.Unlock()
+ continue
+ }
+ delete(j.violationStartTime, email)
+ j.violationMu.Unlock()
+
+ j.banUser(email, activeIPCount, info)
+ }
+
+ if activeIPCount <= info.Limit {
+ j.violationMu.Lock()
+ delete(j.violationStartTime, email)
+ j.violationMu.Unlock()
+
+ if isBanned {
+ j.unbanUser(email, activeIPCount, info)
+ }
+ }
+ }
+
+ for email := range bannedSnapshot {
+ if _, online := activeCounts[email]; online {
+ continue
+ }
+ traffic, err := j.inboundService.GetClientTrafficByEmail(email)
+ if err != nil || traffic == nil {
+ continue
+ }
+ info, ok := inboundInfoMap[traffic.InboundId]
+ if !ok {
+ // Inbound no longer exists; clear stale ban marker.
+ j.clearClientBanned(email)
+ j.violationMu.Lock()
+ delete(j.violationStartTime, email)
+ j.violationMu.Unlock()
+ continue
+ }
+ // Offline users should be restored when enforcement is disabled too.
+ if !info.Enable || info.Limit <= 0 {
+ j.unbanUser(email, 0, info)
+ continue
+ }
+ j.unbanUser(email, 0, info)
+ }
+}
+
+func (j *CheckDeviceLimitJob) banUser(email string, activeIPCount int, info deviceInboundInfo) {
+ _, client, err := j.inboundService.GetClientByEmail(email)
+ if err != nil || client == nil {
+ return
+ }
+
+ logger.Infof("[DeviceLimit] banning email=%s limit=%d current=%d", email, info.Limit, activeIPCount)
+ _ = j.xrayAPI.RemoveUser(info.Tag, email)
+ time.Sleep(5 * time.Second)
+
+ tempClient := *client
+ if tempClient.ID != "" {
+ tempClient.ID = randomUUID()
+ }
+ if tempClient.Password != "" {
+ tempClient.Password = randomUUID()
+ }
+
+ clientMap := map[string]any{}
+ clientJSON, _ := json.Marshal(tempClient)
+ _ = json.Unmarshal(clientJSON, &clientMap)
+
+ if err = j.xrayAPI.AddUser(string(info.Protocol), info.Tag, clientMap); err != nil {
+ logger.Warningf("[DeviceLimit] failed to ban user %s: %v", email, err)
+ return
+ }
+ j.setClientBanned(email, true)
+}
+
+func (j *CheckDeviceLimitJob) unbanUser(email string, activeIPCount int, info deviceInboundInfo) {
+ _, client, err := j.inboundService.GetClientByEmail(email)
+ if err != nil || client == nil {
+ return
+ }
+
+ logger.Infof("[DeviceLimit] unbanning email=%s limit=%d current=%d", email, info.Limit, activeIPCount)
+ _ = j.xrayAPI.RemoveUser(info.Tag, email)
+ time.Sleep(5 * time.Second)
+
+ clientMap := map[string]any{}
+ clientJSON, _ := json.Marshal(client)
+ _ = json.Unmarshal(clientJSON, &clientMap)
+
+ if err = j.xrayAPI.AddUser(string(info.Protocol), info.Tag, clientMap); err != nil {
+ logger.Warningf("[DeviceLimit] failed to restore user %s: %v", email, err)
+ return
+ }
+ j.clearClientBanned(email)
+}
+
+func randomUUID() string {
+ uuid := make([]byte, 16)
+ if _, err := rand.Read(uuid); err != nil {
+ return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
+ }
+ uuid[6] = (uuid[6] & 0x0f) | 0x40
+ uuid[8] = (uuid[8] & 0x3f) | 0x80
+ return hex.EncodeToString(uuid[0:4]) + "-" + hex.EncodeToString(uuid[4:6]) + "-" + hex.EncodeToString(uuid[6:8]) + "-" + hex.EncodeToString(uuid[8:10]) + "-" + hex.EncodeToString(uuid[10:16])
+}
+
+func (j *CheckDeviceLimitJob) isClientBanned(email string) bool {
+ clientStatusMu.RLock()
+ defer clientStatusMu.RUnlock()
+ return clientStatus[email]
+}
+
+func (j *CheckDeviceLimitJob) setClientBanned(email string, banned bool) {
+ clientStatusMu.Lock()
+ clientStatus[email] = banned
+ clientStatusMu.Unlock()
+}
+
+func (j *CheckDeviceLimitJob) clearClientBanned(email string) {
+ clientStatusMu.Lock()
+ delete(clientStatus, email)
+ clientStatusMu.Unlock()
+}
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 669827b4..e0daa47f 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -489,6 +489,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Remark = inbound.Remark
oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime
+ oldInbound.DeviceLimit = inbound.DeviceLimit
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port
diff --git a/web/service/xray.go b/web/service/xray.go
index 511ffdda..b9d4a74a 100644
--- a/web/service/xray.go
+++ b/web/service/xray.go
@@ -33,6 +33,14 @@ func (s *XrayService) IsXrayRunning() bool {
return p != nil && p.IsRunning()
}
+// GetAPIPort returns the currently configured Xray API port.
+func (s *XrayService) GetAPIPort() int {
+ if p == nil {
+ return 0
+ }
+ return p.GetAPIPort()
+}
+
// GetXrayErr returns the error from the Xray process, if any.
func (s *XrayService) GetXrayErr() error {
if p == nil {
diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml
index 047ba4a7..4a21c4f6 100644
--- a/web/translation/translate.en_US.toml
+++ b/web/translation/translate.en_US.toml
@@ -215,6 +215,8 @@
"monitorDesc" = "Leave blank to listen on all IPs"
"meansNoLimit" = "= Unlimited. (unit: GB)"
"totalFlow" = "Total Flow"
+"deviceLimit" = "Device Limit"
+"deviceLimitDesc" = "Max active devices/IPs allowed for this inbound. (0 = unlimited)"
"leaveBlankToNeverExpire" = "Leave blank to never expire"
"noRecommendKeepDefault" = "It is recommended to keep the default"
"certificatePath" = "File Path"
diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml
index 73d71b60..7efede28 100644
--- a/web/translation/translate.zh_CN.toml
+++ b/web/translation/translate.zh_CN.toml
@@ -215,6 +215,8 @@
"monitorDesc" = "留空表示监听所有 IP"
"meansNoLimit" = "= 无限制(单位:GB)"
"totalFlow" = "总流量"
+"deviceLimit" = "设备限制"
+"deviceLimitDesc" = "限制该入站可同时在线的设备/IP 数量(0 = 不限制)"
"leaveBlankToNeverExpire" = "留空表示永不过期"
"noRecommendKeepDefault" = "建议保留默认值"
"certificatePath" = "文件路径"
diff --git a/web/web.go b/web/web.go
index 60934048..44c5601c 100644
--- a/web/web.go
+++ b/web/web.go
@@ -320,6 +320,8 @@ func (s *Server) startTask() {
// check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
+ // check active device limits every 10 sec
+ s.cron.AddJob("@every 10s", job.NewCheckDeviceLimitJob(&s.xrayService))
// check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob())
From 15144e199d488ffd98eb73f9f945b7ff26d183f6 Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 10:49:11 +0800
Subject: [PATCH 3/9] test(limit): add regression tests for reentry and
disabled-limit unban
- inject lightweight hooks in CheckDeviceLimitJob for deterministic tests
- add tests for run re-entrancy, disabled enforcement unban, and stale ban cleanup
---
web/job/check_device_limit_job.go | 76 +++++++++++----
web/job/check_device_limit_job_test.go | 129 +++++++++++++++++++++++++
2 files changed, 187 insertions(+), 18 deletions(-)
create mode 100644 web/job/check_device_limit_job_test.go
diff --git a/web/job/check_device_limit_job.go b/web/job/check_device_limit_job.go
index b3ae68a2..c7299321 100644
--- a/web/job/check_device_limit_job.go
+++ b/web/job/check_device_limit_job.go
@@ -37,6 +37,17 @@ type CheckDeviceLimitJob struct {
violationStartTime map[string]time.Time
violationMu sync.Mutex
running atomic.Bool
+
+ isXrayRunning func() bool
+ getAPIPort func() int
+ loadAllInbounds func() ([]*model.Inbound, error)
+ getClientTraffic func(email string) (*xray.ClientTraffic, error)
+ getClientByEmail func(email string) (*xray.ClientTraffic, *model.Client, error)
+ apiInit func(apiPort int) error
+ apiClose func()
+ removeUser func(inboundTag, email string) error
+ addUser func(protocol, inboundTag string, user map[string]any) error
+ sleep func(time.Duration)
}
type deviceInboundInfo struct {
@@ -47,10 +58,37 @@ type deviceInboundInfo struct {
}
func NewCheckDeviceLimitJob(xrayService *service.XrayService) *CheckDeviceLimitJob {
- return &CheckDeviceLimitJob{
+ j := &CheckDeviceLimitJob{
xrayService: xrayService,
violationStartTime: make(map[string]time.Time),
}
+ j.isXrayRunning = func() bool {
+ return j.xrayService != nil && j.xrayService.IsXrayRunning()
+ }
+ j.getAPIPort = func() int {
+ if j.xrayService == nil {
+ return 0
+ }
+ return j.xrayService.GetAPIPort()
+ }
+ j.loadAllInbounds = func() ([]*model.Inbound, error) {
+ db := database.GetDB()
+ var inbounds []*model.Inbound
+ err := db.Find(&inbounds).Error
+ return inbounds, err
+ }
+ j.getClientTraffic = func(email string) (*xray.ClientTraffic, error) {
+ return j.inboundService.GetClientTrafficByEmail(email)
+ }
+ j.getClientByEmail = func(email string) (*xray.ClientTraffic, *model.Client, error) {
+ return j.inboundService.GetClientByEmail(email)
+ }
+ j.apiInit = j.xrayAPI.Init
+ j.apiClose = j.xrayAPI.Close
+ j.removeUser = j.xrayAPI.RemoveUser
+ j.addUser = j.xrayAPI.AddUser
+ j.sleep = time.Sleep
+ return j
}
func (j *CheckDeviceLimitJob) Run() {
@@ -60,7 +98,7 @@ func (j *CheckDeviceLimitJob) Run() {
}
defer j.running.Store(false)
- if j.xrayService == nil || !j.xrayService.IsXrayRunning() {
+ if j.isXrayRunning == nil || !j.isXrayRunning() {
return
}
j.cleanupExpiredIPs()
@@ -142,20 +180,22 @@ func (j *CheckDeviceLimitJob) parseAccessLog() {
}
func (j *CheckDeviceLimitJob) checkAllClientsLimit() {
- db := database.GetDB()
- var allInbounds []*model.Inbound
- if err := db.Find(&allInbounds).Error; err != nil || len(allInbounds) == 0 {
+ if j.loadAllInbounds == nil {
+ return
+ }
+ allInbounds, err := j.loadAllInbounds()
+ if err != nil || len(allInbounds) == 0 {
return
}
- apiPort := j.xrayService.GetAPIPort()
+ apiPort := j.getAPIPort()
if apiPort == 0 {
return
}
- if err := j.xrayAPI.Init(apiPort); err != nil {
+ if err := j.apiInit(apiPort); err != nil {
return
}
- defer j.xrayAPI.Close()
+ defer j.apiClose()
inboundInfoMap := make(map[int]deviceInboundInfo, len(allInbounds))
for _, inbound := range allInbounds {
@@ -184,7 +224,7 @@ func (j *CheckDeviceLimitJob) checkAllClientsLimit() {
clientStatusMu.RUnlock()
for email, activeIPCount := range activeCounts {
- traffic, err := j.inboundService.GetClientTrafficByEmail(email)
+ traffic, err := j.getClientTraffic(email)
if err != nil || traffic == nil {
continue
}
@@ -244,7 +284,7 @@ func (j *CheckDeviceLimitJob) checkAllClientsLimit() {
if _, online := activeCounts[email]; online {
continue
}
- traffic, err := j.inboundService.GetClientTrafficByEmail(email)
+ traffic, err := j.getClientTraffic(email)
if err != nil || traffic == nil {
continue
}
@@ -267,14 +307,14 @@ func (j *CheckDeviceLimitJob) checkAllClientsLimit() {
}
func (j *CheckDeviceLimitJob) banUser(email string, activeIPCount int, info deviceInboundInfo) {
- _, client, err := j.inboundService.GetClientByEmail(email)
+ _, client, err := j.getClientByEmail(email)
if err != nil || client == nil {
return
}
logger.Infof("[DeviceLimit] banning email=%s limit=%d current=%d", email, info.Limit, activeIPCount)
- _ = j.xrayAPI.RemoveUser(info.Tag, email)
- time.Sleep(5 * time.Second)
+ _ = j.removeUser(info.Tag, email)
+ j.sleep(5 * time.Second)
tempClient := *client
if tempClient.ID != "" {
@@ -288,7 +328,7 @@ func (j *CheckDeviceLimitJob) banUser(email string, activeIPCount int, info devi
clientJSON, _ := json.Marshal(tempClient)
_ = json.Unmarshal(clientJSON, &clientMap)
- if err = j.xrayAPI.AddUser(string(info.Protocol), info.Tag, clientMap); err != nil {
+ if err = j.addUser(string(info.Protocol), info.Tag, clientMap); err != nil {
logger.Warningf("[DeviceLimit] failed to ban user %s: %v", email, err)
return
}
@@ -296,20 +336,20 @@ func (j *CheckDeviceLimitJob) banUser(email string, activeIPCount int, info devi
}
func (j *CheckDeviceLimitJob) unbanUser(email string, activeIPCount int, info deviceInboundInfo) {
- _, client, err := j.inboundService.GetClientByEmail(email)
+ _, client, err := j.getClientByEmail(email)
if err != nil || client == nil {
return
}
logger.Infof("[DeviceLimit] unbanning email=%s limit=%d current=%d", email, info.Limit, activeIPCount)
- _ = j.xrayAPI.RemoveUser(info.Tag, email)
- time.Sleep(5 * time.Second)
+ _ = j.removeUser(info.Tag, email)
+ j.sleep(5 * time.Second)
clientMap := map[string]any{}
clientJSON, _ := json.Marshal(client)
_ = json.Unmarshal(clientJSON, &clientMap)
- if err = j.xrayAPI.AddUser(string(info.Protocol), info.Tag, clientMap); err != nil {
+ if err = j.addUser(string(info.Protocol), info.Tag, clientMap); err != nil {
logger.Warningf("[DeviceLimit] failed to restore user %s: %v", email, err)
return
}
diff --git a/web/job/check_device_limit_job_test.go b/web/job/check_device_limit_job_test.go
new file mode 100644
index 00000000..61a08efb
--- /dev/null
+++ b/web/job/check_device_limit_job_test.go
@@ -0,0 +1,129 @@
+package job
+
+import (
+ "testing"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v2/database/model"
+ "github.com/mhsanaei/3x-ui/v2/xray"
+)
+
+func resetDeviceLimitJobGlobals() {
+ activeClientsLock.Lock()
+ activeClientIPs = make(map[string]map[string]time.Time)
+ activeClientsLock.Unlock()
+
+ clientStatusMu.Lock()
+ clientStatus = make(map[string]bool)
+ clientStatusMu.Unlock()
+}
+
+func TestCheckDeviceLimitJob_Run_SkipWhenAlreadyRunning(t *testing.T) {
+ resetDeviceLimitJobGlobals()
+
+ j := NewCheckDeviceLimitJob(nil)
+ j.running.Store(true)
+ j.isXrayRunning = func() bool {
+ t.Fatal("Run should skip execution when already running")
+ return true
+ }
+
+ j.Run()
+}
+
+func TestCheckDeviceLimitJob_UnbanWhenEnforcementDisabled(t *testing.T) {
+ resetDeviceLimitJobGlobals()
+
+ activeClientsLock.Lock()
+ activeClientIPs["alice@example.com"] = map[string]time.Time{
+ "1.2.3.4": time.Now(),
+ }
+ activeClientsLock.Unlock()
+
+ clientStatusMu.Lock()
+ clientStatus["alice@example.com"] = true
+ clientStatusMu.Unlock()
+
+ j := NewCheckDeviceLimitJob(nil)
+ j.getAPIPort = func() int { return 10085 }
+ j.apiInit = func(int) error { return nil }
+ j.apiClose = func() {}
+ j.sleep = func(time.Duration) {}
+ j.loadAllInbounds = func() ([]*model.Inbound, error) {
+ return []*model.Inbound{
+ {
+ Id: 1,
+ Enable: false, // Enforcement disabled
+ DeviceLimit: 0,
+ Tag: "inbound-10001",
+ Protocol: model.VLESS,
+ },
+ }, nil
+ }
+ j.getClientTraffic = func(email string) (*xray.ClientTraffic, error) {
+ return &xray.ClientTraffic{InboundId: 1, Email: email}, nil
+ }
+ j.getClientByEmail = func(email string) (*xray.ClientTraffic, *model.Client, error) {
+ return &xray.ClientTraffic{InboundId: 1, Email: email}, &model.Client{ID: "orig-id", Email: email}, nil
+ }
+
+ removeCalls := 0
+ addCalls := 0
+ j.removeUser = func(inboundTag, email string) error {
+ removeCalls++
+ return nil
+ }
+ j.addUser = func(protocol, inboundTag string, user map[string]any) error {
+ addCalls++
+ return nil
+ }
+
+ j.checkAllClientsLimit()
+
+ if removeCalls != 1 || addCalls != 1 {
+ t.Fatalf("expected one restore cycle, got remove=%d add=%d", removeCalls, addCalls)
+ }
+ if j.isClientBanned("alice@example.com") {
+ t.Fatal("expected client ban flag to be cleared when enforcement is disabled")
+ }
+}
+
+func TestCheckDeviceLimitJob_ClearStaleBanWhenInboundMissing(t *testing.T) {
+ resetDeviceLimitJobGlobals()
+
+ clientStatusMu.Lock()
+ clientStatus["ghost@example.com"] = true
+ clientStatusMu.Unlock()
+
+ j := NewCheckDeviceLimitJob(nil)
+ j.getAPIPort = func() int { return 10085 }
+ j.apiInit = func(int) error { return nil }
+ j.apiClose = func() {}
+ j.sleep = func(time.Duration) {}
+ j.loadAllInbounds = func() ([]*model.Inbound, error) {
+ return []*model.Inbound{
+ {Id: 2, Enable: true, DeviceLimit: 1, Tag: "inbound-10002", Protocol: model.VLESS},
+ }, nil
+ }
+ j.getClientTraffic = func(email string) (*xray.ClientTraffic, error) {
+ return &xray.ClientTraffic{InboundId: 999, Email: email}, nil
+ }
+ j.getClientByEmail = func(email string) (*xray.ClientTraffic, *model.Client, error) {
+ t.Fatal("GetClientByEmail should not be called when inbound is missing")
+ return nil, nil, nil
+ }
+ j.removeUser = func(inboundTag, email string) error {
+ t.Fatal("RemoveUser should not be called when inbound is missing")
+ return nil
+ }
+ j.addUser = func(protocol, inboundTag string, user map[string]any) error {
+ t.Fatal("AddUser should not be called when inbound is missing")
+ return nil
+ }
+
+ j.checkAllClientsLimit()
+
+ if j.isClientBanned("ghost@example.com") {
+ t.Fatal("expected stale banned status to be cleared when inbound no longer exists")
+ }
+}
From 03144a16ca56ff6694b53e2f5be5d761e8c73973 Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 11:04:43 +0800
Subject: [PATCH 4/9] fix(mariadb): quote settings key column in xray template
queries
---
web/service/setting.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/service/setting.go b/web/service/setting.go
index 472d1fe9..ce855791 100644
--- a/web/service/setting.go
+++ b/web/service/setting.go
@@ -487,7 +487,7 @@ func saveSettings(settings map[string]string) error {
func getXrayTemplateConfigFromDB() (string, error) {
db := database.GetDB()
setting := &model.Setting{}
- err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(setting).Error
+ err := db.Model(model.Setting{}).Where("`key` = ?", "xrayTemplateConfig").First(setting).Error
if err != nil {
return "", err
}
@@ -498,7 +498,7 @@ func getXrayTemplateConfigFromDB() (string, error) {
func saveXrayTemplateConfigToDB(value string) error {
db := database.GetDB()
setting := &model.Setting{}
- err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(setting).Error
+ err := db.Model(model.Setting{}).Where("`key` = ?", "xrayTemplateConfig").First(setting).Error
if database.IsNotFound(err) {
return db.Create(&model.Setting{
Key: "xrayTemplateConfig",
From c085fc3814000a4787b255879eafe6b0c739b6c4 Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 15:45:55 +0800
Subject: [PATCH 5/9] fix(iplimit): keep IP records working without fail2ban
---
web/job/check_client_ip_job.go | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index cbc352dc..0877d987 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -28,6 +28,7 @@ type IPWithTimestamp struct {
type CheckClientIpJob struct {
lastClear int64
disAllowedIps []string
+ fail2BanWarned bool
}
var job *CheckClientIpJob
@@ -45,7 +46,6 @@ func (j *CheckClientIpJob) Run() {
shouldClearAccessLog := false
iplimitActive := j.hasLimitIp()
- f2bInstalled := j.checkFail2BanInstalled()
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
if isAccessLogAvailable {
@@ -55,12 +55,15 @@ func (j *CheckClientIpJob) Run() {
}
} else {
if iplimitActive {
+ // Always process and persist client IP records. Fail2Ban is optional.
+ shouldClearAccessLog = j.processLogFile()
+ f2bInstalled := j.checkFail2BanInstalled()
+ if !f2bInstalled && !j.fail2BanWarned {
+ logger.Warning("[LimitIP] Fail2Ban is not installed, IP records will continue to work but automatic banning is disabled.")
+ j.fail2BanWarned = true
+ }
if f2bInstalled {
- shouldClearAccessLog = j.processLogFile()
- } else {
- if !f2bInstalled {
- logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
- }
+ j.fail2BanWarned = false
}
}
}
From 2008ae6dc3074dd579c2d5f2a989304ae5ae5d35 Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 15:52:25 +0800
Subject: [PATCH 6/9] style(go): gofmt check_client_ip_job
---
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 0877d987..1bb58e06 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -26,8 +26,8 @@ type IPWithTimestamp struct {
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct {
- lastClear int64
- disAllowedIps []string
+ lastClear int64
+ disAllowedIps []string
fail2BanWarned bool
}
From 9864224897fd7d3cc2ba86ce6dbde8b807ac9436 Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 16:04:27 +0800
Subject: [PATCH 7/9] fix(iplimit): enforce limit without fail2ban via xray api
fallback
---
web/job/check_client_ip_job.go | 88 ++++++++++++++++++++++++++++++----
1 file changed, 80 insertions(+), 8 deletions(-)
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index 1bb58e06..8b7c77a1 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -10,6 +10,7 @@ import (
"regexp"
"runtime"
"sort"
+ "strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
@@ -26,16 +27,20 @@ type IPWithTimestamp struct {
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct {
- lastClear int64
- disAllowedIps []string
- fail2BanWarned bool
+ lastClear int64
+ disAllowedIps []string
+ fail2BanWarned bool
+ fail2BanInstalled bool
+ lastDisconnectByID map[string]int64
}
var job *CheckClientIpJob
// NewCheckClientIpJob creates a new client IP monitoring job instance.
func NewCheckClientIpJob() *CheckClientIpJob {
- job = new(CheckClientIpJob)
+ job = &CheckClientIpJob{
+ lastDisconnectByID: map[string]int64{},
+ }
return job
}
@@ -56,13 +61,14 @@ func (j *CheckClientIpJob) Run() {
} else {
if iplimitActive {
// Always process and persist client IP records. Fail2Ban is optional.
- shouldClearAccessLog = j.processLogFile()
f2bInstalled := j.checkFail2BanInstalled()
- if !f2bInstalled && !j.fail2BanWarned {
+ j.fail2BanInstalled = f2bInstalled
+ shouldClearAccessLog = j.processLogFile()
+ if !j.fail2BanInstalled && !j.fail2BanWarned {
logger.Warning("[LimitIP] Fail2Ban is not installed, IP records will continue to work but automatic banning is disabled.")
j.fail2BanWarned = true
}
- if f2bInstalled {
+ if j.fail2BanInstalled {
j.fail2BanWarned = false
}
}
@@ -358,6 +364,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)
}
+ // Fallback enforcement path when Fail2Ban is unavailable:
+ // temporarily remove and re-add user to drop existing sessions.
+ if len(bannedIps) > 0 && !j.fail2BanInstalled {
+ j.disconnectClientTemporarily(inbound, clientEmail, clients)
+ }
+
// Update database with only the currently active (kept) IPs
jsonIps, _ := json.Marshal(keptIps)
inboundClientIps.Ips = string(jsonIps)
@@ -375,7 +387,11 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
}
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))
+ if j.fail2BanInstalled {
+ logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
+ } else {
+ logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, disconnected session to enforce limit without fail2ban", clientEmail, limitIp)
+ }
}
return shouldCleanLog
@@ -392,3 +408,59 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound
return inbound, nil
}
+
+// disconnectClientTemporarily removes and re-adds a client to force stale/excess sessions to drop.
+func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
+ if inbound == nil || inbound.Tag == "" {
+ return
+ }
+
+ // Avoid thrashing the same account on every 10s cron tick.
+ now := time.Now().Unix()
+ if last, ok := j.lastDisconnectByID[clientEmail]; ok && now-last < 30 {
+ return
+ }
+
+ var xrayAPI xray.XrayAPI
+ db := database.GetDB()
+
+ apiPort := 10085
+ var apiPortSetting model.Setting
+ if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
+ if parsed, convErr := strconv.Atoi(apiPortSetting.Value); convErr == nil && parsed > 0 {
+ apiPort = parsed
+ }
+ }
+
+ if err := xrayAPI.Init(apiPort); err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to init Xray API for fallback disconnect: %v", err)
+ return
+ }
+ defer xrayAPI.Close()
+
+ var clientConfig map[string]any
+ for _, client := range clients {
+ if client.Email != clientEmail {
+ continue
+ }
+ clientBytes, _ := json.Marshal(client)
+ _ = json.Unmarshal(clientBytes, &clientConfig)
+ break
+ }
+ if clientConfig == nil {
+ return
+ }
+
+ if err := xrayAPI.RemoveUser(inbound.Tag, clientEmail); err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
+ return
+ }
+
+ time.Sleep(150 * time.Millisecond)
+ if err := xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig); err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
+ return
+ }
+
+ j.lastDisconnectByID[clientEmail] = now
+}
From e468a08a54bbe7d053c625d4bb913ee7bf19d1a1 Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 16:31:39 +0800
Subject: [PATCH 8/9] Fix IP limit fallback Xray API port resolution
---
web/job/check_client_ip_job.go | 52 +++++++++++++++++++++++-----------
xray/process.go | 43 ++++++++++++++++++++++++++++
2 files changed, 78 insertions(+), 17 deletions(-)
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index 8b7c77a1..30318ec0 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"
+ "fmt"
"io"
"log"
"os"
@@ -31,6 +32,7 @@ type CheckClientIpJob struct {
disAllowedIps []string
fail2BanWarned bool
fail2BanInstalled bool
+ lastDisconnectOK bool
lastDisconnectByID map[string]int64
}
@@ -339,6 +341,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
shouldCleanLog := false
j.disAllowedIps = []string{}
+ j.lastDisconnectOK = false
// Open log file
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
@@ -367,7 +370,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
// Fallback enforcement path when Fail2Ban is unavailable:
// temporarily remove and re-add user to drop existing sessions.
if len(bannedIps) > 0 && !j.fail2BanInstalled {
- j.disconnectClientTemporarily(inbound, clientEmail, clients)
+ j.lastDisconnectOK = j.disconnectClientTemporarily(inbound, clientEmail, clients)
}
// Update database with only the currently active (kept) IPs
@@ -389,8 +392,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
if len(j.disAllowedIps) > 0 {
if j.fail2BanInstalled {
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
- } else {
+ } else if j.lastDisconnectOK {
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, disconnected session to enforce limit without fail2ban", clientEmail, limitIp)
+ } else {
+ logger.Warningf("[LIMIT_IP] Client %s: Kept %d current IPs, but failed to disconnect excess sessions without fail2ban", clientEmail, limitIp)
}
}
@@ -410,31 +415,27 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound
}
// disconnectClientTemporarily removes and re-adds a client to force stale/excess sessions to drop.
-func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
+func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) bool {
if inbound == nil || inbound.Tag == "" {
- return
+ return false
}
// Avoid thrashing the same account on every 10s cron tick.
now := time.Now().Unix()
if last, ok := j.lastDisconnectByID[clientEmail]; ok && now-last < 30 {
- return
+ return true
}
var xrayAPI xray.XrayAPI
- db := database.GetDB()
-
- apiPort := 10085
- var apiPortSetting model.Setting
- if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
- if parsed, convErr := strconv.Atoi(apiPortSetting.Value); convErr == nil && parsed > 0 {
- apiPort = parsed
- }
+ apiPort, err := j.resolveXrayAPIPort()
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to resolve Xray API port for fallback disconnect: %v", err)
+ return false
}
if err := xrayAPI.Init(apiPort); err != nil {
logger.Warningf("[LIMIT_IP] Failed to init Xray API for fallback disconnect: %v", err)
- return
+ return false
}
defer xrayAPI.Close()
@@ -448,19 +449,36 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
break
}
if clientConfig == nil {
- return
+ return false
}
if err := xrayAPI.RemoveUser(inbound.Tag, clientEmail); err != nil {
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
- return
+ return false
}
time.Sleep(150 * time.Millisecond)
if err := xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig); err != nil {
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
- return
+ return false
}
j.lastDisconnectByID[clientEmail] = now
+ return true
+}
+
+func (j *CheckClientIpJob) resolveXrayAPIPort() (int, error) {
+ if apiPort, err := xray.GetAPIPortFromConfig(); err == nil && apiPort > 0 {
+ return apiPort, nil
+ }
+
+ db := database.GetDB()
+ var apiPortSetting model.Setting
+ if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
+ if parsed, convErr := strconv.Atoi(apiPortSetting.Value); convErr == nil && parsed > 0 {
+ return parsed, nil
+ }
+ }
+
+ return 0, fmt.Errorf("no usable Xray API port found in config or settings")
}
diff --git a/xray/process.go b/xray/process.go
index 009ec7a5..e7fb5462 100644
--- a/xray/process.go
+++ b/xray/process.go
@@ -93,6 +93,49 @@ func GetAccessLogPath() (string, error) {
return "", err
}
+// GetAPIPortFromConfig reads the Xray config and returns the API inbound port.
+func GetAPIPortFromConfig() (int, error) {
+ config, err := os.ReadFile(GetConfigPath())
+ if err != nil {
+ logger.Warningf("Failed to read configuration file: %s", err)
+ return 0, err
+ }
+
+ jsonConfig := map[string]any{}
+ if err := json.Unmarshal(config, &jsonConfig); err != nil {
+ logger.Warningf("Failed to parse JSON configuration: %s", err)
+ return 0, err
+ }
+
+ rawInbounds, ok := jsonConfig["inbounds"].([]any)
+ if !ok {
+ return 0, fmt.Errorf("xray config does not contain inbounds")
+ }
+
+ for _, rawInbound := range rawInbounds {
+ inbound, ok := rawInbound.(map[string]any)
+ if !ok {
+ continue
+ }
+ if inbound["tag"] != "api" {
+ continue
+ }
+
+ switch port := inbound["port"].(type) {
+ case float64:
+ if port > 0 {
+ return int(port), nil
+ }
+ case int:
+ if port > 0 {
+ return port, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("api inbound port not found in xray config")
+}
+
// stopProcess calls Stop on the given Process instance.
func stopProcess(p *Process) {
p.Stop()
From 2fad726ee12d037e6efe1f09c7dd051a6e2cb42a Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Mon, 6 Apr 2026 16:58:43 +0800
Subject: [PATCH 9/9] Change IP limit to 3-minute temporary disable
---
web/job/check_client_ip_job.go | 194 ++++++++++++---------------------
web/web.go | 2 +-
2 files changed, 70 insertions(+), 126 deletions(-)
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index 30318ec0..9ea79936 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -7,16 +7,14 @@ import (
"io"
"log"
"os"
- "os/exec"
"regexp"
- "runtime"
"sort"
- "strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
+ "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/xray"
)
@@ -28,20 +26,24 @@ type IPWithTimestamp struct {
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct {
- lastClear int64
- disAllowedIps []string
- fail2BanWarned bool
- fail2BanInstalled bool
- lastDisconnectOK bool
- lastDisconnectByID map[string]int64
+ lastClear int64
+ tempBansByEmail map[string]int64
+ inboundService service.InboundService
+ xrayService *service.XrayService
}
var job *CheckClientIpJob
+const (
+ ipLimitWindowDuration = 3 * time.Minute
+ ipLimitBanDuration = 3 * time.Minute
+)
+
// NewCheckClientIpJob creates a new client IP monitoring job instance.
-func NewCheckClientIpJob() *CheckClientIpJob {
+func NewCheckClientIpJob(xrayService *service.XrayService) *CheckClientIpJob {
job = &CheckClientIpJob{
- lastDisconnectByID: map[string]int64{},
+ tempBansByEmail: map[string]int64{},
+ xrayService: xrayService,
}
return job
}
@@ -55,25 +57,11 @@ func (j *CheckClientIpJob) Run() {
iplimitActive := j.hasLimitIp()
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
+ j.restoreExpiredClientAccess()
+
if isAccessLogAvailable {
- if runtime.GOOS == "windows" {
- if iplimitActive {
- shouldClearAccessLog = j.processLogFile()
- }
- } else {
- if iplimitActive {
- // Always process and persist client IP records. Fail2Ban is optional.
- f2bInstalled := j.checkFail2BanInstalled()
- j.fail2BanInstalled = f2bInstalled
- shouldClearAccessLog = j.processLogFile()
- if !j.fail2BanInstalled && !j.fail2BanWarned {
- logger.Warning("[LimitIP] Fail2Ban is not installed, IP records will continue to work but automatic banning is disabled.")
- j.fail2BanWarned = true
- }
- if j.fail2BanInstalled {
- j.fail2BanWarned = false
- }
- }
+ if iplimitActive {
+ shouldClearAccessLog = j.processLogFile()
}
}
@@ -210,13 +198,6 @@ func (j *CheckClientIpJob) processLogFile() bool {
return shouldCleanLog
}
-func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
- cmd := "fail2ban-client"
- args := []string{"-h"}
- err := exec.Command(cmd, args...).Run()
- return err == nil
-}
-
func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
accessLogPath, err := xray.GetAccessLogPath()
if err != nil {
@@ -340,8 +321,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
})
shouldCleanLog := false
- j.disAllowedIps = []string{}
- j.lastDisconnectOK = false
// Open log file
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
@@ -353,33 +332,23 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.SetOutput(logIpFile)
log.SetFlags(log.LstdFlags)
- // Check if we exceed the limit
- if len(allIps) > limitIp {
+ recentIps := j.filterRecentIPs(allIps, time.Now().Add(-ipLimitWindowDuration).Unix())
+
+ jsonIps, _ := json.Marshal(recentIps)
+ inboundClientIps.Ips = string(jsonIps)
+
+ // Check if the recent 3-minute window exceeds the limit.
+ if len(recentIps) > limitIp {
shouldCleanLog = true
-
- // Keep the oldest IPs (currently active connections) and ban the new excess ones.
- keptIps := allIps[:limitIp]
- bannedIps := allIps[limitIp:]
-
- // 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)
+ for _, ipTime := range recentIps {
+ log.Printf("[LIMIT_IP] Email = %s || Recent IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
- // Fallback enforcement path when Fail2Ban is unavailable:
- // temporarily remove and re-add user to drop existing sessions.
- if len(bannedIps) > 0 && !j.fail2BanInstalled {
- j.lastDisconnectOK = j.disconnectClientTemporarily(inbound, clientEmail, clients)
+ if err := j.disableClientTemporarily(clientEmail, ipLimitBanDuration); err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to temporarily disable client %s: %v", clientEmail, err)
+ } else {
+ logger.Infof("[LIMIT_IP] Client %s: observed %d IPs in the last 3 minutes (limit=%d), temporarily disabled for 3 minutes", clientEmail, len(recentIps), limitIp)
}
-
- // 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)
}
db := database.GetDB()
@@ -389,16 +358,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
return false
}
- if len(j.disAllowedIps) > 0 {
- if j.fail2BanInstalled {
- logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
- } else if j.lastDisconnectOK {
- logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, disconnected session to enforce limit without fail2ban", clientEmail, limitIp)
- } else {
- logger.Warningf("[LIMIT_IP] Client %s: Kept %d current IPs, but failed to disconnect excess sessions without fail2ban", clientEmail, limitIp)
- }
- }
-
return shouldCleanLog
}
@@ -414,71 +373,56 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound
return inbound, nil
}
-// disconnectClientTemporarily removes and re-adds a client to force stale/excess sessions to drop.
-func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) bool {
- if inbound == nil || inbound.Tag == "" {
- return false
- }
-
- // Avoid thrashing the same account on every 10s cron tick.
+func (j *CheckClientIpJob) disableClientTemporarily(clientEmail string, duration time.Duration) error {
now := time.Now().Unix()
- if last, ok := j.lastDisconnectByID[clientEmail]; ok && now-last < 30 {
- return true
+ if until, ok := j.tempBansByEmail[clientEmail]; ok && until > now {
+ return nil
}
- var xrayAPI xray.XrayAPI
- apiPort, err := j.resolveXrayAPIPort()
+ changed, needRestart, err := j.inboundService.SetClientEnableByEmail(clientEmail, false)
if err != nil {
- logger.Warningf("[LIMIT_IP] Failed to resolve Xray API port for fallback disconnect: %v", err)
- return false
+ return err
+ }
+ if needRestart && j.xrayService != nil {
+ j.xrayService.SetToNeedRestart()
+ }
+ if !changed {
+ return fmt.Errorf("client %s was not disabled", clientEmail)
}
- if err := xrayAPI.Init(apiPort); err != nil {
- logger.Warningf("[LIMIT_IP] Failed to init Xray API for fallback disconnect: %v", err)
- return false
- }
- defer xrayAPI.Close()
+ j.tempBansByEmail[clientEmail] = time.Now().Add(duration).Unix()
+ return nil
+}
- var clientConfig map[string]any
- for _, client := range clients {
- if client.Email != clientEmail {
+func (j *CheckClientIpJob) restoreExpiredClientAccess() {
+ now := time.Now().Unix()
+ for clientEmail, until := range j.tempBansByEmail {
+ if until > now {
continue
}
- clientBytes, _ := json.Marshal(client)
- _ = json.Unmarshal(clientBytes, &clientConfig)
- break
- }
- if clientConfig == nil {
- return false
- }
- if err := xrayAPI.RemoveUser(inbound.Tag, clientEmail); err != nil {
- logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
- return false
- }
+ changed, needRestart, err := j.inboundService.SetClientEnableByEmail(clientEmail, true)
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to restore client %s after temporary disable: %v", clientEmail, err)
+ continue
+ }
+ if needRestart && j.xrayService != nil {
+ j.xrayService.SetToNeedRestart()
+ }
- time.Sleep(150 * time.Millisecond)
- if err := xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig); err != nil {
- logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
- return false
- }
-
- j.lastDisconnectByID[clientEmail] = now
- return true
-}
-
-func (j *CheckClientIpJob) resolveXrayAPIPort() (int, error) {
- if apiPort, err := xray.GetAPIPortFromConfig(); err == nil && apiPort > 0 {
- return apiPort, nil
- }
-
- db := database.GetDB()
- var apiPortSetting model.Setting
- if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
- if parsed, convErr := strconv.Atoi(apiPortSetting.Value); convErr == nil && parsed > 0 {
- return parsed, nil
+ delete(j.tempBansByEmail, clientEmail)
+ if changed {
+ logger.Infof("[LIMIT_IP] Client %s: temporary 3-minute disable expired, access restored", clientEmail)
}
}
-
- return 0, fmt.Errorf("no usable Xray API port found in config or settings")
+}
+
+func (j *CheckClientIpJob) filterRecentIPs(ips []IPWithTimestamp, minTimestamp int64) []IPWithTimestamp {
+ recent := make([]IPWithTimestamp, 0, len(ips))
+ for _, ipTime := range ips {
+ if ipTime.Timestamp >= minTimestamp {
+ recent = append(recent, ipTime)
+ }
+ }
+ return recent
}
diff --git a/web/web.go b/web/web.go
index 44c5601c..9e8eef91 100644
--- a/web/web.go
+++ b/web/web.go
@@ -319,7 +319,7 @@ func (s *Server) startTask() {
}()
// check client ips from log file every 10 sec
- s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
+ s.cron.AddJob("@every 10s", job.NewCheckClientIpJob(&s.xrayService))
// check active device limits every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckDeviceLimitJob(&s.xrayService))