feat: allow same email across multiple inbounds and auto-add clients on registration

Remove global unique constraint on client_traffics.email, change email
duplication check to per-inbound scope, and automatically register new
users as disabled clients in all existing inbounds within a transaction.
This commit is contained in:
Sora39831 2026-04-03 01:38:31 +08:00
parent 87c94cb5b0
commit b4047cee54
16 changed files with 6156 additions and 34 deletions

View file

@ -106,7 +106,21 @@ func runSeeders(isUsersEmpty bool) error {
hashSeeder := &model.HistoryOfSeeders{ hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash", SeederName: "UserPasswordHash",
} }
return db.Create(hashSeeder).Error if err := db.Create(hashSeeder).Error; err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "RemoveClientTrafficEmailUnique") {
// Drop the old unique index on client_traffics.email to allow
// the same email across multiple inbounds
db.Exec("DROP INDEX IF EXISTS idx_client_traffics_email")
uniqueSeeder := &model.HistoryOfSeeders{
SeederName: "RemoveClientTrafficEmailUnique",
}
if err := db.Create(uniqueSeeder).Error; err != nil {
return err
}
} }
} }

1109
docs/API-DB-MariaDB.md Normal file

File diff suppressed because it is too large Load diff

346
docs/API-DB.md Normal file
View file

@ -0,0 +1,346 @@
# 3x-ui 数据库相关接口
> 以下接口涉及数据库的读写操作(增删改查)或数据库文件的导入导出。
---
## 目录
- [1. 入站管理](#1-入站管理)
- [2. 客户端管理](#2-客户端管理)
- [3. 流量管理](#3-流量管理)
- [4. IP 记录管理](#4-ip-记录管理)
- [5. 面板配置](#5-面板配置)
- [6. 用户管理](#6-用户管理)
- [7. 数据库导入导出](#7-数据库导入导出)
---
## 1. 入站管理
### `GET /panel/api/inbounds/list`
查询数据库,获取当前用户的所有入站记录。
**响应 (`obj`)** `[]Inbound`
---
### `GET /panel/api/inbounds/get/:id`
根据 ID 从数据库查询单条入站记录。
**URL 参数:** `:id`int
**响应 (`obj`)** `Inbound` 对象。
---
### `POST /panel/api/inbounds/add`
向数据库写入一条新的入站记录。
**请求体:** `Inbound` 对象JSON 或表单)。
**响应 (`obj`)** 创建的 `Inbound` 对象。
---
### `POST /panel/api/inbounds/del/:id`
从数据库删除指定入站记录及其关联的客户端流量数据。
**URL 参数:** `:id`int
**响应 (`obj`)** 被删除的入站 IDint
---
### `POST /panel/api/inbounds/update/:id`
更新数据库中指定入站记录。
**URL 参数:** `:id`int
**请求体:** `Inbound` 对象JSON 或表单)。
**响应 (`obj`)** 更新后的 `Inbound` 对象。
---
### `POST /panel/api/inbounds/import`
通过 JSON 数据导入,向数据库写入一条新的入站记录。
**请求体**(表单):
| 字段 | 类型 | 必填 |
|---|---|---|
| `data` | string (JSON) | 是 |
`data` 字段为 JSON 序列化的 `Inbound` 对象。
**响应 (`obj`)** 创建的 `Inbound` 对象。
---
## 2. 客户端管理
### `GET /panel/api/inbounds/getClientTraffics/:email`
根据邮箱从数据库查询客户端流量记录。
**URL 参数:** `:email`string
**响应 (`obj`)** `[]ClientTraffic`
---
### `GET /panel/api/inbounds/getClientTrafficsById/:id`
根据客户端 ID 从数据库查询流量记录。
**URL 参数:** `:id`string
**响应 (`obj`)** `[]ClientTraffic`
---
### `POST /panel/api/inbounds/addClient`
向数据库写入新客户端记录,更新入站的 `Settings` 字段。
**请求体:** 包含新客户端信息的 `Inbound` 对象。
**响应:**
```json
{ "success": true, "msg": "Client added successfully", "obj": null }
```
---
### `POST /panel/api/inbounds/:id/delClient/:clientId`
从数据库删除指定客户端记录,更新入站的 `Settings` 字段。
**URL 参数:** `:id`int、`:clientId`string
**响应:**
```json
{ "success": true, "msg": "Client deleted successfully", "obj": null }
```
---
### `POST /panel/api/inbounds/updateClient/:clientId`
更新数据库中指定客户端的配置。
**URL 参数:** `:clientId`string
**请求体:** 包含更新后客户端设置的 `Inbound` 对象。
**响应:**
```json
{ "success": true, "msg": "Client configuration updated successfully", "obj": null }
```
---
### `POST /panel/api/inbounds/:id/delClientByEmail/:email`
根据邮箱从数据库删除客户端记录。
**URL 参数:** `:id`int、`:email`string
**响应:**
```json
{ "success": true, "msg": "Client deleted successfully", "obj": null }
```
---
### `POST /panel/api/inbounds/delDepletedClients/:id`
批量删除数据库中指定入站下所有流量耗尽的客户端记录。
**URL 参数:** `:id`int
**响应:**
```json
{ "success": true, "msg": "Depleted clients deleted successfully", "obj": null }
```
---
## 3. 流量管理
### `POST /panel/api/inbounds/:id/resetClientTraffic/:email`
将数据库中指定客户端的上行、下行流量重置为 0。
**URL 参数:** `:id`int、`:email`string
**响应:**
```json
{ "success": true, "msg": "Client traffic reset successfully", "obj": null }
```
---
### `POST /panel/api/inbounds/resetAllTraffics`
将数据库中所有入站的上行、下行流量重置为 0。
**响应:**
```json
{ "success": true, "msg": "All traffic reset successfully", "obj": null }
```
---
### `POST /panel/api/inbounds/resetAllClientTraffics/:id`
将数据库中指定入站下所有客户端的上行、下行流量重置为 0。
**URL 参数:** `:id`int
**响应:**
```json
{ "success": true, "msg": "All client traffic reset successfully", "obj": null }
```
---
### `POST /panel/api/inbounds/updateClientTraffic/:email`
手动修改数据库中指定客户端的流量数值。
**URL 参数:** `:email`string
**请求体**JSON
```json
{
"upload": 0,
"download": 0
}
```
**响应:**
```json
{ "success": true, "msg": "Client configuration updated successfully", "obj": null }
```
---
## 4. IP 记录管理
### `POST /panel/api/inbounds/clientIps/:email`
从数据库查询客户端关联的 IP 地址记录。
**URL 参数:** `:email`string
**响应 (`obj`)**
- `[]string`,格式为 `"IP (YYYY-MM-DD HH:MM:SS)"`(含时间戳时)
- `[]string`,纯 IP 字符串(旧格式)
- `"No IP Record"`(无数据时)
---
### `POST /panel/api/inbounds/clearClientIps/:email`
清除数据库中指定客户端的 IP 记录。
**URL 参数:** `:email`string
**响应:**
```json
{ "success": true, "msg": "Log cleanup successful", "obj": null }
```
---
## 5. 面板配置
### `POST /panel/setting/all`
从数据库查询所有面板配置项。
**响应 (`obj`)** `AllSetting` 对象。
---
### `POST /panel/setting/update`
将配置写入数据库(批量更新面板设置)。
**请求体:** `AllSetting` 对象JSON 或表单)。
**响应:**
```json
{ "success": true, "msg": "Settings modified successfully", "obj": null }
```
---
## 6. 用户管理
### `POST /panel/setting/updateUser`
修改数据库中的管理员用户名和密码。
**请求体**JSON 或表单):
```json
{
"oldUsername": "string",
"oldPassword": "string",
"newUsername": "string",
"newPassword": "string"
}
```
**成功响应:**
```json
{ "success": true, "msg": "User modified successfully", "obj": null }
```
**错误响应:**
- `msg: "User modification failed: original username/password incorrect"`
- `msg: "User modification failed: username and password cannot be empty"`
---
## 7. 数据库导入导出
### `GET /panel/api/server/getDb`
导出整个 SQLite 数据库文件(`x-ui.db`)。
**响应:** 二进制文件下载(`application/octet-stream`,文件名 `x-ui.db`)。不使用 `Msg` 包装格式。
---
### `POST /panel/api/server/importDB`
导入数据库备份文件,覆盖当前数据库。导入后自动重启 Xray 服务。
**请求体:** multipart 文件上传(字段名 `db`)。
**响应 (`obj`)** `"Database imported successfully"`

1059
docs/API.md Normal file

File diff suppressed because it is too large Load diff

485
docs/install-logic.md Normal file
View file

@ -0,0 +1,485 @@
# install.sh 逻辑文档
## 概述
`install.sh` 是 3x-ui 面板的安装脚本,负责在 Linux 服务器上完成以下工作:
1. 安装系统依赖包
2. 下载并解压 3x-ui 发行版
3. 配置 systemd / OpenRC 服务
4. 生成随机凭据用户名、密码、端口、Web 路径)
5. 配置 SSL 证书Let's Encrypt 域名证书、IP 证书、或自定义证书)
6. 显示安装结果和访问信息
---
## 全局配置
### 颜色变量
| 变量 | 值 | 用途 |
|---------|----------------|------------|
| `red` | `\033[0;31m` | 红色文本 |
| `green` | `\033[0;32m` | 绿色文本 |
| `blue` | `\033[0;34m` | 蓝色文本 |
| `yellow`| `\033[0;33m` | 黄色文本 |
| `plain` | `\033[0m` | 重置颜色 |
### 路径变量
| 变量 | 默认值 | 说明 |
|-----------------|-------------------------|--------------------------|
| `xui_folder` | `/usr/local/x-ui` | x-ui 安装目录 |
| `xui_service` | `/etc/systemd/system` | systemd 服务文件目录 |
可通过环境变量 `XUI_MAIN_FOLDER``XUI_SERVICE` 覆盖。
---
## 入口流程
```
install.sh 被执行
├─ 检查 root 权限
├─ 检测操作系统发行版
├─ 检测 CPU 架构
├─ install_base() ← 安装系统依赖
└─ install_x-ui($1) ← 主安装逻辑($1 为可选的版本号)
```
---
## 函数详解
### 1. root 权限检查(第 14-15 行)
检查 `$EUID` 是否为 0。非 root 用户直接退出并提示使用 root 权限。
### 2. 操作系统检测(第 17-28 行)
读取 `/etc/os-release``/usr/lib/os-release`,将 `$ID` 赋值给 `release` 变量。
支持的发行版:
| 包管理器 | 发行版 |
|----------|--------|
| `apt` | ubuntu, debian, armbian |
| `dnf` | fedora, amzn, virtuozzo, rhel, almalinux, rocky, ol |
| `yum` | centos 7 |
| `pacman` | arch, manjaro, parch |
| `zypper` | opensuse-tumbleweed, opensuse-leap |
| `apk` | alpine |
### 3. `arch()` — CPU 架构检测(第 30-41 行)
通过 `uname -m` 映射到标准架构标识:
| `uname -m` 输出 | 返回值 |
|------------------------|----------|
| x86_64, x64, amd64 | `amd64` |
| i*86, x86 | `386` |
| armv8*, arm64, aarch64 | `arm64` |
| armv7*, arm | `armv7` |
| armv6* | `armv6` |
| armv5* | `armv5` |
| s390x | `s390x` |
| 其他 | 退出报错 |
### 4. IP/域名验证函数(第 46-57 行)
| 函数 | 逻辑 |
|---------------|---------------------------------------------------|
| `is_ipv4()` | 正则匹配 `数字.数字.数字.数字` 格式 |
| `is_ipv6()` | 检查字符串是否包含 `:` |
| `is_ip()` | 调用 `is_ipv4``is_ipv6` |
| `is_domain()` | 正则匹配标准域名格式(含国际化域名 `xn--` 支持) |
### 5. `is_port_in_use()` — 端口占用检测(第 60-74 行)
按优先级尝试三种方式:
1. `ss -ltn` — 检查监听端口
2. `netstat -lnt` — 回退方案
3. `lsof -nP -iTCP:端口 -sTCP:LISTEN` — 最后手段
任一命中即返回 0端口被占用
### 6. `install_base()` — 安装基础依赖(第 76-104 行)
根据 `$release` 使用对应的包管理器安装以下公共依赖:
```
curl, tar, tzdata, socat, ca-certificates, openssl
```
额外安装 `cron`(用于 acme.sh 自动续期,仅 apt 系列)。
- CentOS 7 使用 `yum`,其他版本使用 `dnf`
- 未识别的发行版默认回退到 `apt-get`
### 7. `gen_random_string(length)` — 随机字符串生成(第 106-111 行)
```
openssl rand -base64(length*2) → 过滤 a-zA-Z0-9 → 截取前 length 个字符
```
用于生成用户名、密码、Web 路径等随机值。
### 8. `install_acme()` — 安装 acme.sh第 113-124 行)
```bash
curl -s https://get.acme.sh | sh
```
安装到 `~/.acme.sh/` 目录。失败返回 1。
---
## SSL 证书管理
### 9. `setup_ssl_certificate(domain, server_ip, port, webBasePath)` — 域名 SSL第 126-191 行)
**用途**:为域名签发 Let's Encrypt 证书。
**流程**
```
检查 acme.sh 是否已安装
├─ 未安装 → 调用 install_acme()
└─ 已安装 → 继续
创建证书目录:/root/cert/${domain}/
签发证书:
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80
↳ 失败 → 清理并返回 1
安装证书:
acme.sh --installcert
--key-file /root/cert/${domain}/privkey.pem
--fullchain-file /root/cert/${domain}/fullchain.pem
--reloadcmd "systemctl restart x-ui"
启用自动续期acme.sh --upgrade --auto-upgrade
设置文件权限:
privkey.pem → 600仅所有者可读
fullchain.pem → 644
配置面板证书路径:
x-ui cert -webCert fullchain.pem -webCertKey privkey.pem
```
**前提条件**80 端口必须可从外网访问。
### 10. `setup_ip_certificate(ipv4, ipv6)` — IP 证书(第 195-343 行)
**用途**:为 IP 地址签发 Let's Encrypt 短期证书(约 6 天有效期)。
**流程**
```
检查 acme.sh
验证 IPv4 地址格式
创建证书目录:/root/cert/ip/
选择 HTTP-01 监听端口:
└─ 默认 80用户可自定义
└─ 循环检测端口占用,被占用则提示换端口
签发证书:
acme.sh --issue
-d ${ipv4} [-d ${ipv6}]
--standalone
--server letsencrypt
--certificate-profile shortlived
--days 6
--httpport ${WebPort}
安装证书:
acme.sh --installcert
--key-file /root/cert/ip/privkey.pem
--fullchain-file /root/cert/ip/fullchain.pem
--reloadcmd "systemctl restart x-ui || rc-service x-ui restart"
↳ 通过检查文件是否存在(而非退出码)判断成功
启用自动续期
设置文件权限
配置面板证书路径
```
**关键特性**
- 使用 `--certificate-profile shortlived` 配置文件,证书有效期约 6 天
- acme.sh cron 任务会在到期前自动续期
- 不依赖退出码判断安装成功(因为 reloadcmd 失败会导致非零退出)
- 支持 IPv4 + IPv6 双栈
### 11. `ssl_cert_issue()` — 手动 SSL 证书签发(第 346-509 行)
**用途**:交互式域名证书签发,提供更多自定义选项。
**流程**
```
读取当前面板的 webBasePath 和 port
检查 acme.sh不存在则安装
获取并验证用户输入的域名:
└─ 循环直到输入有效域名
└─ 检查是否已存在该域名的证书
创建证书目录:/root/cert/${domain}/
选择端口(默认 80
临时停止面板(释放端口)
签发证书:
acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort}
设置 reloadcmd证书续期后执行的命令
├─ 默认systemctl restart x-ui || rc-service x-ui restart
├─ 选项 1systemctl reload nginx ; systemctl restart x-ui
├─ 选项 2自定义命令
└─ 选项 0保持默认
安装证书并启用自动续期
启动面板
询问是否将证书应用到面板:
└─ 是 → x-ui cert -webCert ... -webCertKey ...
└─ 否 → 跳过
```
**特点**
- 签发前会停止面板以释放端口
- 支持自定义 reloadcmd例如先 reload nginx 再重启 x-ui
- 签发失败会自动重新启动面板
### 12. `prompt_and_setup_ssl(panel_port, web_base_path, server_ip)` — SSL 选择菜单(第 513-638 行)
**用途**:安装时的统一 SSL 配置入口,提供三种选择。
**菜单**
```
1. Let's Encrypt 域名证书90 天有效期,自动续期)
└─ 调用 ssl_cert_issue()
└─ 从 acme.sh 列表提取域名作为 SSL_HOST
2. Let's Encrypt IP 证书6 天有效期,自动续期) ← 默认选项
└─ 可选输入 IPv6 地址
└─ 停止面板释放 80 端口
└─ 调用 setup_ip_certificate(server_ip, ipv6)
└─ SSL_HOST = server_ip
3. 自定义 SSL 证书(指定已有文件路径)
└─ 输入域名
└─ 循环验证证书文件(存在、可读、非空)
└─ 循环验证私钥文件(存在、可读、非空)
└─ x-ui cert -webCert ... -webCertKey ...
└─ 提示用户自行管理续期
```
**全局变量**:设置 `SSL_HOST` 供后续显示访问地址使用。
---
## 安装后配置
### 13. `config_after_install()` — 安装后配置(第 640-760 行)
**用途**首次安装后的凭据生成、端口设置、Web 路径生成、SSL 配置。
**流程图**
```
读取当前面板设置:
- hasDefaultCredential是否为默认凭据
- webBasePath
- port
- cert证书路径
获取服务器公网 IP
└─ 依次尝试 6 个 API
1. api4.ipify.org
2. ipv4.icanhazip.com
3. v4.api.ipinfo.io/ip
4. ipv4.myexternalip.com/raw
5. 4.ident.me
6. check-host.net/ip
判断 webBasePath 是否足够长≥4 字符):
┌─ webBasePath 过短
│ ├─ hasDefaultCredential == true首次安装
│ │ ├─ 生成随机 webBasePath18 位)
│ │ ├─ 生成随机用户名10 位)
│ │ ├─ 生成随机密码10 位)
│ │ ├─ 询问是否自定义端口
│ │ │ ├─ 是 → 用户输入端口
│ │ │ └─ 否 → 随机生成 1024-62000 范围端口
│ │ ├─ 应用设置x-ui setting -username ... -password ... -port ... -webBasePath ...
│ │ ├─ prompt_and_setup_ssl() ← 必需
│ │ └─ 显示完整凭据和访问地址
│ │
│ └─ hasDefaultCredential != true非首次安装
│ ├─ 生成新 webBasePath
│ ├─ 检查是否有证书:
│ │ ├─ 无 → prompt_and_setup_ssl()(推荐)
│ │ └─ 有 → 显示 HTTP 访问地址
│ └─ 结束
└─ webBasePath 正常≥4 字符)
├─ hasDefaultCredential == true
│ ├─ 生成随机用户名和密码
│ ├─ 应用新凭据
│ └─ 显示凭据
└─ hasDefaultCredential != true
└─ 提示凭据已正确设置
再次检查证书:
├─ 无证书 → prompt_and_setup_ssl()(推荐)
└─ 有证书 → 跳过
最后执行x-ui migrate数据库迁移
```
---
## 主安装逻辑
### 14. `install_x-ui(version)` — 主安装函数(第 762-958 行)
**参数**`$1` 可选,指定安装版本号(如 `v2.3.5`)。
**流程**
```
cd /usr/local/
┌─ 无版本参数(安装最新版)
│ ├─ 从 GitHub API 获取最新版本号
│ │ └─ IPv4 失败时重试 curl -4
│ └─ 下载x-ui-linux-${arch}.tar.gz
└─ 有版本参数
├─ 验证版本号 ≥ v2.3.5
└─ 下载指定版本
同时下载 x-ui.sh 到 /usr/bin/x-ui-temp
停止已有 x-ui 服务并删除旧安装目录
解压 tar.gz设置执行权限
ARM 架构特殊处理:
armv5/armv6/armv7 → 重命名为 xray-linux-arm
安装 x-ui.sh 到 /usr/bin/x-ui
创建日志目录 /var/log/x-ui/
调用 config_after_install() ← 生成凭据 + SSL
etckeeper 兼容:
└─ 如果 /etc/.git 存在,将 x-ui.db 加入 .gitignore
┌─ Alpine Linux
│ ├─ 下载 OpenRC 脚本 x-ui.rc → /etc/init.d/x-ui
│ ├─ rc-update add x-ui启用开机自启
│ └─ rc-service x-ui start
└─ 其他系统systemd
├─ 优先使用 tar.gz 中的服务文件
│ ├─ x-ui.service ← 通用
│ ├─ x-ui.service.debian ← Ubuntu/Debian
│ ├─ x-ui.service.arch ← Arch/Manjaro
│ └─ x-ui.service.rhel ← 其他CentOS/Fedora 等)
├─ 如果 tar.gz 中没有,从 GitHub 下载对应文件
└─ 配置服务:
chown root:root x-ui.service
chmod 644 x-ui.service
systemctl daemon-reload
systemctl enable x-ui
systemctl start x-ui
显示安装完成信息和子命令用法
```
**子命令列表**(安装完成后显示):
| 命令 | 功能 |
|-------------------|--------------------|
| `x-ui` | 打开管理菜单 |
| `x-ui start` | 启动面板 |
| `x-ui stop` | 停止面板 |
| `x-ui restart` | 重启面板 |
| `x-ui status` | 查看状态 |
| `x-ui settings` | 查看当前设置 |
| `x-ui enable` | 设置开机自启 |
| `x-ui disable` | 取消开机自启 |
| `x-ui log` | 查看日志 |
| `x-ui banlog` | 查看 Fail2ban 日志 |
| `x-ui update` | 更新 |
| `x-ui legacy` | 安装旧版本 |
| `x-ui install` | 安装 |
| `x-ui uninstall` | 卸载 |
---
## 调用关系总结
```
install.sh
├─ install_base()
│ └─ 根据发行版安装 curl, tar, tzdata, socat, ca-certificates, openssl
└─ install_x-ui($1)
├─ 下载 x-ui 发行版和 x-ui.sh
├─ 解压、设置权限
├─ config_after_install()
│ ├─ gen_random_string() × 3用户名/密码/Web路径
│ ├─ 获取公网 IP
│ ├─ prompt_and_setup_ssl()
│ │ ├─ [选项1] ssl_cert_issue()
│ │ │ ├─ install_acme()
│ │ │ └─ acme.sh 签发/安装/续期域名证书
│ │ ├─ [选项2] setup_ip_certificate()
│ │ │ ├─ install_acme()
│ │ │ └─ acme.sh 签发/安装/续期 IP 短期证书
│ │ └─ [选项3] 用户提供自定义证书路径
│ └─ x-ui migrate
└─ 配置系统服务systemd 或 OpenRC
```
---
## 关键设计决策
1. **强制 SSL**:首次安装时必须配置 SSL 证书(三种方式选一),确保面板通过 HTTPS 访问。
2. **随机化安全**用户名、密码、端口、Web 路径全部随机生成,避免使用默认凭据。
3. **多 OS 兼容**:通过 `case` 语句适配 7 大包管理器体系Alpine 使用 OpenRC其余使用 systemd。
4. **IP 证书支持**:利用 Let's Encrypt 的 shortlived profile为无域名场景提供 SSL 支持6 天有效期,自动续期)。
5. **优雅降级**
- GitHub API 失败时用 `curl -4` 重试
- `ss` 不可用时回退到 `netstat`,再回退到 `lsof`
- tar.gz 中无服务文件时从 GitHub 下载
- acme.sh reloadcmd 失败不阻止证书安装
6. **etckeeper 兼容**:自动将数据库文件加入 `/etc/.gitignore`,避免 etckeeper 追踪频繁变化的数据库。

View file

@ -0,0 +1,927 @@
# Panel Settings JSON Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- []`) syntax for tracking.
**Goal:** Extract panel settings from the SQLite `settings` table into a standalone `x-ui.json` file, keeping `xrayTemplateConfig` in the database.
**Architecture:** Replace the database-backed `getSetting`/`saveSetting` in `SettingService` with JSON file read/write. All public `Get*`/`Set*` methods keep their signatures unchanged so controllers, CLI, and sub package need zero changes. `xrayTemplateConfig` gets dedicated DB helper methods to bypass the JSON path.
**Tech Stack:** Go, GORM/SQLite (retained for xrayTemplateConfig only), `encoding/json`, `os`
---
## File Map
| File | Action | Purpose |
|------|--------|---------|
| `config/config.go` | Modify | Add `GetSettingPath()` |
| `web/service/setting.go` | Modify | Replace DB-backed internals with JSON file I/O |
| `web/service/xray_setting.go` | Modify | Use direct DB helpers for xrayTemplateConfig |
| `web/service/setting_test.go` | Create | Unit tests for JSON settings |
No changes needed: `main.go`, `database/db.go`, `database/model/model.go`, `web/entity/entity.go`, any controller, `sub/`, `xray/`.
---
### Task 1: Add `GetSettingPath()` to `config/config.go`
**Files:**
- Modify: `config/config.go:100`
- [ ] **Step 1: Add `GetSettingPath()` function**
Add after the existing `GetDBPath()` function at line 101:
```go
// GetSettingPath returns the full path to the panel settings JSON file.
func GetSettingPath() string {
return fmt.Sprintf("%s/%s.json", GetDBFolderPath(), GetName())
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./config/`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add config/config.go
git commit -m "feat(config): add GetSettingPath for JSON settings file"
```
---
### Task 2: Add JSON file I/O helpers to `web/service/setting.go`
**Files:**
- Modify: `web/service/setting.go`
- [ ] **Step 1: Add imports**
Add `"os"` and `"github.com/mhsanaei/3x-ui/v2/config"` to the import block. The existing imports `"github.com/mhsanaei/3x-ui/v2/database"` and `"github.com/mhsanaei/3x-ui/v2/database/model"` will be kept for now (removed later when `getSetting`/`saveSetting` are replaced and `GetAllSetting` no longer queries DB).
The import block becomes:
```go
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/util/random"
"github.com/mhsanaei/3x-ui/v2/util/reflect_util"
"github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/mhsanaei/3x-ui/v2/xray"
)
```
- [ ] **Step 2: Add `loadSettings()` and `saveSettings()` functions**
Add these package-level functions before the `SettingService` struct (after `defaultValueMap`, around line 106):
```go
// loadSettings reads the JSON settings file into a map.
// If the file doesn't exist, it creates one from defaultValueMap (excluding xrayTemplateConfig).
func loadSettings() (map[string]string, error) {
path := config.GetSettingPath()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
settings := make(map[string]string)
for k, v := range defaultValueMap {
if k == "xrayTemplateConfig" {
continue
}
settings[k] = v
}
return settings, saveSettings(settings)
}
if err != nil {
return nil, err
}
var settings map[string]string
if err := json.Unmarshal(data, &settings); err != nil {
return nil, fmt.Errorf("failed to parse settings file %s: %w", path, err)
}
return settings, nil
}
// saveSettings writes the settings map to the JSON file.
func saveSettings(settings map[string]string) error {
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(config.GetSettingPath(), data, 0644)
}
```
- [ ] **Step 3: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./web/service/`
Expected: no errors (existing code still compiles with old + new functions coexisting)
- [ ] **Step 4: Commit**
```bash
git add web/service/setting.go
git commit -m "feat(service): add JSON file I/O helpers for settings"
```
---
### Task 3: Replace `getSetting`/`saveSetting` with JSON-based implementations
**Files:**
- Modify: `web/service/setting.go:205-229`
- [ ] **Step 1: Replace `getSetting`**
Replace lines 205-213:
```go
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
db := database.GetDB()
setting := &model.Setting{}
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
if err != nil {
return nil, err
}
return setting, nil
}
```
With:
```go
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
settings, err := loadSettings()
if err != nil {
return nil, err
}
value, ok := settings[key]
if !ok {
return nil, fmt.Errorf("setting key %q not found", key)
}
return &model.Setting{Key: key, Value: value}, nil
}
```
- [ ] **Step 2: Replace `saveSetting`**
Replace lines 215-229:
```go
func (s *SettingService) saveSetting(key string, value string) error {
setting, err := s.getSetting(key)
db := database.GetDB()
if database.IsNotFound(err) {
return db.Create(&model.Setting{
Key: key,
Value: value,
}).Error
} else if err != nil {
return err
}
setting.Key = key
setting.Value = value
return db.Save(setting).Error
}
```
With:
```go
func (s *SettingService) saveSetting(key string, value string) error {
settings, err := loadSettings()
if err != nil {
return err
}
settings[key] = value
return saveSettings(settings)
}
```
- [ ] **Step 3: Replace `getString` to use JSON directly**
Replace lines 231-243:
```go
func (s *SettingService) getString(key string) (string, error) {
setting, err := s.getSetting(key)
if database.IsNotFound(err) {
value, ok := defaultValueMap[key]
if !ok {
return "", common.NewErrorf("key <%v> not in defaultValueMap", key)
}
return value, nil
} else if err != nil {
return "", err
}
return setting.Value, nil
}
```
With:
```go
func (s *SettingService) getString(key string) (string, error) {
settings, err := loadSettings()
if err != nil {
return "", err
}
value, ok := settings[key]
if !ok {
defaultValue, hasDefault := defaultValueMap[key]
if !hasDefault {
return "", common.NewErrorf("key <%v> not in defaultValueMap", key)
}
return defaultValue, nil
}
return value, nil
}
```
- [ ] **Step 4: Replace `ResetSettings`**
Replace lines 195-203:
```go
func (s *SettingService) ResetSettings() error {
db := database.GetDB()
err := db.Where("1 = 1").Delete(model.Setting{}).Error
if err != nil {
return err
}
return db.Model(model.User{}).
Where("1 = 1").Error
}
```
With:
```go
func (s *SettingService) ResetSettings() error {
// Delete the JSON settings file
err := os.Remove(config.GetSettingPath())
if err != nil && !os.IsNotExist(err) {
return err
}
// Clear users table
db := database.GetDB()
return db.Where("1 = 1").Delete(model.User{}).Error
}
```
- [ ] **Step 5: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./web/service/`
Expected: no errors
- [ ] **Step 6: Commit**
```bash
git add web/service/setting.go
git commit -m "feat(service): replace DB-backed settings with JSON file operations"
```
---
### Task 4: Update `GetAllSetting` and `UpdateAllSetting` to use JSON
**Files:**
- Modify: `web/service/setting.go:120-193, 691-710`
- [ ] **Step 1: Replace `GetAllSetting`**
Replace lines 120-193:
```go
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
db := database.GetDB()
settings := make([]*model.Setting, 0)
err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
if err != nil {
return nil, err
}
allSetting := &entity.AllSetting{}
t := reflect.TypeFor[entity.AllSetting]()
v := reflect.ValueOf(allSetting).Elem()
fields := reflect_util.GetFields(t)
setSetting := func(key, value string) (err error) {
defer func() {
panicErr := recover()
if panicErr != nil {
err = errors.New(fmt.Sprint(panicErr))
}
}()
var found bool
var field reflect.StructField
for _, f := range fields {
if f.Tag.Get("json") == key {
field = f
found = true
break
}
}
if !found {
// Some settings are automatically generated, no need to return to the front end to modify the user
return nil
}
fieldV := v.FieldByName(field.Name)
switch t := fieldV.Interface().(type) {
case int:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return err
}
fieldV.SetInt(n)
case string:
fieldV.SetString(value)
case bool:
fieldV.SetBool(value == "true")
default:
return common.NewErrorf("unknown field %v type %v", key, t)
}
return
}
keyMap := map[string]bool{}
for _, setting := range settings {
err := setSetting(setting.Key, setting.Value)
if err != nil {
return nil, err
}
keyMap[setting.Key] = true
}
for key, value := range defaultValueMap {
if keyMap[key] {
continue
}
err := setSetting(key, value)
if err != nil {
return nil, err
}
}
return allSetting, nil
}
```
With:
```go
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
settings, err := loadSettings()
if err != nil {
return nil, err
}
allSetting := &entity.AllSetting{}
t := reflect.TypeFor[entity.AllSetting]()
v := reflect.ValueOf(allSetting).Elem()
fields := reflect_util.GetFields(t)
setSetting := func(key, value string) (err error) {
defer func() {
panicErr := recover()
if panicErr != nil {
err = errors.New(fmt.Sprint(panicErr))
}
}()
var found bool
var field reflect.StructField
for _, f := range fields {
if f.Tag.Get("json") == key {
field = f
found = true
break
}
}
if !found {
return nil
}
fieldV := v.FieldByName(field.Name)
switch t := fieldV.Interface().(type) {
case int:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return err
}
fieldV.SetInt(n)
case string:
fieldV.SetString(value)
case bool:
fieldV.SetBool(value == "true")
default:
return common.NewErrorf("unknown field %v type %v", key, t)
}
return
}
keyMap := map[string]bool{}
for key, value := range settings {
err := setSetting(key, value)
if err != nil {
return nil, err
}
keyMap[key] = true
}
for key, value := range defaultValueMap {
if key == "xrayTemplateConfig" {
continue
}
if keyMap[key] {
continue
}
err := setSetting(key, value)
if err != nil {
return nil, err
}
}
return allSetting, nil
}
```
- [ ] **Step 2: Replace `UpdateAllSetting`**
Replace lines 691-710:
```go
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil {
return err
}
v := reflect.ValueOf(allSetting).Elem()
t := reflect.TypeFor[entity.AllSetting]()
fields := reflect_util.GetFields(t)
errs := make([]error, 0)
for _, field := range fields {
key := field.Tag.Get("json")
fieldV := v.FieldByName(field.Name)
value := fmt.Sprint(fieldV.Interface())
err := s.saveSetting(key, value)
if err != nil {
errs = append(errs, err)
}
}
return common.Combine(errs...)
}
```
With:
```go
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil {
return err
}
settings, err := loadSettings()
if err != nil {
return err
}
v := reflect.ValueOf(allSetting).Elem()
t := reflect.TypeFor[entity.AllSetting]()
fields := reflect_util.GetFields(t)
for _, field := range fields {
key := field.Tag.Get("json")
fieldV := v.FieldByName(field.Name)
settings[key] = fmt.Sprint(fieldV.Interface())
}
return saveSettings(settings)
}
```
- [ ] **Step 3: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./web/service/`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add web/service/setting.go
git commit -m "feat(service): migrate GetAllSetting/UpdateAllSetting to JSON"
```
---
### Task 5: Handle `xrayTemplateConfig` — dedicated DB accessors
**Files:**
- Modify: `web/service/setting.go:273-274`
- Modify: `web/service/xray_setting.go:17-21`
- [ ] **Step 1: Add dedicated DB accessor for xrayTemplateConfig**
Add a new private function in `setting.go` (after the `saveSettings` function):
```go
// getXrayTemplateConfigFromDB reads xrayTemplateConfig directly from the database.
func getXrayTemplateConfigFromDB() (string, error) {
db := database.GetDB()
setting := &model.Setting{}
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(setting).Error
if err != nil {
return "", err
}
return setting.Value, nil
}
// saveXrayTemplateConfigToDB writes xrayTemplateConfig directly to the database.
func saveXrayTemplateConfigToDB(value string) error {
db := database.GetDB()
setting := &model.Setting{}
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(setting).Error
if database.IsNotFound(err) {
return db.Create(&model.Setting{
Key: "xrayTemplateConfig",
Value: value,
}).Error
}
if err != nil {
return err
}
setting.Value = value
return db.Save(setting).Error
}
```
- [ ] **Step 2: Update `GetXrayConfigTemplate` to use DB directly**
Replace line 273-274:
```go
func (s *SettingService) GetXrayConfigTemplate() (string, error) {
return s.getString("xrayTemplateConfig")
}
```
With:
```go
func (s *SettingService) GetXrayConfigTemplate() (string, error) {
config, err := getXrayTemplateConfigFromDB()
if err != nil {
// If not in DB, return the embedded default
return xrayTemplateConfig, nil
}
return config, nil
}
```
- [ ] **Step 3: Update `XraySettingService.SaveXraySetting` to use DB directly**
Replace line 17-21 in `xray_setting.go`:
```go
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
if err := s.CheckXrayConfig(newXraySettings); err != nil {
return err
}
return s.SettingService.saveSetting("xrayTemplateConfig", newXraySettings)
}
```
With:
```go
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
if err := s.CheckXrayConfig(newXraySettings); err != nil {
return err
}
return saveXrayTemplateConfigToDB(newXraySettings)
}
```
- [ ] **Step 4: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./web/service/`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add web/service/setting.go web/service/xray_setting.go
git commit -m "feat(service): use direct DB access for xrayTemplateConfig"
```
---
### Task 6: Clean up unused imports
**Files:**
- Modify: `web/service/setting.go`
- [ ] **Step 1: Remove `database` and `model` imports if no longer needed**
Check if `database` and `model` packages are still referenced in `setting.go` after all changes. `database` is still used by `ResetSettings()` (for `database.GetDB()` to clear users table). `model` is no longer needed in `setting.go` since `getSetting`/`saveSetting` no longer use `model.Setting`, and `ResetSettings` uses `model.User` which... actually check: `ResetSettings` references `model.User{}`.
So `database` and `model` are still needed in `setting.go` for:
- `ResetSettings()``database.GetDB()` + `model.User{}`
- `getXrayTemplateConfigFromDB()` / `saveXrayTemplateConfigToDB()``database` + `model.Setting{}`
No import cleanup needed. Skip this step.
- [ ] **Step 2: Verify full build**
Run: `cd /usr/x-ui/3x-ui && go build ./...`
Expected: no errors
- [ ] **Step 3: Commit (only if changes were made)**
```bash
git add web/service/setting.go
git commit -m "chore(service): clean up unused imports"
```
---
### Task 7: Write unit tests
**Files:**
- Create: `web/service/setting_test.go`
- [ ] **Step 1: Write tests for JSON settings**
```go
package service
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v2/config"
)
func setupTestSettings(t *testing.T) func() {
t.Helper()
tmpDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", tmpDir)
return func() {}
}
func TestLoadSettingsCreatesDefaults(t *testing.T) {
setupTestSettings(t)
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings() error: %v", err)
}
// Should contain default values
if settings["webPort"] != "2053" {
t.Errorf("expected webPort=2053, got %s", settings["webPort"])
}
if settings["webBasePath"] != "/" {
t.Errorf("expected webBasePath=/, got %s", settings["webBasePath"])
}
// Should NOT contain xrayTemplateConfig
if _, exists := settings["xrayTemplateConfig"]; exists {
t.Error("xrayTemplateConfig should not be in JSON settings")
}
// File should exist on disk
path := config.GetSettingPath()
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("settings file %s should have been created", path)
}
}
func TestSaveAndLoadSettings(t *testing.T) {
setupTestSettings(t)
settings := map[string]string{
"webPort": "8080",
"webListen": "0.0.0.0",
}
err := saveSettings(settings)
if err != nil {
t.Fatalf("saveSettings() error: %v", err)
}
loaded, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings() error: %v", err)
}
if loaded["webPort"] != "8080" {
t.Errorf("expected webPort=8080, got %s", loaded["webPort"])
}
if loaded["webListen"] != "0.0.0.0" {
t.Errorf("expected webListen=0.0.0.0, got %s", loaded["webListen"])
}
}
func TestSettingServiceGetString(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
// Should return default value when key not set
val, err := svc.getString("webPort")
if err != nil {
t.Fatalf("getString error: %v", err)
}
if val != "2053" {
t.Errorf("expected 2053, got %s", val)
}
}
func TestSettingServiceSetAndGetString(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
err := svc.setString("webPort", "9090")
if err != nil {
t.Fatalf("setString error: %v", err)
}
val, err := svc.getString("webPort")
if err != nil {
t.Fatalf("getString error: %v", err)
}
if val != "9090" {
t.Errorf("expected 9090, got %s", val)
}
}
func TestResetSettingsDeletesFile(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
// Create settings first
_, err := svc.getString("webPort")
if err != nil {
t.Fatalf("getString error: %v", err)
}
path := config.GetSettingPath()
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("settings file should exist before reset")
}
// Note: ResetSettings also needs DB for users table.
// For this unit test, we just verify the JSON file deletion part works.
// Full integration test would need a test DB.
err = os.Remove(path)
if err != nil {
t.Fatalf("remove error: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("settings file should not exist after reset")
}
// Re-loading should recreate defaults
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings after reset error: %v", err)
}
if settings["webPort"] != "2053" {
t.Errorf("expected default webPort=2053 after reset, got %s", settings["webPort"])
}
}
func TestSettingsFileFormat(t *testing.T) {
setupTestSettings(t)
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings error: %v", err)
}
path := config.GetSettingPath()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
// Verify it's valid JSON
var parsed map[string]string
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("settings file is not valid JSON: %v", err)
}
// Verify pretty-printed (has newlines)
if !contains(data, '\n') {
t.Error("settings file should be pretty-printed with newlines")
}
// Verify key count matches
if len(parsed) != len(settings) {
t.Errorf("parsed key count %d != loaded key count %d", len(parsed), len(settings))
}
_ = filepath.Base(path) // just to use the import
}
func contains(data []byte, b byte) bool {
for _, d := range data {
if d == b {
return true
}
}
return false
}
```
- [ ] **Step 2: Run tests**
Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestLoadSettings -v`
Expected: PASS
Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestSaveAndLoad -v`
Expected: PASS
Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestSettingService -v`
Expected: PASS
Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestReset -v`
Expected: PASS
Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestSettingsFile -v`
Expected: PASS
- [ ] **Step 3: Run all tests**
Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -v`
Expected: all PASS
- [ ] **Step 4: Commit**
```bash
git add web/service/setting_test.go
git commit -m "test(service): add unit tests for JSON settings"
```
---
### Task 8: Full build verification
- [ ] **Step 1: Build entire project**
Run: `cd /usr/x-ui/3x-ui && go build ./...`
Expected: no errors
- [ ] **Step 2: Run `go vet`**
Run: `cd /usr/x-ui/3x-ui && go vet ./...`
Expected: no issues
- [ ] **Step 3: Final commit (only if fixes needed)**
```bash
git add -A
git commit -m "chore: fix build issues from settings migration"
```
---
## Self-Review
**1. Spec coverage:**
- Panel settings in flat key-value JSON: Tasks 2-4
- xrayTemplateConfig stays in DB: Task 5
- All new installations (no migration): Task 2 Step 1 (auto-create from defaults)
- JSON file path: Task 1 (`GetSettingPath`)
- JSON auto-created on first run: Task 2 Step 1 (`loadSettings`)
- CLI compatibility: No changes to main.go, works via unchanged `SettingService` API
- Tests: Task 7
**2. Placeholder scan:** No TBD/TODO found. All code blocks contain complete implementations.
**3. Type consistency:**
- `getSetting` still returns `(*model.Setting, error)` — reused by `getString` which checks `database.IsNotFound(err)`. After the change, `getSetting` returns a custom error when key not found (not `gorm.ErrRecordNotFound`). Need to verify: `getString` checks `database.IsNotFound(err)` which tests for `gorm.ErrRecordNotFound`. The new `getSetting` returns `fmt.Errorf(...)` which is NOT a gorm error. This means `getString` would NOT fall through to the default — it would return the error instead.
**FIX:** `getString` must not rely on `database.IsNotFound`. The rewritten `getString` in Task 3 Step 3 already handles this correctly — it reads the map directly and checks `ok`, no longer calling `getSetting` or checking `database.IsNotFound`. Good.

View file

@ -0,0 +1,193 @@
# Pre-release Install/Update Selection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users choose between the latest Stable or Pre-release when installing or updating 3x-ui.
**Architecture:** Replace the hardcoded `/releases/latest` API call with a `/releases` call that parses both stable and pre-release tags. Add an interactive prompt in both `install.sh` and `update.sh` so users pick which version to install. Functions are duplicated across files (matching existing conventions — no shared library).
**Tech Stack:** Bash, GitHub REST API, grep/sed/awk for JSON parsing (no jq).
---
### Task 1: Add `get_releases` helper + prompt to `install.sh`
**Files:**
- Modify: `install.sh:874-911`
- [ ] **Step 1: Add `get_releases` function before `install_x-ui()`**
Insert this function before `install_x-ui()` (around line 874). It fetches all releases and parses out the latest stable and pre-release tags:
```bash
get_releases() {
local releases_json
releases_json=$(curl -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases")
if [[ -z "$releases_json" ]]; then
echo -e "${yellow}正在尝试通过 IPv4 获取版本...${plain}"
releases_json=$(curl -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases")
if [[ -z "$releases_json" ]]; then
echo -e "${red}获取 x-ui 版本失败,可能是 GitHub API 限制,请稍后重试${plain}"
exit 1
fi
fi
# Parse first non-prerelease tag_name
latest_stable=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":false' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
# Parse first prerelease tag_name
latest_prerelease=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":true' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ -z "$latest_stable" && -z "$latest_prerelease" ]]; then
echo -e "${red}获取 x-ui 版本失败${plain}"
exit 1
fi
}
select_version() {
if [[ -n "$latest_stable" && -n "$latest_prerelease" ]]; then
echo ""
echo -e "${green}请选择要安装的版本:${plain}"
echo -e "${green}1)${plain} 最新稳定版: ${latest_stable}"
echo -e "${green}2)${plain} 最新预发布版: ${latest_prerelease}"
read -rp "请输入选择 [1-2]: " version_choice
while [[ "$version_choice" != "1" && "$version_choice" != "2" ]]; do
read -rp "无效输入,请重新输入 [1-2]: " version_choice
done
if [[ "$version_choice" == "1" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
elif [[ -n "$latest_stable" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
}
```
- [ ] **Step 2: Replace the no-argument release fetch block in `install_x-ui()`**
Replace lines 879-888:
```bash
tag_version=$(curl -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ ! -n "$tag_version" ]]; then
echo -e "${yellow}正在尝试通过 IPv4 获取版本...${plain}"
tag_version=$(curl -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ ! -n "$tag_version" ]]; then
echo -e "${red}获取 x-ui 版本失败,可能是 GitHub API 限制,请稍后重试${plain}"
exit 1
fi
fi
echo -e "获取到 x-ui 最新版本:${tag_version},开始安装..."
```
With:
```bash
get_releases
select_version
echo -e "获取到 x-ui 版本:${tag_version},开始安装..."
```
- [ ] **Step 3: Verify the script still works for the specific-version path**
Read the full `install_x-ui()` function and confirm the `else` branch (lines 894-911, where `$1` is provided) is untouched.
- [ ] **Step 4: Commit**
```bash
git add install.sh
git commit -m "feat(install): add pre-release version selection prompt"
```
### Task 2: Add `get_releases` helper + prompt to `update.sh`
**Files:**
- Modify: `update.sh:748-767`
- [ ] **Step 1: Add `get_releases` and `select_version` functions before `update_x-ui()`**
Insert the same two functions before `update_x-ui()` (around line 748). Identical logic to install.sh except the prompt text says "更新" (update) instead of "安装" (install):
```bash
get_releases() {
local releases_json
releases_json=$(${curl_bin} -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases" 2>/dev/null)
if [[ -z "$releases_json" ]]; then
echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
releases_json=$(${curl_bin} -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases" 2>/dev/null)
if [[ -z "$releases_json" ]]; then
_fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
fi
fi
latest_stable=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":false' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
latest_prerelease=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":true' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ -z "$latest_stable" && -z "$latest_prerelease" ]]; then
_fail "ERROR: Failed to fetch x-ui version"
fi
}
select_version() {
if [[ -n "$latest_stable" && -n "$latest_prerelease" ]]; then
echo ""
echo -e "${green}Which version do you want to update to?${plain}"
echo -e "${green}1)${plain} Latest Stable: ${latest_stable}"
echo -e "${green}2)${plain} Latest Pre-release: ${latest_prerelease}"
read -rp "Please enter your choice [1-2]: " version_choice
while [[ "$version_choice" != "1" && "$version_choice" != "2" ]]; do
read -rp "Invalid input, please re-enter [1-2]: " version_choice
done
if [[ "$version_choice" == "1" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
elif [[ -n "$latest_stable" ]]; then
tag_version="$latest_stable"
else
tag_version="$latest_prerelease"
fi
}
```
Note: `update.sh` uses `${curl_bin}` instead of bare `curl` — the helper respects this.
- [ ] **Step 2: Replace the release fetch block in `update_x-ui()`**
Replace lines 760-768:
```bash
tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ ! -n "$tag_version" ]]; then
echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ ! -n "$tag_version" ]]; then
_fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
fi
fi
echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
```
With:
```bash
get_releases
select_version
echo -e "Got x-ui version: ${tag_version}, beginning the installation..."
```
- [ ] **Step 3: Verify the rest of `update_x-ui()` is unchanged**
Confirm lines 769+ (download, cleanup, install) remain intact.
- [ ] **Step 4: Commit**
```bash
git add update.sh
git commit -m "feat(update): add pre-release version selection prompt"
```

View file

@ -0,0 +1,134 @@
# Panel Settings JSON Migration Design
## Overview
Extract panel settings from the SQLite `settings` table into a standalone JSON file (`x-ui.json`) located in the same directory as the database (`/etc/x-ui/` by default). The `xrayTemplateConfig` remains in the database.
## Requirements
- Panel settings (webPort, tgBot*, sub*, ldap*, etc.) stored in a flat key-value JSON file
- `xrayTemplateConfig` stays in the database `settings` table
- All new installations (no migration from existing DB)
- JSON file path: `<DB_FOLDER>/x-ui.json` (same directory as `x-ui.db`)
- JSON file auto-created on first run with default values
## Architecture
### File Layout
```
/etc/x-ui/
x-ui.db # SQLite: users, inbounds, client_traffics, xrayTemplateConfig
x-ui.json # Panel settings (flat key-value JSON)
```
### JSON Format
```json
{
"webListen": "",
"webPort": "2053",
"webCertFile": "",
"webKeyFile": "",
"secret": "random32chars...",
"webBasePath": "/",
"sessionMaxAge": "360",
"tgBotEnable": "false",
"tgBotToken": "",
"subEnable": "true",
"ldapEnable": "false",
...
}
```
All values are strings (consistent with current DB storage). No `xrayTemplateConfig` key.
## Changes
### 1. `config/config.go`
Add `GetSettingPath()` function:
```go
func GetSettingPath() string {
return fmt.Sprintf("%s/%s.json", GetDBFolderPath(), GetName())
}
```
### 2. `web/service/setting.go`
Replace database-backed `getSetting`/`saveSetting` with JSON file operations:
- **`loadSettings()`** — reads JSON file into `map[string]string`; creates file from `defaultValueMap` if not exists
- **`saveSettings(settings)`** — writes `map[string]string` to JSON file
- **`getSetting(key)`** → read from JSON map
- **`saveSetting(key, value)`** → update key in JSON map, write back
- **`getString(key)`** → `getSetting(key)` with fallback to `defaultValueMap`
- **`GetAllSetting()`** → load JSON map, populate `AllSetting` struct via reflection (same as current, data source changes)
- **`UpdateAllSetting()`** → reflect fields into map, save to JSON
- **`ResetSettings()`** → delete JSON file + clear users table
Remove `import "github.com/mhsanaei/3x-ui/v2/database"` and `model` imports (no longer needed for settings operations).
### 3. `web/service/xray_setting.go`
`XraySettingService.SaveXraySetting()` and related methods continue using the database directly for `xrayTemplateConfig`:
- Replace `s.SettingService.saveSetting("xrayTemplateConfig", ...)` with direct DB operation via `database.GetDB()`
- Add a private helper `saveXraySettingToDB()` / `getXraySettingFromDB()` for direct DB access
### 4. `database/db.go`
Keep `model.Setting{}` in `initModels()` — the `settings` table still stores `xrayTemplateConfig`.
### 5. `main.go`
No changes needed. CLI commands use `SettingService` which handles JSON internally.
The only change: `resetSetting()` calls `settingService.ResetSettings()` which now deletes the JSON file instead of DB rows. The `users` table clearing logic is preserved.
## Data Flow
### Reading
```
Controller/CLI → SettingService.GetString("webPort")
→ loadSettings() [reads x-ui.json]
→ returns "2053" (or default if missing)
```
### Writing
```
Controller/CLI → SettingService.SetPort(8080)
→ setInt("webPort", 8080)
→ setString("webPort", "8080")
→ saveSetting("webPort", "8080")
→ loadSettings() → update map["webPort"] = "8080" → saveSettings()
```
### Xray Config (unchanged path)
```
XraySettingService.SaveXraySetting(config)
→ validate config
→ database.GetDB().Where("key = ?", "xrayTemplateConfig").Save(...)
```
## Error Handling
- JSON file read failure: return error (panel cannot start without settings)
- JSON file write failure: return error (settings update fails, no silent data loss)
- JSON file not found: auto-create from defaults (first run)
- Malformed JSON: return error with clear message
- Concurrent access: Go's single-goroutine web server model means no concurrent write issues for settings
## Testing
- Verify first run creates `x-ui.json` with correct defaults
- Verify `GetAllSetting()` returns correct values from JSON
- Verify `UpdateAllSetting()` writes all fields to JSON
- Verify CLI `x-ui setting -port 8080` updates JSON file
- Verify CLI `x-ui setting -reset` deletes JSON file and recreates on next access
- Verify `xrayTemplateConfig` still works via database
- Verify `x-ui setting -show` reads from JSON file correctly

View file

@ -0,0 +1,79 @@
# Pre-release Install/Update Selection
## Summary
Add interactive prompts to `install.sh` and `update.sh` so users can choose between the latest **Stable** release or the latest **Pre-release** when installing or updating 3x-ui.
## Current State
- `install.sh` and `update.sh` both hardcode `GET /repos/Sora39831/3x-ui/releases/latest`, which only returns stable releases.
- No mechanism exists to install or update to a pre-release version through the automated flow.
## Design
### 1. GitHub API Fetch Helper
A shared function (duplicated in both `install.sh` and `update.sh`, matching existing script conventions) that:
- Calls `GET https://api.github.com/repos/Sora39831/3x-ui/releases` (returns all releases)
- Parses the JSON response to extract:
- `latest_stable_tag` — first entry with `"prerelease": false`
- `latest_prerelease_tag` — first entry with `"prerelease": true` (empty if none exists)
- Uses `grep`/`sed`/`awk` (no `jq` dependency, consistent with existing parsing patterns)
- Falls back to `curl -4` on IPv6 failure, matching existing retry pattern
### 2. Interactive Prompt
Both scripts display a menu after fetching release info:
```
Which version do you want to install/update?
1) Latest Stable: v2.x.x
2) Latest Pre-release: v2.x.x-beta
Please enter your choice [1-2]:
```
Behavior:
- Show actual version tags so the user knows what they're selecting
- If no pre-release exists: skip prompt, use stable automatically
- If no stable release exists (edge case): skip prompt, use pre-release automatically
- Invalid input re-prompts
### 3. install.sh Changes
In `install_x-ui()`, the no-argument path (line ~879):
**Before:** Calls `/releases/latest`, parses single tag, downloads.
**After:**
1. Call fetch helper to get both tags
2. Show interactive prompt
3. Set `tag_version` from user choice
4. Download as before (existing logic unchanged)
The specific-version path (`$1` argument) is unchanged.
### 4. update.sh Changes
In `update_x-ui()`, same pattern:
**Before:** Calls `/releases/latest`, parses single tag, downloads.
**After:**
1. Call fetch helper to get both tags
2. Show interactive prompt
3. Set `tag_version` from user choice
4. Continue existing update logic (unchanged)
`x-ui.sh` is **not modified** — it delegates to `update.sh` already.
## Files Modified
- `install.sh` — add fetch helper + prompt in `install_x-ui()`
- `update.sh` — add fetch helper + prompt in `update_x-ui()`
## Out of Scope
- Persisting user's choice across updates (always prompt each time)
- CLI flags like `--pre-release` for non-interactive use
- Changes to `x-ui.sh` (delegation is already in place)

View file

@ -0,0 +1,444 @@
# 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 |

937
docs/x-ui-logic.md Normal file
View file

@ -0,0 +1,937 @@
# x-ui.sh 逻辑文档
## 概述
`x-ui.sh` 是 3x-ui 面板的管理脚本,提供 26 个交互式菜单选项和 15 个子命令涵盖面板的安装、更新、卸载、凭据管理、服务控制、SSL 证书、防火墙、Fail2ban IP 限制、BBR 加速、Geo 文件更新等功能。
---
## 全局配置
### 颜色变量
| 变量 | 值 | 用途 |
|---------|----------------|----------|
| `red` | `\033[0;31m` | 红色 |
| `green` | `\033[0;32m` | 绿色 |
| `blue` | `\033[0;34m` | 蓝色 |
| `yellow`| `\033[0;33m` | 黄色 |
| `plain` | `\033[0m` | 重置 |
### 日志函数
| 函数 | 前缀 | 用途 |
|---------|-----------|------------|
| `LOGD()` | `[调试]` | 调试信息 |
| `LOGE()` | `[错误]` | 错误信息 |
| `LOGI()` | `[信息]` | 普通信息 |
### 路径变量
| 变量 | 默认值 | 说明 |
|-------------------------|---------------------------|-------------------------|
| `xui_folder` | `/usr/local/x-ui` | x-ui 安装目录 |
| `xui_service` | `/etc/systemd/system` | systemd 服务文件目录 |
| `log_folder` | `/var/log/x-ui` | 日志目录 |
| `iplimit_log_path` | `.../3xipl.log` | IP 限制日志 |
| `iplimit_banned_log_path`| `.../3xipl-banned.log` | IP 封禁日志 |
### 辅助函数
| 函数 | 功能 |
|-----------------------|----------------------------------------------|
| `confirm()` | 通用确认提示,支持自定义默认值 |
| `confirm_restart()` | 确认后重启面板(重启 x-ui 也会重启 xray |
| `before_show_menu()` | 按回车返回主菜单 |
| `gen_random_string()` | 通过 openssl 生成指定长度的随机字母数字字符串 |
| `is_port_in_use()` | 端口占用检测ss → netstat → lsof |
| `is_ipv4/is_ipv6/is_ip/is_domain()` | IP/域名格式验证 |
---
## 入口流程
```
x-ui.sh 被执行
├─ 检查 root 权限
├─ 检测操作系统发行版和版本号
├─ 初始化路径和日志目录
├─ 有命令行参数 → 执行对应子命令(不显示菜单)
└─ 无参数 → 显示交互式菜单 show_menu()
├─ 显示当前状态(运行/停止/未安装 + 开机自启 + xray 状态)
├─ 读取用户输入 [0-26]
└─ 根据选择调用对应功能
```
---
## 主菜单 (show_menu)
```
╔────────────────────────────────────────────────╗
│ 0. 退出脚本 │
│────────────────────────────────────────────────│
│ 1. 安装 2. 更新 3. 更新菜单 │
│ 4. 安装旧版本 5. 卸载 │
│────────────────────────────────────────────────│
│ 6. 重置用户名和密码 7. 重置 Web 路径 │
│ 8. 重置设置 9. 修改端口 │
│ 10. 查看当前设置 │
│────────────────────────────────────────────────│
│ 11. 启动 12. 停止 13. 重启 │
│ 14. 重启 Xray 15. 查看状态 │
│ 16. 日志管理 │
│────────────────────────────────────────────────│
│ 17. 设置开机自启 18. 取消开机自启 │
│────────────────────────────────────────────────│
│ 19. SSL 证书管理 20. Cloudflare SSL │
│ 21. IP 限制管理 22. 防火墙管理 │
│ 23. SSH 端口转发管理 │
│────────────────────────────────────────────────│
│ 24. BBR 管理 25. 更新 Geo 文件 │
│ 26. 网速测试 (Speedtest) │
╚────────────────────────────────────────────────╝
```
大部分选项在执行前调用 `check_install`(检查面板是否已安装)或 `check_uninstall`(检查面板是否未安装),防止误操作。
---
## 状态检测函数
| 函数 | 返回值 | 逻辑 |
|------------------------|---------------------------|-------------------------------------------|
| `check_status()` | 0=运行中, 1=未运行, 2=未安装 | Alpine 检查 init.d其他检查 systemd |
| `check_enabled()` | 0=已启用, 1=未启用 | Alpine 检查 rc-update其他检查 systemctl |
| `check_xray_status()` | 0=运行中, 1=未运行 | ps 查找 xray-linux 进程 |
| `check_install()` | 前置检查 | 未安装则提示并返回菜单 |
| `check_uninstall()` | 前置检查 | 已安装则提示"勿重复安装"并返回菜单 |
---
## 菜单选项详解
### 选项 0退出脚本
```bash
exit 0
```
直接退出,无额外逻辑。
---
### 选项 1安装
**函数**`install()`
```
下载并执行 install.sh从 GitHub raw 文件)
└─ 成功后自动调用 start()
```
- 执行 `bash <(curl -Ls https://raw.githubusercontent.com/Sora39831/3x-ui/main/install.sh)`
- 安装成功后自动启动面板
---
### 选项 2更新
**函数**`update()`
```
确认提示:"更新所有 x-ui 组件到最新版本,数据不会丢失"
├─ 取消 → 返回菜单
└─ 确认 → 执行 update.sh从 GitHub 下载)
└─ 成功 → "更新完成,面板已自动重启"
```
---
### 选项 3更新菜单
**函数**`update_menu()`
```
确认提示
└─ 确认 → 下载最新 x-ui.sh 到 /usr/bin/x-ui
└─ 成功 → "更新成功" 并 exit 0
```
仅更新管理脚本自身,不影响面板程序。
---
### 选项 4安装旧版本
**函数**`legacy_version()`
```
提示用户输入版本号(如 2.4.0
├─ 空 → 退出
└─ 有效 → 执行对应版本的 install.sh传入版本参数
```
- 下载指定 tag 的 install.sh`v$tag_version/install.sh`
- 传入参数 `v$tag_version` 进行安装
- install.sh 内部会验证版本 ≥ v2.3.5
---
### 选项 5卸载
**函数**`uninstall()`
```
确认:"卸载面板xray 也会被卸载!"(默认 n
├─ 取消 → 返回菜单
└─ 确认 →
Alpine: rc-service stop → rc-update del → rm init.d
其他: systemctl stop → disable → rm service → daemon-reload → reset-failed
删除 /etc/x-ui/ 和 ${xui_folder}/
显示重装命令
删除脚本自身trap SIGTERM → rm $0
```
---
### 选项 6重置用户名和密码
**函数**`reset_user()`
```
确认提示(默认 n
└─ 确认 →
输入用户名(默认随机 10 位)
输入密码(默认随机 18 位)
询问是否禁用双因素认证
├─ 是 → -resetTwoFactor true
└─ 否 → -resetTwoFactor false
应用设置x-ui setting -username ... -password ...
确认后重启面板
```
---
### 选项 7重置 Web 路径
**函数**`reset_webbasepath()`
```
确认提示
└─ 确认 → 生成随机 18 位字符串
应用x-ui setting -webBasePath ...
重启面板
```
---
### 选项 8重置设置
**函数**`reset_config()`
```
确认:"重置所有面板设置?账户数据不会丢失,用户名和密码不会改变"(默认 n
└─ 确认 → x-ui setting -reset
重启面板
```
仅重置面板配置,不影响账户数据库。
---
### 选项 9修改端口
**函数**`set_port()`
```
输入端口号 [1-65535]
├─ 空 → 取消
└─ 有效 → x-ui setting -port ${port}
确认后重启面板
```
---
### 选项 10查看当前设置
**函数**`check_config()`
```
获取面板设置x-ui setting -show true
获取公网 IPapi.ipify.org → 4.ident.me
检查是否有证书:
├─ 有证书 → 从证书路径提取域名,显示 https://域名:端口/路径
└─ 无证书 →
显示警告
询问是否为 IP 生成 SSL 证书
├─ 是 → 停止面板 → ssl_cert_issue_for_ip() → 启动面板
└─ 否 → 显示 http://IP:端口/路径,建议使用选项 19
```
---
### 选项 11启动
**函数**`start()`
```
检查当前状态
├─ 运行中 → "面板正在运行,无需重复启动"
└─ 未运行 →
Alpine: rc-service x-ui start
其他: systemctl start x-ui
等待 2 秒后再次检查状态
├─ 成功 → "x-ui 启动成功"
└─ 失败 → "面板启动失败,可能是因为启动时间超过两秒"
```
---
### 选项 12停止
**函数**`stop()`
```
检查当前状态
├─ 已停止 → "面板已停止,无需重复停止!"
└─ 运行中 →
Alpine: rc-service x-ui stop
其他: systemctl stop x-ui
等待 2 秒后检查状态
├─ 成功 → "x-ui 和 xray 已停止"
└─ 失败 → "面板停止失败"
```
---
### 选项 13重启
**函数**`restart()`
```
Alpine: rc-service x-ui restart
其他: systemctl restart x-ui
等待 2 秒后检查状态
├─ 成功 → "x-ui 和 xray 重启成功"
└─ 失败 → "面板重启失败"
```
---
### 选项 14重启 Xray
**函数**`restart_xray()`
```
systemctl reload x-ui ← 发送 reload 信号,不重启面板本身
"已发送重启信号,请查看日志确认"
等待 2 秒 → 显示 xray 运行状态
```
与选项 13 的区别:选项 13 重启整个 x-ui 服务,选项 14 仅重载 xray-core。
---
### 选项 15查看状态
**函数**`status()`
```
Alpine: rc-service x-ui status
其他: systemctl status x-ui -l
```
显示完整的 systemd/服务状态信息。
---
### 选项 16日志管理
**函数**`show_log()`
```
Alpine:
1. 调试日志 → grep 'x-ui[' /var/log/messages
0. 返回
其他 (systemd):
1. 调试日志 → journalctl -u x-ui -e --no-pager -f -p debug
2. 清除所有日志 → journalctl --rotate → --vacuum-time=1s → 重启面板
0. 返回
```
---
### 选项 17设置开机自启
**函数**`enable()`
```
Alpine: rc-update add x-ui default
其他: systemctl enable x-ui
```
---
### 选项 18取消开机自启
**函数**`disable()`
```
Alpine: rc-update del x-ui
其他: systemctl disable x-ui
```
---
### 选项 19SSL 证书管理
**函数**`ssl_cert_issue_main()` — 子菜单入口
#### 子菜单
```
1. 获取 SSL域名
2. 吊销证书
3. 强制续期
4. 查看已有域名
5. 为面板设置证书路径
6. 为 IP 地址获取 SSL6 天证书,自动续期)
0. 返回主菜单
```
#### 子选项 1获取 SSL域名证书
**函数**`ssl_cert_issue()`
```
检查/安装 acme.sh
按发行版安装 socat
获取并验证域名(循环直到有效)
检查是否已有该域名的证书acme.sh --list
创建证书目录 /root/cert/${domain}/
选择端口(默认 80
签发证书:
acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
↳ 失败 → 清理并退出
设置 reloadcmd
默认x-ui restart
可选systemctl reload nginx ; x-ui restart
可选:自定义命令
安装证书:
acme.sh --installcert
--key-file /root/cert/${domain}/privkey.pem
--fullchain-file /root/cert/${domain}/fullchain.pem
--reloadcmd ${reloadCmd}
启用自动续期acme.sh --upgrade --auto-upgrade
设置文件权限privkey.pem → 600, fullchain.pem → 644
询问是否为面板设置证书:
├─ 是 → x-ui cert -webCert ... -webCertKey ... → 重启
└─ 否 → 跳过
```
#### 子选项 2吊销证书
```
列出 /root/cert/ 下所有域名目录
选择域名 → acme.sh --revoke -d ${domain}
```
#### 子选项 3强制续期
```
列出所有域名
选择域名 → acme.sh --renew -d ${domain} --force
```
#### 子选项 4查看已有域名
```
遍历 /root/cert/ 下的域名目录
显示每个域名的 fullchain.pem 和 privkey.pem 路径
缺失文件的标记为"证书或密钥缺失"
```
#### 子选项 5为面板设置证书路径
```
列出所有域名
选择域名 → 验证文件存在
x-ui cert -webCert ... -webCertKey ...
重启面板
```
#### 子选项 6为 IP 地址获取 SSL
**函数**`ssl_cert_issue_for_ip()`
```
获取服务器公网 IPapi.ipify.org → 4.ident.me
询问是否包含 IPv6 地址
检查/安装 acme.sh
按发行版安装 socat
创建证书目录 /root/cert/ip/
构建域名参数:-d ${server_ip} [-d ${ipv6}]
选择 HTTP-01 监听端口(默认 80
└─ 循环检测端口占用,被占用则提示换端口
签发证书:
acme.sh --issue
-d ${server_ip} [-d ${ipv6}]
--standalone --server letsencrypt
--certificate-profile shortlived
--days 6 --httpport ${WebPort} --force
安装证书(不依赖退出码,通过检查文件判断成功)
启用自动续期
设置文件权限
为面板设置证书路径 → 显示 https://IP:端口/路径 → 重启面板
```
---
### 选项 20Cloudflare SSL 证书
**函数**`ssl_cert_issue_CF()`
```
显示使用说明(需要:邮箱、全局 API 密钥、域名)
确认提示
检查/安装 acme.sh
输入域名 (CF_Domain)
输入 API 密钥 (CF_GlobalKey)
输入注册邮箱 (CF_AccountEmail)
设置 CA 为 Let's Encrypt
导出环境变量CF_Key, CF_Email
签发通配符证书:
acme.sh --issue --dns dns_cf -d ${domain} -d *.${domain} --force
↳ 使用 Cloudflare DNS 验证
创建证书目录 /root/cert/${domain}/
设置 reloadcmd同域名证书流程
安装证书(含 *.${domain} 通配符)
启用自动续期
询问是否为面板设置证书 → 同域名证书流程
```
**特点**:支持通配符证书 `*.domain.com`,不需要开放 80 端口(使用 DNS 验证)。
---
### 选项 21IP 限制管理Fail2ban
**函数**`iplimit_main()` — 子菜单入口
#### 子菜单
```
1. 安装 Fail2ban 并配置 IP 限制
2. 修改封禁时长
3. 解封所有人
4. 封禁日志
5. 封禁指定 IP 地址
6. 解封指定 IP 地址
7. 实时日志
8. 服务状态
9. 重启服务
10. 卸载 Fail2ban 和 IP 限制
0. 返回主菜单
```
#### 子选项 1安装 Fail2ban
**函数**`install_iplimit()`
```
检查 Fail2ban 是否已安装
└─ 未安装 → 按发行版安装:
Ubuntu ≥ 24: 额外安装 python3-pip + pyasynchat
Debian ≥ 12: 额外安装 python3-systemd
CentOS 7: 先装 epel-release
清除 jail 配置冲突iplimit_remove_conflicts
创建日志文件3xipl.log, 3xipl-banned.log
创建 jail 配置create_iplimit_jails
启动并启用 Fail2ban 服务
```
**Jail 配置详情** (`create_iplimit_jails`)
```ini
# /etc/fail2ban/jail.d/3x-ipl.conf
[3x-ipl]
enabled=true
backend=auto
filter=3x-ipl
action=3x-ipl
logpath=/var/log/x-ui/3xipl.log
maxretry=2
findtime=32
bantime=30m # 默认 30 分钟,可通过子选项 2 修改
```
**过滤器**:匹配 `[LIMIT_IP] Email=... || Disconnecting OLD IP=... || Timestamp=...` 格式的日志行。
**动作**:使用 iptables 封禁/解封 IP同时写入封禁日志文件。
#### 子选项 2修改封禁时长
```
输入新的封禁时长(分钟)
重新生成 jail 配置 → 重启 Fail2ban
```
#### 子选项 3解封所有人
```
fail2ban-client reload --restart --unban 3x-ipl
清空封禁日志文件
```
#### 子选项 5/6手动封禁/解封 IP
```
输入 IP 地址 → 正则验证IPv4/IPv6
fail2ban-client set 3x-ipl banip/unbanip "$ip"
```
#### 子选项 10卸载
```
选项 1仅移除 IP 限制配置(保留 Fail2ban
删除 filter.d/3x-ipl.conf, action.d/3x-ipl.conf, jail.d/3x-ipl.conf
重启 Fail2ban
选项 2完全卸载
删除 /etc/fail2ban
停止服务
按发行版卸载 fail2ban 包 + autoremove
```
---
### 选项 22防火墙管理
**函数**`firewall_menu()` — 子菜单入口(基于 UFW
#### 子菜单
```
1. 安装防火墙
2. 端口列表 [带编号]
3. 开放端口
4. 删除列表中的端口
5. 启用防火墙
6. 禁用防火墙
7. 防火墙状态
0. 返回主菜单
```
#### 子选项 1安装防火墙
**函数**`install_firewall()`
```
检查 ufw 是否安装 → 未安装则 apt-get install ufw
检查防火墙是否激活 → 未激活则:
ufw allow ssh
ufw allow http
ufw allow https
ufw allow 2053/tcp ← webPort
ufw allow 2096/tcp ← subport
ufw --force enable
```
#### 子选项 3开放端口
**函数**`open_ports()`
```
输入端口(逗号分隔或范围,如 80,443,2053 或 400-500
验证输入格式
逐个处理:
范围 → ufw allow start:end/tcp + ufw allow start:end/udp
单端口 → ufw allow port
确认显示已开放的端口
```
#### 子选项 4删除端口
**函数**`delete_ports()`
```
显示当前规则ufw status numbered
选择删除方式:
1. 按规则编号删除 → ufw delete $number
2. 按端口号删除 → ufw delete allow $port
确认显示已删除的端口
```
**注意**:原始代码中选项 4 有一个已知 bug`firewall_wall_menu` 应为 `firewall_menu`),这会导致删除端口后不返回菜单。
---
### 选项 23SSH 端口转发管理
**函数**`SSH_port_forwarding()`
```
获取服务器公网 IP多 API 轮询)
读取当前面板设置:
- webBasePath, port, listenIP, cert, key
判断状态:
├─ 已有证书+密钥 → "面板已配置 SSL安全" → 返回
├─ 无证书且 listenIP 为空或 0.0.0.0 → "面板不安全" 警告
└─ listenIP 已设置且非 0.0.0.0 → 显示 SSH 转发命令
子菜单:
1. 设置监听 IP
├─ 默认 127.0.0.1 或自定义
├─ x-ui setting -listenIP ${ip}
└─ 显示 SSH 转发命令:
ssh -L 2222:${listenIP}:${port} root@${server_ip}
访问 http://localhost:2222${webBasePath}
2. 清除监听 IP
└─ x-ui setting -listenIP 0.0.0.0 → 重启
0. 返回
```
**用途**:将面板绑定到 127.0.0.1,只能通过 SSH 隧道访问,提高安全性。
---
### 选项 24BBR 管理
**函数**`bbr_menu()` — 子菜单入口
#### 子菜单
```
1. 启用 BBR
2. 禁用 BBR
0. 返回主菜单
```
#### 启用 BBR
**函数**`enable_bbr()`
```
检查是否已启用tcp_congestion_control == bbr 且 default_qdisc 为 fq/cake
├─ 已启用 → 直接返回
└─ 未启用 →
有 /etc/sysctl.d/ →
创建 /etc/sysctl.d/99-bbr-x-ui.conf
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
注释 sysctl.conf 中的旧设置
sysctl --system
无 /etc/sysctl.d/ →
直接修改 /etc/sysctl.conf
sysctl -p
验证tcp_congestion_control == bbr → "BBR 已成功启用"
```
**特性**:启用前会备份当前设置(写入注释行 `#旧qdisc:旧拥塞控制`),以便禁用时恢复。
#### 禁用 BBR
**函数**`disable_bbr()`
```
检查是否已启用 → 未启用则返回
有 99-bbr-x-ui.conf →
读取备份的旧设置
恢复 net.core.default_qdisc 和 net.ipv4.tcp_congestion_control
删除配置文件
sysctl --system
无 99-bbr-x-ui.conf →
将 sysctl.conf 中的 fq→pfifo_fast, bbr→cubic
sysctl -p
验证tcp_congestion_control != bbr → "BBR 已成功替换为 CUBIC"
```
---
### 选项 25更新 Geo 文件
**函数**`update_geo()` — 子菜单入口
#### 子菜单
```
1. Loyalsoldier (geoip.dat, geosite.dat)
2. chocolate4u (geoip_IR.dat, geosite_IR.dat)
3. runetfreedom (geoip_RU.dat, geosite_RU.dat)
4. 全部更新
0. 返回主菜单
```
#### 数据源
| 选项 | 数据源 | 文件 | 用途 |
|------|---------------------------------------|------------------------------|------------------|
| 1 | Loyalsoldier/v2ray-rules-dat | geoip.dat, geosite.dat | 通用规则 |
| 2 | chocolate4u/Iran-v2ray-rules | geoip_IR.dat, geosite_IR.dat | 伊朗规则 |
| 3 | runetfreedom/russia-v2ray-rules-dat | geoip_RU.dat, geosite_RU.dat | 俄罗斯规则 |
| 4 | 以上全部 | 全部 6 个文件 | 一键更新 |
**下载逻辑** (`update_geofiles`)
```
每个文件:
curl -fLRo ${xui_folder}/bin/${dat}.dat
-z ${xui_folder}/bin/${dat}.dat ← 仅在远程更新时下载
https://github.com/${source}/releases/latest/download/${remote_file}.dat
```
`-z` 参数确保只有远程文件比本地新时才下载,节省带宽。
更新后自动重启面板以加载新规则。
---
### 选项 26网速测试 (Speedtest)
**函数**`run_speedtest()`
```
检查 speedtest 命令是否存在
└─ 不存在 →
有 snap → snap install speedtest
无 snap → 按包管理器安装:
dnf/yum → rpm 包源
apt-get/apt → deb 包源
curl 安装脚本 → 包管理器安装
执行 speedtest
```
---
## 子命令(命令行模式)
当脚本以参数调用时(如 `x-ui start`),跳过交互菜单直接执行:
| 子命令 | 对应菜单 | 附加行为 |
|------------------------|----------|-------------------------------|
| `start` | 11 | 执行后不返回菜单 |
| `stop` | 12 | 执行后不返回菜单 |
| `restart` | 13 | 执行后不返回菜单 |
| `restart-xray` | 14 | 执行后不返回菜单 |
| `status` | 15 | 执行后不返回菜单 |
| `settings` | 10 | 执行后不返回菜单 |
| `enable` | 17 | 执行后不返回菜单 |
| `disable` | 18 | 执行后不返回菜单 |
| `log` | 16 | 执行后不返回菜单 |
| `banlog` | 4(限制) | 执行后不返回菜单 |
| `update` | 2 | 执行后不返回菜单 |
| `legacy` | 4 | 执行后不返回菜单 |
| `install` | 1 | 使用 check_uninstall 前置检查 |
| `uninstall` | 5 | 执行后不返回菜单 |
| `update-all-geofiles` | 25-4 | 更新后自动重启 |
| 无效参数 | — | 显示用法帮助 |
所有子命令传递参数 `0` 给功能函数,使其执行后不调用 `before_show_menu()` 返回菜单。
---
## 调用关系总览
```
x-ui.sh
├─ show_menu()
│ ├─ show_status() → check_status() + show_enable_status() + show_xray_status()
│ ├─ 0: exit
│ ├─ 1: install() → install.sh → start()
│ ├─ 2: update() → update.sh
│ ├─ 3: update_menu() → 下载 x-ui.sh
│ ├─ 4: legacy_version() → install.sh v$version
│ ├─ 5: uninstall() → 停止服务 + 删除文件
│ ├─ 6: reset_user() → x-ui setting -username/-password
│ ├─ 7: reset_webbasepath() → x-ui setting -webBasePath
│ ├─ 8: reset_config() → x-ui setting -reset
│ ├─ 9: set_port() → x-ui setting -port
│ ├─ 10: check_config() → x-ui setting -show + ssl_cert_issue_for_ip()
│ ├─ 11: start() → systemctl/rc-service start
│ ├─ 12: stop() → systemctl/rc-service stop
│ ├─ 13: restart() → systemctl/rc-service restart
│ ├─ 14: restart_xray() → systemctl reload
│ ├─ 15: status() → systemctl/rc-service status
│ ├─ 16: show_log() → journalctl/grep messages
│ ├─ 17: enable() → systemctl/rc-update enable
│ ├─ 18: disable() → systemctl/rc-update disable
│ ├─ 19: ssl_cert_issue_main()
│ │ ├─ 1: ssl_cert_issue() → acme.sh 域名证书
│ │ ├─ 2: 吊销证书 → acme.sh --revoke
│ │ ├─ 3: 强制续期 → acme.sh --renew --force
│ │ ├─ 4: 查看已有域名
│ │ ├─ 5: 设置面板证书路径
│ │ └─ 6: ssl_cert_issue_for_ip() → acme.sh IP 短期证书
│ ├─ 20: ssl_cert_issue_CF() → acme.sh Cloudflare DNS 通配符证书
│ ├─ 21: iplimit_main()
│ │ ├─ 1: install_iplimit() → install fail2ban + create_iplimit_jails()
│ │ ├─ 2: 修改封禁时长
│ │ ├─ 3: 解封所有人
│ │ ├─ 4: show_banlog()
│ │ ├─ 5/6: 手动封禁/解封 IP
│ │ ├─ 7: tail -f fail2ban.log
│ │ ├─ 8/9: 服务状态/重启
│ │ └─ 10: remove_iplimit()
│ ├─ 22: firewall_menu() → UFW 防火墙管理
│ ├─ 23: SSH_port_forwarding() → 设置 listenIP 为 127.0.0.1
│ ├─ 24: bbr_menu() → enable_bbr() / disable_bbr()
│ ├─ 25: update_geo() → update_geofiles() → 下载 geoip/geosite .dat
│ └─ 26: run_speedtest() → speedtest
└─ 子命令模式($# > 0
└─ case $1 in "start"|"stop"|... → 对应函数 0
```
---
## 关键设计决策
1. **Alpine 兼容**:所有服务管理操作都区分 Alpine (OpenRC) 和其他系统 (systemd),通过 `$release` 变量判断。
2. **操作确认**:危险操作(卸载、重置凭据等)默认为 "n",防止误操作。安全操作(更新等)默认为 "y"。
3. **子命令模式**:支持 `x-ui start` 等非交互式调用,传递参数 `0` 抑制 `before_show_menu()` 的回车等待。
4. **状态前置检查**:大多数菜单选项先调用 `check_install``check_uninstall`,确保操作的前提条件满足。
5. **等待机制**start/stop/restart 后等待 2 秒再检查状态,给 systemd/init.d 足够时间完成操作。
6. **Geo 文件条件下载**:使用 `curl -z` 参数,仅在远程文件比本地新时才下载,节省带宽和时间。
7. **BBR 备份恢复**:启用 BBR 前将当前设置备份到注释行中,禁用时精确恢复原始值。
8. **Fail2ban jail 隔离**IP 限制使用独立的 `3x-ipl` jail与系统默认 jail 分离,互不影响。

View file

@ -35,6 +35,7 @@ type IndexController struct {
settingService service.SettingService settingService service.SettingService
userService service.UserService userService service.UserService
inboundService service.InboundService
tgbot service.Tgbot tgbot service.Tgbot
} }
@ -151,7 +152,7 @@ func (a *IndexController) register(c *gin.Context) {
} }
} }
err = a.userService.RegisterUser(form.Username, form.Password) err = a.userService.RegisterUser(form.Username, form.Password, &a.inboundService)
if err != nil { if err != nil {
errMsg := err.Error() errMsg := err.Error()
if strings.Contains(errMsg, "already exists") { if strings.Contains(errMsg, "already exists") {

View file

@ -190,25 +190,33 @@ func (s *InboundService) checkEmailExistForInbound(inbound *model.Inbound) (stri
if err != nil { if err != nil {
return "", err return "", err
} }
allEmails, err := s.getAllEmails()
if err != nil {
return "", err
}
var emails []string var emails []string
for _, client := range clients { for _, client := range clients {
if client.Email != "" { if client.Email != "" {
if s.contains(emails, client.Email) { if s.contains(emails, client.Email) {
return client.Email, nil return client.Email, nil
} }
if s.contains(allEmails, client.Email) {
return client.Email, nil
}
emails = append(emails, client.Email) emails = append(emails, client.Email)
} }
} }
return "", nil return "", nil
} }
// checkEmailExistInInbound checks if an email already exists in a specific inbound's clients.
func (s *InboundService) checkEmailExistInInbound(inbound *model.Inbound, email string) (bool, error) {
clients, err := s.GetClients(inbound)
if err != nil {
return false, err
}
lowerEmail := strings.ToLower(email)
for _, client := range clients {
if strings.ToLower(client.Email) == lowerEmail {
return true, nil
}
}
return false, nil
}
// AddInbound creates a new inbound configuration. // AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields, // It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance. // then saves the inbound to the database and optionally adds it to the running Xray instance.
@ -582,19 +590,25 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
interfaceClients[i] = cm interfaceClients[i] = cm
} }
} }
existEmail, err := s.checkEmailsExistForClients(clients)
if err != nil {
return false, err
}
if existEmail != "" {
return false, common.NewError("Duplicate email:", existEmail)
}
oldInbound, err := s.GetInbound(data.Id) oldInbound, err := s.GetInbound(data.Id)
if err != nil { if err != nil {
return false, err return false, err
} }
// Check email uniqueness within this inbound only
for _, client := range clients {
if client.Email == "" {
continue
}
exists, err := s.checkEmailExistInInbound(oldInbound, client.Email)
if err != nil {
return false, err
}
if exists {
return false, common.NewError("Duplicate email in this inbound:", client.Email)
}
}
// Secure client ID // Secure client ID
for _, client := range clients { for _, client := range clients {
switch oldInbound.Protocol { switch oldInbound.Protocol {
@ -818,14 +832,25 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
} }
if len(clients[0].Email) > 0 && clients[0].Email != oldEmail { if len(clients[0].Email) > 0 && clients[0].Email != oldEmail {
existEmail, err := s.checkEmailsExistForClients(clients) oldInbound, err := s.GetInbound(data.Id)
if err != nil { if err != nil {
return false, err return false, err
} }
if existEmail != "" {
return false, common.NewError("Duplicate email:", existEmail) // Check email uniqueness within this inbound only
for _, client := range clients {
if client.Email == "" {
continue
}
exists, err := s.checkEmailExistInInbound(oldInbound, client.Email)
if err != nil {
return false, err
}
if exists {
return false, common.NewError("Duplicate email in this inbound:", client.Email)
}
} }
} }
var oldSettings map[string]any var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings) err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)

