3x-ui/docs/x-panel-device-limit.md
root f5862abc2e feat: add CodeMirror YAML editor for Clash template and fix settings save button bug
- 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
2026-04-24 16:15:22 +08:00

444 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# x-panel (xeefei/x-panel) 设备限制功能分析
> 本文档整理了 x-panel 的设备限制(IP限制)相关逻辑代码和接口,供后续修改 3x-ui IP 限制功能参考。
## 目录
1. [架构概览](#架构概览)
2. [数据模型](#数据模型)
3. [核心任务CheckDeviceLimitJob](#核心任务checkdevicelimitjob)
4. [封禁/解封机制](#封禁解封机制)
5. [观察期防误封逻辑](#观察期防误封逻辑)
6. [TTL 过期清理](#ttl-过期清理)
7. [遗留任务CheckClientIpJob](#遗留任务checkclientipjob)
8. [前端 UI](#前端-ui)
9. [主程序启动与依赖注入](#主程序启动与依赖注入)
10. [关键日志路径](#关键日志路径)
11. [与 3x-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 结构体(新增字段)
```go
type Inbound struct {
// ... 原有字段 ...
// 设备限制字段per-inbound 级别(不是 per-client
DeviceLimit int `json:"deviceLimit" form:"deviceLimit" gorm:"column:device_limit;default:0"`
}
```
- `device_limit > 0` 表示该入站规则启用了设备限制
- 这是**入站级别**的限制,不是客户端级别的
### Client 结构体
```go
type Client struct {
ID string `json:"id"`
Security string `json:"security"`
Password string `json:"password"`
SpeedLimit int `json:"speedLimit" form:"speedLimit"` // KB/s0=不限速
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 相同)
```go
type InboundClientIps struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
ClientEmail string `json:"clientEmail" gorm:"unique"`
Ips string `json:"ips"` // JSON 数组字符串
}
```
### 内存状态结构
```go
// 活跃 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`
### 结构体
```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
}
```
### 构造函数
```go
func NewCheckDeviceLimitJob(xrayService *service.XrayService, telegramService service.TelegramService) *CheckDeviceLimitJob
```
### Run() 主循环
```go
func (j *CheckDeviceLimitJob) Run() {
if !j.xrayService.IsXrayRunning() {
return
}
j.cleanupExpiredIPs()
j.parseAccessLog()
j.checkAllClientsLimit()
}
```
### cleanupExpiredIPs() — 清理过期 IP
- TTL 窗口:**3 分钟**
- 超过 3 分钟未出现的 IP 被删除
- 用户所有 IP 都过期后,用户条目也从 map 中移除
```go
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
```go
emailRegex := regexp.MustCompile(`email: ([^ ]+)`)
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
```
- 忽略 `127.0.0.1``::1`
- 读取完毕后记录当前位置;如果文件被截断(当前位置 < 上次位置重置为 0
### checkAllClientsLimit() — 核心检查逻辑
```go
// 查询启用了设备限制且正在运行的入站
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 替换)
```go
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
```go
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
```go
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 数 ≤ 限制)
├─ 之前在观察名单中 → 移除观察记录,皆大欢喜
└─ 之前被封禁 → 执行解封
```
核心代码
```go
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 的实现完全一致
1. 解析 access.log提取每个 email 的所有 IP
2. 与数据库中 `InboundClientIps` 记录对比
3. 超过 `LimitIP` IP 写入 `3xipl.log`
4. 依赖 Fail2ban 读取日志进行 iptables 封禁
5. 每小时清理 access.log
此任务由 cron 调度 `CheckDeviceLimitJob` 独立运行
---
## 前端 UI
**源文件:** `web/html/form/client.html`
### 入站级别
`DeviceLimit` 字段不在 client 表单中显示而是在入站配置中设置具体 UI 未在提供的文件中)。
### 客户端级别
| 字段 | 行号 | 说明 |
|------|------|------|
| `client.limitIp` | 108 | IP 数量限制遗留Fail2ban |
| `client.speedLimit` | 85-92 | 独立限速单位 KB/s0=不限速 |
| `client._totalGB` | 150 | 总流量限制 |
| `client._expiryTime` | 179-182 | 过期时间 |
| `client.reset` | 193 | 续期天数 |
---
## 主程序启动与依赖注入
**源文件:** `main.go`
### 服务初始化runWebServer 函数)
```go
// 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
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 |