diff --git a/.gitignore b/.gitignore index 8fa4eeb0..433c5370 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ x-ui.db docker-compose.override.yml # Ignore .env (Environment Variables) file -.env \ No newline at end of file +.env + +# Ignore local docs directory +/docs/ diff --git a/docs/API-DB-MariaDB.md b/docs/API-DB-MariaDB.md deleted file mode 100644 index eadb849b..00000000 --- a/docs/API-DB-MariaDB.md +++ /dev/null @@ -1,1109 +0,0 @@ -# 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 deleted file mode 100644 index 03846052..00000000 --- a/docs/API-DB.md +++ /dev/null @@ -1,346 +0,0 @@ -# 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 deleted file mode 100644 index c86ad366..00000000 --- a/docs/API.md +++ /dev/null @@ -1,1059 +0,0 @@ -# 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 deleted file mode 100644 index 81409a44..00000000 --- a/docs/install-logic.md +++ /dev/null @@ -1,485 +0,0 @@ -# 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 deleted file mode 100644 index 9a0c45f8..00000000 --- a/docs/superpowers/plans/2026-04-02-json-settings.md +++ /dev/null @@ -1,927 +0,0 @@ -# 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 deleted file mode 100644 index 2af451aa..00000000 --- a/docs/superpowers/plans/2026-04-02-pre-release-install-update.md +++ /dev/null @@ -1,193 +0,0 @@ -# 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 deleted file mode 100644 index 874804fc..00000000 --- a/docs/superpowers/specs/2026-04-02-json-settings-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# 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 deleted file mode 100644 index 701d5377..00000000 --- a/docs/superpowers/specs/2026-04-02-pre-release-install-update-design.md +++ /dev/null @@ -1,79 +0,0 @@ -# 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/superpowers/specs/2026-04-03-mariadb-support-design.md b/docs/superpowers/specs/2026-04-03-mariadb-support-design.md deleted file mode 100644 index ee9138da..00000000 --- a/docs/superpowers/specs/2026-04-03-mariadb-support-design.md +++ /dev/null @@ -1,332 +0,0 @@ -# MariaDB Support for 3x-ui - -## Summary - -Add MariaDB as an alternative database backend to SQLite. Users switch between SQLite and MariaDB via the `x-ui.sh` management script (option 27). Data is automatically migrated during the switch. MariaDB connection credentials are stored in `/etc/x-ui/x-ui.json`. - -## Requirements - -- Support both SQLite and MariaDB as database backends -- Switch via `x-ui.sh` with interactive prompts for MariaDB credentials (IP, port, username, password, database name) -- Auto-migrate data when switching between SQLite and MariaDB -- Keep old database as backup after migration -- MariaDB has core feature parity (CRUD, migrations, seeders) but skips SQLite-specific features (WAL checkpoint, file export/import) -- Credentials stored in `/etc/x-ui/x-ui.json` - -## Architecture: Approach A — Driver-agnostic `InitDB` - -Refactor `database.InitDB()` to read config from the JSON settings file, determine the driver type, and open the appropriate GORM connection. The package-level `var db *gorm.DB` singleton stays unchanged — all callers continue using `database.GetDB()`. - ---- - -## Section 1: Configuration - -### New settings in `web/service/setting.go` - -Add to `defaultValueMap`: - -| Key | Default | Description | -|-----|---------|-------------| -| `dbType` | `"sqlite"` | `"sqlite"` or `"mariadb"` | -| `dbHost` | `"127.0.0.1"` | MariaDB host | -| `dbPort` | `"3306"` | MariaDB port | -| `dbUser` | `""` | MariaDB username | -| `dbPassword` | `""` | MariaDB password | -| `dbName` | `"3xui"` | MariaDB database name | - -Add getter/setter methods: `GetDBType()`, `SetDBType()`, `GetDBHost()`, `SetDBHost()`, `GetDBPort()`, `SetDBPort()`, `GetDBUser()`, `SetDBUser()`, `GetDBPassword()`, `SetDBPassword()`, `GetDBName()`, `SetDBName()`. - -### Config reading before DB init - -Problem: settings are stored IN the database, but we need `dbType` BEFORE opening the DB. - -Solution: `config/config.go` gets a `GetDBTypeFromJSON()` function that reads `/etc/x-ui/x-ui.json` directly (falls back to `"sqlite"` if file doesn't exist or key is missing). This is called before `database.InitDB()`. - -### New CLI flags in `main.go` - -Add `-dbType`, `-dbHost`, `-dbPort`, `-dbUser`, `-dbPassword`, `-dbName` flags to the `setting` subcommand. These write directly to the JSON config file (not via the DB) using `config.WriteSettingToJSON(key, value)`. - -New `config/config.go` helper: `WriteSettingToJSON(key, value string)` — reads the JSON file, updates the key, writes back. - ---- - -## Section 2: Database Layer (`database/db.go`) - -### Refactored `InitDB()` - -```go -func InitDB() error { - dbType := config.GetDBTypeFromJSON() - - switch dbType { - case "mariadb": - return initMariaDB() - default: // "sqlite" - return initSQLite(config.GetDBPath()) - } -} -``` - -### `initSQLite(path string) error` - -Existing logic unchanged — opens SQLite with `gorm.io/driver/sqlite`, runs `initModels()`, `initUser()`, `runSeeders()`. - -### `initMariaDB() error` - -1. Read host, port, user, password, dbName from JSON config. -2. Build DSN: `user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local` -3. Open with `gorm.io/driver/mysql`. -4. Run `initModels()`, `initUser()`, `runSeeders()` (same as SQLite). - -### Adapted functions - -- `Checkpoint()` — if MariaDB, return `nil`. If SQLite, existing WAL logic. -- `IsSQLiteDB()` — unchanged, only called for SQLite. -- `ValidateSQLiteDB()` — unchanged, only called for SQLite. - -### New dependency - -`gorm.io/driver/mysql` added to `go.mod`. - ---- - -## Section 3: Data Migration (`database/migrate.go`) - -New file with two functions: - -### `MigrateSQLiteToMariaDB() error` - -1. Open SQLite connection from `config.GetDBPath()`. -2. Open MariaDB connection from JSON settings. -3. For each table (users, inbounds, outbound_traffics, settings, inbound_client_ips, client_traffics, history_of_seeders): - - AutoMigrate the model on MariaDB. - - `SELECT *` from SQLite → `INSERT` into MariaDB using GORM raw SQL. -4. On success: close connections (SQLite file kept as backup). -5. On failure: return error with context. - -### `MigrateMariaDBToSQLite() error` - -Reverse of above: -1. Open MariaDB connection from JSON settings. -2. Open/create SQLite connection at `config.GetDBPath()`. -3. For each table: read from MariaDB, write to SQLite. -4. On success: close connections. -5. On failure: return error. - -Row transfer approach: Use the existing model structs explicitly. For each table, query all rows from source into a `[]Model` slice, then batch-insert into destination. This avoids raw SQL differences between SQLite and MySQL. Example for users: - -```go -var users []model.User -srcDB.Find(&users) -dstDB.CreateInBatches(&users, 100) -``` - -This pattern repeats for each of the 7 tables. - ---- - -## Section 4: `main.go` Changes - -### Updated callers - -All `database.InitDB(config.GetDBPath())` calls change to `database.InitDB()`: -- `runWebServer()` (line 49) -- `resetSetting()` (line 134) -- `updateTgbotSetting()` (line 221) -- `updateSetting()` (line 259) -- `updateCert()` (line 318) -- `migrateDb()` (line 395) - -### New `migrate-db` subcommand - -```go -case "migrate-db": - migrateDbBetweenDrivers() -``` - -`migrateDbBetweenDrivers()`: -1. Read `dbType` from JSON config. -2. If `dbType == "mariadb"`: call `database.MigrateSQLiteToMariaDB()`. -3. If `dbType == "sqlite"`: call `database.MigrateMariaDBToSQLite()`. -4. Print success/failure message. - -### New CLI flags - -Add to `setting` subcommand: -- `-dbType string` — set database type -- `-dbHost string` — set MariaDB host -- `-dbPort string` — set MariaDB port -- `-dbUser string` — set MariaDB username -- `-dbPassword string` — set MariaDB password -- `-dbName string` — set MariaDB database name - -These call `config.WriteSettingToJSON()` to write directly to the JSON file. Only the 6 DB-related settings use `WriteSettingToJSON()` — all other settings (port, username, etc.) continue to use the existing `SettingService` methods that write through the database. - ---- - -## Section 5: `web/service/server.go` Changes - -### `GetDb()` - -Add check at the top: -```go -dbType, _ := s.GetDBType() -if dbType == "mariadb" { - return nil, common.NewError("Database export is not supported for MariaDB") -} -``` -Existing SQLite logic unchanged. - -### `ImportDB()` - -Add check at the top: -```go -dbType, _ := s.GetDBType() -if dbType == "mariadb" { - return common.NewError("Database import is not supported for MariaDB") -} -``` -Existing SQLite logic unchanged. - ---- - -## Section 6: `x-ui.sh` Changes - -### New menu option 27 - -Add to `show_menu`: -``` -│────────────────────────────────────────────────│ -│ ${green}27.${plain} 数据库管理 │ -``` - -Add to the case statement: -```bash -27) - check_install && db_menu - ;; -``` - -Update prompt: `请输入选择 [0-27]` - -### `db_menu()` function - -```bash -db_menu() { - # Read current dbType from JSON - local current_type=$(read_json_dbtype) - - echo -e " -╔────────────────────────────────────────────────╗ -│ ${green}数据库管理${plain} │ -│ ${green}0.${plain} 返回主菜单 │ -│ ${green}1.${plain} 查看当前数据库类型(当前: ${current_type}) │ -│ ${green}2.${plain} 切换到 MariaDB │ -│ ${green}3.${plain} 切换到 SQLite │ -╚────────────────────────────────────────────────╝ -" - read -rp "请输入选择 [0-3]:" num - case "${num}" in - 0) show_menu ;; - 1) db_show_status && db_menu ;; - 2) db_switch_to_mariadb ;; - 3) db_switch_to_sqlite ;; - *) echo "无效选项" && db_menu ;; - esac -} -``` - -### `db_switch_to_mariadb()` - -```bash -db_switch_to_mariadb() { - echo "请输入 MariaDB 连接信息(直接回车使用默认值):" - - read -rp "MariaDB IP(默认 127.0.0.1): " db_host - db_host=${db_host:-127.0.0.1} - - read -rp "MariaDB 端口(默认 3306): " db_port - db_port=${db_port:-3306} - - read -rp "MariaDB 用户名: " db_user - if [ -z "$db_user" ]; then - echo -e "${red}用户名不能为空${plain}" - db_menu - return - fi - - read -rsp "MariaDB 密码: " db_pass - echo - if [ -z "$db_pass" ]; then - echo -e "${red}密码不能为空${plain}" - db_menu - return - fi - - read -rp "数据库名(默认 3xui): " db_name - db_name=${db_name:-3xui} - - # Write settings to JSON config - /usr/local/x-ui/x-ui setting -dbType mariadb -dbHost "$db_host" -dbPort "$db_port" -dbUser "$db_user" -dbPassword "$db_pass" -dbName "$db_name" - - # Migrate data - echo "正在迁移数据从 SQLite 到 MariaDB..." - /usr/local/x-ui/x-ui migrate-db - - if [ $? -eq 0 ]; then - echo -e "${green}数据库切换成功,正在重启面板...${plain}" - restart - else - echo -e "${red}数据迁移失败,正在回滚到 SQLite...${plain}" - /usr/local/x-ui/x-ui setting -dbType sqlite - restart - fi -} -``` - -### `db_switch_to_sqlite()` - -```bash -db_switch_to_sqlite() { - /usr/local/x-ui/x-ui setting -dbType sqlite - - echo "正在迁移数据从 MariaDB 到 SQLite..." - /usr/local/x-ui/x-ui migrate-db - - if [ $? -eq 0 ]; then - echo -e "${green}数据库切换成功,正在重启面板...${plain}" - restart - else - echo -e "${red}数据迁移失败${plain}" - fi -} -``` - -### Helper functions in x-ui.sh - -- `read_json_dbtype()` — reads `dbType` from `/etc/x-ui/x-ui.json` using `grep`/`sed` or Python if available. -- `db_show_status()` — displays current DB type and connection info. - ---- - -## Files Changed - -| File | Changes | -|------|---------| -| `go.mod` | Add `gorm.io/driver/mysql` | -| `config/config.go` | Add `GetDBTypeFromJSON()`, `WriteSettingToJSON()` | -| `database/db.go` | Refactor `InitDB()` to be driver-agnostic, add `initMariaDB()`, adapt `Checkpoint()` | -| `database/migrate.go` | **New file** — `MigrateSQLiteToMariaDB()`, `MigrateMariaDBToSQLite()` | -| `main.go` | Update all `InitDB` calls, add `migrate-db` subcommand, add setting CLI flags | -| `web/service/setting.go` | Add 6 new settings + getter/setter methods | -| `web/service/server.go` | Guard `GetDb()`/`ImportDB()` for MariaDB | -| `x-ui.sh` | Add option 27, `db_menu()`, `db_switch_to_mariadb()`, `db_switch_to_sqlite()`, helpers | - -## Testing - -1. Fresh install with SQLite (default) — verify panel works as before -2. Switch to MariaDB via x-ui.sh — verify data migrates and panel starts -3. Switch back to SQLite — verify data migrates back -4. Verify MariaDB CRUD operations (create inbound, modify settings, etc.) -5. Verify GetDb/ImportDB return appropriate errors when using MariaDB -6. Verify invalid MariaDB credentials show error and rollback to SQLite diff --git a/docs/x-panel-device-limit.md b/docs/x-panel-device-limit.md deleted file mode 100644 index f3fb1eaf..00000000 --- a/docs/x-panel-device-limit.md +++ /dev/null @@ -1,444 +0,0 @@ -# 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 deleted file mode 100644 index 34dc692e..00000000 --- a/docs/x-ui-logic.md +++ /dev/null @@ -1,937 +0,0 @@ -# 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 分离,互不影响。