|
|
@@ -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{}
|