mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
- Replace plain textarea with CodeMirror editor (YAML syntax highlighting, line numbers, auto-indent) for Clash subscription template - Fix confAlerts crash when subClashURI/subURI/subJsonURI is null/undefined (prevented save button from enabling) - Add yaml.js CodeMirror mode asset - Include docs and .gitignore cleanup
13 KiB
13 KiB
x-panel (xeefei/x-panel) 设备限制功能分析
本文档整理了 x-panel 的设备限制(IP限制)相关逻辑代码和接口,供后续修改 3x-ui IP 限制功能参考。
目录
- 架构概览
- 数据模型
- 核心任务:CheckDeviceLimitJob
- 封禁/解封机制
- 观察期防误封逻辑
- TTL 过期清理
- 遗留任务:CheckClientIpJob
- 前端 UI
- 主程序启动与依赖注入
- 关键日志路径
- 与 3x-ui 的差异总结
架构概览
x-panel 有两套 IP 限制机制并行运行:
| 任务 | 来源 | 执行方式 | 核心思路 |
|---|---|---|---|
CheckDeviceLimitJob |
新增 | main.go 中 goroutine + 10s Ticker |
内存跟踪活跃 IP,超限通过 Xray API 替换 UUID 封禁 |
CheckClientIpJob |
遗留(同 3x-ui) | cron 每 10s | 解析 access.log,超限 IP 写入 Fail2ban 日志 |
CheckDeviceLimitJob 工作流程(每 10 秒一次):
Run()
├─ 1. cleanupExpiredIPs() // 清理 3 分钟不活跃的 IP
├─ 2. parseAccessLog() // 增量读取 access.log,更新活跃 IP 表
└─ 3. checkAllClientsLimit() // 检查所有用户,超限封禁,恢复解封
数据模型
源文件: database/model/model.go
Inbound 结构体(新增字段)
type Inbound struct {
// ... 原有字段 ...
// 设备限制字段,per-inbound 级别(不是 per-client)
DeviceLimit int `json:"deviceLimit" form:"deviceLimit" gorm:"column:device_limit;default:0"`
}
device_limit > 0表示该入站规则启用了设备限制- 这是入站级别的限制,不是客户端级别的
Client 结构体
type Client struct {
ID string `json:"id"`
Security string `json:"security"`
Password string `json:"password"`
SpeedLimit int `json:"speedLimit" form:"speedLimit"` // KB/s,0=不限速
Flow string `json:"flow"`
Email string `json:"email"`
LimitIP int `json:"limitIp"` // 遗留字段,Fail2ban 用
TotalGB int64 `json:"totalGB"`
ExpiryTime int64 `json:"expiryTime"`
Enable bool `json:"enable"`
TgID int64 `json:"tgId"`
SubID string `json:"subId"`
Comment string `json:"comment"`
Reset int `json:"reset"`
}
InboundClientIps(与 3x-ui 相同)
type InboundClientIps struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
ClientEmail string `json:"clientEmail" gorm:"unique"`
Ips string `json:"ips"` // JSON 数组字符串
}
内存状态结构
// 活跃 IP 跟踪(TTL 机制)
// map[用户email] -> map[IP地址] -> 最后活跃时间
var ActiveClientIPs = make(map[string]map[string]time.Time)
var activeClientsLock sync.RWMutex
// 用户封禁状态跟踪
// map[用户email] -> 是否被封禁(true/false)
var ClientStatus = make(map[string]bool)
var clientStatusLock sync.RWMutex
核心任务:CheckDeviceLimitJob
源文件: web/job/check_client_ip_job.go
结构体
type CheckDeviceLimitJob struct {
inboundService service.InboundService
xrayService *service.XrayService
xrayApi xray.XrayAPI
lastPosition int64 // access.log 增量读取位置
telegramService service.TelegramService // TG 通知(可为 nil)
violationStartTime map[string]time.Time // 观察期开始时间
triggerLock sync.Mutex // 保护 violationStartTime
}
构造函数
func NewCheckDeviceLimitJob(xrayService *service.XrayService, telegramService service.TelegramService) *CheckDeviceLimitJob
Run() 主循环
func (j *CheckDeviceLimitJob) Run() {
if !j.xrayService.IsXrayRunning() {
return
}
j.cleanupExpiredIPs()
j.parseAccessLog()
j.checkAllClientsLimit()
}
cleanupExpiredIPs() — 清理过期 IP
- TTL 窗口:3 分钟
- 超过 3 分钟未出现的 IP 被删除
- 用户所有 IP 都过期后,用户条目也从 map 中移除
const activeTTL = 3 * time.Minute
for email, ips := range ActiveClientIPs {
for ip, lastSeen := range ips {
if now.Sub(lastSeen) > activeTTL {
delete(ActiveClientIPs[email], ip)
}
}
if len(ActiveClientIPs[email]) == 0 {
delete(ActiveClientIPs, email)
}
}
parseAccessLog() — 增量解析日志
- 使用
file.Seek(j.lastPosition, 0)实现增量读取 - 正则提取 email 和 IP:
emailRegex := regexp.MustCompile(`email: ([^ ]+)`) ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`) - 忽略
127.0.0.1和::1 - 读取完毕后记录当前位置;如果文件被截断(当前位置 < 上次位置),重置为 0
checkAllClientsLimit() — 核心检查逻辑
// 查询启用了设备限制且正在运行的入站
db.Where("device_limit > 0 AND enable = ?", true).Find(&inbounds)
// 获取 Xray API 端口
apiPort := j.xrayService.GetApiPort()
j.xrayApi.Init(apiPort)
defer j.xrayApi.Close()
第一步:处理在线用户
- 遍历
ActiveClientIPs - 通过
inboundService.GetClientTrafficByEmail(email)关联到入站 - 检查活跃 IP 数 vs
device_limit - 超限 → 进入观察期逻辑 → 封禁
- 恢复 → 解封
第二步:处理已封禁但已下线的用户
- 遍历
ClientStatus - 已封禁但不在
ActiveClientIPs中的用户 → 解封
封禁/解封机制
banUser() — 封禁(UUID 替换)
func (j *CheckDeviceLimitJob) banUser(email string, activeIPCount int, info *struct{...}) {
// 1. 从数据库获取原始客户端信息
_, client, err := j.inboundService.GetClientByEmail(email)
// 2. 发送 Telegram 通知(异步 goroutine)
go func() {
j.telegramService.SendMessage(tgMessage)
}()
// 3. 从 Xray-Core 中删除该用户
j.xrayApi.RemoveUser(info.Tag, email)
// 4. 等待 5 秒,解决竞态条件
time.Sleep(5000 * time.Millisecond)
// 5. 创建临时客户端,替换 UUID/Password
tempClient := *client
if tempClient.ID != "" { tempClient.ID = RandomUUID() }
if tempClient.Password != "" { tempClient.Password = RandomUUID() }
// 6. 用错误的 UUID/Password 添加回去 → 客户端无法通过验证
j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap)
// 7. 标记为已封禁
ClientStatus[email] = true
}
unbanUser() — 解封(恢复原始 UUID)
func (j *CheckDeviceLimitJob) unbanUser(email string, activeIPCount int, info *struct{...}) {
// 1. 从数据库获取原始客户端信息
_, client, err := j.inboundService.GetClientByEmail(email)
// 2. 删除封禁用的临时用户
j.xrayApi.RemoveUser(info.Tag, email)
// 3. 等待 5 秒
time.Sleep(5000 * time.Millisecond)
// 4. 用原始正确的 UUID/Password 添加回去
j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap)
// 5. 移除封禁标记
delete(ClientStatus, email)
}
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])
}
关键依赖接口
| 接口 | 说明 |
|---|---|
j.inboundService.GetClientByEmail(email) |
从数据库获取客户端原始配置(含 UUID/Password) |
j.xrayApi.RemoveUser(tag, email) |
通过 gRPC 从 Xray-Core 移除用户 |
j.xrayApi.AddUser(protocol, tag, clientMap) |
通过 gRPC 向 Xray-Core 添加用户 |
j.xrayService.GetApiPort() |
获取 Xray API 端口号 |
j.xrayService.IsXrayRunning() |
检查 Xray 是否运行中 |
j.telegramService.SendMessage(msg) |
发送 Telegram 通知 |
观察期防误封逻辑
目的: 解决用户切换网络时产生临时双 IP 导致误封的问题。
场景 A:用户设备数超限,且当前未被封禁
├─ 首次发现超限 → 记录时间,进入 3 分钟观察期,不封禁
├─ 观察期内仍超限但未满 3 分钟 → 继续观察
└─ 观察期满 3 分钟仍超限 → 确认封禁
场景 B:用户恢复正常(IP 数 ≤ 限制)
├─ 之前在观察名单中 → 移除观察记录,皆大欢喜
└─ 之前被封禁 → 执行解封
核心代码:
if activeIPCount > info.Limit && !isBanned {
startTime, exists := j.violationStartTime[email]
if !exists {
// 首次超限,开始观察
j.violationStartTime[email] = time.Now()
continue
}
if time.Since(startTime) < 3*time.Minute {
// 还在观察期,暂不封禁
continue
}
// 观察期结束,确认封禁
delete(j.violationStartTime, email)
j.banUser(email, activeIPCount, &info)
}
TTL 过期清理
- 活跃窗口: 3 分钟
- 每 10 秒执行一次清理
- IP 在
ActiveClientIPs中的lastSeen时间超过 3 分钟则删除 - 用户所有 IP 被清理后,用户条目也移除
- 被清理的已封禁用户在
checkAllClientsLimit第二步中会被解封
遗留任务:CheckClientIpJob
源文件: web/job/check_client_ip_job.go (lines 416-714)
与 3x-ui 的实现完全一致:
- 解析 access.log,提取每个 email 的所有 IP
- 与数据库中
InboundClientIps记录对比 - 超过
LimitIP的 IP 写入3xipl.log - 依赖 Fail2ban 读取日志进行 iptables 封禁
- 每小时清理 access.log
此任务由 cron 调度,与 CheckDeviceLimitJob 独立运行。
前端 UI
源文件: web/html/form/client.html
入站级别
DeviceLimit 字段不在 client 表单中显示,而是在入站配置中设置(具体 UI 未在提供的文件中)。
客户端级别
| 字段 | 行号 | 说明 |
|---|---|---|
client.limitIp |
108 | IP 数量限制(遗留,Fail2ban 用) |
client.speedLimit |
85-92 | 独立限速,单位 KB/s,0=不限速 |
client._totalGB |
150 | 总流量限制 |
client._expiryTime |
179-182 | 过期时间 |
client.reset |
193 | 续期天数 |
主程序启动与依赖注入
源文件: main.go
服务初始化(runWebServer 函数)
// 1. 创建服务实例
xrayService := service.XrayService{}
settingService := service.SettingService{}
serverService := service.ServerService{}
inboundService := service.InboundService{}
// 2. 创建 Xray API 实例并注入
xrayApi := xray.XrayAPI{}
xrayService.SetXrayAPI(xrayApi)
inboundService.SetXrayAPI(xrayApi)
// 3. 初始化 Telegram Bot(如已启用)
if tgEnable {
tgBot := service.NewTgBot(...)
tgBotService = tgBot
}
// 4. 注入 Telegram 服务
serverService.SetTelegramService(tgBotService)
inboundService.SetTelegramService(tgBotService)
设备限制定时任务启动
go func() {
time.Sleep(10 * time.Second) // 等待面板和 Xray 稳定
ticker := time.NewTicker(10 * time.Second) // 每 10 秒执行
defer ticker.Stop()
// 创建 Telegram 服务(可为 nil)
var tgBotService service.TelegramService
if tgEnable {
tgBotService = new(service.Tgbot)
}
// 创建任务实例
checkJob := job.NewCheckDeviceLimitJob(&xrayService, tgBotService)
// 无限循环
for {
<-ticker.C
checkJob.Run()
}
}()
关键日志路径
| 路径 | 说明 |
|---|---|
config.GetLogFolder() + "/3xipl.log" |
IP 限制日志(遗留 Fail2ban 用) |
config.GetLogFolder() + "/3xipl-banned.log" |
封禁日志 |
config.GetLogFolder() + "/3xipl-ap.log" |
持久化访问日志 |
| Xray access log(配置中指定) | 用户连接日志,设备限制解析源 |
config.GetBinFolderPath() + "/core_crash_*.log" |
崩溃报告 |
与 3x-ui 的差异总结
| 特性 | 3x-ui | x-panel |
|---|---|---|
| IP 限制级别 | per-client (LimitIP) |
per-inbound (DeviceLimit) + per-client 遗留 |
| 封禁方式 | Fail2ban + iptables | Xray API UUID 替换 |
| 活跃 IP 跟踪 | 无(全量日志分析) | 内存 map + 3 分钟 TTL |
| 误封防护 | 无 | 3 分钟观察期 |
| 解封机制 | Fail2ban unban | 恢复原始 UUID |
| 通知 | 无 | Telegram Bot 集成 |
| 限速 | 无 | per-client SpeedLimit (KB/s) |
| 调度方式 | cron 10s | goroutine + Ticker 10s |
| 依赖 | Fail2ban, iptables | Xray gRPC API |