mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-03-21 10:05:49 +00:00
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>
This commit is contained in:
@@ -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]
|
||||
|
||||
if _, exists := inboundClientIps[email]; !exists {
|
||||
inboundClientIps[email] = make(map[string]struct{})
|
||||
// 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]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{}
|
||||
|
||||
Reference in New Issue
Block a user