297
web/service/setting_test.go Normal file
View file

@ -0,0 +1,297 @@
package service
import (
"encoding/json"
"os"
"testing"
"github.com/mhsanaei/3x-ui/v2/config"
)
func setupTestSettings(t *testing.T) func() {
t.Helper()
tmpDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", tmpDir)
return func() {}
}
func TestLoadSettingsCreatesDefaults(t *testing.T) {
setupTestSettings(t)
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings() error: %v", err)
}
// Should contain default values
if settings["webPort"] != "2053" {
t.Errorf("expected webPort=2053, got %s", settings["webPort"])
}
if settings["webBasePath"] != "/" {
t.Errorf("expected webBasePath=/, got %s", settings["webBasePath"])
}
// Should NOT contain xrayTemplateConfig
if _, exists := settings["xrayTemplateConfig"]; exists {
t.Error("xrayTemplateConfig should not be in JSON settings")
}
// File should exist on disk
path := config.GetSettingPath()
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("settings file %s should have been created", path)
}
}
func TestSaveAndLoadSettings(t *testing.T) {
setupTestSettings(t)
settings := map[string]string{
"webPort": "8080",
"webListen": "0.0.0.0",
}
err := saveSettings(settings)
if err != nil {
t.Fatalf("saveSettings() error: %v", err)
}
loaded, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings() error: %v", err)
}
if loaded["webPort"] != "8080" {
t.Errorf("expected webPort=8080, got %s", loaded["webPort"])
}
if loaded["webListen"] != "0.0.0.0" {
t.Errorf("expected webListen=0.0.0.0, got %s", loaded["webListen"])
}
}
func TestSettingServiceGetString(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
// Should return default value when key not set
val, err := svc.getString("webPort")
if err != nil {
t.Fatalf("getString error: %v", err)
}
if val != "2053" {
t.Errorf("expected 2053, got %s", val)
}
}
func TestSettingServiceSetAndGetString(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
err := svc.setString("webPort", "9090")
if err != nil {
t.Fatalf("setString error: %v", err)
}
val, err := svc.getString("webPort")
if err != nil {
t.Fatalf("getString error: %v", err)
}
if val != "9090" {
t.Errorf("expected 9090, got %s", val)
}
}
func TestResetSettingsDeletesFile(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
// Create settings first
_, err := svc.getString("webPort")
if err != nil {
t.Fatalf("getString error: %v", err)
}
path := config.GetSettingPath()
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("settings file should exist before reset")
}
// Manually delete to simulate the file removal part of ResetSettings
err = os.Remove(path)
if err != nil {
t.Fatalf("remove error: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("settings file should not exist after reset")
}
// Re-loading should recreate defaults
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings after reset error: %v", err)
}
if settings["webPort"] != "2053" {
t.Errorf("expected default webPort=2053 after reset, got %s", settings["webPort"])
}
}
func TestSettingsFileFormat(t *testing.T) {
setupTestSettings(t)
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings error: %v", err)
}
path := config.GetSettingPath()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
// Verify it's valid JSON
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("settings file is not valid JSON: %v", err)
}
// Verify nested format: should contain group objects
for _, group := range []string{"web", "tgBot", "sub", "ldap", "other"} {
val, exists := parsed[group]
if !exists {
t.Errorf("expected group %q in nested JSON", group)
continue
}
if _, isMap := val.(map[string]any); !isMap {
t.Errorf("expected group %q to be an object, got %T", group, val)
}
}
// Verify pretty-printed (has newlines)
hasNewline := false
for _, b := range data {
if b == '\n' {
hasNewline = true
break
}
}
if !hasNewline {
t.Error("settings file should be pretty-printed with newlines")
}
// Verify round-trip: flatten nested back to flat should match loaded settings
flattened := flattenNested(parsed)
if len(flattened) != len(settings) {
t.Errorf("flattened key count %d != loaded key count %d", len(flattened), len(settings))
}
for k, v := range settings {
if fv, ok := flattened[k]; !ok {
t.Errorf("key %q missing after flatten", k)
} else if fv != v {
t.Errorf("key %q: expected %q, got %q", k, v, fv)
}
}
}
func TestLegacyFlatFormatBackwardCompat(t *testing.T) {
setupTestSettings(t)
// Write a flat JSON file (legacy format)
flat := map[string]string{
"webPort": "8080",
"webListen": "0.0.0.0",
"subEnable": "false",
"ldapHost": "ldap.example.com",
}
data, err := json.MarshalIndent(flat, "", " ")
if err != nil {
t.Fatalf("MarshalIndent error: %v", err)
}
path := config.GetSettingPath()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
// loadSettings should parse it as flat and merge defaults
loaded, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings error: %v", err)
}
if loaded["webPort"] != "8080" {
t.Errorf("expected webPort=8080, got %s", loaded["webPort"])
}
if loaded["webListen"] != "0.0.0.0" {
t.Errorf("expected webListen=0.0.0.0, got %s", loaded["webListen"])
}
if loaded["subEnable"] != "false" {
t.Errorf("expected subEnable=false, got %s", loaded["subEnable"])
}
if loaded["ldapHost"] != "ldap.example.com" {
t.Errorf("expected ldapHost=ldap.example.com, got %s", loaded["ldapHost"])
}
// Defaults should be merged for missing keys
if loaded["webBasePath"] != "/" {
t.Errorf("expected webBasePath=/, got %s", loaded["webBasePath"])
}
}
func TestRoundTripNestedFormat(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
// Set some values
if err := svc.setString("webPort", "9090"); err != nil {
t.Fatalf("setString error: %v", err)
}
if err := svc.setString("tgBotEnable", "true"); err != nil {
t.Fatalf("setString error: %v", err)
}
if err := svc.setString("ldapHost", "ldap.test.com"); err != nil {
t.Fatalf("setString error: %v", err)
}
// Read back
val, err := svc.getString("webPort")
if err != nil || val != "9090" {
t.Errorf("expected webPort=9090, got %s (err: %v)", val, err)
}
val, err = svc.getString("tgBotEnable")
if err != nil || val != "true" {
t.Errorf("expected tgBotEnable=true, got %s (err: %v)", val, err)
}
val, err = svc.getString("ldapHost")
if err != nil || val != "ldap.test.com" {
t.Errorf("expected ldapHost=ldap.test.com, got %s (err: %v)", val, err)
}
// Verify on-disk format is nested
path := config.GetSettingPath()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("settings file is not valid JSON: %v", err)
}
if webGroup, ok := parsed["web"].(map[string]any); ok {
if port, ok := webGroup["port"].(string); !ok || port != "9090" {
t.Errorf("expected web.port=9090 in nested JSON, got %v", webGroup["port"])
}
} else {
t.Error("expected 'web' group in nested JSON")
}
if tgGroup, ok := parsed["tgBot"].(map[string]any); ok {
if enable, ok := tgGroup["enable"].(string); !ok || enable != "true" {
t.Errorf("expected tgBot.enable=true in nested JSON, got %v", tgGroup["enable"])
}
} else {
t.Error("expected 'tgBot' group in nested JSON")
}
}

View file

@ -1,9 +1,11 @@
package service package service
import ( import (
"encoding/json"
"errors" "errors"
"strings" "strings"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
@ -127,7 +129,7 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
Error Error
} }
func (s *UserService) RegisterUser(username string, password string) error { func (s *UserService) RegisterUser(username string, password string, inboundService *InboundService) error {
if username == "" { if username == "" {
return errors.New("username can not be empty") return errors.New("username can not be empty")
} }
@ -141,19 +143,89 @@ func (s *UserService) RegisterUser(username string, password string) error {
} }
db := database.GetDB() db := database.GetDB()
user := &model.User{
Username: username, // Create user and add as client to all inbounds in a single transaction
Password: hashedPassword, return db.Transaction(func(tx *gorm.DB) error {
Role: "user", user := &model.User{
} Username: username,
if err := db.Create(user).Error; err != nil { Password: hashedPassword,
// Check for unique constraint violation Role: "user",
if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") {
return errors.New("username already exists")
} }
return err if err := tx.Create(user).Error; err != nil {
} if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") {
return nil return errors.New("username already exists")
}
return err
}
// Add the new user as a disabled client to all existing inbounds
inbounds, err := inboundService.GetAllInbounds()
if err != nil {
return err
}
for _, inbound := range inbounds {
clientID := uuid.New().String()
client := model.Client{
ID: clientID,
Email: username,
Enable: false,
SubID: uuid.New().String()[:8],
Comment: "auto-added on registration",
}
// Build the client JSON entry based on protocol
clientEntry := map[string]any{
"email": client.Email,
"enable": client.Enable,
"totalGB": 0,
"expiryTime": 0,
"limitIp": 0,
"subId": client.SubID,
"comment": client.Comment,
"created_at": 0,
"updated_at": 0,
}
switch inbound.Protocol {
case "trojan":
clientEntry["password"] = clientID
case "shadowsocks":
clientEntry["password"] = clientID
default:
clientEntry["id"] = clientID
}
// Parse inbound settings and append the new client
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
return err
}
clientsRaw, ok := settings["clients"].([]any)
if !ok {
clientsRaw = []any{}
}
clientsRaw = append(clientsRaw, clientEntry)
settings["clients"] = clientsRaw
newSettings, err := json.Marshal(settings)
if err != nil {
return err
}
inbound.Settings = string(newSettings)
// Save the updated inbound settings
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Update("settings", inbound.Settings).Error; err != nil {
return err
}
// Create ClientTraffic record for this inbound
if err := inboundService.AddClientStat(tx, inbound.Id, &client); err != nil {
return err
}
}
return nil
})
} }
func (s *UserService) UpdateFirstUser(username string, password string) error { func (s *UserService) UpdateFirstUser(username string, password string) error {

View file

@ -6,7 +6,7 @@ type ClientTraffic struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
InboundId int `json:"inboundId" form:"inboundId"` InboundId int `json:"inboundId" form:"inboundId"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable"`
Email string `json:"email" form:"email" gorm:"unique"` Email string `json:"email" form:"email"`
UUID string `json:"uuid" form:"uuid" gorm:"-"` UUID string `json:"uuid" form:"uuid" gorm:"-"`
SubId string `json:"subId" form:"subId" gorm:"-"` SubId string `json:"subId" form:"subId" gorm:"-"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up"`