2023-02-28 19:54:29 +00:00
package job
import (
2024-02-03 10:41:57 +00:00
"bufio"
2023-04-13 19:37:13 +00:00
"encoding/json"
2026-04-06 08:31:39 +00:00
"fmt"
2024-02-03 10:41:57 +00:00
"io"
2023-06-24 20:36:18 +00:00
"log"
2023-04-13 19:37:13 +00:00
"os"
"regexp"
2023-07-01 12:26:43 +00:00
"sort"
"time"
2025-09-19 08:05:43 +00:00
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
2026-04-06 08:58:43 +00:00
"github.com/mhsanaei/3x-ui/v2/web/service"
2025-09-19 08:05:43 +00:00
"github.com/mhsanaei/3x-ui/v2/xray"
2023-02-28 19:54:29 +00:00
)
2026-02-03 23:38:11 +00:00
// IPWithTimestamp tracks an IP address with its last seen timestamp
type IPWithTimestamp struct {
IP string ` json:"ip" `
Timestamp int64 ` json:"timestamp" `
}
2025-09-20 07:35:50 +00:00
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
2024-01-21 11:09:15 +00:00
type CheckClientIpJob struct {
2026-04-06 08:58:43 +00:00
lastClear int64
tempBansByEmail map [ string ] int64
inboundService service . InboundService
xrayService * service . XrayService
2024-01-21 11:09:15 +00:00
}
2023-04-13 19:37:13 +00:00
2023-02-28 19:54:29 +00:00
var job * CheckClientIpJob
2026-04-06 08:58:43 +00:00
const (
ipLimitWindowDuration = 3 * time . Minute
ipLimitBanDuration = 3 * time . Minute
)
2025-09-20 07:35:50 +00:00
// NewCheckClientIpJob creates a new client IP monitoring job instance.
2026-04-06 08:58:43 +00:00
func NewCheckClientIpJob ( xrayService * service . XrayService ) * CheckClientIpJob {
2026-04-06 08:04:27 +00:00
job = & CheckClientIpJob {
2026-04-06 08:58:43 +00:00
tempBansByEmail : map [ string ] int64 { } ,
xrayService : xrayService ,
2026-04-06 08:04:27 +00:00
}
2023-02-28 19:54:29 +00:00
return job
}
func ( j * CheckClientIpJob ) Run ( ) {
2024-03-05 13:39:20 +00:00
if j . lastClear == 0 {
j . lastClear = time . Now ( ) . Unix ( )
2023-07-01 12:26:43 +00:00
}
2023-06-24 20:36:18 +00:00
2024-03-10 21:31:24 +00:00
shouldClearAccessLog := false
2024-09-12 07:44:17 +00:00
iplimitActive := j . hasLimitIp ( )
isAccessLogAvailable := j . checkAccessLogAvailable ( iplimitActive )
2024-03-05 13:39:20 +00:00
2026-04-06 08:58:43 +00:00
j . restoreExpiredClientAccess ( )
2025-09-11 09:05:06 +00:00
if isAccessLogAvailable {
2026-04-06 08:58:43 +00:00
if iplimitActive {
shouldClearAccessLog = j . processLogFile ( )
2024-10-28 19:13:42 +00:00
}
2023-06-15 21:38:35 +00:00
}
2024-02-09 22:22:20 +00:00
2024-09-09 07:52:29 +00:00
if shouldClearAccessLog || ( isAccessLogAvailable && time . Now ( ) . Unix ( ) - j . lastClear > 3600 ) {
2024-02-09 22:22:20 +00:00
j . clearAccessLog ( )
}
}
func ( j * CheckClientIpJob ) clearAccessLog ( ) {
2024-03-10 21:31:24 +00:00
logAccessP , err := os . OpenFile ( xray . GetAccessPersistentLogPath ( ) , os . O_CREATE | os . O_APPEND | os . O_WRONLY , 0 o644 )
2024-02-10 10:40:39 +00:00
j . checkError ( err )
2025-08-17 11:37:49 +00:00
defer logAccessP . Close ( )
2024-02-10 10:40:39 +00:00
2024-03-13 07:54:41 +00:00
accessLogPath , err := xray . GetAccessLogPath ( )
j . checkError ( err )
2024-02-10 10:40:39 +00:00
file , err := os . Open ( accessLogPath )
j . checkError ( err )
2025-08-17 11:37:49 +00:00
defer file . Close ( )
2024-02-10 10:40:39 +00:00
_ , err = io . Copy ( logAccessP , file )
j . checkError ( err )
err = os . Truncate ( accessLogPath , 0 )
2024-02-09 22:22:20 +00:00
j . checkError ( err )
2025-08-17 11:37:49 +00:00
2024-03-05 13:39:20 +00:00
j . lastClear = time . Now ( ) . Unix ( )
2023-02-28 19:54:29 +00:00
}
2023-07-01 12:26:43 +00:00
func ( j * CheckClientIpJob ) hasLimitIp ( ) bool {
2023-06-15 21:38:35 +00:00
db := database . GetDB ( )
var inbounds [ ] * model . Inbound
2023-07-01 12:26:43 +00:00
2023-06-15 21:38:35 +00:00
err := db . Model ( model . Inbound { } ) . Find ( & inbounds ) . Error
if err != nil {
return false
}
for _ , inbound := range inbounds {
if inbound . Settings == "" {
continue
}
settings := map [ string ] [ ] model . Client { }
json . Unmarshal ( [ ] byte ( inbound . Settings ) , & settings )
clients := settings [ "clients" ]
for _ , client := range clients {
limitIp := client . LimitIP
if limitIp > 0 {
return true
}
}
}
2023-07-01 12:26:43 +00:00
2023-06-15 21:38:35 +00:00
return false
}
2024-03-05 13:39:20 +00:00
func ( j * CheckClientIpJob ) processLogFile ( ) bool {
2024-02-03 14:24:04 +00:00
2025-01-05 17:58:51 +00:00
ipRegex := regexp . MustCompile ( ` from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted ` )
2024-10-28 19:13:42 +00:00
emailRegex := regexp . MustCompile ( ` email: (.+)$ ` )
2026-02-03 23:38:11 +00:00
timestampRegex := regexp . MustCompile ( ` ^(\d { 4}/\d { 2}/\d { 2} \d { 2}:\d { 2}:\d { 2}) ` )
2024-10-28 19:13:42 +00:00
accessLogPath , _ := xray . GetAccessLogPath ( )
file , _ := os . Open ( accessLogPath )
defer file . Close ( )
2024-02-03 10:41:57 +00:00
2026-02-03 23:38:11 +00:00
// Track IPs with their last seen timestamp
inboundClientIps := make ( map [ string ] map [ string ] int64 , 100 )
2024-02-03 10:41:57 +00:00
scanner := bufio . NewScanner ( file )
for scanner . Scan ( ) {
line := scanner . Text ( )
2023-02-28 19:54:29 +00:00
2024-10-28 19:13:42 +00:00
ipMatches := ipRegex . FindStringSubmatch ( line )
if len ( ipMatches ) < 2 {
continue
}
2023-02-28 19:54:29 +00:00
2024-10-28 19:13:42 +00:00
ip := ipMatches [ 1 ]
2023-02-28 19:54:29 +00:00
2024-10-28 19:13:42 +00:00
if ip == "127.0.0.1" || ip == "::1" {
continue
}
2023-04-13 19:37:13 +00:00
2024-10-28 19:13:42 +00:00
emailMatches := emailRegex . FindStringSubmatch ( line )
if len ( emailMatches ) < 2 {
continue
2023-02-28 19:54:29 +00:00
}
2024-10-28 19:13:42 +00:00
email := emailMatches [ 1 ]
2023-07-01 12:26:43 +00:00
2026-02-03 23:38:11 +00:00
// Extract timestamp from log line
var timestamp int64
timestampMatches := timestampRegex . FindStringSubmatch ( line )
if len ( timestampMatches ) >= 2 {
t , err := time . Parse ( "2006/01/02 15:04:05" , timestampMatches [ 1 ] )
if err == nil {
timestamp = t . Unix ( )
} else {
timestamp = time . Now ( ) . Unix ( )
}
} else {
timestamp = time . Now ( ) . Unix ( )
}
2024-10-28 19:13:42 +00:00
if _ , exists := inboundClientIps [ email ] ; ! exists {
2026-02-03 23:38:11 +00:00
inboundClientIps [ email ] = make ( map [ string ] int64 )
}
// Update timestamp - keep the latest
if existingTime , ok := inboundClientIps [ email ] [ ip ] ; ! ok || timestamp > existingTime {
inboundClientIps [ email ] [ ip ] = timestamp
2024-10-28 19:13:42 +00:00
}
}
2024-02-03 10:41:57 +00:00
2023-06-15 19:15:34 +00:00
shouldCleanLog := false
2026-02-03 23:38:11 +00:00
for email , ipTimestamps := range inboundClientIps {
2023-02-28 19:54:29 +00:00
2026-02-03 23:38:11 +00:00
// Convert to IPWithTimestamp slice
ipsWithTime := make ( [ ] IPWithTimestamp , 0 , len ( ipTimestamps ) )
for ip , timestamp := range ipTimestamps {
ipsWithTime = append ( ipsWithTime , IPWithTimestamp { IP : ip , Timestamp : timestamp } )
2024-10-28 19:13:42 +00:00
}
2024-12-16 13:26:47 +00:00
clientIpsRecord , err := j . getInboundClientIps ( email )
2023-04-13 19:37:13 +00:00
if err != nil {
2026-02-03 23:38:11 +00:00
j . addInboundClientIps ( email , ipsWithTime )
2024-10-28 19:13:42 +00:00
continue
2023-02-28 19:54:29 +00:00
}
2024-10-28 19:13:42 +00:00
2026-02-03 23:38:11 +00:00
shouldCleanLog = j . updateInboundClientIps ( clientIpsRecord , email , ipsWithTime ) || shouldCleanLog
2023-04-13 19:37:13 +00:00
}
2023-06-15 21:38:35 +00:00
2024-03-05 13:39:20 +00:00
return shouldCleanLog
2023-02-28 19:54:29 +00:00
}
2023-07-01 12:26:43 +00:00
2024-09-12 07:44:17 +00:00
func ( j * CheckClientIpJob ) checkAccessLogAvailable ( iplimitActive bool ) bool {
2024-03-13 07:54:41 +00:00
accessLogPath , err := xray . GetAccessLogPath ( )
if err != nil {
return false
}
2024-09-12 07:44:17 +00:00
if accessLogPath == "none" || accessLogPath == "" {
if iplimitActive {
2024-09-24 11:24:10 +00:00
logger . Warning ( "[LimitIP] Access log path is not set, Please configure the access log path in Xray configs." )
2024-09-12 07:44:17 +00:00
}
return false
2024-03-10 21:31:24 +00:00
}
2024-03-13 07:54:41 +00:00
2024-09-12 07:44:17 +00:00
return true
2024-03-10 21:31:24 +00:00
}
2023-07-01 12:26:43 +00:00
func ( j * CheckClientIpJob ) checkError ( e error ) {
2023-04-13 19:37:13 +00:00
if e != nil {
2023-02-28 19:54:29 +00:00
logger . Warning ( "client ip job err:" , e )
}
}
2023-07-01 12:26:43 +00:00
func ( j * CheckClientIpJob ) getInboundClientIps ( clientEmail string ) ( * model . InboundClientIps , error ) {
2023-02-28 19:54:29 +00:00
db := database . GetDB ( )
InboundClientIps := & model . InboundClientIps { }
err := db . Model ( model . InboundClientIps { } ) . Where ( "client_email = ?" , clientEmail ) . First ( InboundClientIps ) . Error
if err != nil {
return nil , err
}
return InboundClientIps , nil
}
2023-07-01 12:26:43 +00:00
2026-02-03 23:38:11 +00:00
func ( j * CheckClientIpJob ) addInboundClientIps ( clientEmail string , ipsWithTime [ ] IPWithTimestamp ) error {
2023-02-28 19:54:29 +00:00
inboundClientIps := & model . InboundClientIps { }
2026-02-03 23:38:11 +00:00
jsonIps , err := json . Marshal ( ipsWithTime )
2023-07-01 12:26:43 +00:00
j . checkError ( err )
2023-02-28 19:54:29 +00:00
inboundClientIps . ClientEmail = clientEmail
inboundClientIps . Ips = string ( jsonIps )
db := database . GetDB ( )
tx := db . Begin ( )
defer func ( ) {
2023-06-03 15:29:32 +00:00
if err == nil {
2023-05-24 23:51:31 +00:00
tx . Commit ( )
2023-06-03 15:29:32 +00:00
} else {
tx . Rollback ( )
2023-02-28 19:54:29 +00:00
}
} ( )
err = tx . Save ( inboundClientIps ) . Error
if err != nil {
return err
}
return nil
}
2023-06-03 15:29:32 +00:00
2026-02-03 23:38:11 +00:00
func ( j * CheckClientIpJob ) updateInboundClientIps ( inboundClientIps * model . InboundClientIps , clientEmail string , newIpsWithTime [ ] IPWithTimestamp ) bool {
// Get the inbound configuration
2023-07-01 12:26:43 +00:00
inbound , err := j . getInboundByEmail ( clientEmail )
2024-07-08 21:08:00 +00:00
if err != nil {
logger . Errorf ( "failed to fetch inbound settings for email %s: %s" , clientEmail , err )
return false
}
2023-02-28 19:54:29 +00:00
if inbound . Settings == "" {
2024-07-08 21:08:00 +00:00
logger . Debug ( "wrong data:" , inbound )
2023-06-08 10:20:35 +00:00
return false
2023-02-28 19:54:29 +00:00
}
settings := map [ string ] [ ] model . Client { }
json . Unmarshal ( [ ] byte ( inbound . Settings ) , & settings )
clients := settings [ "clients" ]
2026-02-03 23:38:11 +00:00
// Find the client's IP limit
var limitIp int
var clientFound bool
for _ , client := range clients {
if client . Email == clientEmail {
limitIp = client . LimitIP
clientFound = true
break
}
}
if ! clientFound || limitIp <= 0 || ! inbound . Enable {
// No limit or inbound disabled, just update and return
jsonIps , _ := json . Marshal ( newIpsWithTime )
inboundClientIps . Ips = string ( jsonIps )
db := database . GetDB ( )
db . Save ( inboundClientIps )
return false
}
// Parse old IPs from database
var oldIpsWithTime [ ] IPWithTimestamp
if inboundClientIps . Ips != "" {
json . Unmarshal ( [ ] byte ( inboundClientIps . Ips ) , & oldIpsWithTime )
}
// Merge old and new IPs, keeping the latest timestamp for each IP
ipMap := make ( map [ string ] int64 )
for _ , ipTime := range oldIpsWithTime {
ipMap [ ipTime . IP ] = ipTime . Timestamp
}
for _ , ipTime := range newIpsWithTime {
if existingTime , ok := ipMap [ ipTime . IP ] ; ! ok || ipTime . Timestamp > existingTime {
ipMap [ ipTime . IP ] = ipTime . Timestamp
}
}
2026-03-17 20:18:10 +00:00
// Convert back to slice and sort by timestamp (oldest first)
// This ensures we always protect the original/current connections and ban new excess ones.
2026-02-03 23:38:11 +00:00
allIps := make ( [ ] IPWithTimestamp , 0 , len ( ipMap ) )
for ip , timestamp := range ipMap {
allIps = append ( allIps , IPWithTimestamp { IP : ip , Timestamp : timestamp } )
}
sort . Slice ( allIps , func ( i , j int ) bool {
2026-03-17 20:18:10 +00:00
return allIps [ i ] . Timestamp < allIps [ j ] . Timestamp // Ascending order (oldest first)
2026-02-03 23:38:11 +00:00
} )
2023-06-15 19:15:34 +00:00
shouldCleanLog := false
2023-02-28 19:54:29 +00:00
2026-02-03 23:38:11 +00:00
// Open log file
2024-07-08 21:08:00 +00:00
logIpFile , err := os . OpenFile ( xray . GetIPLimitLogPath ( ) , os . O_CREATE | os . O_APPEND | os . O_WRONLY , 0644 )
2023-07-01 12:26:43 +00:00
if err != nil {
2024-07-08 21:08:00 +00:00
logger . Errorf ( "failed to open IP limit log file: %s" , err )
return false
2023-07-01 12:26:43 +00:00
}
defer logIpFile . Close ( )
log . SetOutput ( logIpFile )
log . SetFlags ( log . LstdFlags )
2026-04-06 08:58:43 +00:00
recentIps := j . filterRecentIPs ( allIps , time . Now ( ) . Add ( - ipLimitWindowDuration ) . Unix ( ) )
2023-06-03 15:29:32 +00:00
2026-04-06 08:58:43 +00:00
jsonIps , _ := json . Marshal ( recentIps )
inboundClientIps . Ips = string ( jsonIps )
2023-06-15 19:15:34 +00:00
2026-04-06 08:58:43 +00:00
// Check if the recent 3-minute window exceeds the limit.
if len ( recentIps ) > limitIp {
shouldCleanLog = true
for _ , ipTime := range recentIps {
log . Printf ( "[LIMIT_IP] Email = %s || Recent IP = %s || Timestamp = %d" , clientEmail , ipTime . IP , ipTime . Timestamp )
2023-02-28 19:54:29 +00:00
}
2024-01-21 11:09:15 +00:00
2026-04-06 08:58:43 +00:00
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 )
2026-04-06 08:04:27 +00:00
}
2024-01-21 11:09:15 +00:00
}
2023-02-28 19:54:29 +00:00
db := database . GetDB ( )
err = db . Save ( inboundClientIps ) . Error
2024-07-08 21:08:00 +00:00
if err != nil {
logger . Error ( "failed to save inboundClientIps:" , err )
return false
}
2024-03-05 13:39:20 +00:00
2023-06-15 19:15:34 +00:00
return shouldCleanLog
2023-02-28 19:54:29 +00:00
}
2023-06-08 10:20:35 +00:00
2023-07-01 12:26:43 +00:00
func ( j * CheckClientIpJob ) getInboundByEmail ( clientEmail string ) ( * model . Inbound , error ) {
2023-02-28 19:54:29 +00:00
db := database . GetDB ( )
2024-12-16 13:26:47 +00:00
inbound := & model . Inbound { }
2023-07-01 12:26:43 +00:00
2024-12-16 13:26:47 +00:00
err := db . Model ( & model . Inbound { } ) . Where ( "settings LIKE ?" , "%" + clientEmail + "%" ) . First ( inbound ) . Error
2023-02-28 19:54:29 +00:00
if err != nil {
return nil , err
}
2023-07-01 12:26:43 +00:00
2024-12-16 13:26:47 +00:00
return inbound , nil
2023-06-15 21:38:35 +00:00
}
2026-04-06 08:04:27 +00:00
2026-04-06 08:58:43 +00:00
func ( j * CheckClientIpJob ) disableClientTemporarily ( clientEmail string , duration time . Duration ) error {
2026-04-06 08:04:27 +00:00
now := time . Now ( ) . Unix ( )
2026-04-06 08:58:43 +00:00
if until , ok := j . tempBansByEmail [ clientEmail ] ; ok && until > now {
return nil
2026-04-06 08:04:27 +00:00
}
2026-04-06 08:58:43 +00:00
changed , needRestart , err := j . inboundService . SetClientEnableByEmail ( clientEmail , false )
2026-04-06 08:31:39 +00:00
if err != nil {
2026-04-06 08:58:43 +00:00
return err
2026-04-06 08:04:27 +00:00
}
2026-04-06 08:58:43 +00:00
if needRestart && j . xrayService != nil {
j . xrayService . SetToNeedRestart ( )
}
if ! changed {
return fmt . Errorf ( "client %s was not disabled" , clientEmail )
2026-04-06 08:04:27 +00:00
}
2026-04-06 08:58:43 +00:00
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 {
2026-04-06 08:04:27 +00:00
continue
}
2026-04-06 08:58:43 +00:00
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 ( )
}
2026-04-06 08:04:27 +00:00
2026-04-06 08:58:43 +00:00
delete ( j . tempBansByEmail , clientEmail )
if changed {
logger . Infof ( "[LIMIT_IP] Client %s: temporary 3-minute disable expired, access restored" , clientEmail )
}
2026-04-06 08:04:27 +00:00
}
2026-04-06 08:31:39 +00:00
}
2026-04-06 08:58:43 +00:00
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 )
2026-04-06 08:31:39 +00:00
}
}
2026-04-06 08:58:43 +00:00
return recent
2026-04-06 08:04:27 +00:00
}