Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorAung Ye Zaw <116444104+AungYeZawDev@users.noreply.github.com>2026-02-04 02:38:11 +0300
committerGitHub <noreply@github.com>2026-02-04 02:38:11 +0300
commitd8fb09faaeeebe34b57f1c26aa1ab8b529327989 (patch)
treeb0cef732187fb3573571f34a001a548f15c1a3e3 /web
parentf87c68ea682de3b0545b206557a17affcdf2be3a (diff)
feat: implement 'last IP wins' policy for IP limitation (#3735)
- Add timestamp tracking for each client IP address - Sort IPs by connection time (newest first) instead of alphabetically - Automatically disconnect old connections when IP limit exceeded - Keep only the most recent N IPs based on LimitIP setting - Force disconnection via Xray API (RemoveUser + AddUser) - Prevents account sharing while allowing legitimate network switching - Log format: [LIMIT_IP] Email = user@example.com || Disconnecting OLD IP = 1.2.3.4 || Timestamp = 1738521234 This ensures users can seamlessly switch between networks (mobile/WiFi) and the system maintains connections from their most recent IPs only. Fixes account sharing prevention for VPN providers selling per-IP licenses. Co-authored-by: Aung Ye Zaw <zaw.a.y@phluid.world>
Diffstat (limited to 'web')
-rw-r--r--web/job/check_client_ip_job.go208
1 files changed, 170 insertions, 38 deletions
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index e783a6df..94486236 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"
@@ -10,6 +11,7 @@ import (
"regexp"
"runtime"
"sort"
+ "strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
@@ -18,6 +20,12 @@ import (
"github.com/mhsanaei/3x-ui/v2/xray"
)
+// IPWithTimestamp tracks an IP address with its last seen timestamp
+type IPWithTimestamp struct {
+ IP string `json:"ip"`
+ Timestamp int64 `json:"timestamp"`
+}
+
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct {
lastClear int64
@@ -119,12 +127,14 @@ func (j *CheckClientIpJob) processLogFile() bool {
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
emailRegex := regexp.MustCompile(`email: (.+)$`)
+ timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
accessLogPath, _ := xray.GetAccessLogPath()
file, _ := os.Open(accessLogPath)
defer file.Close()
- inboundClientIps := make(map[string]map[string]struct{}, 100)
+ // Track IPs with their last seen timestamp
+ inboundClientIps := make(map[string]map[string]int64, 100)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
@@ -147,28 +157,45 @@ func (j *CheckClientIpJob) processLogFile() bool {
}
email := emailMatches[1]
+ // 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()
+ }
+
if _, exists := inboundClientIps[email]; !exists {
- inboundClientIps[email] = make(map[string]struct{})
+ inboundClientIps[email] = make(map[string]int64)
+ }
+ // Update timestamp - keep the latest
+ if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
+ inboundClientIps[email][ip] = timestamp
}
- inboundClientIps[email][ip] = struct{}{}
}
shouldCleanLog := false
- for email, uniqueIps := range inboundClientIps {
+ for email, ipTimestamps := range inboundClientIps {
- ips := make([]string, 0, len(uniqueIps))
- for ip := range uniqueIps {
- ips = append(ips, ip)
+ // Convert to IPWithTimestamp slice
+ ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
+ for ip, timestamp := range ipTimestamps {
+ ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
}
- sort.Strings(ips)
clientIpsRecord, err := j.getInboundClientIps(email)
if err != nil {
- j.addInboundClientIps(email, ips)
+ j.addInboundClientIps(email, ipsWithTime)
continue
}
- shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ips) || shouldCleanLog
+ shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
}
return shouldCleanLog
@@ -213,9 +240,9 @@ func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.Inbou
return InboundClientIps, nil
}
-func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string) error {
+func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
inboundClientIps := &model.InboundClientIps{}
- jsonIps, err := json.Marshal(ips)
+ jsonIps, err := json.Marshal(ipsWithTime)
j.checkError(err)
inboundClientIps.ClientEmail = clientEmail
@@ -239,16 +266,8 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string)
return nil
}
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, ips []string) bool {
- jsonIps, err := json.Marshal(ips)
- if err != nil {
- logger.Error("failed to marshal IPs to JSON:", err)
- return false
- }
-
- inboundClientIps.ClientEmail = clientEmail
- inboundClientIps.Ips = string(jsonIps)
-
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
+ // Get the inbound configuration
inbound, err := j.getInboundByEmail(clientEmail)
if err != nil {
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
@@ -263,9 +282,57 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"]
+
+ // 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
+ }
+ }
+
+ // Convert back to slice and sort by timestamp (newest first)
+ allIps := make([]IPWithTimestamp, 0, len(ipMap))
+ for ip, timestamp := range ipMap {
+ allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
+ }
+ sort.Slice(allIps, func(i, j int) bool {
+ return allIps[i].Timestamp > allIps[j].Timestamp // Descending order (newest first)
+ })
+
shouldCleanLog := false
j.disAllowedIps = []string{}
+ // Open log file
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
logger.Errorf("failed to open IP limit log file: %s", err)
@@ -275,27 +342,33 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.SetOutput(logIpFile)
log.SetFlags(log.LstdFlags)
- for _, client := range clients {
- if client.Email == clientEmail {
- limitIp := client.LimitIP
+ // Check if we exceed the limit
+ if len(allIps) > limitIp {
+ shouldCleanLog = true
- if limitIp > 0 && inbound.Enable {
- shouldCleanLog = true
+ // Keep only the newest IPs (up to limitIp)
+ keptIps := allIps[:limitIp]
+ disconnectedIps := allIps[limitIp:]
- if limitIp < len(ips) {
- j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...)
- for i := limitIp; i < len(ips); i++ {
- log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i])
- }
- }
- }
+ // Log the disconnected IPs (old ones)
+ for _, ipTime := range disconnectedIps {
+ j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
+ log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
- }
- sort.Strings(j.disAllowedIps)
+ // Actually disconnect old IPs by temporarily removing and re-adding user
+ // This forces Xray to drop existing connections from old IPs
+ if len(disconnectedIps) > 0 {
+ j.disconnectClientTemporarily(inbound, clientEmail, clients)
+ }
- if len(j.disAllowedIps) > 0 {
- logger.Debug("disAllowedIps:", j.disAllowedIps)
+ // Update database with only the newest 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()
@@ -305,9 +378,68 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
return false
}
+ if len(j.disAllowedIps) > 0 {
+ logger.Infof("[LIMIT_IP] Client %s: Kept %d newest IPs, disconnected %d old IPs", clientEmail, limitIp, len(j.disAllowedIps))
+ }
+
return shouldCleanLog
}
+// disconnectClientTemporarily removes and re-adds a client to force disconnect old connections
+func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
+ var xrayAPI xray.XrayAPI
+
+ // Get panel settings for API port
+ db := database.GetDB()
+ var apiPort int
+ var apiPortSetting model.Setting
+ if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
+ apiPort, _ = strconv.Atoi(apiPortSetting.Value)
+ }
+
+ if apiPort == 0 {
+ apiPort = 10085 // Default API port
+ }
+
+ err := xrayAPI.Init(apiPort)
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
+ return
+ }
+ defer xrayAPI.Close()
+
+ // Find the client config
+ var clientConfig map[string]any
+ for _, client := range clients {
+ if client.Email == clientEmail {
+ // Convert client to map for API
+ clientBytes, _ := json.Marshal(client)
+ json.Unmarshal(clientBytes, &clientConfig)
+ break
+ }
+ }
+
+ if clientConfig == nil {
+ return
+ }
+
+ // Remove user to disconnect all connections
+ err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
+ return
+ }
+
+ // Wait a moment for disconnection to take effect
+ time.Sleep(100 * time.Millisecond)
+
+ // Re-add user to allow new connections
+ err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig)
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
+ }
+}
+
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}