From 8506b53e3882643b51c0bdb095dc6fc7d5a6a8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=83=E9=9A=A8=E7=B7=A3=E5=8B=95?= Date: Tue, 26 Aug 2025 15:39:09 +0800 Subject: [PATCH] v2.6.7 --- web/job/check_client_ip_job.go | 269 +++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 5a30b616..519f290d 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -10,13 +10,282 @@ import ( "regexp" "sort" "time" + "sync" + "crypto/rand" + "encoding/hex" "x-ui/database" "x-ui/database/model" "x-ui/logger" "x-ui/xray" + "x-ui/web/service" ) +// ================================================================= +// 中文注释: 以下是用于实现设备限制功能的核心代码 +// ================================================================= + +// ActiveClientIPs 中文注释: 用于在内存中跟踪每个用户的活跃IP (TTL机制) +// 结构: map[用户email] -> map[IP地址] -> 最后活跃时间 +var ActiveClientIPs = make(map[string]map[string]time.Time) +var activeClientsLock sync.RWMutex + +// ClientStatus 中文注释: 用于跟踪每个用户的状态(是否因为设备超限而被禁用) +// 结构: map[用户email] -> 是否被禁用(true/false) +var ClientStatus = make(map[string]bool) +var clientStatusLock sync.RWMutex + +// CheckDeviceLimitJob 中文注释: 这是我们的设备限制任务的结构体 +type CheckDeviceLimitJob struct { + inboundService service.InboundService + xrayService *service.XrayService + // 中文注释: 新增 xrayApi 字段,用于持有 Xray API 客户端实例 + xrayApi xray.XrayAPI + // lastPosition 中文注释: 用于记录上次读取 access.log 的位置,避免重复读取 + lastPosition int64 +} + +// RandomUUID 中文注释: 新增一个辅助函数,用于生成一个随机的 UUID +func RandomUUID() string { + uuid := make([]byte, 16) + rand.Read(uuid) + uuid[6] = (uuid[6] & 0x0f) | 0x40 + uuid[8] = (uuid[8] & 0x3f) | 0x80 + return hex.EncodeToString(uuid[0:4]) + "-" + hex.EncodeToString(uuid[4:6]) + "-" + hex.EncodeToString(uuid[6:8]) + "-" + hex.EncodeToString(uuid[8:10]) + "-" + hex.EncodeToString(uuid[10:16]) +} + +// NewCheckDeviceLimitJob 中文注释: 创建一个新的任务实例 +func NewCheckDeviceLimitJob(xrayService *service.XrayService) *CheckDeviceLimitJob { + return &CheckDeviceLimitJob{ + xrayService: xrayService, + // 中文注释: 初始化 xrayApi 字段 + xrayApi: xray.XrayAPI{}, + } +} + +// Run 中文注释: 定时任务的主函数,每次定时器触发时执行 +func (j *CheckDeviceLimitJob) Run() { + // 中文注释: 检查 xray 是否正在运行,如果xray没运行,则无需执行此任务 + if !j.xrayService.IsXrayRunning() { + return + } + + // 1. 清理过期的IP + j.cleanupExpiredIPs() + + // 2. 解析新的日志并更新IP列表 + j.parseAccessLog() + + // 3. 检查所有用户的设备限制状态 + j.checkAllClientsLimit() +} + +// cleanupExpiredIPs 中文注释: 清理长时间不活跃的IP +func (j *CheckDeviceLimitJob) cleanupExpiredIPs() { + activeClientsLock.Lock() + defer activeClientsLock.Unlock() + + now := time.Now() + // 中文注释: 活跃判断窗口(TTL): 近3分钟内出现过就算“活跃” + const activeTTL = 3 * time.Minute + for email, ips := range ActiveClientIPs { + for ip, lastSeen := range ips { + // 中文注释: 如果一个IP超过3分钟没有新的连接日志,我们就认为它已经下线 + if now.Sub(lastSeen) > activeTTL { + delete(ActiveClientIPs[email], ip) + } + } + // 中文注释: 如果一个用户的所有IP都下线了,就从大Map中移除这个用户,节省内存 + if len(ActiveClientIPs[email]) == 0 { + delete(ActiveClientIPs, email) + } + } +} + +// parseAccessLog 中文注释: 解析 xray access log 来获取最新的用户IP信息 +func (j *CheckDeviceLimitJob) parseAccessLog() { + logPath, err := xray.GetAccessLogPath() + if err != nil || logPath == "none" || logPath == "" { + return + } + + file, err := os.Open(logPath) + if err != nil { + return + } + defer file.Close() + + // 中文注释: 移动到上次读取结束的位置,实现增量读取 + file.Seek(j.lastPosition, 0) + + scanner := bufio.NewScanner(file) + + // 中文注释: 使用正则表达式从日志行中提取 email 和 IP + emailRegex := regexp.MustCompile(`email: ([^ ]+)`) + ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`) + + activeClientsLock.Lock() + defer activeClientsLock.Unlock() + + now := time.Now() + for scanner.Scan() { + line := scanner.Text() + + emailMatch := emailRegex.FindStringSubmatch(line) + ipMatch := ipRegex.FindStringSubmatch(line) + + if len(emailMatch) > 1 && len(ipMatch) > 1 { + email := emailMatch[1] + ip := ipMatch[1] + + if ip == "127.0.0.1" || ip == "::1" { + continue + } + + if _, ok := ActiveClientIPs[email]; !ok { + ActiveClientIPs[email] = make(map[string]time.Time) + } + ActiveClientIPs[email][ip] = now + } + } + + currentPosition, err := file.Seek(0, os.SEEK_END) + if err == nil { + if currentPosition < j.lastPosition { + j.lastPosition = 0 + } else { + j.lastPosition = currentPosition + } + } +} + +// checkAllClientsLimit 中文注释: 核心功能,检查所有用户,对超限的执行封禁,对恢复的执行解封 +func (j *CheckDeviceLimitJob) checkAllClientsLimit() { + db := database.GetDB() + var inbounds []*model.Inbound + // 中文注释: 这里仅查询启用了设备限制(device_limit > 0)并且自身是开启状态的入站规则 + db.Where("device_limit > 0 AND enable = ?", true).Find(&inbounds) + + if len(inbounds) == 0 { + return + } + + // 中文注释: 获取 API 端口。如果端口为0 (说明Xray未完全启动或有问题),则直接返回 + apiPort := j.xrayService.GetApiPort() + if apiPort == 0 { + return + } + // 中文注释: 使用获取到的端口号初始化 API 客户端 + j.xrayApi.Init(apiPort) + defer j.xrayApi.Close() + + // 中文注释: 优化 - 在一次循环中同时获取 tag 和 protocol + inboundInfo := make(map[int]struct { + Limit int + Tag string + Protocol model.Protocol + }) + for _, inbound := range inbounds { + inboundInfo[inbound.Id] = struct { + Limit int + Tag string + Protocol model.Protocol + }{Limit: inbound.DeviceLimit, Tag: inbound.Tag, Protocol: inbound.Protocol} + } + + activeClientsLock.RLock() + clientStatusLock.Lock() + defer activeClientsLock.RUnlock() + defer clientStatusLock.Unlock() + + for email, ips := range ActiveClientIPs { + traffic, err := j.inboundService.GetClientTrafficByEmail(email) + if err != nil || traffic == nil { + continue + } + + info, ok := inboundInfo[traffic.InboundId] + if !ok || info.Limit <= 0 { + continue + } + + + isBanned := ClientStatus[email] + activeIPCount := len(ips) + + // 情况一: IP数量超限,且用户当前未被封禁 -> 执行封禁 (UUID 替换) + if activeIPCount > info.Limit && !isBanned { + _, client, err := j.inboundService.GetClientByEmail(email) + if err != nil || client == nil { + continue + } + logger.Infof("设备限制超限: 用户 %s. 限制: %d, 当前活跃: %d. 禁用该用户。", email, info.Limit, activeIPCount) + + // 中文注释: 步骤一:先从 Xray-Core 中删除该用户。 + err = j.xrayApi.RemoveUser(info.Tag, email) + if err != nil { + // 中文注释: 即便删除失败(例如,因为某些原因Xray内存中已无此用户),也应继续尝试添加封禁用的临时用户。 + logger.Warningf("尝试为用户 %s 启动设备限制时,删除原始用户失败(可忽略): %v", email, err) + } + + // 中文注释: 创建一个带有随机UUID/Password的临时客户端配置用于“封禁” + tempClient := *client + + // 适用于 VMess/VLESS + if tempClient.ID != "" { + tempClient.ID = RandomUUID() + } + + // 适用于 Trojan/Shadowsocks/Socks + if tempClient.Password != "" { + tempClient.Password = RandomUUID() + } + var clientMap map[string]interface{} + clientJson, _ := json.Marshal(tempClient) + json.Unmarshal(clientJson, &clientMap) + + // 中文注释: 步骤二:将这个带有错误UUID/Password的临时用户添加回去。 + // 客户端持有的还是旧的UUID,自然就无法通过验证,从而达到了“封禁”的效果。 + err = j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap) + if err != nil { + logger.Warningf("通过API限制用户 %s 失败: %v", email, err) + } else { + // 中文注释: 封禁成功后,在内存中标记该用户为“已封禁”状态。 + ClientStatus[email] = true + } + } + + // 情况二: IP数量已恢复正常,但用户当前处于封禁状态 -> 执行解封 (恢复原始 UUID) + if activeIPCount <= info.Limit && isBanned { + _, client, err := j.inboundService.GetClientByEmail(email) + if err != nil || client == nil { + continue + } + logger.Infof("设备数量已恢复: 用户 %s. 限制: %d, 当前活跃: %d. 恢复用户。", email, info.Limit, activeIPCount) + + // 中文注释: 步骤一:先从 Xray-Core 中删除用于“封禁”的那个临时用户。 + err = j.xrayApi.RemoveUser(info.Tag, email) + if err != nil { + // 中文注释: 同样,这里的删除失败也可以容忍,最终目的是恢复用户。 + logger.Warningf("尝试为用户 %s 解除设备限制时,删除临时用户失败(可忽略): %v", email, err) + } + + var clientMap map[string]interface{} + clientJson, _ := json.Marshal(client) + json.Unmarshal(clientJson, &clientMap) + + // 中文注释: 步骤二:将数据库中原始的、正确的用户信息重新添加回 Xray-Core,从而实现“解封”。 + err = j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap) + if err != nil { + logger.Warningf("通过API恢复用户 %s 失败: %v", email, err) + } else { + // 中文注释: 解封成功后,从内存中移除该用户的“已封禁”状态标记。 + delete(ClientStatus, email) + } + } + } +} + type CheckClientIpJob struct { lastClear int64 disAllowedIps []string