mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
commit
74a51e4265
15 changed files with 690 additions and 57 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") }}'>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,20 @@
|
|||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.deviceLimitDesc" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.inbounds.deviceLimit" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="dbInbound.deviceLimit"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
|
|
@ -170,4 +184,4 @@
|
|||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ package job
|
|||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -26,15 +26,25 @@ 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
|
||||
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 {
|
||||
job = new(CheckClientIpJob)
|
||||
func NewCheckClientIpJob(xrayService *service.XrayService) *CheckClientIpJob {
|
||||
job = &CheckClientIpJob{
|
||||
tempBansByEmail: map[string]int64{},
|
||||
xrayService: xrayService,
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
|
|
@ -45,24 +55,13 @@ func (j *CheckClientIpJob) Run() {
|
|||
|
||||
shouldClearAccessLog := false
|
||||
iplimitActive := j.hasLimitIp()
|
||||
f2bInstalled := j.checkFail2BanInstalled()
|
||||
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
|
||||
|
||||
j.restoreExpiredClientAccess()
|
||||
|
||||
if isAccessLogAvailable {
|
||||
if runtime.GOOS == "windows" {
|
||||
if iplimitActive {
|
||||
shouldClearAccessLog = j.processLogFile()
|
||||
}
|
||||
} else {
|
||||
if iplimitActive {
|
||||
if f2bInstalled {
|
||||
shouldClearAccessLog = j.processLogFile()
|
||||
} else {
|
||||
if !f2bInstalled {
|
||||
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if iplimitActive {
|
||||
shouldClearAccessLog = j.processLogFile()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,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 {
|
||||
|
|
@ -329,7 +321,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||
})
|
||||
|
||||
shouldCleanLog := false
|
||||
j.disAllowedIps = []string{}
|
||||
|
||||
// Open log file
|
||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
|
|
@ -341,27 +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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
|
@ -371,10 +358,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
|
||||
}
|
||||
|
||||
|
|
@ -389,3 +372,57 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound
|
|||
|
||||
return inbound, nil
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) disableClientTemporarily(clientEmail string, duration time.Duration) error {
|
||||
now := time.Now().Unix()
|
||||
if until, ok := j.tempBansByEmail[clientEmail]; ok && until > now {
|
||||
return nil
|
||||
}
|
||||
|
||||
changed, needRestart, err := j.inboundService.SetClientEnableByEmail(clientEmail, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if needRestart && j.xrayService != nil {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
if !changed {
|
||||
return fmt.Errorf("client %s was not disabled", clientEmail)
|
||||
}
|
||||
|
||||
j.tempBansByEmail[clientEmail] = time.Now().Add(duration).Unix()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) restoreExpiredClientAccess() {
|
||||
now := time.Now().Unix()
|
||||
for clientEmail, until := range j.tempBansByEmail {
|
||||
if until > now {
|
||||
continue
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
delete(j.tempBansByEmail, clientEmail)
|
||||
if changed {
|
||||
logger.Infof("[LIMIT_IP] Client %s: temporary 3-minute disable expired, access restored", clientEmail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
385
web/job/check_device_limit_job.go
Normal file
385
web/job/check_device_limit_job.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
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
|
||||
|
||||
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 {
|
||||
Limit int
|
||||
Tag string
|
||||
Protocol model.Protocol
|
||||
Enable bool
|
||||
}
|
||||
|
||||
func NewCheckDeviceLimitJob(xrayService *service.XrayService) *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() {
|
||||
// Avoid concurrent re-entrancy when previous run is still processing.
|
||||
if !j.running.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer j.running.Store(false)
|
||||
|
||||
if j.isXrayRunning == nil || !j.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() {
|
||||
if j.loadAllInbounds == nil {
|
||||
return
|
||||
}
|
||||
allInbounds, err := j.loadAllInbounds()
|
||||
if err != nil || len(allInbounds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
apiPort := j.getAPIPort()
|
||||
if apiPort == 0 {
|
||||
return
|
||||
}
|
||||
if err := j.apiInit(apiPort); err != nil {
|
||||
return
|
||||
}
|
||||
defer j.apiClose()
|
||||
|
||||
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.getClientTraffic(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.getClientTraffic(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.getClientByEmail(email)
|
||||
if err != nil || client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("[DeviceLimit] banning email=%s limit=%d current=%d", email, info.Limit, activeIPCount)
|
||||
_ = j.removeUser(info.Tag, email)
|
||||
j.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.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.getClientByEmail(email)
|
||||
if err != nil || client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("[DeviceLimit] unbanning email=%s limit=%d current=%d", email, info.Limit, activeIPCount)
|
||||
_ = 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.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()
|
||||
}
|
||||
129
web/job/check_device_limit_job_test.go
Normal file
129
web/job/check_device_limit_job_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -215,6 +215,8 @@
|
|||
"monitorDesc" = "留空表示监听所有 IP"
|
||||
"meansNoLimit" = "= 无限制(单位:GB)"
|
||||
"totalFlow" = "总流量"
|
||||
"deviceLimit" = "设备限制"
|
||||
"deviceLimitDesc" = "限制该入站可同时在线的设备/IP 数量(0 = 不限制)"
|
||||
"leaveBlankToNeverExpire" = "留空表示永不过期"
|
||||
"noRecommendKeepDefault" = "建议保留默认值"
|
||||
"certificatePath" = "文件路径"
|
||||
|
|
|
|||
|
|
@ -319,7 +319,9 @@ 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))
|
||||
|
||||
// check client ips from log file every day
|
||||
s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue