mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
87c94cb5b0
commit
b4047cee54
16 changed files with 6156 additions and 34 deletions
|
|
@ -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
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
346
docs/API-DB.md
Normal 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`):** 被删除的入站 ID(int)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `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
1059
docs/API.md
Normal file
File diff suppressed because it is too large
Load diff
485
docs/install-logic.md
Normal file
485
docs/install-logic.md
Normal 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
|
||||||
|
├─ 选项 1:systemctl 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(首次安装)
|
||||||
|
│ │ ├─ 生成随机 webBasePath(18 位)
|
||||||
|
│ │ ├─ 生成随机用户名(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 追踪频繁变化的数据库。
|
||||||
927
docs/superpowers/plans/2026-04-02-json-settings.md
Normal file
927
docs/superpowers/plans/2026-04-02-json-settings.md
Normal 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.
|
||||||
193
docs/superpowers/plans/2026-04-02-pre-release-install-update.md
Normal file
193
docs/superpowers/plans/2026-04-02-pre-release-install-update.md
Normal 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"
|
||||||
|
```
|
||||||
134
docs/superpowers/specs/2026-04-02-json-settings-design.md
Normal file
134
docs/superpowers/specs/2026-04-02-json-settings-design.md
Normal 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
|
||||||
|
|
@ -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)
|
||||||
444
docs/x-panel-device-limit.md
Normal file
444
docs/x-panel-device-limit.md
Normal 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/s,0=不限速
|
||||||
|
Flow string `json:"flow"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
LimitIP int `json:"limitIp"` // 遗留字段,Fail2ban 用
|
||||||
|
TotalGB int64 `json:"totalGB"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime"`
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
TgID int64 `json:"tgId"`
|
||||||
|
SubID string `json:"subId"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Reset int `json:"reset"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### InboundClientIps(与 3x-ui 相同)
|
||||||
|
|
||||||
|
```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/s,0=不限速 |
|
||||||
|
| `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
937
docs/x-ui-logic.md
Normal 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)
|
||||||
|
获取公网 IP(api.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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 选项 19:SSL 证书管理
|
||||||
|
|
||||||
|
**函数**:`ssl_cert_issue_main()` — 子菜单入口
|
||||||
|
|
||||||
|
#### 子菜单
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 获取 SSL(域名)
|
||||||
|
2. 吊销证书
|
||||||
|
3. 强制续期
|
||||||
|
4. 查看已有域名
|
||||||
|
5. 为面板设置证书路径
|
||||||
|
6. 为 IP 地址获取 SSL(6 天证书,自动续期)
|
||||||
|
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()`
|
||||||
|
|
||||||
|
```
|
||||||
|
获取服务器公网 IP(api.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:端口/路径 → 重启面板
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 选项 20:Cloudflare 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 验证)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 选项 21:IP 限制管理(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`),这会导致删除端口后不返回菜单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 选项 23:SSH 端口转发管理
|
||||||
|
|
||||||
|
**函数**:`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 隧道访问,提高安全性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 选项 24:BBR 管理
|
||||||
|
|
||||||
|
**函数**:`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 分离,互不影响。
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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
297
web/service/setting_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue