mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Restore IP limitation feature to v2.8.5 behavior
Agent-Logs-Url: https://github.com/xAlokyx/3x-ui/sessions/0e9b200a-ecf1-45c9-8552-a13819e14e78 Co-authored-by: xAlokyx <234771438+xAlokyx@users.noreply.github.com>
This commit is contained in:
parent
c52963ac7e
commit
50550f0d4b
5 changed files with 41 additions and 213 deletions
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
@ -194,37 +193,6 @@ func (a *InboundController) getClientIps(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer returning a normalized string list for consistent UI rendering
|
|
||||||
type ipWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var ipsWithTime []ipWithTimestamp
|
|
||||||
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
|
||||||
formatted := make([]string, 0, len(ipsWithTime))
|
|
||||||
for _, item := range ipsWithTime {
|
|
||||||
if item.IP == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if item.Timestamp > 0 {
|
|
||||||
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
|
||||||
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
formatted = append(formatted, item.IP)
|
|
||||||
}
|
|
||||||
jsonObj(c, formatted, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldIps []string
|
|
||||||
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
|
||||||
jsonObj(c, oldIps, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If parsing fails, return as string
|
|
||||||
jsonObj(c, ips, nil)
|
jsonObj(c, ips, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,6 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"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.
|
// 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
|
||||||
|
|
@ -125,14 +119,12 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
||||||
|
|
||||||
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
||||||
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
||||||
timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
|
|
||||||
|
|
||||||
accessLogPath, _ := xray.GetAccessLogPath()
|
accessLogPath, _ := xray.GetAccessLogPath()
|
||||||
file, _ := os.Open(accessLogPath)
|
file, _ := os.Open(accessLogPath)
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Track IPs with their last seen timestamp
|
inboundClientIps := make(map[string]map[string]struct{}, 100)
|
||||||
inboundClientIps := make(map[string]map[string]int64, 100)
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
|
@ -155,45 +147,28 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
||||||
}
|
}
|
||||||
email := emailMatches[1]
|
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 {
|
if _, exists := inboundClientIps[email]; !exists {
|
||||||
inboundClientIps[email] = make(map[string]int64)
|
inboundClientIps[email] = make(map[string]struct{})
|
||||||
}
|
|
||||||
// Update timestamp - keep the latest
|
|
||||||
if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
|
|
||||||
inboundClientIps[email][ip] = timestamp
|
|
||||||
}
|
}
|
||||||
|
inboundClientIps[email][ip] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
for email, ipTimestamps := range inboundClientIps {
|
for email, uniqueIps := range inboundClientIps {
|
||||||
|
|
||||||
// Convert to IPWithTimestamp slice
|
ips := make([]string, 0, len(uniqueIps))
|
||||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
for ip := range uniqueIps {
|
||||||
for ip, timestamp := range ipTimestamps {
|
ips = append(ips, ip)
|
||||||
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
|
||||||
}
|
}
|
||||||
|
sort.Strings(ips)
|
||||||
|
|
||||||
clientIpsRecord, err := j.getInboundClientIps(email)
|
clientIpsRecord, err := j.getInboundClientIps(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.addInboundClientIps(email, ipsWithTime)
|
j.addInboundClientIps(email, ips)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
|
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ips) || shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
|
|
@ -238,9 +213,9 @@ func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.Inbou
|
||||||
return InboundClientIps, nil
|
return InboundClientIps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
|
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string) error {
|
||||||
inboundClientIps := &model.InboundClientIps{}
|
inboundClientIps := &model.InboundClientIps{}
|
||||||
jsonIps, err := json.Marshal(ipsWithTime)
|
jsonIps, err := json.Marshal(ips)
|
||||||
j.checkError(err)
|
j.checkError(err)
|
||||||
|
|
||||||
inboundClientIps.ClientEmail = clientEmail
|
inboundClientIps.ClientEmail = clientEmail
|
||||||
|
|
@ -264,8 +239,16 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
|
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, ips []string) bool {
|
||||||
// Get the inbound configuration
|
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)
|
||||||
|
|
||||||
inbound, err := j.getInboundByEmail(clientEmail)
|
inbound, err := j.getInboundByEmail(clientEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
|
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
|
||||||
|
|
@ -280,58 +263,9 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
settings := map[string][]model.Client{}
|
settings := map[string][]model.Client{}
|
||||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
clients := settings["clients"]
|
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 (oldest first)
|
|
||||||
// This ensures we always protect the original/current connections and ban new excess ones.
|
|
||||||
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 // Ascending order (oldest first)
|
|
||||||
})
|
|
||||||
|
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
j.disAllowedIps = []string{}
|
j.disAllowedIps = []string{}
|
||||||
|
|
||||||
// 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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||||
|
|
@ -341,27 +275,27 @@ 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
|
for _, client := range clients {
|
||||||
if len(allIps) > limitIp {
|
if client.Email == clientEmail {
|
||||||
shouldCleanLog = true
|
limitIp := client.LimitIP
|
||||||
|
|
||||||
// Keep the oldest IPs (currently active connections) and ban the new excess ones.
|
if limitIp > 0 && inbound.Enable {
|
||||||
keptIps := allIps[:limitIp]
|
shouldCleanLog = true
|
||||||
bannedIps := allIps[limitIp:]
|
|
||||||
|
|
||||||
// Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
|
if limitIp < len(ips) {
|
||||||
for _, ipTime := range bannedIps {
|
j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...)
|
||||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
for i := limitIp; i < len(ips); i++ {
|
||||||
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update database with only the currently active (kept) IPs
|
sort.Strings(j.disAllowedIps)
|
||||||
jsonIps, _ := json.Marshal(keptIps)
|
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
if len(j.disAllowedIps) > 0 {
|
||||||
} else {
|
logger.Debug("disAllowedIps:", j.disAllowedIps)
|
||||||
// Under limit, save all IPs
|
|
||||||
jsonIps, _ := json.Marshal(allIps)
|
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
@ -371,10 +305,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(j.disAllowedIps) > 0 {
|
|
||||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
|
|
||||||
}
|
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2140,43 +2140,6 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if InboundClientIps.Ips == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse as new format (with timestamps)
|
|
||||||
type IPWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var ipsWithTime []IPWithTimestamp
|
|
||||||
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
|
|
||||||
|
|
||||||
// If successfully parsed as new format, return with timestamps
|
|
||||||
if err == nil && len(ipsWithTime) > 0 {
|
|
||||||
return InboundClientIps.Ips, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, assume it's old format (simple string array)
|
|
||||||
// Try to parse as simple array and convert to new format
|
|
||||||
var oldIps []string
|
|
||||||
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
|
|
||||||
if err == nil && len(oldIps) > 0 {
|
|
||||||
// Convert old format to new format with current timestamp
|
|
||||||
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
|
|
||||||
for i, ip := range oldIps {
|
|
||||||
newIpsWithTime[i] = IPWithTimestamp{
|
|
||||||
IP: ip,
|
|
||||||
Timestamp: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result, _ := json.Marshal(newIpsWithTime)
|
|
||||||
return string(result), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return as-is if parsing fails
|
|
||||||
return InboundClientIps.Ips, nil
|
return InboundClientIps.Ips, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
|
@ -3154,41 +3153,9 @@ func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
||||||
ips = t.I18nBot("tgbot.noIpRecord")
|
ips = t.I18nBot("tgbot.noIpRecord")
|
||||||
}
|
}
|
||||||
|
|
||||||
formattedIps := ips
|
|
||||||
if err == nil && len(ips) > 0 {
|
|
||||||
type ipWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var ipsWithTime []ipWithTimestamp
|
|
||||||
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
|
|
||||||
lines := make([]string, 0, len(ipsWithTime))
|
|
||||||
for _, item := range ipsWithTime {
|
|
||||||
if item.IP == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if item.Timestamp > 0 {
|
|
||||||
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
|
|
||||||
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, item.IP)
|
|
||||||
}
|
|
||||||
if len(lines) > 0 {
|
|
||||||
formattedIps = strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var oldIps []string
|
|
||||||
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
|
|
||||||
formattedIps = strings.Join(oldIps, "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output := ""
|
output := ""
|
||||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||||
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
|
output += t.I18nBot("tgbot.messages.ips", "IPs=="+ips)
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
inlineKeyboard := tu.InlineKeyboard(
|
||||||
|
|
|
||||||
2
x-ui.sh
2
x-ui.sh
|
|
@ -2013,7 +2013,7 @@ EOF
|
||||||
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
||||||
[Definition]
|
[Definition]
|
||||||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
||||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*SRC\s*=\s*<ADDR>
|
||||||
ignoreregex =
|
ignoreregex =
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue