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

13 KiB
Raw Blame History

x-panel (xeefei/x-panel) 设备限制功能分析

本文档整理了 x-panel 的设备限制(IP限制)相关逻辑代码和接口,供后续修改 3x-ui IP 限制功能参考。

目录

  1. 架构概览
  2. 数据模型
  3. 核心任务CheckDeviceLimitJob
  4. 封禁/解封机制
  5. 观察期防误封逻辑
  6. TTL 过期清理
  7. 遗留任务CheckClientIpJob
  8. 前端 UI
  9. 主程序启动与依赖注入
  10. 关键日志路径
  11. 与 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/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 相同)

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 的实现完全一致:

  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 函数)

// 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