diff --git a/.gitignore b/.gitignore index 8b43b917..561bc850 100644 --- a/.gitignore +++ b/.gitignore @@ -39,9 +39,5 @@ docker-compose.override.yml # Ignore .env (Environment Variables) file .env -# Ignore local docs directory -/docs/ -/docs/* - # Ignore local git worktrees /.worktrees/ diff --git a/docs/Tasktracking/2026-04-24-fix-mariadb-json-each-compat.md b/docs/Tasktracking/2026-04-24-fix-mariadb-json-each-compat.md new file mode 100644 index 00000000..26d41d2c --- /dev/null +++ b/docs/Tasktracking/2026-04-24-fix-mariadb-json-each-compat.md @@ -0,0 +1,30 @@ +# 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 diff --git a/docs/install-fix-checklist.md b/docs/install-fix-checklist.md new file mode 100644 index 00000000..77da044c --- /dev/null +++ b/docs/install-fix-checklist.md @@ -0,0 +1,466 @@ +# 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。 + - 修复前要先定策略: + - **策略 A:SSL 失败仍允许安装** + - 但必须准确提示“当前为 HTTP” + - **策略 B:SSL 失败即安装失败** + - 真正把 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 地址 diff --git a/docs/install-issue-assessment.md b/docs/install-issue-assessment.md new file mode 100644 index 00000000..6a51b53e --- /dev/null +++ b/docs/install-issue-assessment.md @@ -0,0 +1,113 @@ +# 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 端口不可用、证书签发失败等场景下,脚本能给出准确结果而非伪成功 diff --git a/docs/install-logic.md b/docs/install-logic.md new file mode 100644 index 00000000..81409a44 --- /dev/null +++ b/docs/install-logic.md @@ -0,0 +1,485 @@ +# install.sh 逻辑文档 + +## 概述 + +`install.sh` 是 3x-ui 面板的安装脚本,负责在 Linux 服务器上完成以下工作: + +1. 安装系统依赖包 +2. 下载并解压 3x-ui 发行版 +3. 配置 systemd / OpenRC 服务 +4. 生成随机凭据(用户名、密码、端口、Web 路径) +5. 配置 SSL 证书(Let's Encrypt 域名证书、IP 证书、或自定义证书) +6. 显示安装结果和访问信息 + +--- + +## 全局配置 + +### 颜色变量 + +| 变量 | 值 | 用途 | +|---------|----------------|------------| +| `red` | `\033[0;31m` | 红色文本 | +| `green` | `\033[0;32m` | 绿色文本 | +| `blue` | `\033[0;34m` | 蓝色文本 | +| `yellow`| `\033[0;33m` | 黄色文本 | +| `plain` | `\033[0m` | 重置颜色 | + +### 路径变量 + +| 变量 | 默认值 | 说明 | +|-----------------|-------------------------|--------------------------| +| `xui_folder` | `/usr/local/x-ui` | x-ui 安装目录 | +| `xui_service` | `/etc/systemd/system` | systemd 服务文件目录 | + +可通过环境变量 `XUI_MAIN_FOLDER` 和 `XUI_SERVICE` 覆盖。 + +--- + +## 入口流程 + +``` +install.sh 被执行 + ├─ 检查 root 权限 + ├─ 检测操作系统发行版 + ├─ 检测 CPU 架构 + ├─ install_base() ← 安装系统依赖 + └─ install_x-ui($1) ← 主安装逻辑($1 为可选的版本号) +``` + +--- + +## 函数详解 + +### 1. root 权限检查(第 14-15 行) + +检查 `$EUID` 是否为 0。非 root 用户直接退出并提示使用 root 权限。 + +### 2. 操作系统检测(第 17-28 行) + +读取 `/etc/os-release` 或 `/usr/lib/os-release`,将 `$ID` 赋值给 `release` 变量。 + +支持的发行版: + +| 包管理器 | 发行版 | +|----------|--------| +| `apt` | ubuntu, debian, armbian | +| `dnf` | fedora, amzn, virtuozzo, rhel, almalinux, rocky, ol | +| `yum` | centos 7 | +| `pacman` | arch, manjaro, parch | +| `zypper` | opensuse-tumbleweed, opensuse-leap | +| `apk` | alpine | + +### 3. `arch()` — CPU 架构检测(第 30-41 行) + +通过 `uname -m` 映射到标准架构标识: + +| `uname -m` 输出 | 返回值 | +|------------------------|----------| +| x86_64, x64, amd64 | `amd64` | +| i*86, x86 | `386` | +| armv8*, arm64, aarch64 | `arm64` | +| armv7*, arm | `armv7` | +| armv6* | `armv6` | +| armv5* | `armv5` | +| s390x | `s390x` | +| 其他 | 退出报错 | + +### 4. IP/域名验证函数(第 46-57 行) + +| 函数 | 逻辑 | +|---------------|---------------------------------------------------| +| `is_ipv4()` | 正则匹配 `数字.数字.数字.数字` 格式 | +| `is_ipv6()` | 检查字符串是否包含 `:` | +| `is_ip()` | 调用 `is_ipv4` 或 `is_ipv6` | +| `is_domain()` | 正则匹配标准域名格式(含国际化域名 `xn--` 支持) | + +### 5. `is_port_in_use()` — 端口占用检测(第 60-74 行) + +按优先级尝试三种方式: + +1. `ss -ltn` — 检查监听端口 +2. `netstat -lnt` — 回退方案 +3. `lsof -nP -iTCP:端口 -sTCP:LISTEN` — 最后手段 + +任一命中即返回 0(端口被占用)。 + +### 6. `install_base()` — 安装基础依赖(第 76-104 行) + +根据 `$release` 使用对应的包管理器安装以下公共依赖: + +``` +curl, tar, tzdata, socat, ca-certificates, openssl +``` + +额外安装 `cron`(用于 acme.sh 自动续期,仅 apt 系列)。 + +- CentOS 7 使用 `yum`,其他版本使用 `dnf` +- 未识别的发行版默认回退到 `apt-get` + +### 7. `gen_random_string(length)` — 随机字符串生成(第 106-111 行) + +``` +openssl rand -base64(length*2) → 过滤 a-zA-Z0-9 → 截取前 length 个字符 +``` + +用于生成用户名、密码、Web 路径等随机值。 + +### 8. `install_acme()` — 安装 acme.sh(第 113-124 行) + +```bash +curl -s https://get.acme.sh | sh +``` + +安装到 `~/.acme.sh/` 目录。失败返回 1。 + +--- + +## SSL 证书管理 + +### 9. `setup_ssl_certificate(domain, server_ip, port, webBasePath)` — 域名 SSL(第 126-191 行) + +**用途**:为域名签发 Let's Encrypt 证书。 + +**流程**: + +``` +检查 acme.sh 是否已安装 + ├─ 未安装 → 调用 install_acme() + └─ 已安装 → 继续 + +创建证书目录:/root/cert/${domain}/ + +签发证书: + acme.sh --set-default-ca --server letsencrypt + acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 + ↳ 失败 → 清理并返回 1 + +安装证书: + acme.sh --installcert + --key-file /root/cert/${domain}/privkey.pem + --fullchain-file /root/cert/${domain}/fullchain.pem + --reloadcmd "systemctl restart x-ui" + +启用自动续期:acme.sh --upgrade --auto-upgrade + +设置文件权限: + privkey.pem → 600(仅所有者可读) + fullchain.pem → 644 + +配置面板证书路径: + x-ui cert -webCert fullchain.pem -webCertKey privkey.pem +``` + +**前提条件**:80 端口必须可从外网访问。 + +### 10. `setup_ip_certificate(ipv4, ipv6)` — IP 证书(第 195-343 行) + +**用途**:为 IP 地址签发 Let's Encrypt 短期证书(约 6 天有效期)。 + +**流程**: + +``` +检查 acme.sh +验证 IPv4 地址格式 + +创建证书目录:/root/cert/ip/ + +选择 HTTP-01 监听端口: + └─ 默认 80,用户可自定义 + └─ 循环检测端口占用,被占用则提示换端口 + +签发证书: + acme.sh --issue + -d ${ipv4} [-d ${ipv6}] + --standalone + --server letsencrypt + --certificate-profile shortlived + --days 6 + --httpport ${WebPort} + +安装证书: + acme.sh --installcert + --key-file /root/cert/ip/privkey.pem + --fullchain-file /root/cert/ip/fullchain.pem + --reloadcmd "systemctl restart x-ui || rc-service x-ui restart" + ↳ 通过检查文件是否存在(而非退出码)判断成功 + +启用自动续期 +设置文件权限 +配置面板证书路径 +``` + +**关键特性**: + +- 使用 `--certificate-profile shortlived` 配置文件,证书有效期约 6 天 +- acme.sh cron 任务会在到期前自动续期 +- 不依赖退出码判断安装成功(因为 reloadcmd 失败会导致非零退出) +- 支持 IPv4 + IPv6 双栈 + +### 11. `ssl_cert_issue()` — 手动 SSL 证书签发(第 346-509 行) + +**用途**:交互式域名证书签发,提供更多自定义选项。 + +**流程**: + +``` +读取当前面板的 webBasePath 和 port + +检查 acme.sh(不存在则安装) + +获取并验证用户输入的域名: + └─ 循环直到输入有效域名 + └─ 检查是否已存在该域名的证书 + +创建证书目录:/root/cert/${domain}/ + +选择端口(默认 80) + +临时停止面板(释放端口) + +签发证书: + acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} + +设置 reloadcmd(证书续期后执行的命令): + ├─ 默认:systemctl restart x-ui || rc-service x-ui restart + ├─ 选项 1:systemctl reload nginx ; systemctl restart x-ui + ├─ 选项 2:自定义命令 + └─ 选项 0:保持默认 + +安装证书并启用自动续期 + +启动面板 + +询问是否将证书应用到面板: + └─ 是 → x-ui cert -webCert ... -webCertKey ... + └─ 否 → 跳过 +``` + +**特点**: + +- 签发前会停止面板以释放端口 +- 支持自定义 reloadcmd(例如先 reload nginx 再重启 x-ui) +- 签发失败会自动重新启动面板 + +### 12. `prompt_and_setup_ssl(panel_port, web_base_path, server_ip)` — SSL 选择菜单(第 513-638 行) + +**用途**:安装时的统一 SSL 配置入口,提供三种选择。 + +**菜单**: + +``` +1. Let's Encrypt 域名证书(90 天有效期,自动续期) + └─ 调用 ssl_cert_issue() + └─ 从 acme.sh 列表提取域名作为 SSL_HOST + +2. Let's Encrypt IP 证书(6 天有效期,自动续期) ← 默认选项 + └─ 可选输入 IPv6 地址 + └─ 停止面板释放 80 端口 + └─ 调用 setup_ip_certificate(server_ip, ipv6) + └─ SSL_HOST = server_ip + +3. 自定义 SSL 证书(指定已有文件路径) + └─ 输入域名 + └─ 循环验证证书文件(存在、可读、非空) + └─ 循环验证私钥文件(存在、可读、非空) + └─ x-ui cert -webCert ... -webCertKey ... + └─ 提示用户自行管理续期 +``` + +**全局变量**:设置 `SSL_HOST` 供后续显示访问地址使用。 + +--- + +## 安装后配置 + +### 13. `config_after_install()` — 安装后配置(第 640-760 行) + +**用途**:首次安装后的凭据生成、端口设置、Web 路径生成、SSL 配置。 + +**流程图**: + +``` +读取当前面板设置: + - hasDefaultCredential(是否为默认凭据) + - webBasePath + - port + - cert(证书路径) + +获取服务器公网 IP: + └─ 依次尝试 6 个 API: + 1. api4.ipify.org + 2. ipv4.icanhazip.com + 3. v4.api.ipinfo.io/ip + 4. ipv4.myexternalip.com/raw + 5. 4.ident.me + 6. check-host.net/ip + +判断 webBasePath 是否足够长(≥4 字符): + + ┌─ webBasePath 过短 + │ + │ ├─ hasDefaultCredential == true(首次安装) + │ │ ├─ 生成随机 webBasePath(18 位) + │ │ ├─ 生成随机用户名(10 位) + │ │ ├─ 生成随机密码(10 位) + │ │ ├─ 询问是否自定义端口 + │ │ │ ├─ 是 → 用户输入端口 + │ │ │ └─ 否 → 随机生成 1024-62000 范围端口 + │ │ ├─ 应用设置:x-ui setting -username ... -password ... -port ... -webBasePath ... + │ │ ├─ prompt_and_setup_ssl() ← 必需 + │ │ └─ 显示完整凭据和访问地址 + │ │ + │ └─ hasDefaultCredential != true(非首次安装) + │ ├─ 生成新 webBasePath + │ ├─ 检查是否有证书: + │ │ ├─ 无 → prompt_and_setup_ssl()(推荐) + │ │ └─ 有 → 显示 HTTP 访问地址 + │ └─ 结束 + │ + └─ webBasePath 正常(≥4 字符) + + ├─ hasDefaultCredential == true + │ ├─ 生成随机用户名和密码 + │ ├─ 应用新凭据 + │ └─ 显示凭据 + │ + └─ hasDefaultCredential != true + └─ 提示凭据已正确设置 + + 再次检查证书: + ├─ 无证书 → prompt_and_setup_ssl()(推荐) + └─ 有证书 → 跳过 + +最后执行:x-ui migrate(数据库迁移) +``` + +--- + +## 主安装逻辑 + +### 14. `install_x-ui(version)` — 主安装函数(第 762-958 行) + +**参数**:`$1` 可选,指定安装版本号(如 `v2.3.5`)。 + +**流程**: + +``` +cd /usr/local/ + +┌─ 无版本参数(安装最新版) +│ ├─ 从 GitHub API 获取最新版本号 +│ │ └─ IPv4 失败时重试 curl -4 +│ └─ 下载:x-ui-linux-${arch}.tar.gz +│ +└─ 有版本参数 + ├─ 验证版本号 ≥ v2.3.5 + └─ 下载指定版本 + +同时下载 x-ui.sh 到 /usr/bin/x-ui-temp + +停止已有 x-ui 服务并删除旧安装目录 + +解压 tar.gz,设置执行权限 + +ARM 架构特殊处理: + armv5/armv6/armv7 → 重命名为 xray-linux-arm + +安装 x-ui.sh 到 /usr/bin/x-ui +创建日志目录 /var/log/x-ui/ + +调用 config_after_install() ← 生成凭据 + SSL + +etckeeper 兼容: + └─ 如果 /etc/.git 存在,将 x-ui.db 加入 .gitignore + +┌─ Alpine Linux +│ ├─ 下载 OpenRC 脚本 x-ui.rc → /etc/init.d/x-ui +│ ├─ rc-update add x-ui(启用开机自启) +│ └─ rc-service x-ui start +│ +└─ 其他系统(systemd) + ├─ 优先使用 tar.gz 中的服务文件 + │ ├─ x-ui.service ← 通用 + │ ├─ x-ui.service.debian ← Ubuntu/Debian + │ ├─ x-ui.service.arch ← Arch/Manjaro + │ └─ x-ui.service.rhel ← 其他(CentOS/Fedora 等) + │ + ├─ 如果 tar.gz 中没有,从 GitHub 下载对应文件 + │ + └─ 配置服务: + chown root:root x-ui.service + chmod 644 x-ui.service + systemctl daemon-reload + systemctl enable x-ui + systemctl start x-ui + +显示安装完成信息和子命令用法 +``` + +**子命令列表**(安装完成后显示): + +| 命令 | 功能 | +|-------------------|--------------------| +| `x-ui` | 打开管理菜单 | +| `x-ui start` | 启动面板 | +| `x-ui stop` | 停止面板 | +| `x-ui restart` | 重启面板 | +| `x-ui status` | 查看状态 | +| `x-ui settings` | 查看当前设置 | +| `x-ui enable` | 设置开机自启 | +| `x-ui disable` | 取消开机自启 | +| `x-ui log` | 查看日志 | +| `x-ui banlog` | 查看 Fail2ban 日志 | +| `x-ui update` | 更新 | +| `x-ui legacy` | 安装旧版本 | +| `x-ui install` | 安装 | +| `x-ui uninstall` | 卸载 | + +--- + +## 调用关系总结 + +``` +install.sh + │ + ├─ install_base() + │ └─ 根据发行版安装 curl, tar, tzdata, socat, ca-certificates, openssl + │ + └─ install_x-ui($1) + ├─ 下载 x-ui 发行版和 x-ui.sh + ├─ 解压、设置权限 + ├─ config_after_install() + │ ├─ gen_random_string() × 3(用户名/密码/Web路径) + │ ├─ 获取公网 IP + │ ├─ prompt_and_setup_ssl() + │ │ ├─ [选项1] ssl_cert_issue() + │ │ │ ├─ install_acme() + │ │ │ └─ acme.sh 签发/安装/续期域名证书 + │ │ ├─ [选项2] setup_ip_certificate() + │ │ │ ├─ install_acme() + │ │ │ └─ acme.sh 签发/安装/续期 IP 短期证书 + │ │ └─ [选项3] 用户提供自定义证书路径 + │ └─ x-ui migrate + └─ 配置系统服务(systemd 或 OpenRC) +``` + +--- + +## 关键设计决策 + +1. **强制 SSL**:首次安装时必须配置 SSL 证书(三种方式选一),确保面板通过 HTTPS 访问。 + +2. **随机化安全**:用户名、密码、端口、Web 路径全部随机生成,避免使用默认凭据。 + +3. **多 OS 兼容**:通过 `case` 语句适配 7 大包管理器体系,Alpine 使用 OpenRC,其余使用 systemd。 + +4. **IP 证书支持**:利用 Let's Encrypt 的 shortlived profile,为无域名场景提供 SSL 支持(6 天有效期,自动续期)。 + +5. **优雅降级**: + - GitHub API 失败时用 `curl -4` 重试 + - `ss` 不可用时回退到 `netstat`,再回退到 `lsof` + - tar.gz 中无服务文件时从 GitHub 下载 + - acme.sh reloadcmd 失败不阻止证书安装 + +6. **etckeeper 兼容**:自动将数据库文件加入 `/etc/.gitignore`,避免 etckeeper 追踪频繁变化的数据库。 diff --git a/docs/superpowers/plans/2026-04-02-json-settings.md b/docs/superpowers/plans/2026-04-02-json-settings.md new file mode 100644 index 00000000..9a0c45f8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-json-settings.md @@ -0,0 +1,927 @@ +# Panel Settings JSON Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- []`) syntax for tracking. + +**Goal:** Extract panel settings from the SQLite `settings` table into a standalone `x-ui.json` file, keeping `xrayTemplateConfig` in the database. + +**Architecture:** Replace the database-backed `getSetting`/`saveSetting` in `SettingService` with JSON file read/write. All public `Get*`/`Set*` methods keep their signatures unchanged so controllers, CLI, and sub package need zero changes. `xrayTemplateConfig` gets dedicated DB helper methods to bypass the JSON path. + +**Tech Stack:** Go, GORM/SQLite (retained for xrayTemplateConfig only), `encoding/json`, `os` + +--- + +## File Map + +| File | Action | Purpose | +|------|--------|---------| +| `config/config.go` | Modify | Add `GetSettingPath()` | +| `web/service/setting.go` | Modify | Replace DB-backed internals with JSON file I/O | +| `web/service/xray_setting.go` | Modify | Use direct DB helpers for xrayTemplateConfig | +| `web/service/setting_test.go` | Create | Unit tests for JSON settings | + +No changes needed: `main.go`, `database/db.go`, `database/model/model.go`, `web/entity/entity.go`, any controller, `sub/`, `xray/`. + +--- + +### Task 1: Add `GetSettingPath()` to `config/config.go` + +**Files:** +- Modify: `config/config.go:100` + +- [ ] **Step 1: Add `GetSettingPath()` function** + +Add after the existing `GetDBPath()` function at line 101: + +```go +// GetSettingPath returns the full path to the panel settings JSON file. +func GetSettingPath() string { + return fmt.Sprintf("%s/%s.json", GetDBFolderPath(), GetName()) +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /usr/x-ui/3x-ui && go build ./config/` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add config/config.go +git commit -m "feat(config): add GetSettingPath for JSON settings file" +``` + +--- + +### Task 2: Add JSON file I/O helpers to `web/service/setting.go` + +**Files:** +- Modify: `web/service/setting.go` + +- [ ] **Step 1: Add imports** + +Add `"os"` and `"github.com/mhsanaei/3x-ui/v2/config"` to the import block. The existing imports `"github.com/mhsanaei/3x-ui/v2/database"` and `"github.com/mhsanaei/3x-ui/v2/database/model"` will be kept for now (removed later when `getSetting`/`saveSetting` are replaced and `GetAllSetting` no longer queries DB). + +The import block becomes: + +```go +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "reflect" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v2/config" + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/util/common" + "github.com/mhsanaei/3x-ui/v2/util/random" + "github.com/mhsanaei/3x-ui/v2/util/reflect_util" + "github.com/mhsanaei/3x-ui/v2/web/entity" + "github.com/mhsanaei/3x-ui/v2/xray" +) +``` + +- [ ] **Step 2: Add `loadSettings()` and `saveSettings()` functions** + +Add these package-level functions before the `SettingService` struct (after `defaultValueMap`, around line 106): + +```go +// loadSettings reads the JSON settings file into a map. +// If the file doesn't exist, it creates one from defaultValueMap (excluding xrayTemplateConfig). +func loadSettings() (map[string]string, error) { + path := config.GetSettingPath() + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + settings := make(map[string]string) + for k, v := range defaultValueMap { + if k == "xrayTemplateConfig" { + continue + } + settings[k] = v + } + return settings, saveSettings(settings) + } + if err != nil { + return nil, err + } + var settings map[string]string + if err := json.Unmarshal(data, &settings); err != nil { + return nil, fmt.Errorf("failed to parse settings file %s: %w", path, err) + } + return settings, nil +} + +// saveSettings writes the settings map to the JSON file. +func saveSettings(settings map[string]string) error { + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + return os.WriteFile(config.GetSettingPath(), data, 0644) +} +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd /usr/x-ui/3x-ui && go build ./web/service/` +Expected: no errors (existing code still compiles with old + new functions coexisting) + +- [ ] **Step 4: Commit** + +```bash +git add web/service/setting.go +git commit -m "feat(service): add JSON file I/O helpers for settings" +``` + +--- + +### Task 3: Replace `getSetting`/`saveSetting` with JSON-based implementations + +**Files:** +- Modify: `web/service/setting.go:205-229` + +- [ ] **Step 1: Replace `getSetting`** + +Replace lines 205-213: + +```go +func (s *SettingService) getSetting(key string) (*model.Setting, error) { + db := database.GetDB() + setting := &model.Setting{} + err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error + if err != nil { + return nil, err + } + return setting, nil +} +``` + +With: + +```go +func (s *SettingService) getSetting(key string) (*model.Setting, error) { + settings, err := loadSettings() + if err != nil { + return nil, err + } + value, ok := settings[key] + if !ok { + return nil, fmt.Errorf("setting key %q not found", key) + } + return &model.Setting{Key: key, Value: value}, nil +} +``` + +- [ ] **Step 2: Replace `saveSetting`** + +Replace lines 215-229: + +```go +func (s *SettingService) saveSetting(key string, value string) error { + setting, err := s.getSetting(key) + db := database.GetDB() + if database.IsNotFound(err) { + return db.Create(&model.Setting{ + Key: key, + Value: value, + }).Error + } else if err != nil { + return err + } + setting.Key = key + setting.Value = value + return db.Save(setting).Error +} +``` + +With: + +```go +func (s *SettingService) saveSetting(key string, value string) error { + settings, err := loadSettings() + if err != nil { + return err + } + settings[key] = value + return saveSettings(settings) +} +``` + +- [ ] **Step 3: Replace `getString` to use JSON directly** + +Replace lines 231-243: + +```go +func (s *SettingService) getString(key string) (string, error) { + setting, err := s.getSetting(key) + if database.IsNotFound(err) { + value, ok := defaultValueMap[key] + if !ok { + return "", common.NewErrorf("key <%v> not in defaultValueMap", key) + } + return value, nil + } else if err != nil { + return "", err + } + return setting.Value, nil +} +``` + +With: + +```go +func (s *SettingService) getString(key string) (string, error) { + settings, err := loadSettings() + if err != nil { + return "", err + } + value, ok := settings[key] + if !ok { + defaultValue, hasDefault := defaultValueMap[key] + if !hasDefault { + return "", common.NewErrorf("key <%v> not in defaultValueMap", key) + } + return defaultValue, nil + } + return value, nil +} +``` + +- [ ] **Step 4: Replace `ResetSettings`** + +Replace lines 195-203: + +```go +func (s *SettingService) ResetSettings() error { + db := database.GetDB() + err := db.Where("1 = 1").Delete(model.Setting{}).Error + if err != nil { + return err + } + return db.Model(model.User{}). + Where("1 = 1").Error +} +``` + +With: + +```go +func (s *SettingService) ResetSettings() error { + // Delete the JSON settings file + err := os.Remove(config.GetSettingPath()) + if err != nil && !os.IsNotExist(err) { + return err + } + // Clear users table + db := database.GetDB() + return db.Where("1 = 1").Delete(model.User{}).Error +} +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cd /usr/x-ui/3x-ui && go build ./web/service/` +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add web/service/setting.go +git commit -m "feat(service): replace DB-backed settings with JSON file operations" +``` + +--- + +### Task 4: Update `GetAllSetting` and `UpdateAllSetting` to use JSON + +**Files:** +- Modify: `web/service/setting.go:120-193, 691-710` + +- [ ] **Step 1: Replace `GetAllSetting`** + +Replace lines 120-193: + +```go +func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) { + db := database.GetDB() + settings := make([]*model.Setting, 0) + err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error + if err != nil { + return nil, err + } + allSetting := &entity.AllSetting{} + t := reflect.TypeFor[entity.AllSetting]() + v := reflect.ValueOf(allSetting).Elem() + fields := reflect_util.GetFields(t) + + setSetting := func(key, value string) (err error) { + defer func() { + panicErr := recover() + if panicErr != nil { + err = errors.New(fmt.Sprint(panicErr)) + } + }() + + var found bool + var field reflect.StructField + for _, f := range fields { + if f.Tag.Get("json") == key { + field = f + found = true + break + } + } + + if !found { + // Some settings are automatically generated, no need to return to the front end to modify the user + return nil + } + + fieldV := v.FieldByName(field.Name) + switch t := fieldV.Interface().(type) { + case int: + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + fieldV.SetInt(n) + case string: + fieldV.SetString(value) + case bool: + fieldV.SetBool(value == "true") + default: + return common.NewErrorf("unknown field %v type %v", key, t) + } + return + } + + keyMap := map[string]bool{} + for _, setting := range settings { + err := setSetting(setting.Key, setting.Value) + if err != nil { + return nil, err + } + keyMap[setting.Key] = true + } + + for key, value := range defaultValueMap { + if keyMap[key] { + continue + } + err := setSetting(key, value) + if err != nil { + return nil, err + } + } + + return allSetting, nil +} +``` + +With: + +```go +func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) { + settings, err := loadSettings() + if err != nil { + return nil, err + } + allSetting := &entity.AllSetting{} + t := reflect.TypeFor[entity.AllSetting]() + v := reflect.ValueOf(allSetting).Elem() + fields := reflect_util.GetFields(t) + + setSetting := func(key, value string) (err error) { + defer func() { + panicErr := recover() + if panicErr != nil { + err = errors.New(fmt.Sprint(panicErr)) + } + }() + + var found bool + var field reflect.StructField + for _, f := range fields { + if f.Tag.Get("json") == key { + field = f + found = true + break + } + } + + if !found { + return nil + } + + fieldV := v.FieldByName(field.Name) + switch t := fieldV.Interface().(type) { + case int: + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + fieldV.SetInt(n) + case string: + fieldV.SetString(value) + case bool: + fieldV.SetBool(value == "true") + default: + return common.NewErrorf("unknown field %v type %v", key, t) + } + return + } + + keyMap := map[string]bool{} + for key, value := range settings { + err := setSetting(key, value) + if err != nil { + return nil, err + } + keyMap[key] = true + } + + for key, value := range defaultValueMap { + if key == "xrayTemplateConfig" { + continue + } + if keyMap[key] { + continue + } + err := setSetting(key, value) + if err != nil { + return nil, err + } + } + + return allSetting, nil +} +``` + +- [ ] **Step 2: Replace `UpdateAllSetting`** + +Replace lines 691-710: + +```go +func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { + if err := allSetting.CheckValid(); err != nil { + return err + } + + v := reflect.ValueOf(allSetting).Elem() + t := reflect.TypeFor[entity.AllSetting]() + fields := reflect_util.GetFields(t) + errs := make([]error, 0) + for _, field := range fields { + key := field.Tag.Get("json") + fieldV := v.FieldByName(field.Name) + value := fmt.Sprint(fieldV.Interface()) + err := s.saveSetting(key, value) + if err != nil { + errs = append(errs, err) + } + } + return common.Combine(errs...) +} +``` + +With: + +```go +func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { + if err := allSetting.CheckValid(); err != nil { + return err + } + + settings, err := loadSettings() + if err != nil { + return err + } + + v := reflect.ValueOf(allSetting).Elem() + t := reflect.TypeFor[entity.AllSetting]() + fields := reflect_util.GetFields(t) + for _, field := range fields { + key := field.Tag.Get("json") + fieldV := v.FieldByName(field.Name) + settings[key] = fmt.Sprint(fieldV.Interface()) + } + return saveSettings(settings) +} +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd /usr/x-ui/3x-ui && go build ./web/service/` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add web/service/setting.go +git commit -m "feat(service): migrate GetAllSetting/UpdateAllSetting to JSON" +``` + +--- + +### Task 5: Handle `xrayTemplateConfig` — dedicated DB accessors + +**Files:** +- Modify: `web/service/setting.go:273-274` +- Modify: `web/service/xray_setting.go:17-21` + +- [ ] **Step 1: Add dedicated DB accessor for xrayTemplateConfig** + +Add a new private function in `setting.go` (after the `saveSettings` function): + +```go +// getXrayTemplateConfigFromDB reads xrayTemplateConfig directly from the database. +func getXrayTemplateConfigFromDB() (string, error) { + db := database.GetDB() + setting := &model.Setting{} + err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(setting).Error + if err != nil { + return "", err + } + return setting.Value, nil +} + +// saveXrayTemplateConfigToDB writes xrayTemplateConfig directly to the database. +func saveXrayTemplateConfigToDB(value string) error { + db := database.GetDB() + setting := &model.Setting{} + err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(setting).Error + if database.IsNotFound(err) { + return db.Create(&model.Setting{ + Key: "xrayTemplateConfig", + Value: value, + }).Error + } + if err != nil { + return err + } + setting.Value = value + return db.Save(setting).Error +} +``` + +- [ ] **Step 2: Update `GetXrayConfigTemplate` to use DB directly** + +Replace line 273-274: + +```go +func (s *SettingService) GetXrayConfigTemplate() (string, error) { + return s.getString("xrayTemplateConfig") +} +``` + +With: + +```go +func (s *SettingService) GetXrayConfigTemplate() (string, error) { + config, err := getXrayTemplateConfigFromDB() + if err != nil { + // If not in DB, return the embedded default + return xrayTemplateConfig, nil + } + return config, nil +} +``` + +- [ ] **Step 3: Update `XraySettingService.SaveXraySetting` to use DB directly** + +Replace line 17-21 in `xray_setting.go`: + +```go +func (s *XraySettingService) SaveXraySetting(newXraySettings string) error { + if err := s.CheckXrayConfig(newXraySettings); err != nil { + return err + } + return s.SettingService.saveSetting("xrayTemplateConfig", newXraySettings) +} +``` + +With: + +```go +func (s *XraySettingService) SaveXraySetting(newXraySettings string) error { + if err := s.CheckXrayConfig(newXraySettings); err != nil { + return err + } + return saveXrayTemplateConfigToDB(newXraySettings) +} +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cd /usr/x-ui/3x-ui && go build ./web/service/` +Expected: no errors + +- [ ] **Step 5: Commit** + +```bash +git add web/service/setting.go web/service/xray_setting.go +git commit -m "feat(service): use direct DB access for xrayTemplateConfig" +``` + +--- + +### Task 6: Clean up unused imports + +**Files:** +- Modify: `web/service/setting.go` + +- [ ] **Step 1: Remove `database` and `model` imports if no longer needed** + +Check if `database` and `model` packages are still referenced in `setting.go` after all changes. `database` is still used by `ResetSettings()` (for `database.GetDB()` to clear users table). `model` is no longer needed in `setting.go` since `getSetting`/`saveSetting` no longer use `model.Setting`, and `ResetSettings` uses `model.User` which... actually check: `ResetSettings` references `model.User{}`. + +So `database` and `model` are still needed in `setting.go` for: +- `ResetSettings()` → `database.GetDB()` + `model.User{}` +- `getXrayTemplateConfigFromDB()` / `saveXrayTemplateConfigToDB()` → `database` + `model.Setting{}` + +No import cleanup needed. Skip this step. + +- [ ] **Step 2: Verify full build** + +Run: `cd /usr/x-ui/3x-ui && go build ./...` +Expected: no errors + +- [ ] **Step 3: Commit (only if changes were made)** + +```bash +git add web/service/setting.go +git commit -m "chore(service): clean up unused imports" +``` + +--- + +### Task 7: Write unit tests + +**Files:** +- Create: `web/service/setting_test.go` + +- [ ] **Step 1: Write tests for JSON settings** + +```go +package service + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v2/config" +) + +func setupTestSettings(t *testing.T) func() { + t.Helper() + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + return func() {} +} + +func TestLoadSettingsCreatesDefaults(t *testing.T) { + setupTestSettings(t) + + settings, err := loadSettings() + if err != nil { + t.Fatalf("loadSettings() error: %v", err) + } + + // Should contain default values + if settings["webPort"] != "2053" { + t.Errorf("expected webPort=2053, got %s", settings["webPort"]) + } + if settings["webBasePath"] != "/" { + t.Errorf("expected webBasePath=/, got %s", settings["webBasePath"]) + } + + // Should NOT contain xrayTemplateConfig + if _, exists := settings["xrayTemplateConfig"]; exists { + t.Error("xrayTemplateConfig should not be in JSON settings") + } + + // File should exist on disk + path := config.GetSettingPath() + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("settings file %s should have been created", path) + } +} + +func TestSaveAndLoadSettings(t *testing.T) { + setupTestSettings(t) + + settings := map[string]string{ + "webPort": "8080", + "webListen": "0.0.0.0", + } + err := saveSettings(settings) + if err != nil { + t.Fatalf("saveSettings() error: %v", err) + } + + loaded, err := loadSettings() + if err != nil { + t.Fatalf("loadSettings() error: %v", err) + } + + if loaded["webPort"] != "8080" { + t.Errorf("expected webPort=8080, got %s", loaded["webPort"]) + } + if loaded["webListen"] != "0.0.0.0" { + t.Errorf("expected webListen=0.0.0.0, got %s", loaded["webListen"]) + } +} + +func TestSettingServiceGetString(t *testing.T) { + setupTestSettings(t) + + svc := &SettingService{} + + // Should return default value when key not set + val, err := svc.getString("webPort") + if err != nil { + t.Fatalf("getString error: %v", err) + } + if val != "2053" { + t.Errorf("expected 2053, got %s", val) + } +} + +func TestSettingServiceSetAndGetString(t *testing.T) { + setupTestSettings(t) + + svc := &SettingService{} + + err := svc.setString("webPort", "9090") + if err != nil { + t.Fatalf("setString error: %v", err) + } + + val, err := svc.getString("webPort") + if err != nil { + t.Fatalf("getString error: %v", err) + } + if val != "9090" { + t.Errorf("expected 9090, got %s", val) + } +} + +func TestResetSettingsDeletesFile(t *testing.T) { + setupTestSettings(t) + + svc := &SettingService{} + + // Create settings first + _, err := svc.getString("webPort") + if err != nil { + t.Fatalf("getString error: %v", err) + } + + path := config.GetSettingPath() + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatal("settings file should exist before reset") + } + + // Note: ResetSettings also needs DB for users table. + // For this unit test, we just verify the JSON file deletion part works. + // Full integration test would need a test DB. + err = os.Remove(path) + if err != nil { + t.Fatalf("remove error: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("settings file should not exist after reset") + } + + // Re-loading should recreate defaults + settings, err := loadSettings() + if err != nil { + t.Fatalf("loadSettings after reset error: %v", err) + } + if settings["webPort"] != "2053" { + t.Errorf("expected default webPort=2053 after reset, got %s", settings["webPort"]) + } +} + +func TestSettingsFileFormat(t *testing.T) { + setupTestSettings(t) + + settings, err := loadSettings() + if err != nil { + t.Fatalf("loadSettings error: %v", err) + } + + path := config.GetSettingPath() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + // Verify it's valid JSON + var parsed map[string]string + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("settings file is not valid JSON: %v", err) + } + + // Verify pretty-printed (has newlines) + if !contains(data, '\n') { + t.Error("settings file should be pretty-printed with newlines") + } + + // Verify key count matches + if len(parsed) != len(settings) { + t.Errorf("parsed key count %d != loaded key count %d", len(parsed), len(settings)) + } + + _ = filepath.Base(path) // just to use the import +} + +func contains(data []byte, b byte) bool { + for _, d := range data { + if d == b { + return true + } + } + return false +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestLoadSettings -v` +Expected: PASS + +Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestSaveAndLoad -v` +Expected: PASS + +Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestSettingService -v` +Expected: PASS + +Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestReset -v` +Expected: PASS + +Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -run TestSettingsFile -v` +Expected: PASS + +- [ ] **Step 3: Run all tests** + +Run: `cd /usr/x-ui/3x-ui && go test ./web/service/ -v` +Expected: all PASS + +- [ ] **Step 4: Commit** + +```bash +git add web/service/setting_test.go +git commit -m "test(service): add unit tests for JSON settings" +``` + +--- + +### Task 8: Full build verification + +- [ ] **Step 1: Build entire project** + +Run: `cd /usr/x-ui/3x-ui && go build ./...` +Expected: no errors + +- [ ] **Step 2: Run `go vet`** + +Run: `cd /usr/x-ui/3x-ui && go vet ./...` +Expected: no issues + +- [ ] **Step 3: Final commit (only if fixes needed)** + +```bash +git add -A +git commit -m "chore: fix build issues from settings migration" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Panel settings in flat key-value JSON: Tasks 2-4 +- xrayTemplateConfig stays in DB: Task 5 +- All new installations (no migration): Task 2 Step 1 (auto-create from defaults) +- JSON file path: Task 1 (`GetSettingPath`) +- JSON auto-created on first run: Task 2 Step 1 (`loadSettings`) +- CLI compatibility: No changes to main.go, works via unchanged `SettingService` API +- Tests: Task 7 + +**2. Placeholder scan:** No TBD/TODO found. All code blocks contain complete implementations. + +**3. Type consistency:** +- `getSetting` still returns `(*model.Setting, error)` — reused by `getString` which checks `database.IsNotFound(err)`. After the change, `getSetting` returns a custom error when key not found (not `gorm.ErrRecordNotFound`). Need to verify: `getString` checks `database.IsNotFound(err)` which tests for `gorm.ErrRecordNotFound`. The new `getSetting` returns `fmt.Errorf(...)` which is NOT a gorm error. This means `getString` would NOT fall through to the default — it would return the error instead. + +**FIX:** `getString` must not rely on `database.IsNotFound`. The rewritten `getString` in Task 3 Step 3 already handles this correctly — it reads the map directly and checks `ok`, no longer calling `getSetting` or checking `database.IsNotFound`. Good. diff --git a/docs/superpowers/plans/2026-04-02-pre-release-install-update.md b/docs/superpowers/plans/2026-04-02-pre-release-install-update.md new file mode 100644 index 00000000..2af451aa --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-pre-release-install-update.md @@ -0,0 +1,193 @@ +# Pre-release Install/Update Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users choose between the latest Stable or Pre-release when installing or updating 3x-ui. + +**Architecture:** Replace the hardcoded `/releases/latest` API call with a `/releases` call that parses both stable and pre-release tags. Add an interactive prompt in both `install.sh` and `update.sh` so users pick which version to install. Functions are duplicated across files (matching existing conventions — no shared library). + +**Tech Stack:** Bash, GitHub REST API, grep/sed/awk for JSON parsing (no jq). + +--- + +### Task 1: Add `get_releases` helper + prompt to `install.sh` + +**Files:** +- Modify: `install.sh:874-911` + +- [ ] **Step 1: Add `get_releases` function before `install_x-ui()`** + +Insert this function before `install_x-ui()` (around line 874). It fetches all releases and parses out the latest stable and pre-release tags: + +```bash +get_releases() { + local releases_json + releases_json=$(curl -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases") + if [[ -z "$releases_json" ]]; then + echo -e "${yellow}正在尝试通过 IPv4 获取版本...${plain}" + releases_json=$(curl -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases") + if [[ -z "$releases_json" ]]; then + echo -e "${red}获取 x-ui 版本失败,可能是 GitHub API 限制,请稍后重试${plain}" + exit 1 + fi + fi + + # Parse first non-prerelease tag_name + latest_stable=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":false' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + + # Parse first prerelease tag_name + latest_prerelease=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":true' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + + if [[ -z "$latest_stable" && -z "$latest_prerelease" ]]; then + echo -e "${red}获取 x-ui 版本失败${plain}" + exit 1 + fi +} + +select_version() { + if [[ -n "$latest_stable" && -n "$latest_prerelease" ]]; then + echo "" + echo -e "${green}请选择要安装的版本:${plain}" + echo -e "${green}1)${plain} 最新稳定版: ${latest_stable}" + echo -e "${green}2)${plain} 最新预发布版: ${latest_prerelease}" + read -rp "请输入选择 [1-2]: " version_choice + while [[ "$version_choice" != "1" && "$version_choice" != "2" ]]; do + read -rp "无效输入,请重新输入 [1-2]: " version_choice + done + if [[ "$version_choice" == "1" ]]; then + tag_version="$latest_stable" + else + tag_version="$latest_prerelease" + fi + elif [[ -n "$latest_stable" ]]; then + tag_version="$latest_stable" + else + tag_version="$latest_prerelease" + fi +} +``` + +- [ ] **Step 2: Replace the no-argument release fetch block in `install_x-ui()`** + +Replace lines 879-888: + +```bash + tag_version=$(curl -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [[ ! -n "$tag_version" ]]; then + echo -e "${yellow}正在尝试通过 IPv4 获取版本...${plain}" + tag_version=$(curl -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [[ ! -n "$tag_version" ]]; then + echo -e "${red}获取 x-ui 版本失败,可能是 GitHub API 限制,请稍后重试${plain}" + exit 1 + fi + fi + echo -e "获取到 x-ui 最新版本:${tag_version},开始安装..." +``` + +With: + +```bash + get_releases + select_version + echo -e "获取到 x-ui 版本:${tag_version},开始安装..." +``` + +- [ ] **Step 3: Verify the script still works for the specific-version path** + +Read the full `install_x-ui()` function and confirm the `else` branch (lines 894-911, where `$1` is provided) is untouched. + +- [ ] **Step 4: Commit** + +```bash +git add install.sh +git commit -m "feat(install): add pre-release version selection prompt" +``` + +### Task 2: Add `get_releases` helper + prompt to `update.sh` + +**Files:** +- Modify: `update.sh:748-767` + +- [ ] **Step 1: Add `get_releases` and `select_version` functions before `update_x-ui()`** + +Insert the same two functions before `update_x-ui()` (around line 748). Identical logic to install.sh except the prompt text says "更新" (update) instead of "安装" (install): + +```bash +get_releases() { + local releases_json + releases_json=$(${curl_bin} -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases" 2>/dev/null) + if [[ -z "$releases_json" ]]; then + echo -e "${yellow}Trying to fetch version with IPv4...${plain}" + releases_json=$(${curl_bin} -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases" 2>/dev/null) + if [[ -z "$releases_json" ]]; then + _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later" + fi + fi + + latest_stable=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":false' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + latest_prerelease=$(echo "$releases_json" | tr '{' '\n' | grep '"prerelease":true' | head -1 | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + + if [[ -z "$latest_stable" && -z "$latest_prerelease" ]]; then + _fail "ERROR: Failed to fetch x-ui version" + fi +} + +select_version() { + if [[ -n "$latest_stable" && -n "$latest_prerelease" ]]; then + echo "" + echo -e "${green}Which version do you want to update to?${plain}" + echo -e "${green}1)${plain} Latest Stable: ${latest_stable}" + echo -e "${green}2)${plain} Latest Pre-release: ${latest_prerelease}" + read -rp "Please enter your choice [1-2]: " version_choice + while [[ "$version_choice" != "1" && "$version_choice" != "2" ]]; do + read -rp "Invalid input, please re-enter [1-2]: " version_choice + done + if [[ "$version_choice" == "1" ]]; then + tag_version="$latest_stable" + else + tag_version="$latest_prerelease" + fi + elif [[ -n "$latest_stable" ]]; then + tag_version="$latest_stable" + else + tag_version="$latest_prerelease" + fi +} +``` + +Note: `update.sh` uses `${curl_bin}` instead of bare `curl` — the helper respects this. + +- [ ] **Step 2: Replace the release fetch block in `update_x-ui()`** + +Replace lines 760-768: + +```bash + tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [[ ! -n "$tag_version" ]]; then + echo -e "${yellow}Trying to fetch version with IPv4...${plain}" + tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/Sora39831/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [[ ! -n "$tag_version" ]]; then + _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later" + fi + fi + echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..." +``` + +With: + +```bash + get_releases + select_version + echo -e "Got x-ui version: ${tag_version}, beginning the installation..." +``` + +- [ ] **Step 3: Verify the rest of `update_x-ui()` is unchanged** + +Confirm lines 769+ (download, cleanup, install) remain intact. + +- [ ] **Step 4: Commit** + +```bash +git add update.sh +git commit -m "feat(update): add pre-release version selection prompt" +``` diff --git a/docs/superpowers/plans/2026-04-07-cloudflare-cdn-assets.md b/docs/superpowers/plans/2026-04-07-cloudflare-cdn-assets.md new file mode 100644 index 00000000..d7676b24 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-cloudflare-cdn-assets.md @@ -0,0 +1,819 @@ +# 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 + + +src: url('{{ asset "Vazirmatn-UI-NL-Regular.woff2" }}') format('woff2'); + + + + + + + + +``` + +- [ ] **Step 4: Replace page-specific asset URLs** + +Apply the same conversion in these files: + +`web/html/component/aPersianDatepicker.html` + +```gotemplate + + + +``` + +`web/html/inbounds.html` + +```gotemplate + + + + + +``` + +`web/html/settings/panel/subscription/subpage.html` + +```gotemplate + + + + + + + +``` + +`web/html/settings.html` + +```gotemplate + + + +``` + +`web/html/xray.html` + +```gotemplate + + + + + + + + + + + + + + + +``` + +- [ ] **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. diff --git a/docs/superpowers/plans/2026-04-09-install-ssl-domain-port-fix.md b/docs/superpowers/plans/2026-04-09-install-ssl-domain-port-fix.md new file mode 100644 index 00000000..81e71bcf --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-install-ssl-domain-port-fix.md @@ -0,0 +1,218 @@ +# 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. diff --git a/docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md b/docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md new file mode 100644 index 00000000..09507350 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md @@ -0,0 +1,721 @@ +# 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" +``` + diff --git a/docs/superpowers/plans/2026-04-10-multi-node-shared-control-implementation.md b/docs/superpowers/plans/2026-04-10-multi-node-shared-control-implementation.md new file mode 100644 index 00000000..d93a2f69 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-multi-node-shared-control-implementation.md @@ -0,0 +1,1970 @@ +# Multi-Node Shared Control 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-node shared-control mode to `3x-ui` where `master` owns shared-account writes, `worker` nodes rebuild local Xray config from synchronized snapshots, and all nodes flush traffic deltas back to MariaDB without counter loss. + +**Architecture:** Keep the current `controller -> service -> database -> local Xray` flow intact. Add node-role config, shared metadata tables, cache-backed worker sync, and a durable traffic delta flush path around the existing services instead of changing Xray protocol behavior. In shared mode, workers never write shared account definitions directly, and traffic reconciliation that mutates enable/expiry state runs only on `master`. + +**Tech Stack:** Go, GORM, MariaDB, SQLite compatibility mode for legacy single-node installs, existing `web/service`, `web/job`, `web/controller`, `xray` models, shell installers `install.sh` and `x-ui.sh`. + +--- + +## File Map + +### Existing files to modify + +- `config/config.go` — add typed node-role config readers, validation, and runtime file path helpers. +- `config/config_test.go` — cover node config defaults, validation, and shared runtime file paths. +- `main.go` — validate node config at startup, extend `setting` CLI flags, and print node settings in `setting -show`. +- `database/db.go` — migrate shared metadata models and seed the shared version row. +- `database/db_test.go` — verify metadata tables, version helpers, and node-state upsert behavior. +- `web/service/inbound.go` — enforce master-only shared writes, bump shared version on successful shared mutations, and extract shared traffic reconciliation from `AddTraffic`. +- `web/service/xray.go` — split config building from DB reads so worker sync can rebuild from cached snapshots. +- `web/web.go` — start worker sync / master heartbeat loops and the traffic flush loop with server lifecycle context. +- `web/job/xray_traffic_job.go` — branch shared-mode traffic collection away from direct DB writes. +- `xray/client_traffic.go` — add a composite unique key for `(inbound_id, email)` so atomic delta upserts are safe. +- `x-ui.sh` — show node config and add minimal node-management actions. +- `install.sh` — add fresh-install prompts for MariaDB and node role while preserving upgrade behavior. +- `README.md` — add high-level multi-node shared-control documentation. +- `README.zh_CN.md` — add Chinese operator guidance. + +### New files to create + +- `database/model/shared_state.go` — `shared_accounts_version` metadata model. +- `database/model/node_state.go` — node heartbeat / sync status model. +- `database/shared_state.go` — shared version and node-state repository helpers. +- `web/service/node_guard.go` — node-role helpers and `RequireMaster`. +- `web/service/node_guard_test.go` — node-role guard and transactional version-bump tests. +- `web/service/node_cache.go` — shared snapshot load/save helpers. +- `web/service/node_sync.go` — worker snapshot polling, cache refresh, heartbeat, and node-state updates. +- `web/service/node_sync_test.go` — snapshot persistence and sync-loop unit tests. +- `web/service/traffic_pending.go` — durable pending traffic delta store. +- `web/service/traffic_flush.go` — shared-mode delta collection and batch flush service. +- `web/service/traffic_flush_test.go` — pending delta merge and flush success/failure coverage. +- `docs/multi-node-sync.md` — operator runbook and manual verification checklist. +- `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md` — task-by-task execution tracker with mode and commit checkpoints. + +### Runtime files created by the feature + +- `/etc/x-ui/x-ui.json` — now also stores `nodeRole`, `nodeId`, `syncInterval`, `trafficFlushInterval`. +- `/etc/x-ui/shared-cache.json` — last good shared account snapshot used by workers. +- `/etc/x-ui/traffic-pending.json` — durable queue of unflushed traffic deltas. + +--- + +### Task 1: Add node config, runtime file paths, and startup validation + +**Files:** +- Modify: `config/config.go` +- Modify: `config/config_test.go` +- Modify: `main.go` +**Execution Mode:** Inline + +- [ ] **Step 1: Write the failing config tests** + +Add these tests to `config/config_test.go`: + +```go +func writeTestSettingsFile(t *testing.T, settings map[string]any) { + t.Helper() + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatalf("MarshalIndent error: %v", err) + } + if err := os.WriteFile(GetSettingPath(), data, 0644); err != nil { + t.Fatalf("WriteFile error: %v", err) + } +} + +func TestGetNodeConfigFromJSONDefaults(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + writeTestSettingsFile(t, map[string]any{}) + + cfg := GetNodeConfigFromJSON() + if cfg.Role != NodeRoleMaster { + t.Fatalf("expected default role %q, got %q", NodeRoleMaster, cfg.Role) + } + if cfg.NodeID != "" { + t.Fatalf("expected empty default node id, got %q", cfg.NodeID) + } + if cfg.SyncIntervalSeconds != 30 { + t.Fatalf("expected default sync interval 30, got %d", cfg.SyncIntervalSeconds) + } + if cfg.TrafficFlushSeconds != 10 { + t.Fatalf("expected default traffic flush interval 10, got %d", cfg.TrafficFlushSeconds) + } +} + +func TestValidateNodeConfigWorkerRequiresNodeID(t *testing.T) { + err := ValidateNodeConfig(NodeConfig{ + Role: NodeRoleWorker, + SyncIntervalSeconds: 30, + TrafficFlushSeconds: 10, + }, DBConfig{Type: "mariadb"}) + if err == nil { + t.Fatal("expected worker without node id to fail validation") + } +} + +func TestValidateNodeConfigWorkerRequiresMariaDB(t *testing.T) { + err := ValidateNodeConfig(NodeConfig{ + Role: NodeRoleWorker, + NodeID: "worker-1", + SyncIntervalSeconds: 30, + TrafficFlushSeconds: 10, + }, DBConfig{Type: "sqlite"}) + if err == nil { + t.Fatal("expected worker on sqlite to fail validation") + } +} + +func TestSharedRuntimeFilePaths(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + + if got := GetSharedCachePath(); got != filepath.Join(tmpDir, "shared-cache.json") { + t.Fatalf("unexpected shared cache path: %s", got) + } + if got := GetTrafficPendingPath(); got != filepath.Join(tmpDir, "traffic-pending.json") { + t.Fatalf("unexpected traffic pending path: %s", got) + } +} +``` + +- [ ] **Step 2: Run the config test subset and confirm it fails** + +Run: + +```bash +go test ./config -run 'Test(GetNodeConfigFromJSONDefaults|ValidateNodeConfigWorkerRequiresNodeID|ValidateNodeConfigWorkerRequiresMariaDB|SharedRuntimeFilePaths)' -v +``` + +Expected: FAIL with undefined `NodeConfig`, `NodeRoleMaster`, `GetNodeConfigFromJSON`, `ValidateNodeConfig`, `GetSharedCachePath`, or `GetTrafficPendingPath`. + +- [ ] **Step 3: Implement node config and runtime path helpers** + +Add these types and helpers in `config/config.go`: + +```go +type NodeRole string + +const ( + NodeRoleMaster NodeRole = "master" + NodeRoleWorker NodeRole = "worker" +) + +type NodeConfig struct { + Role NodeRole + NodeID string + SyncIntervalSeconds int + TrafficFlushSeconds int +} + +func GetSharedCachePath() string { + return filepath.Join(GetDBFolderPath(), "shared-cache.json") +} + +func GetTrafficPendingPath() string { + return filepath.Join(GetDBFolderPath(), "traffic-pending.json") +} + +func readGroupedInt(settings map[string]any, key string, fallback int) int { + readInt := func(value any) (int, bool) { + switch v := value.(type) { + case float64: + return int(v), true + case int: + return v, true + case string: + i, err := strconv.Atoi(v) + if err == nil { + return i, true + } + } + return 0, false + } + if groups, ok := settingGroupAliases[key]; ok { + for _, groupName := range groups { + if group, ok := settings[groupName].(map[string]any); ok { + if value, ok := readInt(group[key]); ok { + return value + } + } + } + } + if value, ok := readInt(settings[key]); ok { + return value + } + return fallback +} + +func GetNodeConfigFromJSON() NodeConfig { + data, err := os.ReadFile(GetSettingPath()) + if err != nil { + return NodeConfig{Role: NodeRoleMaster, SyncIntervalSeconds: 30, TrafficFlushSeconds: 10} + } + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + return NodeConfig{Role: NodeRoleMaster, SyncIntervalSeconds: 30, TrafficFlushSeconds: 10} + } + role := readGroupedString(settings, "nodeRole") + if role == "" { + role = string(NodeRoleMaster) + } + return NodeConfig{ + Role: NodeRole(role), + NodeID: readGroupedString(settings, "nodeId"), + SyncIntervalSeconds: readGroupedInt(settings, "syncInterval", 30), + TrafficFlushSeconds: readGroupedInt(settings, "trafficFlushInterval", 10), + } +} + +func ValidateNodeConfig(nodeCfg NodeConfig, dbCfg DBConfig) error { + switch nodeCfg.Role { + case NodeRoleMaster, NodeRoleWorker: + default: + return fmt.Errorf("invalid nodeRole %q", nodeCfg.Role) + } + if nodeCfg.Role == NodeRoleWorker && nodeCfg.NodeID == "" { + return fmt.Errorf("worker mode requires nodeId") + } + if nodeCfg.Role == NodeRoleWorker && dbCfg.Type != "mariadb" { + return fmt.Errorf("worker mode requires mariadb") + } + if nodeCfg.SyncIntervalSeconds <= 0 { + return fmt.Errorf("syncInterval must be positive") + } + if nodeCfg.TrafficFlushSeconds <= 0 { + return fmt.Errorf("trafficFlushInterval must be positive") + } + return nil +} +``` + +Also extend `settingGroupAliases` so these keys can be read from both top-level JSON and the legacy `other` group: + +```go +"nodeRole": {"other"}, +"nodeId": {"other"}, +"syncInterval": {"other"}, +"trafficFlushInterval": {"other"}, +``` + +- [ ] **Step 4: Validate node config at startup and expose CLI setters** + +Patch `main.go` in two places. + +At startup, validate config before DB init: + +```go +func runWebServer() { + log.Printf("Starting %v %v", config.GetName(), config.GetVersion()) + + dbCfg := config.GetDBConfigFromJSON() + nodeCfg := config.GetNodeConfigFromJSON() + if err := config.ValidateNodeConfig(nodeCfg, dbCfg); err != nil { + log.Fatalf("invalid node configuration: %v", err) + } + + switch config.GetLogLevel() { +``` + +Extend `setting` flags and `setting -show` output: + +```go +var nodeRoleFlag string +var nodeIDFlag string +var syncIntervalFlag int +var trafficFlushIntervalFlag int + +settingCmd.StringVar(&nodeRoleFlag, "nodeRole", "", "Set node role (master or worker)") +settingCmd.StringVar(&nodeIDFlag, "nodeId", "", "Set node identifier") +settingCmd.IntVar(&syncIntervalFlag, "syncInterval", 0, "Set shared sync interval in seconds") +settingCmd.IntVar(&trafficFlushIntervalFlag, "trafficFlushInterval", 0, "Set traffic flush interval in seconds") +``` + +```go +func showSetting(show bool) { + if show { + nodeCfg := config.GetNodeConfigFromJSON() + fmt.Println("nodeRole:", nodeCfg.Role) + fmt.Println("nodeId:", nodeCfg.NodeID) + fmt.Println("syncInterval:", nodeCfg.SyncIntervalSeconds) + fmt.Println("trafficFlushInterval:", nodeCfg.TrafficFlushSeconds) + } +} +``` + +When setters are used, validate before writing: + +```go +candidate := config.GetNodeConfigFromJSON() +if nodeRoleFlag != "" { + candidate.Role = config.NodeRole(nodeRoleFlag) +} +if nodeIDFlag != "" { + candidate.NodeID = nodeIDFlag +} +if syncIntervalFlag > 0 { + candidate.SyncIntervalSeconds = syncIntervalFlag +} +if trafficFlushIntervalFlag > 0 { + candidate.TrafficFlushSeconds = trafficFlushIntervalFlag +} +if err := config.ValidateNodeConfig(candidate, config.GetDBConfigFromJSON()); err != nil { + fmt.Println("Invalid node settings:", err) + return +} +``` + +Then persist with `WriteSettingToJSON`: + +```go +if nodeRoleFlag != "" { + _ = config.WriteSettingToJSON("nodeRole", nodeRoleFlag) +} +if nodeIDFlag != "" { + _ = config.WriteSettingToJSON("nodeId", nodeIDFlag) +} +if syncIntervalFlag > 0 { + _ = config.WriteSettingToJSON("syncInterval", strconv.Itoa(syncIntervalFlag)) +} +if trafficFlushIntervalFlag > 0 { + _ = config.WriteSettingToJSON("trafficFlushInterval", strconv.Itoa(trafficFlushIntervalFlag)) +} +``` + +- [ ] **Step 5: Run config tests and package discovery** + +Run: + +```bash +go test ./config -run 'Test(GetNodeConfigFromJSONDefaults|ValidateNodeConfigWorkerRequiresNodeID|ValidateNodeConfigWorkerRequiresMariaDB|SharedRuntimeFilePaths|GetDBConfigFromJSONSupportsModulePurposeLayout|WriteSettingToJSONUsesModulePurposeGroup)' -v +go test ./... -run TestNonExistent -count=0 +``` + +Expected: + +- the focused `./config` tests PASS +- package discovery succeeds without running unrelated tests + +- [ ] **Step 6: Checkpoint Commit the config work** + +Run: + +```bash +git add config/config.go config/config_test.go main.go +git commit -m "feat: add node config and startup validation" +``` + +After commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md`: + +- mark Task 1 complete +- record the short commit hash beside Task 1 + +--- + +### Task 2: Add shared metadata models and repository helpers + +**Files:** +- Create: `database/model/shared_state.go` +- Create: `database/model/node_state.go` +- Create: `database/shared_state.go` +- Modify: `database/db.go` +- Modify: `database/db_test.go` +**Execution Mode:** Inline + +- [ ] **Step 1: Write the failing database tests** + +Add these tests to `database/db_test.go`: + +```go +func TestInitDB_CreatesSharedMetadataTables(t *testing.T) { + setupTestDB(t) + + for _, table := range []string{"shared_states", "node_states"} { + var count int64 + if err := db.Table(table).Count(&count).Error; err != nil { + t.Fatalf("table %s should exist: %v", table, err) + } + } +} + +func TestBumpSharedAccountsVersion(t *testing.T) { + setupTestDB(t) + + version, err := GetSharedAccountsVersion(GetDB()) + if err != nil { + t.Fatalf("GetSharedAccountsVersion error: %v", err) + } + if version != 0 { + t.Fatalf("expected seeded version 0, got %d", version) + } + + tx := GetDB().Begin() + if err := BumpSharedAccountsVersion(tx); err != nil { + t.Fatalf("BumpSharedAccountsVersion error: %v", err) + } + if err := tx.Commit().Error; err != nil { + t.Fatalf("Commit error: %v", err) + } + + version, err = GetSharedAccountsVersion(GetDB()) + if err != nil { + t.Fatalf("GetSharedAccountsVersion error: %v", err) + } + if version != 1 { + t.Fatalf("expected bumped version 1, got %d", version) + } +} + +func TestUpsertNodeState(t *testing.T) { + setupTestDB(t) + + state := &model.NodeState{ + NodeID: "worker-1", + NodeRole: "worker", + LastSeenVersion: 7, + LastError: "dial tcp timeout", + } + if err := UpsertNodeState(GetDB(), state); err != nil { + t.Fatalf("UpsertNodeState error: %v", err) + } + + var stored model.NodeState + if err := GetDB().First(&stored, "node_id = ?", "worker-1").Error; err != nil { + t.Fatalf("lookup node state failed: %v", err) + } + if stored.LastSeenVersion != 7 { + t.Fatalf("expected last seen version 7, got %d", stored.LastSeenVersion) + } + if stored.LastError != "dial tcp timeout" { + t.Fatalf("expected last error to round-trip, got %q", stored.LastError) + } +} +``` + +- [ ] **Step 2: Run the database test subset and confirm it fails** + +Run: + +```bash +go test ./database -run 'Test(InitDB_CreatesSharedMetadataTables|BumpSharedAccountsVersion|UpsertNodeState)' -v +``` + +Expected: FAIL with missing `model.NodeState`, `GetSharedAccountsVersion`, `BumpSharedAccountsVersion`, or `UpsertNodeState`. + +- [ ] **Step 3: Add metadata models and DB helpers** + +Create `database/model/shared_state.go`: + +```go +package model + +type SharedState struct { + Key string `json:"key" gorm:"primaryKey"` + Version int64 `json:"version" gorm:"not null;default:0"` + UpdatedAt int64 `json:"updatedAt"` +} +``` + +Create `database/model/node_state.go`: + +```go +package model + +type NodeState struct { + NodeID string `json:"nodeId" gorm:"primaryKey"` + NodeRole string `json:"nodeRole" gorm:"not null"` + LastSyncAt int64 `json:"lastSyncAt"` + LastHeartbeatAt int64 `json:"lastHeartbeatAt"` + LastSeenVersion int64 `json:"lastSeenVersion"` + LastError string `json:"lastError"` + UpdatedAt int64 `json:"updatedAt"` +} +``` + +Create `database/shared_state.go`: + +```go +package database + +import ( + "time" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "gorm.io/gorm" +) + +const SharedAccountsVersionKey = "shared_accounts_version" + +func txOrDB(tx *gorm.DB) *gorm.DB { + if tx != nil { + return tx + } + return GetDB() +} + +func seedSharedAccountsVersion(tx *gorm.DB) error { + return txOrDB(tx).FirstOrCreate(&model.SharedState{ + Key: SharedAccountsVersionKey, + Version: 0, + UpdatedAt: time.Now().Unix(), + }, &model.SharedState{Key: SharedAccountsVersionKey}).Error +} + +func GetSharedAccountsVersion(tx *gorm.DB) (int64, error) { + state := &model.SharedState{} + err := txOrDB(tx).First(state, "key = ?", SharedAccountsVersionKey).Error + if err != nil { + return 0, err + } + return state.Version, nil +} + +func BumpSharedAccountsVersion(tx *gorm.DB) error { + now := time.Now().Unix() + return txOrDB(tx).Model(&model.SharedState{}). + Where("key = ?", SharedAccountsVersionKey). + Updates(map[string]any{ + "version": gorm.Expr("version + 1"), + "updated_at": now, + }).Error +} + +func UpsertNodeState(tx *gorm.DB, state *model.NodeState) error { + state.UpdatedAt = time.Now().Unix() + return txOrDB(tx).Save(state).Error +} +``` + +- [ ] **Step 4: Register metadata models and seed the shared version row** + +Patch `database/db.go`: + +```go +func initModels() error { + models := []any{ + &model.User{}, + &model.Inbound{}, + &model.OutboundTraffics{}, + &model.Setting{}, + &model.InboundClientIps{}, + &xray.ClientTraffic{}, + &model.HistoryOfSeeders{}, + &model.SharedState{}, + &model.NodeState{}, + } + for _, model := range models { + if err := db.AutoMigrate(model); err != nil { + return err + } + } + if err := seedSharedAccountsVersion(db); err != nil { + return err + } + return nil +} +``` + +- [ ] **Step 5: Run the database tests** + +Run: + +```bash +go test ./database -run 'Test(InitDB_CreatesSharedMetadataTables|BumpSharedAccountsVersion|UpsertNodeState|InitDB_CreatesTables|InitDB_Idempotent)' -v +``` + +Expected: PASS + +- [ ] **Step 6: Checkpoint Commit the metadata work** + +Run: + +```bash +git add database/model/shared_state.go database/model/node_state.go database/shared_state.go database/db.go database/db_test.go +git commit -m "feat: add shared metadata models and helpers" +``` + +After commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md`: + +- mark Task 2 complete +- record the short commit hash beside Task 2 + +--- + +### Task 3: Enforce master-only shared writes and transactional version bumping + +**Files:** +- Create: `web/service/node_guard.go` +- Create: `web/service/node_guard_test.go` +- Modify: `web/service/inbound.go` +**Execution Mode:** Inline + +- [ ] **Step 1: Write the failing node-guard tests** + +Create `web/service/node_guard_test.go` with: + +```go +package service + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v2/config" + "github.com/mhsanaei/3x-ui/v2/database" +) + +func writeNodeGuardSettings(t *testing.T, settings map[string]any) { + t.Helper() + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatalf("MarshalIndent error: %v", err) + } + if err := os.WriteFile(config.GetSettingPath(), data, 0644); err != nil { + t.Fatalf("WriteFile error: %v", err) + } +} + +func setupNodeGuardDB(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + if err := database.InitDBWithPath(filepath.Join(tmpDir, "service.db")); err != nil { + t.Fatalf("InitDBWithPath error: %v", err) + } + t.Cleanup(func() { database.CloseDB() }) +} + +func TestRequireMasterRejectsWorker(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + writeNodeGuardSettings(t, map[string]any{ + "dbType": "mariadb", + "nodeRole": "worker", + "nodeId": "worker-1", + }) + + if err := RequireMaster(); err == nil { + t.Fatal("expected worker mode to be rejected") + } +} + +func TestRequireMasterAllowsMaster(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + writeNodeGuardSettings(t, map[string]any{ + "dbType": "mariadb", + "nodeRole": "master", + }) + + if err := RequireMaster(); err != nil { + t.Fatalf("expected master mode to pass: %v", err) + } +} + +func TestBumpSharedAccountsVersionRollsBackWithTransaction(t *testing.T) { + setupNodeGuardDB(t) + + tx := database.GetDB().Begin() + if err := database.BumpSharedAccountsVersion(tx); err != nil { + t.Fatalf("BumpSharedAccountsVersion error: %v", err) + } + tx.Rollback() + + version, err := database.GetSharedAccountsVersion(database.GetDB()) + if err != nil { + t.Fatalf("GetSharedAccountsVersion error: %v", err) + } + if version != 0 { + t.Fatalf("expected rolled-back version to remain 0, got %d", version) + } +} +``` + +- [ ] **Step 2: Run the guard tests and confirm they fail** + +Run: + +```bash +go test ./web/service -run 'Test(RequireMasterRejectsWorker|RequireMasterAllowsMaster|BumpSharedAccountsVersionRollsBackWithTransaction)' -v +``` + +Expected: FAIL with undefined `RequireMaster`. + +- [ ] **Step 3: Add shared-write guard helpers** + +Create `web/service/node_guard.go`: + +```go +package service + +import ( + "errors" + + "github.com/mhsanaei/3x-ui/v2/config" +) + +var ErrSharedWriteRequiresMaster = errors.New("shared-account writes are only allowed on master nodes") + +func IsWorker() bool { + return config.GetNodeConfigFromJSON().Role == config.NodeRoleWorker +} + +func IsMaster() bool { + return !IsWorker() +} + +func RequireMaster() error { + if IsWorker() { + return ErrSharedWriteRequiresMaster + } + return nil +} + +func IsSharedModeEnabled() bool { + return config.GetDBConfigFromJSON().Type == "mariadb" +} +``` + +- [ ] **Step 4: Guard shared writes and bump shared version inside successful transactions** + +Patch `web/service/inbound.go` with one reusable helper: + +```go +func ensureSharedWriteAllowed() error { + return RequireMaster() +} + +func bumpSharedVersion(tx *gorm.DB) error { + return database.BumpSharedAccountsVersion(tx) +} +``` + +Apply this prologue to shared mutators: + +```go +if err := ensureSharedWriteAllowed(); err != nil { + return nil, false, err +} +``` + +Apply the version bump immediately before commit in: + +- `AddInbound` +- `UpdateInbound` +- `DelInbound` +- `AddInboundClient` +- `DelInboundClient` +- `UpdateInboundClient` +- `ResetClientTraffic` +- `ResetAllTraffics` +- `DelDepletedClients` +- `DelInboundClientByEmail` + +Use the same pattern in each method’s existing transaction, preserving that method’s current return type: + +```go +if err := tx.Save(oldInbound).Error; err != nil { + return false, err +} +if err := bumpSharedVersion(tx); err != nil { + return false, err +} +return needRestart, nil +``` + +Do not change controller behavior in this task. Let the existing controller paths surface the service-layer error message. + +- [ ] **Step 5: Run the node-guard tests** + +Run: + +```bash +go test ./web/service -run 'Test(RequireMasterRejectsWorker|RequireMasterAllowsMaster|BumpSharedAccountsVersionRollsBackWithTransaction)' -v +``` + +Expected: PASS + +- [ ] **Step 6: Checkpoint Commit the guard work** + +Run: + +```bash +git add web/service/node_guard.go web/service/node_guard_test.go web/service/inbound.go +git commit -m "feat: guard shared writes and bump version transactionally" +``` + +After commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md`: + +- mark Task 3 complete +- record the short commit hash beside Task 3 + +--- + +### Task 4: Add shared snapshot cache, worker sync loop, and snapshot-driven Xray rebuild + +**Files:** +- Create: `web/service/node_cache.go` +- Create: `web/service/node_sync.go` +- Create: `web/service/node_sync_test.go` +- Modify: `web/service/xray.go` +- Modify: `web/web.go` +**Execution Mode:** Subagent-Driven + +- [ ] **Step 1: Write the failing snapshot and sync tests** + +Create `web/service/node_sync_test.go`: + +```go +package service + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v2/database/model" +) + +func TestLoadAndSaveSharedAccountsSnapshot(t *testing.T) { + path := filepath.Join(t.TempDir(), "shared-cache.json") + snapshot := &SharedAccountsSnapshot{ + Version: 2, + Inbounds: []*model.Inbound{ + {Id: 1, Tag: "inbound-443", Enable: true, Port: 443}, + }, + } + + if err := SaveSharedAccountsSnapshot(path, snapshot); err != nil { + t.Fatalf("SaveSharedAccountsSnapshot error: %v", err) + } + + loaded, err := LoadSharedAccountsSnapshot(path) + if err != nil { + t.Fatalf("LoadSharedAccountsSnapshot error: %v", err) + } + if loaded.Version != 2 || len(loaded.Inbounds) != 1 { + t.Fatalf("unexpected snapshot round-trip: %+v", loaded) + } +} + +func TestSyncOnceSkipsApplyWhenVersionUnchanged(t *testing.T) { + applyCalls := 0 + svc := &NodeSyncService{ + cachePath: filepath.Join(t.TempDir(), "shared-cache.json"), + loadVersion: func() (int64, error) { return 3, nil }, + loadSnapshot: func(int64) (*SharedAccountsSnapshot, error) { + t.Fatal("loadSnapshot should not run when version is unchanged") + return nil, nil + }, + applySnapshot: func(*SharedAccountsSnapshot) error { + applyCalls++ + return nil + }, + lastSeenVersion: 3, + } + + if err := svc.SyncOnce(); err != nil { + t.Fatalf("SyncOnce error: %v", err) + } + if applyCalls != 0 { + t.Fatalf("expected applySnapshot to be skipped, got %d calls", applyCalls) + } +} + +func TestSyncOnceRefreshesCacheAndAppliesSnapshot(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "shared-cache.json") + applyCalls := 0 + svc := &NodeSyncService{ + cachePath: cachePath, + loadVersion: func() (int64, error) { return 4, nil }, + loadSnapshot: func(version int64) (*SharedAccountsSnapshot, error) { + return &SharedAccountsSnapshot{ + Version: version, + Inbounds: []*model.Inbound{ + {Id: 7, Tag: "worker-8443", Enable: true, Port: 8443}, + }, + }, nil + }, + applySnapshot: func(snapshot *SharedAccountsSnapshot) error { + applyCalls++ + if snapshot.Version != 4 { + t.Fatalf("expected snapshot version 4, got %d", snapshot.Version) + } + return nil + }, + } + + if err := svc.SyncOnce(); err != nil { + t.Fatalf("SyncOnce error: %v", err) + } + if applyCalls != 1 { + t.Fatalf("expected one apply call, got %d", applyCalls) + } + + loaded, err := LoadSharedAccountsSnapshot(cachePath) + if err != nil { + t.Fatalf("LoadSharedAccountsSnapshot error: %v", err) + } + if loaded.Version != 4 { + t.Fatalf("expected cached version 4, got %d", loaded.Version) + } +} + +func TestBootstrapFromCacheAppliesCachedSnapshot(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "shared-cache.json") + if err := SaveSharedAccountsSnapshot(cachePath, &SharedAccountsSnapshot{ + Version: 5, + Inbounds: []*model.Inbound{ + {Id: 9, Tag: "cached-9443", Enable: true, Port: 9443}, + }, + }); err != nil { + t.Fatalf("SaveSharedAccountsSnapshot error: %v", err) + } + + applyCalls := 0 + svc := &NodeSyncService{ + cachePath: cachePath, + applySnapshot: func(snapshot *SharedAccountsSnapshot) error { + applyCalls++ + if snapshot.Version != 5 { + t.Fatalf("expected cached version 5, got %d", snapshot.Version) + } + return nil + }, + } + + if err := svc.BootstrapFromCache(); err != nil { + t.Fatalf("BootstrapFromCache error: %v", err) + } + if applyCalls != 1 { + t.Fatalf("expected one cached apply, got %d", applyCalls) + } +} +``` + +- [ ] **Step 2: Run the sync test subset and confirm it fails** + +Run: + +```bash +go test ./web/service -run 'Test(LoadAndSaveSharedAccountsSnapshot|SyncOnceSkipsApplyWhenVersionUnchanged|SyncOnceRefreshesCacheAndAppliesSnapshot|BootstrapFromCacheAppliesCachedSnapshot)' -v +``` + +Expected: FAIL with undefined `SharedAccountsSnapshot`, `SaveSharedAccountsSnapshot`, `LoadSharedAccountsSnapshot`, or `NodeSyncService`. + +- [ ] **Step 3: Add cache helpers and refactor Xray config building away from DB reads** + +Create `web/service/node_cache.go`: + +```go +package service + +import ( + "encoding/json" + "os" + + "github.com/mhsanaei/3x-ui/v2/database/model" +) + +type SharedAccountsSnapshot struct { + Version int64 `json:"version"` + Inbounds []*model.Inbound `json:"inbounds"` +} + +func LoadSharedAccountsSnapshot(path string) (*SharedAccountsSnapshot, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + snapshot := &SharedAccountsSnapshot{} + if err := json.Unmarshal(data, snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func SaveSharedAccountsSnapshot(path string, snapshot *SharedAccountsSnapshot) error { + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} +``` + +Refactor `web/service/xray.go` so worker sync can build from cached inbounds: + +```go +func (s *XrayService) BuildConfigFromInbounds(inbounds []*model.Inbound) (*xray.Config, error) { + templateConfig, err := s.settingService.GetXrayConfigTemplate() + if err != nil { + return nil, err + } + + xrayConfig := &xray.Config{} + if err := json.Unmarshal([]byte(templateConfig), xrayConfig); err != nil { + return nil, err + } + + for _, inbound := range inbounds { + if !inbound.Enable { + continue + } + // move the existing settings/streamSettings normalization logic here + inboundConfig := inbound.GenXrayInboundConfig() + xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig) + } + return xrayConfig, nil +} + +func (s *XrayService) RestartXrayWithConfig(xrayConfig *xray.Config, isForce bool) error { + lock.Lock() + defer lock.Unlock() + isManuallyStopped.Store(false) + + if s.IsXrayRunning() { + if !isForce && p.GetConfig().Equals(xrayConfig) && !isNeedXrayRestart.Load() { + return nil + } + p.Stop() + } + + p = xray.NewProcess(xrayConfig) + result = "" + return p.Start() +} + +func (s *XrayService) GetXrayConfig() (*xray.Config, error) { + inbounds, err := s.inboundService.GetAllInbounds() + if err != nil { + return nil, err + } + return s.BuildConfigFromInbounds(inbounds) +} + +func (s *XrayService) ApplySharedSnapshot(snapshot *SharedAccountsSnapshot) error { + xrayConfig, err := s.BuildConfigFromInbounds(snapshot.Inbounds) + if err != nil { + return err + } + return s.RestartXrayWithConfig(xrayConfig, false) +} +``` + +- [ ] **Step 4: Implement the node sync service** + +Create `web/service/node_sync.go`: + +```go +package service + +import ( + "context" + "os" + "time" + + "github.com/mhsanaei/3x-ui/v2/config" + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" +) + +type NodeSyncService struct { + xrayService XrayService + cachePath string + lastSeenVersion int64 + loadVersion func() (int64, error) + loadSnapshot func(int64) (*SharedAccountsSnapshot, error) + applySnapshot func(*SharedAccountsSnapshot) error +} + +func NewNodeSyncService() *NodeSyncService { + svc := &NodeSyncService{ + cachePath: config.GetSharedCachePath(), + } + svc.loadVersion = func() (int64, error) { + return database.GetSharedAccountsVersion(database.GetDB()) + } + svc.loadSnapshot = func(version int64) (*SharedAccountsSnapshot, error) { + inbounds, err := svc.xrayService.inboundService.GetAllInbounds() + if err != nil { + return nil, err + } + return &SharedAccountsSnapshot{Version: version, Inbounds: inbounds}, nil + } + svc.applySnapshot = svc.xrayService.ApplySharedSnapshot + return svc +} + +func (s *NodeSyncService) updateNodeState(version int64, syncErr error, didSync bool) { + nodeCfg := config.GetNodeConfigFromJSON() + now := time.Now().Unix() + state := &model.NodeState{ + NodeID: nodeCfg.NodeID, + NodeRole: string(nodeCfg.Role), + LastHeartbeatAt: now, + LastSeenVersion: version, + } + if didSync { + state.LastSyncAt = now + } + if syncErr != nil { + state.LastError = syncErr.Error() + } + _ = database.UpsertNodeState(database.GetDB(), state) +} + +func (s *NodeSyncService) BootstrapFromCache() error { + snapshot, err := LoadSharedAccountsSnapshot(s.cachePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + s.lastSeenVersion = snapshot.Version + return s.applySnapshot(snapshot) +} + +func (s *NodeSyncService) SyncOnce() error { + version, err := s.loadVersion() + if err != nil { + s.updateNodeState(s.lastSeenVersion, err, false) + return err + } + if version == s.lastSeenVersion { + s.updateNodeState(version, nil, false) + return nil + } + + snapshot, err := s.loadSnapshot(version) + if err != nil { + s.updateNodeState(s.lastSeenVersion, err, false) + return err + } + if err := SaveSharedAccountsSnapshot(s.cachePath, snapshot); err != nil { + s.updateNodeState(s.lastSeenVersion, err, false) + return err + } + if err := s.applySnapshot(snapshot); err != nil { + s.updateNodeState(s.lastSeenVersion, err, false) + return err + } + + s.lastSeenVersion = version + s.updateNodeState(version, nil, true) + return nil +} + +func (s *NodeSyncService) Run(ctx context.Context, interval time.Duration) { + _ = s.BootstrapFromCache() + _ = s.SyncOnce() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _ = s.SyncOnce() + } + } +} + +func (s *NodeSyncService) RunHeartbeatLoop(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + version, _ := database.GetSharedAccountsVersion(database.GetDB()) + s.updateNodeState(version, nil, false) + } + } +} +``` + +- [ ] **Step 5: Start worker sync or master heartbeat on server startup** + +Patch `web/web.go`: + +```go +func (s *Server) startNodeLoops() { + nodeCfg := config.GetNodeConfigFromJSON() + nodeSyncService := service.NewNodeSyncService() + interval := time.Duration(nodeCfg.SyncIntervalSeconds) * time.Second + + if nodeCfg.Role == config.NodeRoleWorker { + go nodeSyncService.Run(s.ctx, interval) + return + } + if nodeCfg.NodeID != "" { + go nodeSyncService.RunHeartbeatLoop(s.ctx, interval) + } +} +``` + +Call it from `Start()` after `s.startTask()`: + +```go +s.startTask() +s.startNodeLoops() +``` + +- [ ] **Step 6: Run the sync tests** + +Run: + +```bash +go test ./web/service -run 'Test(LoadAndSaveSharedAccountsSnapshot|SyncOnceSkipsApplyWhenVersionUnchanged|SyncOnceRefreshesCacheAndAppliesSnapshot|BootstrapFromCacheAppliesCachedSnapshot)' -v +``` + +Expected: PASS + +- [ ] **Step 7: Checkpoint Commit the sync work** + +Run: + +```bash +git add web/service/node_cache.go web/service/node_sync.go web/service/node_sync_test.go web/service/xray.go web/web.go +git commit -m "feat: add cache-backed worker sync and heartbeat loops" +``` + +After commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md`: + +- mark Task 4 complete +- record the short commit hash beside Task 4 + +--- + +### Task 5: Add durable traffic delta persistence and safe shared-mode flushes + +**Files:** +- Create: `web/service/traffic_pending.go` +- Create: `web/service/traffic_flush.go` +- Create: `web/service/traffic_flush_test.go` +- Modify: `web/job/xray_traffic_job.go` +- Modify: `web/service/inbound.go` +- Modify: `web/web.go` +- Modify: `xray/client_traffic.go` +**Execution Mode:** Subagent-Driven + +- [ ] **Step 1: Write the failing pending-delta and flush tests** + +Create `web/service/traffic_flush_test.go`: + +```go +package service + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/xray" +) + +func TestTrafficPendingStoreMerge(t *testing.T) { + store := NewTrafficPendingStore(filepath.Join(t.TempDir(), "traffic-pending.json")) + + if err := store.Merge([]TrafficDelta{{InboundID: 1, Email: "alice@example.com", UpDelta: 7}}); err != nil { + t.Fatalf("Merge error: %v", err) + } + if err := store.Merge([]TrafficDelta{{InboundID: 1, Email: "alice@example.com", DownDelta: 9}}); err != nil { + t.Fatalf("Merge error: %v", err) + } + + deltas, err := store.Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if len(deltas) != 1 { + t.Fatalf("expected one merged delta, got %d", len(deltas)) + } + if deltas[0].UpDelta != 7 || deltas[0].DownDelta != 9 { + t.Fatalf("unexpected merged delta: %+v", deltas[0]) + } +} + +func TestFlushOnceClearsPendingOnSuccess(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + if err := database.InitDBWithPath(filepath.Join(tmpDir, "flush.db")); err != nil { + t.Fatalf("InitDBWithPath error: %v", err) + } + defer database.CloseDB() + + if err := database.GetDB().Create(&model.Inbound{Id: 1, Tag: "inbound-443", Enable: true}).Error; err != nil { + t.Fatalf("seed inbound failed: %v", err) + } + if err := database.GetDB().Create(&xray.ClientTraffic{InboundId: 1, Email: "alice@example.com"}).Error; err != nil { + t.Fatalf("seed client traffic failed: %v", err) + } + + store := NewTrafficPendingStore(filepath.Join(tmpDir, "traffic-pending.json")) + if err := store.Merge([]TrafficDelta{{InboundID: 1, Email: "alice@example.com", UpDelta: 7, DownDelta: 9}}); err != nil { + t.Fatalf("Merge error: %v", err) + } + + svc := NewTrafficFlushService(store) + if err := svc.FlushOnce(); err != nil { + t.Fatalf("FlushOnce error: %v", err) + } + + var clientTraffic xray.ClientTraffic + if err := database.GetDB().First(&clientTraffic, "inbound_id = ? AND email = ?", 1, "alice@example.com").Error; err != nil { + t.Fatalf("lookup client traffic failed: %v", err) + } + if clientTraffic.Up != 7 || clientTraffic.Down != 9 { + t.Fatalf("unexpected flushed traffic: %+v", clientTraffic) + } + + deltas, err := store.Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if len(deltas) != 0 { + t.Fatalf("expected pending deltas to be cleared, got %+v", deltas) + } +} + +func TestFlushOnceKeepsPendingOnFailure(t *testing.T) { + store := NewTrafficPendingStore(filepath.Join(t.TempDir(), "traffic-pending.json")) + if err := store.Merge([]TrafficDelta{{InboundID: 1, Email: "alice@example.com", UpDelta: 3}}); err != nil { + t.Fatalf("Merge error: %v", err) + } + + svc := NewTrafficFlushService(store) + svc.flushFn = func([]TrafficDelta) error { return errors.New("boom") } + + if err := svc.FlushOnce(); err == nil { + t.Fatal("expected flush failure") + } + + deltas, err := store.Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if len(deltas) != 1 { + t.Fatalf("expected pending delta to remain, got %+v", deltas) + } +} +``` + +- [ ] **Step 2: Run the flush test subset and confirm it fails** + +Run: + +```bash +go test ./web/service -run 'Test(TrafficPendingStoreMerge|FlushOnceClearsPendingOnSuccess|FlushOnceKeepsPendingOnFailure)' -v +``` + +Expected: FAIL with undefined `TrafficDelta`, `NewTrafficPendingStore`, or `NewTrafficFlushService`. + +- [ ] **Step 3: Implement the pending-delta store and add the composite unique key** + +Create `web/service/traffic_pending.go`: + +```go +package service + +import ( + "encoding/json" + "fmt" + "os" + "sync" +) + +type TrafficDelta struct { + InboundID int `json:"inboundId"` + Email string `json:"email"` + UpDelta int64 `json:"upDelta"` + DownDelta int64 `json:"downDelta"` +} + +type TrafficPendingStore struct { + path string + mu sync.Mutex +} + +func NewTrafficPendingStore(path string) *TrafficPendingStore { + return &TrafficPendingStore{path: path} +} + +func (s *TrafficPendingStore) Load() ([]TrafficDelta, error) { + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return []TrafficDelta{}, nil + } + if err != nil { + return nil, err + } + var deltas []TrafficDelta + if err := json.Unmarshal(data, &deltas); err != nil { + return nil, err + } + return deltas, nil +} + +func (s *TrafficPendingStore) Save(deltas []TrafficDelta) error { + data, err := json.MarshalIndent(deltas, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0644) +} + +func (s *TrafficPendingStore) Merge(newDeltas []TrafficDelta) error { + s.mu.Lock() + defer s.mu.Unlock() + + current, err := s.Load() + if err != nil { + return err + } + index := map[string]int{} + for i, delta := range current { + index[deltaKey(delta.InboundID, delta.Email)] = i + } + for _, delta := range newDeltas { + key := deltaKey(delta.InboundID, delta.Email) + if idx, ok := index[key]; ok { + current[idx].UpDelta += delta.UpDelta + current[idx].DownDelta += delta.DownDelta + continue + } + index[key] = len(current) + current = append(current, delta) + } + return s.Save(current) +} + +func deltaKey(inboundID int, email string) string { + return fmt.Sprintf("%d:%s", inboundID, email) +} +``` + +Patch `xray/client_traffic.go` so shared flushes can use deterministic upserts: + +```go +type ClientTraffic struct { + Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_client_traffics_inbound_email"` + Enable bool `json:"enable" form:"enable"` + Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_client_traffics_inbound_email"` +``` + +- [ ] **Step 4: Implement shared-mode flushes and master-only traffic reconciliation** + +Create `web/service/traffic_flush.go`: + +```go +package service + +import ( + "context" + "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/xray" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type TrafficFlushService struct { + store *TrafficPendingStore + inbounds InboundService + flushFn func([]TrafficDelta) error +} + +func NewTrafficFlushService(store *TrafficPendingStore) *TrafficFlushService { + svc := &TrafficFlushService{store: store} + svc.flushFn = svc.flushToDatabase + return svc +} + +func (s *TrafficFlushService) Collect(clientTraffics []*xray.ClientTraffic) error { + deltas := make([]TrafficDelta, 0, len(clientTraffics)) + for _, traffic := range clientTraffics { + if traffic.Up == 0 && traffic.Down == 0 { + continue + } + deltas = append(deltas, TrafficDelta{ + InboundID: traffic.InboundId, + Email: traffic.Email, + UpDelta: traffic.Up, + DownDelta: traffic.Down, + }) + } + return s.store.Merge(deltas) +} + +func (s *TrafficFlushService) flushToDatabase(deltas []TrafficDelta) error { + return database.GetDB().Transaction(func(tx *gorm.DB) error { + for _, delta := range deltas { + if err := tx.Model(&model.Inbound{}). + Where("id = ?", delta.InboundID). + Updates(map[string]any{ + "up": gorm.Expr("up + ?", delta.UpDelta), + "down": gorm.Expr("down + ?", delta.DownDelta), + "all_time": gorm.Expr("COALESCE(all_time, 0) + ?", delta.UpDelta+delta.DownDelta), + }).Error; err != nil { + return err + } + + row := xray.ClientTraffic{ + InboundId: delta.InboundID, + Email: delta.Email, + Up: delta.UpDelta, + Down: delta.DownDelta, + AllTime: delta.UpDelta + delta.DownDelta, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "inbound_id"}, {Name: "email"}}, + DoUpdates: clause.Assignments(map[string]any{ + "up": gorm.Expr("up + ?", delta.UpDelta), + "down": gorm.Expr("down + ?", delta.DownDelta), + "all_time": gorm.Expr("all_time + ?", delta.UpDelta+delta.DownDelta), + }), + }).Create(&row).Error; err != nil { + return err + } + } + + if IsMaster() { + _, err := s.inbounds.ReconcileSharedTrafficState(tx) + if err != nil { + return err + } + } + return nil + }) +} + +func (s *TrafficFlushService) FlushOnce() error { + deltas, err := s.store.Load() + if err != nil || len(deltas) == 0 { + return err + } + if err := s.flushFn(deltas); err != nil { + return err + } + return s.store.Save([]TrafficDelta{}) +} + +func (s *TrafficFlushService) Run(ctx context.Context) { + interval := time.Duration(config.GetNodeConfigFromJSON().TrafficFlushSeconds) * time.Second + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + _ = s.FlushOnce() + return + case <-ticker.C: + _ = s.FlushOnce() + } + } +} +``` + +Extract master-only reconciliation from `web/service/inbound.go`: + +```go +func (s *InboundService) ReconcileSharedTrafficState(tx *gorm.DB) (bool, error) { + needRestart0, _, err := s.autoRenewClients(tx) + if err != nil { + return false, err + } + needRestart1, _, err := s.disableInvalidClients(tx) + if err != nil { + return false, err + } + needRestart2, _, err := s.disableInvalidInbounds(tx) + if err != nil { + return false, err + } + return needRestart0 || needRestart1 || needRestart2, nil +} +``` + +Do not let worker shared-mode traffic processing call `AddTraffic()`, because that path mutates shared enable/expiry state. + +- [ ] **Step 5: Route shared-mode traffic collection through the pending store and start the flush loop** + +Patch `web/job/xray_traffic_job.go`: + +```go +type XrayTrafficJob struct { + settingService service.SettingService + xrayService service.XrayService + inboundService service.InboundService + outboundService service.OutboundService + trafficFlushSvc *service.TrafficFlushService +} + +func NewXrayTrafficJob() *XrayTrafficJob { + return &XrayTrafficJob{ + trafficFlushSvc: service.NewTrafficFlushService( + service.NewTrafficPendingStore(config.GetTrafficPendingPath()), + ), + } +} +``` + +In `Run()`, branch on shared mode: + +```go +if service.IsSharedModeEnabled() { + if err := j.trafficFlushSvc.Collect(clientTraffics); err != nil { + logger.Warning("collect shared traffic failed:", err) + } +} else { + err, needRestart0 := j.inboundService.AddTraffic(traffics, clientTraffics) + if err != nil { + logger.Warning("add inbound traffic failed:", err) + } + if needRestart0 { + j.xrayService.SetToNeedRestart() + } +} +``` + +Start the flush loop in `web/web.go`: + +```go +func (s *Server) startTrafficFlushLoop() { + store := service.NewTrafficPendingStore(config.GetTrafficPendingPath()) + flushService := service.NewTrafficFlushService(store) + go flushService.Run(s.ctx) +} +``` + +Call it from `Start()` after `s.startNodeLoops()`: + +```go +s.startTrafficFlushLoop() +``` + +- [ ] **Step 6: Run the flush tests and package discovery** + +Run: + +```bash +go test ./web/service -run 'Test(TrafficPendingStoreMerge|FlushOnceClearsPendingOnSuccess|FlushOnceKeepsPendingOnFailure)' -v +go test ./... -run TestNonExistent -count=0 +``` + +Expected: + +- the focused flush tests PASS +- package discovery still succeeds after the new service wiring + +- [ ] **Step 7: Checkpoint Commit the shared traffic work** + +Run: + +```bash +git add 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 +git commit -m "feat: add durable traffic deltas and shared flush loop" +``` + +After commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md`: + +- mark Task 5 complete +- record the short commit hash beside Task 5 + +--- + +### Task 6: Expose node management in shell tools and the installer + +**Files:** +- Modify: `x-ui.sh` +- Modify: `install.sh` +**Execution Mode:** Subagent-Driven + +- [ ] **Step 1: Add read helpers and node status display to `x-ui.sh`** + +Add reusable JSON readers near the existing DB helpers: + +```bash +get_node_setting() { + local key="$1" + local default_value="$2" + local json_path="/etc/x-ui/x-ui.json" + if [ ! -f "$json_path" ]; then + echo "$default_value" + return + fi + jq -r "$key // $default_value" "$json_path" 2>/dev/null +} + +show_node_status() { + local node_role + local node_id + local sync_interval + local flush_interval + node_role=$(get_node_setting '.nodeRole' '"master"') + node_id=$(get_node_setting '.nodeId' '""') + sync_interval=$(get_node_setting '.syncInterval' '30') + flush_interval=$(get_node_setting '.trafficFlushInterval' '10') + + echo "Node role: ${node_role}" + echo "Node ID: ${node_id:-}" + echo "Sync interval: ${sync_interval}s" + echo "Traffic flush interval: ${flush_interval}s" +} +``` + +- [ ] **Step 2: Add minimal node-management actions to `x-ui.sh`** + +Add menu actions that call the existing Go binary instead of editing JSON directly: + +```bash +set_node_role() { + read -rp "Enter node role (master/worker): " node_role + if [ "$node_role" != "master" ] && [ "$node_role" != "worker" ]; then + echo "Invalid node role" + return 1 + fi + ${xui_folder}/x-ui setting -nodeRole "$node_role" +} + +set_node_id() { + read -rp "Enter node ID: " node_id + ${xui_folder}/x-ui setting -nodeId "$node_id" +} +``` + +Menu text should stay minimal: + +- show current node role +- set `master` / `worker` +- set `nodeId` +- remind the operator to restart after changes + +- [ ] **Step 3: Prompt for MariaDB and node role during fresh installs** + +Patch the fresh-install branch in `install.sh`: + +```bash +read -rp "Database type [mariadb]: " db_type +db_type=${db_type:-mariadb} +${xui_folder}/x-ui setting -dbType "$db_type" + +if [ "$db_type" = "mariadb" ]; then + read -rp "MariaDB host [127.0.0.1]: " db_host + read -rp "MariaDB port [3306]: " db_port + read -rp "MariaDB user: " db_user + read -rsp "MariaDB password: " db_pass + echo + read -rp "MariaDB database [3xui]: " db_name + + ${xui_folder}/x-ui setting -dbHost "${db_host:-127.0.0.1}" -dbPort "${db_port:-3306}" -dbUser "$db_user" -dbPassword "$db_pass" -dbName "${db_name:-3xui}" +fi + +read -rp "Node role [master]: " node_role +node_role=${node_role:-master} + +if [ "$node_role" = "worker" ]; then + read -rp "Node ID: " node_id + ${xui_folder}/x-ui setting -nodeRole worker -nodeId "$node_id" +else + ${xui_folder}/x-ui setting -nodeRole master +fi +``` + +Do not add this prompt path to upgrades. Preserve existing SQLite upgrade behavior for old installs. + +- [ ] **Step 4: Run shell syntax checks and a CLI smoke check** + +Run: + +```bash +bash -n x-ui.sh +bash -n install.sh +./x-ui setting -show true +``` + +Expected: + +- both shell scripts pass `bash -n` +- `./x-ui setting -show true` prints `nodeRole`, `nodeId`, `syncInterval`, and `trafficFlushInterval` + +- [ ] **Step 5: Checkpoint Commit the operator tooling work** + +Run: + +```bash +git add x-ui.sh install.sh +git commit -m "feat: add node management shell and installer flows" +``` + +After commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md`: + +- mark Task 6 complete +- record the short commit hash beside Task 6 + +--- + +### Task 7: Document the feature and run focused verification + +**Files:** +- Create: `docs/multi-node-sync.md` +- Modify: `README.md` +- Modify: `README.zh_CN.md` +**Execution Mode:** Subagent-Driven + +- [ ] **Step 1: Write the operator runbook** + +Create `docs/multi-node-sync.md` with these sections: + +```md +# 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 +``` + +- [ ] **Step 2: Add concise README entries in both languages** + +Append a short section to `README.md`: + +```md +## Multi-Node Shared Control + +- use MariaDB as the shared control database +- keep one `master` node for shared-account writes +- configure other nodes as `worker` +- workers rebuild local Xray config from synchronized snapshots +- traffic is flushed back as deltas, not absolute totals +``` + +Append the matching section to `README.zh_CN.md`: + +```md +## 多节点共享控制 + +- 使用 MariaDB 作为共享控制数据库 +- 仅保留一个 `master` 节点负责共享账号写入 +- 其他节点配置为 `worker` +- `worker` 通过同步快照重建本地 Xray 配置 +- 流量按增量回刷,不覆盖绝对总量 +``` + +- [ ] **Step 3: Add the manual verification checklist to the runbook** + +Append this checklist to `docs/multi-node-sync.md`: + +```md +## 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. +``` + +- [ ] **Step 4: Run focused verification** + +Run: + +```bash +go test ./config ./database ./web/service -v +go test ./... -run TestNonExistent -count=0 +``` + +Expected: + +- focused packages PASS +- package discovery succeeds across the repo + +- [ ] **Step 5: Checkpoint Commit the docs** + +Run: + +```bash +git add docs/multi-node-sync.md README.md README.zh_CN.md +git commit -m "docs: add multi-node shared control guidance" +``` + +After commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md`: + +- mark Task 7 complete +- record the short commit hash beside Task 7 + +--- + +## Rollout Order + +1. Task 1 — config, runtime file paths, CLI setters, startup validation +2. Task 2 — metadata tables and repository helpers +3. Task 3 — master-only guards and version bumping +4. Task 4 — cache-backed worker sync and snapshot-driven Xray rebuild +5. Task 5 — durable traffic delta collection, atomic flush, and master-only reconciliation +6. Task 6 — shell and installer flows +7. Task 7 — docs and focused verification + +## Execution Strategy + +- Tasks 1–3 execute Inline in the current session to establish the shared foundations before parallel work begins. +- Tasks 4–7 execute Subagent-Driven after Tasks 1–3 are complete and committed. +- Each task is a checkpoint and must end with its own git commit; do not batch adjacent tasks into one commit. +- After each checkpoint commit, update `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md` before moving to the next task. + +## Acceptance Criteria + +- `master` is the only node that can mutate shared account definitions. +- successful shared-account writes increment `shared_accounts_version` in the same transaction. +- `worker` nodes poll the shared version and rebuild local Xray config from cached snapshots. +- workers keep serving from `shared-cache.json` when MariaDB is temporarily unavailable. +- traffic is stored locally as deltas and flushed back without overwriting aggregate totals. +- shared-mode traffic collection on `worker` no longer calls `InboundService.AddTraffic()` and therefore no longer mutates shared enable / expiry state. +- only `master` performs shared traffic reconciliation that can disable or renew clients and inbounds. +- `x-ui.sh`, `install.sh`, and the README documents expose the node role and shared-control workflow clearly. + +## Self-Review + +- Spec coverage: the plan covers node config, shared metadata, master-only writes, worker snapshot sync, cache fallback, durable traffic deltas, master-only reconciliation after flush, operator tooling, and docs. +- Placeholder scan: no unfinished placeholder markers remain. +- Type consistency: `NodeConfig`, `SharedAccountsSnapshot`, `TrafficDelta`, `NodeSyncService`, `TrafficFlushService`, `RequireMaster`, and `ReconcileSharedTrafficState` are used consistently across tasks. + +## Execution Handoff + +Selected execution strategy: + +- Inline foundation: Tasks 1–3 +- Subagent-Driven expansion: Tasks 4–7 + +Progress tracker: + +- `docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md` diff --git a/docs/superpowers/plans/2026-04-11-local-remote-mariadb-install-switch.md b/docs/superpowers/plans/2026-04-11-local-remote-mariadb-install-switch.md new file mode 100644 index 00000000..b23b8f65 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-local-remote-mariadb-install-switch.md @@ -0,0 +1,69 @@ +# 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 ./...`. diff --git a/docs/superpowers/plans/2026-04-22-mariadb-remote-ip-access.md b/docs/superpowers/plans/2026-04-22-mariadb-remote-ip-access.md new file mode 100644 index 00000000..da7031e3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-mariadb-remote-ip-access.md @@ -0,0 +1,60 @@ +# 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. diff --git a/docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md b/docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md new file mode 100644 index 00000000..a242f6db --- /dev/null +++ b/docs/superpowers/progress/2026-04-10-multi-node-shared-control-progress.md @@ -0,0 +1,157 @@ +# Multi-Node Shared Control Progress + +## Execution Strategy + +- Tasks 1–3: Inline +- Tasks 4–7: 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 task’s 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 1–3 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 4–6 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 diff --git a/docs/superpowers/specs/2026-04-02-json-settings-design.md b/docs/superpowers/specs/2026-04-02-json-settings-design.md new file mode 100644 index 00000000..874804fc --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-json-settings-design.md @@ -0,0 +1,134 @@ +# Panel Settings JSON Migration Design + +## Overview + +Extract panel settings from the SQLite `settings` table into a standalone JSON file (`x-ui.json`) located in the same directory as the database (`/etc/x-ui/` by default). The `xrayTemplateConfig` remains in the database. + +## Requirements + +- Panel settings (webPort, tgBot*, sub*, ldap*, etc.) stored in a flat key-value JSON file +- `xrayTemplateConfig` stays in the database `settings` table +- All new installations (no migration from existing DB) +- JSON file path: `/x-ui.json` (same directory as `x-ui.db`) +- JSON file auto-created on first run with default values + +## Architecture + +### File Layout + +``` +/etc/x-ui/ + x-ui.db # SQLite: users, inbounds, client_traffics, xrayTemplateConfig + x-ui.json # Panel settings (flat key-value JSON) +``` + +### JSON Format + +```json +{ + "webListen": "", + "webPort": "2053", + "webCertFile": "", + "webKeyFile": "", + "secret": "random32chars...", + "webBasePath": "/", + "sessionMaxAge": "360", + "tgBotEnable": "false", + "tgBotToken": "", + "subEnable": "true", + "ldapEnable": "false", + ... +} +``` + +All values are strings (consistent with current DB storage). No `xrayTemplateConfig` key. + +## Changes + +### 1. `config/config.go` + +Add `GetSettingPath()` function: + +```go +func GetSettingPath() string { + return fmt.Sprintf("%s/%s.json", GetDBFolderPath(), GetName()) +} +``` + +### 2. `web/service/setting.go` + +Replace database-backed `getSetting`/`saveSetting` with JSON file operations: + +- **`loadSettings()`** — reads JSON file into `map[string]string`; creates file from `defaultValueMap` if not exists +- **`saveSettings(settings)`** — writes `map[string]string` to JSON file +- **`getSetting(key)`** → read from JSON map +- **`saveSetting(key, value)`** → update key in JSON map, write back +- **`getString(key)`** → `getSetting(key)` with fallback to `defaultValueMap` +- **`GetAllSetting()`** → load JSON map, populate `AllSetting` struct via reflection (same as current, data source changes) +- **`UpdateAllSetting()`** → reflect fields into map, save to JSON +- **`ResetSettings()`** → delete JSON file + clear users table + +Remove `import "github.com/mhsanaei/3x-ui/v2/database"` and `model` imports (no longer needed for settings operations). + +### 3. `web/service/xray_setting.go` + +`XraySettingService.SaveXraySetting()` and related methods continue using the database directly for `xrayTemplateConfig`: + +- Replace `s.SettingService.saveSetting("xrayTemplateConfig", ...)` with direct DB operation via `database.GetDB()` +- Add a private helper `saveXraySettingToDB()` / `getXraySettingFromDB()` for direct DB access + +### 4. `database/db.go` + +Keep `model.Setting{}` in `initModels()` — the `settings` table still stores `xrayTemplateConfig`. + +### 5. `main.go` + +No changes needed. CLI commands use `SettingService` which handles JSON internally. + +The only change: `resetSetting()` calls `settingService.ResetSettings()` which now deletes the JSON file instead of DB rows. The `users` table clearing logic is preserved. + +## Data Flow + +### Reading + +``` +Controller/CLI → SettingService.GetString("webPort") + → loadSettings() [reads x-ui.json] + → returns "2053" (or default if missing) +``` + +### Writing + +``` +Controller/CLI → SettingService.SetPort(8080) + → setInt("webPort", 8080) + → setString("webPort", "8080") + → saveSetting("webPort", "8080") + → loadSettings() → update map["webPort"] = "8080" → saveSettings() +``` + +### Xray Config (unchanged path) + +``` +XraySettingService.SaveXraySetting(config) + → validate config + → database.GetDB().Where("key = ?", "xrayTemplateConfig").Save(...) +``` + +## Error Handling + +- JSON file read failure: return error (panel cannot start without settings) +- JSON file write failure: return error (settings update fails, no silent data loss) +- JSON file not found: auto-create from defaults (first run) +- Malformed JSON: return error with clear message +- Concurrent access: Go's single-goroutine web server model means no concurrent write issues for settings + +## Testing + +- Verify first run creates `x-ui.json` with correct defaults +- Verify `GetAllSetting()` returns correct values from JSON +- Verify `UpdateAllSetting()` writes all fields to JSON +- Verify CLI `x-ui setting -port 8080` updates JSON file +- Verify CLI `x-ui setting -reset` deletes JSON file and recreates on next access +- Verify `xrayTemplateConfig` still works via database +- Verify `x-ui setting -show` reads from JSON file correctly diff --git a/docs/superpowers/specs/2026-04-02-pre-release-install-update-design.md b/docs/superpowers/specs/2026-04-02-pre-release-install-update-design.md new file mode 100644 index 00000000..701d5377 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-pre-release-install-update-design.md @@ -0,0 +1,79 @@ +# Pre-release Install/Update Selection + +## Summary + +Add interactive prompts to `install.sh` and `update.sh` so users can choose between the latest **Stable** release or the latest **Pre-release** when installing or updating 3x-ui. + +## Current State + +- `install.sh` and `update.sh` both hardcode `GET /repos/Sora39831/3x-ui/releases/latest`, which only returns stable releases. +- No mechanism exists to install or update to a pre-release version through the automated flow. + +## Design + +### 1. GitHub API Fetch Helper + +A shared function (duplicated in both `install.sh` and `update.sh`, matching existing script conventions) that: + +- Calls `GET https://api.github.com/repos/Sora39831/3x-ui/releases` (returns all releases) +- Parses the JSON response to extract: + - `latest_stable_tag` — first entry with `"prerelease": false` + - `latest_prerelease_tag` — first entry with `"prerelease": true` (empty if none exists) +- Uses `grep`/`sed`/`awk` (no `jq` dependency, consistent with existing parsing patterns) +- Falls back to `curl -4` on IPv6 failure, matching existing retry pattern + +### 2. Interactive Prompt + +Both scripts display a menu after fetching release info: + +``` +Which version do you want to install/update? + 1) Latest Stable: v2.x.x + 2) Latest Pre-release: v2.x.x-beta +Please enter your choice [1-2]: +``` + +Behavior: +- Show actual version tags so the user knows what they're selecting +- If no pre-release exists: skip prompt, use stable automatically +- If no stable release exists (edge case): skip prompt, use pre-release automatically +- Invalid input re-prompts + +### 3. install.sh Changes + +In `install_x-ui()`, the no-argument path (line ~879): + +**Before:** Calls `/releases/latest`, parses single tag, downloads. + +**After:** +1. Call fetch helper to get both tags +2. Show interactive prompt +3. Set `tag_version` from user choice +4. Download as before (existing logic unchanged) + +The specific-version path (`$1` argument) is unchanged. + +### 4. update.sh Changes + +In `update_x-ui()`, same pattern: + +**Before:** Calls `/releases/latest`, parses single tag, downloads. + +**After:** +1. Call fetch helper to get both tags +2. Show interactive prompt +3. Set `tag_version` from user choice +4. Continue existing update logic (unchanged) + +`x-ui.sh` is **not modified** — it delegates to `update.sh` already. + +## Files Modified + +- `install.sh` — add fetch helper + prompt in `install_x-ui()` +- `update.sh` — add fetch helper + prompt in `update_x-ui()` + +## Out of Scope + +- Persisting user's choice across updates (always prompt each time) +- CLI flags like `--pre-release` for non-interactive use +- Changes to `x-ui.sh` (delegation is already in place) diff --git a/docs/superpowers/specs/2026-04-03-mariadb-support-design.md b/docs/superpowers/specs/2026-04-03-mariadb-support-design.md new file mode 100644 index 00000000..ee9138da --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-mariadb-support-design.md @@ -0,0 +1,332 @@ +# MariaDB Support for 3x-ui + +## Summary + +Add MariaDB as an alternative database backend to SQLite. Users switch between SQLite and MariaDB via the `x-ui.sh` management script (option 27). Data is automatically migrated during the switch. MariaDB connection credentials are stored in `/etc/x-ui/x-ui.json`. + +## Requirements + +- Support both SQLite and MariaDB as database backends +- Switch via `x-ui.sh` with interactive prompts for MariaDB credentials (IP, port, username, password, database name) +- Auto-migrate data when switching between SQLite and MariaDB +- Keep old database as backup after migration +- MariaDB has core feature parity (CRUD, migrations, seeders) but skips SQLite-specific features (WAL checkpoint, file export/import) +- Credentials stored in `/etc/x-ui/x-ui.json` + +## Architecture: Approach A — Driver-agnostic `InitDB` + +Refactor `database.InitDB()` to read config from the JSON settings file, determine the driver type, and open the appropriate GORM connection. The package-level `var db *gorm.DB` singleton stays unchanged — all callers continue using `database.GetDB()`. + +--- + +## Section 1: Configuration + +### New settings in `web/service/setting.go` + +Add to `defaultValueMap`: + +| Key | Default | Description | +|-----|---------|-------------| +| `dbType` | `"sqlite"` | `"sqlite"` or `"mariadb"` | +| `dbHost` | `"127.0.0.1"` | MariaDB host | +| `dbPort` | `"3306"` | MariaDB port | +| `dbUser` | `""` | MariaDB username | +| `dbPassword` | `""` | MariaDB password | +| `dbName` | `"3xui"` | MariaDB database name | + +Add getter/setter methods: `GetDBType()`, `SetDBType()`, `GetDBHost()`, `SetDBHost()`, `GetDBPort()`, `SetDBPort()`, `GetDBUser()`, `SetDBUser()`, `GetDBPassword()`, `SetDBPassword()`, `GetDBName()`, `SetDBName()`. + +### Config reading before DB init + +Problem: settings are stored IN the database, but we need `dbType` BEFORE opening the DB. + +Solution: `config/config.go` gets a `GetDBTypeFromJSON()` function that reads `/etc/x-ui/x-ui.json` directly (falls back to `"sqlite"` if file doesn't exist or key is missing). This is called before `database.InitDB()`. + +### New CLI flags in `main.go` + +Add `-dbType`, `-dbHost`, `-dbPort`, `-dbUser`, `-dbPassword`, `-dbName` flags to the `setting` subcommand. These write directly to the JSON config file (not via the DB) using `config.WriteSettingToJSON(key, value)`. + +New `config/config.go` helper: `WriteSettingToJSON(key, value string)` — reads the JSON file, updates the key, writes back. + +--- + +## Section 2: Database Layer (`database/db.go`) + +### Refactored `InitDB()` + +```go +func InitDB() error { + dbType := config.GetDBTypeFromJSON() + + switch dbType { + case "mariadb": + return initMariaDB() + default: // "sqlite" + return initSQLite(config.GetDBPath()) + } +} +``` + +### `initSQLite(path string) error` + +Existing logic unchanged — opens SQLite with `gorm.io/driver/sqlite`, runs `initModels()`, `initUser()`, `runSeeders()`. + +### `initMariaDB() error` + +1. Read host, port, user, password, dbName from JSON config. +2. Build DSN: `user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local` +3. Open with `gorm.io/driver/mysql`. +4. Run `initModels()`, `initUser()`, `runSeeders()` (same as SQLite). + +### Adapted functions + +- `Checkpoint()` — if MariaDB, return `nil`. If SQLite, existing WAL logic. +- `IsSQLiteDB()` — unchanged, only called for SQLite. +- `ValidateSQLiteDB()` — unchanged, only called for SQLite. + +### New dependency + +`gorm.io/driver/mysql` added to `go.mod`. + +--- + +## Section 3: Data Migration (`database/migrate.go`) + +New file with two functions: + +### `MigrateSQLiteToMariaDB() error` + +1. Open SQLite connection from `config.GetDBPath()`. +2. Open MariaDB connection from JSON settings. +3. For each table (users, inbounds, outbound_traffics, settings, inbound_client_ips, client_traffics, history_of_seeders): + - AutoMigrate the model on MariaDB. + - `SELECT *` from SQLite → `INSERT` into MariaDB using GORM raw SQL. +4. On success: close connections (SQLite file kept as backup). +5. On failure: return error with context. + +### `MigrateMariaDBToSQLite() error` + +Reverse of above: +1. Open MariaDB connection from JSON settings. +2. Open/create SQLite connection at `config.GetDBPath()`. +3. For each table: read from MariaDB, write to SQLite. +4. On success: close connections. +5. On failure: return error. + +Row transfer approach: Use the existing model structs explicitly. For each table, query all rows from source into a `[]Model` slice, then batch-insert into destination. This avoids raw SQL differences between SQLite and MySQL. Example for users: + +```go +var users []model.User +srcDB.Find(&users) +dstDB.CreateInBatches(&users, 100) +``` + +This pattern repeats for each of the 7 tables. + +--- + +## Section 4: `main.go` Changes + +### Updated callers + +All `database.InitDB(config.GetDBPath())` calls change to `database.InitDB()`: +- `runWebServer()` (line 49) +- `resetSetting()` (line 134) +- `updateTgbotSetting()` (line 221) +- `updateSetting()` (line 259) +- `updateCert()` (line 318) +- `migrateDb()` (line 395) + +### New `migrate-db` subcommand + +```go +case "migrate-db": + migrateDbBetweenDrivers() +``` + +`migrateDbBetweenDrivers()`: +1. Read `dbType` from JSON config. +2. If `dbType == "mariadb"`: call `database.MigrateSQLiteToMariaDB()`. +3. If `dbType == "sqlite"`: call `database.MigrateMariaDBToSQLite()`. +4. Print success/failure message. + +### New CLI flags + +Add to `setting` subcommand: +- `-dbType string` — set database type +- `-dbHost string` — set MariaDB host +- `-dbPort string` — set MariaDB port +- `-dbUser string` — set MariaDB username +- `-dbPassword string` — set MariaDB password +- `-dbName string` — set MariaDB database name + +These call `config.WriteSettingToJSON()` to write directly to the JSON file. Only the 6 DB-related settings use `WriteSettingToJSON()` — all other settings (port, username, etc.) continue to use the existing `SettingService` methods that write through the database. + +--- + +## Section 5: `web/service/server.go` Changes + +### `GetDb()` + +Add check at the top: +```go +dbType, _ := s.GetDBType() +if dbType == "mariadb" { + return nil, common.NewError("Database export is not supported for MariaDB") +} +``` +Existing SQLite logic unchanged. + +### `ImportDB()` + +Add check at the top: +```go +dbType, _ := s.GetDBType() +if dbType == "mariadb" { + return common.NewError("Database import is not supported for MariaDB") +} +``` +Existing SQLite logic unchanged. + +--- + +## Section 6: `x-ui.sh` Changes + +### New menu option 27 + +Add to `show_menu`: +``` +│────────────────────────────────────────────────│ +│ ${green}27.${plain} 数据库管理 │ +``` + +Add to the case statement: +```bash +27) + check_install && db_menu + ;; +``` + +Update prompt: `请输入选择 [0-27]` + +### `db_menu()` function + +```bash +db_menu() { + # Read current dbType from JSON + local current_type=$(read_json_dbtype) + + echo -e " +╔────────────────────────────────────────────────╗ +│ ${green}数据库管理${plain} │ +│ ${green}0.${plain} 返回主菜单 │ +│ ${green}1.${plain} 查看当前数据库类型(当前: ${current_type}) │ +│ ${green}2.${plain} 切换到 MariaDB │ +│ ${green}3.${plain} 切换到 SQLite │ +╚────────────────────────────────────────────────╝ +" + read -rp "请输入选择 [0-3]:" num + case "${num}" in + 0) show_menu ;; + 1) db_show_status && db_menu ;; + 2) db_switch_to_mariadb ;; + 3) db_switch_to_sqlite ;; + *) echo "无效选项" && db_menu ;; + esac +} +``` + +### `db_switch_to_mariadb()` + +```bash +db_switch_to_mariadb() { + echo "请输入 MariaDB 连接信息(直接回车使用默认值):" + + read -rp "MariaDB IP(默认 127.0.0.1): " db_host + db_host=${db_host:-127.0.0.1} + + read -rp "MariaDB 端口(默认 3306): " db_port + db_port=${db_port:-3306} + + read -rp "MariaDB 用户名: " db_user + if [ -z "$db_user" ]; then + echo -e "${red}用户名不能为空${plain}" + db_menu + return + fi + + read -rsp "MariaDB 密码: " db_pass + echo + if [ -z "$db_pass" ]; then + echo -e "${red}密码不能为空${plain}" + db_menu + return + fi + + read -rp "数据库名(默认 3xui): " db_name + db_name=${db_name:-3xui} + + # Write settings to JSON config + /usr/local/x-ui/x-ui setting -dbType mariadb -dbHost "$db_host" -dbPort "$db_port" -dbUser "$db_user" -dbPassword "$db_pass" -dbName "$db_name" + + # Migrate data + echo "正在迁移数据从 SQLite 到 MariaDB..." + /usr/local/x-ui/x-ui migrate-db + + if [ $? -eq 0 ]; then + echo -e "${green}数据库切换成功,正在重启面板...${plain}" + restart + else + echo -e "${red}数据迁移失败,正在回滚到 SQLite...${plain}" + /usr/local/x-ui/x-ui setting -dbType sqlite + restart + fi +} +``` + +### `db_switch_to_sqlite()` + +```bash +db_switch_to_sqlite() { + /usr/local/x-ui/x-ui setting -dbType sqlite + + echo "正在迁移数据从 MariaDB 到 SQLite..." + /usr/local/x-ui/x-ui migrate-db + + if [ $? -eq 0 ]; then + echo -e "${green}数据库切换成功,正在重启面板...${plain}" + restart + else + echo -e "${red}数据迁移失败${plain}" + fi +} +``` + +### Helper functions in x-ui.sh + +- `read_json_dbtype()` — reads `dbType` from `/etc/x-ui/x-ui.json` using `grep`/`sed` or Python if available. +- `db_show_status()` — displays current DB type and connection info. + +--- + +## Files Changed + +| File | Changes | +|------|---------| +| `go.mod` | Add `gorm.io/driver/mysql` | +| `config/config.go` | Add `GetDBTypeFromJSON()`, `WriteSettingToJSON()` | +| `database/db.go` | Refactor `InitDB()` to be driver-agnostic, add `initMariaDB()`, adapt `Checkpoint()` | +| `database/migrate.go` | **New file** — `MigrateSQLiteToMariaDB()`, `MigrateMariaDBToSQLite()` | +| `main.go` | Update all `InitDB` calls, add `migrate-db` subcommand, add setting CLI flags | +| `web/service/setting.go` | Add 6 new settings + getter/setter methods | +| `web/service/server.go` | Guard `GetDb()`/`ImportDB()` for MariaDB | +| `x-ui.sh` | Add option 27, `db_menu()`, `db_switch_to_mariadb()`, `db_switch_to_sqlite()`, helpers | + +## Testing + +1. Fresh install with SQLite (default) — verify panel works as before +2. Switch to MariaDB via x-ui.sh — verify data migrates and panel starts +3. Switch back to SQLite — verify data migrates back +4. Verify MariaDB CRUD operations (create inbound, modify settings, etc.) +5. Verify GetDb/ImportDB return appropriate errors when using MariaDB +6. Verify invalid MariaDB credentials show error and rollback to SQLite diff --git a/docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md b/docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md new file mode 100644 index 00000000..66fdcfe3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md @@ -0,0 +1,338 @@ +# 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 diff --git a/docs/superpowers/specs/2026-04-11-local-remote-mariadb-install-design.md b/docs/superpowers/specs/2026-04-11-local-remote-mariadb-install-design.md new file mode 100644 index 00000000..221d5919 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-local-remote-mariadb-install-design.md @@ -0,0 +1,43 @@ +# 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. diff --git a/docs/x-panel-device-limit.md b/docs/x-panel-device-limit.md new file mode 100644 index 00000000..f3fb1eaf --- /dev/null +++ b/docs/x-panel-device-limit.md @@ -0,0 +1,444 @@ +# x-panel (xeefei/x-panel) 设备限制功能分析 + +> 本文档整理了 x-panel 的设备限制(IP限制)相关逻辑代码和接口,供后续修改 3x-ui IP 限制功能参考。 + +## 目录 + +1. [架构概览](#架构概览) +2. [数据模型](#数据模型) +3. [核心任务:CheckDeviceLimitJob](#核心任务checkdevicelimitjob) +4. [封禁/解封机制](#封禁解封机制) +5. [观察期防误封逻辑](#观察期防误封逻辑) +6. [TTL 过期清理](#ttl-过期清理) +7. [遗留任务:CheckClientIpJob](#遗留任务checkclientipjob) +8. [前端 UI](#前端-ui) +9. [主程序启动与依赖注入](#主程序启动与依赖注入) +10. [关键日志路径](#关键日志路径) +11. [与 3x-ui 的差异总结](#与-3x-ui-的差异总结) + +--- + +## 架构概览 + +x-panel 有两套 IP 限制机制并行运行: + +| 任务 | 来源 | 执行方式 | 核心思路 | +|------|------|----------|----------| +| `CheckDeviceLimitJob` | 新增 | `main.go` 中 goroutine + 10s Ticker | 内存跟踪活跃 IP,超限通过 Xray API 替换 UUID 封禁 | +| `CheckClientIpJob` | 遗留(同 3x-ui) | cron 每 10s | 解析 access.log,超限 IP 写入 Fail2ban 日志 | + +**CheckDeviceLimitJob 工作流程(每 10 秒一次):** + +``` +Run() + ├─ 1. cleanupExpiredIPs() // 清理 3 分钟不活跃的 IP + ├─ 2. parseAccessLog() // 增量读取 access.log,更新活跃 IP 表 + └─ 3. checkAllClientsLimit() // 检查所有用户,超限封禁,恢复解封 +``` + +--- + +## 数据模型 + +**源文件:** `database/model/model.go` + +### Inbound 结构体(新增字段) + +```go +type Inbound struct { + // ... 原有字段 ... + + // 设备限制字段,per-inbound 级别(不是 per-client) + DeviceLimit int `json:"deviceLimit" form:"deviceLimit" gorm:"column:device_limit;default:0"` +} +``` + +- `device_limit > 0` 表示该入站规则启用了设备限制 +- 这是**入站级别**的限制,不是客户端级别的 + +### Client 结构体 + +```go +type Client struct { + ID string `json:"id"` + Security string `json:"security"` + Password string `json:"password"` + SpeedLimit int `json:"speedLimit" form:"speedLimit"` // KB/s,0=不限速 + Flow string `json:"flow"` + Email string `json:"email"` + LimitIP int `json:"limitIp"` // 遗留字段,Fail2ban 用 + TotalGB int64 `json:"totalGB"` + ExpiryTime int64 `json:"expiryTime"` + Enable bool `json:"enable"` + TgID int64 `json:"tgId"` + SubID string `json:"subId"` + Comment string `json:"comment"` + Reset int `json:"reset"` +} +``` + +### InboundClientIps(与 3x-ui 相同) + +```go +type InboundClientIps struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + ClientEmail string `json:"clientEmail" gorm:"unique"` + Ips string `json:"ips"` // JSON 数组字符串 +} +``` + +### 内存状态结构 + +```go +// 活跃 IP 跟踪(TTL 机制) +// map[用户email] -> map[IP地址] -> 最后活跃时间 +var ActiveClientIPs = make(map[string]map[string]time.Time) +var activeClientsLock sync.RWMutex + +// 用户封禁状态跟踪 +// map[用户email] -> 是否被封禁(true/false) +var ClientStatus = make(map[string]bool) +var clientStatusLock sync.RWMutex +``` + +--- + +## 核心任务:CheckDeviceLimitJob + +**源文件:** `web/job/check_client_ip_job.go` + +### 结构体 + +```go +type CheckDeviceLimitJob struct { + inboundService service.InboundService + xrayService *service.XrayService + xrayApi xray.XrayAPI + lastPosition int64 // access.log 增量读取位置 + telegramService service.TelegramService // TG 通知(可为 nil) + violationStartTime map[string]time.Time // 观察期开始时间 + triggerLock sync.Mutex // 保护 violationStartTime +} +``` + +### 构造函数 + +```go +func NewCheckDeviceLimitJob(xrayService *service.XrayService, telegramService service.TelegramService) *CheckDeviceLimitJob +``` + +### Run() 主循环 + +```go +func (j *CheckDeviceLimitJob) Run() { + if !j.xrayService.IsXrayRunning() { + return + } + j.cleanupExpiredIPs() + j.parseAccessLog() + j.checkAllClientsLimit() +} +``` + +### cleanupExpiredIPs() — 清理过期 IP + +- TTL 窗口:**3 分钟** +- 超过 3 分钟未出现的 IP 被删除 +- 用户所有 IP 都过期后,用户条目也从 map 中移除 + +```go +const activeTTL = 3 * time.Minute +for email, ips := range ActiveClientIPs { + for ip, lastSeen := range ips { + if now.Sub(lastSeen) > activeTTL { + delete(ActiveClientIPs[email], ip) + } + } + if len(ActiveClientIPs[email]) == 0 { + delete(ActiveClientIPs, email) + } +} +``` + +### parseAccessLog() — 增量解析日志 + +- 使用 `file.Seek(j.lastPosition, 0)` 实现增量读取 +- 正则提取 email 和 IP: + ```go + emailRegex := regexp.MustCompile(`email: ([^ ]+)`) + ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`) + ``` +- 忽略 `127.0.0.1` 和 `::1` +- 读取完毕后记录当前位置;如果文件被截断(当前位置 < 上次位置),重置为 0 + +### checkAllClientsLimit() — 核心检查逻辑 + +```go +// 查询启用了设备限制且正在运行的入站 +db.Where("device_limit > 0 AND enable = ?", true).Find(&inbounds) + +// 获取 Xray API 端口 +apiPort := j.xrayService.GetApiPort() +j.xrayApi.Init(apiPort) +defer j.xrayApi.Close() +``` + +**第一步:处理在线用户** +- 遍历 `ActiveClientIPs` +- 通过 `inboundService.GetClientTrafficByEmail(email)` 关联到入站 +- 检查活跃 IP 数 vs `device_limit` +- 超限 → 进入观察期逻辑 → 封禁 +- 恢复 → 解封 + +**第二步:处理已封禁但已下线的用户** +- 遍历 `ClientStatus` +- 已封禁但不在 `ActiveClientIPs` 中的用户 → 解封 + +--- + +## 封禁/解封机制 + +### banUser() — 封禁(UUID 替换) + +```go +func (j *CheckDeviceLimitJob) banUser(email string, activeIPCount int, info *struct{...}) { + // 1. 从数据库获取原始客户端信息 + _, client, err := j.inboundService.GetClientByEmail(email) + + // 2. 发送 Telegram 通知(异步 goroutine) + go func() { + j.telegramService.SendMessage(tgMessage) + }() + + // 3. 从 Xray-Core 中删除该用户 + j.xrayApi.RemoveUser(info.Tag, email) + + // 4. 等待 5 秒,解决竞态条件 + time.Sleep(5000 * time.Millisecond) + + // 5. 创建临时客户端,替换 UUID/Password + tempClient := *client + if tempClient.ID != "" { tempClient.ID = RandomUUID() } + if tempClient.Password != "" { tempClient.Password = RandomUUID() } + + // 6. 用错误的 UUID/Password 添加回去 → 客户端无法通过验证 + j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap) + + // 7. 标记为已封禁 + ClientStatus[email] = true +} +``` + +### unbanUser() — 解封(恢复原始 UUID) + +```go +func (j *CheckDeviceLimitJob) unbanUser(email string, activeIPCount int, info *struct{...}) { + // 1. 从数据库获取原始客户端信息 + _, client, err := j.inboundService.GetClientByEmail(email) + + // 2. 删除封禁用的临时用户 + j.xrayApi.RemoveUser(info.Tag, email) + + // 3. 等待 5 秒 + time.Sleep(5000 * time.Millisecond) + + // 4. 用原始正确的 UUID/Password 添加回去 + j.xrayApi.AddUser(string(info.Protocol), info.Tag, clientMap) + + // 5. 移除封禁标记 + delete(ClientStatus, email) +} +``` + +### RandomUUID() — 生成随机 UUID + +```go +func RandomUUID() string { + uuid := make([]byte, 16) + rand.Read(uuid) + uuid[6] = (uuid[6] & 0x0f) | 0x40 + uuid[8] = (uuid[8] & 0x3f) | 0x80 + return hex.EncodeToString(uuid[0:4]) + "-" + hex.EncodeToString(uuid[4:6]) + "-" + + hex.EncodeToString(uuid[6:8]) + "-" + hex.EncodeToString(uuid[8:10]) + "-" + + hex.EncodeToString(uuid[10:16]) +} +``` + +### 关键依赖接口 + +| 接口 | 说明 | +|------|------| +| `j.inboundService.GetClientByEmail(email)` | 从数据库获取客户端原始配置(含 UUID/Password) | +| `j.xrayApi.RemoveUser(tag, email)` | 通过 gRPC 从 Xray-Core 移除用户 | +| `j.xrayApi.AddUser(protocol, tag, clientMap)` | 通过 gRPC 向 Xray-Core 添加用户 | +| `j.xrayService.GetApiPort()` | 获取 Xray API 端口号 | +| `j.xrayService.IsXrayRunning()` | 检查 Xray 是否运行中 | +| `j.telegramService.SendMessage(msg)` | 发送 Telegram 通知 | + +--- + +## 观察期防误封逻辑 + +**目的:** 解决用户切换网络时产生临时双 IP 导致误封的问题。 + +``` +场景 A:用户设备数超限,且当前未被封禁 +├─ 首次发现超限 → 记录时间,进入 3 分钟观察期,不封禁 +├─ 观察期内仍超限但未满 3 分钟 → 继续观察 +└─ 观察期满 3 分钟仍超限 → 确认封禁 + +场景 B:用户恢复正常(IP 数 ≤ 限制) +├─ 之前在观察名单中 → 移除观察记录,皆大欢喜 +└─ 之前被封禁 → 执行解封 +``` + +核心代码: + +```go +if activeIPCount > info.Limit && !isBanned { + startTime, exists := j.violationStartTime[email] + if !exists { + // 首次超限,开始观察 + j.violationStartTime[email] = time.Now() + continue + } + if time.Since(startTime) < 3*time.Minute { + // 还在观察期,暂不封禁 + continue + } + // 观察期结束,确认封禁 + delete(j.violationStartTime, email) + j.banUser(email, activeIPCount, &info) +} +``` + +--- + +## TTL 过期清理 + +- **活跃窗口:** 3 分钟 +- 每 10 秒执行一次清理 +- IP 在 `ActiveClientIPs` 中的 `lastSeen` 时间超过 3 分钟则删除 +- 用户所有 IP 被清理后,用户条目也移除 +- 被清理的已封禁用户在 `checkAllClientsLimit` 第二步中会被解封 + +--- + +## 遗留任务:CheckClientIpJob + +**源文件:** `web/job/check_client_ip_job.go` (lines 416-714) + +与 3x-ui 的实现完全一致: + +1. 解析 access.log,提取每个 email 的所有 IP +2. 与数据库中 `InboundClientIps` 记录对比 +3. 超过 `LimitIP` 的 IP 写入 `3xipl.log` +4. 依赖 Fail2ban 读取日志进行 iptables 封禁 +5. 每小时清理 access.log + +此任务由 cron 调度,与 `CheckDeviceLimitJob` 独立运行。 + +--- + +## 前端 UI + +**源文件:** `web/html/form/client.html` + +### 入站级别 + +`DeviceLimit` 字段不在 client 表单中显示,而是在入站配置中设置(具体 UI 未在提供的文件中)。 + +### 客户端级别 + +| 字段 | 行号 | 说明 | +|------|------|------| +| `client.limitIp` | 108 | IP 数量限制(遗留,Fail2ban 用) | +| `client.speedLimit` | 85-92 | 独立限速,单位 KB/s,0=不限速 | +| `client._totalGB` | 150 | 总流量限制 | +| `client._expiryTime` | 179-182 | 过期时间 | +| `client.reset` | 193 | 续期天数 | + +--- + +## 主程序启动与依赖注入 + +**源文件:** `main.go` + +### 服务初始化(runWebServer 函数) + +```go +// 1. 创建服务实例 +xrayService := service.XrayService{} +settingService := service.SettingService{} +serverService := service.ServerService{} +inboundService := service.InboundService{} + +// 2. 创建 Xray API 实例并注入 +xrayApi := xray.XrayAPI{} +xrayService.SetXrayAPI(xrayApi) +inboundService.SetXrayAPI(xrayApi) + +// 3. 初始化 Telegram Bot(如已启用) +if tgEnable { + tgBot := service.NewTgBot(...) + tgBotService = tgBot +} + +// 4. 注入 Telegram 服务 +serverService.SetTelegramService(tgBotService) +inboundService.SetTelegramService(tgBotService) +``` + +### 设备限制定时任务启动 + +```go +go func() { + time.Sleep(10 * time.Second) // 等待面板和 Xray 稳定 + + ticker := time.NewTicker(10 * time.Second) // 每 10 秒执行 + defer ticker.Stop() + + // 创建 Telegram 服务(可为 nil) + var tgBotService service.TelegramService + if tgEnable { + tgBotService = new(service.Tgbot) + } + + // 创建任务实例 + checkJob := job.NewCheckDeviceLimitJob(&xrayService, tgBotService) + + // 无限循环 + for { + <-ticker.C + checkJob.Run() + } +}() +``` + +--- + +## 关键日志路径 + +| 路径 | 说明 | +|------|------| +| `config.GetLogFolder() + "/3xipl.log"` | IP 限制日志(遗留 Fail2ban 用) | +| `config.GetLogFolder() + "/3xipl-banned.log"` | 封禁日志 | +| `config.GetLogFolder() + "/3xipl-ap.log"` | 持久化访问日志 | +| Xray access log(配置中指定) | 用户连接日志,设备限制解析源 | +| `config.GetBinFolderPath() + "/core_crash_*.log"` | 崩溃报告 | + +--- + +## 与 3x-ui 的差异总结 + +| 特性 | 3x-ui | x-panel | +|------|-------|---------| +| IP 限制级别 | per-client (`LimitIP`) | per-inbound (`DeviceLimit`) + per-client 遗留 | +| 封禁方式 | Fail2ban + iptables | Xray API UUID 替换 | +| 活跃 IP 跟踪 | 无(全量日志分析) | 内存 map + 3 分钟 TTL | +| 误封防护 | 无 | 3 分钟观察期 | +| 解封机制 | Fail2ban unban | 恢复原始 UUID | +| 通知 | 无 | Telegram Bot 集成 | +| 限速 | 无 | per-client `SpeedLimit` (KB/s) | +| 调度方式 | cron 10s | goroutine + Ticker 10s | +| 依赖 | Fail2ban, iptables | Xray gRPC API | diff --git a/docs/x-ui-logic.md b/docs/x-ui-logic.md new file mode 100644 index 00000000..34dc692e --- /dev/null +++ b/docs/x-ui-logic.md @@ -0,0 +1,937 @@ +# x-ui.sh 逻辑文档 + +## 概述 + +`x-ui.sh` 是 3x-ui 面板的管理脚本,提供 26 个交互式菜单选项和 15 个子命令,涵盖面板的安装、更新、卸载、凭据管理、服务控制、SSL 证书、防火墙、Fail2ban IP 限制、BBR 加速、Geo 文件更新等功能。 + +--- + +## 全局配置 + +### 颜色变量 + +| 变量 | 值 | 用途 | +|---------|----------------|----------| +| `red` | `\033[0;31m` | 红色 | +| `green` | `\033[0;32m` | 绿色 | +| `blue` | `\033[0;34m` | 蓝色 | +| `yellow`| `\033[0;33m` | 黄色 | +| `plain` | `\033[0m` | 重置 | + +### 日志函数 + +| 函数 | 前缀 | 用途 | +|---------|-----------|------------| +| `LOGD()` | `[调试]` | 调试信息 | +| `LOGE()` | `[错误]` | 错误信息 | +| `LOGI()` | `[信息]` | 普通信息 | + +### 路径变量 + +| 变量 | 默认值 | 说明 | +|-------------------------|---------------------------|-------------------------| +| `xui_folder` | `/usr/local/x-ui` | x-ui 安装目录 | +| `xui_service` | `/etc/systemd/system` | systemd 服务文件目录 | +| `log_folder` | `/var/log/x-ui` | 日志目录 | +| `iplimit_log_path` | `.../3xipl.log` | IP 限制日志 | +| `iplimit_banned_log_path`| `.../3xipl-banned.log` | IP 封禁日志 | + +### 辅助函数 + +| 函数 | 功能 | +|-----------------------|----------------------------------------------| +| `confirm()` | 通用确认提示,支持自定义默认值 | +| `confirm_restart()` | 确认后重启面板(重启 x-ui 也会重启 xray) | +| `before_show_menu()` | 按回车返回主菜单 | +| `gen_random_string()` | 通过 openssl 生成指定长度的随机字母数字字符串 | +| `is_port_in_use()` | 端口占用检测(ss → netstat → lsof) | +| `is_ipv4/is_ipv6/is_ip/is_domain()` | IP/域名格式验证 | + +--- + +## 入口流程 + +``` +x-ui.sh 被执行 + ├─ 检查 root 权限 + ├─ 检测操作系统发行版和版本号 + ├─ 初始化路径和日志目录 + │ + ├─ 有命令行参数 → 执行对应子命令(不显示菜单) + └─ 无参数 → 显示交互式菜单 show_menu() + ├─ 显示当前状态(运行/停止/未安装 + 开机自启 + xray 状态) + ├─ 读取用户输入 [0-26] + └─ 根据选择调用对应功能 +``` + +--- + +## 主菜单 (show_menu) + +``` +╔────────────────────────────────────────────────╗ +│ 0. 退出脚本 │ +│────────────────────────────────────────────────│ +│ 1. 安装 2. 更新 3. 更新菜单 │ +│ 4. 安装旧版本 5. 卸载 │ +│────────────────────────────────────────────────│ +│ 6. 重置用户名和密码 7. 重置 Web 路径 │ +│ 8. 重置设置 9. 修改端口 │ +│ 10. 查看当前设置 │ +│────────────────────────────────────────────────│ +│ 11. 启动 12. 停止 13. 重启 │ +│ 14. 重启 Xray 15. 查看状态 │ +│ 16. 日志管理 │ +│────────────────────────────────────────────────│ +│ 17. 设置开机自启 18. 取消开机自启 │ +│────────────────────────────────────────────────│ +│ 19. SSL 证书管理 20. Cloudflare SSL │ +│ 21. IP 限制管理 22. 防火墙管理 │ +│ 23. SSH 端口转发管理 │ +│────────────────────────────────────────────────│ +│ 24. BBR 管理 25. 更新 Geo 文件 │ +│ 26. 网速测试 (Speedtest) │ +╚────────────────────────────────────────────────╝ +``` + +大部分选项在执行前调用 `check_install`(检查面板是否已安装)或 `check_uninstall`(检查面板是否未安装),防止误操作。 + +--- + +## 状态检测函数 + +| 函数 | 返回值 | 逻辑 | +|------------------------|---------------------------|-------------------------------------------| +| `check_status()` | 0=运行中, 1=未运行, 2=未安装 | Alpine 检查 init.d,其他检查 systemd | +| `check_enabled()` | 0=已启用, 1=未启用 | Alpine 检查 rc-update,其他检查 systemctl | +| `check_xray_status()` | 0=运行中, 1=未运行 | ps 查找 xray-linux 进程 | +| `check_install()` | 前置检查 | 未安装则提示并返回菜单 | +| `check_uninstall()` | 前置检查 | 已安装则提示"勿重复安装"并返回菜单 | + +--- + +## 菜单选项详解 + +### 选项 0:退出脚本 + +```bash +exit 0 +``` + +直接退出,无额外逻辑。 + +--- + +### 选项 1:安装 + +**函数**:`install()` + +``` +下载并执行 install.sh(从 GitHub raw 文件) + └─ 成功后自动调用 start() +``` + +- 执行 `bash <(curl -Ls https://raw.githubusercontent.com/Sora39831/3x-ui/main/install.sh)` +- 安装成功后自动启动面板 + +--- + +### 选项 2:更新 + +**函数**:`update()` + +``` +确认提示:"更新所有 x-ui 组件到最新版本,数据不会丢失" + ├─ 取消 → 返回菜单 + └─ 确认 → 执行 update.sh(从 GitHub 下载) + └─ 成功 → "更新完成,面板已自动重启" +``` + +--- + +### 选项 3:更新菜单 + +**函数**:`update_menu()` + +``` +确认提示 + └─ 确认 → 下载最新 x-ui.sh 到 /usr/bin/x-ui + └─ 成功 → "更新成功" 并 exit 0 +``` + +仅更新管理脚本自身,不影响面板程序。 + +--- + +### 选项 4:安装旧版本 + +**函数**:`legacy_version()` + +``` +提示用户输入版本号(如 2.4.0) + ├─ 空 → 退出 + └─ 有效 → 执行对应版本的 install.sh,传入版本参数 +``` + +- 下载指定 tag 的 install.sh:`v$tag_version/install.sh` +- 传入参数 `v$tag_version` 进行安装 +- install.sh 内部会验证版本 ≥ v2.3.5 + +--- + +### 选项 5:卸载 + +**函数**:`uninstall()` + +``` +确认:"卸载面板?xray 也会被卸载!"(默认 n) + ├─ 取消 → 返回菜单 + └─ 确认 → + Alpine: rc-service stop → rc-update del → rm init.d + 其他: systemctl stop → disable → rm service → daemon-reload → reset-failed + 删除 /etc/x-ui/ 和 ${xui_folder}/ + 显示重装命令 + 删除脚本自身(trap SIGTERM → rm $0) +``` + +--- + +### 选项 6:重置用户名和密码 + +**函数**:`reset_user()` + +``` +确认提示(默认 n) + └─ 确认 → + 输入用户名(默认随机 10 位) + 输入密码(默认随机 18 位) + 询问是否禁用双因素认证 + ├─ 是 → -resetTwoFactor true + └─ 否 → -resetTwoFactor false + 应用设置:x-ui setting -username ... -password ... + 确认后重启面板 +``` + +--- + +### 选项 7:重置 Web 路径 + +**函数**:`reset_webbasepath()` + +``` +确认提示 + └─ 确认 → 生成随机 18 位字符串 + 应用:x-ui setting -webBasePath ... + 重启面板 +``` + +--- + +### 选项 8:重置设置 + +**函数**:`reset_config()` + +``` +确认:"重置所有面板设置?账户数据不会丢失,用户名和密码不会改变"(默认 n) + └─ 确认 → x-ui setting -reset + 重启面板 +``` + +仅重置面板配置,不影响账户数据库。 + +--- + +### 选项 9:修改端口 + +**函数**:`set_port()` + +``` +输入端口号 [1-65535] + ├─ 空 → 取消 + └─ 有效 → x-ui setting -port ${port} + 确认后重启面板 +``` + +--- + +### 选项 10:查看当前设置 + +**函数**:`check_config()` + +``` +获取面板设置(x-ui setting -show true) +获取公网 IP(api.ipify.org → 4.ident.me) + +检查是否有证书: + ├─ 有证书 → 从证书路径提取域名,显示 https://域名:端口/路径 + └─ 无证书 → + 显示警告 + 询问是否为 IP 生成 SSL 证书 + ├─ 是 → 停止面板 → ssl_cert_issue_for_ip() → 启动面板 + └─ 否 → 显示 http://IP:端口/路径,建议使用选项 19 +``` + +--- + +### 选项 11:启动 + +**函数**:`start()` + +``` +检查当前状态 + ├─ 运行中 → "面板正在运行,无需重复启动" + └─ 未运行 → + Alpine: rc-service x-ui start + 其他: systemctl start x-ui + 等待 2 秒后再次检查状态 + ├─ 成功 → "x-ui 启动成功" + └─ 失败 → "面板启动失败,可能是因为启动时间超过两秒" +``` + +--- + +### 选项 12:停止 + +**函数**:`stop()` + +``` +检查当前状态 + ├─ 已停止 → "面板已停止,无需重复停止!" + └─ 运行中 → + Alpine: rc-service x-ui stop + 其他: systemctl stop x-ui + 等待 2 秒后检查状态 + ├─ 成功 → "x-ui 和 xray 已停止" + └─ 失败 → "面板停止失败" +``` + +--- + +### 选项 13:重启 + +**函数**:`restart()` + +``` +Alpine: rc-service x-ui restart +其他: systemctl restart x-ui +等待 2 秒后检查状态 + ├─ 成功 → "x-ui 和 xray 重启成功" + └─ 失败 → "面板重启失败" +``` + +--- + +### 选项 14:重启 Xray + +**函数**:`restart_xray()` + +``` +systemctl reload x-ui ← 发送 reload 信号,不重启面板本身 +"已发送重启信号,请查看日志确认" +等待 2 秒 → 显示 xray 运行状态 +``` + +与选项 13 的区别:选项 13 重启整个 x-ui 服务,选项 14 仅重载 xray-core。 + +--- + +### 选项 15:查看状态 + +**函数**:`status()` + +``` +Alpine: rc-service x-ui status +其他: systemctl status x-ui -l +``` + +显示完整的 systemd/服务状态信息。 + +--- + +### 选项 16:日志管理 + +**函数**:`show_log()` + +``` +Alpine: + 1. 调试日志 → grep 'x-ui[' /var/log/messages + 0. 返回 + +其他 (systemd): + 1. 调试日志 → journalctl -u x-ui -e --no-pager -f -p debug + 2. 清除所有日志 → journalctl --rotate → --vacuum-time=1s → 重启面板 + 0. 返回 +``` + +--- + +### 选项 17:设置开机自启 + +**函数**:`enable()` + +``` +Alpine: rc-update add x-ui default +其他: systemctl enable x-ui +``` + +--- + +### 选项 18:取消开机自启 + +**函数**:`disable()` + +``` +Alpine: rc-update del x-ui +其他: systemctl disable x-ui +``` + +--- + +### 选项 19:SSL 证书管理 + +**函数**:`ssl_cert_issue_main()` — 子菜单入口 + +#### 子菜单 + +``` +1. 获取 SSL(域名) +2. 吊销证书 +3. 强制续期 +4. 查看已有域名 +5. 为面板设置证书路径 +6. 为 IP 地址获取 SSL(6 天证书,自动续期) +0. 返回主菜单 +``` + +#### 子选项 1:获取 SSL(域名证书) + +**函数**:`ssl_cert_issue()` + +``` +检查/安装 acme.sh +按发行版安装 socat + +获取并验证域名(循环直到有效) +检查是否已有该域名的证书(acme.sh --list) + +创建证书目录 /root/cert/${domain}/ + +选择端口(默认 80) + +签发证书: + acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force + ↳ 失败 → 清理并退出 + +设置 reloadcmd: + 默认:x-ui restart + 可选:systemctl reload nginx ; x-ui restart + 可选:自定义命令 + +安装证书: + acme.sh --installcert + --key-file /root/cert/${domain}/privkey.pem + --fullchain-file /root/cert/${domain}/fullchain.pem + --reloadcmd ${reloadCmd} + +启用自动续期:acme.sh --upgrade --auto-upgrade +设置文件权限:privkey.pem → 600, fullchain.pem → 644 + +询问是否为面板设置证书: + ├─ 是 → x-ui cert -webCert ... -webCertKey ... → 重启 + └─ 否 → 跳过 +``` + +#### 子选项 2:吊销证书 + +``` +列出 /root/cert/ 下所有域名目录 +选择域名 → acme.sh --revoke -d ${domain} +``` + +#### 子选项 3:强制续期 + +``` +列出所有域名 +选择域名 → acme.sh --renew -d ${domain} --force +``` + +#### 子选项 4:查看已有域名 + +``` +遍历 /root/cert/ 下的域名目录 +显示每个域名的 fullchain.pem 和 privkey.pem 路径 +缺失文件的标记为"证书或密钥缺失" +``` + +#### 子选项 5:为面板设置证书路径 + +``` +列出所有域名 +选择域名 → 验证文件存在 + x-ui cert -webCert ... -webCertKey ... + 重启面板 +``` + +#### 子选项 6:为 IP 地址获取 SSL + +**函数**:`ssl_cert_issue_for_ip()` + +``` +获取服务器公网 IP(api.ipify.org → 4.ident.me) +询问是否包含 IPv6 地址 +检查/安装 acme.sh +按发行版安装 socat + +创建证书目录 /root/cert/ip/ +构建域名参数:-d ${server_ip} [-d ${ipv6}] + +选择 HTTP-01 监听端口(默认 80) + └─ 循环检测端口占用,被占用则提示换端口 + +签发证书: + acme.sh --issue + -d ${server_ip} [-d ${ipv6}] + --standalone --server letsencrypt + --certificate-profile shortlived + --days 6 --httpport ${WebPort} --force + +安装证书(不依赖退出码,通过检查文件判断成功) +启用自动续期 +设置文件权限 + +为面板设置证书路径 → 显示 https://IP:端口/路径 → 重启面板 +``` + +--- + +### 选项 20:Cloudflare SSL 证书 + +**函数**:`ssl_cert_issue_CF()` + +``` +显示使用说明(需要:邮箱、全局 API 密钥、域名) +确认提示 + +检查/安装 acme.sh + +输入域名 (CF_Domain) +输入 API 密钥 (CF_GlobalKey) +输入注册邮箱 (CF_AccountEmail) + +设置 CA 为 Let's Encrypt +导出环境变量:CF_Key, CF_Email + +签发通配符证书: + acme.sh --issue --dns dns_cf -d ${domain} -d *.${domain} --force + ↳ 使用 Cloudflare DNS 验证 + +创建证书目录 /root/cert/${domain}/ + +设置 reloadcmd(同域名证书流程) +安装证书(含 *.${domain} 通配符) +启用自动续期 + +询问是否为面板设置证书 → 同域名证书流程 +``` + +**特点**:支持通配符证书 `*.domain.com`,不需要开放 80 端口(使用 DNS 验证)。 + +--- + +### 选项 21:IP 限制管理(Fail2ban) + +**函数**:`iplimit_main()` — 子菜单入口 + +#### 子菜单 + +``` + 1. 安装 Fail2ban 并配置 IP 限制 + 2. 修改封禁时长 + 3. 解封所有人 + 4. 封禁日志 + 5. 封禁指定 IP 地址 + 6. 解封指定 IP 地址 + 7. 实时日志 + 8. 服务状态 + 9. 重启服务 +10. 卸载 Fail2ban 和 IP 限制 + 0. 返回主菜单 +``` + +#### 子选项 1:安装 Fail2ban + +**函数**:`install_iplimit()` + +``` +检查 Fail2ban 是否已安装 + └─ 未安装 → 按发行版安装: + Ubuntu ≥ 24: 额外安装 python3-pip + pyasynchat + Debian ≥ 12: 额外安装 python3-systemd + CentOS 7: 先装 epel-release + +清除 jail 配置冲突(iplimit_remove_conflicts) +创建日志文件(3xipl.log, 3xipl-banned.log) +创建 jail 配置(create_iplimit_jails) +启动并启用 Fail2ban 服务 +``` + +**Jail 配置详情** (`create_iplimit_jails`): + +```ini +# /etc/fail2ban/jail.d/3x-ipl.conf +[3x-ipl] +enabled=true +backend=auto +filter=3x-ipl +action=3x-ipl +logpath=/var/log/x-ui/3xipl.log +maxretry=2 +findtime=32 +bantime=30m # 默认 30 分钟,可通过子选项 2 修改 +``` + +**过滤器**:匹配 `[LIMIT_IP] Email=... || Disconnecting OLD IP=... || Timestamp=...` 格式的日志行。 + +**动作**:使用 iptables 封禁/解封 IP,同时写入封禁日志文件。 + +#### 子选项 2:修改封禁时长 + +``` +输入新的封禁时长(分钟) +重新生成 jail 配置 → 重启 Fail2ban +``` + +#### 子选项 3:解封所有人 + +``` +fail2ban-client reload --restart --unban 3x-ipl +清空封禁日志文件 +``` + +#### 子选项 5/6:手动封禁/解封 IP + +``` +输入 IP 地址 → 正则验证(IPv4/IPv6) + fail2ban-client set 3x-ipl banip/unbanip "$ip" +``` + +#### 子选项 10:卸载 + +``` +选项 1:仅移除 IP 限制配置(保留 Fail2ban) + 删除 filter.d/3x-ipl.conf, action.d/3x-ipl.conf, jail.d/3x-ipl.conf + 重启 Fail2ban + +选项 2:完全卸载 + 删除 /etc/fail2ban + 停止服务 + 按发行版卸载 fail2ban 包 + autoremove +``` + +--- + +### 选项 22:防火墙管理 + +**函数**:`firewall_menu()` — 子菜单入口(基于 UFW) + +#### 子菜单 + +``` +1. 安装防火墙 +2. 端口列表 [带编号] +3. 开放端口 +4. 删除列表中的端口 +5. 启用防火墙 +6. 禁用防火墙 +7. 防火墙状态 +0. 返回主菜单 +``` + +#### 子选项 1:安装防火墙 + +**函数**:`install_firewall()` + +``` +检查 ufw 是否安装 → 未安装则 apt-get install ufw +检查防火墙是否激活 → 未激活则: + ufw allow ssh + ufw allow http + ufw allow https + ufw allow 2053/tcp ← webPort + ufw allow 2096/tcp ← subport + ufw --force enable +``` + +#### 子选项 3:开放端口 + +**函数**:`open_ports()` + +``` +输入端口(逗号分隔或范围,如 80,443,2053 或 400-500) +验证输入格式 +逐个处理: + 范围 → ufw allow start:end/tcp + ufw allow start:end/udp + 单端口 → ufw allow port +确认显示已开放的端口 +``` + +#### 子选项 4:删除端口 + +**函数**:`delete_ports()` + +``` +显示当前规则(ufw status numbered) +选择删除方式: + 1. 按规则编号删除 → ufw delete $number + 2. 按端口号删除 → ufw delete allow $port +确认显示已删除的端口 +``` + +**注意**:原始代码中选项 4 有一个已知 bug(`firewall_wall_menu` 应为 `firewall_menu`),这会导致删除端口后不返回菜单。 + +--- + +### 选项 23:SSH 端口转发管理 + +**函数**:`SSH_port_forwarding()` + +``` +获取服务器公网 IP(多 API 轮询) +读取当前面板设置: + - webBasePath, port, listenIP, cert, key + +判断状态: + ├─ 已有证书+密钥 → "面板已配置 SSL,安全" → 返回 + ├─ 无证书且 listenIP 为空或 0.0.0.0 → "面板不安全" 警告 + └─ listenIP 已设置且非 0.0.0.0 → 显示 SSH 转发命令 + +子菜单: + 1. 设置监听 IP + ├─ 默认 127.0.0.1 或自定义 + ├─ x-ui setting -listenIP ${ip} + └─ 显示 SSH 转发命令: + ssh -L 2222:${listenIP}:${port} root@${server_ip} + 访问 http://localhost:2222${webBasePath} + + 2. 清除监听 IP + └─ x-ui setting -listenIP 0.0.0.0 → 重启 + + 0. 返回 +``` + +**用途**:将面板绑定到 127.0.0.1,只能通过 SSH 隧道访问,提高安全性。 + +--- + +### 选项 24:BBR 管理 + +**函数**:`bbr_menu()` — 子菜单入口 + +#### 子菜单 + +``` +1. 启用 BBR +2. 禁用 BBR +0. 返回主菜单 +``` + +#### 启用 BBR + +**函数**:`enable_bbr()` + +``` +检查是否已启用(tcp_congestion_control == bbr 且 default_qdisc 为 fq/cake) + ├─ 已启用 → 直接返回 + └─ 未启用 → + 有 /etc/sysctl.d/ → + 创建 /etc/sysctl.d/99-bbr-x-ui.conf: + net.core.default_qdisc = fq + net.ipv4.tcp_congestion_control = bbr + 注释 sysctl.conf 中的旧设置 + sysctl --system + 无 /etc/sysctl.d/ → + 直接修改 /etc/sysctl.conf + sysctl -p + +验证:tcp_congestion_control == bbr → "BBR 已成功启用" +``` + +**特性**:启用前会备份当前设置(写入注释行 `#旧qdisc:旧拥塞控制`),以便禁用时恢复。 + +#### 禁用 BBR + +**函数**:`disable_bbr()` + +``` +检查是否已启用 → 未启用则返回 + +有 99-bbr-x-ui.conf → + 读取备份的旧设置 + 恢复 net.core.default_qdisc 和 net.ipv4.tcp_congestion_control + 删除配置文件 + sysctl --system + +无 99-bbr-x-ui.conf → + 将 sysctl.conf 中的 fq→pfifo_fast, bbr→cubic + sysctl -p + +验证:tcp_congestion_control != bbr → "BBR 已成功替换为 CUBIC" +``` + +--- + +### 选项 25:更新 Geo 文件 + +**函数**:`update_geo()` — 子菜单入口 + +#### 子菜单 + +``` +1. Loyalsoldier (geoip.dat, geosite.dat) +2. chocolate4u (geoip_IR.dat, geosite_IR.dat) +3. runetfreedom (geoip_RU.dat, geosite_RU.dat) +4. 全部更新 +0. 返回主菜单 +``` + +#### 数据源 + +| 选项 | 数据源 | 文件 | 用途 | +|------|---------------------------------------|------------------------------|------------------| +| 1 | Loyalsoldier/v2ray-rules-dat | geoip.dat, geosite.dat | 通用规则 | +| 2 | chocolate4u/Iran-v2ray-rules | geoip_IR.dat, geosite_IR.dat | 伊朗规则 | +| 3 | runetfreedom/russia-v2ray-rules-dat | geoip_RU.dat, geosite_RU.dat | 俄罗斯规则 | +| 4 | 以上全部 | 全部 6 个文件 | 一键更新 | + +**下载逻辑** (`update_geofiles`): + +``` +每个文件: + curl -fLRo ${xui_folder}/bin/${dat}.dat + -z ${xui_folder}/bin/${dat}.dat ← 仅在远程更新时下载 + https://github.com/${source}/releases/latest/download/${remote_file}.dat +``` + +`-z` 参数确保只有远程文件比本地新时才下载,节省带宽。 + +更新后自动重启面板以加载新规则。 + +--- + +### 选项 26:网速测试 (Speedtest) + +**函数**:`run_speedtest()` + +``` +检查 speedtest 命令是否存在 + └─ 不存在 → + 有 snap → snap install speedtest + 无 snap → 按包管理器安装: + dnf/yum → rpm 包源 + apt-get/apt → deb 包源 + curl 安装脚本 → 包管理器安装 + +执行 speedtest +``` + +--- + +## 子命令(命令行模式) + +当脚本以参数调用时(如 `x-ui start`),跳过交互菜单直接执行: + +| 子命令 | 对应菜单 | 附加行为 | +|------------------------|----------|-------------------------------| +| `start` | 11 | 执行后不返回菜单 | +| `stop` | 12 | 执行后不返回菜单 | +| `restart` | 13 | 执行后不返回菜单 | +| `restart-xray` | 14 | 执行后不返回菜单 | +| `status` | 15 | 执行后不返回菜单 | +| `settings` | 10 | 执行后不返回菜单 | +| `enable` | 17 | 执行后不返回菜单 | +| `disable` | 18 | 执行后不返回菜单 | +| `log` | 16 | 执行后不返回菜单 | +| `banlog` | 4(限制) | 执行后不返回菜单 | +| `update` | 2 | 执行后不返回菜单 | +| `legacy` | 4 | 执行后不返回菜单 | +| `install` | 1 | 使用 check_uninstall 前置检查 | +| `uninstall` | 5 | 执行后不返回菜单 | +| `update-all-geofiles` | 25-4 | 更新后自动重启 | +| 无效参数 | — | 显示用法帮助 | + +所有子命令传递参数 `0` 给功能函数,使其执行后不调用 `before_show_menu()` 返回菜单。 + +--- + +## 调用关系总览 + +``` +x-ui.sh + │ + ├─ show_menu() + │ ├─ show_status() → check_status() + show_enable_status() + show_xray_status() + │ ├─ 0: exit + │ ├─ 1: install() → install.sh → start() + │ ├─ 2: update() → update.sh + │ ├─ 3: update_menu() → 下载 x-ui.sh + │ ├─ 4: legacy_version() → install.sh v$version + │ ├─ 5: uninstall() → 停止服务 + 删除文件 + │ ├─ 6: reset_user() → x-ui setting -username/-password + │ ├─ 7: reset_webbasepath() → x-ui setting -webBasePath + │ ├─ 8: reset_config() → x-ui setting -reset + │ ├─ 9: set_port() → x-ui setting -port + │ ├─ 10: check_config() → x-ui setting -show + ssl_cert_issue_for_ip() + │ ├─ 11: start() → systemctl/rc-service start + │ ├─ 12: stop() → systemctl/rc-service stop + │ ├─ 13: restart() → systemctl/rc-service restart + │ ├─ 14: restart_xray() → systemctl reload + │ ├─ 15: status() → systemctl/rc-service status + │ ├─ 16: show_log() → journalctl/grep messages + │ ├─ 17: enable() → systemctl/rc-update enable + │ ├─ 18: disable() → systemctl/rc-update disable + │ ├─ 19: ssl_cert_issue_main() + │ │ ├─ 1: ssl_cert_issue() → acme.sh 域名证书 + │ │ ├─ 2: 吊销证书 → acme.sh --revoke + │ │ ├─ 3: 强制续期 → acme.sh --renew --force + │ │ ├─ 4: 查看已有域名 + │ │ ├─ 5: 设置面板证书路径 + │ │ └─ 6: ssl_cert_issue_for_ip() → acme.sh IP 短期证书 + │ ├─ 20: ssl_cert_issue_CF() → acme.sh Cloudflare DNS 通配符证书 + │ ├─ 21: iplimit_main() + │ │ ├─ 1: install_iplimit() → install fail2ban + create_iplimit_jails() + │ │ ├─ 2: 修改封禁时长 + │ │ ├─ 3: 解封所有人 + │ │ ├─ 4: show_banlog() + │ │ ├─ 5/6: 手动封禁/解封 IP + │ │ ├─ 7: tail -f fail2ban.log + │ │ ├─ 8/9: 服务状态/重启 + │ │ └─ 10: remove_iplimit() + │ ├─ 22: firewall_menu() → UFW 防火墙管理 + │ ├─ 23: SSH_port_forwarding() → 设置 listenIP 为 127.0.0.1 + │ ├─ 24: bbr_menu() → enable_bbr() / disable_bbr() + │ ├─ 25: update_geo() → update_geofiles() → 下载 geoip/geosite .dat + │ └─ 26: run_speedtest() → speedtest + │ + └─ 子命令模式($# > 0) + └─ case $1 in "start"|"stop"|... → 对应函数 0 +``` + +--- + +## 关键设计决策 + +1. **Alpine 兼容**:所有服务管理操作都区分 Alpine (OpenRC) 和其他系统 (systemd),通过 `$release` 变量判断。 + +2. **操作确认**:危险操作(卸载、重置凭据等)默认为 "n",防止误操作。安全操作(更新等)默认为 "y"。 + +3. **子命令模式**:支持 `x-ui start` 等非交互式调用,传递参数 `0` 抑制 `before_show_menu()` 的回车等待。 + +4. **状态前置检查**:大多数菜单选项先调用 `check_install` 或 `check_uninstall`,确保操作的前提条件满足。 + +5. **等待机制**:start/stop/restart 后等待 2 秒再检查状态,给 systemd/init.d 足够时间完成操作。 + +6. **Geo 文件条件下载**:使用 `curl -z` 参数,仅在远程文件比本地新时才下载,节省带宽和时间。 + +7. **BBR 备份恢复**:启用 BBR 前将当前设置备份到注释行中,禁用时精确恢复原始值。 + +8. **Fail2ban jail 隔离**:IP 限制使用独立的 `3x-ipl` jail,与系统默认 jail 分离,互不影响。 diff --git a/web/assets/codemirror/yaml.js b/web/assets/codemirror/yaml.js new file mode 100644 index 00000000..f0bf4bac --- /dev/null +++ b/web/assets/codemirror/yaml.js @@ -0,0 +1 @@ +!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0'"%@`][^\s'":]|[^,\[\]{}#&*!|>'"%@`])[^#]*?(?=\s*:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")}); \ No newline at end of file diff --git a/web/html/settings.html b/web/html/settings.html index 6447650c..0255f7c8 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -1,4 +1,6 @@ {{ template "page/head_start" .}} + + {{ template "page/head_end" .}} {{ template "page/body_start" .}} @@ -62,7 +64,7 @@ - +