mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-08 14:14:19 +00:00
Change IP limit to 3-minute temporary disable
This commit is contained in:
parent
e468a08a54
commit
2fad726ee1
2 changed files with 70 additions and 126 deletions
|
|
@ -7,16 +7,14 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,19 +27,23 @@ type IPWithTimestamp struct {
|
||||||
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
|
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
|
||||||
type CheckClientIpJob struct {
|
type CheckClientIpJob struct {
|
||||||
lastClear int64
|
lastClear int64
|
||||||
disAllowedIps []string
|
tempBansByEmail map[string]int64
|
||||||
fail2BanWarned bool
|
inboundService service.InboundService
|
||||||
fail2BanInstalled bool
|
xrayService *service.XrayService
|
||||||
lastDisconnectOK bool
|
|
||||||
lastDisconnectByID map[string]int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var job *CheckClientIpJob
|
var job *CheckClientIpJob
|
||||||
|
|
||||||
|
const (
|
||||||
|
ipLimitWindowDuration = 3 * time.Minute
|
||||||
|
ipLimitBanDuration = 3 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
||||||
func NewCheckClientIpJob() *CheckClientIpJob {
|
func NewCheckClientIpJob(xrayService *service.XrayService) *CheckClientIpJob {
|
||||||
job = &CheckClientIpJob{
|
job = &CheckClientIpJob{
|
||||||
lastDisconnectByID: map[string]int64{},
|
tempBansByEmail: map[string]int64{},
|
||||||
|
xrayService: xrayService,
|
||||||
}
|
}
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
@ -55,26 +57,12 @@ func (j *CheckClientIpJob) Run() {
|
||||||
iplimitActive := j.hasLimitIp()
|
iplimitActive := j.hasLimitIp()
|
||||||
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
|
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
|
||||||
|
|
||||||
|
j.restoreExpiredClientAccess()
|
||||||
|
|
||||||
if isAccessLogAvailable {
|
if isAccessLogAvailable {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
if iplimitActive {
|
if iplimitActive {
|
||||||
shouldClearAccessLog = j.processLogFile()
|
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 shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
||||||
|
|
@ -210,13 +198,6 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
||||||
return shouldCleanLog
|
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 {
|
func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
|
||||||
accessLogPath, err := xray.GetAccessLogPath()
|
accessLogPath, err := xray.GetAccessLogPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -340,8 +321,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
})
|
})
|
||||||
|
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
j.disAllowedIps = []string{}
|
|
||||||
j.lastDisconnectOK = false
|
|
||||||
|
|
||||||
// Open log file
|
// Open log file
|
||||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
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.SetOutput(logIpFile)
|
||||||
log.SetFlags(log.LstdFlags)
|
log.SetFlags(log.LstdFlags)
|
||||||
|
|
||||||
// Check if we exceed the limit
|
recentIps := j.filterRecentIPs(allIps, time.Now().Add(-ipLimitWindowDuration).Unix())
|
||||||
if len(allIps) > limitIp {
|
|
||||||
|
jsonIps, _ := json.Marshal(recentIps)
|
||||||
|
inboundClientIps.Ips = string(jsonIps)
|
||||||
|
|
||||||
|
// Check if the recent 3-minute window exceeds the limit.
|
||||||
|
if len(recentIps) > limitIp {
|
||||||
shouldCleanLog = true
|
shouldCleanLog = true
|
||||||
|
for _, ipTime := range recentIps {
|
||||||
// Keep the oldest IPs (currently active connections) and ban the new excess ones.
|
log.Printf("[LIMIT_IP] Email = %s || Recent IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback enforcement path when Fail2Ban is unavailable:
|
if err := j.disableClientTemporarily(clientEmail, ipLimitBanDuration); err != nil {
|
||||||
// temporarily remove and re-add user to drop existing sessions.
|
logger.Warningf("[LIMIT_IP] Failed to temporarily disable client %s: %v", clientEmail, err)
|
||||||
if len(bannedIps) > 0 && !j.fail2BanInstalled {
|
|
||||||
j.lastDisconnectOK = j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database with only the currently active (kept) IPs
|
|
||||||
jsonIps, _ := json.Marshal(keptIps)
|
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
|
||||||
} else {
|
} else {
|
||||||
// Under limit, save all IPs
|
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)
|
||||||
jsonIps, _ := json.Marshal(allIps)
|
}
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
@ -389,16 +358,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
return false
|
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
|
return shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,71 +373,56 @@ func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound
|
||||||
return inbound, nil
|
return inbound, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// disconnectClientTemporarily removes and re-adds a client to force stale/excess sessions to drop.
|
func (j *CheckClientIpJob) disableClientTemporarily(clientEmail string, duration time.Duration) error {
|
||||||
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.
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
if last, ok := j.lastDisconnectByID[clientEmail]; ok && now-last < 30 {
|
if until, ok := j.tempBansByEmail[clientEmail]; ok && until > now {
|
||||||
return true
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var xrayAPI xray.XrayAPI
|
changed, needRestart, err := j.inboundService.SetClientEnableByEmail(clientEmail, false)
|
||||||
apiPort, err := j.resolveXrayAPIPort()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("[LIMIT_IP] Failed to resolve Xray API port for fallback disconnect: %v", err)
|
return err
|
||||||
return false
|
}
|
||||||
|
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 {
|
j.tempBansByEmail[clientEmail] = time.Now().Add(duration).Unix()
|
||||||
logger.Warningf("[LIMIT_IP] Failed to init Xray API for fallback disconnect: %v", err)
|
return nil
|
||||||
return false
|
}
|
||||||
}
|
|
||||||
defer xrayAPI.Close()
|
|
||||||
|
|
||||||
var clientConfig map[string]any
|
func (j *CheckClientIpJob) restoreExpiredClientAccess() {
|
||||||
for _, client := range clients {
|
now := time.Now().Unix()
|
||||||
if client.Email != clientEmail {
|
for clientEmail, until := range j.tempBansByEmail {
|
||||||
|
if until > now {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
clientBytes, _ := json.Marshal(client)
|
|
||||||
_ = json.Unmarshal(clientBytes, &clientConfig)
|
changed, needRestart, err := j.inboundService.SetClientEnableByEmail(clientEmail, true)
|
||||||
break
|
if err != nil {
|
||||||
|
logger.Warningf("[LIMIT_IP] Failed to restore client %s after temporary disable: %v", clientEmail, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if clientConfig == nil {
|
if needRestart && j.xrayService != nil {
|
||||||
return false
|
j.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := xrayAPI.RemoveUser(inbound.Tag, clientEmail); err != nil {
|
delete(j.tempBansByEmail, clientEmail)
|
||||||
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
|
if changed {
|
||||||
return false
|
logger.Infof("[LIMIT_IP] Client %s: temporary 3-minute disable expired, access restored", clientEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func (j *CheckClientIpJob) filterRecentIPs(ips []IPWithTimestamp, minTimestamp int64) []IPWithTimestamp {
|
||||||
if apiPort, err := xray.GetAPIPortFromConfig(); err == nil && apiPort > 0 {
|
recent := make([]IPWithTimestamp, 0, len(ips))
|
||||||
return apiPort, nil
|
for _, ipTime := range ips {
|
||||||
}
|
if ipTime.Timestamp >= minTimestamp {
|
||||||
|
recent = append(recent, ipTime)
|
||||||
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 recent
|
||||||
return 0, fmt.Errorf("no usable Xray API port found in config or settings")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ func (s *Server) startTask() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// check client ips from log file every 10 sec
|
// 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
|
// check active device limits every 10 sec
|
||||||
s.cron.AddJob("@every 10s", job.NewCheckDeviceLimitJob(&s.xrayService))
|
s.cron.AddJob("@every 10s", job.NewCheckDeviceLimitJob(&s.xrayService))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue