chore: remove tracked files matching gitignore (vscode, docs)

This commit is contained in:
root 2026-04-26 17:23:54 +08:00
parent 19a36435bd
commit 9badadc97b
69 changed files with 0 additions and 11901 deletions

35
.vscode/launch.json vendored
View file

@ -1,35 +0,0 @@
{
"$schema": "vscode://schemas/launch",
"version": "0.2.0",
"configurations": [
{
"name": "Run 3x-ui (Debug)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
},
"console": "integratedTerminal"
},
{
"name": "Run 3x-ui (Debug, custom env)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
// Set to true to serve assets/templates directly from disk for development
"XUI_DEBUG": "true",
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
// "XUI_DB_FOLDER": "${workspaceFolder}",
// Example: override log level (debug|info|notice|warn|error)
// "XUI_LOG_LEVEL": "debug"
},
"console": "integratedTerminal"
}
]
}

75
.vscode/tasks.json vendored
View file

@ -1,75 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "go: build",
"type": "shell",
"command": "go",
"args": [
"build",
"-o",
"bin/3x-ui.exe",
"./main.go"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "go: run",
"type": "shell",
"command": "go",
"args": [
"run",
"./main.go"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
}
},
"problemMatcher": [
"$go"
]
},
{
"label": "go: test",
"type": "shell",
"command": "go",
"args": [
"test",
"./..."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test"
},
{
"label": "go: vet",
"type": "shell",
"command": "go",
"args": [
"vet",
"./..."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
]
}
]
}

View file

@ -1,51 +0,0 @@
Task Record: Multi-node shared control — Go backend foundation
Date: 2026-04-10
Related Module: config, database, web/service — multi-node architecture
Change Type: Feature
Background
需要支持多个 3x-ui 面板实例共享同一个 MariaDB 数据库,由 master 节点管理配置worker 节点同步配置并上报流量。为此引入节点角色配置、共享元数据模型、写入保护和版本同步机制。
Changes
- `config/config.go`: 新增 `NodeRole`、`NodeConfig` 结构体,`GetNodeConfigFromJSON()` 读取节点角色/ID/同步间隔/流量刷盘间隔,`ValidateNodeConfig()` 校验配置合法性
- `database/model/node_state.go`: 新增 `NodeState` 模型,记录每个节点的心跳、同步状态、错误信息
- `database/model/shared_state.go`: 新增 `SharedState` 模型,键值对 + 版本计数器,用于缓存失效检测
- `database/shared_state.go`: `BumpSharedAccountsVersion()` 原子递增版本号,`GetSharedAccountsVersion()` 读取当前版本,`UpsertNodeState()` 更新节点状态
- `database/db.go`: MariaDB 模式下自动迁移 `SharedState``NodeState`seed 版本行
- `web/service/node_guard.go`: `IsWorker()`/`IsMaster()`/`RequireMaster()`/`IsSharedModeEnabled()` 角色判断和写入保护
- `web/service/inbound.go`: 所有写操作AddInbound/DelInbound/UpdateInbound/AddInboundClient 等)前调用 `ensureSharedWriteAllowed()`,写操作内 `bumpSharedVersion(tx)` 原子递增版本号
- `web/service/node_sync.go`: `NodeSyncService` — worker 轮询版本号变化 → 加载快照 → 缓存到本地 → 应用到 Xraymaster 心跳循环
- `web/service/node_cache.go`: `SharedAccountsSnapshot` 序列化/反序列化到本地 JSON 缓存
- `web/service/traffic_flush.go`: `TrafficFlushService` — 收集流量 delta → 写入持久化队列 → 定时刷盘到 MariaDB
- `web/service/traffic_pending.go`: `TrafficPendingStore` — 基于文件的持久化 delta 队列,支持 merge 语义
- `web/job/xray_traffic_job.go`: 共享模式下走 `TrafficFlushService.Collect()` 路径,非共享模式走原有 `AddTraffic()` 路径
- `web/web.go`: 新增 `startNodeLoops()``startTrafficFlushLoop()` 启动入口
- `x-ui.sh`: 新增节点管理菜单(设置角色/ID/同步间隔/流量刷盘间隔)
- `install.sh`: 安装流程中增加节点角色配置提示
- `README.md`: 新增多节点共享控制文档
Impact
- 新增数据库表:`node_states`、`shared_states`
- 新增配置项:`nodeRole`、`nodeId`、`syncInterval`、`trafficFlushInterval`
- 修改 `inbounds``client_traffics` 写入流程,增加共享写入保护
- 新增流量持久化队列文件:`traffic-pending.json`
- 不影响非 MariaDB 模式的现有行为
Verification
- `go test ./config/ -v` — PASS
- `go test ./database/ -v` — PASS
- `go test ./web/service/ -run TestNode -v` — PASS
- `go test ./web/service/ -run TestTraffic -v` — PASS
- `bash -n install.sh` — syntax OK
- `bash -n x-ui.sh` — syntax OK
Risks And Follow-Up
- worker 节点需要配置 `nodeId``mariadb` 数据库类型,否则启动校验失败
- 流量刷盘依赖 `traffic-pending.json` 文件,磁盘故障可能导致 delta 丢失
- 后续需要处理残留 `inboundId: 0` delta 导致外键约束失败的问题(已在后续 commit 中修复)

View file

@ -1,61 +0,0 @@
Task Record: Improve MariaDB flow, DB settings init, and traffic flush
Date: 2026-04-15
Related Module: install.sh, x-ui.sh, config, database, web/service — MariaDB 安装/切换/流量刷盘
Change Type: Feature
Background
MariaDB 安装和切换流程存在多个问题:安装脚本逻辑分散、数据库设置初始化不完整、流量刷盘服务存在 bug。本次提交对整个 MariaDB 相关流程进行了大规模重构和修复。
Changes
- `install.sh`+598/-行重构):
- 重构 MariaDB 安装/切换流程,统一本地和远程 MariaDB 配置路径
- 新增 MariaDB 业务用户/数据库创建逻辑
- 改进卸载流程,支持部分安装状态下的清理
- 新增 `tests/mariadb_install_switch_test.sh` 测试覆盖
- `x-ui.sh`+422/-行重构):
- 重构数据库管理菜单,支持本地/远程 MariaDB 切换
- 新增 MariaDB 端口校验、远程访问管理等菜单项
- `config/config.go`:
- 新增 `GetDBConfigFromJSON()` 读取数据库连接配置
- 新增 `readGroupedString()`/`readGroupedInt()` 通用配置读取辅助函数
- 配置别名映射支持多分组查找
- `database/shared_state.go`:
- 改进版本号操作的事务安全性
- `web/service/traffic_flush.go`:
- `Collect()` 新增 inbound-only 残留流量 delta 计算inbound 总量 - 客户端总量)
- `flushToDatabase()` 改进 UPSERT 逻辑,支持 MariaDB 的 `ON DUPLICATE KEY UPDATE`
- 新增 `ReconcileSharedTrafficState()` 调用auto-renew/disable 过期客户端)
- `web/service/traffic_pending.go`:
- 改进 `Merge()` 语义,支持按 `(kind, inboundId, email)` 键去重合并
- `main.go`:
- 新增 `NodeConfig` 启动校验入口
- 改进 MariaDB 连接初始化流程
- `update.sh`: 更新脚本适配新的安装流程
Impact
- `install.sh`: 大规模重构,影响所有 MariaDB 安装/切换/卸载路径
- `x-ui.sh`: 数据库管理菜单重构
- `config/config.go`: 新增配置读取辅助函数
- `web/service/traffic_flush.go`: 流量刷盘逻辑改进
- `web/service/traffic_pending.go`: delta 队列合并语义改进
- `main.go`: 启动流程增加节点配置校验
Verification
- `go test ./config/ -v` — PASS
- `go test ./database/ -v` — PASS
- `go test ./web/service/ -run TestTraffic -v` — PASS
- `go test ./main_test.go -v` — PASS
- `bash -n install.sh` — syntax OK
- `bash -n x-ui.sh` — syntax OK
- `bash tests/mariadb_install_switch_test.sh` — PASS
Risks And Follow-Up
- 安装脚本改动量大,需要在多种发行版上验证
- 流量刷盘的 `inboundId: 0` 残留问题尚未处理(后续 commit 修复)
- 配置读取辅助函数的分组别名映射需要与 JSON 结构保持同步

View file

@ -1,31 +0,0 @@
# 任务记录uninstall-mariadb-option
- 日期2026-04-15
- 关联模块x-ui uninstall flow / database cleanup / test script
- 变更类型:优化
## 背景
卸载流程原先只移除面板服务与文件,不处理 MariaDB 业务库、业务账号和本机 MariaDB 包,用户在希望彻底清理时需要手动处理。
## 修改内容
- 在 `x-ui.sh``uninstall()` 中新增交互项:`是否删除数据库并卸载本机 MariaDB`。
- 当当前数据库类型为 MariaDB 且 host 为本机地址(`127.0.0.1`/`localhost`/`::1`)时:
- 删除业务库与业务账号(`localhost`、`127.0.0.1`、`::1`)。
- 卸载本机 MariaDB 服务与相关软件包。
- 当数据库为远程 MariaDB 时,输出提示并跳过数据库删除与卸载,避免误删远程资源。
- 新增 `remove_local_mariadb_data``uninstall_local_mariadb_packages` 两个函数。
- 更新 `tests/mariadb_install_switch_test.sh`,增加新卸载逻辑关键文本断言。
## 影响范围
- 影响文件:`x-ui.sh`、`tests/mariadb_install_switch_test.sh`。
- 不影响面板安装流程、数据库切换流程、数据库结构。
- 仅在卸载流程中新增可选数据库清理能力。
## 验证情况
- 执行 `bash -n x-ui.sh`,通过。
- 执行 `bash -n install.sh`,通过。
- 执行 `bash tests/mariadb_install_switch_test.sh`,通过。
## 风险与后续
- 用户若选择删除数据库,相关业务数据将不可恢复。
- 后续可增加二次确认,显示将删除的数据库名和用户名,以进一步降低误操作风险。

View file

@ -1,27 +0,0 @@
# 任务记录validate-mariadb-port-input
- 日期2026-04-15
- 关联模块install script / db switch menu / test script
- 变更类型:修复
## 背景
远程 MariaDB 连接配置流程中,端口输入未做格式和范围校验,用户输入非法值时只能在后续连接阶段失败,定位不直观。
## 修改内容
- 在 `install.sh` 的远程 MariaDB 分支中新增端口校验循环。
- 在 `x-ui.sh` 的数据库切换到 MariaDB远程分支中新增端口校验循环。
- 在 `tests/mariadb_install_switch_test.sh` 增加断言,校验两处脚本都包含端口非法提示文本。
## 影响范围
- 影响文件:`install.sh`、`x-ui.sh`、`tests/mariadb_install_switch_test.sh`。
- 不影响数据库结构、接口协议、构建流程。
- 仅影响交互式输入阶段的参数合法性检查。
## 验证情况
- 执行 `bash -n install.sh`,通过。
- 执行 `bash -n x-ui.sh`,通过。
- 执行 `bash tests/mariadb_install_switch_test.sh`,通过。
## 风险与后续
- 当前风险较低,变更仅限输入校验逻辑。
- 后续可考虑将端口校验抽为统一函数,减少重复逻辑。

View file

@ -1,56 +0,0 @@
Task Record
Date: 2026-04-22
Related Module: install.sh, x-ui.sh, MariaDB runtime configuration
Change Type: Fix
Background
The existing MariaDB setup flow only supported local-only business accounts and default port handling. It did not provide a script path to change the local MariaDB server port or to explicitly authorize selected remote VPS IPs for worker-style access after installation.
Changes
Added reusable shell helpers in `install.sh` and `x-ui.sh` to manage local MariaDB server network settings through an override config file, including `port` and `bind-address`.
Updated the local MariaDB install/switch flow to prompt for `本地 MariaDB port [3306]`, validate the input, apply the port to the local MariaDB server, and keep default local-only binding (`127.0.0.1`).
Extended the `x-ui.sh` database management menu with local MariaDB runtime actions:
- set local MariaDB port
- view MariaDB remote access status
- enable MariaDB remote access
- disable MariaDB remote access
- view allowed remote IPs
- add allowed remote IP
- remove allowed remote IP
Remote access management now uses MariaDB per-host grants for the current business user and current business database. Enabling remote access switches MariaDB bind address to `0.0.0.0` and requires at least one authorized remote IP. Disabling remote access restores `127.0.0.1` and removes non-local grants for the current business user.
Impact
Affected files:
- `install.sh`
- `x-ui.sh`
- `tests/mariadb_install_switch_test.sh`
- `docs/Tasktracking/2026-04-22-add-mariadb-remote-ip-access.md`
This changes shell installer behavior, runtime shell menu behavior, and local MariaDB server configuration on hosts that use the new flow.
No Go API, database schema, or frontend behavior was changed.
Worker-side remote database host and port configuration continues to use the existing `dbHost` and `dbPort` settings flow.
Verification
Commands:
- `bash tests/mariadb_install_switch_test.sh`
- `bash -n install.sh`
- `bash -n x-ui.sh`
Result:
- All commands completed successfully.
Not verified:
- No live MariaDB install/runtime session was executed in this environment.
- No end-to-end validation against an actual remote worker VPS IP was executed in this session.
Risks And Follow-Up
The MariaDB config override path is selected from common distro include directories. On an uncommon MariaDB packaging layout, manual adjustment may still be required.
This implementation restricts remote access by MariaDB host grants, not by firewall source filtering. Unauthorised source IPs should be rejected by MariaDB authentication, but the database service still listens on the configured port while remote access is enabled.
If stricter network-layer isolation is required later, a follow-up can add optional per-IP firewall rules on top of the current MariaDB host-grant model.

View file

@ -1,43 +0,0 @@
Task Record
Date: 2026-04-22
Related Module: x-ui.sh
Change Type: Fix
Background
The database management menu exposed node role and node ID settings, but it did not provide a standalone way to maintain remote MariaDB connection fields after multi-node setup. Operators had no menu entry to update host, port, username, password, or database name without re-running the database switch flow or editing config files manually.
Changes
Added a reusable database-setting reader in `x-ui.sh` for MariaDB connection values stored in `/etc/x-ui/x-ui.json`.
Added a new database management menu entry to update remote MariaDB host, port, username, password, and database name independently from the database migration flow.
The new flow shows current values, preserves the stored password when the operator leaves the password prompt empty, validates required fields and port range, and tests the MariaDB business connection before saving.
Updated the existing database status output to reuse the new setting reader instead of duplicating JSON parsing logic.
Impact
Affected files:
- `x-ui.sh`
- `docs/Tasktracking/2026-04-22-add-remote-db-settings-menu.md`
Runtime behavior is affected in the shell management menu only.
No API, database schema, or build pipeline changes were made.
The new menu path updates MariaDB connection values in `/etc/x-ui/x-ui.json` through the existing `x-ui setting` command and does not automatically switch the active database type.
Verification
Command:
- `bash -n x-ui.sh`
Result:
- Passed syntax validation with exit code 0.
Not verified:
- Interactive execution of the new menu path against a real remote MariaDB instance was not run in this session.
Risks And Follow-Up
The new flow depends on the MariaDB client being available or installable at runtime, consistent with the existing database switch flow.
Password preservation relies on the currently stored JSON value; if the stored password is stale, the connection test will fail until the operator enters the correct password explicitly.
Recommended follow-up work is a manual end-to-end check of menu option `9` on a host with an actual remote MariaDB target.

View file

@ -1,51 +0,0 @@
Task Record:
Date: 2026-04-22
Related Module: MariaDB admin auth in installer and menu script
Change Type: Fix
Background
During local MariaDB setup, when socket auth failed the script asked for admin credentials.
On hosts where admin/root password is empty, the previous command construction always appended `-p`, which can cause authentication checks to fail for empty-password accounts.
This led to false "管理员账号连接失败" and blocked install/uninstall flows.
Changes
Updated MariaDB connection execution in both `install.sh` and `x-ui.sh`:
- Build MariaDB client command with optional password flag.
- Append `-p<password>` only when password is non-empty.
- Apply this to server-connection check, database-connection check, and admin SQL execution.
- Prompt text updated to indicate admin password can be empty.
- Added retry logic (up to 10 seconds) before declaring socket auth failure.
- Added automatic fallback to `root@127.0.0.1` with empty password before prompting admin credentials.
- For freshly installed local MariaDB in script flow, set an install marker and prefer non-interactive auto init path.
Added `tests/mariadb_admin_empty_password_test.sh` for static regression checks.
Impact
Affected files:
- `install.sh`
- `x-ui.sh`
- `tests/mariadb_admin_empty_password_test.sh`
No API/database schema change.
Install, DB switching, and uninstall paths are more compatible with empty-password admin setups.
Verification
Commands:
- `bash -n install.sh`
- `bash -n x-ui.sh`
- `bash tests/mariadb_admin_empty_password_test.sh`
- `bash tests/install_uninstall_resilience_test.sh`
- `bash tests/panel_port_prompt_test.sh`
- `bash tests/mariadb_install_switch_test.sh`
Result:
- All checks passed.
Risks And Follow-Up
Current tests are static assertions. Full runtime verification still depends on real MariaDB environment variants (socket auth, TCP auth, empty password, root-password mode).

View file

@ -1,52 +0,0 @@
Task Record:
Date: 2026-04-22
Related Module: install/uninstall scripts (`install.sh`, `x-ui.sh`)
Change Type: Fix
Background
Installation could get stuck or fail at `x-ui migrate` before service installation completed.
When this happened, service files were not installed, but files under `/usr/local/x-ui` or `/etc/x-ui` could already exist.
Then `x-ui.sh uninstall` might reject uninstall with "please install first" because installation detection relied only on service file presence.
Changes
In `install.sh`, changed migration call to non-blocking behavior:
- Use `timeout 30` when available for `${xui_folder}/x-ui migrate`.
- If migration times out or fails, print warning and continue installation.
- Keep manual migration command hint for follow-up.
In `x-ui.sh`, improved install detection in `check_status`:
- If service/init file is missing but residual install artifacts exist (`${xui_folder}/x-ui`, `${xui_folder}`, or `/etc/x-ui`), treat it as installed-but-not-running instead of not installed.
- This allows `x-ui uninstall` to proceed and clean residual files.
Added `tests/install_uninstall_resilience_test.sh` for static regression checks of the new logic.
Impact
Affected files:
- `install.sh`
- `x-ui.sh`
- `tests/install_uninstall_resilience_test.sh`
No API or database schema changes.
Installer runtime behavior is more resilient when migration has connectivity issues.
Uninstall command now works for partial/failed installation residue.
Verification
Commands:
- `bash -n install.sh`
- `bash -n x-ui.sh`
- `bash tests/install_uninstall_resilience_test.sh`
- `bash tests/panel_port_prompt_test.sh`
- `bash tests/mariadb_install_switch_test.sh`
Result:
- All commands passed.
Risks And Follow-Up
Migration failure is now non-blocking, so some environments may finish install while still requiring manual migration.
For complete runtime coverage, a pty-driven install E2E test with unreachable MariaDB simulation can be added later.

View file

@ -1,43 +0,0 @@
Task Record:
Date: 2026-04-22
Related Module: install script (`install.sh`)
Change Type: Fix
Background
The panel port setup in fresh install used a `y/n` confirmation before reading the port.
When users directly entered a numeric port like `443` at the confirmation prompt, it was treated as non-`y` and the script generated a random port, causing unexpected behavior.
Changes
Replaced the two-step `y/n` + port input flow with a single direct input flow in `install.sh`.
Now the script asks for panel port directly:
- Empty input: generate random panel port.
- Valid numeric port (`1-65535`): use as panel port.
- Invalid input: show error and prompt again.
Added `tests/panel_port_prompt_test.sh` to verify the expected prompt and validation logic exists and the legacy `y/n` prompt is removed.
Impact
Affected files:
- `install.sh`
- `tests/panel_port_prompt_test.sh`
No API, database schema, or build pipeline changes.
Runtime install interaction is changed for fresh install panel-port setup only.
Verification
Commands:
- `bash tests/panel_port_prompt_test.sh`
- `bash tests/mariadb_install_switch_test.sh`
Result:
- Both scripts passed.
Risks And Follow-Up
Current verification is static prompt/logic assertion, not full interactive E2E install simulation.
If needed, add an automated pty-based interaction test to validate runtime behavior across shells.

View file

@ -1,34 +0,0 @@
Task Record: Fix MariaDB restart error diagnostics
Date: 2026-04-23
Related Module: install.sh, x-ui.sh — MariaDB service management
Change Type: Fix
Background
用户在安装过程中选择本地 MariaDB 后,遇到 "重启 MariaDB 失败,请检查配置文件" 错误。该错误信息过于笼统,因为 `restart_mariadb_service()``start_mariadb_service()` 函数使用 `2>/dev/null` 抑制了 stderr导致 systemctl 返回的实际错误信息被隐藏,无法定位根因。
Changes
- `restart_mariadb_service()`install.sh + x-ui.sh移除 stderr 抑制,捕获 systemctl/rc-service 输出,失败时打印实际错误信息和 `systemctl status` 诊断输出
- `start_mariadb_service()`install.sh + x-ui.shsystemctl start/enable 失败时使用 `|| true` 避免 set -e 场景下脚本意外退出,保持行为一致
Impact
- install.sh: `restart_mariadb_service()``start_mariadb_service()`
- x-ui.sh: `restart_mariadb_service()``start_mariadb_service()`
- 不影响功能逻辑,仅改善错误诊断输出
- 无 API、数据库、配置变更
Verification
- `bash -n install.sh` — syntax OK
- `bash -n x-ui.sh` — syntax OK
- `bash tests/mariadb_install_switch_test.sh` — PASS
- `bash tests/mariadb_admin_empty_password_test.sh` — PASS
- `bash tests/install_uninstall_resilience_test.sh` — PASS
Risks And Follow-Up
- 无风险。改动仅影响错误输出,不改变控制流
- 用户重新运行安装脚本后,应能看到 systemctl 实际报错原因(如配置文件语法错误、端口冲突等),可据此进一步定位

View file

@ -1,33 +0,0 @@
Task Record: Detect stale mariadb service file when server package is missing
Date: 2026-04-23
Related Module: install.sh, x-ui.sh — has_local_mariadb_service()
Change Type: Fix
Background
用户在安装过程中 MariaDB 重启失败。排查发现 systemd unit 文件存在于 `systemctl list-unit-files` 输出中,但 `mariadb-server` 包实际已被卸载,服务文件是残留状态。`has_local_mariadb_service()` 只检查了 unit 文件是否存在,未验证包是否已安装,导致跳过了服务器重新安装。
Changes
- `has_local_mariadb_service()`install.sh + x-ui.sh在检测到 unit 文件后,追加 `dpkg -s mariadb-server`Debian/Ubuntu`rpm -q mariadb-server`RHEL/Fedora验证包是否已安装。包不存在时返回 1触发重新安装。
Impact
- install.sh: `has_local_mariadb_service()`
- x-ui.sh: `has_local_mariadb_service()`
- 不影响正常安装流程,仅在包被卸载但 unit 文件残留时触发重新安装
- 无 API、数据库、配置变更
Verification
- `bash -n install.sh` — syntax OK
- `bash -n x-ui.sh` — syntax OK
- `bash tests/mariadb_install_switch_test.sh` — PASS
- `bash tests/mariadb_admin_empty_password_test.sh` — PASS
- `bash tests/install_uninstall_resilience_test.sh` — PASS
Risks And Follow-Up
- 无风险。仅增加包安装状态检查,不影响已有逻辑
- Arch/Alpine 等非 dpkg/rpm 发行版保持原行为(仅检查 unit 文件)

View file

@ -1,35 +0,0 @@
Task Record: Install cron before acme.sh for all distros
Date: 2026-04-23
Related Module: install.sh — cron 安装
Change Type: Fix
Background
acme.sh 依赖 cron 来执行证书自动续期但在部分发行版RHEL/Fedora/CentOS/Arch/openSUSE/Alpinecron 服务可能未预装。acme.sh 安装时如果找不到 cron会静默失败或报错导致证书续期不生效。
Changes
- `install.sh`:
- 在 `install_base()` 中新增 cron 包安装逻辑
- RHEL/Fedora/CentOS/Arch/openSUSE: 安装 `cronie`
- Alpine: 安装 `dcron`
- 安装后确保 crond 服务启用并启动(`enable --now`
- 将 cron 安装移到 acme.sh 安装之前,确保依赖顺序正确
Impact
- `install.sh`: `install_base()` 函数
- 不影响已有安装流程,仅在 cron 未安装时补充安装
- 不影响数据库、API、前端
Verification
- `bash -n install.sh` — syntax OK
- 在 Ubuntu/Debian 上验证cron 通常已预装,无副作用)
- 需要在 RHEL/Alpine 等发行版上验证 cron 安装逻辑
Risks And Follow-Up
- 无风险。仅增加缺失包的安装,不影响已有逻辑
- 如果用户手动禁用了 cron证书续期仍会失败非本次修复范围

View file

@ -1,17 +0,0 @@
# 2026-04-24: Clash YAML CodeMirror Editor + Settings Save Button Fix
## Changes
1. **Fix: settings save button not enabling when toggling Clash Subscription**
- `confAlerts` computed property crashed when `subClashURI`/`subURI`/`subJsonURI` was null/undefined
- Added `|| ''` fallback before `.length` access for all three URI fields
2. **Feat: CodeMirror YAML editor for Clash template**
- Replaced plain `<a-textarea>` with CodeMirror editor (YAML syntax highlighting, line numbers, auto-indent)
- Added `web/assets/codemirror/yaml.js` (CodeMirror 5.65.1 YAML mode)
- Updated `settings.html` with CodeMirror CSS/JS includes, tab change handler, and init method
- Updated `clash.html` to use hidden textarea for CodeMirror attachment
3. **Chore: version bump to v1.5.4.1-beta**
## Tag
- `v1.5.4.1-beta`

View file

@ -1,30 +0,0 @@
# Clash YAML Subscription Endpoint
## Date: 2026-04-24
## Changes
### New Files
- `sub/subClashService.go` — Clash YAML subscription service: reads user template, generates proxies from inbound/client data, injects via `proxies: []` placeholder replacement
- `web/html/settings/panel/subscription/clash.html` — Clash subscription settings panel (path, URI, template textarea)
### Backend
- `web/entity/entity.go` — Added `SubClashEnable`, `SubClashPath`, `SubClashURI`, `SubClashTemplate` to `AllSetting`
- `web/service/setting.go` — Added defaults, getter functions, `GetSubSettings()` auto-build URI for Clash
- `sub/sub.go` — Read Clash settings, pass to controller
- `sub/subController.go` — Added Clash fields, route `GET /clash/:subid`, `clashSubs()` handler returning `text/yaml`
### Frontend
- `web/html/settings.html` — Added Clash settings tab (key 6)
- `web/html/settings/panel/subscription/general.html` — Added Clash enable toggle
- `web/html/settings/panel/subscription/subpage.html` — Added Clash QR code, Desktop dropdown with Clash Verge deep link
- `web/assets/js/subscription.js` — Added `subClashUrl`, `clashvergeUrl` prefers Clash URL
- `web/assets/js/model/setting.js` — Added Clash defaults
- `web/html/inbounds.html` — Added `subClashEnable`, `subClashURI` to subscription settings
- `web/html/modals/qrcode_modal.html` — Added Clash QR code + `genSubClashLink()`
- `web/html/modals/inbound_info_modal.html` — Added Clash subscription link
- `web/translation/translate.en_US.toml` — Added `subClashEnable` i18n
- `web/translation/translate.zh_CN.toml` — Added `subClashEnable` i18n
### Version
- `config/version` — Bumped to v1.5.2-beta

View file

@ -1,18 +0,0 @@
# Fix Clash YAML Subscription Code Quality Issues
**Date:** 2026-04-24
**Type:** bugfix / code-quality
**Version:** v1.5.3-beta
## Issues Fixed
- **YAML injection (C2):** All string values in Clash YAML generation changed from `%s` to `%q` for proper quoting
- **Unused parameter (I1):** Removed unused `host` parameter from `GetClash` method
- **Path validation (I3):** Added backend normalization for `SubClashPath` (auto-add leading/trailing `/`)
- **Silent JSON error (I4):** Added warning log when `StreamSettings` JSON unmarshal fails
- **Template visibility:** Clash YAML template panel now expanded by default
- **Default template:** Added sensible default Clash YAML template in settings defaults
## Note
C1 from review (nil `inboundService` panic) was a false alarm — `InboundService` is a value type, not a pointer.

View file

@ -1,30 +0,0 @@
# fix: MariaDB JSON_EACH compatibility for subscription and traffic queries
## Date: 2026-04-24
## Problem
Subscription endpoint (`/sub/:subid`, `/json/:subid`) returns `Error!` on MariaDB.
Root cause: `JSON_EACH` is SQLite-only; MariaDB requires `JSON_TABLE`.
## Changes
### sub/subService.go
- `getInboundsBySubId`: branch SQL by DB type — `JSON_TABLE` for MariaDB, `JSON_EACH` for SQLite
- `getFallbackMaster`: same branching for fallback query
- Added `config` import for `GetDBTypeFromJSON()`
### web/service/inbound.go
- `GetClientTrafficByID`: branch SQL by DB type
- `MigrationRemoveOrphanedTraffics`: branch SQL by DB type
- Added `config` import
### config/version
- Bump to v1.5.1
## Not in scope
- `getAllEmails()` (TG Bot) — same issue, deferred
## Verification
- `go build ./...` passes
- `go test ./...` all pass
- `go vet ./sub/ ./web/service/` clean

View file

@ -1,49 +0,0 @@
# 2026-04-24 Fix Settings Save Button and UI Bugs
## Problem
Settings page save button would not enable when user changed settings.
## Root Cause Analysis
Systematic debugging found multiple UI bugs affecting the settings page:
1. **Duplicate `:min` attributes on `a-input-number`** (3 locations)
- `general.html:42` — webPort had `:min="1" :min="65535"` (second should be `:max`)
- `subscription/general.html:43` — subPort had same issue
- `telegram.html:64` — tgCpu had `:min="0" :min="100"` (second should be `:max`)
2. **Mismatched closing tags**
- `general.html:42``<a-input-number>` closed with `</a-input>`
- `telegram.html:64``<a-input-number>` closed with `</a-switch>`
3. **twoFactorEnable toggle broken** (`security.html:39`)
- Used `@click="toggleTwoFactor" :checked="..."` instead of proper event handling
- `@click` passes MouseEvent as first arg, not the boolean toggle value
- Method expected boolean but received Event → always truthy → always triggered enable flow
4. **Noise input handlers referenced undefined `event`** (`json.html:91,99`)
- `(value) => updateNoisePacket(index, event.target.value)``event` is not defined
- Arrow function parameter named `value` but code accessed global `event`
5. **Polling loop had no error handling** (`settings.html:653-656`)
- Any error in the `while(true)` loop would silently stop change detection
## Changes
- Fixed `:min`/`:max` attributes on all `a-input-number` components
- Fixed closing tags to match opening tags
- Changed twoFactorEnable to use `@click.prevent="toggleTwoFactor(!allSetting.twoFactorEnable)"`
- Updated `toggleTwoFactor` method to only set `twoFactorEnable` on success
- Fixed noise input handlers to use `(e) => ... e.target.value`
- Added try/catch around polling loop comparison
## Files Modified
- `web/html/settings/panel/general.html` — webPort input fix
- `web/html/settings/panel/subscription/general.html` — subPort input fix
- `web/html/settings/panel/telegram.html` — tgCpu input fix
- `web/html/settings/panel/security.html` — twoFactorEnable toggle fix
- `web/html/settings/panel/subscription/json.html` — noise input handler fix
- `web/html/settings.html` — toggleTwoFactor method + polling error handling
## Verification
- Visual inspection of all modified templates
- Confirmed `ObjectUtil.equals()` shallow comparison works correctly with Vue 2 reactivity
- Confirmed `AllSetting` class properties match Go struct fields

View file

@ -1,42 +0,0 @@
Task Record: Resolve shared-mode traffic flush blocked by stale inboundId=0 delta
Date: 2026-04-24
Related Module: web/service/traffic_flush.go, web/web.go, web/job/xray_traffic_job.go — 流量刷盘
Change Type: Fix
Background
共享模式下流量统计始终为 0MariaDB 的 `client_traffics` 表从未被写入。排查发现 `traffic-pending.json` 中存在一个残留的 `inboundId: 0` 客户端流量 delta在 InboundId 解析修复前产生)。`flushToDatabase()` 尝试将其写入 `client_traffics` 时,违反外键约束 `fk_inbounds_client_stats``inbounds` 表不存在 `id=0`),导致整个事务回滚,所有流量永远无法写入。
此外,`NewXrayTrafficJob()` 和 `startTrafficFlushLoop()` 各自创建了独立的 `TrafficPendingStore` 实例,指向同一个 `traffic-pending.json` 文件但使用独立的 `sync.Mutex`,存在数据竞争风险。
Changes
- `web/service/traffic_flush.go`:
- `flushToDatabase()` 循环开头新增 `InboundID == 0` 检查,跳过无效 delta 并记录 warning 日志
- `web/job/xray_traffic_job.go`:
- `NewXrayTrafficJob()` 改为接受 `*service.TrafficPendingStore` 参数,不再自行创建 store
- 移除 `config` 包依赖
- `web/web.go`:
- `Server` struct 新增 `trafficStore *service.TrafficPendingStore` 字段
- `Start()` 中统一创建一个 `TrafficPendingStore` 实例
- `startTask()``startTrafficFlushLoop()` 共享同一个 store 实例,消除双实例竞争
- `web/service/traffic_flush_test.go`:
- 新增 `TestFlushOnceSkipsZeroInboundIdDelta` 测试
Impact
- `web/service/traffic_flush.go`: flushToDatabase() 跳过无效 delta
- `web/web.go`: Server 启动流程变更store 统一创建
- `web/job/xray_traffic_job.go`: 构造函数签名变更
- 修复后需要删除残留的 `traffic-pending.json` 文件才能生效
Verification
- `go test ./web/service/ -run TestTraffic -v` — PASS
- `go test ./web/service/ -run TestFlushOnceSkipsZeroInboundIdDelta -v` — PASS
Risks And Follow-Up
- 部署时必须删除 `/etc/x-ui/traffic-pending.json`,否则残留的 `inboundId: 0` delta 仍会被跳过(不影响功能,但会产生 warning 日志)
- `TrafficPendingStore` 的文件级锁已通过共享实例解决,但如果未来有多个进程访问同一文件,仍需考虑进程级锁

View file

@ -1,67 +0,0 @@
# Tasktracking: Node Management Sidebar
**Date:** 2026-04-24
**Branch:** fix
**Status:** Done
**Tags:** v1.6.0-beta, v1.6.1, v1.6.3, v1.6.4, v1.6.5
## Overview
Adding a Node Management sidebar page to the 3x-ui web panel for cluster node visibility.
## Tasks
| # | Task | Status | Commit |
|---|------|--------|--------|
| 1 | Add `GetNodeStates` database query | DONE | 85c6b661 |
| 2 | Create `NodeController` with API endpoints | DONE | 16eb179e |
| 3 | Register `NodeController` routes in server | DONE | — |
| 4 | Add i18n translations for node page | DONE | fc77154c |
| 5 | Add sidebar menu item | DONE | c09c6182 |
| 6 | Create `nodes.html` template page | DONE | 7d75d02c |
| 7 | Build and verify | DONE | — |
| 8 | Fix themeSwitcher + API 404 errors | DONE | 07fecdbf |
| 9 | Fix gofmt formatting | DONE | a3d8e9c5 |
| 10 | Fix shared MariaDB query for node states | DONE | d5bf2858 |
| 11 | Fix node settings not auto-created in x-ui.json | DONE | d733ff2a |
| 12 | Fix master heartbeat not visible to workers | DONE | 226bae2b |
| 13 | Fix ensureDefaultNodeSettings to write both "node" and "other" groups | DONE | — |
| 14 | Replace a-descriptions with HTML table (component missing from antd bundle) | DONE | — |
## v1.6.3 Fix Details
**Problem:** Node settings (`nodeRole`, `nodeId`, `syncInterval`, `trafficFlushInterval`) were not present in `x-ui.json` on fresh install. Users had to manually configure them via the database settings menu before they appeared.
**Root cause:** These keys were not in `defaultValueMap` in `web/service/setting.go`, so they were never auto-created when the panel initialized settings.
**Fix (commit d733ff2a):**
- Added `nodeRole`, `nodeId`, `syncInterval`, `trafficFlushInterval` to `defaultValueMap`
- Added `"node"` group to `settingGroups`
- Updated `settingGroupAliases` in `config/config.go` to look in `"node"` first, then `"other"` for backward compat
- Updated `ensureDefaultNodeSettings` to write to `"node"` group
## v1.6.4 Fix Details
**Problem:** Worker node page showed no connected master node, even though master had nodeId configured and was running.
**Root cause:** `updateNodeState()` in `node_sync.go` wrote heartbeats to `database.GetDB()` — the local SQLite for a master using SQLite. Workers query the shared MariaDB via `getNodeStatesFromShared()`, so the master's heartbeat was never visible to workers.
**Fix (commit 226bae2b):**
- Master now also writes heartbeat to shared MariaDB via `writeStateToSharedMariaDB()` when MariaDB connection settings are configured
- Check uses `dbUser` (no default value) as the most reliable indicator that MariaDB is configured
- This works even when `dbType` is "sqlite" (master uses SQLite locally but has shared MariaDB settings)
## Task 2 Details
**File created:** `web/controller/node.go`
**Endpoints:**
- `GET /node/list` — returns connected nodes with online/offline status based on heartbeat threshold
- `GET /node/config` — returns current node role, ID, sync intervals, and DB connection settings
- `POST /node/config` — validates and persists node + DB settings to `x-ui.json`
**Bug fixes applied from spec:**
1. Added missing `"time"` import (used by `time.Now().Unix()`)
2. Added missing `"os"` import (used by `os.ErrInvalid`)
3. Removed unused `"net/http"` import
4. Removed unused `"model"` import (return type of `database.GetNodeStates()` is inferred)

View file

@ -1,39 +0,0 @@
Task Record: Resolve client traffic InboundId from DB in shared mode
Date: 2026-04-24
Related Module: web/service/traffic_flush.go, web/job/xray_traffic_job.go — 共享模式流量收集
Change Type: Fix
Background
共享模式MariaDB 多节点Xray gRPC Stats API 返回的客户端流量只包含 email不包含 InboundId始终为 0。`Collect()` 函数直接使用了这个 `InboundId: 0`,导致流量无法正确关联到 inbound写入数据库时违反外键约束或写入错误的 inbound。
Changes
- `web/service/traffic_flush.go`:
- `Collect()` 新增 `emailToInboundID` 映射:在处理客户端流量前,先从 `client_traffics` 表查询所有 email 对应的 `inbound_id`
- 用查询到的真实 `InboundId` 替换 Xray API 返回的 `InboundId: 0`
- 未知 email数据库中无对应记录跳过并记录 warning 日志
- 新增测试用例:`TestCollectResolvesInboundIdFromDB`、`TestCollectSkipsUnknownEmail`、`TestCollectClampsNegativeResidualAndLogsDetailedWarning`
- `web/job/xray_traffic_job.go`:
- 共享模式下跳过 `addClientTraffic()`(因为 `Collect()` 已处理),改为手动计算并设置在线客户端列表
- `web/service/inbound.go`:
- 新增 `SetOnlineClients()``GetOnlineClients()` 方法,供共享模式设置在线状态
- `x-ui.sh`:
- 节点配置菜单增加 `trafficFlushInterval` 输入提示
Impact
- `web/service/traffic_flush.go`: Collect() 逻辑变更,影响所有共享模式节点的流量收集
- `web/job/xray_traffic_job.go`: 共享模式的在线客户端检测逻辑
- 不影响非共享模式SQLite/单节点 MariaDB
Verification
- `go test ./web/service/ -run TestCollect -v` — PASS
- `go test ./web/service/ -run TestTraffic -v` — PASS
Risks And Follow-Up
- 如果 `client_traffics` 表为空(首次部署),所有客户端流量都会被跳过,直到第一个 inbound 被创建并产生 `client_traffics`
- 旧的 `inboundId: 0` 残留 delta 仍可能存在于 `traffic-pending.json` 中(后续 commit 修复)

View file

@ -1,24 +0,0 @@
# Clash Link: Full Mihomo Template + Multi-Server Support
## Date: 2026-04-25
## Changes
### Backend
- `config/config.go` — Added `GetClashTemplatePath()`, `GetServersPath()`, `ReadClashTemplate()`, `SaveClashTemplate()`, `ReadServers()`, `SaveServers()`. Files stored at `/etc/x-ui/clash_template.yaml` and `/etc/x-ui/servers.yaml`
- `sub/subClashService.go` — Added `splitTemplate()` (from mihomo-gen), modified `GetClash()` to split at `proxies:`/`proxy-groups:` markers instead of `proxies: []` replacement. Added multi-server support: each `ClashServer` × each client generates a proxy entry. Falls back to old approach if split fails.
- `sub/sub.go` — Reads template and servers from files via `config.ReadClashTemplate()`/`config.ReadServers()`. Added `ClashServer` struct and `parseServers()`.
- `sub/subController.go` — Updated `NewSUBController` to accept `clashServers []ClashServer`
- `web/controller/xray_setting.go` — Added 4 API endpoints: `GET/POST /xray/clashTemplate`, `GET/POST /xray/servers`
- `web/service/setting.go` — Cleared `subClashTemplate` default (template now from file)
### Frontend
- `web/html/settings/xray/advanced.html` — Added "Clash" and "Servers" radio buttons in Xray advanced config
- `web/html/xray.html` — Added `clashTemplate`/`servers` data with old-value tracking, load/save methods, YAML CodeMirror mode, smart save button dispatches to correct save handler
- `web/html/settings/panel/subscription/clash.html` — Removed template editor (now in Xray advanced config)
- `web/html/settings.html` — Removed `initClashCodeMirror()` (template editor moved)
- `web/translation/translate.en_US.toml` — Added "Servers" key
- `web/translation/translate.zh_CN.toml` — Added "Servers" key
### Version
- `config/version` — Bumped to v1.7.2.1

View file

@ -1,13 +0,0 @@
# Fix: Clash proxy entries missing reality-opts, client-fingerprint, network
## Date: 2026-04-25
## Changes
### Bug Fixes
- `sub/subClashService.go` — Fixed REALITY settings extraction: `publicKey` is at `realitySettings.settings.publicKey`, not `realitySettings.publicKey`. `shortIds` is an array (use first element). `fingerprint` is at `realitySettings.settings.fingerprint`.
- `sub/subClashService.go` — Added `network` field to all proxy entries (default "tcp")
- `sub/subClashService.go` — Moved non-REALITY fingerprint code inside the `else` branch to avoid duplication
### Version
- `config/version` — Bumped to v1.7.2.1

View file

@ -1,10 +0,0 @@
# Fix: use servername and add encryption to Clash proxy entries
## Date: 2026-04-25
## Changes
- `sub/subClashService.go``sni``servername` (correct mihomo/Clash Meta field name)
- `sub/subClashService.go` — Added `encryption` field parsed from `inbound.Settings.encryption`
## Version
- v1.7.2.3

View file

@ -1,9 +0,0 @@
# Fix: Clash proxy YAML indentation for Clash Verge
## Date: 2026-04-25
## Changes
- `sub/subClashService.go` — Fixed YAML indentation: proxy fields now use 4-space indent under list item, nested `*-opts` fields use 6-space indent. Previously fields were at same level as `- name:` causing "invalid yaml" in Clash Verge.
## Version
- v1.7.2.2 (same)

View file

@ -1,48 +0,0 @@
# 2026-04-25 Fix ensureDefaultNodeSettings and worker node display
## Problem
### 1. Test failures in config package
Two tests were failing:
- `TestWriteSettingToJSONCreatesSettingsFileWhenMissing`
- `TestWriteSettingToJSONBackfillsDefaultNodeSettings`
Both failed with: `expected other group, got <nil>`
### 2. Worker frontend not showing connected master node
The worker's node management page rendered the card structure but didn't display the
master node information. The `a-descriptions` and `a-descriptions-item` components were
used in the template but were NOT included in the Ant Design Vue bundle (`antd.min.js`).
Vue silently skipped the unregistered components, resulting in an empty card body.
## Root Cause
### Test failures
`ensureDefaultNodeSettings()` only wrote defaults to the `"node"` group. Tests expected
the `"other"` group to also have defaults for backward compatibility.
### Worker node display
Ant Design Vue 2.x uses tree-shaking — only components actually imported during the build
are included in the bundle. `a-descriptions` and `a-descriptions-item` were not imported
in the project's Ant Design Vue build config, so they were missing from `antd.min.js`.
When Vue encounters an unregistered component tag, it silently ignores it.
## Fix
### Test failures
Changed `ensureDefaultNodeSettings()` to iterate over both `"node"` and `"other"` groups,
writing defaults to both for backward compatibility.
### Worker node display
Replaced `a-descriptions` / `a-descriptions-item` with a plain HTML `<table>` that
replicates the same visual layout (label-value pairs with borders). This doesn't depend
on any Ant Design Vue component.
## Files Changed
- `config/config.go`: Modified `ensureDefaultNodeSettings()` to write to both groups
- `web/html/nodes.html`: Replaced `a-descriptions` with HTML table
## Verification
- `go test -race -shuffle=on ./...` — all PASS

View file

@ -1,20 +0,0 @@
# 2026-04-25 Security: Fix login rate limiting and IP spoofing
## Changes
- Add `RateLimitMiddleware(10, time.Minute)` to `POST /login` endpoint (was unprotected, only register had rate limiting)
- Fix `getRemoteIp()` to use `c.Request.RemoteAddr` instead of trusting `X-Real-IP` / `X-Forwarded-For` headers
- Fix `RateLimitMiddleware` to use `RemoteAddr` directly, preventing IP-based rate limit bypass via header spoofing
## Security Issue
- Login endpoint had zero rate limiting, enabling unlimited brute-force attempts
- Both IP extraction and rate limiter trusted client-supplied headers, allowing attackers to spoof IPs and bypass all rate limiting
## Files Modified
- `web/controller/index.go` — add rate limit middleware to login route
- `web/controller/util.go` — use RemoteAddr in getRemoteIp()
- `web/middleware/ratelimit.go` — use RemoteAddr in rate limiter
## Note
- Trusts Cloudflare's `CF-Connecting-IP` header (CF overwrites it, clients cannot spoof)
- Falls back to `RemoteAddr` for direct connections without CDN
- `X-Real-IP` / `X-Forwarded-For` are NOT trusted (can be spoofed by clients)

View file

@ -1,38 +0,0 @@
# 2026-04-25 Fix node config save, dbType mismatch, and dark theme
## Problem
### 1. Node config save always fails
The `saveConfig` endpoint in `node.go` used `c.ShouldBindJSON()` which expects
`Content-Type: application/json`. But the global axios interceptor in `axios-init.js`
converts all POST data via `Qs.stringify()` and sends it as
`application/x-www-form-urlencoded`. The backend rejected every save with:
`invalid request (invalid character 's' looking for beginning of value)`.
### 2. dbType dropdown value mismatch
The frontend `<a-select>` used `value="mysql"` for the MySQL/MariaDB option, but the
backend checks for `"mariadb"` everywhere (database init, node list query, validation).
Saving through the UI would write `"mysql"`, which the backend would treat as SQLite.
### 3. Worker node info table invisible in dark theme
The HTML table for the worker's master node info used hardcoded inline styles
(`background:#fafafa`, `border-color:#e8e8e8`). In dark theme, inherited white text
on `#fafafa` background made label cells nearly invisible.
## Fix
### Config save
- Changed `ShouldBindJSON` to `ShouldBind` (matches all other controllers in the project)
- Added `form` struct tags to `updateConfigRequest` fields
### dbType mismatch
- Changed dropdown value from `"mysql"` to `"mariadb"` to match the backend constant
### Dark theme
- Extracted inline styles into CSS classes (`.node-info-wrap`, `.node-info-table`)
- Added `.dark` theme overrides using existing CSS custom properties
## Files Changed
- `web/controller/node.go`: `ShouldBindJSON``ShouldBind`, added `form` tags
- `web/html/nodes.html`: Fixed dbType value, replaced inline styles with theme-aware CSS

View file

@ -1,32 +0,0 @@
# 2026-04-25 Fix self-closing a-empty tag swallowing sibling elements
## Problem
Worker node panel showed an empty card body instead of the master node info table.
The API returned correct data (`/panel/api/nodes/list` had the master node), but the
rendered DOM was `<div><!----></div>` — both the `<a-empty>` and the info table `<div>`
were missing.
## Root Cause
Self-closing custom elements (`<a-empty ... />`) are invalid in HTML5 in-DOM templates.
The browser's HTML parser does NOT treat `/>` as self-closing for custom elements — it
treats `<a-empty ...>` as an **opening tag** and looks for `</a-empty>`.
This caused the next sibling `<div v-if="nodes.length > 0">` to become a **child** of
`<a-empty>` instead of a sibling. When `nodes.length > 0`, the `v-if="nodes.length === 0"`
on `<a-empty>` was false, and Vue skipped rendering its entire subtree — including the
info table that was accidentally nested inside it.
Verified with a headless browser:
- `<a-empty ... />` followed by `<div>`: div NOT visible (swallowed)
- `<a-empty ...></a-empty>` followed by `<div>`: div visible (correct)
## Fix
Changed all self-closing `<a-empty ... />` to explicit `<a-empty ...></a-empty>` in
`nodes.html`.
## Files Changed
- `web/html/nodes.html`: 2 self-closing `<a-empty>` tags → explicit closing tags

View file

@ -1,30 +0,0 @@
# 2026-04-25 Unify master/worker connected nodes table to a-table
## Problem
Worker node used a plain HTML `<table>` to display the connected master node, while master
used `<a-table>`. This caused several inconsistencies:
- Worker table had no role column, no ellipsis on error column
- Worker empty state had dead-code ternary (checking `nodeRole === 'master'` inside a `v-if="nodeRole === 'worker'"` block)
- Two separate `<a-empty>` instances with overlapping conditions
- 8 lines of CSS (`.node-info-table`, `.node-info-wrap`) only used by the worker table
## Fix
Unified both views to a single `<a-table>` using the existing `nodeColumns` definition:
- Removed `v-if="nodeRole === 'master'"` so the table renders for both roles
- Worker now shows all 7 columns (including role) with proper ellipsis on error
- Empty state handled via `<a-table :locale="{ emptyText: ... }">` with role-aware message
- Removed unused `.node-info-table` / `.node-info-wrap` CSS
- Removed the duplicate `<a-empty>` and the dead-code ternary
## Safety
The previous v1.6.6.x fixes are not regressed:
- `a-descriptions` tree-shaking issue: not applicable, `<a-table>` is already in the bundle (used by master)
- `v-else` on table: removed entirely, no conditional rendering on table element
- Self-closing `<a-empty>`: removed worker's `<a-empty>`, empty state is now via table locale prop
## Files Changed
- `web/html/nodes.html`: unified to single `<a-table>`, removed plain table + CSS + duplicate empty state

View file

@ -1,30 +0,0 @@
# 2026-04-25 — User Panel: Add Clash Link & Quick Import Button
## Summary
Optimized the user panel (`/panel/user`) to show subscription info and add a one-click import dropdown.
## Changes
### Backend
- Added `settingService` field to `InboundController`
- New endpoint `GET /panel/api/inbounds/userSubscriptions` — returns `subId`, `subClashEnable`, `subClashUrl` for the logged-in user
- Route registered before `checkAdmin` middleware so non-admin users can access
### Frontend (`web/html/user.html`)
- Redesigned page with 3 cards:
1. **User Info** — traffic stats, expiry, status (polished)
2. **Clash Link** — shows Clash subscription URL with copy button, or "暂无订阅" if not enabled
3. **Quick Import** — dropdown button with Android/iOS/Desktop options with icons (visual only, functionality TBD)
- Added copy-to-clipboard via `ClipboardManager`
### i18n
- Added keys to `translate.en_US.toml` and `translate.zh_CN.toml`:
- `clashUrl`, `quickImport`, `android`, `ios`, `desktop`, `copied`, `noSubscription`
## Files Modified
- `web/controller/inbound.go` — added settingService, getUserSubscriptions method
- `web/controller/api.go` — registered new route
- `web/html/user.html` — redesigned user panel page
- `web/translation/translate.en_US.toml` — new i18n keys
- `web/translation/translate.zh_CN.toml` — new i18n keys
- `config/version` — bumped to v1.7.2.5

View file

@ -1,28 +0,0 @@
# 2026-04-25 — User Panel: Quick Import URL Schemes
## Summary
Wired up the 3 Quick Import dropdown buttons (Android/iOS/Desktop) with deep link URL schemes to launch proxy client apps directly from the user panel.
## Changes
### Backend (`web/controller/inbound.go`)
- Extended `getUserSubscriptions` API to also return `subEnable` and `subUrl` (standard subscription URL)
- Previously only returned `subClashEnable` and `subClashUrl`
### Frontend (`web/html/user.html`)
- Added `subEnable` and `subUrl` data fields
- Updated `loadSubscriptions()` to save the new fields
- Added 3 URL scheme methods:
- **Android**`clash://install-config?url=<encoded_url>` (Clash Meta for Android)
- **iOS**`shadowrocket://add/sub/<base64_url>?remark=<name>` (Shadowrocket)
- **Desktop**`clash-verge://install-config?url=<encoded_url>&name=<name>` (Clash Verge)
- Added `@click` handlers on the 3 dropdown menu items
- Each method validates subscription availability before opening the URL scheme
### URL Scheme Priority
- Android/Desktop: prefers Clash URL (`subClashUrl`), falls back to standard URL (`subUrl`)
- iOS (Shadowrocket): prefers standard URL (`subUrl`), falls back to Clash URL
## Files Modified
- `web/controller/inbound.go` — extended API response with subEnable/subUrl
- `web/html/user.html` — added URL scheme methods and click handlers

View file

@ -1,55 +0,0 @@
Task Record
Date: 2026-04-26
Related Module: sub subscription services
Change Type: Fix
Background
Current subscription generation logic filters clients by both `subId` and `enable=true`.
As a result, when a client account is disabled, subscription endpoints cannot return Subscription Link or Clash Link content.
Requirement is to allow access to subscription and Clash links even when the account is not enabled.
Changes
Updated client matching conditions in subscription generation flows to only match `subId` and not require `client.Enable`:
- `sub/subService.go` (`GetSubs`)
- `sub/subJsonService.go` (`GetJson`)
- `sub/subClashService.go` (`GetClash`)
The inbound-level enable filter remains unchanged.
Impact
Affected modules or files.
- `sub/subService.go`
- `sub/subJsonService.go`
- `sub/subClashService.go`
Whether APIs, database, config, build, or compatibility are affected.
- API routes unchanged.
- Database schema unchanged.
- Configuration schema unchanged.
- Runtime behavior changed: disabled clients with valid `subId` can now receive subscription payloads.
Whether upstream or downstream callers are affected.
- Subscription consumers (normal/JSON/Clash links) now receive content even when client enable flag is false.
Verification
List validation commands or checks performed.
- `go test ./sub/...`
State the result.
- Passed.
If not verified, explain why.
- No remote runtime deployment verification was performed in this local environment.
Risks And Follow-Up
Remaining risks.
- This change intentionally relaxes access control semantics for disabled clients at subscription layer. If disable is expected to fully revoke access, this behavior is now different by design.
Recommended follow-up work.
- Confirm product expectation on whether this policy should also apply to other export channels (if any).

View file

@ -1,64 +0,0 @@
Task Record
Date: 2026-04-26
Related Module: web/service inbound client management
Change Type: Fix
Background
When adding new clients under VLESS + TCP + (TLS/Reality), `flow` may be left empty.
This causes missing expected default flow behavior and inconsistent client configuration.
Requirement is to auto-fill `flow` with `xtls-rprx-vision` only when the client context requires flow.
Changes
Added backend auto-fill logic for new clients:
- Introduced `shouldAutoFillVisionFlow(...)` to detect flow-required context:
- protocol is `vless`
- stream `network` is `tcp`
- stream `security` is `tls` or `reality`
- Introduced `autoFillVisionFlowInSettings(...)` to fill empty/missing client `flow` as `xtls-rprx-vision`.
Integrated into add/update flows:
- `AddInbound`: auto-fill for initial clients of a newly created inbound.
- `AddInboundClient`: auto-fill for clients added via add-client endpoint (includes bulk add and TG bot path).
- `UpdateInbound`: auto-fill only for newly added VLESS clients (does not override existing clients).
Added tests:
- `web/service/inbound_flow_autofill_test.go`
- all eligible clients are auto-filled
- selected new clients only can be targeted
- no change when flow is not required
Impact
Affected modules or files.
- `web/service/inbound.go`
- `web/service/inbound_flow_autofill_test.go`
Whether APIs, database, config, build, or compatibility are affected.
- API endpoints unchanged.
- Database schema unchanged.
- Runtime behavior change: new clients in eligible VLESS context auto-receive `xtls-rprx-vision` flow.
Whether upstream or downstream callers are affected.
- UI add-client, bulk add-client, and TG bot add-client now share consistent default flow behavior via backend logic.
Verification
List validation commands or checks performed.
- `go test ./web/service/...`
State the result.
- Passed.
If not verified, explain why.
- No remote runtime deployment test was performed in local environment.
Risks And Follow-Up
Remaining risks.
- If operators expect empty flow for newly added VLESS+TCP+TLS/Reality clients, behavior is now intentionally changed.
Recommended follow-up work.
- If needed, expose a setting switch to opt out of default flow auto-fill.

View file

@ -1,60 +0,0 @@
Task Record: Batch Edit Clients
Date: 2026-04-26
Related Module: Client management (web/service, web/controller, web/html)
Change Type: Add
Background
Users previously had to edit each client individually to change shared settings (flow, limit IP, total GB, expiry time, enable state, Telegram ID, comment, reset period). This was time-consuming when managing many clients in an inbound.
Changes
Added batch multi-select and batch editing for clients in the inbound expanded view:
Backend:
- New `BatchUpdateInboundClients` service method in `web/service/inbound.go` - updates multiple clients' common fields in one transaction, syncs client_traffics table (total, expiry_time, enable, reset, tg_id) and Xray API (enable/disable)
- New `BatchUpdateInboundClientsForUser` authorization wrapper
- New `POST /panel/api/inbounds/batchUpdateClients` API route in `web/controller/inbound.go`
- Added `toInt64` helper for JSON number type conversion
Frontend:
- New `client_batch_edit_modal.html` modal for batch editing common fields (enable, security, flow, limitIP, totalGB, expiryTime, delayedStart, reset, tgId, comment) with "keep unchanged" defaults
- Modified `inbounds.html` expanded row: added row-selection to client table, batch action bar (batch edit, enable/disable, reset traffic, delete, deselect)
- Added `clientSelection` reactive state and helper methods (`getClientRowKey`, `getClientRowSelection`, `getSelectedClients`, `openBatchEditClient`, `batchEnableClient`, `batchResetClientTraffic`, `batchDelClient`, `clearClientSelection`)
Translations:
- Added keys to `translate.en_US.toml` and `translate.zh_CN.toml`: batchEdit, batchEditAlert, batchKeep, batchEditNoFields, batchDeselect, batchDeleteLastClient, selected
Fields explicitly NOT batch-editable (enforced both frontend and backend):
- email (unique identifier for traffic tracking)
- id (protocol-specific client UUID)
- subId (subscription identifier)
- password (Trojan client password)
Impact
Affected files:
- `web/service/inbound.go` (+~210 lines)
- `web/controller/inbound.go` (+~30 lines)
- `web/html/inbounds.html` (~+90 lines in methods, +15 lines in template)
- `web/html/modals/client_batch_edit_modal.html` (new, 209 lines)
- `web/translation/translate.en_US.toml` (+8 keys)
- `web/translation/translate.zh_CN.toml` (+8 keys)
No database schema changes. No config changes. No breaking API changes (new route only).
Verification
- `go build ./...` - passes
- `CGO_ENABLED=1 go build` - passes
- `go vet ./...` - passes
- `gofmt -d` - no formatting issues
Not verified: runtime integration testing (requires running panel with Xray and clients).
Risks And Follow-Up
- Batch delete does not prevent deleting the LAST client (only warns when trying to delete ALL remaining). The individual delete button already shows/hides based on `isRemovable()`.
- If the Xray API call fails during batch enable/disable, the service returns `needRestart=true` which triggers Xray restart by the periodic check.
- The batch edit modal resets all field values when reopened; no persistence across invocations.

View file

@ -1,30 +0,0 @@
Task Record:
Date: 2026-04-26
Related Module: config/version
Change Type: Config
Background
Release version needs to be updated to `v1.7.2.11` so runtime-reported version aligns with release metadata.
Changes
Updated `config/version` from `v1.7.2.9` to `v1.7.2.11`.
Added this task tracking record for version bump traceability.
Impact
Affected files: `config/version`.
No API, database, or runtime logic changes.
Only release metadata/version display is updated.
Verification
Check: inspected `config/version` content after update.
Result: value is `v1.7.2.11`.
Risks And Follow-Up
Low risk.
Follow-up: create/push release tag if remote publishing is required.

View file

@ -1,29 +0,0 @@
Task Record: Bump Version To v1.7.2.7
Date: 2026-04-26
Related Module: Release metadata
Change Type: Config
Background
The user panel quick import dropdown fix is ready to be published as version `v1.7.2.7`.
Changes
Updated `config/version` from `v1.7.2.6` to `v1.7.2.7`.
No application logic, API behavior, database schema, or frontend layout was changed in this version bump.
Impact
Affected file: `config/version`.
The embedded application version will report `v1.7.2.7` after build.
No upstream or downstream callers are affected.
Verification
Ran `GOCACHE=/tmp/go-build go test ./config ./web/...`; it passed.
Risks And Follow-Up
No known runtime risk from the version metadata change.
The `v1.7.2.7` git tag should point at the version bump commit before pushing.

View file

@ -1,29 +0,0 @@
Task Record:
Date: 2026-04-26
Related Module: config/version
Change Type: Config
Background
A new release tag `v1.7.2.8` is required. The embedded version source still pointed to `v1.7.2.7`, which would cause runtime version output to mismatch the release tag.
Changes
Updated `config/version` from `v1.7.2.7` to `v1.7.2.8`.
Added this task tracking record for the version bump and release consistency.
Impact
Affected files: `config/version`.
No API, database, runtime logic, or compatibility behavior changes.
Version display and release tag are aligned.
Verification
Check: inspected `config/version` content after update.
Result: shows `v1.7.2.8`.
Risks And Follow-Up
Low risk. Follow-up is to push commit and `v1.7.2.8` tag to remote repository.

View file

@ -1,30 +0,0 @@
Task Record:
Date: 2026-04-26
Related Module: config/version
Change Type: Config
Background
A new release `v1.7.2.9` is requested. The embedded version metadata must match the release tag to avoid runtime/reporting mismatch.
Changes
Updated `config/version` from `v1.7.2.8` to `v1.7.2.9`.
Added this task tracking record for the version bump.
Impact
Affected files: `config/version`.
No API behavior, database schema, or runtime logic changes.
Binary/runtime version output is aligned with release tag `v1.7.2.9`.
Verification
Check: inspected `config/version` after update.
Result: value is `v1.7.2.9`.
Risks And Follow-Up
Low risk.
Follow-up: push commit and `v1.7.2.9` tag to remote.

View file

@ -1,65 +0,0 @@
Task Record
Date: 2026-04-26
Related Module: web settings / subscription assets / setting service
Change Type: Fix
Background
The Clash subscription toggle in `/panel/settings` did not behave correctly in practice.
The page was loading an outdated fingerprinted frontend model from `web/public` that did not include Clash subscription fields, which caused inconsistent binding and update behavior for `subClashEnable` and related properties.
In addition, Clash subscription keys were not mapped in the settings grouping metadata, making their nested config representation inconsistent.
Changes
Regenerated fingerprinted frontend assets via `go run ./cmd/genassets` so the settings page now loads an updated `AllSetting` model containing:
- `subClashEnable`
- `subClashPath`
- `subClashURI`
Updated settings group mappings in `web/service/setting.go`:
- Added Clash keys to `subscriptionNetwork`:
- `clashEnable -> subClashEnable`
- `clashPath -> subClashPath`
- `clashURI -> subClashURI`
- Added corresponding Clash keys to legacy `sub` mapping for compatibility.
Impact
Affected modules or files.
- `web/service/setting.go`
- `web/public/assets-manifest.json`
- `web/public/assets/js/model/setting.*.js` (fingerprinted replacement)
- `web/public/assets/js/subscription.*.js` (fingerprinted replacement)
- `web/public/assets/codemirror/yaml.*.js` (fingerprinted generated asset)
Whether APIs, database, config, build, or compatibility are affected.
- API schema unchanged.
- Database unchanged.
- Settings file structure compatibility improved for Clash keys in nested/legacy mappings.
- Frontend static asset fingerprints updated.
Whether upstream or downstream callers are affected.
- Panel settings frontend now correctly tracks and submits Clash subscription toggle/path fields.
- Subscription page uses refreshed asset bundle.
Verification
List validation commands or checks performed.
- `go run ./cmd/genassets`
- `go test -race ./web/service/...`
State the result.
- Asset generation succeeded.
- Related service tests passed.
If not verified, explain why.
- No live runtime verification against a deployed panel/subscription server was performed in this local environment.
Risks And Follow-Up
Remaining risks.
- Existing browser caches may keep old fingerprint mappings until refresh; after deploy/restart, hard refresh may still be needed in some clients.
Recommended follow-up work.
- Verify in a deployed environment that toggling `Clash Subscription` and editing `subClashPath` immediately reflects expected behavior after restart/reload cycle.

View file

@ -1,52 +0,0 @@
Task Record
Date: 2026-04-26
Related Module: client add modals (web/html)
Change Type: Fix
Background
Although backend flow auto-fill was implemented for eligible new clients, the add-client UI still initialized `flow` as empty by default.
This made the feature appear non-functional during client creation.
Changes
Updated UI defaults for new VLESS clients in add dialogs:
- `web/html/modals/client_modal.html`
- In single add flow, when inbound `canEnableTlsFlow()` is true and client flow is empty, default to `xtls-rprx-vision`.
- `web/html/modals/client_bulk_modal.html`
- On modal show, default selected bulk `flow` to `xtls-rprx-vision` when `canEnableTlsFlow()` is true.
- In `newClient(...)` for VLESS, initialize empty flow to `xtls-rprx-vision` under the same condition.
Impact
Affected modules or files.
- `web/html/modals/client_modal.html`
- `web/html/modals/client_bulk_modal.html`
Whether APIs, database, config, build, or compatibility are affected.
- No API contract changes.
- No database schema changes.
- UI behavior change only for default values in eligible flow-required scenarios.
Whether upstream or downstream callers are affected.
- Panel operators adding clients now immediately see expected default flow value.
Verification
List validation commands or checks performed.
- `go test ./web/...`
State the result.
- Passed.
If not verified, explain why.
- No remote runtime interaction was performed in this local environment.
Risks And Follow-Up
Remaining risks.
- Existing browser cache may keep older template assets until refresh/reload.
Recommended follow-up work.
- Verify add-client and bulk-add flows in panel UI for VLESS+TCP+TLS/Reality in deployed environment.

View file

@ -1,54 +0,0 @@
Task Record
Date: 2026-04-26
Related Module: web/service user registration client auto-provisioning
Change Type: Fix
Background
New user registration auto-creates clients across inbounds via `addUserClientsToAllInbounds`.
This path bypassed the previously added AddInboundClient flow auto-fill logic, so newly registered users could still get empty `flow` in eligible VLESS contexts.
Changes
Updated registration auto-provisioning path in `web/service/user.go`:
- When target inbound requires flow (`VLESS + TCP + TLS/Reality`), set client `Flow` to `xtls-rprx-vision`.
- Persist `flow` field in generated client entry when populated.
Added test in `web/service/user_test.go`:
- `TestRegisterUser_AutoFillFlowForEligibleVlessInbound`
- Verifies registered user gets `xtls-rprx-vision` flow in eligible VLESS inbound.
- Verifies non-VLESS inbound does not get forced flow.
Impact
Affected modules or files.
- `web/service/user.go`
- `web/service/user_test.go`
Whether APIs, database, config, build, or compatibility are affected.
- API unchanged.
- DB schema unchanged.
- Runtime behavior fixed for registration-created clients only.
Whether upstream or downstream callers are affected.
- Newly registered users now receive expected default flow in eligible VLESS inbounds.
Verification
List validation commands or checks performed.
- `go test ./web/service/...`
State the result.
- Passed.
If not verified, explain why.
- No remote runtime verification in deployed environment was performed locally.
Risks And Follow-Up
Remaining risks.
- Existing already-created clients are unaffected (no migration applied).
Recommended follow-up work.
- If needed, add a one-time migration tool to backfill empty flow for existing eligible clients.

View file

@ -1,31 +0,0 @@
Task Record: Fix User Quick Import Dropdown
Date: 2026-04-26
Related Module: User panel
Change Type: Fix
Background
The `/panel/user` quick import dropdown had three menu entries for Android, iOS, and Desktop. The entries were clickable, but the frontend did not render them visibly in the dropdown.
Changes
Updated the quick import dropdown menu to use Ant Design Vue's menu `theme` prop instead of applying the current theme as a plain CSS class.
Added a dedicated dropdown overlay class so the detached popup layer can inherit page theme styles.
Added scoped sizing and alignment styles for the quick import menu icons and items.
Impact
Affected file: `web/html/user.html`.
No API, database, configuration, build, or compatibility changes were made.
The existing Android, iOS, and Desktop click handlers are unchanged.
Verification
Ran `go test ./web/...`; it failed because the default Go build cache path `/root/.cache/go-build` is read-only in this environment.
Ran `GOCACHE=/tmp/go-build go test ./web/...`; it passed.
No local browser runtime verification was performed, following the project constraint to avoid local panel startup and integration runs.
Risks And Follow-Up
The fix targets the dropdown rendering path only. A remote browser check on `/panel/user` is recommended after deployment to confirm the dropdown is visible in both light and dark themes.

View file

@ -1,33 +0,0 @@
Task Record:
Date: 2026-04-26
Related Module: web/html/user.html
Change Type: Fix
Background
The quick import entry on `/panel/user` was implemented as an Ant Design dropdown menu. In the current UI state, menu item text failed to display reliably unless SVG-related markup was manually removed, making the one-click import actions unusable.
Changes
Replaced the dropdown-based quick import block with an inline action section inside the existing card body.
Changed the quick import area to show a small title using `pages.user.quickImport`.
Added three dedicated buttons for Android, iOS, and Desktop that reuse the existing handlers: `quickImportAndroid`, `quickImportIOS`, and `quickImportDesktop`.
Removed obsolete dropdown-specific styles and added compact styles for the new button group layout.
Impact
Affected files: `web/html/user.html`.
No API, database, build pipeline, or backend logic changes.
User-panel behavior changed from "click dropdown then choose platform" to "direct platform buttons".
No upstream/downstream interface compatibility impact.
Verification
Command: `go test ./web/...`
Result: Passed.
Risks And Follow-Up
The change avoids dropdown/menu rendering paths and should be more robust for text visibility.
If further UX tuning is needed, spacing and button style can be adjusted without changing quick import logic.

View file

@ -1,466 +0,0 @@
# install.sh 修复清单与逐项修复说明
## 说明
本文基于 `docs/install-issue-assessment.md` 的分级结果,结合 `install.sh`、`x-ui.sh`、`update.sh`、service 模板与项目核心配置/启动逻辑的交叉检查,整理出一份可执行的修复清单。
文档目标:
- 把问题清单转成“可落地的修复项”
- 标明每项修复是否需要联动改动
- 提前指出与现有代码约定的冲突点
- 给出最小可验证的验收方式
---
## 使用建议
- 优先处理 `P0 / P1`,再处理输入校验、引号和死代码
- 涉及 service 路径、自定义安装目录、MariaDB 首装流程的项,不建议只改 `install.sh`
- 涉及“是否强制 HTTPS”“是否保留默认 admin/admin”的项属于产品行为决策修复前先定策略
---
## 一、核心修复项
### 1. 修复端口占用检测逻辑
- **对应问题**`P0-01`
- **涉及文件**
- `install.sh`
- `x-ui.sh`
- `update.sh`
- **修复说明**
- 当前 `ss` / `netstat` 分支使用 `awk '$4 ~ p {exit 0} END {exit 1}'`,即使命中也会在 `END` 阶段返回失败。
- 应统一改成可靠的布尔判断方式,例如:
- 用 `ss -ltn | awk ...` 时使用状态变量控制退出码
- 或直接用 `grep -q`
- 三个脚本里有重复实现,必须一次性同步修改,避免菜单脚本和安装脚本行为不一致。
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,但属于“从错误行为修正为正确行为”
- **验收点**
- 在端口已被监听时能稳定返回“占用”
- 在端口空闲时能稳定返回“空闲”
- 三个脚本的检测结果一致
### 2. 修复 SSL 失败仍显示成功
- **对应问题**`P0-02`
- **涉及文件**
- `install.sh`
- **修复说明**
- `prompt_and_setup_ssl` 返回失败后,当前脚本仍会输出 HTTPS 地址和“SSL 已启用并配置”。
- 至少要做到:
- 根据返回值区分“SSL 成功 / SSL 失败”
- 失败时不要显示成功文案
- 失败时不要强制输出 `https://...`
- 更进一步可以考虑:
- 失败即中止安装
- 或允许继续安装但明确标记为“HTTP/未启用 SSL”
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,安装摘要将不再“伪成功”
- **注意事项**
- 运行时 Web 服务本身支持 HTTP fallback因此此项修复应优先修正文案不要未经确认直接改成“SSL 失败即安装失败”
- **验收点**
- 证书签发失败时摘要中不再出现“SSL 已启用”
- 地址协议与实际生效状态一致
### 3. 修复 `ssl_cert_issue` 的成功判定方式
- **对应问题**`P0-03`
- **涉及文件**
- `install.sh`
- **修复说明**
- 现在通过 `acme.sh --list | tail -1` 推断“本次签发的是哪张证书”,这不可靠。
- 更合理的修法:
- 让 `ssl_cert_issue()` 在成功时直接返回本次域名
- 或在函数内直接完成域名保存,不依赖外部再去猜
- 或基于用户输入域名作为唯一可信来源,不再查列表尾项
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,证书成功识别逻辑更严格
- **验收点**
- 机器上存在多张证书时,脚本仍能准确识别本次域名
- 不会因为列表最后一项不是当前域名而误写配置
### 4. 去除默认 `admin/admin` 安装行为
- **对应问题**`P0-04`
- **涉及文件**
- `install.sh`
- **关联代码**
- `database/db.go`
- **修复说明**
- 当前脚本文案声称“留空自动生成”,实际却回退为 `admin/admin`
- 建议修法:
- 用户名留空时默认 `admin` 可以接受,但应明确提示
- 密码留空时必须随机生成
- 若输入 `rd`,也应随机生成密码而不是回退默认值
- 安装完成时明确输出最终用户名/密码
- **是否与项目代码冲突**:否
- **是否会改变行为**:是,默认安装体验会改变
- **注意事项**
- 核心代码默认种子用户仍是 `admin/admin`,安装脚本只是覆盖它
- 因此此项不要求先改 Go 代码,也能落地
- **验收点**
- 留空安装时不再生成 `admin/admin`
- 安装摘要里显示的密码与实际可登录密码一致
### 5. 重构 MariaDB 首装流程
- **对应问题**`P0-05`
- **涉及文件**
- `install.sh`
- **关联代码**
- `main.go`
- `config/config.go`
- `web/service/setting.go`
- **修复说明**
- 当前流程是:
1. 先按默认 SQLite 初始化和写配置
2. 再把 `dbType` 改成 MariaDB
3. 但没有做 SQLite → MariaDB 数据迁移
- 这会导致“用户输入过的配置不一定落到最终使用的库”。
- 推荐修法有两种:
- **方案 A保守修法**
- 首装统一先走 SQLite
- 若用户选择 MariaDB提示安装完成后执行明确迁移
- 避免脚本伪装成“已直接落到 MariaDB”
- **方案 B彻底修法**
- 在项目代码里补一个“无 DB bootstrap 配置写入流程”
- 先写 settings JSON再按 `dbType` 初始化对应数据库
- 如果只改脚本顺序,不改项目代码,风险仍然较高。
- **是否与项目代码冲突**:有耦合,需要联动
- **是否会改变行为**:是,尤其是 MariaDB 首装路径
- **验收点**
- 首次选择 MariaDB 后,最终实际运行库中存在用户刚设置的账号/端口/路径
- 不再出现“看似设置成功,实际登录不上”的情况
### 6. 修复 `install_acme()` 污染当前目录
- **对应问题**`P0-06`
- **涉及文件**
- `install.sh`
- **修复说明**
- `install_acme()` 进入 `~` 后不恢复当前目录,后续若依赖解压目录中的文件会出错。
- 建议修法:
- 函数内保存当前目录并在结束前恢复
- 或彻底改成绝对路径,不依赖当前工作目录
- **是否与项目代码冲突**:否
- **是否会改变行为**:否,属于纯修复
- **验收点**
- 安装 acme 之后,仍能从解压目录找到 service 文件
- 安装过程中的后续 `cp`/`cd` 行为不再受影响
### 7. 统一 Arch 的安装路径与 service 路径
- **对应问题**`P0-07`
- **涉及文件**
- `install.sh`
- `x-ui.service.arch`
- **修复说明**
- 当前脚本默认安装到 `/usr/local/x-ui`,但 Arch service 指向 `/usr/lib/x-ui`
- 修法应二选一:
- 要么 Arch 平台真的安装到 `/usr/lib/x-ui`
- 要么修改 Arch service 模板,让它和 `xui_folder` 保持一致
- 推荐后者:统一用安装脚本渲染 service 路径,而不是靠固定模板硬编码。
- **是否与项目代码冲突**:否,但与 service 模板强相关
- **是否会改变行为**:会,从“默认可能起不来”变成“可正常启动”
- **验收点**
- Arch 安装后 `systemctl status x-ui` 正常
- service 的 `WorkingDirectory``ExecStart` 指向真实安装目录
### 8. 给删除安装目录增加安全保护
- **对应问题**`P0-08`
- **涉及文件**
- `install.sh`
- 建议同步评估 `x-ui.sh`
- **修复说明**
- 当前直接 `rm -rf ${xui_folder}/`,若环境变量异常可能误删危险路径。
- 建议加以下保护:
- 变量不能为空
- 不能是 `/`
- 不能是 `/usr`、`/usr/local` 这类上层目录
- 路径必须匹配预期模式
- 统一加引号
- **是否与项目代码冲突**:否
- **是否会改变行为**:否,属于安全兜底
- **验收点**
- 正常安装目录可删除
- 危险目录会被拒绝并报错
---
## 二、联动修复项
### 9. 自定义安装目录支持做全链路统一
- **对应问题**`P1-01`、`P1-11`
- **涉及文件**
- `install.sh`
- `x-ui.sh`
- `update.sh`
- `x-ui.service.debian`
- `x-ui.service.rhel`
- `x-ui.service.arch`
- `x-ui.rc`
- **修复说明**
- 目前只有脚本变量允许覆盖安装目录,但 service 模板与部分流程仍硬编码路径。
- 修法建议:
- 统一把 service/init 文件改为模板渲染
- 所有脚本都以 `XUI_MAIN_FOLDER` 为唯一事实来源
- 不再通过 `${xui_folder%/x-ui}` 推导工作目录
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,自定义目录将真正可用
- **验收点**
- 设置 `XUI_MAIN_FOLDER`安装、升级、菜单、service 启动全部正常
### 10. 统一证书状态判定逻辑
- **对应问题**`P1-07` 及相关派生问题
- **涉及文件**
- `install.sh`
- `web/web.go`
- `web/html/settings.html`
- **修复说明**
- 当前前端和安装脚本在“何时算 HTTPS 启用”上判断过于宽松。
- 运行时真实逻辑是:只有证书和私钥都能被加载,才会启用 TLS。
- 修法建议:
- 安装摘要中:必须 `cert + key` 都有效才显示 HTTPS
- 前端跳转逻辑中:不要用 `webCertFile || webKeyFile` 判定 HTTPS
- 可选地增加“证书路径存在但加载失败”的状态提示
- **是否与项目代码冲突**:否,是向运行时行为靠拢
- **是否会改变行为**:会,状态显示更严格
- **验收点**
- 只有 cert/key 成对有效时才显示 HTTPS
- 前端不会因为只填了一项路径就强跳 HTTPS
### 11. 同步修复脚本族中的重复问题
- **对应问题**:重复逻辑导致的派生风险
- **涉及文件**
- `install.sh`
- `x-ui.sh`
- `update.sh`
- **修复说明**
- 多处辅助函数和路径逻辑是复制粘贴的。
- 推荐修法:
- 先列出三者重复函数:端口检测、域名/IP 校验、acme 检查、目录变量
- 同批次修改,避免只修了安装脚本但菜单脚本/更新脚本依旧保留旧 bug
- 长期建议是提取共享 shell 片段,但短期不一定需要重构。
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,让相关脚本行为一致
- **验收点**
- 同一类操作在三个脚本中的表现一致
---
## 三、需要先做产品决策的修复项
### 12. 决定 SSL 失败时是否中止安装
- **对应问题**`P0-02` 的策略层延伸
- **涉及文件**
- `install.sh`
- 间接关联 `web/web.go`
- **修复说明**
- 当前脚本文案把 SSL 描述成“必需”,但运行时实际上支持 HTTP。
- 修复前要先定策略:
- **策略 ASSL 失败仍允许安装**
- 但必须准确提示“当前为 HTTP”
- **策略 BSSL 失败即安装失败**
- 真正把 SSL 变成安装前置条件
- 两种都能实现,但这是产品行为选择,不是单纯 bugfix。
- **是否与项目代码冲突**:否
- **是否会改变行为**:是
- **验收点**
- 安装流程与产品定义一致
- 文案、输出地址、最终服务状态三者一致
### 13. 决定默认凭据策略
- **对应问题**`P0-04`
- **涉及文件**
- `install.sh`
- 间接关联 `database/db.go`
- **修复说明**
- 需要明确项目策略是:
- 保留默认用户名 `admin`
- 默认密码随机
- 还是用户名、密码都随机
- 一旦确定,应同步修改:
- 安装提示
- 安装完成摘要
- 相关文档
- **是否与项目代码冲突**:否
- **是否会改变行为**:是
- **验收点**
- 安装体验符合预期
- 登录凭据输出准确、可复现
### 14. 决定是否正式支持“首装直连 MariaDB”
- **对应问题**`P0-05`
- **涉及文件**
- `install.sh`
- `main.go`
- `config/config.go`
- `web/service/setting.go`
- **修复说明**
- 若确认保留这个能力,建议不要再依赖“先 InitDB 再改 dbType”的间接流程。
- 更合理的做法是引入正式 bootstrap
- 先生成 settings JSON
- 再依据 `dbType` 初始化数据库
- 再写用户与面板基础配置
- 若短期不想改 Go 代码则建议安装脚本暂时不要把“MariaDB 首装直连”包装成已完整支持。
- **是否与项目代码冲突**:有实现耦合
- **是否会改变行为**:是
- **验收点**
- 首装 MariaDB 路径不再依赖 SQLite 中间态
---
## 四、低风险可批量修复项
### 15. 给关键命令补错误检查
- **对应问题**`P1-02`、`P1-03`、`P1-04`
- **涉及文件**
- `install.sh`
- **修复说明**
- `tar`、`cd`、`config_after_install`、依赖安装等关键步骤都应显式校验返回值。
- 建议统一模式:
- 关键命令失败立即退出
- 输出明确错误原因
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,更早失败、更容易定位问题
- **验收点**
- 任一关键步骤失败时,脚本立即停止并给出准确信息
### 16. 修复路径和变量未加引号
- **对应问题**`P2-18`
- **涉及文件**
- `install.sh`
- `x-ui.sh`
- `update.sh`
- **修复说明**
- 所有路径、URL、证书目录、安装目录、环境变量路径都应统一加引号。
- 尤其是:
- `rm -rf`
- `cp`
- `mv`
- `chmod`
- 执行 `${xui_folder}/x-ui`
- **是否与项目代码冲突**:否
- **是否会改变行为**:通常不会,只提升稳健性
- **验收点**
- 自定义目录、带空格路径时脚本仍工作正常
### 17. 修复手动端口输入默认值与数字校验
- **对应问题**`P2-05`、`P2-06`、`P2-07`
- **涉及文件**
- `install.sh`
- **修复说明**
- 用户回车时应自然使用默认端口,而不是走“无效输入”
- 所有端口输入统一做:
- 去空格
- 数字校验
- 1-65535 范围校验
- 端口占用校验
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,交互更合理
- **验收点**
- 空输入能正确落默认值
- 非数字输入会被拒绝
- 冲突端口会被识别
### 18. 修复公网 IP 获取失败后的交互路径
- **对应问题**`P2-12`
- **涉及文件**
- `install.sh`
- **修复说明**
- 当前 `server_ip` 获取失败后IP 证书路径依然可能被当作默认路径。
- 建议修法:
- 获取失败时提示用户手动输入 IPv4
- 或自动把默认 SSL 选项切到域名/自定义证书
- 至少不能继续伪装“默认可走 IP 证书”
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,失败路径更明确
- **验收点**
- IP 获取失败时不会再生成空主机名 URL
### 19. Cloudflare API Key 改为静默输入并清理环境变量
- **对应问题**`P2-13`、`P2-14`
- **涉及文件**
- `install.sh`
- **修复说明**
- API Key 应使用静默输入
- 导出环境变量后应在流程结束时 `unset`
- 域名也应走 `is_domain` 校验
- **是否与项目代码冲突**:否
- **是否会改变行为**:会,更安全
- **验收点**
- 终端不会明文回显 API Key
- 函数结束后环境变量不残留
### 20. 清理死代码与误导注释
- **对应问题**`P3-01`、`P3-02`、`P3-03`、`P3-05`、`P3-06`、`P3-07`、`P3-08`
- **涉及文件**
- `install.sh`
- **修复说明**
- 删除未使用变量与未接入函数
- 修正不准确注释和提示文案
- 调整 bash 风格问题(如 `== 0``-eq 0` 这类可读性优化)
- **是否与项目代码冲突**:否
- **是否会改变行为**:通常不会
- **验收点**
- 代码更容易维护,输出文案与实际逻辑一致
---
## 五、建议执行顺序
### 第一批:立即修
- 修复端口检测
- 修复 SSL 伪成功
- 修复 `install_acme` 目录污染
- 修复 Arch service 路径
- 修复危险删除逻辑
### 第二批:联动修
- 自定义安装目录全链路支持
- 三个 shell 脚本重复逻辑同步修复
- 证书状态判定统一到运行时真实行为
### 第三批:策略确认后再修
- SSL 失败是否中止安装
- 默认凭据策略
- MariaDB 首装是否正式支持
### 第四批:批量清理
- 输入校验
- 错误检查
- 引号与安全性
- 死代码与注释
---
## 六、最小验收清单
- [ ] `install.sh`、`x-ui.sh`、`update.sh` 的端口检测结果一致
- [ ] 证书失败时安装摘要不再伪装 HTTPS 成功
- [ ] Arch / Debian / RHEL / Alpine 的 service/init 路径与实际安装目录一致
- [ ] 自定义 `XUI_MAIN_FOLDER` 后,安装、升级、菜单脚本都能正常工作
- [ ] 选择 MariaDB 时,最终实际使用的数据库中存在用户刚输入的配置
- [ ] 密码留空不再回退 `admin/admin`
- [ ] Cloudflare API Key 不再明文输入
- [ ] 公网 IP 获取失败时,默认流程不会给出空地址或错误 HTTPS 地址

View file

@ -1,113 +0,0 @@
# install.sh 问题评估与分级
## 说明
本文对 `install.sh` 及其直接依赖的服务文件进行静态审查,输出逻辑问题、潜在 Bug 与风险分级。
- 审查对象:`install.sh`、`x-ui.service.arch`、`x-ui.service.debian`、`x-ui.service.rhel`、`x-ui.rc`
- 审查方式:静态阅读 + `bash -n install.sh`
- 结论范围:以脚本逻辑为主,不包含联网下载内容真实性、外部服务可用性与目标主机现场状态
## 分级标准
| 级别 | 含义 |
|---|---|
| P0 / 致命 | 高概率导致安装结果错误、服务不可用、错误安全结论、数据错写或危险操作 |
| P1 / 高 | 在常见环境下容易触发,导致功能失败、行为不一致或明显错误 |
| P2 / 中 | 不是每次触发,但会造成兼容性、稳健性、可维护性或边界行为问题 |
| P3 / 低 | 风险较低,主要是误导、死代码、风格不一致或隐含维护成本 |
## 总览
| 级别 | 数量 | 重点 |
|---|---:|---|
| P0 / 致命 | 8 | 端口检测失效、SSL 失败仍报成功、MariaDB 新装数据错位、目录漂移 |
| P1 / 高 | 12 | 自定义目录不完整支持、安装失败继续、弱口令、服务路径错误 |
| P2 / 中 | 18 | 输入校验不足、平台兼容性差、下载/解析脆弱、引号缺失 |
| P3 / 低 | 8 | 死代码、注释偏差、提示文案不准确、维护性问题 |
---
## P0 / 致命
| ID | 位置 | 问题 | 影响 | 建议 |
|---|---|---|---|---|
| P0-01 | `install.sh:63`, `install.sh:67` | `is_port_in_use()``awk ... END {exit 1}` 覆盖前面的成功退出码,导致 `ss`/`netstat` 分支几乎总返回“端口未占用” | 端口冲突检测失效ACME 独立监听端口选择可能错误,安装过程可能与现有服务抢端口 | 改为纯 `grep`/`ss` 判断,或让 `awk` 使用状态变量并在 `END` 中按变量返回 |
| P0-02 | `install.sh:955`, `install.sh:966`, `install.sh:969` | SSL 配置函数失败后,安装摘要仍打印 HTTPS 地址和“SSL 已启用并配置” | 用户被误导为已启用 HTTPS可能错误暴露面板 | 必须检查 `prompt_and_setup_ssl` 返回值,失败时终止安装摘要或明确显示失败 |
| P0-03 | `install.sh:587`, `install.sh:589` | `ssl_cert_issue()` 返回值未检查,且用 `acme.sh --list | tail -1` 取最后一张证书推断本次签发结果 | 可能把旧证书/无关证书当成本次成功结果写入面板域名 | 直接在 `ssl_cert_issue()` 内返回明确域名或通过全局/输出参数传递,不要读列表尾行 |
| P0-04 | `install.sh:859`, `install.sh:863`, `install.sh:867`, `install.sh:869` | 提示称“留空或 rd 自动生成”,实际用户名/密码默认是 `admin/admin` | 新装面板默认弱口令,安全风险极高 | 留空时应真正随机生成密码,用户名至少提示确认 |
| P0-05 | `install.sh:890`, `install.sh:914-920`, `install.sh:991` | 新装流程先按默认 SQLite 写面板配置,再切换 `dbType=mariadb`,但没有执行 SQLite→MariaDB 数据迁移 | 最终服务连接 MariaDB 后,用户刚设置的账号/端口等可能不在实际使用库里 | 先确定数据库类型,再初始化与写配置;若从 SQLite 切 MariaDB必须执行显式迁移 |
| P0-06 | `install.sh:157`, `install.sh:1106`, `install.sh:1135-1176` | `install_acme()` 进入家目录后不恢复;`config_after_install()` 里一旦调用它,会污染后续当前工作目录 | 可能找不到解压包中的 service 文件,导致安装行为与预期不一致 | 在函数内保存并恢复目录,或完全使用绝对路径 |
| P0-07 | `x-ui.service.arch:10-11`, `install.sh:11`, `install.sh:1135-1159` | Arch 服务文件硬编码 `/usr/lib/x-ui`,而脚本默认安装到 `/usr/local/x-ui` | Arch 系统默认安装后服务大概率无法启动 | 安装时模板渲染 `ExecStart`/`WorkingDirectory`,不要硬编码 |
| P0-08 | `install.sh:1078-1084` | 依赖环境变量的目录删除缺乏安全防护,直接 `rm -rf ${xui_folder}/` | 若 `XUI_MAIN_FOLDER` 被误设,可能删除错误目录 | 删除前校验路径非空、非根目录、符合预期前缀,并统一加引号 |
## P1 / 高
| ID | 位置 | 问题 | 影响 | 建议 |
|---|---|---|---|---|
| P1-01 | `install.sh:1041` | 用 `${xui_folder%/x-ui}` 推导工作目录,隐式假设安装目录以 `/x-ui` 结尾 | 自定义目录时下载/解压位置可能错误 | 单独定义工作目录,不要从安装目录字符串裁剪推导 |
| P1-02 | `install.sh:1088-1091` | 解压与 `cd x-ui` 不检查失败 | 后续 chmod、复制 service、配置命令可能在错误目录执行 | 每一步关键文件操作都要显式检查退出码 |
| P1-03 | `install.sh:1106` | `config_after_install` 失败后仍继续安装 service 并启动 | 配置失败被掩盖,最终系统状态不可预测 | 将其纳入主流程错误链,失败立即退出 |
| P1-04 | `install.sh:76-104`, `install.sh:1232-1234` | `install_base()` 失败后脚本仍继续执行 | 缺依赖状态下继续安装,错误位置后移且更难排查 | 主流程必须检查基础依赖安装结果 |
| P1-05 | `install.sh:1071`, `install.sh:1176-1188` | 指定版本安装时,管理脚本和 fallback service 仍从 `main` 分支下载 | 脚本/服务文件与二进制版本不匹配,可能出现不兼容 | 统一按所选 tag 下载同版本配套文件 |
| P1-06 | `install.sh:1048`, `install.sh:1065`, `install.sh:1071`, `install.sh:1180-1186` | 多处强制 `curl -4` | IPv6-only 主机无法安装或更新 | 优先正常双栈请求,失败后再回退到 `-4` |
| P1-07 | `install.sh:981-988` | 已有安装路径中,只要 `cert` 字段非空就认定已配置 SSL并打印 HTTPS 地址 | 可能在没有有效证书/私钥时误导用户 | 同时校验 `cert`、`key`、文件存在性和可读性 |
| P1-08 | `install.sh:429-435` | 证书重复判断只比较 `acme.sh --list` 最后一条记录 | 已有当前域名证书时仍可能重复签发 | 遍历列表精确匹配域名,或直接查询目标证书目录 |
| P1-09 | `install.sh:384-386` | IP 证书流程中,`x-ui cert` 失败只告警不失败,仍打印“安装并配置成功” | 证书文件虽存在,但面板未实际启用证书 | 写入路径失败应判定整体失败 |
| P1-10 | `install.sh:611-618`, `install.sh:623-625` | IP 证书模式停止面板后,失败路径没有统一保证服务恢复 | 安装中断后面板可能保持停止状态 | 使用 `trap` 或统一清理/恢复逻辑 |
| P1-11 | `install.sh:1137-1203`, `x-ui.service.debian:10-11`, `x-ui.service.rhel:10-11`, `x-ui.rc:3`, `x-ui.rc:12` | 虽允许 `XUI_MAIN_FOLDER` 覆盖,但 service/init 模板大量硬编码 `/usr/local/x-ui` | 自定义安装目录时服务启动失败 | 安装时根据变量生成 service/init 文件 |
| P1-12 | `install.sh:994-1015` | 用 `tr`/`grep`/`sed` 解析 GitHub Releases JSON逻辑脆弱 | API 结构变化、字段顺序变化、错误响应时容易取错版本 | 使用 `jq`,或服务端返回最小化 API 请求并严格校验 JSON |
## P2 / 中
| ID | 位置 | 问题 | 影响 | 建议 |
|---|---|---|---|---|
| P2-01 | `install.sh:158` | `curl ... | sh` 缺少 `pipefail`,下载失败时可能误报 acme 安装成功 | 安装状态不可信 | 启用 `set -o pipefail`,或先下载再执行 |
| P2-02 | `install.sh:177`, `install.sh:249`, `install.sh:396`, `install.sh:732` | 用 `command -v ~/.acme.sh/acme.sh` 检查路径式命令,可用但不规范 | 兼容性与可读性较差 | 直接使用 `[ -x ~/.acme.sh/acme.sh ]` |
| P2-03 | `install.sh:193`, `install.sh:321`, `install.sh:463`, `install.sh:743` | 设置默认 CA 的命令结果多数未检查 | 前置动作失败时后续错误定位困难 | 对关键前置步骤逐一校验 |
| P2-04 | `install.sh:263-266`, `install.sh:46-50` | IPv4/IPv6 校验过于宽松IPv4 不校验每段范围IPv6 只看是否含 `:` | 非法地址可能进入证书流程 | 使用更严格的地址校验函数 |
| P2-05 | `install.sh:451-455` | 手动 SSL 端口输入为空时不会自然采用默认值,而是走“无效输入”分支 | 交互体验与提示不一致 | 先 `WebPort="${WebPort:-80}"` 再校验 |
| P2-06 | `install.sh:452` | 手动 SSL 端口校验未先判断是否纯数字 | 异常输入由 shell 算术比较隐式处理,不稳健 | 先正则校验再做范围比较 |
| P2-07 | `install.sh:881-888` | 面板端口输入没有有效校验,也不检测占用 | 可能写入非法端口或冲突端口 | 校验数字范围并复用端口占用检测 |
| P2-08 | `install.sh:886` | 随机端口不检查是否已被占用 | 启动服务时可能冲突 | 生成后循环检测空闲端口 |
| P2-09 | `install.sh:890-897` | 只验证端口写入成功,不验证用户名、密码、`webBasePath` 是否确实写入 | 配置失败可能部分隐藏 | 对关键配置项逐项校验 |
| P2-10 | `install.sh:899-920` | MariaDB 连接信息读取与写入缺少完整校验,账号/密码/库名可以为空 | 后续数据库初始化失败 | 按数据库类型做必填校验与连通性预检查 |
| P2-11 | `install.sh:929-945` | `worker` 仅校验 `dbType=mariadb`,不校验 MariaDB 是否可连接 | worker 模式可能被写入无效配置 | 在设置前做配置与连接校验 |
| P2-12 | `install.sh:846-855`, `install.sh:955` | 获取公网 IP 失败后不提示手动输入IP 证书默认路径会直接失败 | 默认交互路径容易走向失败 | IP 获取失败时要求用户手输或默认跳过 IP 证书 |
| P2-13 | `install.sh:707-729` | Cloudflare 域名未用 `is_domain` 校验API Key 非静默输入 | 容易误输,且敏感信息暴露在终端 | 域名校验 + `read -rsp` 输入密钥 |
| P2-14 | `install.sh:751-752` | Cloudflare 凭证 `export` 后未清理 | 后续子进程可见环境变量 | 完成后 `unset CF_Key CF_Email` |
| P2-15 | `install.sh:491` | 允许用户输入任意 `reloadcmd`,续期时将以高权限执行 | 误操作或恶意输入可能造成风险 | 至少明确安全提示,并对常见场景提供模板而非完全任意命令 |
| P2-16 | `install.sh:91-92` | Arch 依赖安装执行两次 `pacman -Syu`,且第一次无 `--noconfirm` | 交互阻塞、重复更新、耗时增加 | 合并为一次清晰的安装命令 |
| P2-17 | `install.sh:1088-1100` | ARM 分支先重命名 `bin/xray-linux-$(arch)`,后面又对原文件名 `chmod` | 可能输出错误信息并污染日志 | 重命名后按最终文件名处理权限 |
| P2-18 | `install.sh:1048`, `install.sh:1065`, `install.sh:1071` 等多处 | 路径、URL、命令参数大量未加引号 | 自定义目录、异常字符或空格路径下行为不稳定 | 统一为变量引用加引号 |
## P3 / 低
| ID | 位置 | 问题 | 影响 | 建议 |
|---|---|---|---|---|
| P3-01 | `install.sh:9` | `cur_dir` 未使用 | 增加噪音 | 删除死代码 |
| P3-02 | `install.sh:168-236` | `setup_ssl_certificate()` 未被调用,且参数 `server_ip`、`existing_port`、`existing_webBasePath` 未使用 | 阅读成本高,容易误导维护者 | 删除死函数或接入统一 SSL 流程 |
| P3-03 | `install.sh:52-54` | `is_ip()` 未被调用 | 维护噪音 | 删除或实际复用 |
| P3-04 | `install.sh:39` | 不支持架构时删除当前 `install.sh` | 行为危险且无必要 | 直接报错退出,不做文件删除 |
| P3-05 | `install.sh:578` | 注释“非 1、3、4 默认为 2”表述不完整 | 容易造成维护误解 | 注释改为“除 1/3/4 外均视为 2” |
| P3-06 | `install.sh:827-832` | “全新安装”的判定只看两个文件是否存在,注释与语义不够精确 | 可读性一般 | 把注释改成“基于配置/数据库文件存在性判断” |
| P3-07 | `install.sh:1044` | `[ $# == 0 ]` 属于 bash 风格写法,可运行但不如 `-eq` 清晰 | 风格一致性较差 | 改用 `[ $# -eq 0 ]` |
| P3-08 | `install.sh:1234` | `install_x-ui $1` 未加引号 | 边界输入下会参数拆分 | 改为 `install_x-ui "$1"` |
## 优先修复建议
建议按以下顺序修复:
1. 先修 `P0-01`、`P0-02`、`P0-05`、`P0-06`、`P0-07`
2. 再修 `P1-01`、`P1-03`、`P1-05`、`P1-11`
3. 然后补齐输入校验、错误处理和引号问题
4. 最后清理死代码、注释和交互文案
## 建议的验收点
- 端口占用检测在 `ss`、`netstat`、`lsof` 三种环境下都能正确判定
- SSL 任一路径失败时,最终摘要不再显示成功状态
- SQLite / MariaDB 两种新装流程都能得到一致且可登录的实际配置
- 自定义 `XUI_MAIN_FOLDER` 后,服务文件仍能正确启动
- Arch、Debian/RHEL、Alpine 三类服务安装路径与执行路径一致
- 在公网 IP 获取失败、80 端口不可用、证书签发失败等场景下,脚本能给出准确结果而非伪成功

View file

@ -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
├─ 选项 1systemctl reload nginx ; systemctl restart x-ui
├─ 选项 2自定义命令
└─ 选项 0保持默认
安装证书并启用自动续期
启动面板
询问是否将证书应用到面板:
└─ 是 → x-ui cert -webCert ... -webCertKey ...
└─ 否 → 跳过
```
**特点**
- 签发前会停止面板以释放端口
- 支持自定义 reloadcmd例如先 reload nginx 再重启 x-ui
- 签发失败会自动重新启动面板
### 12. `prompt_and_setup_ssl(panel_port, web_base_path, server_ip)` — SSL 选择菜单(第 513-638 行)
**用途**:安装时的统一 SSL 配置入口,提供三种选择。
**菜单**
```
1. Let's Encrypt 域名证书90 天有效期,自动续期)
└─ 调用 ssl_cert_issue()
└─ 从 acme.sh 列表提取域名作为 SSL_HOST
2. Let's Encrypt IP 证书6 天有效期,自动续期) ← 默认选项
└─ 可选输入 IPv6 地址
└─ 停止面板释放 80 端口
└─ 调用 setup_ip_certificate(server_ip, ipv6)
└─ SSL_HOST = server_ip
3. 自定义 SSL 证书(指定已有文件路径)
└─ 输入域名
└─ 循环验证证书文件(存在、可读、非空)
└─ 循环验证私钥文件(存在、可读、非空)
└─ x-ui cert -webCert ... -webCertKey ...
└─ 提示用户自行管理续期
```
**全局变量**:设置 `SSL_HOST` 供后续显示访问地址使用。
---
## 安装后配置
### 13. `config_after_install()` — 安装后配置(第 640-760 行)
**用途**首次安装后的凭据生成、端口设置、Web 路径生成、SSL 配置。
**流程图**
```
读取当前面板设置:
- hasDefaultCredential是否为默认凭据
- webBasePath
- port
- cert证书路径
获取服务器公网 IP
└─ 依次尝试 6 个 API
1. api4.ipify.org
2. ipv4.icanhazip.com
3. v4.api.ipinfo.io/ip
4. ipv4.myexternalip.com/raw
5. 4.ident.me
6. check-host.net/ip
判断 webBasePath 是否足够长≥4 字符):
┌─ webBasePath 过短
│ ├─ hasDefaultCredential == true首次安装
│ │ ├─ 生成随机 webBasePath18 位)
│ │ ├─ 生成随机用户名10 位)
│ │ ├─ 生成随机密码10 位)
│ │ ├─ 询问是否自定义端口
│ │ │ ├─ 是 → 用户输入端口
│ │ │ └─ 否 → 随机生成 1024-62000 范围端口
│ │ ├─ 应用设置x-ui setting -username ... -password ... -port ... -webBasePath ...
│ │ ├─ prompt_and_setup_ssl() ← 必需
│ │ └─ 显示完整凭据和访问地址
│ │
│ └─ hasDefaultCredential != true非首次安装
│ ├─ 生成新 webBasePath
│ ├─ 检查是否有证书:
│ │ ├─ 无 → prompt_and_setup_ssl()(推荐)
│ │ └─ 有 → 显示 HTTP 访问地址
│ └─ 结束
└─ webBasePath 正常≥4 字符)
├─ hasDefaultCredential == true
│ ├─ 生成随机用户名和密码
│ ├─ 应用新凭据
│ └─ 显示凭据
└─ hasDefaultCredential != true
└─ 提示凭据已正确设置
再次检查证书:
├─ 无证书 → prompt_and_setup_ssl()(推荐)
└─ 有证书 → 跳过
最后执行x-ui migrate数据库迁移
```
---
## 主安装逻辑
### 14. `install_x-ui(version)` — 主安装函数(第 762-958 行)
**参数**`$1` 可选,指定安装版本号(如 `v2.3.5`)。
**流程**
```
cd /usr/local/
┌─ 无版本参数(安装最新版)
│ ├─ 从 GitHub API 获取最新版本号
│ │ └─ IPv4 失败时重试 curl -4
│ └─ 下载x-ui-linux-${arch}.tar.gz
└─ 有版本参数
├─ 验证版本号 ≥ v2.3.5
└─ 下载指定版本
同时下载 x-ui.sh 到 /usr/bin/x-ui-temp
停止已有 x-ui 服务并删除旧安装目录
解压 tar.gz设置执行权限
ARM 架构特殊处理:
armv5/armv6/armv7 → 重命名为 xray-linux-arm
安装 x-ui.sh 到 /usr/bin/x-ui
创建日志目录 /var/log/x-ui/
调用 config_after_install() ← 生成凭据 + SSL
etckeeper 兼容:
└─ 如果 /etc/.git 存在,将 x-ui.db 加入 .gitignore
┌─ Alpine Linux
│ ├─ 下载 OpenRC 脚本 x-ui.rc → /etc/init.d/x-ui
│ ├─ rc-update add x-ui启用开机自启
│ └─ rc-service x-ui start
└─ 其他系统systemd
├─ 优先使用 tar.gz 中的服务文件
│ ├─ x-ui.service ← 通用
│ ├─ x-ui.service.debian ← Ubuntu/Debian
│ ├─ x-ui.service.arch ← Arch/Manjaro
│ └─ x-ui.service.rhel ← 其他CentOS/Fedora 等)
├─ 如果 tar.gz 中没有,从 GitHub 下载对应文件
└─ 配置服务:
chown root:root x-ui.service
chmod 644 x-ui.service
systemctl daemon-reload
systemctl enable x-ui
systemctl start x-ui
显示安装完成信息和子命令用法
```
**子命令列表**(安装完成后显示):
| 命令 | 功能 |
|-------------------|--------------------|
| `x-ui` | 打开管理菜单 |
| `x-ui start` | 启动面板 |
| `x-ui stop` | 停止面板 |
| `x-ui restart` | 重启面板 |
| `x-ui status` | 查看状态 |
| `x-ui settings` | 查看当前设置 |
| `x-ui enable` | 设置开机自启 |
| `x-ui disable` | 取消开机自启 |
| `x-ui log` | 查看日志 |
| `x-ui banlog` | 查看 Fail2ban 日志 |
| `x-ui update` | 更新 |
| `x-ui legacy` | 安装旧版本 |
| `x-ui install` | 安装 |
| `x-ui uninstall` | 卸载 |
---
## 调用关系总结
```
install.sh
├─ install_base()
│ └─ 根据发行版安装 curl, tar, tzdata, socat, ca-certificates, openssl
└─ install_x-ui($1)
├─ 下载 x-ui 发行版和 x-ui.sh
├─ 解压、设置权限
├─ config_after_install()
│ ├─ gen_random_string() × 3用户名/密码/Web路径
│ ├─ 获取公网 IP
│ ├─ prompt_and_setup_ssl()
│ │ ├─ [选项1] ssl_cert_issue()
│ │ │ ├─ install_acme()
│ │ │ └─ acme.sh 签发/安装/续期域名证书
│ │ ├─ [选项2] setup_ip_certificate()
│ │ │ ├─ install_acme()
│ │ │ └─ acme.sh 签发/安装/续期 IP 短期证书
│ │ └─ [选项3] 用户提供自定义证书路径
│ └─ x-ui migrate
└─ 配置系统服务systemd 或 OpenRC
```
---
## 关键设计决策
1. **强制 SSL**:首次安装时必须配置 SSL 证书(三种方式选一),确保面板通过 HTTPS 访问。
2. **随机化安全**用户名、密码、端口、Web 路径全部随机生成,避免使用默认凭据。
3. **多 OS 兼容**:通过 `case` 语句适配 7 大包管理器体系Alpine 使用 OpenRC其余使用 systemd。
4. **IP 证书支持**:利用 Let's Encrypt 的 shortlived profile为无域名场景提供 SSL 支持6 天有效期,自动续期)。
5. **优雅降级**
- GitHub API 失败时用 `curl -4` 重试
- `ss` 不可用时回退到 `netstat`,再回退到 `lsof`
- tar.gz 中无服务文件时从 GitHub 下载
- acme.sh reloadcmd 失败不阻止证书安装
6. **etckeeper 兼容**:自动将数据库文件加入 `/etc/.gitignore`,避免 etckeeper 追踪频繁变化的数据库。

View file

@ -1,29 +0,0 @@
# Multi-Node Shared Control
## Roles
- `master`: the only node allowed to change shared account definitions
- `worker`: rebuilds local Xray config from shared snapshots and flushes traffic deltas
## Requirements
- shared mode requires MariaDB
- each worker needs a unique `nodeId`
- workers keep `/etc/x-ui/shared-cache.json` for outage survival
## Runtime Loops
- workers poll `shared_accounts_version` every `syncInterval`
- all nodes flush `/etc/x-ui/traffic-pending.json` every `trafficFlushInterval`
- only `master` runs shared traffic reconciliation that can disable or renew clients
## Manual Verification
1. Start a `master` node on MariaDB.
2. Start a `worker` node on the same MariaDB with a unique `nodeId`.
3. Change an inbound or client on `master`.
4. Confirm the worker sees a newer `shared_accounts_version` and rebuilds local Xray.
5. Generate traffic on both nodes.
6. Confirm aggregated MariaDB counters increase without overwriting each other.
7. Stop MariaDB briefly and confirm the worker continues using `shared-cache.json`.
8. Restore MariaDB and confirm pending traffic deltas flush successfully.

View file

@ -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.

View file

@ -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"
```

View file

@ -1,819 +0,0 @@
# Cloudflare CDN Assets 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:** Add build-time fingerprinted frontend assets, manifest-based template asset resolution, and Cloudflare-friendly cache headers without changing panel behavior.
**Architecture:** Introduce a small Go asset-generation package plus a CLI that transforms `web/assets` into fingerprinted files under `web/public/assets` and emits `web/public/assets-manifest.json`. Update the web server to load the manifest in production, expose an `asset` template helper, serve the generated embedded files, and separate HTML caching from immutable asset caching.
**Tech Stack:** Go 1.26, `go:embed`, Gin, standard library `crypto/sha256`, `encoding/json`, `html/template`, Go tests
---
## File Structure
- Create: `web/assetsgen/generator.go`
- Create: `web/assetsgen/generator_test.go`
- Create: `cmd/genassets/main.go`
- Create: `web/public/.gitkeep`
- Create: `web/public/README.md`
- Create: `web/asset_manifest.go`
- Create: `web/asset_manifest_test.go`
- Modify: `web/web.go`
- Modify: `web/html/common/page.html`
- Modify: `web/html/component/aPersianDatepicker.html`
- Modify: `web/html/inbounds.html`
- Modify: `web/html/settings/panel/subscription/subpage.html`
- Modify: `web/html/settings.html`
- Modify: `web/html/xray.html`
- Modify: `Dockerfile`
- Modify: `README.md`
### Task 1: Build Fingerprinted Asset Generator
**Files:**
- Create: `web/assetsgen/generator.go`
- Test: `web/assetsgen/generator_test.go`
- [ ] **Step 1: Write the failing generator tests**
```go
package assetsgen
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestGenerateProducesFingerprintManifestAndFiles(t *testing.T) {
src := t.TempDir()
dst := t.TempDir()
if err := os.MkdirAll(filepath.Join(src, "js"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "js", "app.js"), []byte("console.log('v1')\n"), 0o644); err != nil {
t.Fatal(err)
}
manifest, err := Generate(Options{
SourceDir: src,
OutputDir: filepath.Join(dst, "assets"),
HashLen: 8,
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
got, ok := manifest["js/app.js"]
if !ok {
t.Fatalf("manifest missing logical path: %#v", manifest)
}
if got == "js/app.js" {
t.Fatalf("expected hashed filename, got %q", got)
}
if _, err := os.Stat(filepath.Join(dst, "assets", got)); err != nil {
t.Fatalf("hashed output missing: %v", err)
}
}
func TestWriteManifestSerializesStableJson(t *testing.T) {
dst := t.TempDir()
path := filepath.Join(dst, "assets-manifest.json")
manifest := Manifest{
"css/a.css": "css/a.11111111.css",
"js/b.js": "js/b.22222222.js",
}
if err := WriteManifest(path, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var decoded map[string]string
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("manifest json invalid: %v", err)
}
if decoded["js/b.js"] != "js/b.22222222.js" {
t.Fatalf("unexpected manifest entry: %#v", decoded)
}
}
```
- [ ] **Step 2: Run the generator tests to verify they fail**
Run: `go test ./web/assetsgen -run 'TestGenerate|TestWriteManifest' -count=1`
Expected: FAIL with undefined `Generate`, `Options`, `Manifest`, or `WriteManifest`.
- [ ] **Step 3: Write the minimal generator implementation**
```go
package assetsgen
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
type Manifest map[string]string
type Options struct {
SourceDir string
OutputDir string
HashLen int
}
func Generate(opts Options) (Manifest, error) {
if opts.HashLen <= 0 {
opts.HashLen = 8
}
manifest := make(Manifest)
if err := os.RemoveAll(opts.OutputDir); err != nil {
return nil, err
}
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
return nil, err
}
err := filepath.WalkDir(opts.SourceDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(opts.SourceDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
raw, err := os.ReadFile(path)
if err != nil {
return err
}
sum := sha256.Sum256(raw)
hash := hex.EncodeToString(sum[:])[:opts.HashLen]
target := fingerprint(rel, hash)
targetPath := filepath.Join(opts.OutputDir, filepath.FromSlash(target))
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
return err
}
manifest[rel] = target
return nil
})
if err != nil {
return nil, err
}
return manifest, nil
}
func WriteManifest(path string, manifest Manifest) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
keys := make([]string, 0, len(manifest))
for key := range manifest {
keys = append(keys, key)
}
sort.Strings(keys)
ordered := make(map[string]string, len(keys))
for _, key := range keys {
ordered[key] = manifest[key]
}
raw, err := json.MarshalIndent(ordered, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
return os.WriteFile(path, raw, 0o644)
}
func fingerprint(rel, hash string) string {
ext := filepath.Ext(rel)
base := strings.TrimSuffix(rel, ext)
if ext == "" {
return rel + "." + hash
}
return base + "." + hash + ext
}
func CopyFile(dst string, src io.Reader) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, src)
return err
}
```
- [ ] **Step 4: Run the generator tests to verify they pass**
Run: `go test ./web/assetsgen -run 'TestGenerate|TestWriteManifest' -count=1`
Expected: PASS
- [ ] **Step 5: Commit the generator package**
```bash
git add web/assetsgen/generator.go web/assetsgen/generator_test.go
git commit -m "feat: add fingerprinted asset generator"
```
### Task 2: Add Generator CLI and Generated Output Conventions
**Files:**
- Create: `cmd/genassets/main.go`
- Create: `web/public/.gitkeep`
- Create: `web/public/README.md`
- Test: `web/assetsgen/generator_test.go`
- [ ] **Step 1: Extend tests for nested paths and hash placement**
```go
func TestGeneratePreservesNestedDirectories(t *testing.T) {
src := t.TempDir()
dst := t.TempDir()
if err := os.MkdirAll(filepath.Join(src, "css"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "css", "custom.min.css"), []byte("body{}\n"), 0o644); err != nil {
t.Fatal(err)
}
manifest, err := Generate(Options{
SourceDir: src,
OutputDir: filepath.Join(dst, "assets"),
HashLen: 8,
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
got := manifest["css/custom.min.css"]
if got == "" {
t.Fatalf("missing css/custom.min.css entry: %#v", manifest)
}
if filepath.Dir(got) != "css" {
t.Fatalf("expected nested output directory, got %q", got)
}
if filepath.Ext(got) != ".css" {
t.Fatalf("expected css extension, got %q", got)
}
}
```
- [ ] **Step 2: Run the test to verify the generator still drives the behavior**
Run: `go test ./web/assetsgen -run TestGeneratePreservesNestedDirectories -count=1`
Expected: PASS after Task 1, confirming the generator already satisfies the nested-path rule.
- [ ] **Step 3: Add the CLI entrypoint**
```go
package main
import (
"log"
"path/filepath"
"github.com/mhsanaei/3x-ui/v2/web/assetsgen"
)
func main() {
const (
sourceDir = "web/assets"
outputDir = "web/public/assets"
manifestPath = "web/public/assets-manifest.json"
)
manifest, err := assetsgen.Generate(assetsgen.Options{
SourceDir: sourceDir,
OutputDir: outputDir,
HashLen: 8,
})
if err != nil {
log.Fatalf("generate fingerprinted assets: %v", err)
}
if err := assetsgen.WriteManifest(filepath.Clean(manifestPath), manifest); err != nil {
log.Fatalf("write asset manifest: %v", err)
}
}
```
- [ ] **Step 4: Add generated-output conventions**
`web/public/README.md`
```md
# Generated frontend assets
This directory is generated from `web/assets` by:
- `go run ./cmd/genassets`
Contents:
- `assets/`: fingerprinted files for production embedding
- `assets-manifest.json`: logical-to-fingerprinted path mapping
Do not edit generated files by hand.
```
`web/public/.gitkeep`
```text
```
- [ ] **Step 5: Verify the CLI generates output**
Run: `go run ./cmd/genassets`
Expected: `web/public/assets-manifest.json` exists and `web/public/assets/` contains hashed files.
- [ ] **Step 6: Commit the CLI and generated-output convention**
```bash
git add cmd/genassets/main.go web/public/.gitkeep web/public/README.md
git commit -m "feat: add asset generation command"
```
### Task 3: Load Asset Manifest and Serve Fingerprinted Assets
**Files:**
- Create: `web/asset_manifest.go`
- Test: `web/asset_manifest_test.go`
- Modify: `web/web.go`
- [ ] **Step 1: Write the failing manifest and helper tests**
```go
package web
import (
"strings"
"testing"
)
func TestAssetResolverReturnsFingerprintedPathInProduction(t *testing.T) {
resolver := newAssetResolver("/panel/", false, assetManifest{
"js/websocket.js": "js/websocket.12345678.js",
})
got := resolver.URL("js/websocket.js")
want := "/panel/assets/js/websocket.12345678.js"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestAssetResolverReturnsLogicalPathInDebug(t *testing.T) {
resolver := newAssetResolver("/panel/", true, nil)
got := resolver.URL("js/websocket.js")
want := "/panel/assets/js/websocket.js"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) {
resolver := newAssetResolver("/", false, assetManifest{})
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for missing manifest key")
}
}()
resolver.URL("missing.js")
}
func TestFingerprintCacheHeaderIncludesImmutable(t *testing.T) {
got := assetCacheControl("js/websocket.12345678.js")
if !strings.Contains(got, "immutable") {
t.Fatalf("expected immutable cache-control, got %q", got)
}
}
```
- [ ] **Step 2: Run the web tests to verify they fail**
Run: `go test ./web -run 'TestAssetResolver|TestFingerprintCacheHeader' -count=1`
Expected: FAIL with undefined `newAssetResolver`, `assetManifest`, or `assetCacheControl`.
- [ ] **Step 3: Add the manifest loader and resolver**
`web/asset_manifest.go`
```go
package web
import (
"encoding/json"
"fmt"
"path"
"strings"
)
type assetManifest map[string]string
type assetResolver struct {
basePath string
debug bool
manifest assetManifest
}
func newAssetResolver(basePath string, debug bool, manifest assetManifest) assetResolver {
return assetResolver{
basePath: basePath,
debug: debug,
manifest: manifest,
}
}
func (r assetResolver) URL(logical string) string {
target := logical
if !r.debug {
hashed, ok := r.manifest[logical]
if !ok {
panic(fmt.Sprintf("missing asset manifest entry for %q", logical))
}
target = hashed
}
return path.Join(r.basePath, "assets", target)
}
func loadAssetManifest(raw []byte) (assetManifest, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("asset manifest is empty")
}
var manifest assetManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, err
}
if len(manifest) == 0 {
return nil, fmt.Errorf("asset manifest has no entries")
}
return manifest, nil
}
func assetCacheControl(requestPath string) string {
if hasFingerprint(requestPath) {
return "public, max-age=31536000, immutable"
}
return "public, max-age=300"
}
func hasFingerprint(requestPath string) bool {
base := path.Base(requestPath)
parts := strings.Split(base, ".")
if len(parts) < 3 {
return false
}
hash := parts[len(parts)-2]
if len(hash) != 8 {
return false
}
for _, ch := range hash {
if !strings.ContainsRune("0123456789abcdef", ch) {
return false
}
}
return true
}
```
- [ ] **Step 4: Wire manifest loading and static serving into `web/web.go`**
Add or update the embedded assets section and router setup with code shaped like this:
```go
//go:embed public/assets
var publicAssetsFS embed.FS
//go:embed public/assets-manifest.json
var assetsManifestRaw []byte
var productionAssetManifest assetManifest
func init() {
if config.IsDebug() {
return
}
manifest, err := loadAssetManifest(assetsManifestRaw)
if err != nil {
panic(err)
}
productionAssetManifest = manifest
}
```
In `initRouter`, register the helper and cache policy:
```go
assetResolver := newAssetResolver(basePath, config.IsDebug(), productionAssetManifest)
funcMap := template.FuncMap{
"i18n": i18nWebFunc,
"asset": assetResolver.URL,
}
engine.Use(func(c *gin.Context) {
uri := c.Request.URL.Path
if strings.HasPrefix(uri, assetsBasePath) {
c.Header("Cache-Control", assetCacheControl(uri))
return
}
if c.Request.Method == http.MethodGet {
c.Header("Cache-Control", "no-cache, must-revalidate")
}
})
```
And switch the production static filesystem to:
```go
engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: publicAssetsFS}))
```
- [ ] **Step 5: Run the web tests to verify they pass**
Run: `go test ./web -run 'TestAssetResolver|TestFingerprintCacheHeader' -count=1`
Expected: PASS
- [ ] **Step 6: Commit the server-side asset resolution changes**
```bash
git add web/asset_manifest.go web/asset_manifest_test.go web/web.go
git commit -m "feat: load fingerprinted asset manifest"
```
### Task 4: Update Templates to Use Manifest-Based Asset URLs
**Files:**
- Modify: `web/html/common/page.html`
- Modify: `web/html/component/aPersianDatepicker.html`
- Modify: `web/html/inbounds.html`
- Modify: `web/html/settings/panel/subscription/subpage.html`
- Modify: `web/html/settings.html`
- Modify: `web/html/xray.html`
- Test: `web/asset_manifest_test.go`
- [ ] **Step 1: Add a template-focused regression test**
Append this test to `web/asset_manifest_test.go`:
```go
func TestAssetResolverPreservesBasePathWithoutDoubleSlash(t *testing.T) {
resolver := newAssetResolver("/xui/", false, assetManifest{
"css/custom.min.css": "css/custom.min.11111111.css",
})
got := resolver.URL("css/custom.min.css")
want := "/xui/assets/css/custom.min.11111111.css"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
```
- [ ] **Step 2: Run the focused test before template edits**
Run: `go test ./web -run TestAssetResolverPreservesBasePathWithoutDoubleSlash -count=1`
Expected: PASS after Task 3, confirming the helper output is safe to roll through templates.
- [ ] **Step 3: Replace direct asset URLs in shared page template**
Update `web/html/common/page.html` references like this:
```gotemplate
<link rel="stylesheet" href="{{ asset "ant-design-vue/antd.min.css" }}">
<link rel="stylesheet" href="{{ asset "css/custom.min.css" }}">
src: url('{{ asset "Vazirmatn-UI-NL-Regular.woff2" }}') format('woff2');
<script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ asset "axios/axios.min.js" }}"></script>
<script src="{{ asset "qs/qs.min.js" }}"></script>
<script src="{{ asset "js/axios-init.js" }}"></script>
<script src="{{ asset "js/util/index.js" }}"></script>
<script src="{{ asset "js/websocket.js" }}"></script>
```
- [ ] **Step 4: Replace page-specific asset URLs**
Apply the same conversion in these files:
`web/html/component/aPersianDatepicker.html`
```gotemplate
<link rel="stylesheet" href="{{ asset "persian-datepicker/persian-datepicker.min.css" }}" />
<script src="{{ asset "moment/moment-jalali.min.js" }}"></script>
<script src="{{ asset "persian-datepicker/persian-datepicker.min.js" }}"></script>
```
`web/html/inbounds.html`
```gotemplate
<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "uri/URI.min.js" }}"></script>
<script src="{{ asset "js/model/reality_targets.js" }}"></script>
<script src="{{ asset "js/model/inbound.js" }}"></script>
<script src="{{ asset "js/model/dbinbound.js" }}"></script>
```
`web/html/settings/panel/subscription/subpage.html`
```gotemplate
<script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ asset "moment/moment-jalali.min.js" }}"></script>
<script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ asset "js/util/index.js" }}"></script>
<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "js/subscription.js" }}"></script>
```
`web/html/settings.html`
```gotemplate
<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "otpauth/otpauth.umd.min.js" }}"></script>
<script src="{{ asset "js/model/setting.js" }}"></script>
```
`web/html/xray.html`
```gotemplate
<link rel="stylesheet" href="{{ asset "codemirror/codemirror.min.css" }}">
<link rel="stylesheet" href="{{ asset "codemirror/fold/foldgutter.css" }}">
<link rel="stylesheet" href="{{ asset "codemirror/xq.min.css" }}">
<link rel="stylesheet" href="{{ asset "codemirror/lint/lint.css" }}">
<script src="{{ asset "js/model/outbound.js" }}"></script>
<script src="{{ asset "codemirror/codemirror.min.js" }}"></script>
<script src="{{ asset "codemirror/javascript.js" }}"></script>
<script src="{{ asset "codemirror/jshint.js" }}"></script>
<script src="{{ asset "codemirror/jsonlint.js" }}"></script>
<script src="{{ asset "codemirror/lint/lint.js" }}"></script>
<script src="{{ asset "codemirror/lint/javascript-lint.js" }}"></script>
<script src="{{ asset "codemirror/hint/javascript-hint.js" }}"></script>
<script src="{{ asset "codemirror/fold/foldcode.js" }}"></script>
<script src="{{ asset "codemirror/fold/foldgutter.js" }}"></script>
<script src="{{ asset "codemirror/fold/brace-fold.js" }}"></script>
```
- [ ] **Step 5: Verify all fingerprintable asset references use the helper**
Run: `rg -n '{{ \\.base_path }}assets|\\?{{ \\.cur_ver }}' web/html`
Expected: no remaining static asset references; route strings like `{{ .base_path }}panel/` may remain.
- [ ] **Step 6: Commit the template updates**
```bash
git add web/html/common/page.html web/html/component/aPersianDatepicker.html web/html/inbounds.html web/html/settings/panel/subscription/subpage.html web/html/settings.html web/html/xray.html web/asset_manifest_test.go
git commit -m "refactor: resolve template assets through manifest helper"
```
### Task 5: Integrate Build Workflow and Verify End-to-End Behavior
**Files:**
- Modify: `Dockerfile`
- Modify: `README.md`
- Test: `web/web.go`
- [ ] **Step 1: Add a build-step verification test for cache policy**
Add this test to `web/asset_manifest_test.go`:
```go
func TestAssetCacheControlForLogicalPathIsShortLived(t *testing.T) {
got := assetCacheControl("js/websocket.js")
want := "public, max-age=300"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
```
- [ ] **Step 2: Run the cache-policy tests**
Run: `go test ./web -run 'TestFingerprintCacheHeader|TestAssetCacheControlForLogicalPathIsShortLived' -count=1`
Expected: PASS
- [ ] **Step 3: Update the builder image to generate assets before `go build`**
In `Dockerfile`, insert the generator run before the binary build:
```dockerfile
COPY . .
ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
RUN go run ./cmd/genassets
RUN go build -ldflags "-w -s" -o build/x-ui main.go
```
- [ ] **Step 4: Document the direct-build workflow**
Add a section to `README.md` like this:
```md
## Building from source
Generate fingerprinted frontend assets before compiling:
- `go run ./cmd/genassets`
- `go build -ldflags "-w -s" -o build/x-ui main.go`
Production builds embed files from `web/public/assets` and `web/public/assets-manifest.json`.
```
- [ ] **Step 5: Run end-to-end verification**
Run:
```bash
go run ./cmd/genassets
go test ./web/assetsgen -count=1
go test ./web -run 'TestAssetResolver|TestFingerprintCacheHeader|TestAssetCacheControlForLogicalPathIsShortLived' -count=1
go build ./...
```
Expected:
- generator command succeeds
- asset generation tests pass
- web asset helper tests pass
- repository builds successfully
- [ ] **Step 6: Commit the build integration**
```bash
git add Dockerfile README.md web/asset_manifest_test.go
git commit -m "build: generate fingerprinted assets before compile"
```
## Self-Review
### Spec Coverage
- Build-time generator and manifest: covered by Tasks 1 and 2.
- Production embed of generated assets: covered by Task 3.
- Manifest-based template helper: covered by Tasks 3 and 4.
- Immutable asset caching and HTML revalidation: covered by Tasks 3 and 5.
- Build and release documentation updates: covered by Task 5.
No spec gaps remain.
### Placeholder Scan
- No `TODO`, `TBD`, or deferred implementation markers remain.
- Each code-changing step includes concrete code or exact replacement snippets.
- Each verification step includes an explicit command and expected result.
### Type Consistency
- `assetManifest`, `assetResolver`, `loadAssetManifest`, and `assetCacheControl` are introduced in Task 3 and used consistently later.
- Generator APIs use `assetsgen.Options`, `assetsgen.Generate`, and `assetsgen.WriteManifest` consistently across Tasks 1, 2, and 5.

View file

@ -1,218 +0,0 @@
# Install SSL Domain and Port Fix 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:** Fix the install flow so a user-chosen port is persisted, Cloudflare SSL writes certificate paths into panel settings, and the final access URL uses the configured domain for domain certificates while only falling back to IP for IP certificates.
**Architecture:** Keep the fix narrow and local to the install and settings paths. The shell installer should collect the domain once, persist it through the existing `x-ui setting` command, and decide the final URL host from the SSL mode instead of from a generic fallback. The Go settings layer should expose the domain field in the command-line settings updater so the installer can write the configured hostname into the same JSON-backed settings file as the port and certificate paths.
**Tech Stack:** Bash installer script, Go CLI entrypoint, JSON-backed settings service, Go unit tests.
---
### Task 1: Persist panel domain and verify SSL paths in the Go CLI
**Files:**
- Modify: `main.go`
- Modify: `web/service/setting.go`
- Modify: `web/service/setting_test.go`
- [ ] **Step 1: Write the failing test**
```go
func TestUpdateAllSettingCanPersistWebDomain(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
if err := svc.setString("webDomain", "panel.example.com"); err != nil {
t.Fatalf("setString webDomain error: %v", err)
}
got, err := svc.GetWebDomain()
if err != nil {
t.Fatalf("GetWebDomain error: %v", err)
}
if got != "panel.example.com" {
t.Fatalf("expected webDomain to be persisted, got %q", got)
}
}
```
- [ ] **Step 2: Run the test to verify it fails or is missing the CLI path**
Run: `go test ./web/service -run TestUpdateAllSettingCanPersistWebDomain -v`
Expected: pass only after the CLI writes `webDomain`; before the change, there is no installer path using it.
- [ ] **Step 3: Write minimal implementation**
```go
// in main.go setting command handling
var webDomain string
settingCmd.StringVar(&webDomain, "webDomain", "", "Set panel domain")
// in updateSetting
if webDomain != "" {
if err := settingService.SetWebDomain(webDomain); err != nil {
fmt.Println("Failed to set web domain:", err)
} else {
fmt.Printf("Web domain set successfully: %v\n", webDomain)
}
}
```
```go
// in web/service/setting.go
func (s *SettingService) GetWebDomain() (string, error) {
return s.getString("webDomain")
}
func (s *SettingService) SetWebDomain(domain string) error {
return s.setString("webDomain", domain)
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `go test ./web/service -run TestUpdateAllSettingCanPersistWebDomain -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add main.go web/service/setting.go web/service/setting_test.go
git commit -m "fix: persist panel domain in settings"
```
### Task 2: Make install.sh write the configured domain and certificate paths, then derive the final URL host correctly
**Files:**
- Modify: `install.sh`
- [ ] **Step 1: Write the failing test**
```bash
#!/bin/bash
# Add a shell-level regression check by running the installer in a controlled
# environment and confirming it emits the configured domain in the final URL
# when Cloudflare SSL is selected, and the configured port in the saved settings.
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `bash install.sh` in a sandboxed test environment with mocked inputs for:
`port`, `webBasePath`, `SSL choice = Cloudflare`, and `cf_domain`.
Expected: before the fix, the final URL can still print an IP host or omit the domain from settings.
- [ ] **Step 3: Write minimal implementation**
```bash
# After collecting `cf_domain`, persist it alongside port and path.
${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" -webDomain "${cf_domain}"
# After writing cert paths, confirm the settings file actually contains them.
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1
current_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
current_key=$(${xui_folder}/x-ui setting -getCert true | grep 'key:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
if [[ "$current_cert" != "$webCertFile" || "$current_key" != "$webKeyFile" ]]; then
echo -e "${red}证书路径写入失败,已终止。${plain}"
return 1
fi
# Track the host used for final output separately from the current server IP.
case "$ssl_choice" in
1|4)
SSL_HOST="${domain_or_cf_domain}"
;;
2)
SSL_HOST="${server_ip}"
;;
*)
SSL_HOST="${server_ip}"
;;
esac
```
```bash
# For Cloudflare and domain SSL modes, print the domain-based access URL.
echo -e "${green}访问地址: https://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `bash install.sh` with mocked answers for the four SSL branches.
Expected: Cloudflare and domain SSL branches print the entered domain; IP SSL prints the IP.
- [ ] **Step 5: Commit**
```bash
git add install.sh
git commit -m "fix: use configured domain and verify ssl settings in installer"
```
### Task 3: Add regression coverage for settings round-trip and URL selection behavior
**Files:**
- Modify: `web/service/setting_test.go`
- Modify: `web/html/settings.html` only if the post-restart URL selection needs a matching frontend update
- [ ] **Step 1: Write the failing test**
```go
func TestSettingServiceStoresWebDomainAlongsidePortAndCert(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
if err := svc.SetPort(8443); err != nil {
t.Fatalf("SetPort error: %v", err)
}
if err := svc.SetWebDomain("panel.example.com"); err != nil {
t.Fatalf("SetWebDomain error: %v", err)
}
if err := svc.SetCertFile("/root/cert/panel.example.com/fullchain.pem"); err != nil {
t.Fatalf("SetCertFile error: %v", err)
}
if err := svc.SetKeyFile("/root/cert/panel.example.com/privkey.pem"); err != nil {
t.Fatalf("SetKeyFile error: %v", err)
}
allSetting, err := svc.GetAllSetting()
if err != nil {
t.Fatalf("GetAllSetting error: %v", err)
}
if allSetting.WebPort != 8443 || allSetting.WebDomain != "panel.example.com" {
t.Fatalf("unexpected stored values: %+v", allSetting)
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./web/service -run TestSettingServiceStoresWebDomainAlongsidePortAndCert -v`
Expected: fails until `SetWebDomain` exists and the settings round-trip is covered.
- [ ] **Step 3: Write minimal implementation**
Use the new `SetWebDomain` and `GetWebDomain` methods, plus any tiny frontend adjustment only if the current post-restart URL logic still prefers IP when a domain cert is configured.
- [ ] **Step 4: Run the test to verify it passes**
Run: `go test ./web/service -run TestSettingServiceStoresWebDomainAlongsidePortAndCert -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/service/setting_test.go web/html/settings.html
git commit -m "test: cover web domain settings round-trip"
```
---
### Self-Review
- [ ] No placeholders remain.
- [ ] Each requirement maps to a task.
- [ ] Domain persistence is written through the same JSON-backed settings path as port and cert files.
- [ ] Final installer output distinguishes domain SSL from IP SSL.
- [ ] Tests cover the new settings round-trip.

View file

@ -1,721 +0,0 @@
# Trojan-Go Style MariaDB Sync 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:** Add a minimal multi-VPS `master` / `worker` sync model on top of the existing MariaDB support, with install-time role selection, runtime role switching, MariaDB as the default for fresh installs, and SQLite compatibility preserved.
**Architecture:** Keep MariaDB as the shared source of truth and keep local Xray config generation unchanged. Add node-role and sync settings into the JSON config, expose them through CLI and shell scripts, enforce write restrictions in backend services, and add a polling plus traffic-delta sync loop that workers use against MariaDB-backed shared state.
**Tech Stack:** Go, GORM, Gin, shell scripts (`install.sh`, `x-ui.sh`), MariaDB, SQLite, Go tests, `bash -n`
---
## File Map
**Modify**
- `config/config.go`
- `config/config_test.go`
- `web/service/setting.go`
- `web/service/setting_test.go`
- `web/entity/entity.go`
- `main.go`
- `x-ui.sh`
- `install.sh`
- `database/model/model.go`
- `database/db.go`
- `web/service/inbound.go`
- `web/service/server.go`
- `web/service/xray.go`
**Create**
- `web/service/node_sync.go`
- `web/service/node_sync_test.go`
**Reference**
- `docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md`
### Task 1: Add node-role settings and MariaDB-first defaults
**Files:**
- Modify: `web/service/setting.go`
- Modify: `config/config.go`
- Modify: `config/config_test.go`
- Modify: `web/service/setting_test.go`
- [ ] **Step 1: Write the failing config tests**
```go
func TestGetNodeConfigFromJSONSupportsModulePurposeLayout(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", tmpDir)
settings := map[string]any{
"_meta": map[string]any{"layout": "按模块-用途来归类"},
"databaseConnection": map[string]any{
"dbType": "mariadb",
},
"systemIntegration": map[string]any{
"nodeRole": "worker",
"nodeId": "vps-01",
"syncInterval": "30",
"trafficFlushInterval": "60",
},
}
data, _ := json.MarshalIndent(settings, "", " ")
if err := os.WriteFile(GetSettingPath(), data, 0644); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
cfg := GetNodeConfigFromJSON()
if cfg.Role != "worker" || cfg.ID != "vps-01" || cfg.SyncInterval != "30" || cfg.TrafficFlushInterval != "60" {
t.Fatalf("unexpected node config: %+v", cfg)
}
}
func TestLoadSettingsUsesMariaDBAsFreshInstallDefault(t *testing.T) {
setupTestSettings(t)
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings() error: %v", err)
}
if settings["dbType"] != "mariadb" {
t.Fatalf("expected default dbType=mariadb, got %s", settings["dbType"])
}
if settings["nodeRole"] != "master" {
t.Fatalf("expected default nodeRole=master, got %s", settings["nodeRole"])
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./config ./web/service -run 'Test(GetNodeConfigFromJSONSupportsModulePurposeLayout|LoadSettingsUsesMariaDBAsFreshInstallDefault)' -v`
Expected: FAIL because `GetNodeConfigFromJSON`, `nodeRole`, and the new defaults do not exist yet.
- [ ] **Step 3: Add defaults and JSON readers**
```go
type NodeConfig struct {
Role string
ID string
SyncInterval string
TrafficFlushInterval string
}
func GetNodeConfigFromJSON() NodeConfig {
data, err := os.ReadFile(GetSettingPath())
if err != nil {
return NodeConfig{Role: "master", ID: "", SyncInterval: "30", TrafficFlushInterval: "60"}
}
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
return NodeConfig{Role: "master", ID: "", SyncInterval: "30", TrafficFlushInterval: "60"}
}
return NodeConfig{
Role: readGroupedString(settings, "nodeRole"),
ID: readGroupedString(settings, "nodeId"),
SyncInterval: readGroupedString(settings, "syncInterval"),
TrafficFlushInterval: readGroupedString(settings, "trafficFlushInterval"),
}
}
```
```go
// web/service/setting.go
"dbType": "mariadb",
"nodeRole": "master",
"nodeId": "",
"syncInterval": "30",
"trafficFlushInterval": "60",
```
- [ ] **Step 4: Add setting groups and getters/setters**
```go
"systemIntegration": {
"nodeRole": "nodeRole",
"nodeId": "nodeId",
"syncInterval": "syncInterval",
"trafficFlushInterval": "trafficFlushInterval",
},
```
```go
func (s *SettingService) GetNodeRole() (string, error) { return s.getString("nodeRole") }
func (s *SettingService) SetNodeRole(value string) error { return s.setString("nodeRole", value) }
func (s *SettingService) GetNodeID() (string, error) { return s.getString("nodeId") }
func (s *SettingService) SetNodeID(value string) error { return s.setString("nodeId", value) }
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `go test ./config ./web/service -run 'Test(GetNodeConfigFromJSONSupportsModulePurposeLayout|LoadSettingsUsesMariaDBAsFreshInstallDefault)' -v`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add config/config.go config/config_test.go web/service/setting.go web/service/setting_test.go
git commit -m "feat: add node sync settings and mariadb defaults"
```
### Task 2: Expose node-role configuration through the Go CLI
**Files:**
- Modify: `main.go`
- Modify: `web/entity/entity.go`
- Modify: `web/service/setting_test.go`
- [ ] **Step 1: Write the failing validation and CLI tests**
```go
func TestSettingEntityAcceptsNodeRoleValues(t *testing.T) {
s := AllSetting{DBType: "mariadb", NodeRole: "worker"}
if err := s.Check(); err != nil {
t.Fatalf("expected worker nodeRole to be accepted: %v", err)
}
}
func TestSettingServiceSetAndGetNodeRole(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
if err := svc.SetNodeRole("worker"); err != nil {
t.Fatalf("SetNodeRole error: %v", err)
}
role, err := svc.GetNodeRole()
if err != nil {
t.Fatalf("GetNodeRole error: %v", err)
}
if role != "worker" {
t.Fatalf("expected worker, got %s", role)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./web/service ./web/entity -run 'Test(SettingEntityAcceptsNodeRoleValues|SettingServiceSetAndGetNodeRole)' -v`
Expected: FAIL because `NodeRole` is not part of the validated setting payload and service API yet.
- [ ] **Step 3: Extend the CLI flags and JSON write path**
```go
var nodeRole string
var nodeID string
var syncInterval string
var trafficFlushInterval string
settingCmd.StringVar(&nodeRole, "nodeRole", "", "Set node role (master or worker)")
settingCmd.StringVar(&nodeID, "nodeId", "", "Set node identifier")
settingCmd.StringVar(&syncInterval, "syncInterval", "", "Set account sync interval in seconds")
settingCmd.StringVar(&trafficFlushInterval, "trafficFlushInterval", "", "Set traffic flush interval in seconds")
```
```go
if nodeRole != "" {
if err := config.WriteSettingToJSON("nodeRole", nodeRole); err != nil {
fmt.Println("Failed to set nodeRole:", err)
} else {
fmt.Println("nodeRole set to:", nodeRole)
}
}
```
- [ ] **Step 4: Validate `nodeRole` in the setting entity**
```go
if s.NodeRole != "" && s.NodeRole != "master" && s.NodeRole != "worker" {
return common.NewError("node role must be master or worker, got:", s.NodeRole)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `go test ./web/service ./web/entity -run 'Test(SettingEntityAcceptsNodeRoleValues|SettingServiceSetAndGetNodeRole)' -v`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add main.go web/entity/entity.go web/service/setting_test.go
git commit -m "feat: expose node role settings in cli"
```
### Task 3: Add node management to `x-ui.sh`
**Files:**
- Modify: `x-ui.sh`
- [ ] **Step 1: Write a shell syntax safety checkpoint**
Run: `bash -n x-ui.sh`
Expected: PASS before changes, establishing a clean syntax baseline.
- [ ] **Step 2: Add helpers for reading and writing node settings**
```bash
read_json_noderole() {
local node_role
node_role=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep '^nodeRole:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
if [ -z "$node_role" ]; then
echo "master"
else
echo "$node_role"
fi
}
switch_node_role() {
local role="$1"
${xui_folder}/x-ui setting -nodeRole "$role" >/dev/null 2>&1
}
```
- [ ] **Step 3: Add a node management menu**
```bash
node_menu() {
local current_role=$(read_json_noderole)
echo -e "
╔────────────────────────────────────────────────╗
│ ${green}节点管理${plain} │
│────────────────────────────────────────────────│
│ ${green}0.${plain} 返回主菜单 │
│ ${green}1.${plain} 查看当前节点角色(当前: ${current_role}
│ ${green}2.${plain} 切换到 master │
│ ${green}3.${plain} 切换到 worker │
│ ${green}4.${plain} 设置 nodeId │
╚════════════════════════════════════════════════╝
"
}
```
- [ ] **Step 4: Wire the menu into `show_menu` and preserve existing database menu**
```bash
│ ${green}27.${plain} 数据库管理 │
│ ${green}28.${plain} 节点管理 │
```
```bash
27)
check_install && db_menu
;;
28)
check_install && node_menu
;;
```
- [ ] **Step 5: Re-run shell syntax verification**
Run: `bash -n x-ui.sh`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add x-ui.sh
git commit -m "feat: add node role management to x-ui shell"
```
### Task 4: Add install-time role selection and MariaDB-first bootstrap
**Files:**
- Modify: `install.sh`
- [ ] **Step 1: Write a shell syntax safety checkpoint**
Run: `bash -n install.sh`
Expected: PASS before changes.
- [ ] **Step 2: Add fresh-install prompts for database type and node role**
```bash
read -rp "请选择数据库类型 [默认 mariadb可选 sqlite/mariadb]" install_db_type
install_db_type="${install_db_type// /}"
install_db_type="${install_db_type,,}"
if [[ -z "$install_db_type" ]]; then
install_db_type="mariadb"
fi
read -rp "请选择节点角色 [默认 master可选 master/worker]" install_node_role
install_node_role="${install_node_role// /}"
install_node_role="${install_node_role,,}"
if [[ -z "$install_node_role" ]]; then
install_node_role="master"
fi
```
- [ ] **Step 3: Persist fresh-install settings through the existing CLI**
```bash
${xui_folder}/x-ui setting -dbType "${install_db_type}" -nodeRole "${install_node_role}"
if [[ -n "${install_node_id}" ]]; then
${xui_folder}/x-ui setting -nodeId "${install_node_id}"
fi
```
```bash
if [[ "${install_db_type}" == "mariadb" ]]; then
XUI_DB_PASSWORD="${db_pass}" ${xui_folder}/x-ui setting \
-dbHost "${db_host}" \
-dbPort "${db_port}" \
-dbUser "${db_user}" \
-dbName "${db_name}"
fi
```
- [ ] **Step 4: Preserve SQLite compatibility for existing installs**
```bash
if [[ "$is_fresh_install" != "true" ]]; then
echo -e "${green}检测到现有安装,保留当前数据库类型与节点角色。${plain}"
else
# prompt for install_db_type and install_node_role only on fresh install
fi
```
- [ ] **Step 5: Re-run shell syntax verification**
Run: `bash -n install.sh`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add install.sh
git commit -m "feat: prompt for node role and mariadb on install"
```
### Task 5: Enforce `master` / `worker` write boundaries in Go services
**Files:**
- Create: `web/service/node_sync.go`
- Create: `web/service/node_sync_test.go`
- Modify: `web/service/inbound.go`
- Modify: `web/service/server.go`
- Modify: `web/service/user_test.go`
- [ ] **Step 1: Write the failing role-enforcement tests**
```go
func TestRequireMasterAllowsMaster(t *testing.T) {
setupTestSettings(t)
if err := config.WriteSettingToJSON("nodeRole", "master"); err != nil {
t.Fatalf("WriteSettingToJSON error: %v", err)
}
if err := RequireMaster(); err != nil {
t.Fatalf("expected master to pass: %v", err)
}
}
func TestRequireMasterRejectsWorker(t *testing.T) {
setupTestSettings(t)
if err := config.WriteSettingToJSON("nodeRole", "worker"); err != nil {
t.Fatalf("WriteSettingToJSON error: %v", err)
}
if err := RequireMaster(); err == nil {
t.Fatal("expected worker role to be rejected")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./web/service -run 'TestRequireMaster(AllowsMaster|RejectsWorker)' -v`
Expected: FAIL because no role gate exists yet.
- [ ] **Step 3: Implement a shared guard helper**
```go
func CurrentNodeRole() string {
cfg := config.GetNodeConfigFromJSON()
if cfg.Role == "" {
return "master"
}
return cfg.Role
}
func RequireMaster() error {
if CurrentNodeRole() != "master" {
return common.NewError("write operations are only allowed on master nodes")
}
return nil
}
```
- [ ] **Step 4: Call the guard in shared-state write paths**
```go
func (s *InboundService) AddInbound(inbound *model.Inbound) error {
if err := RequireMaster(); err != nil {
return err
}
// existing logic
}
```
```go
func (s *InboundService) DelInbound(id int) error {
if err := RequireMaster(); err != nil {
return err
}
// existing logic
}
```
- [ ] **Step 5: Re-run the tests and a focused service package pass**
Run: `go test ./web/service -run 'TestRequireMaster(AllowsMaster|RejectsWorker)' -v`
Expected: PASS
Run: `go test ./web/service/...`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add web/service/node_sync.go web/service/node_sync_test.go web/service/inbound.go web/service/server.go web/service/user_test.go
git commit -m "feat: enforce master-only shared writes"
```
### Task 6: Add account polling and local cache refresh
**Files:**
- Create: `web/service/node_sync.go`
- Create: `web/service/node_sync_test.go`
- Modify: `web/service/xray.go`
- Modify: `main.go`
- [ ] **Step 1: Write the failing sync-interval and version tests**
```go
func TestShouldRefreshAccountsWhenVersionChanges(t *testing.T) {
state := syncState{lastVersion: 2}
if !state.shouldRefresh(3) {
t.Fatal("expected newer version to trigger refresh")
}
}
func TestShouldNotRefreshAccountsWhenVersionMatches(t *testing.T) {
state := syncState{lastVersion: 3}
if state.shouldRefresh(3) {
t.Fatal("expected same version to skip refresh")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./web/service -run 'TestShould(RefreshAccountsWhenVersionChanges|NotRefreshAccountsWhenVersionMatches)' -v`
Expected: FAIL because the sync state object and refresh logic do not exist.
- [ ] **Step 3: Implement the sync state and poller skeleton**
```go
type syncState struct {
lastVersion int64
}
func (s syncState) shouldRefresh(version int64) bool {
return version > s.lastVersion
}
func StartNodeSyncLoop() {
cfg := config.GetNodeConfigFromJSON()
if config.GetDBTypeFromJSON() != "mariadb" {
return
}
if cfg.Role != "worker" && cfg.Role != "master" {
return
}
go runAccountSyncLoop(cfg)
}
```
- [ ] **Step 4: Hook the poller into process startup without changing local Xray ownership**
```go
func runWebServer() {
if err := database.InitDB(); err != nil {
log.Fatal(err)
}
service.StartNodeSyncLoop()
// existing startup
}
```
- [ ] **Step 5: Re-run focused tests**
Run: `go test ./web/service -run 'TestShould(RefreshAccountsWhenVersionChanges|NotRefreshAccountsWhenVersionMatches)' -v`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add web/service/node_sync.go web/service/node_sync_test.go web/service/xray.go main.go
git commit -m "feat: add account sync polling skeleton"
```
### Task 7: Add traffic delta writeback and worker-safe accounting
**Files:**
- Modify: `database/model/model.go`
- Modify: `database/db.go`
- Modify: `web/service/node_sync.go`
- Modify: `web/service/node_sync_test.go`
- [ ] **Step 1: Write the failing delta accounting tests**
```go
func TestApplyTrafficDeltaAccumulatesUsage(t *testing.T) {
current := trafficTotals{Upload: 100, Download: 200}
next := current.applyDelta(20, 30)
if next.Upload != 120 || next.Download != 230 {
t.Fatalf("unexpected totals: %+v", next)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./web/service -run TestApplyTrafficDeltaAccumulatesUsage -v`
Expected: FAIL because the delta helper does not exist.
- [ ] **Step 3: Implement atomic delta write helpers**
```go
type trafficTotals struct {
Upload int64
Download int64
}
func (t trafficTotals) applyDelta(uploadDelta, downloadDelta int64) trafficTotals {
t.Upload += uploadDelta
t.Download += downloadDelta
return t
}
```
```go
func ApplyTrafficDelta(clientID int64, uploadDelta, downloadDelta int64) error {
return GetDB().Model(&model.ClientTraffic{}).
Where("client_id = ?", clientID).
Updates(map[string]any{
"up": gorm.Expr("up + ?", uploadDelta),
"down": gorm.Expr("down + ?", downloadDelta),
}).Error
}
```
- [ ] **Step 4: Flush only deltas from worker nodes**
```go
func flushTrafficDeltas(deltas map[int64]trafficTotals) error {
for clientID, delta := range deltas {
if err := database.ApplyTrafficDelta(clientID, delta.Upload, delta.Download); err != nil {
return err
}
}
return nil
}
```
- [ ] **Step 5: Re-run focused tests and database package tests**
Run: `go test ./web/service -run TestApplyTrafficDeltaAccumulatesUsage -v`
Expected: PASS
Run: `go test ./database/...`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add database/model/model.go database/db.go web/service/node_sync.go web/service/node_sync_test.go
git commit -m "feat: add worker traffic delta writeback"
```
### Task 8: Final verification and docs alignment
**Files:**
- Modify: `docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md`
- Modify: `docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md`
- [ ] **Step 1: Run the targeted Go test suites**
Run: `go test ./config ./database/... ./web/service/...`
Expected: PASS
- [ ] **Step 2: Run shell syntax checks**
Run: `bash -n x-ui.sh`
Expected: PASS
Run: `bash -n install.sh`
Expected: PASS
- [ ] **Step 3: Manually verify fresh-install and runtime flows**
Run:
```bash
./x-ui setting -showDbType
./x-ui setting -nodeRole worker -nodeId vps-02
./x-ui setting -dbType sqlite
```
Expected:
- first command prints the configured backend
- second command updates the JSON config without touching the DB schema
- third command remains accepted for compatibility
- [ ] **Step 4: Update docs if the final implementation diverges from the spec**
```md
- document the final JSON keys
- document the exact `x-ui.sh` menu entries
- document the fresh-install MariaDB default and SQLite compatibility rule
```
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md
git commit -m "docs: finalize node sync implementation notes"
```

View file

@ -1,69 +0,0 @@
# Local/Remote MariaDB Install and Switch 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:** Add consistent local-vs-remote MariaDB handling to first install and runtime database switching, with local setup ensuring the business database and business user exist before x-ui saves business credentials.
**Architecture:** Extend the shell scripts with shared helper patterns for local detection, admin credential acquisition, idempotent database/user provisioning, and business-credential validation. Keep remote MariaDB as a pure validation-and-save path, while local MariaDB performs install/start/provision/validate before saving config and migrating data.
**Tech Stack:** Bash, MariaDB CLI, `bash -n`, shell regression tests, Go tests
---
## File Map
**Modify**
- `install.sh`
- `x-ui.sh`
**Create**
- `tests/mariadb_install_switch_test.sh`
**Reference**
- `docs/superpowers/specs/2026-04-11-local-remote-mariadb-install-design.md`
### Task 1: Add failing regression checks
**Files:**
- Create: `tests/mariadb_install_switch_test.sh`
- [ ] Add assertions that `install.sh` prompts for local vs remote MariaDB and handles local business DB fields.
- [ ] Add assertions that `x-ui.sh` exposes the same local vs remote MariaDB prompt and helper flow.
- [ ] Run `bash tests/mariadb_install_switch_test.sh` and verify it fails before script changes.
### Task 2: Implement install-time local/remote MariaDB flow
**Files:**
- Modify: `install.sh`
- [ ] Add helper functions for local-host detection, admin connection attempts, identifier validation, provisioning SQL, and business-connection validation.
- [ ] Update fresh-install MariaDB prompts to choose local or remote before collecting credentials.
- [ ] For remote, validate direct business connection to the target database and save settings.
- [ ] For local, collect business db name/user/password first, provision MariaDB resources, validate the business connection, and save settings.
### Task 3: Implement switch-time local/remote MariaDB flow
**Files:**
- Modify: `x-ui.sh`
- [ ] Reuse or mirror the same prompt structure and provisioning logic in `db_switch_to_mariadb`.
- [ ] Keep remote switch as validate-and-migrate only.
- [ ] Keep local switch as install/start/provision/validate/migrate.
### Task 4: Verify scripts
**Files:**
- Modify: `install.sh`
- Modify: `x-ui.sh`
- Test: `tests/mariadb_install_switch_test.sh`
- [ ] Run `bash tests/mariadb_install_switch_test.sh`.
- [ ] Run `bash -n install.sh x-ui.sh`.
- [ ] Run `go test ./...`.

View file

@ -1,60 +0,0 @@
# MariaDB Remote IP Access 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:** Add local MariaDB port customization plus x-ui.sh menu actions to manage remote access by explicit allowed IPs.
**Architecture:** Keep the existing MariaDB install/switch flow, but add reusable shell helpers to update MariaDB server network settings and per-IP grants. Store no new backend state; read active DB settings from `/etc/x-ui/x-ui.json` and query MariaDB for remote hosts when needed.
**Tech Stack:** Bash, MariaDB CLI, existing `install.sh`/`x-ui.sh`, shell prompt assertions in `tests/mariadb_install_switch_test.sh`
---
### Task 1: Extend prompt coverage first
**Files:**
- Modify: `tests/mariadb_install_switch_test.sh`
- [ ] Add assertions for the new local MariaDB port prompt and remote access menu labels.
- [ ] Run `bash tests/mariadb_install_switch_test.sh` and confirm it fails before implementation.
### Task 2: Add MariaDB network config helpers
**Files:**
- Modify: `install.sh`
- Modify: `x-ui.sh`
- [ ] Add shared shell helpers to detect a MariaDB server config file, update `port`, update `bind-address`, and restart MariaDB.
- [ ] Keep defaults local-only (`127.0.0.1`) unless remote access is explicitly enabled.
### Task 3: Support local MariaDB custom port
**Files:**
- Modify: `install.sh`
- Modify: `x-ui.sh`
- [ ] Prompt for local MariaDB port in local install / switch flows.
- [ ] Validate `1-65535`.
- [ ] Apply the chosen server port before business DB/user creation.
- [ ] Persist the selected port through the existing `x-ui setting -dbPort`.
### Task 4: Add remote IP allowlist management
**Files:**
- Modify: `x-ui.sh`
- [ ] Add menu actions to view status, enable remote access, disable remote access, list allowed IPs, add allowed IP, and remove allowed IP.
- [ ] Query MariaDB for existing non-local hosts of the current DB user.
- [ ] Add and remove per-IP grants for the current DB user against the current DB name.
- [ ] Enable remote access by switching bind address to `0.0.0.0`; disable by restoring `127.0.0.1` and removing remote grants.
### Task 5: Verify and record
**Files:**
- Modify: `tests/mariadb_install_switch_test.sh`
- Create: `docs/Tasktracking/2026-04-22-add-mariadb-remote-ip-access.md`
- [ ] Run `bash tests/mariadb_install_switch_test.sh`.
- [ ] Run `bash -n install.sh`.
- [ ] Run `bash -n x-ui.sh`.
- [ ] Write the Tasktracking record with exact verification and residual runtime risks.

View file

@ -1,839 +0,0 @@
# Node Management Sidebar — 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:** Add a "节点管理" sidebar item and page that shows connected nodes (master→workers, worker→master) with detailed status and node configuration editing.
**Architecture:** New NodeController serves API endpoints under `/panel/api/nodes/`. New `nodes.html` page follows existing patterns (Vue 2 + Ant Design Vue). Database layer adds `GetNodeStates()` query. Sidebar gets a new menu item gated by `{{if .is_admin}}`.
**Tech Stack:** Go, Gin, GORM, Vue.js 2, Ant Design Vue 1.x, Go html/template
---
### Task 1: Add GetNodeStates database query
**Files:**
- Modify: `database/shared_state.go`
- [ ] **Step 1: Add GetNodeStates function**
Add this function to `database/shared_state.go`, after the existing `UpsertNodeState` function:
```go
// GetNodeStates returns all node_state records ordered by node_id.
func GetNodeStates() ([]model.NodeState, error) {
var states []model.NodeState
err := GetDB().Order("node_id").Find(&states).Error
return states, err
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./...`
Expected: PASS (no output)
- [ ] **Step 3: Commit**
```bash
git add database/shared_state.go
git commit -m "feat: add GetNodeStates query for node management"
```
---
### Task 2: Create NodeController with API endpoints
**Files:**
- Create: `web/controller/node.go`
- [ ] **Step 1: Create node.go with full controller**
Create `web/controller/node.go`:
```go
package controller
import (
"encoding/json"
"net/http"
"os"
"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/gin-gonic/gin"
)
// NodeController handles node management API endpoints.
type NodeController struct {
BaseController
}
// NewNodeController creates a new NodeController and initializes its routes.
func NewNodeController(g *gin.RouterGroup) *NodeController {
a := &NodeController{}
a.initRouter(g)
return a
}
// initRouter sets up the routes for node management.
func (a *NodeController) initRouter(g *gin.RouterGroup) {
g = g.Group("/nodes")
g.Use(a.checkAdmin)
g.GET("/list", a.list)
g.GET("/config", a.getConfig)
g.POST("/config", a.updateConfig)
}
// NodeView is the JSON shape returned to the frontend for each node.
type NodeView struct {
NodeID string `json:"nodeId"`
NodeRole string `json:"nodeRole"`
Online bool `json:"online"`
LastHeartbeatAt int64 `json:"lastHeartbeatAt"`
LastSyncAt int64 `json:"lastSyncAt"`
LastSeenVersion int64 `json:"lastSeenVersion"`
LastError string `json:"lastError"`
}
// list returns connected nodes. Master sees all workers; worker sees the master.
func (a *NodeController) list(c *gin.Context) {
nodeCfg := config.GetNodeConfigFromJSON()
states, err := database.GetNodeStates()
if err != nil {
jsonMsg(c, "get node states", err)
return
}
syncInterval := nodeCfg.SyncIntervalSeconds
if syncInterval <= 0 {
syncInterval = 30
}
offlineThreshold := int64(syncInterval * 2)
now := time.Now().Unix()
var nodes []NodeView
for _, s := range states {
// Master shows workers; worker shows master
if nodeCfg.Role == config.NodeRoleMaster && s.NodeRole != string(config.NodeRoleWorker) {
continue
}
if nodeCfg.Role == config.NodeRoleWorker && s.NodeRole != string(config.NodeRoleMaster) {
continue
}
online := (now - s.LastHeartbeatAt) < offlineThreshold
nodes = append(nodes, NodeView{
NodeID: s.NodeID,
NodeRole: s.NodeRole,
Online: online,
LastHeartbeatAt: s.LastHeartbeatAt,
LastSyncAt: s.LastSyncAt,
LastSeenVersion: s.LastSeenVersion,
LastError: s.LastError,
})
}
if nodes == nil {
nodes = []NodeView{}
}
jsonObj(c, nodes, nil)
}
// NodeConfigView is the JSON shape for node configuration.
type NodeConfigView struct {
Role string `json:"role"`
NodeID string `json:"nodeId"`
SyncInterval int `json:"syncInterval"`
TrafficFlushInterval int `json:"trafficFlushInterval"`
DBType string `json:"dbType"`
DBHost string `json:"dbHost"`
DBPort string `json:"dbPort"`
DBUser string `json:"dbUser"`
DBPass string `json:"dbPass"`
DBName string `json:"dbName"`
}
// getConfig returns the current node configuration.
func (a *NodeController) getConfig(c *gin.Context) {
nodeCfg := config.GetNodeConfigFromJSON()
dbCfg := config.GetDBConfigFromJSON()
jsonObj(c, NodeConfigView{
Role: string(nodeCfg.Role),
NodeID: nodeCfg.NodeID,
SyncInterval: nodeCfg.SyncIntervalSeconds,
TrafficFlushInterval: nodeCfg.TrafficFlushSeconds,
DBType: dbCfg.Type,
DBHost: dbCfg.Host,
DBPort: dbCfg.Port,
DBUser: dbCfg.User,
DBPass: dbCfg.Password,
DBName: dbCfg.Name,
}, nil)
}
// updateConfigRequest is the JSON body for updating node config.
type updateConfigRequest struct {
SyncInterval int `json:"syncInterval"`
TrafficFlushInterval int `json:"trafficFlushInterval"`
DBType string `json:"dbType"`
DBHost string `json:"dbHost"`
DBPort string `json:"dbPort"`
DBUser string `json:"dbUser"`
DBPass string `json:"dbPass"`
DBName string `json:"dbName"`
}
// updateConfig updates the node configuration in x-ui.json.
func (a *NodeController) updateConfig(c *gin.Context) {
var req updateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, "invalid request", err)
return
}
// Validate
if req.SyncInterval <= 0 {
jsonMsg(c, "syncInterval must be positive", os.ErrInvalid)
return
}
if req.TrafficFlushInterval <= 0 {
jsonMsg(c, "trafficFlushInterval must be positive", os.ErrInvalid)
return
}
// Write each setting to JSON config
settings := map[string]string{
"syncInterval": json.NumberString(req.SyncInterval),
"trafficFlushInterval": json.NumberString(req.TrafficFlushInterval),
"dbType": req.DBType,
"dbHost": req.DBHost,
"dbPort": req.DBPort,
"dbUser": req.DBUser,
"dbPassword": req.DBPass,
"dbName": req.DBName,
}
for key, value := range settings {
if err := config.WriteSettingToJSON(key, value); err != nil {
jsonMsg(c, "save "+key, err)
return
}
}
jsonMsg(c, I18nWeb(c, "pages.nodes.saveSuccess"), nil)
}
```
Wait — I need to check how `json.NumberString` works. Let me use `strconv.Itoa` instead.
- [ ] **Step 2: Fix the import — use strconv instead of json.NumberString**
The `updateConfig` function should use `strconv.Itoa` for integer-to-string conversion. Replace the settings map in the `updateConfig` function:
```go
"strconv"
```
Add `"strconv"` to the import block, and change the settings map to:
```go
settings := map[string]string{
"syncInterval": strconv.Itoa(req.SyncInterval),
"trafficFlushInterval": strconv.Itoa(req.TrafficFlushInterval),
"dbType": req.DBType,
"dbHost": req.DBHost,
"dbPort": req.DBPort,
"dbUser": req.DBUser,
"dbPassword": req.DBPass,
"dbName": req.DBName,
}
```
Remove the `"encoding/json"` and `"os"` imports (no longer needed).
- [ ] **Step 3: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./...`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add web/controller/node.go
git commit -m "feat: add NodeController with list/config API endpoints"
```
---
### Task 3: Register NodeController routes
**Files:**
- Modify: `web/controller/api.go:39-63`
- Modify: `web/controller/xui.go:27-39`
- [ ] **Step 1: Add NodeController to APIController**
In `web/controller/api.go`, add a field to the `APIController` struct (after `userController`):
```go
nodeController *NodeController
```
In the `initRouter` method, add node routes after the users API group (after line 59):
```go
// Nodes API
nodes := api.Group("/nodes")
nodes.Use(a.checkAdmin)
a.nodeController = NewNodeController(nodes)
```
Wait — the NodeController already calls `a.checkAdmin` in its own `initRouter`. Looking at the pattern: `ServerController` and `UserController` don't call `checkAdmin` themselves — the `api.go` applies it at the group level. But my NodeController's `initRouter` applies `a.checkAdmin` on its own sub-group. This would double-apply the middleware.
Let me fix: the NodeController's `initRouter` should NOT call `a.checkAdmin` since the parent group in `api.go` already applies it. But actually, looking at the code more carefully:
- `api.go` line 51-52: `server := api.Group("/server")` then `server.Use(a.checkAdmin)` then `a.serverController = NewServerController(server)`
- The ServerController's `initRouter` receives the group and doesn't add `checkAdmin` again
So I need to remove the `g.Use(a.checkAdmin)` from NodeController's `initRouter`. Let me update the NodeController code:
In `web/controller/node.go`, remove the line `g.Use(a.checkAdmin)` from `initRouter`:
```go
func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/config", a.getConfig)
g.POST("/config", a.updateConfig)
}
```
But wait — for the page route in `xui.go`, the `checkAdmin` is applied at the route level, not via middleware on the group. Let me check the XUI controller again...
Looking at `xui.go:27-39`:
```go
g = g.Group("/panel")
g.Use(a.checkLogin)
// ...
g.GET("/settings", a.checkAdmin, a.settings)
```
So for page routes, `checkAdmin` is per-route. For API routes, it's per-group. The NodeController's API routes will be under the `/panel/api/nodes` group which already has `checkAdmin` applied.
OK, the approach:
1. In `api.go`, add the node controller registration with `checkAdmin` on the group
2. In `xui.go`, add the page route with `checkAdmin` per-route
3. In `node.go`, remove the `checkAdmin` from `initRouter` since it's applied by the parent
- [ ] **Step 1 (corrected): Update api.go — add NodeController**
In `web/controller/api.go`:
1. Add field to struct (after `userController`):
```go
nodeController *NodeController
```
2. Add route registration in `initRouter` (after the users block, before the "Extra routes" comment):
```go
// Nodes API
nodes := api.Group("/nodes")
nodes.Use(a.checkAdmin)
a.nodeController = NewNodeController(nodes)
```
- [ ] **Step 2: Update node.go — remove checkAdmin from initRouter**
In `web/controller/node.go`, change `initRouter` to remove `g.Use(a.checkAdmin)`:
```go
func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/config", a.getConfig)
g.POST("/config", a.updateConfig)
}
```
- [ ] **Step 3: Update xui.go — add page route**
In `web/controller/xui.go`, add the nodes page route in `initRouter` (after the `xray` line, before the `users` line):
```go
g.GET("/nodes", a.checkAdmin, a.nodes)
```
Add the handler method (after `xraySettings`):
```go
// nodes renders the node management page.
func (a *XUIController) nodes(c *gin.Context) {
html(c, "nodes.html", "pages.nodes.title", nil)
}
```
- [ ] **Step 4: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./...`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add web/controller/api.go web/controller/node.go web/controller/xui.go
git commit -m "feat: register NodeController routes and nodes page"
```
---
### Task 4: Add i18n translations
**Files:**
- Modify: `web/translation/translate.en_US.toml`
- Modify: `web/translation/translate.zh_CN.toml`
- [ ] **Step 1: Add English translations**
In `web/translation/translate.en_US.toml`, add `nodes` to the `[menu]` section (after `"xray"`):
```toml
"nodes" = "Nodes"
```
Add a new section at the end of the file:
```toml
[pages.nodes]
"title" = "Node Management"
"nodeId" = "Node ID"
"role" = "Role"
"status" = "Status"
"online" = "Online"
"offline" = "Offline"
"lastHeartbeat" = "Last Heartbeat"
"lastSync" = "Last Sync"
"syncVersion" = "Sync Version"
"error" = "Error"
"syncInterval" = "Sync Interval (seconds)"
"trafficFlushInterval" = "Traffic Flush Interval (seconds)"
"dbType" = "Database Type"
"dbHost" = "Database Host"
"dbPort" = "Database Port"
"dbUser" = "Database User"
"dbPass" = "Database Password"
"dbName" = "Database Name"
"save" = "Save"
"saveSuccess" = "Node configuration saved successfully"
"noWorkerNodes" = "No worker nodes connected"
"masterNode" = "Master Node"
"workerNodes" = "Worker Nodes"
"currentNodeConfig" = "Current Node Configuration"
"connectedNodes" = "Connected Nodes"
"refresh" = "Refresh"
```
Also add the page title key. In the `[pages.nodes]` section, make sure `"title"` is present (it's used by `html(c, "nodes.html", "pages.nodes.title", nil)`).
- [ ] **Step 2: Add Chinese translations**
In `web/translation/translate.zh_CN.toml`, add `nodes` to the `[menu]` section (after `"xray"`):
```toml
"nodes" = "节点管理"
```
Add a new section at the end of the file:
```toml
[pages.nodes]
"title" = "节点管理"
"nodeId" = "节点 ID"
"role" = "角色"
"status" = "状态"
"online" = "在线"
"offline" = "离线"
"lastHeartbeat" = "最后心跳"
"lastSync" = "最后同步"
"syncVersion" = "同步版本"
"error" = "错误"
"syncInterval" = "同步间隔(秒)"
"trafficFlushInterval" = "流量刷新间隔(秒)"
"dbType" = "数据库类型"
"dbHost" = "数据库主机"
"dbPort" = "数据库端口"
"dbUser" = "数据库用户"
"dbPass" = "数据库密码"
"dbName" = "数据库名称"
"save" = "保存"
"saveSuccess" = "节点配置保存成功"
"noWorkerNodes" = "暂无 Worker 节点连接"
"masterNode" = "主节点"
"workerNodes" = "Worker 节点"
"currentNodeConfig" = "当前节点配置"
"connectedNodes" = "已连接节点"
"refresh" = "刷新"
```
- [ ] **Step 3: Verify it compiles**
Run: `cd /usr/x-ui/3x-ui && go build ./...`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add web/translation/translate.en_US.toml web/translation/translate.zh_CN.toml
git commit -m "feat: add i18n translations for node management"
```
---
### Task 5: Add sidebar menu item
**Files:**
- Modify: `web/html/component/aSidebar.html:61-66`
- [ ] **Step 1: Add nodes menu item**
In `web/html/component/aSidebar.html`, add the nodes entry in the `tabs` array between the `settings` item and the `xray` item. After the closing `},` of the settings item (line 61), add:
```javascript
{{if .is_admin}}
{
key: '{{ .base_path }}panel/nodes',
icon: 'cluster',
title: '{{ i18n "menu.nodes"}}'
},
{{end}}
```
The full tabs array should now be:
```javascript
tabs: [
{ key: '{{ .base_path }}panel/', icon: 'dashboard', title: '{{ i18n "menu.dashboard"}}' },
{ key: '{{ .base_path }}panel/inbounds', icon: 'user', title: '{{ i18n "menu.inbounds"}}' },
{ key: '{{ .base_path }}panel/settings', icon: 'setting', title: '{{ i18n "menu.settings"}}' },
{{if .is_admin}}
{ key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' },
{{end}}
{ key: '{{ .base_path }}panel/xray', icon: 'tool', title: '{{ i18n "menu.xray"}}' },
{{if .is_admin}}
{ key: '{{ .base_path }}panel/users', icon: 'team', title: '{{ i18n "menu.users"}}' },
{{end}}
{ key: '{{ .base_path }}logout/', icon: 'logout', title: '{{ i18n "menu.logout"}}' },
],
```
- [ ] **Step 2: Commit**
```bash
git add web/html/component/aSidebar.html
git commit -m "feat: add nodes menu item to sidebar"
```
---
### Task 6: Create nodes.html page
**Files:**
- Create: `web/html/nodes.html`
- [ ] **Step 1: Create the full nodes.html page**
Create `web/html/nodes.html`:
```html
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' nodes-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loading" :delay="200" tip='{{ i18n "loading"}}'>
<transition name="list" appear>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
<!-- Connected Nodes Section -->
<a-col :span="24">
<a-card hoverable>
<template #title>
<a-row type="flex" justify="space-between" align="middle">
<a-col>
<a-space>
<a-icon type="cluster"></a-icon>
<span>{{ i18n "pages.nodes.connectedNodes" }}</span>
<a-tag :color="nodeRole === 'master' ? 'blue' : 'green'">[[ nodeRole ]]</a-tag>
</a-space>
</a-col>
<a-col>
<a-button icon="reload" size="small" @click="loadNodes">{{ i18n "pages.nodes.refresh" }}</a-button>
</a-col>
</a-row>
</template>
<a-table
v-if="nodeRole === 'master'"
:columns="nodeColumns"
:data-source="nodes"
:row-key="record => record.nodeId"
:pagination="false"
:scroll="isMobile ? { x: 700 } : undefined"
size="middle">
<template slot="status" slot-scope="text, record">
<a-badge :status="record.online ? 'success' : 'error'" :text="record.online ? '{{ i18n "pages.nodes.online" }}' : '{{ i18n "pages.nodes.offline" }}'" />
</template>
<template slot="role" slot-scope="text, record">
<a-tag :color="record.nodeRole === 'master' ? 'blue' : 'green'">[[ record.nodeRole ]]</a-tag>
</template>
<template slot="lastHeartbeat" slot-scope="text, record">
[[ record.lastHeartbeatAt ? formatTime(record.lastHeartbeatAt) : '-' ]]
</template>
<template slot="lastSync" slot-scope="text, record">
[[ record.lastSyncAt ? formatTime(record.lastSyncAt) : '-' ]]
</template>
</a-table>
<div v-if="nodeRole === 'worker'">
<a-empty v-if="nodes.length === 0" description='{{ i18n "pages.nodes.noWorkerNodes" }}' />
<a-descriptions v-else bordered size="small" :column="isMobile ? 1 : 2">
<a-descriptions-item label='{{ i18n "pages.nodes.nodeId" }}'>[[ nodes[0].nodeId ]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "pages.nodes.status" }}'>
<a-badge :status="nodes[0].online ? 'success' : 'error'" :text="nodes[0].online ? '{{ i18n "pages.nodes.online" }}' : '{{ i18n "pages.nodes.offline" }}'" />
</a-descriptions-item>
<a-descriptions-item label='{{ i18n "pages.nodes.lastHeartbeat" }}'>[[ nodes[0].lastHeartbeatAt ? formatTime(nodes[0].lastHeartbeatAt) : '-' ]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "pages.nodes.lastSync" }}'>[[ nodes[0].lastSyncAt ? formatTime(nodes[0].lastSyncAt) : '-' ]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "pages.nodes.syncVersion" }}'>[[ nodes[0].lastSeenVersion ]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "pages.nodes.error" }}'>[[ nodes[0].lastError || '-' ]]</a-descriptions-item>
</a-descriptions>
</div>
<a-empty v-if="nodeRole === 'master' && nodes.length === 0" description='{{ i18n "pages.nodes.noWorkerNodes" }}' />
</a-card>
</a-col>
<!-- Current Node Config Section -->
<a-col :span="24">
<a-card hoverable>
<template #title>
<a-space>
<a-icon type="setting"></a-icon>
<span>{{ i18n "pages.nodes.currentNodeConfig" }}</span>
</a-space>
</template>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.role" }}'>
<a-input :value="nodeConfig.role" disabled></a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.nodeId" }}'>
<a-input :value="nodeConfig.nodeId" disabled></a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.dbType" }}'>
<a-select v-model="nodeConfig.dbType" :disabled="saving">
<a-select-option value="sqlite">SQLite</a-select-option>
<a-select-option value="mysql">MySQL/MariaDB</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.syncInterval" }}'>
<a-input-number v-model="nodeConfig.syncInterval" :min="5" :max="3600" style="width: 100%"></a-input-number>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.trafficFlushInterval" }}'>
<a-input-number v-model="nodeConfig.trafficFlushInterval" :min="5" :max="3600" style="width: 100%"></a-input-number>
</a-form-item>
</a-col>
</a-row>
<a-divider>{{ i18n "pages.nodes.dbHost" }}</a-divider>
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.dbHost" }}'>
<a-input v-model="nodeConfig.dbHost" :disabled="saving"></a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.dbPort" }}'>
<a-input v-model="nodeConfig.dbPort" :disabled="saving"></a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.dbName" }}'>
<a-input v-model="nodeConfig.dbName" :disabled="saving"></a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.dbUser" }}'>
<a-input v-model="nodeConfig.dbUser" :disabled="saving"></a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label='{{ i18n "pages.nodes.dbPass" }}'>
<a-input-password v-model="nodeConfig.dbPass" :disabled="saving"></a-input-password>
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button type="primary" icon="save" :loading="saving" @click="saveConfig">
{{ i18n "pages.nodes.save" }}
</a-button>
</a-form-item>
</a-form>
</a-card>
</a-col>
</a-row>
</transition>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script>
const app = new Vue({
el: '#app',
delimiters: ['[[', ']]'],
data() {
return {
loading: false,
saving: false,
nodeRole: '{{ if .is_admin }}master{{ else }}worker{{ end }}',
nodes: [],
nodeConfig: {
role: '',
nodeId: '',
syncInterval: 30,
trafficFlushInterval: 10,
dbType: '',
dbHost: '',
dbPort: '',
dbUser: '',
dbPass: '',
dbName: '',
},
nodeColumns: [
{ title: '{{ i18n "pages.nodes.nodeId" }}', dataIndex: 'nodeId', width: 150 },
{ title: '{{ i18n "pages.nodes.status" }}', scopedSlots: { customRender: 'status' }, width: 100 },
{ title: '{{ i18n "pages.nodes.role" }}', scopedSlots: { customRender: 'role' }, width: 80 },
{ title: '{{ i18n "pages.nodes.lastHeartbeat" }}', scopedSlots: { customRender: 'lastHeartbeat' }, width: 180 },
{ title: '{{ i18n "pages.nodes.lastSync" }}', scopedSlots: { customRender: 'lastSync' }, width: 180 },
{ title: '{{ i18n "pages.nodes.syncVersion" }}', dataIndex: 'lastSeenVersion', width: 120 },
{ title: '{{ i18n "pages.nodes.error" }}', dataIndex: 'lastError', ellipsis: true },
],
refreshTimer: null,
}
},
computed: {
isMobile() {
return window.innerWidth <= 768;
}
},
methods: {
async loadNodes() {
try {
const res = await axios.get('api/nodes/list');
if (res.data.success) {
this.nodes = res.data.obj;
// Determine current role from the first node or config
if (this.nodes.length > 0) {
// If we're master, all returned nodes are workers
// If we're worker, returned nodes are master
// We can also check from config
}
}
} catch (e) {
console.error('Failed to load nodes', e);
}
},
async loadConfig() {
try {
const res = await axios.get('api/nodes/config');
if (res.data.success) {
Object.assign(this.nodeConfig, res.data.obj);
this.nodeRole = res.data.obj.role;
}
} catch (e) {
console.error('Failed to load node config', e);
}
},
async saveConfig() {
this.saving = true;
try {
const res = await axios.post('api/nodes/config', {
syncInterval: this.nodeConfig.syncInterval,
trafficFlushInterval: this.nodeConfig.trafficFlushInterval,
dbType: this.nodeConfig.dbType,
dbHost: this.nodeConfig.dbHost,
dbPort: this.nodeConfig.dbPort,
dbUser: this.nodeConfig.dbUser,
dbPass: this.nodeConfig.dbPass,
dbName: this.nodeConfig.dbName,
});
if (res.data.success) {
this.$message.success(res.data.msg);
} else {
this.$message.error(res.data.msg);
}
} catch (e) {
this.$message.error('Save failed');
} finally {
this.saving = false;
}
},
formatTime(ts) {
if (!ts) return '-';
return moment.unix(ts).format('YYYY-MM-DD HH:mm:ss');
},
},
mounted() {
this.loadNodes();
this.loadConfig();
// Auto-refresh node list every 10 seconds
this.refreshTimer = setInterval(() => {
this.loadNodes();
}, 10000);
},
beforeDestroy() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
},
});
</script>
{{ template "page/body_end" }}
</html>
```
- [ ] **Step 2: Commit**
```bash
git add web/html/nodes.html
git commit -m "feat: add nodes.html page with node list and config form"
```
---
### Task 7: Build and verify
- [ ] **Step 1: Full build check**
Run: `cd /usr/x-ui/3x-ui && go build ./...`
Expected: PASS
- [ ] **Step 2: Run vet**
Run: `cd /usr/x-ui/3x-ui && go vet ./...`
Expected: PASS
- [ ] **Step 3: Final commit (if any fixes needed)**
```bash
git add -A
git commit -m "fix: address build issues from node management feature"
```

View file

@ -1,157 +0,0 @@
# Multi-Node Shared Control Progress
## Execution Strategy
- Tasks 13: Inline
- Tasks 47: Subagent-Driven
- Every task is a checkpoint and must end with its own git commit.
- After each task commit, record the short hash here before moving on.
## Update Rules
- Change `Status: [ ] Pending` to `Status: [x] Done` only after the tasks checkpoint commit succeeds.
- Replace `Commit: pending` with the short commit hash for that task.
- If a task is blocked, update the `Blockers:` line on that task instead of opening a separate scratch section.
- Keep this file current before handing work from Inline execution to Subagent-Driven execution.
## Task Tracker
### Task 1: Add node config, runtime file paths, and startup validation
- Status: [x] Done
- Mode: Inline
- Commit: 36826706
- Depends on: none
- Scope: add `NodeConfig`, role validation, runtime file path helpers, CLI setters, and startup validation before DB init
- Primary files: `config/config.go`, `config/config_test.go`, `main.go`
- Checkpoints:
- add focused failing tests for defaults, validation, and runtime file paths
- implement `GetNodeConfigFromJSON`, `ValidateNodeConfig`, `GetSharedCachePath`, and `GetTrafficPendingPath`
- wire startup validation and `setting` CLI flags into `main.go`
- run focused `./config` tests and package discovery
- Done when:
- worker mode rejects missing `nodeId` and `sqlite`
- `setting -show` prints node settings
- Task 1 checkpoint commit is recorded
- Blockers: none
### Task 2: Add shared metadata models and repository helpers
- Status: [x] Done
- Mode: Inline
- Commit: fd0af148
- Depends on: Task 1 committed
- Scope: add shared version and node heartbeat metadata tables plus repository helpers for version bump and node-state upsert
- Primary files: `database/model/shared_state.go`, `database/model/node_state.go`, `database/shared_state.go`, `database/db.go`, `database/db_test.go`
- Checkpoints:
- add failing tests for metadata tables, version seeding, and node-state upsert
- create `SharedState` and `NodeState` models
- add `GetSharedAccountsVersion`, `BumpSharedAccountsVersion`, and `UpsertNodeState`
- register models in DB init and seed `shared_accounts_version`
- run focused `./database` tests
- Done when:
- metadata tables are auto-migrated by `InitDB`
- shared version starts at `0` and bumps transactionally
- Task 2 checkpoint commit is recorded
- Blockers: none
### Task 3: Enforce master-only shared writes and transactional version bumping
- Status: [x] Done
- Mode: Inline
- Commit: `34b9f01d`
- Depends on: Task 2 committed
- Scope: establish the shared write boundary in service code before any worker sync or shared traffic work lands
- Primary files: `web/service/node_guard.go`, `web/service/node_guard_test.go`, `web/service/inbound.go`
- Checkpoints:
- [x] add failing tests for `RequireMaster` and version rollback behavior
- [x] implement `IsWorker`, `IsMaster`, `RequireMaster`, and shared-mode detection
- [x] guard shared inbound/client mutation paths in `InboundService`
- [x] bump shared version only inside successful write transactions
- [x] run focused `./web/service` guard tests
- Done when:
- worker-side shared write attempts fail in service layer
- successful shared writes advance `shared_accounts_version`
- Task 3 checkpoint commit is recorded
- Blockers: none
### Task 4: Add shared snapshot cache, worker sync loop, and snapshot-driven Xray rebuild
- Status: [x] Done
- Mode: Subagent-Driven
- Commit: `3cfa5547`
- Depends on: Tasks 13 committed
- Scope: make workers survive from cached shared snapshots and rebuild local Xray from synchronized inbounds instead of live DB reads only
- Primary files: `web/service/node_cache.go`, `web/service/node_sync.go`, `web/service/node_sync_test.go`, `web/service/xray.go`, `web/web.go`
- Checkpoints:
- [x] add failing tests for snapshot round-trip, no-op sync, changed-version sync, and cache bootstrap
- [x] add `SharedAccountsSnapshot` load/save helpers
- [x] refactor `XrayService` to build config from an explicit inbound list
- [x] implement `NodeSyncService` with cache bootstrap, version polling, and node-state updates
- [x] start worker sync or master heartbeat loops from server startup
- [x] run focused sync tests
- Done when:
- worker startup can apply `shared-cache.json` before the first DB poll
- changed shared version leads to cache refresh plus local Xray rebuild
- Task 4 checkpoint commit is recorded
- Blockers: none
### Task 5: Add durable traffic delta persistence and safe shared-mode flushes
- Status: [x] Done
- Mode: Subagent-Driven
- Commit: `87282dde`
- Depends on: Task 3 committed, Task 4 wiring available
- Scope: stop worker shared-mode traffic from mutating shared state directly; collect local deltas, flush atomically, and keep reconciliation master-only
- Primary files: `web/service/traffic_pending.go`, `web/service/traffic_flush.go`, `web/service/traffic_flush_test.go`, `web/job/xray_traffic_job.go`, `web/service/inbound.go`, `web/web.go`, `xray/client_traffic.go`
- Checkpoints:
- [x] add failing tests for delta merge, successful flush, and failed flush retention
- [x] implement `TrafficPendingStore` and `TrafficDelta`
- [x] add composite unique key on `(inbound_id, email)` for safe upserts
- [x] implement `TrafficFlushService` with atomic DB updates
- [x] extract `ReconcileSharedTrafficState` so only master performs enable/expiry mutations
- [x] route shared-mode traffic collection through pending-store accumulation and start flush loop
- [x] run focused flush tests and package discovery
- Done when:
- worker shared-mode traffic no longer calls `InboundService.AddTraffic()`
- pending deltas survive failures and clear only after successful flush
- Task 5 checkpoint commit is recorded
- Blockers: none
### Task 6: Expose node management in shell tools and the installer
- Status: [x] Done
- Mode: Subagent-Driven
- Commit: `6b58044e`
- Depends on: Task 1 committed
- Scope: expose the new node settings to operators through `x-ui.sh` and `install.sh` without destabilizing upgrades
- Primary files: `x-ui.sh`, `install.sh`
- Checkpoints:
- [x] add node-setting readers and status display to `x-ui.sh`
- [x] add minimal node-role and node-id mutation actions via `./x-ui setting`
- [x] prompt for MariaDB and node role during fresh installs only
- [x] run `bash -n` on both scripts and smoke-check `./x-ui setting -show true`
- Done when:
- shell scripts pass syntax checks
- fresh-install path can capture MariaDB plus node role settings
- Task 6 checkpoint commit is recorded
- Blockers: none
### Task 7: Document the feature and run focused verification
- Status: [x] Done
- Mode: Subagent-Driven
- Commit: `85cd0b60`
- Depends on: Tasks 46 committed
- Scope: publish operator-facing docs, verification steps, and final package-level checks after implementation is stable
- Primary files: `docs/multi-node-sync.md`, `README.md`, `README.zh_CN.md`
- Checkpoints:
- [x] write the operator runbook with role model, requirements, runtime loops, and failure behavior
- [x] add concise README sections in English and Chinese
- [x] append the manual verification checklist
- [x] run focused verification across `./config`, `./database`, and `./web/service`, then package discovery
- Done when:
- docs match the shipped runtime behavior
- verification commands pass
- Task 7 checkpoint commit is recorded
- Blockers: none

View file

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

View file

@ -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)

View file

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

View file

@ -1,281 +0,0 @@
# Cloudflare CDN Frontend Asset Optimization Design
## Context
The panel currently serves frontend assets from embedded files under `web/assets` and references them directly from HTML templates. A subset of assets uses `?{{ .cur_ver }}` query strings for cache busting, while some third-party files have no version token at all. The server sets `Cache-Control: max-age=31536000` for requests under `/assets/`, and enables gzip at the Gin layer.
This works for basic browser caching, but it is not a strong fit for Cloudflare edge caching:
- Query-string cache busting is weaker than content-addressed filenames.
- Some assets are not versioned at all.
- HTML and static assets are not explicitly separated into short-cache vs long-cache behavior.
- The current embedded asset flow does not provide a manifest-based way to map logical asset names to hashed output names.
The deployment model is Go binary compilation with `go:embed`, so the design must preserve compile-time embedding and avoid runtime dependence on the local filesystem.
## Goals
- Keep all frontend assets self-hosted.
- Optimize asset delivery for Cloudflare CDN edge caching.
- Replace query-string cache busting with content-hashed filenames.
- Preserve the current HTML templates, base path support, and embedded deployment model.
- Keep API routes, session behavior, and WebSocket endpoints unchanged.
## Non-Goals
- No migration to third-party script or stylesheet CDNs.
- No change to business logic, Vue component behavior, or page structure.
- No runtime asset compilation in production.
- No broad frontend bundler migration in this change.
## Recommended Approach
Adopt a build-time asset fingerprinting pipeline that generates a new embedded asset output tree and a manifest file. Templates will resolve logical asset paths through the manifest, and the server will serve only fingerprinted asset URLs with long-lived immutable caching headers.
This is the recommended approach because it is the most compatible with Cloudflare's cache model and the current Go binary deployment flow.
## Alternatives Considered
### 1. Build-time fingerprinted assets and manifest
This is the recommended option.
Pros:
- Best Cloudflare cache efficiency and invalidation behavior.
- Safe long-lived caching with `immutable`.
- Explicit and debuggable asset mapping.
- Compatible with `go:embed`.
Cons:
- Adds a pre-build asset generation step.
- Requires template updates to use a manifest helper.
### 2. Runtime virtual hashed routes backed by embedded assets
Pros:
- No extra pre-build step.
Cons:
- Adds runtime complexity to compute or maintain mappings.
- Less transparent than generated files.
- Harder to reason about and test than build-time outputs.
### 3. Keep filenames and use per-file hash query strings
Pros:
- Smallest code change.
Cons:
- Weaker fit for Cloudflare edge caching.
- Less operationally clear than immutable fingerprinted paths.
- Leaves ambiguity around caches that normalize or vary on query strings.
## Design
### Asset Source and Output Layout
Keep `web/assets` as the source tree checked into the repository.
Add a generated output tree for embedded production assets:
- `web/public/assets/...` for fingerprinted files
- `web/public/assets-manifest.json` for logical-to-fingerprinted path mapping
`web/public` is generated content. `go:embed` in production should target the generated tree rather than the source tree.
Example mapping:
- logical: `css/custom.min.css`
- output: `css/custom.min.4f3c2a1b.css`
- logical: `js/websocket.js`
- output: `js/websocket.a9c88d71.js`
### Build Pipeline
Add a build-time generator command or script that:
1. Walks `web/assets`
2. Computes a deterministic content hash for each file
3. Writes the file into `web/public/assets` with the hash inserted before the extension
4. Emits `web/public/assets-manifest.json`
Hash requirements:
- Deterministic for identical file content
- Stable across platforms
- Short enough for readable filenames
An 8 to 12 character hex digest from SHA-256 is sufficient here.
The generator must preserve subdirectories so current logical organization remains intact.
### Manifest Format
Use a flat JSON object keyed by logical asset path relative to `web/assets`.
Example:
```json
{
"ant-design-vue/antd.min.css": "ant-design-vue/antd.min.4f3c2a1b.css",
"css/custom.min.css": "css/custom.min.182d7e0a.css",
"js/axios-init.js": "js/axios-init.bf4d1d4e.js",
"js/websocket.js": "js/websocket.a9c88d71.js",
"Vazirmatn-UI-NL-Regular.woff2": "Vazirmatn-UI-NL-Regular.4c2a16f1.woff2"
}
```
This keeps template lookup simple and avoids path reconstruction logic.
### Embed Strategy
Replace the production asset embed source in `web/web.go` so that production serving reads from generated output, not raw source assets.
Development mode can keep serving from `web/assets` directly to avoid slowing local iteration.
Production mode behavior:
- embed `web/public/assets`
- load `web/public/assets-manifest.json`
- serve only the generated fingerprinted files
### Template Asset Resolution
Add a template function, for example `asset`, that accepts a logical asset path and returns the final URL under the current `basePath`.
Example usage in templates:
```gotemplate
<link rel="stylesheet" href="{{ asset "ant-design-vue/antd.min.css" }}">
<script src="{{ asset "vue/vue.min.js" }}"></script>
```
This replaces direct `{{ .base_path }}assets/...` references and removes `?{{ .cur_ver }}` from static asset URLs.
The helper behavior should be:
- resolve the logical path through the manifest in production
- prefix with `{{ .base_path }}assets/`
- fail loudly during server init if a required manifest entry is missing
For debug mode, the helper can return the original non-fingerprinted path so templates work unchanged during local development.
### Cache-Control Policy
Separate HTML caching from static asset caching.
HTML responses:
- `Cache-Control: no-cache, must-revalidate`
Fingerprint asset responses:
- `Cache-Control: public, max-age=31536000, immutable`
This allows Cloudflare and browsers to retain asset files for a year while ensuring HTML revalidates and can reference new asset filenames after deployment.
### ETag and Last-Modified
This design does not require ETag for fingerprinted assets because filename changes already provide cache invalidation. ETag may still be present if provided by the underlying file serving behavior, but it is not required for correctness.
`Last-Modified` is also non-critical for fingerprinted assets. The current `ModTime` override tied to process start is not a reliable version signal, and should not be treated as part of cache invalidation. The fingerprinted filename is the source of truth.
### Cloudflare Behavior
Expected Cloudflare policy after this design:
- Cache `/assets/*` aggressively at the edge
- Do not cache HTML application pages for long durations
- Avoid purge-heavy workflows because asset invalidation is filename-based
This design keeps Cloudflare configuration simple. New deployments produce new asset URLs; old assets remain safely cacheable until naturally evicted.
### Backward Compatibility
Preserve:
- `basePath` support
- current routes outside static asset delivery
- current debug mode serving behavior
Change:
- production asset references move from raw names plus optional query strings to fingerprinted filenames
- production asset embed source moves to generated output
Existing un-fingerprinted `/assets/...` paths should not remain part of the production template output. If any route continues to expose them, that should be treated as compatibility-only behavior, not a primary path.
## Implementation Outline
1. Add an asset generation tool under the repository, preferably Go-based for portability with the existing build stack.
2. Generate `web/public/assets` and `web/public/assets-manifest.json` from `web/assets`.
3. Update `go:embed` usage in production to embed the generated asset tree and manifest.
4. Add manifest loading during server initialization.
5. Add the `asset` template helper.
6. Replace direct static asset references in HTML templates with `asset(...)`.
7. Update asset response headers to use immutable long-lived caching for fingerprinted assets.
8. Keep HTML responses on short-cache or revalidation semantics.
9. Document the new build prerequisite in developer and release documentation.
## Error Handling
Server startup should fail if:
- the manifest file is missing in production
- a manifest entry is malformed
- a template references an asset key that is absent from the manifest
Fail-fast is preferable here because silent fallback would hide release integrity problems and produce broken pages under CDN caching.
## Testing Strategy
### Automated
- Unit test the asset generator:
- stable hash naming
- preserved directory structure
- correct manifest output
- Unit test manifest loading:
- valid manifest parses
- missing or malformed entries fail
- Unit test template helper:
- returns base-path-prefixed fingerprinted URLs in production
- returns raw asset URLs in debug mode
- Integration test asset responses:
- fingerprinted asset path returns `Cache-Control: public, max-age=31536000, immutable`
- HTML response returns `Cache-Control: no-cache, must-revalidate`
### Manual
- Build a production binary and open the panel in a browser
- Inspect HTML and verify asset URLs contain hashes in filenames
- Confirm page reload after deployment references new filenames when a source asset changes
- Confirm Cloudflare can cache asset responses without manual purge
## Operational Notes
- Release workflows must run the asset generation step before `go build`.
- Developers should have a single documented command to regenerate embedded assets.
- Generated assets should either be committed consistently or regenerated in CI/build scripts. This decision should be made once and documented to avoid drift.
## Open Decision
One repository policy still needs to be chosen during implementation:
- Commit generated `web/public` outputs to git
- Or treat them as build artifacts generated before release and excluded from source control
Recommendation:
Do not commit generated fingerprinted assets if the release pipeline reliably runs the generator before building. Committing generated outputs increases churn and review noise. If the project's release flow is manual and local builds are common, committing generated outputs may be acceptable for simplicity.
## Summary
Use a build-time fingerprinting pipeline to generate embedded static assets and a manifest. Resolve template asset URLs through the manifest, serve fingerprinted asset files with one-year immutable caching, and keep HTML on revalidation semantics. This gives Cloudflare a clean, robust cache model without changing the panel's runtime behavior or introducing third-party CDNs.

View file

@ -1,338 +0,0 @@
# Trojan-Go Style Multi-VPS MariaDB Sync Design
## Context
The current project already supports MariaDB as a database backend, but the runtime model is still single-node in practice: the panel reads database state, generates a local Xray configuration, and starts a local Xray process. Xray itself does not read MariaDB directly.
The target deployment is multiple VPS instances sharing the same MariaDB-backed account system with the following data:
- account names
- passwords
- traffic quotas
- traffic usage counters
The goal is to keep the change set small and to reuse the existing MariaDB backend, while moving toward a `trojan-go`-style pattern where nodes periodically pull account state and maintain local runtime caches.
This design intentionally does not turn the system into a distributed cluster. It is a shared control-data model with one writable node and multiple read-oriented nodes.
## Goals
- Keep MariaDB as the single source of truth.
- Share account, password, quota, and traffic state across multiple VPS instances.
- Minimize code changes and preserve the current Xray startup model.
- Prevent multiple nodes from concurrently mutating account definitions.
- Support local runtime caches so each VPS can continue operating if the control node is temporarily unavailable.
## Non-Goals
- No direct database access from Xray.
- No distributed consensus or leader election.
- No automatic active-active write coordination.
- No node-aware load balancing or request routing layer.
- No redesign of the existing panel UI or the MariaDB migration logic.
## Recommended Approach
Use a two-role model:
- `writer/master` node: the only node allowed to write shared account state.
- `reader/worker` nodes: read shared account state, generate local Xray configuration, and report traffic deltas.
This is the lowest-risk option because it keeps the current database-centric flow intact and only adds synchronization boundaries around it.
## Alternatives Considered
### 1. Fully shared read/write MariaDB across all VPS
Pros:
- Lowest immediate implementation effort.
- No role distinction to manage.
Cons:
- Highest risk of conflicting writes.
- Traffic counters can be corrupted by last-write-wins behavior.
- Hard to reason about ownership of account edits.
### 2. Single writer, multiple readers with local caches
Pros:
- Clear ownership model.
- Smallest safe change.
- Compatible with periodic polling and incremental stat updates.
Cons:
- Not real-time.
- Requires one node to be the administrative source of writes.
### 3. Push-based control plane
Pros:
- Lower sync latency.
- Better scalability for many nodes.
Cons:
- Larger change set.
- Needs a push channel, retry logic, and delivery tracking.
## Design
### 1. Role Model
Introduce a node role concept:
- `master`: may create, update, and delete shared account records.
- `worker`: may only read shared account data and submit traffic increments.
The role is a deployment-time setting, not an automated cluster election. This avoids new consensus logic.
### 2. Data Ownership
Split the shared data into three logical groups:
- `account state`: username, password, enabled/disabled, quota, expiry, tags.
- `usage state`: accumulated download/upload totals, quota consumption, last update time.
- `node state`: node identifier, last sync time, last heartbeat, last reported version.
The important rule is that `usage state` must be updated as an increment, not as an overwrite.
### 3. Synchronization Flow
Each worker node follows this loop:
1. Poll MariaDB for account records and version changes.
2. Refresh the local cache when a newer version is detected.
3. Regenerate the local Xray configuration from the cache.
4. Continue serving traffic using the local runtime state.
5. Periodically submit traffic deltas back to MariaDB.
The master node follows the same pull logic, but additionally accepts account edits and propagates new version numbers.
### 4. Versioning
Add a monotonically increasing version marker to the shared account records or to the account set as a whole.
The version marker is used to answer one question only:
- "Has the account state changed since the last sync?"
This avoids full-table diffs and keeps polling cheap.
### 5. Traffic Accounting
Traffic accounting must be incremental.
Required behavior:
- each node accumulates local upload/download totals for the accounts it serves
- the node submits `delta_upload` and `delta_download` on a schedule
- MariaDB applies atomic increments to the canonical counters
Forbidden behavior:
- writing a full absolute total from a worker node
- letting multiple nodes overwrite the same total counter
This rule is what prevents traffic counter loss under concurrent updates.
### 6. Local Runtime Cache
Each VPS should keep a local in-memory cache, and optionally a lightweight on-disk snapshot if that is already aligned with the existing runtime flow.
The cache is the source for:
- Xray configuration generation
- local access checks
- short-lived serving behavior if MariaDB is temporarily unreachable
The cache is not the source of truth.
### 7. Failure Handling
If MariaDB is unreachable:
- worker nodes continue using the last valid cached account state
- traffic deltas remain buffered locally until the database returns
- no attempt is made to write account edits from workers
If the master node is unreachable:
- workers continue serving
- account edits pause
- existing synced data remains usable
If a traffic delta write fails:
- keep the delta in a retry queue
- retry with idempotent semantics if possible
## Minimal Schema Changes
The design assumes the existing MariaDB schema already stores the relevant shared data. Only small additions are needed.
Recommended additions:
- `version` or `updated_at` for account state change detection
- `node_id` for identifying each VPS instance
- `last_sync_at` for sync monitoring
- `pending_upload_delta` and `pending_download_delta` if local buffering is persisted
If the current schema already has equivalent fields, reuse them instead of adding new ones.
## Implementation Boundary
The following areas should change:
- database read/write logic for account sync
- traffic update path to support incremental writes
- configuration generation path to consume local cache
- role enforcement in update operations
The following areas should stay unchanged in the first pass:
- Xray protocol handling
- local Xray process management
- existing MariaDB connection settings and backend initialization
- unrelated panel features
## Operational Model
### Deployment
1. Pick one VPS as `master`.
2. Point every VPS at the same MariaDB instance.
3. Mark all non-master VPS instances as `worker`.
4. Configure workers to poll at a fixed interval.
5. Configure workers to submit traffic deltas on a fixed interval.
### Administration
Only the master node should be used for:
- adding accounts
- changing passwords
- changing quotas
- disabling/enabling shared accounts
Workers should expose read-only behavior for shared account data.
### Node Configuration
Store node metadata in the existing JSON configuration file so the role can be switched without touching the database schema.
Recommended keys:
- `nodeRole`: `master` or `worker`
- `nodeId`: unique node identifier, such as hostname or UUID
- `syncInterval`: account sync interval in seconds
- `trafficFlushInterval`: traffic flush interval in seconds
Operational rules:
- `nodeRole` controls write permission for shared account state
- `nodeId` identifies the node when writing heartbeats or usage deltas
- `master` and `worker` both continue to use the same MariaDB backend
- switching `nodeRole` should be a config update plus restart, not a database migration
### Management Entry Points
The shell entry points should expose the same separation of responsibilities:
- `x-ui.sh`: runtime switching and node-role management
- `install.sh`: first-install selection of node role and database type
`x-ui.sh` should gain a node-management menu that can:
- show the current node role
- switch between `master` and `worker`
- edit `nodeId`
- show last sync and heartbeat information
`install.sh` should prompt for:
- node role
- database type
- MariaDB connection settings when `mariadb` is selected
This keeps post-install operation and initial deployment distinct.
### Default Database Policy
The default database for new installs should be MariaDB, while SQLite remains fully supported for compatibility and downgrade scenarios.
Recommended rules:
- fresh installs default to `mariadb`
- existing SQLite installs remain on SQLite unless the operator explicitly migrates
- SQLite remains a valid fallback for single-node or offline deployments
- MariaDB remains the preferred backend for multi-VPS shared-account deployments
This means `dbType` is still a backend choice, not a role choice. `nodeRole` and `dbType` are independent settings.
### SQLite Compatibility
Keep SQLite support intact in the first pass:
- do not remove SQLite migration paths
- do not require MariaDB for local single-node operation
- preserve existing SQLite behavior for users who never opt into multi-node syncing
The compatibility guarantee is:
- SQLite continues to work as a standalone backend
- MariaDB becomes the default for new deployments
- node-role logic applies on top of either backend, but only the multi-node sync model depends on MariaDB in practice
## Testing Strategy
### Unit Tests
- version change detection
- account cache refresh behavior
- traffic delta accumulation
- atomic counter update semantics
### Integration Tests
- master edits account data and workers observe the update
- multiple workers submit traffic deltas without losing increments
- workers continue with cached data during temporary database outage
### Failure Tests
- stale cache fallback
- retry behavior for failed usage writes
- duplicate delta submission protection if the same buffered record is retried
## Risks
- Polling introduces sync delay.
- Incremental traffic writes must be idempotent or protected against retry duplication.
- Without node isolation, it is still possible to mix usage ownership if deployment discipline is poor.
- This design improves safety, but it is not a substitute for a real distributed control plane.
## Recommended Rollout
1. Add role-based write protection for shared account edits.
2. Add versioned polling for account state.
3. Add incremental traffic writeback.
4. Add buffered retry for failed delta submissions.
5. Only after that, consider pushing updates or adding more advanced node management.
## Open Questions
- Should traffic counters be stored per node and aggregated periodically, or written directly into a shared total counter with atomic increments?
- Should workers be allowed to serve indefinitely from cache, or should a sync-age limit disable stale accounts after a timeout?
## Decisions Captured
- Use JSON configuration for `nodeRole` and `nodeId`
- Add runtime node switching in `x-ui.sh`
- Add first-install role selection in `install.sh`
- Default new installs to MariaDB
- Preserve SQLite compatibility for existing and standalone deployments

View file

@ -1,357 +0,0 @@
# Multi-Node Shared Control Design
## Context
`3x-ui` already supports MariaDB as a database backend, but the runtime model remains single-node in practice: the panel reads persisted data, generates a local Xray configuration, and starts a local Xray process. Xray itself does not read MariaDB directly.
The target capability is a minimal multi-node control model where multiple VPS nodes share account definitions and aggregate traffic through MariaDB, while each node still runs its own local Xray instance.
This design intentionally keeps the existing panel pattern intact. It adds synchronization and role boundaries around the current database and Xray services instead of redesigning the runtime into a distributed cluster.
## Goals
- Support multiple nodes sharing account definitions through MariaDB.
- Keep `master` as the only writer for shared account definitions.
- Keep `worker` nodes running local Xray instances built from synchronized shared data.
- Aggregate traffic from all nodes without last-write-wins corruption.
- Preserve survivability when MariaDB is temporarily unavailable by using local cache files.
- Minimize changes to the existing `3x-ui` architecture and operator workflow.
## Non-Goals
- No direct MariaDB access from Xray.
- No leader election or automatic role failover.
- No active-active shared account writes across nodes.
- No push-based real-time config propagation.
- No synchronization of panel-global settings such as panel port, web domain, TLS files, Telegram bot settings, or panel users.
- No first-pass frontend-only read-only redesign for worker nodes.
## Accepted Scope
The first phase only synchronizes the shared-account surface:
- inbounds and inbound client definitions
- passwords and credential-bearing settings
- quota, expiry, enabled/disabled state
- aggregate upload and download counters
The first phase does not synchronize node-local operational settings or observability data beyond minimal sync status metadata.
## Recommended Approach
Use a single-writer control model:
- `master` is the only node allowed to mutate shared account definitions.
- `worker` nodes keep the existing panel pages, but shared-account write requests are rejected in the backend.
- all nodes, including `master`, continue to run local Xray instances and carry traffic
- workers poll MariaDB for a shared account-set version and rebuild their local Xray state when that version changes
- all nodes accumulate local traffic deltas and periodically flush them back to MariaDB using atomic increments
This is the lowest-risk option because it keeps the current `controller -> service -> database -> local Xray` model intact while adding explicit control-plane boundaries.
## Architecture
### Runtime Model
The system remains node-local at runtime:
- each node runs its own `3x-ui` process
- each node generates its own local Xray configuration
- each node starts and restarts its own local Xray process
- MariaDB acts only as the shared control database
There is no direct node-to-node RPC. Synchronization happens indirectly through MariaDB and local background loops.
### Role Boundaries
`master` responsibilities:
- accept and apply shared-account writes
- bump the shared account-set version after successful writes
- rebuild local Xray state immediately after successful writes
- flush local traffic deltas like any other node
`worker` responsibilities:
- reject shared-account write requests in the backend
- load synchronized shared account snapshots
- rebuild local Xray state from the synchronized snapshot
- flush local traffic deltas
### Shared vs Local Data
Shared through MariaDB:
- shared inbound definitions
- shared client definitions
- shared quota and expiry state
- aggregate traffic totals
- synchronization metadata
Kept local to each node:
- panel port and path
- web domain and TLS files
- Telegram bot settings
- panel users and login state
- local cache and pending traffic files
- local logs and node-specific operational state
## Configuration Model
Store node-control settings in `/etc/x-ui/x-ui.json`:
- `nodeRole`: `master` or `worker`, default `master`
- `nodeId`: unique node identifier, required for `worker`
- `syncInterval`: shared snapshot poll interval in seconds, default `30`
- `trafficFlushInterval`: traffic delta flush interval in seconds, default `10`
Validation rules:
- `nodeRole` must be `master` or `worker`
- `worker` requires non-empty `nodeId`
- `worker` requires `dbType = mariadb`
- `syncInterval` must be positive
- `trafficFlushInterval` must be positive
Compatibility rules:
- legacy configs without these keys must continue to load with defaults
- existing grouped JSON layout rules for other settings must remain compatible
- switching roles is a config change plus restart, not a schema migration
## Data Model
The first phase keeps shared business data in the existing tables and adds minimal synchronization metadata.
### Shared Metadata Tables
`shared_state`
- `key` primary key
- `version` monotonic int64
- `updated_at`
Reserved key:
- `shared_accounts_version`
`node_state`
- `node_id` primary key
- `node_role`
- `last_sync_at`
- `last_heartbeat_at`
- `last_seen_version`
- `last_error`
- `updated_at`
### Local Persistent Files
`/etc/x-ui/shared-cache.json`
- stores the last valid shared account snapshot
- primarily used by workers for startup and MariaDB outage survivability
- is not the system of record
`/etc/x-ui/traffic-pending.json`
- stores traffic deltas that have not yet been flushed successfully
- is used by all nodes
- prevents delta loss across retries or restarts
### Versioning Strategy
The design uses a single account-set version instead of per-record versions.
Rule:
- whenever `master` successfully changes shared account definitions, it increments `shared_accounts_version` in the same transaction
This version answers only one question:
- has the shared account set changed since the node last synchronized
That keeps worker polling cheap and avoids per-row merge logic in the first phase.
## Synchronization and Write Flow
### Shared Account Write Flow
For shared-account writes such as add, update, delete, enable, disable, quota change, expiry change, or client mutation:
1. request enters the existing controller/service path
2. service layer enforces `RequireMaster()`
3. `worker` requests are rejected before any database or Xray mutation
4. `master` applies the write
5. the same transaction increments `shared_accounts_version`
6. after transaction commit, `master` rebuilds local Xray configuration immediately
The `master` node does not wait for polling to apply its own writes.
### Worker Snapshot Sync Flow
Workers synchronize on startup and on a fixed interval:
1. load `shared-cache.json` if present
2. perform an immediate version check against MariaDB
3. if the shared version is newer, fetch the full shared account snapshot
4. persist the snapshot to `shared-cache.json`
5. rebuild local Xray configuration from that snapshot
6. update `node_state` with sync status
During steady state:
- poll `shared_accounts_version` every `syncInterval`
- if unchanged, only update node heartbeat/sync status
- if changed, fetch the full snapshot and rebuild local Xray state
### Traffic Flush Flow
All nodes, including `master`, follow the same traffic accounting pattern:
1. accumulate local per-account upload and download deltas
2. persist pending deltas locally
3. flush pending deltas every `trafficFlushInterval`
4. apply deltas in MariaDB using atomic increments
5. remove flushed deltas from local pending state after success
Required rule:
- nodes must never write absolute aggregate totals back to MariaDB
Forbidden behavior:
- reading the current total, adding locally, then overwriting the row
- allowing concurrent nodes to race with last-write-wins semantics
## Trigger Timing
Synchronization is triggered by control-state changes and fixed intervals, not by direct node-to-node notifications.
Triggers:
- `master` shared-account write success: bump version in-transaction and rebuild local Xray immediately
- worker startup: load local cache, then perform an immediate version check
- worker periodic sync: poll version every `syncInterval`
- all nodes periodic traffic flush: flush deltas every `trafficFlushInterval`
- optional best-effort shutdown flush: attempt one final delta flush on graceful shutdown
There is no direct push from `master` to `worker` in the first phase.
## Failure Handling
If MariaDB is temporarily unreachable:
- workers continue using the last valid shared snapshot
- traffic deltas remain in local pending storage
- shared-account writes cannot proceed
If `shared-cache.json` is missing or invalid and MariaDB is unreachable at worker startup:
- do not construct a speculative snapshot
- preserve the last successfully loaded runtime state if one already exists
- record the error in node status
If snapshot fetch succeeds but local Xray rebuild fails:
- keep the last known-good local runtime configuration active
- record the failure in `node_state.last_error`
- retry on the next synchronization cycle
If `master` writes shared state successfully but its own local rebuild fails:
- MariaDB remains the source of truth with the new version
- other nodes still synchronize from that committed state
- `master` records the local rebuild error for operator action
If a traffic flush fails:
- keep the delta in pending storage
- retry later
- never drop pending deltas on flush failure
## Testing Strategy
### Config Tests
Cover:
- legacy config defaults
- invalid `nodeRole`
- `worker` without `nodeId`
- `worker` with `sqlite`
- non-positive interval validation
### Service Tests
Cover:
- `RequireMaster()` enforcement
- version bumping on successful shared-account writes
- worker no-op behavior when version is unchanged
- worker snapshot fetch and rebuild trigger when version changes
- traffic flush success and retry behavior
### Database Tests
Cover:
- migration of `shared_state` and `node_state`
- seeding of `shared_accounts_version`
- atomic increment semantics for traffic totals
### Verification Expectations
Phase-one acceptance:
1. `master` writes are visible to workers within one poll cycle
2. worker-side shared-account writes are rejected
3. concurrent node traffic flushes do not lose aggregate totals
4. workers continue operating from the last valid cache during temporary MariaDB outages
## Operational Visibility
Expose minimal operator-visible node information:
- `nodeRole`
- `nodeId`
- `syncInterval`
- `trafficFlushInterval`
Record minimal per-node status in `node_state`:
- last sync time
- last heartbeat time
- last seen version
- last error
This is sufficient for the first phase to answer:
- whether a worker is lagging behind the current shared version
- whether a node is alive but failing to synchronize
- whether traffic flush retries are accumulating due to database problems
## Implementation Boundary
Expected change areas:
- config loading and validation
- startup validation and CLI setters
- database model registration and metadata helpers
- service-layer master-only guards
- worker sync and cache services
- traffic pending and flush services
- startup wiring in the web server
- operator-facing shell scripts and docs
Areas intentionally unchanged in the first phase:
- Xray protocol implementation
- local Xray process ownership model
- panel-global settings model
- distributed leadership or push-based synchronization
## Summary
This design adds a minimal shared control plane to `3x-ui` without turning the system into a distributed cluster. `master` owns shared account writes, all nodes keep local Xray ownership, workers rebuild from synchronized snapshots, and traffic returns to MariaDB only as atomic deltas. The result is a bounded first phase that matches the existing architecture and keeps future extension paths open.

View file

@ -1,43 +0,0 @@
# Local/Remote MariaDB Install and Switch Design
## Summary
Unify first-install and runtime database switching so users choose between local and remote MariaDB before credentials are collected. Remote MariaDB only validates and saves business connection info. Local MariaDB collects business database name, username, and password first, then installs or starts MariaDB, ensures the business database and business user exist, grants privileges, validates the business connection, and finally writes only the business credentials into x-ui settings.
## Requirements
- Fresh install asks whether MariaDB is local or remote before collecting MariaDB credentials.
- Remote MariaDB does not create databases or users.
- Local MariaDB ensures the target database exists, the business user exists, and privileges are granted on the target database.
- Local MariaDB does not persist admin credentials in x-ui config.
- `x-ui.sh` database switching follows the same local/remote behavior.
- Existing SQLite flow remains available.
## Design
### Fresh install
- After `dbType=mariadb`, prompt for `local` or `remote`.
- For `remote`, collect `host`, `port`, `db_name`, `db_user`, and `db_pass`, validate a direct business connection to the target database, then save those values.
- For `local`, collect `db_name`, `db_user`, and `db_pass` first, default to `127.0.0.1:3306`, then ensure local MariaDB is installed and running.
- Local setup first attempts admin access through local root socket. If that fails, prompt for an admin username and password.
- Local setup runs idempotent SQL to create the database, create the business user if missing, and grant privileges on the target database.
### Runtime switch
- `x-ui.sh` keeps the existing MariaDB install/start helpers.
- Add the same local/remote prompt to the SQLite -> MariaDB switch path.
- Remote switch only validates the business connection and saves settings before migration.
- Local switch ensures local MariaDB resources exist before migration.
### Safety
- Store only business credentials in the x-ui JSON config.
- Keep admin credentials in local shell variables only.
- Validate or safely quote database and username identifiers before issuing SQL.
## Testing
- Add a shell-level regression test that checks both scripts expose the new local/remote prompts and helper entry points.
- Run `bash -n install.sh x-ui.sh`.
- Run `go test ./...` to ensure script changes do not break Go-based integration assumptions.

View file

@ -1,157 +0,0 @@
# Node Management Sidebar — Design Spec
**Date:** 2026-04-24
**Status:** Approved
## Overview
Add a "Node Management" page accessible from the sidebar, visible only to admin users. The page displays connected node status and allows modifying node configuration.
## Behavior by Role
- **Master node:** Shows a table of all connected worker nodes with detailed status
- **Worker node:** Shows a card with the master node's info
## Backend
### New Controller: `NodeController`
File: `web/controller/node.go`
API endpoints (all admin-only via `checkAdmin` middleware):
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/panel/api/nodes/list` | GET | Node list (master: all workers; worker: master) |
| `/panel/api/nodes/config` | GET | Current node config |
| `/panel/api/nodes/config` | POST | Update current node config |
### New Page Route
In `xui.go`, add:
```go
func (x *XUIController) Nodes(c *gin.Context) {
// render nodes.html
}
```
Route: `GET /panel/nodes``XUIController.Nodes` (admin only)
### Data Sources
- **Node list:** Query `node_states` table via `database.GetNodeStates()` (new function in `database/shared_state.go`)
- **Node config:** Read from `x-ui.json` via existing `config.GetNodeConfigFromJSON()`
- **DB config:** Read from `AllSetting` entity (dbType, dbHost, dbPort, dbUser, dbPass, dbName)
- **Online status:** `LastHeartbeatAt` > 2 × `syncInterval` ago → offline
### Config Update Logic
POST `/panel/api/nodes/config` accepts JSON body with:
- `syncInterval` (int, seconds)
- `trafficFlushInterval` (int, seconds)
- `dbType`, `dbHost`, `dbPort`, `dbUser`, `dbPass`, `dbName`
Writes to `x-ui.json` under `"other"` group. Does NOT allow changing `nodeRole` or `nodeId` at runtime (displayed as read-only).
## Frontend
### New Page: `web/html/xui/nodes.html`
Structure (mirrors settings.html pattern):
- Head section: imports, template includes
- Vue app with two sections:
1. **Node list**`<a-table>` (master) or `<a-card>` (worker)
2. **Node config form**`<a-form>` with save button
### Node List Columns (master view)
| Column | Source |
|--------|--------|
| Node ID | `NodeState.NodeID` |
| Status | Online/Offline (heartbeat check) |
| Last Heartbeat | `NodeState.LastHeartbeatAt` (formatted) |
| Last Sync | `NodeState.LastSyncAt` (formatted) |
| Sync Version | `NodeState.LastSeenVersion` |
| Error | `NodeState.LastError` |
Worker view: same fields in a card layout.
### Config Form Fields
| Field | Type | Editable |
|-------|------|----------|
| Node Role | Text | No (read-only) |
| Node ID | Text | No (read-only) |
| Sync Interval | Number (seconds) | Yes |
| Traffic Flush Interval | Number (seconds) | Yes |
| DB Type | Select (sqlite/mysql) | Yes |
| DB Host | Text | Yes |
| DB Port | Number | Yes |
| DB User | Text | Yes |
| DB Password | Password | Yes |
| DB Name | Text | Yes |
### Auto-Refresh
Node list polls `/panel/api/nodes/list` every 10 seconds via `setInterval`.
### Sidebar Change
In `web/html/component/aSidebar.html`, add between `settings` and `xray`:
```javascript
{{if .is_admin}}
{ key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' },
{{end}}
```
## i18n
Add to `translate.en_US.toml` and `translate.zh_CN.toml`:
```toml
[menu]
"nodes" = "Nodes" # en
"nodes" = "节点管理" # zh
[nodes]
"title" = "Node Management"
"nodeId" = "Node ID"
"role" = "Role"
"status" = "Status"
"online" = "Online"
"offline" = "Offline"
"lastHeartbeat" = "Last Heartbeat"
"lastSync" = "Last Sync"
"syncVersion" = "Sync Version"
"error" = "Error"
"syncInterval" = "Sync Interval"
"trafficFlushInterval" = "Traffic Flush Interval"
"dbType" = "Database Type"
"dbHost" = "Database Host"
"dbPort" = "Database Port"
"dbUser" = "Database User"
"dbPass" = "Database Password"
"dbName" = "Database Name"
"save" = "Save"
"saveSuccess" = "Saved successfully"
"noWorkerNodes" = "No worker nodes connected"
"masterNode" = "Master Node"
"workerNodes" = "Worker Nodes"
```
## Files to Create/Modify
| File | Action |
|------|--------|
| `web/controller/node.go` | **Create** — NodeController with list/config APIs |
| `web/html/xui/nodes.html` | **Create** — Node management page |
| `web/html/component/aSidebar.html` | **Modify** — Add nodes menu item |
| `web/web.go` | **Modify** — Register routes and controller |
| `web/controller/xui.go` | **Modify** — Add Nodes() page method |
| `web/translation/translate.en_US.toml` | **Modify** — Add i18n keys |
| `web/translation/translate.zh_CN.toml` | **Modify** — Add i18n keys |
| `database/shared_state.go` | **Modify** — Add GetNodeStates() query function |
## Scope Boundaries
- **In scope:** View node status, modify node config, sidebar entry
- **Out of scope:** Node registration/removal, restart, adding new nodes, real-time WebSocket updates (uses polling instead)

View file

@ -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/s0=不限速
Flow string `json:"flow"`
Email string `json:"email"`
LimitIP int `json:"limitIp"` // 遗留字段Fail2ban 用
TotalGB int64 `json:"totalGB"`
ExpiryTime int64 `json:"expiryTime"`
Enable bool `json:"enable"`
TgID int64 `json:"tgId"`
SubID string `json:"subId"`
Comment string `json:"comment"`
Reset int `json:"reset"`
}
```
### InboundClientIps与 3x-ui 相同)
```go
type InboundClientIps struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
ClientEmail string `json:"clientEmail" gorm:"unique"`
Ips string `json:"ips"` // JSON 数组字符串
}
```
### 内存状态结构
```go
// 活跃 IP 跟踪TTL 机制)
// map[用户email] -> map[IP地址] -> 最后活跃时间
var ActiveClientIPs = make(map[string]map[string]time.Time)
var activeClientsLock sync.RWMutex
// 用户封禁状态跟踪
// map[用户email] -> 是否被封禁(true/false)
var ClientStatus = make(map[string]bool)
var clientStatusLock sync.RWMutex
```
---
## 核心任务CheckDeviceLimitJob
**源文件:** `web/job/check_client_ip_job.go`
### 结构体
```go
type CheckDeviceLimitJob struct {
inboundService service.InboundService
xrayService *service.XrayService
xrayApi xray.XrayAPI
lastPosition int64 // access.log 增量读取位置
telegramService service.TelegramService // TG 通知(可为 nil
violationStartTime map[string]time.Time // 观察期开始时间
triggerLock sync.Mutex // 保护 violationStartTime
}
```
### 构造函数
```go
func NewCheckDeviceLimitJob(xrayService *service.XrayService, telegramService service.TelegramService) *CheckDeviceLimitJob
```
### Run() 主循环
```go
func (j *CheckDeviceLimitJob) Run() {
if !j.xrayService.IsXrayRunning() {
return
}
j.cleanupExpiredIPs()
j.parseAccessLog()
j.checkAllClientsLimit()
}
```
### cleanupExpiredIPs() — 清理过期 IP
- TTL 窗口:**3 分钟**
- 超过 3 分钟未出现的 IP 被删除
- 用户所有 IP 都过期后,用户条目也从 map 中移除
```go
const activeTTL = 3 * time.Minute
for email, ips := range ActiveClientIPs {
for ip, lastSeen := range ips {
if now.Sub(lastSeen) > activeTTL {
delete(ActiveClientIPs[email], ip)
}
}
if len(ActiveClientIPs[email]) == 0 {
delete(ActiveClientIPs, email)
}
}
```
### parseAccessLog() — 增量解析日志
- 使用 `file.Seek(j.lastPosition, 0)` 实现增量读取
- 正则提取 email 和 IP
```go
emailRegex := regexp.MustCompile(`email: ([^ ]+)`)
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
```
- 忽略 `127.0.0.1``::1`
- 读取完毕后记录当前位置;如果文件被截断(当前位置 < 上次位置重置为 0
### checkAllClientsLimit() — 核心检查逻辑
```go
// 查询启用了设备限制且正在运行的入站
db.Where("device_limit > 0 AND enable = ?", true).Find(&inbounds)
// 获取 Xray API 端口
apiPort := j.xrayService.GetApiPort()
j.xrayApi.Init(apiPort)
defer j.xrayApi.Close()
```
**第一步:处理在线用户**
- 遍历 `ActiveClientIPs`
- 通过 `inboundService.GetClientTrafficByEmail(email)` 关联到入站
- 检查活跃 IP 数 vs `device_limit`
- 超限 → 进入观察期逻辑 → 封禁
- 恢复 → 解封
**第二步:处理已封禁但已下线的用户**
- 遍历 `ClientStatus`
- 已封禁但不在 `ActiveClientIPs` 中的用户 → 解封
---
## 封禁/解封机制
### banUser() — 封禁UUID 替换)
```go
func (j *CheckDeviceLimitJob) banUser(email string, activeIPCount int, info *struct{...}) {
// 1. 从数据库获取原始客户端信息
_, client, err := j.inboundService.GetClientByEmail(email)
// 2. 发送 Telegram 通知(异步 goroutine
go func() {
j.telegramService.SendMessage(tgMessage)
}()
// 3. 从 Xray-Core 中删除该用户
j.xrayApi.RemoveUser(info.Tag, email)
// 4. 等待 5 秒,解决竞态条件
time.Sleep(5000 * time.Millisecond)
// 5. 创建临时客户端,替换 UUID/Password
tempClient := *client
if tempClient.ID != "" { tempClient.ID = RandomUUID() }
if tempClient.Password != "" { tempClient.Password = RandomUUID() }
// 6. 用错误的 UUID/Password 添加回去 → 客户端无法通过验证
j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap)
// 7. 标记为已封禁
ClientStatus[email] = true
}
```
### unbanUser() — 解封(恢复原始 UUID
```go
func (j *CheckDeviceLimitJob) unbanUser(email string, activeIPCount int, info *struct{...}) {
// 1. 从数据库获取原始客户端信息
_, client, err := j.inboundService.GetClientByEmail(email)
// 2. 删除封禁用的临时用户
j.xrayApi.RemoveUser(info.Tag, email)
// 3. 等待 5 秒
time.Sleep(5000 * time.Millisecond)
// 4. 用原始正确的 UUID/Password 添加回去
j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap)
// 5. 移除封禁标记
delete(ClientStatus, email)
}
```
### RandomUUID() — 生成随机 UUID
```go
func RandomUUID() string {
uuid := make([]byte, 16)
rand.Read(uuid)
uuid[6] = (uuid[6] & 0x0f) | 0x40
uuid[8] = (uuid[8] & 0x3f) | 0x80
return hex.EncodeToString(uuid[0:4]) + "-" + hex.EncodeToString(uuid[4:6]) + "-" +
hex.EncodeToString(uuid[6:8]) + "-" + hex.EncodeToString(uuid[8:10]) + "-" +
hex.EncodeToString(uuid[10:16])
}
```
### 关键依赖接口
| 接口 | 说明 |
|------|------|
| `j.inboundService.GetClientByEmail(email)` | 从数据库获取客户端原始配置(含 UUID/Password |
| `j.xrayApi.RemoveUser(tag, email)` | 通过 gRPC 从 Xray-Core 移除用户 |
| `j.xrayApi.AddUser(protocol, tag, clientMap)` | 通过 gRPC 向 Xray-Core 添加用户 |
| `j.xrayService.GetApiPort()` | 获取 Xray API 端口号 |
| `j.xrayService.IsXrayRunning()` | 检查 Xray 是否运行中 |
| `j.telegramService.SendMessage(msg)` | 发送 Telegram 通知 |
---
## 观察期防误封逻辑
**目的:** 解决用户切换网络时产生临时双 IP 导致误封的问题。
```
场景 A用户设备数超限且当前未被封禁
├─ 首次发现超限 → 记录时间,进入 3 分钟观察期,不封禁
├─ 观察期内仍超限但未满 3 分钟 → 继续观察
└─ 观察期满 3 分钟仍超限 → 确认封禁
场景 B用户恢复正常IP 数 ≤ 限制)
├─ 之前在观察名单中 → 移除观察记录,皆大欢喜
└─ 之前被封禁 → 执行解封
```
核心代码:
```go
if activeIPCount > info.Limit && !isBanned {
startTime, exists := j.violationStartTime[email]
if !exists {
// 首次超限,开始观察
j.violationStartTime[email] = time.Now()
continue
}
if time.Since(startTime) < 3*time.Minute {
// 还在观察期,暂不封禁
continue
}
// 观察期结束,确认封禁
delete(j.violationStartTime, email)
j.banUser(email, activeIPCount, &info)
}
```
---
## TTL 过期清理
- **活跃窗口:** 3 分钟
- 每 10 秒执行一次清理
- IP 在 `ActiveClientIPs` 中的 `lastSeen` 时间超过 3 分钟则删除
- 用户所有 IP 被清理后,用户条目也移除
- 被清理的已封禁用户在 `checkAllClientsLimit` 第二步中会被解封
---
## 遗留任务CheckClientIpJob
**源文件:** `web/job/check_client_ip_job.go` (lines 416-714)
与 3x-ui 的实现完全一致:
1. 解析 access.log提取每个 email 的所有 IP
2. 与数据库中 `InboundClientIps` 记录对比
3. 超过 `LimitIP` 的 IP 写入 `3xipl.log`
4. 依赖 Fail2ban 读取日志进行 iptables 封禁
5. 每小时清理 access.log
此任务由 cron 调度,与 `CheckDeviceLimitJob` 独立运行。
---
## 前端 UI
**源文件:** `web/html/form/client.html`
### 入站级别
`DeviceLimit` 字段不在 client 表单中显示,而是在入站配置中设置(具体 UI 未在提供的文件中)。
### 客户端级别
| 字段 | 行号 | 说明 |
|------|------|------|
| `client.limitIp` | 108 | IP 数量限制遗留Fail2ban 用) |
| `client.speedLimit` | 85-92 | 独立限速,单位 KB/s0=不限速 |
| `client._totalGB` | 150 | 总流量限制 |
| `client._expiryTime` | 179-182 | 过期时间 |
| `client.reset` | 193 | 续期天数 |
---
## 主程序启动与依赖注入
**源文件:** `main.go`
### 服务初始化runWebServer 函数)
```go
// 1. 创建服务实例
xrayService := service.XrayService{}
settingService := service.SettingService{}
serverService := service.ServerService{}
inboundService := service.InboundService{}
// 2. 创建 Xray API 实例并注入
xrayApi := xray.XrayAPI{}
xrayService.SetXrayAPI(xrayApi)
inboundService.SetXrayAPI(xrayApi)
// 3. 初始化 Telegram Bot如已启用
if tgEnable {
tgBot := service.NewTgBot(...)
tgBotService = tgBot
}
// 4. 注入 Telegram 服务
serverService.SetTelegramService(tgBotService)
inboundService.SetTelegramService(tgBotService)
```
### 设备限制定时任务启动
```go
go func() {
time.Sleep(10 * time.Second) // 等待面板和 Xray 稳定
ticker := time.NewTicker(10 * time.Second) // 每 10 秒执行
defer ticker.Stop()
// 创建 Telegram 服务(可为 nil
var tgBotService service.TelegramService
if tgEnable {
tgBotService = new(service.Tgbot)
}
// 创建任务实例
checkJob := job.NewCheckDeviceLimitJob(&xrayService, tgBotService)
// 无限循环
for {
<-ticker.C
checkJob.Run()
}
}()
```
---
## 关键日志路径
| 路径 | 说明 |
|------|------|
| `config.GetLogFolder() + "/3xipl.log"` | IP 限制日志(遗留 Fail2ban 用) |
| `config.GetLogFolder() + "/3xipl-banned.log"` | 封禁日志 |
| `config.GetLogFolder() + "/3xipl-ap.log"` | 持久化访问日志 |
| Xray access log配置中指定 | 用户连接日志,设备限制解析源 |
| `config.GetBinFolderPath() + "/core_crash_*.log"` | 崩溃报告 |
---
## 与 3x-ui 的差异总结
| 特性 | 3x-ui | x-panel |
|------|-------|---------|
| IP 限制级别 | per-client (`LimitIP`) | per-inbound (`DeviceLimit`) + per-client 遗留 |
| 封禁方式 | Fail2ban + iptables | Xray API UUID 替换 |
| 活跃 IP 跟踪 | 无(全量日志分析) | 内存 map + 3 分钟 TTL |
| 误封防护 | 无 | 3 分钟观察期 |
| 解封机制 | Fail2ban unban | 恢复原始 UUID |
| 通知 | 无 | Telegram Bot 集成 |
| 限速 | 无 | per-client `SpeedLimit` (KB/s) |
| 调度方式 | cron 10s | goroutine + Ticker 10s |
| 依赖 | Fail2ban, iptables | Xray gRPC API |

View file

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