diff --git a/database/db.go b/database/db.go index 1354d8a4..48b5538c 100644 --- a/database/db.go +++ b/database/db.go @@ -106,7 +106,21 @@ func runSeeders(isUsersEmpty bool) error { hashSeeder := &model.HistoryOfSeeders{ 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 + } } } diff --git a/docs/API-DB-MariaDB.md b/docs/API-DB-MariaDB.md new file mode 100644 index 00000000..eadb849b --- /dev/null +++ b/docs/API-DB-MariaDB.md @@ -0,0 +1,1109 @@ +# 3x-ui MariaDB 迁移 API 文档 + +> 本文档说明如何将 3x-ui 面板的数据库从 SQLite 迁移到 MariaDB,涵盖所有数据库相关的 API 接口、数据模型映射、SQL 差异及兼容性改造方案。 + +--- + +## 目录 + +- [1. 架构概览与迁移总览](#1-架构概览与迁移总览) +- [2. 环境准备与依赖](#2-环境准备与依赖) +- [3. 数据库连接层改造](#3-数据库连接层改造) +- [4. 数据模型与 MariaDB 表结构映射](#4-数据模型与-mariadb-表结构映射) +- [5. SQLite 特有 SQL 的 MariaDB 替代方案](#5-sqlite-特有-sql-的-mariadb-替代方案) +- [6. 入站管理 API(数据库层)](#6-入站管理-api数据库层) +- [7. 客户端管理 API(数据库层)](#7-客户端管理-api数据库层) +- [8. 流量管理 API(数据库层)](#8-流量管理-api数据库层) +- [9. IP 记录管理 API(数据库层)](#9-ip-记录管理-api数据库层) +- [10. 面板配置 API(数据库层)](#10-面板配置-api数据库层) +- [11. 用户管理 API(数据库层)](#11-用户管理-api数据库层) +- [12. 数据库导入导出(MariaDB 方案)](#12-数据库导入导出mariadb-方案) +- [13. 订阅服务的数据库查询改造](#13-订阅服务的数据库查询改造) +- [14. 数据库维护操作(MariaDB 对应)](#14-数据库维护操作mariadb-对应) +- [15. 迁移实施步骤](#15-迁移实施步骤) + +--- + +## 1. 架构概览与迁移总览 + +### 1.1 当前架构(SQLite) + +``` +┌─────────────┐ ┌──────────┐ ┌───────────────┐ +│ Web Panel │────▶│ GORM │────▶│ SQLite DB │ +│ (Gin) │ │ v1.31.1 │ │ x-ui.db │ +└─────────────┘ └──────────┘ └───────────────┘ + │ + sqlite driver + (mattn/go-sqlite3) +``` + +### 1.2 目标架构(MariaDB) + +``` +┌─────────────┐ ┌──────────┐ ┌───────────────┐ +│ Web Panel │────▶│ GORM │────▶│ MariaDB │ +│ (Gin) │ │ v1.31.1 │ │ Server │ +└─────────────┘ └──────────┘ └───────────────┘ + │ + mysql driver + (go-sqlite3 → go-mysql-driver) +``` + +### 1.3 迁移范围 + +| 组件 | SQLite 特有 | MariaDB 兼容 | 需改造 | +|---|---|---|---| +| `database/db.go` 连接初始化 | `sqlite.Open()` | `mysql.Open()` | **是** | +| GORM 标准 CRUD | `db.Where/First/Create/Save/Delete` | 相同 | 否 | +| `gorm.Expr` 原子递增 | `gorm.Expr("up + ?", val)` | 相同 | 否 | +| `JSON_EACH()` 原始 SQL | SQLite 特有 | **无等价函数** | **是** | +| `PRAGMA wal_checkpoint` | SQLite 特有 | `FLUSH TABLES` | **是** | +| `PRAGMA integrity_check` | SQLite 特有 | `CHECK TABLE` | **是** | +| `VACUUM` | SQLite 特有 | `OPTIMIZE TABLE` | **是** | +| `IsSQLiteDB()` | 检查 SQLite 文件头 | 不适用 | **是** | +| `JSON_EXTRACT()` / `JSON_TYPE()` | SQLite JSON1 | MariaDB 原生支持 | 否 | +| `IFNULL()` | SQLite | MariaDB `IFNULL()` (也支持 `COALESCE`) | 否 | +| `GROUP_CONCAT()` | SQLite | MariaDB 支持 | 否 | +| `LIKE` 模糊查询 | SQLite | MariaDB 支持 | 否 | +| `AutoMigrate` | GORM 自动生成 | GORM 自动生成 | 否 | + +--- + +## 2. 环境准备与依赖 + +### 2.1 Go 依赖替换 + +在 `go.mod` 中: + +**移除:** +``` +gorm.io/driver/sqlite v1.6.0 +github.com/mattn/go-sqlite3 v1.14.38 +``` + +**新增:** +``` +gorm.io/driver/mysql v1.5.7 +``` + +### 2.2 环境变量 + +| 环境变量 | 说明 | 示例 | +|---|---|---| +| `XUI_DB_DRIVER` | 数据库驱动类型 | `mysql`(默认 `sqlite`) | +| `XUI_DB_HOST` | MariaDB 主机地址 | `127.0.0.1` | +| `XUI_DB_PORT` | MariaDB 端口 | `3306` | +| `XUI_DB_NAME` | 数据库名 | `xui` | +| `XUI_DB_USER` | 数据库用户 | `xui` | +| `XUI_DB_PASSWORD` | 数据库密码 | `secret` | +| `XUI_DB_CHARSET` | 字符集 | `utf8mb4` | +| `XUI_DB_FOLDER` | SQLite 数据库文件夹(保留向后兼容) | `/etc/x-ui` | + +### 2.3 MariaDB 数据库初始化 + +```sql +CREATE DATABASE xui CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'xui'@'localhost' IDENTIFIED BY 'your_password'; +GRANT ALL PRIVILEGES ON xui.* TO 'xui'@'localhost'; +FLUSH PRIVILEGES; +``` + +--- + +## 3. 数据库连接层改造 + +> 原始文件:`database/db.go` + +### 3.1 连接初始化(InitDB) + +**当前 SQLite 实现(第 141 行):** +```go +db, err = gorm.Open(sqlite.Open(dbPath), c) +``` + +**MariaDB 改造:** +```go +import ( + "gorm.io/driver/mysql" +) + +func InitDB(dbDriver, dsn string) error { + var dialector gorm.Dialector + + switch dbDriver { + case "mysql": + dialector = mysql.Open(dsn) + default: + // 保持 SQLite 向后兼容 + dialector = sqlite.Open(dsn) + } + + db, err = gorm.Open(dialector, c) + // ... 后续逻辑不变 +} +``` + +### 3.2 DSN 连接字符串格式 + +``` +xui:password@tcp(127.0.0.1:3306)/xui?charset=utf8mb4&parseTime=True&loc=Local +``` + +### 3.3 不再需要的功能 + +| 函数 | 原用途 | MariaDB 处理 | +|---|---|---| +| `IsSQLiteDB()` | 验证 SQLite 文件头 | MariaDB 下返回 `false` 或跳过 | +| `ValidateSQLiteDB()` | `PRAGMA integrity_check` | 改用 `CHECK TABLE` 或 `mysqladmin check` | +| `Checkpoint()` | `PRAGMA wal_checkpoint` | 改用 `FLUSH TABLES` | + +--- + +## 4. 数据模型与 MariaDB 表结构映射 + +> 原始文件:`database/model/model.go`, `xray/client_traffic.go` + +GORM 的 `AutoMigrate` 在 MariaDB 下会自动创建表。以下是每个模型到 MariaDB 表结构的映射。 + +### 4.1 `users` 表 + +**Go 结构体:** +```go +type User struct { + Id int `gorm:"primaryKey;autoIncrement"` + Username string + Password string +} +``` + +**MariaDB DDL:** +```sql +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL DEFAULT '', + password VARCHAR(255) NOT NULL DEFAULT '' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 4.2 `inbounds` 表 + +**Go 结构体:** +```go +type Inbound struct { + Id int `gorm:"primaryKey;autoIncrement"` + UserId int + Up int64 + Down int64 + Total int64 + AllTime int64 `gorm:"default:0"` + Remark string + Enable bool `gorm:"index:idx_enable_traffic_reset,priority:1"` + ExpiryTime int64 + TrafficReset string `gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` + LastTrafficResetTime int64 `gorm:"default:0"` + Listen string + Port int + Protocol Protocol + Settings string -- LONGTEXT (JSON) + StreamSettings string -- LONGTEXT (JSON) + Tag string `gorm:"unique"` + Sniffing string -- LONGTEXT (JSON) +} +``` + +**MariaDB DDL:** +```sql +CREATE TABLE inbounds ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL DEFAULT 0, + up BIGINT NOT NULL DEFAULT 0, + down BIGINT NOT NULL DEFAULT 0, + total BIGINT NOT NULL DEFAULT 0, + all_time BIGINT NOT NULL DEFAULT 0, + remark VARCHAR(255) NOT NULL DEFAULT '', + enable TINYINT(1) NOT NULL DEFAULT 0, + expiry_time BIGINT NOT NULL DEFAULT 0, + traffic_reset VARCHAR(255) NOT NULL DEFAULT 'never', + last_traffic_reset_time BIGINT NOT NULL DEFAULT 0, + listen VARCHAR(255) NOT NULL DEFAULT '', + port INT NOT NULL DEFAULT 0, + protocol VARCHAR(50) NOT NULL DEFAULT '', + settings LONGTEXT, + stream_settings LONGTEXT, + tag VARCHAR(255) NOT NULL DEFAULT '', + sniffing LONGTEXT, + UNIQUE KEY idx_tag (tag), + INDEX idx_enable_traffic_reset (enable, traffic_reset) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**MariaDB 关键差异:** +- `bool` → `TINYINT(1)` (GORM 自动处理) +- JSON 字段存储为 `LONGTEXT`(MariaDB 10.2+ 也可用原生 `JSON` 类型做校验) +- `unique` 索引在 MariaDB 中正常工作 + +### 4.3 `client_traffics` 表 + +**Go 结构体:** +```go +type ClientTraffic struct { + Id int `gorm:"primaryKey;autoIncrement"` + InboundId int + Enable bool + Email string `gorm:"unique"` + Up int64 + Down int64 + AllTime int64 + ExpiryTime int64 + Total int64 + Reset int `gorm:"default:0"` + LastOnline int64 `gorm:"default:0"` +} +``` + +**MariaDB DDL:** +```sql +CREATE TABLE client_traffics ( + id INT AUTO_INCREMENT PRIMARY KEY, + inbound_id INT NOT NULL DEFAULT 0, + enable TINYINT(1) NOT NULL DEFAULT 0, + email VARCHAR(255) NOT NULL DEFAULT '', + up BIGINT NOT NULL DEFAULT 0, + down BIGINT NOT NULL DEFAULT 0, + all_time BIGINT NOT NULL DEFAULT 0, + expiry_time BIGINT NOT NULL DEFAULT 0, + total BIGINT NOT NULL DEFAULT 0, + reset INT NOT NULL DEFAULT 0, + last_online BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY idx_email (email), + INDEX idx_inbound_id (inbound_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**注意:** `UUID` 和 `SubId` 字段标记了 `gorm:"-"`,不会持久化到数据库。 + +### 4.4 `outbound_traffics` 表 + +**MariaDB DDL:** +```sql +CREATE TABLE outbound_traffics ( + id INT AUTO_INCREMENT PRIMARY KEY, + tag VARCHAR(255) NOT NULL DEFAULT '', + up BIGINT NOT NULL DEFAULT 0, + down BIGINT NOT NULL DEFAULT 0, + total BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY idx_tag (tag) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 4.5 `settings` 表 + +**MariaDB DDL:** +```sql +CREATE TABLE settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + `key` VARCHAR(255) NOT NULL DEFAULT '', + value LONGTEXT, + INDEX idx_key (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**注意:** `key` 是 MariaDB 保留字,需要反引号包裹。 + +### 4.6 `inbound_client_ips` 表 + +**MariaDB DDL:** +```sql +CREATE TABLE inbound_client_ips ( + id INT AUTO_INCREMENT PRIMARY KEY, + client_email VARCHAR(255) NOT NULL DEFAULT '', + ips LONGTEXT, + UNIQUE KEY idx_client_email (client_email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 4.7 `history_of_seeders` 表 + +**MariaDB DDL:** +```sql +CREATE TABLE history_of_seeders ( + id INT AUTO_INCREMENT PRIMARY KEY, + seeder_name VARCHAR(255) NOT NULL DEFAULT '' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +--- + +## 5. SQLite 特有 SQL 的 MariaDB 替代方案 + +### 5.1 `JSON_EACH()` — 最大阻塞点 + +`JSON_EACH()` 是 SQLite 特有的表值函数,用于展开 JSON 数组。**MariaDB 没有等价函数。** + +以下代码中使用了 `JSON_EACH`,必须改造: + +#### 5.1.1 `web/service/inbound.go:144-156` — `getAllEmails()` + +**当前 SQLite SQL:** +```sql +SELECT JSON_EXTRACT(client.value, '$.email') +FROM inbounds, + JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client +``` + +**MariaDB 替代方案(应用层解析):** +```go +func (s *InboundService) getAllEmails() ([]string, error) { + db := database.GetDB() + var inbounds []model.Inbound + err := db.Model(model.Inbound{}).Select("settings").Find(&inbounds).Error + if err != nil { + return nil, err + } + var emails []string + for _, inbound := range inbounds { + clients, _ := s.GetClients(&inbound) + for _, c := range clients { + if c.Email != "" { + emails = append(emails, c.Email) + } + } + } + return emails, nil +} +``` + +#### 5.1.2 `web/service/inbound.go:1313-1323` — `MigrationRemoveOrphanedTraffics()` + +**当前 SQLite SQL:** +```sql +DELETE FROM client_traffics +WHERE email NOT IN ( + SELECT JSON_EXTRACT(client.value, '$.email') + FROM inbounds, + JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client +) +``` + +**MariaDB 替代方案(应用层):** +```go +func (s *InboundService) MigrationRemoveOrphanedTraffics() { + db := database.GetDB() + var allEmails []string + var inbounds []model.Inbound + db.Model(model.Inbound{}).Select("settings").Find(&inbounds) + for _, inbound := range inbounds { + clients, _ := s.GetClients(&inbound) + for _, c := range clients { + if c.Email != "" { + allEmails = append(allEmails, c.Email) + } + } + } + if len(allEmails) > 0 { + db.Where("email NOT IN ?", allEmails).Delete(xray.ClientTraffic{}) + } else { + db.Where("1 = 1").Delete(xray.ClientTraffic{}) + } +} +``` + +#### 5.1.3 `web/service/inbound.go:2057-2082` — `GetClientTrafficByID()` + +**当前 SQLite SQL:** +```sql +SELECT ... FROM client_traffics WHERE email IN ( + SELECT JSON_EXTRACT(client.value, '$.email') as email + FROM inbounds, + JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client + WHERE JSON_EXTRACT(client.value, '$.id') IN (?) +) +``` + +**MariaDB 替代方案:** +```go +func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) { + db := database.GetDB() + // 先从 inbounds 的 settings JSON 中按应用层查出 email + var inbounds []model.Inbound + db.Model(model.Inbound{}). + Where("JSON_EXTRACT(settings, '$.clients[*].id') LIKE ?", "%"+id+"%"). + Find(&inbounds) + + var emails []string + for _, inbound := range inbounds { + clients, _ := s.GetClients(&inbound) + for _, c := range clients { + if c.ID == id && c.Email != "" { + emails = append(emails, c.Email) + } + } + } + if len(emails) == 0 { + return nil, nil + } + var traffics []xray.ClientTraffic + err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&traffics).Error + // ... 后续 reconcile 逻辑不变 + return traffics, err +} +``` + +#### 5.1.4 `sub/subService.go:115-130` — `getInboundsBySubId()` + +**当前 SQLite SQL:** +```sql +SELECT DISTINCT inbounds.id +FROM inbounds, + JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client +WHERE protocol in ('vmess','vless','trojan','shadowsocks') + AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ? +``` + +**MariaDB 替代方案:** +```go +func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { + db := database.GetDB() + var allInbounds []*model.Inbound + err := db.Model(model.Inbound{}).Preload("ClientStats"). + Where("enable = ? AND protocol IN (?)", true, + []string{"vmess", "vless", "trojan", "shadowsocks"}). + Find(&allInbounds).Error + if err != nil { + return nil, err + } + // 应用层过滤 subId + var result []*model.Inbound + for _, inbound := range allInbounds { + clients, _ := s.inboundService.GetClients(inbound) + for _, c := range clients { + if c.Enable && c.SubID == subId { + result = append(result, inbound) + break + } + } + } + return result, nil +} +``` + +#### 5.1.5 `sub/subService.go:141-162` — `getFallbackMaster()` + +**当前 SQLite SQL:** +```sql +SELECT * FROM inbounds +WHERE JSON_TYPE(settings, '$.fallbacks') = 'array' +AND EXISTS ( + SELECT * FROM json_each(settings, '$.fallbacks') + WHERE json_extract(value, '$.dest') = ? +) +``` + +**MariaDB 替代方案:** +```go +func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) { + db := database.GetDB() + var inbounds []*model.Inbound + // 使用 MariaDB 的 JSON_TYPE 和 JSON_SEARCH + err := db.Model(model.Inbound{}). + Where("JSON_TYPE(JSON_EXTRACT(settings, '$.fallbacks')) = 'ARRAY'"). + Find(&inbounds).Error + if err != nil { + return "", 0, "", err + } + // 应用层查找匹配 dest 的 fallback + for _, inbound := range inbounds { + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + if fallbacks, ok := settings["fallbacks"].([]any); ok { + for _, fb := range fallbacks { + if f, ok := fb.(map[string]any); ok { + if f["dest"] == dest { + // 找到匹配,返回主入站信息 + var stream, masterStream map[string]any + json.Unmarshal([]byte(streamSettings), &stream) + json.Unmarshal([]byte(inbound.StreamSettings), &masterStream) + stream["security"] = masterStream["security"] + stream["tlsSettings"] = masterStream["tlsSettings"] + stream["externalProxy"] = masterStream["externalProxy"] + modifiedStream, _ := json.MarshalIndent(stream, "", " ") + return inbound.Listen, inbound.Port, string(modifiedStream), nil + } + } + } + } + } + return "", 0, "", fmt.Errorf("fallback master not found for dest: %s", dest) +} +``` + +### 5.2 `PRAGMA` 和 `VACUUM` 替代 + +#### `Checkpoint()` — `database/db.go:195-202` + +```go +// SQLite: +db.Exec("PRAGMA wal_checkpoint;") + +// MariaDB 替代: +func Checkpoint() error { + sqlDB, err := db.DB() + if err != nil { + return err + } + _, err = sqlDB.Exec("FLUSH TABLES") + return err +} +``` + +#### `ValidateSQLiteDB()` — `database/db.go:207-228` + +```go +// SQLite: +db.Raw("PRAGMA integrity_check;").Scan(&res) + +// MariaDB 替代: +func ValidateDB() error { + var tables []string + db.Raw("SHOW TABLES").Scan(&tables) + for _, table := range tables { + var result string + db.Raw("CHECK TABLE " + table).Scan(&result) + // 检查 result 中是否包含 "error" + } + return nil +} +``` + +#### `VACUUM` — `web/service/inbound.go:2213` + +```go +// SQLite: +db.Exec(`VACUUM "main"`) + +// MariaDB 替代(逐表 OPTIMIZE): +var tables []string +db.Raw("SHOW TABLES").Scan(&tables) +for _, table := range tables { + db.Exec("OPTIMIZE TABLE " + table) +} +``` + +### 5.3 兼容函数(可直接使用) + +| 函数 | SQLite | MariaDB | 备注 | +|---|---|---|---| +| `IFNULL()` | 支持 | 支持 | 也可用 `COALESCE()` | +| `JSON_EXTRACT()` | 支持 | 支持 | 语法相同 | +| `GROUP_CONCAT()` | 支持 | 支持 | MariaDB 功能更丰富 | +| `COALESCE()` | 支持 | 支持 | | +| `INSTR()` | 支持 | 支持 | | +| `REPLACE()` | 支持 | 支持 | | +| `LIKE` | 支持 | 支持 | 大小写敏感性不同 | + +### 5.4 布尔值差异 + +- SQLite:`bool` 存储为 `0/1` +- MariaDB:GORM 使用 `TINYINT(1)`,同样存储为 `0/1` +- **无代码改动**:GORM 自动处理两种驱动的布尔值序列化 + +### 5.5 `IFNULL` vs `COALESCE` + +代码中使用了 `COALESCE`(如 `inbound.go:999`): +```go +gorm.Expr("COALESCE(all_time, 0) + ?", traffic.Up+traffic.Down) +``` +也使用了 `IFNULL`(如 `inbound.go:2224`): +```sql +SET all_time = IFNULL(up, 0) + IFNULL(down, 0) +``` +**两者在 MariaDB 和 SQLite 中都支持,无需修改。** + +--- + +## 6. 入站管理 API(数据库层) + +### `GET /panel/api/inbounds/list` + +**数据库操作:** GORM 标准查询,可移植。 + +```go +db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Find(&inbounds) +``` + +**MariaDB 无改动。** + +--- + +### `GET /panel/api/inbounds/get/:id` + +**数据库操作:** GORM `First`,可移植。 + +```go +db.Model(model.Inbound{}).First(inbound, id) +``` + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/add` + +**数据库操作:** GORM `Save` + `Create`,事务,可移植。 + +```go +tx := db.Begin() +tx.Save(inbound) +tx.Create(&clientTraffic) +tx.Commit() +``` + +**MariaDB 无改动。** InnoDB 事务支持良好。 + +--- + +### `POST /panel/api/inbounds/del/:id` + +**数据库操作:** GORM `Delete` + 级联删除客户端流量,可移植。 + +```go +db.Where("inbound_id = ?", id).Delete(xray.ClientTraffic{}) +db.Delete(model.Inbound{}, id) +``` + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/update/:id` + +**数据库操作:** GORM `Save` + 事务,可移植。 + +**MariaDB 无改动。** 需注意 `updateClientTraffics` 内部的 `GetClients` 是应用层 JSON 解析,不涉及数据库特有 SQL。 + +--- + +### `POST /panel/api/inbounds/import` + +**数据库操作:** 接收 JSON 数据后通过 `AddInbound` 写入。 + +**MariaDB 无改动。** + +--- + +## 7. 客户端管理 API(数据库层) + +### `GET /panel/api/inbounds/getClientTraffics/:email` + +**数据库操作:** GORM 标准查询。 + +```go +db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics) +``` + +**MariaDB 无改动。** + +--- + +### `GET /panel/api/inbounds/getClientTrafficsById/:id` + +**数据库操作:** **使用 `JSON_EACH`,需改造。** + +改造方案见 [5.1.3](#513-webserviceinboundgo2057-2082--getclienttrafficbyid)。 + +--- + +### `POST /panel/api/inbounds/addClient` + +**数据库操作:** GORM `Save` 更新 `inbound.Settings` JSON 字段 + `Create` 客户端流量记录。 + +**MariaDB 无改动。** 客户端信息存储在 `inbounds.settings` 的 JSON 中,应用层解析。 + +--- + +### `POST /panel/api/inbounds/:id/delClient/:clientId` + +**数据库操作:** GORM `Delete` 删除客户端流量记录 + `Save` 更新入站 Settings。 + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/updateClient/:clientId` + +**数据库操作:** GORM `Updates` 更新客户端流量 + `Save` 更新入站 Settings。 + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/:id/delClientByEmail/:email` + +**数据库操作:** GORM `Delete` + `Save`,可移植。 + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/delDepletedClients/:id` + +**数据库操作:** 使用 `GROUP_CONCAT` 和条件查询。 + +```go +db.Model(xray.ClientTraffic{}). + Where(whereText+" and ((total > 0 and up + down >= total) or ...)", id, now). + Select("inbound_id, GROUP_CONCAT(email) as email"). + Group("inbound_id"). + Find(&depletedClients) +``` + +**MariaDB 兼容:** `GROUP_CONCAT` 在 MariaDB 中工作正常,**无需改动**。 + +--- + +## 8. 流量管理 API(数据库层) + +### `POST /panel/api/inbounds/:id/resetClientTraffic/:email` + +**数据库操作:** GORM `Updates`,可移植。 + +```go +db.Model(xray.ClientTraffic{}). + Where("email = ?", email). + Updates(map[string]any{"enable": true, "up": 0, "down": 0}) +``` + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/resetAllTraffics` + +**数据库操作:** GORM `Updates`,可移植。 + +```go +db.Model(model.Inbound{}).Where("user_id > ?", 0). + Updates(map[string]any{"up": 0, "down": 0}) +``` + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/resetAllClientTraffics/:id` + +**数据库操作:** GORM 事务 + `Updates`,可移植。 + +```go +db.Transaction(func(tx *gorm.DB) error { + tx.Model(xray.ClientTraffic{}).Where(whereText, id). + Updates(map[string]any{"enable": true, "up": 0, "down": 0}) + tx.Model(model.Inbound{}).Where(inboundWhereText, id). + Update("last_traffic_reset_time", now) + return nil +}) +``` + +**MariaDB 无改动。** InnoDB 事务支持良好。 + +--- + +### `POST /panel/api/inbounds/updateClientTraffic/:email` + +**数据库操作:** GORM `Updates`,可移植。 + +```go +db.Model(xray.ClientTraffic{}).Where("email = ?", email). + Updates(map[string]any{"up": upload, "down": download}) +``` + +**MariaDB 无改动。** + +--- + +### `addInboundTraffic` — 原子递增 + +```go +tx.Model(&model.Inbound{}).Where("tag = ?", traffic.Tag). + Updates(map[string]any{ + "up": gorm.Expr("up + ?", traffic.Up), + "down": gorm.Expr("down + ?", traffic.Down), + "all_time": gorm.Expr("COALESCE(all_time, 0) + ?", traffic.Up+traffic.Down), + }) +``` + +**MariaDB 兼容:** `gorm.Expr` 直接生成 SQL,`COALESCE` 在 MariaDB 中支持。**无需改动。** + +--- + +### 自动禁用逻辑 — `disableInvalidClients` / `disableInvalidInbounds` + +```go +// Join 查询 +tx.Table("inbounds"). + Select("inbounds.tag, client_traffics.email"). + Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id"). + Where("((client_traffics.total > 0 AND ...) OR ...) AND client_traffics.enable = ?", now, true). + Scan(&results) +``` + +**MariaDB 兼容:** 标准 SQL JOIN。**无需改动。** + +--- + +## 9. IP 记录管理 API(数据库层) + +### `POST /panel/api/inbounds/clientIps/:email` + +**数据库操作:** GORM `First` 查询。 + +```go +db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps) +``` + +**MariaDB 无改动。** + +--- + +### `POST /panel/api/inbounds/clearClientIps/:email` + +**数据库操作:** GORM `Update`。 + +```go +db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).Update("ips", "") +``` + +**MariaDB 无改动。** + +--- + +## 10. 面板配置 API(数据库层) + +### `POST /panel/setting/all` + +**数据库操作:** GORM `Find` 查询 settings 表,应用层通过反射映射到 `AllSetting` 结构体。 + +```go +db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings) +``` + +**MariaDB 无改动。** `key` 字段在 MariaDB 中是保留字,但 GORM 会自动加反引号。 + +--- + +### `POST /panel/setting/update` + +**数据库操作:** 逐字段 `saveSetting`(`First` → `Save` 或 `Create`)。 + +**MariaDB 无改动。** + +--- + +## 11. 用户管理 API(数据库层) + +### `POST /panel/setting/updateUser` + +**数据库操作:** GORM `First` + `Updates`。 + +```go +db.Model(model.User{}).Where("username = ?", username).First(user) +db.Model(model.User{}).Where("id = ?", id). + Updates(map[string]any{"username": username, "password": hashedPassword}) +``` + +**MariaDB 无改动。** + +--- + +### 认证与 LDAP + +`CheckUser` 中的数据库查询使用 GORM 标准 API,LDAP 认证不涉及数据库。**MariaDB 无改动。** + +--- + +## 12. 数据库导入导出(MariaDB 方案) + +### `GET /panel/api/server/getDb` + +**当前 SQLite 实现:** 直接复制 `x-ui.db` 文件提供下载。 + +**MariaDB 替代方案:** + +```go +func (s *ServerService) GetDb(c *gin.Context) error { + db := database.GetDB() + sqlDB, _ := db.DB() + + // 方案 A:mysqldump 逻辑(推荐) + // 导出所有表的 SQL dump + cmd := exec.Command("mysqldump", + "-u", dbUser, "-p"+dbPassword, + "-h", dbHost, "-P", dbPort, + "--single-transaction", + "--result-file=/tmp/xui-backup.sql", + dbName, + ) + if err := cmd.Run(); err != nil { + return err + } + c.FileAttachment("/tmp/xui-backup.sql", "xui-backup.sql") + + // 方案 B:JSON 导出(应用层) + // 逐表读取数据并序列化为 JSON + dump := map[string]any{} + var inbounds []model.Inbound + db.Find(&inbounds) + dump["inbounds"] = inbounds + // ... 其他表 + c.JSON(200, dump) +} +``` + +### `POST /panel/api/server/importDB` + +**当前 SQLite 实现:** 接收 `.db` 文件,覆盖当前数据库文件。 + +**MariaDB 替代方案:** + +```go +func (s *ServerService) ImportDB(file *multipart.FileHeader) error { + // 方案 A:SQL dump 导入 + cmd := exec.Command("mysql", + "-u", dbUser, "-p"+dbPassword, + "-h", dbHost, "-P", dbPort, + dbName, + "-e", "source /tmp/xui-import.sql", + ) + return cmd.Run() + + // 方案 B:JSON 导入 + // 解析 JSON,逐表 truncate 后重新插入 +} +``` + +--- + +## 13. 订阅服务的数据库查询改造 + +> 原始文件:`sub/subService.go` + +### `getInboundsBySubId()` — 需改造 + +详见 [5.1.4](#514-subsubservicego115-130--getinboundsbysubid)。 + +### `getFallbackMaster()` — 需改造 + +详见 [5.1.5](#515-subsubservicego141-162--getfallbackmaster)。 + +### 其他订阅逻辑 + +链接生成、流量统计、HTML 渲染等均为应用层逻辑,不涉及 SQLite 特有 SQL。**MariaDB 无改动。** + +--- + +## 14. 数据库维护操作(MariaDB 对应) + +| SQLite 操作 | 用途 | MariaDB 等价操作 | +|---|---|---| +| `PRAGMA wal_checkpoint` | 刷写 WAL 日志 | `FLUSH TABLES` | +| `PRAGMA integrity_check` | 数据完整性检查 | `CHECK TABLE table_name` | +| `VACUUM` | 回收空间/碎片整理 | `OPTIMIZE TABLE table_name` | +| `PRAGMA journal_mode=WAL` | 日志模式 | 无需设置(InnoDB 自带崩溃恢复) | +| 文件复制备份 | 备份数据库 | `mysqldump --single-transaction` | +| 文件替换恢复 | 恢复数据库 | `mysql < backup.sql` | + +### InnoDB 原生优势 + +MariaDB 的 InnoDB 引擎自带以下特性,无需额外操作: +- **崩溃恢复**:通过 redo/undo 日志自动恢复 +- **行级锁**:并发性能远优于 SQLite 的库级锁 +- **事务隔离**:支持 READ COMMITTED、REPEATABLE READ 等级别 + +--- + +## 15. 迁移实施步骤 + +### 步骤 1:安装 MariaDB 并创建数据库 + +```bash +# Ubuntu/Debian +apt install mariadb-server +mysql_secure_installation + +# 创建数据库和用户 +mysql -u root -p < xui-data.sql +# 手动调整 SQL 兼容性后导入 +mysql xui < xui-data.sql +``` + +### 步骤 6:测试验证 + +- [ ] 启动面板,确认 `AutoMigrate` 正确创建所有表 +- [ ] 登录认证正常 +- [ ] CRUD 入站/客户端正常 +- [ ] 流量统计正确累加 +- [ ] 订阅链接生成正常 +- [ ] 客户端在线状态跟踪正常 +- [ ] 数据库导出/导入正常 +- [ ] 并发写入无锁冲突 + +--- + +## 附录 A:完整代码改动清单 + +| 文件 | 改动类型 | 说明 | +|---|---|---| +| `go.mod` | 依赖替换 | `sqlite` → `mysql` driver | +| `database/db.go` | 连接层改造 | 支持双驱动初始化 | +| `database/db.go` | 删除/改造 | `IsSQLiteDB`, `ValidateSQLiteDB`, `Checkpoint` | +| `config/config.go` | 新增环境变量 | `XUI_DB_DRIVER/HOST/PORT/NAME/USER/PASSWORD` | +| `web/service/inbound.go:144` | SQL → 应用层 | `getAllEmails()` | +| `web/service/inbound.go:1313` | SQL → 应用层 | `MigrationRemoveOrphanedTraffics()` | +| `web/service/inbound.go:2057` | SQL → 应用层 | `GetClientTrafficByID()` | +| `web/service/inbound.go:2206` | SQL → MariaDB | `MigrationRequirements()` 中的 `VACUUM` | +| `sub/subService.go:115` | SQL → 应用层 | `getInboundsBySubId()` | +| `sub/subService.go:141` | SQL → 应用层 | `getFallbackMaster()` | +| `web/controller/server.go` | 导入导出改造 | `getDb` / `importDB` | + +## 附录 B:风险评估 + +| 风险项 | 等级 | 缓解措施 | +|---|---|---| +| `JSON_EACH` 无等价函数 | **高** | 改用应用层解析,可能影响大数据量性能 | +| JSON 字段查询性能 | 中 | MariaDB 原生 JSON 类型 + 虚拟列索引 | +| 数据迁移完整性 | 中 | 先导出 JSON 备份,再逐表验证行数 | +| 并发写入锁行为变化 | 低 | InnoDB 行级锁,实际上优于 SQLite | +| 大小写敏感性差异 | 低 | 统一使用 `utf8mb4_unicode_ci` 排序规则 | diff --git a/docs/API-DB.md b/docs/API-DB.md new file mode 100644 index 00000000..03846052 --- /dev/null +++ b/docs/API-DB.md @@ -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"` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..c86ad366 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,1059 @@ +# 3x-ui API 文档 + +> 基础路径: `{basePath}`(可配置,默认 `/`) +> Web 框架: Gin (Go) +> 所有 JSON API 响应(除特别说明外)使用统一格式: `{ "success": bool, "msg": string, "obj": any }` + +--- + +## 目录 + +- [1. 认证接口](#1-认证接口) +- [2. 面板页面](#2-面板页面) +- [3. 面板设置](#3-面板设置) +- [4. Xray 设置](#4-xray-设置) +- [5. 入站 API](#5-入站-api) +- [6. 服务器 API](#6-服务器-api) +- [7. WebSocket](#7-websocket) +- [8. 订阅服务](#8-订阅服务) +- [数据模型](#数据模型) + +--- + +## 1. 认证接口 + +### `GET /` + +根路径。已认证时重定向到 `/panel/`,否则显示登录页面。 + +--- + +### `POST /login` + +用户登录认证。 + +**请求体**(JSON 或表单): + +```json +{ + "username": "string", + "password": "string", + "twoFactorCode": "string" // 可选,开启双因素认证时必填 +} +``` + +**成功响应:** + +```json +{ "success": true, "msg": "Successfully logged in", "obj": null } +``` + +**错误响应:** + +- `success: false, msg: "Username cannot be empty"` +- `success: false, msg: "Password cannot be empty"` +- `success: false, msg: "Wrong username or password"` + +--- + +### `GET /logout` + +清除会话并重定向到基础路径。 + +--- + +### `POST /getTwoFactorEnable` + +查询是否开启双因素认证。 + +**响应:** + +```json +{ "success": true, "msg": "", "obj": true } +``` + +--- + +## 2. 面板页面 + +> `/panel` 下的所有路由需要认证(`checkLogin` 中间件)。 + +### `GET /panel/` + +面板主页面(HTML)。 + +### `GET /panel/inbounds` + +入站管理页面(HTML)。 + +### `GET /panel/settings` + +面板设置页面(HTML)。 + +### `GET /panel/xray` + +Xray 配置页面(HTML)。 + +--- + +## 3. 面板设置 + +> `/panel/setting` 下的所有路由需要认证。 + +### `POST /panel/setting/all` + +获取所有面板配置。 + +**响应 (`obj`):** `AllSetting` 对象(详见 [数据模型](#allsetting))。 + +--- + +### `POST /panel/setting/defaultSettings` + +获取默认配置(根据请求 Host 头生成)。 + +**响应 (`obj`):** 默认 `AllSetting` 对象。 + +--- + +### `POST /panel/setting/update` + +更新面板配置。 + +**请求体:** `AllSetting` 对象(JSON 或表单)。 + +**响应:** + +```json +{ "success": true, "msg": "Settings modified successfully", "obj": null } +``` + +--- + +### `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"` + +--- + +### `POST /panel/setting/restartPanel` + +重启面板(3 秒后重启)。 + +**响应:** + +```json +{ "success": true, "msg": "Panel restart successful", "obj": null } +``` + +--- + +### `GET /panel/setting/getDefaultJsonConfig` + +获取默认 Xray JSON 配置。 + +**响应 (`obj`):** Xray 配置 JSON 对象。 + +--- + +## 4. Xray 设置 + +> `/panel/xray` 下的所有路由需要认证。 + +### `POST /panel/xray/` + +获取当前 Xray 配置及元数据。 + +**响应 (`obj`):** + +```json +{ + "xraySetting": { /* Xray 配置 JSON */ }, + "inboundTags": ["tag1", "tag2"], + "outboundTestUrl": "https://www.google.com/generate_204" +} +``` + +--- + +### `GET /panel/xray/getDefaultJsonConfig` + +获取默认 Xray JSON 配置。 + +**响应 (`obj`):** Xray 配置 JSON 对象。 + +--- + +### `GET /panel/xray/getOutboundsTraffic` + +获取出站流量统计。 + +**响应 (`obj`):** `OutboundTraffics` 对象数组。 + +--- + +### `GET /panel/xray/getXrayResult` + +获取当前 Xray 服务运行状态。 + +**响应 (`obj`):** Xray 服务状态字符串。 + +--- + +### `POST /panel/xray/warp/:action` + +管理 Cloudflare Warp 集成。 + +**URL 参数:** + +| 参数 | 可选值 | +|---|---| +| `:action` | `data`、`del`、`config`、`reg`、`license` | + +**请求体**(表单,取决于 action): + +| Action | 字段 | +|---|---| +| `reg` | `privateKey`、`publicKey` | +| `license` | `license` | +| 其他 | 无 | + +**响应 (`obj`):** Warp 数据/配置字符串。`del` 操作返回 `obj: null`。 + +--- + +### `POST /panel/xray/update` + +更新 Xray 配置。 + +**请求体**(表单): + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `xraySetting` | string (JSON) | 是 | Xray 配置 JSON | +| `outboundTestUrl` | string | 否 | 默认: `https://www.google.com/generate_204` | + +**响应:** + +```json +{ "success": true, "msg": "Settings modified successfully", "obj": null } +``` + +--- + +### `POST /panel/xray/resetOutboundsTraffic` + +重置出站流量统计。 + +**请求体**(表单): + +| 字段 | 类型 | 必填 | +|---|---|---| +| `tag` | string | 是 | + +**响应 (`obj`):** `""` + +--- + +### `POST /panel/xray/testOutbound` + +测试出站连通性。 + +**请求体**(表单): + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `outbound` | string (JSON) | 是 | 要测试的出站配置 | +| `allOutbounds` | string (JSON 数组) | 否 | 所有出站配置(用于解析 dialerProxy 依赖) | + +**响应 (`obj`):** 测试结果,包含延迟/响应时间。 + +--- + +## 5. 入站 API + +> `/panel/api` 下的所有路由需要通过 `checkAPIAuth` 中间件认证(未认证时返回 404 以隐藏接口存在)。 + +### `GET /panel/api/inbounds/list` + +获取当前用户的所有入站列表。 + +**响应 (`obj`):** `[]Inbound` + +--- + +### `GET /panel/api/inbounds/get/:id` + +根据 ID 获取指定入站。 + +**URL 参数:** `:id`(int) + +**响应 (`obj`):** `Inbound` 对象。 + +--- + +### `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/add` + +添加新入站。 + +**请求体:** `Inbound` 对象(JSON 或表单,详见 [数据模型](#inbound))。 + +**响应 (`obj`):** 创建的 `Inbound` 对象。通过 WebSocket 广播更新。 + +--- + +### `POST /panel/api/inbounds/del/:id` + +删除入站。 + +**URL 参数:** `:id`(int) + +**响应 (`obj`):** 被删除的入站 ID(int)。通过 WebSocket 广播更新。 + +--- + +### `POST /panel/api/inbounds/update/:id` + +更新入站。 + +**URL 参数:** `:id`(int) + +**请求体:** `Inbound` 对象(JSON 或表单)。 + +**响应 (`obj`):** 更新后的 `Inbound` 对象。通过 WebSocket 广播更新。 + +--- + +### `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 } +``` + +--- + +### `POST /panel/api/inbounds/addClient` + +向入站添加客户端。 + +**请求体:** `Inbound` 对象,新客户端信息在其 `Settings` JSON 中。 + +**响应:** + +```json +{ "success": true, "msg": "Client added successfully", "obj": null } +``` + +--- + +### `POST /panel/api/inbounds/:id/delClient/:clientId` + +从入站删除客户端。 + +**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/resetClientTraffic/:email` + +重置指定客户端的流量统计。 + +**URL 参数:** `:id`(int)、`:email`(string) + +**响应:** + +```json +{ "success": true, "msg": "Client traffic reset successfully", "obj": null } +``` + +--- + +### `POST /panel/api/inbounds/resetAllTraffics` + +重置所有流量统计。 + +**响应:** + +```json +{ "success": true, "msg": "All traffic reset successfully", "obj": null } +``` + +--- + +### `POST /panel/api/inbounds/resetAllClientTraffics/:id` + +重置入站下所有客户端的流量。 + +**URL 参数:** `:id`(int) + +**响应:** + +```json +{ "success": true, "msg": "All client traffic reset successfully", "obj": null } +``` + +--- + +### `POST /panel/api/inbounds/delDepletedClients/:id` + +删除入站中所有流量耗尽的客户端。 + +**URL 参数:** `:id`(int) + +**响应:** + +```json +{ "success": true, "msg": "Depleted clients deleted successfully", "obj": null } +``` + +--- + +### `POST /panel/api/inbounds/import` + +导入入站配置。 + +**请求体**(表单): + +| 字段 | 类型 | 必填 | +|---|---|---| +| `data` | string (JSON) | 是 | + +`data` 字段为 JSON 序列化的 `Inbound` 对象。 + +**响应 (`obj`):** 创建的 `Inbound` 对象。通过 WebSocket 广播更新。 + +--- + +### `POST /panel/api/inbounds/onlines` + +获取当前在线客户端。 + +**响应 (`obj`):** 在线客户端标识列表。 + +--- + +### `POST /panel/api/inbounds/lastOnline` + +获取所有客户端的最后在线时间。 + +**响应 (`obj`):** 客户端邮箱到最近在线时间戳的映射。 + +--- + +### `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 } +``` + +--- + +### `POST /panel/api/inbounds/:id/delClientByEmail/:email` + +根据邮箱删除客户端。 + +**URL 参数:** `:id`(int)、`:email`(string) + +**响应:** + +```json +{ "success": true, "msg": "Client deleted successfully", "obj": null } +``` + +--- + +### `GET /panel/api/backuptotgbot` + +向 Telegram Bot 管理员发送数据库备份。 + +**响应:** 空 `200 OK`。 + +--- + +## 6. 服务器 API + +> `/panel/api/server` 下的所有路由需要通过 `checkAPIAuth` 认证。 + +### `GET /panel/api/server/status` + +获取服务器状态(CPU、内存、磁盘、网络、运行时间)。 + +**响应 (`obj`):** 服务器状态对象(每 2 秒刷新)。 + +--- + +### `GET /panel/api/server/cpuHistory/:bucket` + +获取 CPU 使用历史。 + +**URL 参数:** `:bucket`(int)—— 每个数据点的秒数。 + +| 允许值 | 说明 | +|---|---| +| `2` | 每 2 秒一个点(最近 120 秒) | +| `30` | 每 30 秒一个点(最近 30 分钟) | +| `60` | 每 1 分钟一个点(最近 1 小时) | +| `120` | 每 2 分钟一个点(最近 2 小时) | +| `180` | 每 3 分钟一个点(最近 3 小时) | +| `300` | 每 5 分钟一个点(最近 5 小时) | + +**响应 (`obj`):** CPU 历史数据点数组(最多 60 个采样点)。 + +--- + +### `GET /panel/api/server/getXrayVersion` + +获取可用的 Xray 版本列表。 + +**响应 (`obj`):** `[]string` —— 版本列表(缓存 60 秒)。 + +--- + +### `GET /panel/api/server/getConfigJson` + +获取当前 Xray 配置 JSON。 + +**响应 (`obj`):** Xray 配置 JSON。 + +--- + +### `GET /panel/api/server/getDb` + +下载 SQLite 数据库文件。 + +**响应:** 二进制文件下载(`application/octet-stream`,文件名 `x-ui.db`)。不使用 `Msg` 包装格式。 + +--- + +### `GET /panel/api/server/getNewUUID` + +生成新的 UUID。 + +**响应 (`obj`):** UUID 字符串。 + +--- + +### `GET /panel/api/server/getNewX25519Cert` + +生成新的 X25519 密钥对。 + +**响应 (`obj`):** X25519 密钥对数据。 + +--- + +### `GET /panel/api/server/getNewmldsa65` + +生成新的 ML-DSA-65 密钥。 + +**响应 (`obj`):** ML-DSA-65 密钥数据。 + +--- + +### `GET /panel/api/server/getNewmlkem768` + +生成新的 ML-KEM-768 密钥。 + +**响应 (`obj`):** ML-KEM-768 密钥数据。 + +--- + +### `GET /panel/api/server/getNewVlessEnc` + +生成新的 VLESS 加密密钥。 + +**响应 (`obj`):** VLESS 加密密钥数据。 + +--- + +### `POST /panel/api/server/stopXrayService` + +停止 Xray 服务。通过 WebSocket 广播状态变更。 + +**响应:** + +```json +{ "success": true, "msg": "Xray service stopped successfully", "obj": null } +``` + +--- + +### `POST /panel/api/server/restartXrayService` + +重启 Xray 服务。通过 WebSocket 广播状态变更。 + +**响应:** + +```json +{ "success": true, "msg": "Xray service restarted successfully", "obj": null } +``` + +--- + +### `POST /panel/api/server/installXray/:version` + +安装/切换到指定 Xray 版本。 + +**URL 参数:** `:version`(string) + +**响应:** + +```json +{ "success": true, "msg": "Xray version switched", "obj": null } +``` + +--- + +### `POST /panel/api/server/updateGeofile` + +### `POST /panel/api/server/updateGeofile/:fileName` + +更新 GeoIP/Geosite 数据文件。 + +**URL 参数:** `:fileName`(string,可选)—— 指定要更新的文件名。省略时更新所有文件。 + +**响应:** + +```json +{ "success": true, "msg": "Geo file update result", "obj": null } +``` + +--- + +### `POST /panel/api/server/logs/:count` + +获取应用日志。 + +**URL 参数:** `:count`(int)—— 日志行数。 + +**请求体**(表单): + +| 字段 | 类型 | 说明 | +|---|---|---| +| `level` | string | 日志级别过滤 | +| `syslog` | string | 系统日志过滤 | + +**响应 (`obj`):** 日志内容字符串。 + +--- + +### `POST /panel/api/server/xraylogs/:count` + +获取 Xray 服务日志。 + +**URL 参数:** `:count`(int)—— 日志行数。 + +**请求体**(表单): + +| 字段 | 类型 | 说明 | +|---|---|---| +| `filter` | string | 日志过滤关键词 | +| `showDirect` | string | 显示直连日志 | +| `showBlocked` | string | 显示被阻止的日志 | +| `showProxy` | string | 显示代理日志 | + +**响应 (`obj`):** 过滤后的 Xray 日志内容字符串。 + +--- + +### `POST /panel/api/server/importDB` + +导入数据库备份。导入后自动重启 Xray。 + +**请求体:** multipart 文件上传(字段名 `db`)。 + +**响应 (`obj`):** `"Database imported successfully"` + +--- + +### `POST /panel/api/server/getNewEchCert` + +生成新的 ECH(Encrypted Client Hello)证书。 + +**请求体**(表单): + +| 字段 | 类型 | 必填 | +|---|---|---| +| `sni` | string | 是 | + +**响应 (`obj`):** ECH 证书数据。 + +--- + +## 7. WebSocket + +### `GET {basePath}/ws` + +WebSocket 端点,用于面板实时更新(服务器状态、入站变更、Xray 状态、通知)。 + +--- + +## 8. 订阅服务 + +> 运行在独立端口(可配置)。独立的 Gin 服务器。 + +### `GET {subPath}:subid` + +获取客户端订阅链接。 + +**URL 参数:** `:subid`(string)—— 订阅 ID。 + +**查询参数:** + +| 参数 | 说明 | +|---|---| +| `html=1` 或 `view=html` | 强制渲染 HTML 页面 | + +**响应(订阅客户端):** + +- Content-Type: `text/plain` +- Body: 每行一个代理分享链接(`vmess://`、`vless://`、`trojan://`、`ss://`) +- 若开启 `subEncrypt`:body 经过 base64 编码 + +**响应(浏览器 / `?html=1`):** + +- Content-Type: `text/html` +- HTML 页面,展示流量统计、到期时间、代理链接 + +**响应头:** + +| 响应头 | 说明 | +|---|---| +| `Subscription-Userinfo` | `upload=N; download=N; total=N; expire=N`(字节数,Unix 时间戳) | +| `Profile-Update-Interval` | 重新拉取间隔(分钟,默认 `10`) | +| `Profile-Title` | Base64 编码的配置标题(已配置时) | +| `Support-Url` | 支持页面 URL(已配置时) | +| `Profile-Web-Page-Url` | 配置网页 URL | +| `Announce` | Base64 编码的公告(已配置时) | +| `Routing-Enable` | `"true"` 或 `"false"` | +| `Routing` | 自定义路由规则 JSON(已配置时) | + +**错误:** `400 Bad Request`,body 为 `"Error!"` + +--- + +### `GET {jsonPath}:subid` + +获取 JSON 格式订阅配置(仅在 `subJsonEnable` 为 true 时注册此路由)。 + +**URL 参数:** `:subid`(string)—— 订阅 ID。 + +**响应:** + +- Content-Type: `text/plain` +- Body: JSON 字符串,包含完整客户端配置(分片、噪声、多路复用、路由规则) +- 响应头与订阅链接接口相同 + +**错误:** `400 Bad Request`,body 为 `"Error!"` + +--- + +## 数据模型 + +### 统一响应格式 (`Msg`) + +```json +{ + "success": true, + "msg": "string", + "obj": null +} +``` + +### Inbound + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 主键 | +| `up` | int64 | 上行流量(字节) | +| `down` | int64 | 下行流量(字节) | +| `total` | int64 | 总流量限制(字节) | +| `allTime` | int64 | 累计总流量(字节) | +| `remark` | string | 入站备注/名称 | +| `enable` | bool | 是否启用 | +| `expiryTime` | int64 | 过期时间戳(毫秒,0 = 永不过期) | +| `trafficReset` | string | 流量重置周期(默认 `"never"`) | +| `lastTrafficResetTime` | int64 | 上次流量重置时间戳 | +| `clientStats` | []ClientTraffic | 客户端流量统计 | +| `listen` | string | 监听地址 | +| `port` | int | 监听端口 | +| `protocol` | string | 协议:`vmess`、`vless`、`trojan`、`shadowsocks`、`http`、`mixed`、`wireguard`、`tunnel` | +| `settings` | string (JSON) | 协议相关设置 | +| `streamSettings` | string (JSON) | 传输/流设置 | +| `tag` | string | 唯一的 Xray 入站标签 | +| `sniffing` | string (JSON) | 探测配置 | + +### ClientTraffic + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 主键 | +| `inboundId` | int | 所属入站 ID | +| `enable` | bool | 是否启用 | +| `email` | string | 客户端邮箱(唯一) | +| `uuid` | string | 客户端 UUID(非持久化) | +| `subId` | string | 订阅 ID(非持久化) | +| `up` | int64 | 上行流量(字节) | +| `down` | int64 | 下行流量(字节) | +| `allTime` | int64 | 累计总流量(字节) | +| `expiryTime` | int64 | 过期时间戳(毫秒) | +| `total` | int64 | 总流量限制(字节) | +| `reset` | int | 流量重置计数器 | +| `lastOnline` | int64 | 最后在线时间戳 | + +### Client + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | 客户端 ID | +| `security` | string | 加密方式 | +| `password` | string | 客户端密码 | +| `flow` | string | VLESS flow 类型 | +| `email` | string | 客户端邮箱 | +| `limitIp` | int | IP 限制(0 = 不限制) | +| `totalGB` | int64 | 流量限制(字节) | +| `expiryTime` | int64 | 过期时间戳(毫秒) | +| `enable` | bool | 是否启用 | +| `tgId` | int64 | Telegram 用户 ID | +| `subId` | string | 订阅 ID | +| `comment` | string | 客户端备注 | +| `reset` | int | 流量重置计数器 | + +### User + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 主键 | +| `username` | string | 登录用户名 | +| `password` | string | 登录密码 | + +### OutboundTraffics + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 主键 | +| `tag` | string | 出站标签(唯一) | +| `up` | int64 | 上行流量(字节) | +| `down` | int64 | 下行流量(字节) | +| `total` | int64 | 总流量限制(字节) | + +### InboundClientIps + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 主键 | +| `clientEmail` | string | 客户端邮箱(唯一) | +| `ips` | string | IP 地址(JSON 字符串) | + +### AllSetting + +**Web 服务器:** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `webListen` | string | 监听地址 | +| `webDomain` | string | 域名 | +| `webPort` | int | 端口 | +| `webCertFile` | string | TLS 证书文件路径 | +| `webKeyFile` | string | TLS 私钥文件路径 | +| `webBasePath` | string | 基础路径 | +| `sessionMaxAge` | int | 会话最大有效期(天) | + +**界面:** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `pageSize` | int | 分页大小 | +| `expireDiff` | int | 到期提醒天数差 | +| `trafficDiff` | int | 流量提醒差值 | +| `remarkModel` | string | 备注显示模式 | +| `datepicker` | string | 日期选择器格式 | + +**Telegram Bot:** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `tgBotEnable` | bool | 是否启用 Telegram Bot | +| `tgBotToken` | string | Bot Token | +| `tgBotProxy` | string | Bot 代理地址 | +| `tgBotAPIServer` | string | Bot API 服务器地址 | +| `tgBotChatId` | string | 管理员 Chat ID | +| `tgRunTime` | string | 定时任务执行时间 | +| `tgBotBackup` | bool | 是否启用自动备份 | +| `tgBotLoginNotify` | bool | 是否启用登录通知 | +| `tgCpu` | int | CPU 告警阈值 | +| `tgLang` | string | Bot 语言 | + +**安全:** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `timeLocation` | string | 时区 | +| `twoFactorEnable` | bool | 是否开启双因素认证 | +| `twoFactorToken` | string | 双因素认证令牌 | + +**订阅服务:** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `subEnable` | bool | 是否启用订阅服务 | +| `subJsonEnable` | bool | 是否启用 JSON 订阅 | +| `subTitle` | string | 订阅标题 | +| `subSupportUrl` | string | 支持页面 URL | +| `subProfileUrl` | string | 配置页面 URL | +| `subAnnounce` | string | 公告内容 | +| `subEnableRouting` | bool | 是否启用路由规则 | +| `subRoutingRules` | string | 自定义路由规则 | +| `subListen` | string | 订阅服务监听地址 | +| `subPort` | int | 订阅服务端口 | +| `subPath` | string | 订阅路径 | +| `subDomain` | string | 订阅服务域名 | +| `subCertFile` | string | 订阅服务 TLS 证书路径 | +| `subKeyFile` | string | 订阅服务 TLS 私钥路径 | +| `subUpdates` | int | 客户端更新间隔(分钟) | +| `subEncrypt` | bool | 是否加密订阅内容 | +| `subShowInfo` | bool | 是否显示服务器信息 | +| `subURI` | string | 订阅 URI | +| `subJsonPath` | string | JSON 订阅路径 | +| `subJsonURI` | string | JSON 订阅 URI | +| `subJsonFragment` | string | TLS 分片配置 | +| `subJsonNoises` | string | WebSocket/HTTP 噪声配置 | +| `subJsonMux` | string | 多路复用配置 | +| `subJsonRules` | string | 自定义路由规则 | +| `externalTrafficInformEnable` | bool | 是否启用外部流量通知 | +| `externalTrafficInformURI` | string | 外部流量通知 URI | + +**LDAP:** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ldapEnable` | bool | 是否启用 LDAP | +| `ldapHost` | string | LDAP 服务器地址 | +| `ldapPort` | int | LDAP 端口 | +| `ldapUseTLS` | bool | 是否使用 TLS | +| `ldapBindDN` | string | 绑定 DN | +| `ldapPassword` | string | 绑定密码 | +| `ldapBaseDN` | string | 基础 DN | +| `ldapUserFilter` | string | 用户过滤器 | +| `ldapUserAttr` | string | 用户属性 | +| `ldapVlessField` | string | VLESS 字段映射 | +| `ldapSyncCron` | string | 同步周期(Cron 表达式) | +| `ldapFlagField` | string | 标志字段 | +| `ldapTruthyValues` | string | 真值列表 | +| `ldapInvertFlag` | bool | 是否反转标志 | +| `ldapInboundTags` | string | 关联入站标签 | +| `ldapAutoCreate` | bool | 是否自动创建客户端 | +| `ldapAutoDelete` | bool | 是否自动删除客户端 | +| `ldapDefaultTotalGB` | int | 默认流量限制(GB) | +| `ldapDefaultExpiryDays` | int | 默认有效天数 | +| `ldapDefaultLimitIP` | int | 默认 IP 限制 | + +--- + +## 向后兼容重定向 + +以下重定向自动处理(301): + +| 原路径 | 重定向到 | +|---|---| +| `/panel/API/*` | `/panel/api/*` | +| `/xui/API/*` | `/panel/api/*` | +| `/xui/*` | `/panel/*` | diff --git a/docs/install-logic.md b/docs/install-logic.md new file mode 100644 index 00000000..81409a44 --- /dev/null +++ b/docs/install-logic.md @@ -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 追踪频繁变化的数据库。 diff --git a/docs/superpowers/plans/2026-04-02-json-settings.md b/docs/superpowers/plans/2026-04-02-json-settings.md new file mode 100644 index 00000000..9a0c45f8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-json-settings.md @@ -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. diff --git a/docs/superpowers/plans/2026-04-02-pre-release-install-update.md b/docs/superpowers/plans/2026-04-02-pre-release-install-update.md new file mode 100644 index 00000000..2af451aa --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-pre-release-install-update.md @@ -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" +``` diff --git a/docs/superpowers/specs/2026-04-02-json-settings-design.md b/docs/superpowers/specs/2026-04-02-json-settings-design.md new file mode 100644 index 00000000..874804fc --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-json-settings-design.md @@ -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: `/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 diff --git a/docs/superpowers/specs/2026-04-02-pre-release-install-update-design.md b/docs/superpowers/specs/2026-04-02-pre-release-install-update-design.md new file mode 100644 index 00000000..701d5377 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-pre-release-install-update-design.md @@ -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) diff --git a/docs/x-panel-device-limit.md b/docs/x-panel-device-limit.md new file mode 100644 index 00000000..f3fb1eaf --- /dev/null +++ b/docs/x-panel-device-limit.md @@ -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 | diff --git a/docs/x-ui-logic.md b/docs/x-ui-logic.md new file mode 100644 index 00000000..34dc692e --- /dev/null +++ b/docs/x-ui-logic.md @@ -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 分离,互不影响。 diff --git a/web/controller/index.go b/web/controller/index.go index fd607ab6..82373527 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -35,6 +35,7 @@ type IndexController struct { settingService service.SettingService userService service.UserService + inboundService service.InboundService 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 { errMsg := err.Error() if strings.Contains(errMsg, "already exists") { diff --git a/web/service/inbound.go b/web/service/inbound.go index 8a3a4ae2..babbab36 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -190,25 +190,33 @@ func (s *InboundService) checkEmailExistForInbound(inbound *model.Inbound) (stri if err != nil { return "", err } - allEmails, err := s.getAllEmails() - if err != nil { - return "", err - } var emails []string for _, client := range clients { if client.Email != "" { if s.contains(emails, client.Email) { return client.Email, nil } - if s.contains(allEmails, client.Email) { - return client.Email, nil - } emails = append(emails, client.Email) } } 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. // 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. @@ -582,19 +590,25 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) { 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) if err != nil { 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 for _, client := range clients { 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 { - existEmail, err := s.checkEmailsExistForClients(clients) + oldInbound, err := s.GetInbound(data.Id) if err != nil { 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 err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings) diff --git a/web/service/setting_test.go b/web/service/setting_test.go new file mode 100644 index 00000000..32208e50 --- /dev/null +++ b/web/service/setting_test.go @@ -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") + } +} diff --git a/web/service/user.go b/web/service/user.go index 8f0e47c0..7afbc3d5 100644 --- a/web/service/user.go +++ b/web/service/user.go @@ -1,9 +1,11 @@ package service import ( + "encoding/json" "errors" "strings" + "github.com/google/uuid" "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" @@ -127,7 +129,7 @@ func (s *UserService) UpdateUser(id int, username string, password string) error Error } -func (s *UserService) RegisterUser(username string, password string) error { +func (s *UserService) RegisterUser(username string, password string, inboundService *InboundService) error { if username == "" { return errors.New("username can not be empty") } @@ -141,19 +143,89 @@ func (s *UserService) RegisterUser(username string, password string) error { } db := database.GetDB() - user := &model.User{ - Username: username, - Password: hashedPassword, - Role: "user", - } - if err := db.Create(user).Error; err != nil { - // Check for unique constraint violation - if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") { - return errors.New("username already exists") + + // Create user and add as client to all inbounds in a single transaction + return db.Transaction(func(tx *gorm.DB) error { + user := &model.User{ + Username: username, + Password: hashedPassword, + Role: "user", } - return err - } - return nil + if err := tx.Create(user).Error; err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") { + 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 { diff --git a/xray/client_traffic.go b/xray/client_traffic.go index fcb2585e..cab4bdc3 100644 --- a/xray/client_traffic.go +++ b/xray/client_traffic.go @@ -6,7 +6,7 @@ type ClientTraffic struct { Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` InboundId int `json:"inboundId" form:"inboundId"` 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:"-"` SubId string `json:"subId" form:"subId" gorm:"-"` Up int64 `json:"up" form:"up"`