mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-15 01:04:22 +08:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 508a37a84c | |||
| d60655045a | |||
| 31ef861504 | |||
| f1bdb2e2ef | |||
| bd30b61018 | |||
| e0dd70a054 | |||
| 4966a8aad1 | |||
| 3e11549370 | |||
| addf83a249 | |||
| c3e35fd416 | |||
| 775dfe19f1 | |||
| db3b2f651b | |||
| 669323f926 | |||
| 7202b69e4e | |||
| 31977a62e6 | |||
| 87479c2ac1 | |||
| ffda0fb71a | |||
| 9c0e7341c3 | |||
| 1db5452be9 | |||
| c10f894afd | |||
| 7fb75baa73 | |||
| 15e6cd69eb | |||
| f6eb88d75e | |||
| f45b580984 | |||
| 4f50c47550 | |||
| 2e1d75dc36 | |||
| f496f58a4d | |||
| 32474bec20 | |||
| 581cda7edc | |||
| 96aebb8d61 | |||
| 735fd40786 | |||
| a3b0bf4898 | |||
| 9703e4a081 | |||
| a43653f252 | |||
| 348900de01 | |||
| b93c259fac | |||
| 2e3d5c9249 | |||
| c8c1841058 | |||
| 1c596fae4b | |||
| 2ff52e3275 | |||
| 7efb49bdab | |||
| a00b20abf3 | |||
| 1450b25475 | |||
| b815be54b8 | |||
| 75edeb9afa | |||
| 7c54192055 | |||
| 7ba68778c1 | |||
| ef613c1518 |
@@ -0,0 +1,32 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_349a99cf4ffeQMqVmv7PqyI3y6
|
||||
Commit Message: 普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
|
||||
|
||||
## Summary
|
||||
|
||||
## 分析结果
|
||||
|
||||
### 当前状态
|
||||
- **角色系统**: `roleID == 0` 为管理员,`roleID != 0` 为普通用户
|
||||
- **问题**: 普通用户在创建/编辑转发时可设置 `speedId`(限速) 和 `inPort`(自定义端口)
|
||||
|
||||
### 实施方案
|
||||
|
||||
**后端修改** (`go-backend/internal/http/handler/mutations.go`):
|
||||
- `forwardCreate`: 检查 `roleID != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
- `forwardUpdate`: 检查 `actorRole != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
|
||||
**前端修改** (`vite-frontend/src/pages/forward.tsx`):
|
||||
- 限速规则选择器和入口端口输入框使用 `{isAdmin && (...)}` 条件渲染
|
||||
|
||||
**测试**:
|
||||
- 添加契约测试验证权限限制
|
||||
|
||||
计划文档: `plans/009-restrict-user-forward-permissions.md`
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
|
||||
@@ -0,0 +1,19 @@
|
||||
## 分析结果
|
||||
|
||||
### 当前状态
|
||||
- **角色系统**: `roleID == 0` 为管理员,`roleID != 0` 为普通用户
|
||||
- **问题**: 普通用户在创建/编辑转发时可设置 `speedId`(限速) 和 `inPort`(自定义端口)
|
||||
|
||||
### 实施方案
|
||||
|
||||
**后端修改** (`go-backend/internal/http/handler/mutations.go`):
|
||||
- `forwardCreate`: 检查 `roleID != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
- `forwardUpdate`: 检查 `actorRole != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
|
||||
**前端修改** (`vite-frontend/src/pages/forward.tsx`):
|
||||
- 限速规则选择器和入口端口输入框使用 `{isAdmin && (...)}` 条件渲染
|
||||
|
||||
**测试**:
|
||||
- 添加契约测试验证权限限制
|
||||
|
||||
计划文档: `plans/009-restrict-user-forward-permissions.md`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta6
|
||||
@@ -0,0 +1,5 @@
|
||||
完成:
|
||||
- 已提交并推送变更
|
||||
- PR #259 已合并到 main
|
||||
- 已创建并推送 tag `2.1.7-beta6`
|
||||
- 已删除远程分支
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
转发选择限速后保存,然后把限速删除,再次编辑转发报错会报错限速不存在,请保存的时候限速不存在直接删除限速,不阻断提示限速不存在
|
||||
@@ -0,0 +1,20 @@
|
||||
我已经定位到问题点了,确实在后端 `forward/update` 的限速校验会把“已被删除的限速ID”当成硬错误返回,导致你说的二次编辑报 `限速规则不存在`。
|
||||
|
||||
**定位结果**
|
||||
- 触发点在 `go-backend/internal/http/handler/mutations.go:1259` 附近(`forwardUpdate`)和 `go-backend/internal/http/handler/mutations.go:1147` 附近(`forwardCreate`)。
|
||||
- 当前逻辑是:只要请求里带了 `speedId`,且 `SpeedLimitExists=false`,就直接 `ErrDefault("限速规则不存在")` 返回。
|
||||
- 前端编辑提交在 `vite-frontend/src/pages/forward.tsx:1403` 会一直带 `speedId`(包括编辑场景),所以很容易复现你说的问题。
|
||||
|
||||
**实施计划(不改动其他业务)**
|
||||
- 新建计划文档:`plans/006-forward-save-missing-speed-limit-auto-clear.md`(按现有 001~005 递增)。
|
||||
- 后端仅改转发保存逻辑,不改用户隧道限速逻辑:
|
||||
- `forwardCreate`:若 `speedId` 不存在,自动当成 `nil`(即“不限速”)继续保存,不返回“限速不存在”错误。
|
||||
- `forwardUpdate`:按“最终要写入的 speed_id”做一次兜底校验;若不存在则自动清空为 `NULL` 再继续保存,确保编辑不阻断。
|
||||
- 不新增“限速不存在”阻断提示(符合你要求)。
|
||||
- 增加/更新合约测试:`go-backend/tests/contract/forward_contract_test.go`
|
||||
- 覆盖场景:转发绑定限速后,删除该限速,再编辑转发保存,应返回成功且 `forward.speed_id` 被清空。
|
||||
- 验证命令(实施后执行):
|
||||
- `cd go-backend && go test ./tests/contract/... -run Forward`
|
||||
- 如有必要再跑 `cd go-backend && go test ./...`
|
||||
|
||||
如果你确认,我下一步就按这个方案进入实现。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta5
|
||||
@@ -0,0 +1,7 @@
|
||||
✅ 已完成:
|
||||
- 提交变更:feat: auto-fallback to default bind IP when invalid bind address detected
|
||||
- 推送分支:opencode/mighty-nebula → origin
|
||||
- 创建PR:#258(已自动合并)
|
||||
- 发布tag:2.1.7-beta5
|
||||
|
||||
PR: https://github.com/Sagit-chu/flvx/pull/258
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta4
|
||||
@@ -0,0 +1,6 @@
|
||||
Tag `2.1.7-beta4` 已在 GitHub 远程创建成功。
|
||||
|
||||
**完成摘要:**
|
||||
- 提交: `7efb49b` - fix: handle forward service rebind on self-occupied port
|
||||
- PR: https://github.com/Sagit-chu/flvx/pull/257 (已合并)
|
||||
- Tag: `2.1.7-beta4` 已推送到远程
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
转发编辑保存报错create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:16765: bind: cannot assign requested address
|
||||
引发条件:当编辑选择入口IP原来是默认地址,选择成和默认地址一样的地址的时候报错
|
||||
|
||||
|
||||
---
|
||||
|
||||
保持“只要用户选了IP就一定显式绑定”,如果选择的IP后发现端口占用,检测是否是当前转发占用的,如果是那就释放掉,避免原来是默认监听::所有地址后永远无法切换
|
||||
|
||||
---
|
||||
|
||||
按计划实现
|
||||
|
||||
---
|
||||
|
||||
节点 test-sg 下发失败: create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:56025: bind: cannot assign requested address
|
||||
|
||||
还是不行啊,由默认入口IP创建的转发,然后编辑选择了唯一的一个IP后报错了
|
||||
@@ -0,0 +1,23 @@
|
||||
你这个新报错我确认了:这次**不是端口占用问题**,是 **IP 本机不可绑定** 问题。
|
||||
|
||||
- 报错是 `bind: cannot assign requested address`,含义是节点机器网卡上没有 `13.228.170.187` 这个本地地址;释放旧服务也不会解决。
|
||||
- 你说“唯一一个IP可选”,很可能来自前端当前候选逻辑把 `serverIp/serverIpV4/serverIpV6` 当成监听候选(`vite-frontend/src/pages/forward.tsx:612` 一带),但这些字段在 NAT/云环境下常是“对外地址”,不一定是本机可 bind 地址。
|
||||
- 我之前加的自占用释放逻辑会在冲突时尝试删旧服务再加新服务,但对 `cannot assign requested address` 这种错误会一直失败(因为 IP 本身不可绑定)。
|
||||
|
||||
我建议下一步这样改(这次是关键):
|
||||
|
||||
- 后端只把 `address already in use` 走“自占用释放重绑”流程;`cannot assign requested address` 不走该流程。
|
||||
位置:`go-backend/internal/http/handler/control_plane.go`
|
||||
- 前端监听 IP 下拉改为“可绑定地址优先”:优先 `extraIPs` + 明确配置的监听地址,**不再默认把 `serverIp*` 作为可绑定监听IP**。
|
||||
位置:`vite-frontend/src/pages/forward.tsx`
|
||||
- 后端增加快速校验并返回更明确提示:
|
||||
`监听IP不是节点本机地址,请在节点 extraIPs 配置可绑定IP后再选择`。
|
||||
位置:`go-backend/internal/http/handler/control_plane.go` + repo 查询辅助。
|
||||
- 你的临时可用绕过:到节点配置里把真实网卡 IP 填到 `extraIPs`,然后在编辑转发时选该 IP。
|
||||
|
||||
一个需要你拍板的点(推荐第1个):
|
||||
|
||||
1. **推荐**:前端不再展示 `serverIp/serverIpV4/serverIpV6` 为监听IP候选,只展示 `extraIPs`(和明确监听地址)。
|
||||
2. 继续展示 `serverIp*`,但加“可能不可绑定”标签和失败提示。
|
||||
|
||||
你回我 `1` 或 `2`,我就按这个继续落地。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta3
|
||||
@@ -0,0 +1,8 @@
|
||||
完成!已成功:
|
||||
|
||||
1. **提交变更**: 5个文件(修复绑定IP保留问题 + 新增单元测试)
|
||||
2. **推送分支**: opencode/mighty-nebula → origin
|
||||
3. **创建PR**: #256 (已合并)
|
||||
4. **发布tag**: 2.1.7-beta3
|
||||
|
||||
PR #256: https://github.com/Sagit-chu/flvx/pull/256
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
编辑转发后保存报错 下发失败: create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:16765: bind: cannot assign requested address,这个bug是因为增加了自定义IP功能后导致的,监听IP 不选择就会报错,应该根据原来的监听状态保存
|
||||
@@ -0,0 +1,23 @@
|
||||
你这个判断是对的,我已经把链路看清了,确实是“新增自定义监听 IP 后,编辑保存把原监听状态覆盖掉”导致的。
|
||||
|
||||
- 前端编辑表单会把 `forward.inIp` 直接回填到 `form.inIp`(`vite-frontend/src/pages/forward.tsx:1302`)。
|
||||
- 保存时会把 `form.inIp` 带到更新请求(`vite-frontend/src/pages/forward.tsx:1391`)。
|
||||
- 后端更新时无论是否真的改了监听 IP,都会用请求里的 `inIp` 全量重建 `forward_port`(`go-backend/internal/http/handler/mutations.go:1288`、`go-backend/internal/http/handler/mutations.go:1305`、`go-backend/internal/http/handler/mutations.go:3019`)。
|
||||
- 而列表里的 `forward.inIp` 可能是“展示回退值”(隧道 IP/节点 IP),不一定是原始 `forward_port.in_ip`(`go-backend/internal/store/repo/repository.go:731`、`go-backend/internal/store/repo/repository.go:2778`、`go-backend/internal/store/repo/repository.go:2780`)。
|
||||
- 这样就会在“没手动选监听 IP”时,把服务改成绑定不可用地址,触发 `bind: cannot assign requested address`。
|
||||
|
||||
我建议的修复方案(实现时会做成双保险):
|
||||
|
||||
- 前端:编辑模式下仅当用户“主动改过监听 IP”时才提交 `inIp` 字段;未触碰则不传,保持原状态。
|
||||
- 后端:`forward/update` 中如果请求没带 `inIp`,就保留原 `forward_port.in_ip`(不清空、不覆盖);若带了 `inIp`(包括空串),按用户意图更新。
|
||||
- 回归测试:
|
||||
- 后端新增用例:未传 `inIp` 时更新不改变监听绑定。
|
||||
- 后端新增用例:显式传空串时清空监听绑定。
|
||||
- (可选)前端补充一个轻量逻辑测试/至少手工验证路径。
|
||||
|
||||
另外按仓库规则,这次会新增计划文档为:`plans/003-forward-edit-bind-ip-preserve.md`(当前是只读 Plan Mode,我还没动文件)。
|
||||
|
||||
一个需要你拍板的小点(我给默认建议):
|
||||
- **推荐默认**:编辑时如果改了隧道但没有手动选“监听IP”,按“默认监听(空)”处理,不继承旧隧道的监听 IP。
|
||||
这样能避免把旧隧道的 IP 误带到新隧道。
|
||||
你是否同意按这个规则?
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,beta版本
|
||||
|
||||
---
|
||||
|
||||
不对,tag是2.1.7-beta2
|
||||
|
||||
---
|
||||
|
||||
2.1.8-beta1 标签没有被删除成功
|
||||
|
||||
---
|
||||
|
||||
2.1.8-alpha1 标签没有被删除成功
|
||||
@@ -0,0 +1 @@
|
||||
已删除 `2.1.8-alpha1` 标签。当前最新版本为 `2.1.7-beta2`。
|
||||
@@ -0,0 +1,41 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_34d1fa4b1ffeH4sVbjXkh5P6IY
|
||||
Commit Message: 实施
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
转发导入,ny兼容格式,不要选择入口,改成选择隧道,修改兼容一下
|
||||
|
||||
### Prompt 2
|
||||
|
||||
自动分配端口(推荐)
|
||||
|
||||
### Prompt 3
|
||||
|
||||
实施
|
||||
|
||||
## Summary
|
||||
|
||||
已实施完成,这次改动把 `ny` 导入完全对齐到“选隧道”流程,并按你指定支持“端口缺失自动分配”。
|
||||
|
||||
- `ny` 解析兼容增强:支持字段别名、可选端口
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.ts`
|
||||
- `listen_port` 现在允许为 `null`(缺失即自动分配)
|
||||
- 兼容字段:`dest/dst/target/targets`、`listen_port/listenPort/port/in_port/inPort`、`name/forward_name/forwardName`
|
||||
- `dest` 支持数组或逗号分隔字符串,统一归一化
|
||||
- 导入 UI 文案已同步更新(强调按所选隧道导入 + 端口可省略)
|
||||
- 修改 `vite-frontend/src/pages/forward.tsx:4623`、`vite-frontend/src/pages/forward.tsx:4699`
|
||||
- 测试已补齐并通过
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.test.ts`
|
||||
- 新增用例:缺失端口自动分配、别名字段兼容、`inPort: null` 映射
|
||||
- 计划文档已新增并完成勾选
|
||||
- `plans/002-ny-import-tunnel-selection-compat.md`
|
||||
|
||||
验证结果:
|
||||
- `npm run build`(`vite-frontend`)通过
|
||||
- `node --test src/pages/forward/import-format.test.ts` 7/7 通过
|
||||
|
||||
如果你要,我下一步可以直接帮你提交一个规范 commit。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
转发导入,ny兼容格式,不要选择入口,改成选择隧道,修改兼容一下
|
||||
|
||||
---
|
||||
|
||||
自动分配端口(推荐)
|
||||
|
||||
---
|
||||
|
||||
实施
|
||||
@@ -0,0 +1,20 @@
|
||||
已实施完成,这次改动把 `ny` 导入完全对齐到“选隧道”流程,并按你指定支持“端口缺失自动分配”。
|
||||
|
||||
- `ny` 解析兼容增强:支持字段别名、可选端口
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.ts`
|
||||
- `listen_port` 现在允许为 `null`(缺失即自动分配)
|
||||
- 兼容字段:`dest/dst/target/targets`、`listen_port/listenPort/port/in_port/inPort`、`name/forward_name/forwardName`
|
||||
- `dest` 支持数组或逗号分隔字符串,统一归一化
|
||||
- 导入 UI 文案已同步更新(强调按所选隧道导入 + 端口可省略)
|
||||
- 修改 `vite-frontend/src/pages/forward.tsx:4623`、`vite-frontend/src/pages/forward.tsx:4699`
|
||||
- 测试已补齐并通过
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.test.ts`
|
||||
- 新增用例:缺失端口自动分配、别名字段兼容、`inPort: null` 映射
|
||||
- 计划文档已新增并完成勾选
|
||||
- `plans/002-ny-import-tunnel-selection-compat.md`
|
||||
|
||||
验证结果:
|
||||
- `npm run build`(`vite-frontend`)通过
|
||||
- `node --test src/pages/forward/import-format.test.ts` 7/7 通过
|
||||
|
||||
如果你要,我下一步可以直接帮你提交一个规范 commit。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
|
||||
---
|
||||
|
||||
不对,撤销tag,发布2.1.7-beta1
|
||||
@@ -0,0 +1,3 @@
|
||||
已完成:
|
||||
- 删除标签 `2.1.8-alpha1`(本地+远程)
|
||||
- 创建并推送标签 `2.1.7-beta1` 到 `origin/main`
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
@@ -0,0 +1,13 @@
|
||||
完成!
|
||||
|
||||
**总结:**
|
||||
- ✅ 提交变更:`feat: add ny format support for forward import with node-based tunnel matching`
|
||||
- ✅ 推送到远程分支 `opencode/mighty-nebula`
|
||||
- ✅ 创建 PR #250 并自动合并到 main
|
||||
- ✅ 发布新 tag `2.1.7-alpha9`
|
||||
|
||||
**新版本特性:**
|
||||
- 新增 ny 格式转发数据导入支持(JSON格式,多目标地址负载均衡)
|
||||
- 入口节点选择功能,自动匹配端口转发类型隧道
|
||||
- 多隧道匹配时提供选择界面
|
||||
- 新增 import-format.ts 解析模块及测试文件
|
||||
@@ -0,0 +1,49 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_34d7fcdb8ffekb7lvyJwVvoS8T
|
||||
Commit Message: 增加功能:转发规则批量导入格式,兼容ny的导入格式
|
||||
ny格式如下:
|
||||
{"dest":["151.241.129.52:23609"],"list
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
增加功能:转发规则批量导入格式,兼容ny的导入格式
|
||||
ny格式如下:
|
||||
{"dest":["151.241.129.52:23609"],"listen_port":20224,"name":"灵玥-JP-Lpt【三网通用】"}{"dest":["64.81.33.2:24577"],"listen_port":41034,"name":"Yolo-US-Lpt【三网通用】"}
|
||||
|
||||
ny导入的时候是选择入口的,flvx转发导入是选择隧道的,需要兼容一下,请设计计划
|
||||
|
||||
## Summary
|
||||
|
||||
计划文档已设计完成。以下是计划摘要:
|
||||
|
||||
## 计划摘要
|
||||
|
||||
### 格式对比
|
||||
| 特性 | flvx格式 | ny格式 |
|
||||
|------|----------|--------|
|
||||
| 格式 | 管道分隔文本 | 紧凑JSON对象 |
|
||||
| 示例 | `ip:port\|name\|port` | `{"dest":["ip:port"],"listen_port":port,"name":"name"}` |
|
||||
| 多目标 | 逗号分隔 | dest数组 |
|
||||
| 选择器 | 选择隧道 | 选择入口节点→匹配隧道 |
|
||||
|
||||
### 核心设计
|
||||
1. **格式选择器**:手动选择"flvx格式"或"ny格式"
|
||||
2. **ny格式流程**:选择入口节点 → 匹配端口转发隧道 → 匹配0个报错、1个直接用、多个弹窗选
|
||||
3. **不自动创建隧道**:无匹配时提示用户先创建
|
||||
|
||||
### 任务清单
|
||||
1. 新增`importFormat`、`selectedEntryNode`、`matchedTunnels`等状态
|
||||
2. 重构导入模态框UI,添加格式选择器
|
||||
3. 实现ny格式紧凑JSON解析函数
|
||||
4. 实现入口节点选择与隧道匹配逻辑
|
||||
5. 多隧道匹配时的选择器弹窗
|
||||
6. 整合ny格式到`executeImport`函数
|
||||
7. 更新提示文案
|
||||
|
||||
### 改动范围
|
||||
- `vite-frontend/src/pages/forward.tsx` - 主要改动文件
|
||||
|
||||
是否需要我调整计划内容?
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
增加功能:转发规则批量导入格式,兼容ny的导入格式
|
||||
ny格式如下:
|
||||
{"dest":["151.241.129.52:23609"],"listen_port":20224,"name":"灵玥-JP-Lpt【三网通用】"}{"dest":["64.81.33.2:24577"],"listen_port":41034,"name":"Yolo-US-Lpt【三网通用】"}
|
||||
|
||||
ny导入的时候是选择入口的,flvx转发导入是选择隧道的,需要兼容一下,请设计计划
|
||||
@@ -0,0 +1,30 @@
|
||||
计划文档已设计完成。以下是计划摘要:
|
||||
|
||||
## 计划摘要
|
||||
|
||||
### 格式对比
|
||||
| 特性 | flvx格式 | ny格式 |
|
||||
|------|----------|--------|
|
||||
| 格式 | 管道分隔文本 | 紧凑JSON对象 |
|
||||
| 示例 | `ip:port\|name\|port` | `{"dest":["ip:port"],"listen_port":port,"name":"name"}` |
|
||||
| 多目标 | 逗号分隔 | dest数组 |
|
||||
| 选择器 | 选择隧道 | 选择入口节点→匹配隧道 |
|
||||
|
||||
### 核心设计
|
||||
1. **格式选择器**:手动选择"flvx格式"或"ny格式"
|
||||
2. **ny格式流程**:选择入口节点 → 匹配端口转发隧道 → 匹配0个报错、1个直接用、多个弹窗选
|
||||
3. **不自动创建隧道**:无匹配时提示用户先创建
|
||||
|
||||
### 任务清单
|
||||
1. 新增`importFormat`、`selectedEntryNode`、`matchedTunnels`等状态
|
||||
2. 重构导入模态框UI,添加格式选择器
|
||||
3. 实现ny格式紧凑JSON解析函数
|
||||
4. 实现入口节点选择与隧道匹配逻辑
|
||||
5. 多隧道匹配时的选择器弹窗
|
||||
6. 整合ny格式到`executeImport`函数
|
||||
7. 更新提示文案
|
||||
|
||||
### 改动范围
|
||||
- `vite-frontend/src/pages/forward.tsx` - 主要改动文件
|
||||
|
||||
是否需要我调整计划内容?
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
@@ -0,0 +1,6 @@
|
||||
✅ 完成:
|
||||
- **提交**: `fix: use configured connectIp for tunnel chain diagnosis` (1c10347)
|
||||
- **PR**: #248 已合并 → https://github.com/Sagit-chu/flvx/pull/248
|
||||
- **Tag**: `2.1.7-alpha8` 已创建并推送
|
||||
|
||||
变更内容:修复了隧道链诊断时使用配置的 connectIp、forward 绑定 IP 支持带端口的地址、添加了全面的测试覆盖。
|
||||
@@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
||||
|
||||
## CONVENTIONS
|
||||
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
|
||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||
|
||||
@@ -223,20 +223,32 @@ func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {
|
||||
}
|
||||
|
||||
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {
|
||||
_, err := h.syncForwardServicesWithWarnings(forward, method, allowFallbackAdd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method string, allowFallbackAdd bool) ([]string, error) {
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward sync context")
|
||||
return nil, errors.New("invalid forward sync context")
|
||||
}
|
||||
|
||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
ports, err := h.listForwardPorts(forward.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if len(ports) == 0 {
|
||||
return errors.New("转发入口端口不存在")
|
||||
return nil, errors.New("转发入口端口不存在")
|
||||
}
|
||||
warnings := make([]string, 0)
|
||||
|
||||
// Resolve user tunnel first so runtime service name can carry the real user_tunnel id.
|
||||
userTunnelID, utLimiterID, utSpeed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
|
||||
@@ -254,45 +266,163 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
|
||||
if limiterID == nil {
|
||||
// Fall back to UserTunnel speed limit
|
||||
var utLimiterID *int64
|
||||
var utSpeed *int
|
||||
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limiterID = utLimiterID
|
||||
speed = utSpeed
|
||||
}
|
||||
|
||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
|
||||
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
|
||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, fp := range ports {
|
||||
if limiterID != nil && speed != nil {
|
||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(fp.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
|
||||
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||
if isNotFoundError(err) {
|
||||
if delErr := h.deleteForwardServicesOnNode(forward, node.ID); delErr != nil && !isNotFoundError(delErr) {
|
||||
return warnings, fmt.Errorf("节点 %s 清理旧服务失败: %w", node.Name, delErr)
|
||||
}
|
||||
}
|
||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
||||
}
|
||||
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isAddressAlreadyInUseError(err) {
|
||||
err = h.rebindForwardServiceOnSelfOccupiedPort(forward, node, fp.Port, services)
|
||||
}
|
||||
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
|
||||
var warning string
|
||||
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol)
|
||||
if err == nil && warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
|
||||
if h == nil || forward == nil || tunnel == nil || node == nil {
|
||||
return "", errors.New("invalid bind fallback context")
|
||||
}
|
||||
if fp.Port <= 0 {
|
||||
return "", errors.New("invalid forward port")
|
||||
}
|
||||
explicitBindIP := strings.TrimSpace(fp.InIP)
|
||||
if explicitBindIP == "" {
|
||||
return "", errors.New("default bind address cannot be assigned")
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
|
||||
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.repo.UpdateForwardPortBindIP(forward.ID, node.ID, fp.Port, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
warning := fmt.Sprintf("节点 %s 监听IP %s 不在主机网卡地址中,已自动回退为默认监听IP", strings.TrimSpace(node.Name), explicitBindIP)
|
||||
return warning, nil
|
||||
}
|
||||
|
||||
func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord, node *nodeRecord, port int, services []map[string]interface{}) error {
|
||||
if h == nil || forward == nil || node == nil {
|
||||
return errors.New("invalid self-occupy rebind context")
|
||||
}
|
||||
if port <= 0 {
|
||||
return errors.New("invalid forward port")
|
||||
}
|
||||
|
||||
hasOtherForward, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, forward.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasOtherForward {
|
||||
return fmt.Errorf("端口 %d 已被其他转发占用", port)
|
||||
}
|
||||
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServiceBasesOnNode(node.ID, bases); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int64) error {
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward delete context")
|
||||
}
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.deleteForwardServiceBasesOnNode(nodeID, bases)
|
||||
|
||||
}
|
||||
|
||||
func (h *Handler) forwardServiceBaseCandidates(forward *forwardRecord) ([]string, error) {
|
||||
if h == nil || forward == nil {
|
||||
return nil, errors.New("invalid forward service base context")
|
||||
}
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||
return buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs), nil
|
||||
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
|
||||
return deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"services": []string{name},
|
||||
}
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward control context")
|
||||
@@ -321,40 +451,26 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||
seen := map[int64]struct{}{}
|
||||
healed := false
|
||||
for _, fp := range ports {
|
||||
if _, ok := seen[fp.NodeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[fp.NodeID] = struct{}{}
|
||||
|
||||
var lastNotFoundErr error
|
||||
nodeHandled := false
|
||||
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, base := range bases {
|
||||
variants := []string{base + "_tcp", base + "_udp"}
|
||||
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||
variants = append(variants, base)
|
||||
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
|
||||
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
|
||||
return healErr
|
||||
}
|
||||
|
||||
candidateHandled := false
|
||||
for _, name := range variants {
|
||||
payload := map[string]interface{}{
|
||||
"services": []string{name},
|
||||
}
|
||||
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)
|
||||
if err == nil {
|
||||
candidateHandled = true
|
||||
continue
|
||||
}
|
||||
if !isNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
lastNotFoundErr = err
|
||||
}
|
||||
|
||||
if candidateHandled {
|
||||
nodeHandled = true
|
||||
break
|
||||
healed = true
|
||||
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +488,65 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
|
||||
return controlForwardServiceCommand(bases, commandType, func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"services": []string{name},
|
||||
}
|
||||
_, err := h.sendNodeCommand(nodeID, commandType, payload, false, false)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func controlForwardServiceCommand(bases []string, commandType string, send func(name string) error) (bool, error, error) {
|
||||
var lastNotFoundErr error
|
||||
for _, base := range bases {
|
||||
variants := []string{base + "_tcp", base + "_udp"}
|
||||
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||
variants = append(variants, base)
|
||||
}
|
||||
|
||||
candidateHandled := false
|
||||
for _, name := range variants {
|
||||
err := send(name)
|
||||
if err == nil {
|
||||
candidateHandled = true
|
||||
continue
|
||||
}
|
||||
if !isNotFoundError(err) {
|
||||
return false, lastNotFoundErr, err
|
||||
}
|
||||
lastNotFoundErr = err
|
||||
}
|
||||
|
||||
if candidateHandled {
|
||||
return true, nil, nil
|
||||
}
|
||||
}
|
||||
return false, lastNotFoundErr, nil
|
||||
}
|
||||
|
||||
func deleteForwardServiceCandidates(bases []string, send func(name string) error) error {
|
||||
for _, base := range bases {
|
||||
for _, name := range append([]string{base + "_tcp", base + "_udp", base}, []string{}...) {
|
||||
err := send(name)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldSelfHealForwardServiceControl(commandType string) bool {
|
||||
cmd := strings.ToLower(strings.TrimSpace(commandType))
|
||||
return cmd == "pauseservice" || cmd == "resumeservice"
|
||||
}
|
||||
|
||||
func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {
|
||||
_, err := h.sendNodeCommand(nodeID, "SetProtocol", map[string]interface{}{
|
||||
"http": httpVal,
|
||||
@@ -1246,6 +1421,13 @@ func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {
|
||||
return fmt.Sprintf("%d_%d_%d", forwardID, userID, userTunnelID)
|
||||
}
|
||||
|
||||
func buildForwardServiceBaseWithResolvedUserTunnel(forwardID, userID, resolvedUserTunnelID int64) string {
|
||||
if resolvedUserTunnelID <= 0 {
|
||||
return buildForwardServiceBase(forwardID, userID, 0)
|
||||
}
|
||||
return buildForwardServiceBase(forwardID, userID, resolvedUserTunnelID)
|
||||
}
|
||||
|
||||
func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {
|
||||
orderedIDs := make([]int64, 0, len(userTunnelIDs)+2)
|
||||
seen := make(map[int64]struct{}, len(userTunnelIDs)+2)
|
||||
@@ -1297,10 +1479,61 @@ func isAlreadyExistsMessage(message string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
if isAddressAlreadyInUseMessage(msg) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
|
||||
compact := compactErrorMessage(msg)
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") || strings.Contains(compact, "alreadyexists")
|
||||
}
|
||||
|
||||
func isBindAddressInUseError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return isAddressAlreadyInUseMessage(msg) || strings.Contains(msg, "cannot assign requested address")
|
||||
}
|
||||
|
||||
func isAddressAlreadyInUseError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return isAddressAlreadyInUseMessage(strings.ToLower(strings.TrimSpace(err.Error())))
|
||||
}
|
||||
|
||||
func isAddressAlreadyInUseMessage(msg string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(compactErrorMessage(msg), "addressalreadyinuse")
|
||||
}
|
||||
|
||||
func isCannotAssignRequestedAddressError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "cannot assign requested address") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(compactErrorMessage(msg), "cannotassignrequestedaddress")
|
||||
}
|
||||
|
||||
func compactErrorMessage(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
|
||||
@@ -42,6 +45,20 @@ func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
|
||||
if got != "12_34_56" {
|
||||
t.Fatalf("expected 12_34_56, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
|
||||
if got != "12_34_0" {
|
||||
t.Fatalf("expected 12_34_0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
if !shouldTryLegacySingleService("PauseService") {
|
||||
t.Fatalf("PauseService should require legacy fallback")
|
||||
@@ -54,6 +71,184 @@ func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSelfHealForwardServiceControl(t *testing.T) {
|
||||
if !shouldSelfHealForwardServiceControl("PauseService") {
|
||||
t.Fatalf("PauseService should trigger self-heal")
|
||||
}
|
||||
if !shouldSelfHealForwardServiceControl(" resumeService ") {
|
||||
t.Fatalf("ResumeService should trigger self-heal")
|
||||
}
|
||||
if shouldSelfHealForwardServiceControl("DeleteService") {
|
||||
t.Fatalf("DeleteService should not trigger self-heal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandHandledOnKnownVariant(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
called := make([]string, 0)
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_56_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatalf("expected handled=true")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("expected lastNotFoundErr=nil when handled")
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false")
|
||||
}
|
||||
if lastNotFoundErr == nil {
|
||||
t.Fatalf("expected lastNotFoundErr when all variants are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_0" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("all-missing delete should be tolerated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
|
||||
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
|
||||
want := []string{"46_9_123", "46_9_77", "46_9_0"}
|
||||
if !reflect.DeepEqual(bases, want) {
|
||||
t.Fatalf("expected %v, got %v", want, bases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
|
||||
bases := []string{"46_9_123", "46_9_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
|
||||
bases := []string{"57_7_7", "57_7_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
switch name {
|
||||
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
|
||||
return nil
|
||||
default:
|
||||
return errors.New("service " + name + " not found")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
|
||||
h := &Handler{repo: nil}
|
||||
node := &nodeRecord{ID: 9, Name: "test-node"}
|
||||
_ = h
|
||||
_ = node
|
||||
|
||||
rawRepo, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
h = &Handler{repo: rawRepo}
|
||||
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
|
||||
t.Fatalf("insert forward port: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
|
||||
if err == nil {
|
||||
t.Fatalf("expected occupancy error")
|
||||
}
|
||||
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("same forward should be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
if name == "12_34_56_tcp" {
|
||||
return errors.New("network timeout")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false on hard error")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("did not expect not-found error alongside hard error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
if !isAlreadyExistsMessage("service demo already exists") {
|
||||
t.Fatalf("expected already exists message to be tolerated")
|
||||
@@ -61,9 +256,123 @@ func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
if !isAlreadyExistsMessage("服务已存在") {
|
||||
t.Fatalf("expected Chinese already exists message to be tolerated")
|
||||
}
|
||||
if !isAlreadyExistsMessage("service demo alreadyexists") {
|
||||
t.Fatalf("missing-space alreadyexists should be tolerated")
|
||||
}
|
||||
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
|
||||
t.Fatalf("address already in use must not be treated as already exists")
|
||||
}
|
||||
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
|
||||
t.Fatalf("alreadyin-use variant must not be treated as already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBindAddressInUseError(t *testing.T) {
|
||||
if !isBindAddressInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isBindAddressInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if isBindAddressInUseError(errors.New("service demo already exists")) {
|
||||
t.Fatalf("already exists should not be treated as bind conflict")
|
||||
}
|
||||
if isBindAddressInUseError(nil) {
|
||||
t.Fatalf("nil error should not be treated as bind conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAddressAlreadyInUseError(t *testing.T) {
|
||||
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
|
||||
t.Fatalf("missing-space alreadyin-use variant should be detected")
|
||||
}
|
||||
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCannotAssignRequestedAddressError(t *testing.T) {
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
|
||||
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
|
||||
}
|
||||
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should not be treated as cannot-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
if addCalls == 1 {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry to succeed, got %v", err)
|
||||
}
|
||||
if addCalls != 2 {
|
||||
t.Fatalf("expected 2 add attempts, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 1 {
|
||||
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
return errors.New("network timeout")
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if addCalls != 1 {
|
||||
t.Fatalf("expected 1 add attempt, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 0 {
|
||||
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
|
||||
cleanupErr := errors.New("delete failed")
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
},
|
||||
func() error {
|
||||
return cleanupErr
|
||||
},
|
||||
0,
|
||||
)
|
||||
if !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
|
||||
@@ -43,6 +43,19 @@ func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "10.8.0.5:21002" {
|
||||
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
|
||||
@@ -571,7 +571,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
"userId": t.UserID,
|
||||
"tunnelId": t.TunnelID,
|
||||
"tunnelName": t.TunnelName,
|
||||
"status": 1,
|
||||
"status": t.Status,
|
||||
"flow": t.Flow,
|
||||
"num": t.Num,
|
||||
"expTime": t.ExpTime,
|
||||
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const tunnelServiceBindRetryDelay = 150 * time.Millisecond
|
||||
|
||||
func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -259,6 +261,9 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(""),
|
||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||
asInt(req["http"], 0),
|
||||
asInt(req["tls"], 0),
|
||||
asInt(req["socks"], 0),
|
||||
@@ -324,6 +329,9 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(asString(req["extraIPs"])),
|
||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||
newHTTP,
|
||||
newTLS,
|
||||
newSocks,
|
||||
@@ -456,6 +464,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if err := validateTunnelConnectIPConstraints(req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
name := asString(req["name"])
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道名称不能为空"))
|
||||
@@ -663,6 +675,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if err := validateTunnelConnectIPConstraints(req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
id := asInt64(req["id"], 0)
|
||||
if id <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
||||
@@ -1057,7 +1073,8 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if err := h.validateSpeedLimitReference(speedID); err != nil {
|
||||
speedID, err := h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -1144,17 +1161,17 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
|
||||
return
|
||||
}
|
||||
if roleID != 0 {
|
||||
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
|
||||
return
|
||||
}
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if speedID != nil {
|
||||
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
|
||||
if speedErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
|
||||
return
|
||||
}
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
port := asInt(req["inPort"], 0)
|
||||
if port <= 0 {
|
||||
@@ -1164,6 +1181,11 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
port = 10000
|
||||
}
|
||||
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||
inIp := strings.TrimSpace(asString(req["inIp"]))
|
||||
if inIp != "" && len(entryNodes) > 1 {
|
||||
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
|
||||
return
|
||||
}
|
||||
for _, nodeID := range entryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
@@ -1173,6 +1195,14 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := validateLocalNodePort(node, port); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, port, 0); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
inx := h.repo.NextIndex("forward")
|
||||
@@ -1180,7 +1210,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if userName == "" {
|
||||
userName = "user"
|
||||
}
|
||||
inIp := strings.TrimSpace(asString(req["inIp"]))
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -1256,17 +1285,17 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if strategy == "" {
|
||||
strategy = forward.Strategy
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if speedID != nil {
|
||||
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
|
||||
if speedErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
|
||||
return
|
||||
}
|
||||
rawSpeedID, hasSpeedID := req["speedId"]
|
||||
requestedSpeedID := asAnyToInt64Ptr(rawSpeedID)
|
||||
if actorRole != 0 && hasSpeedID && requestedSpeedID != nil && !sameSpeedLimitSelection(forward.SpeedID, requestedSpeedID) {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
speedID := requestedSpeedID
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
newSpeedID := forward.SpeedID
|
||||
if speedID != nil {
|
||||
@@ -1285,8 +1314,17 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
port = h.pickTunnelPort(tunnelID)
|
||||
}
|
||||
}
|
||||
inIp := asString(req["inIp"])
|
||||
hasInIP := false
|
||||
inIp := ""
|
||||
if rawInIP, ok := req["inIp"]; ok {
|
||||
hasInIP = true
|
||||
inIp = asString(rawInIP)
|
||||
}
|
||||
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if hasInIP && strings.TrimSpace(inIp) != "" && len(fwdEntryNodes) > 1 {
|
||||
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
|
||||
return
|
||||
}
|
||||
for _, nodeID := range fwdEntryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
@@ -1296,13 +1334,28 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := validateLocalNodePort(node, port); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, port, id); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {
|
||||
if hasInIP {
|
||||
err = h.replaceForwardPorts(id, tunnelID, port, inIp)
|
||||
} else if tunnelID != forward.TunnelID {
|
||||
err = h.replaceForwardPorts(id, tunnelID, port, "")
|
||||
} else {
|
||||
err = h.replaceForwardPortsPreservingInIP(id, tunnelID, port, oldPorts)
|
||||
}
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1313,11 +1366,16 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
|
||||
warnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"warnings": warnings}))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -2160,6 +2218,32 @@ func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, i
|
||||
return strings.Join(ordered, ",")
|
||||
}
|
||||
|
||||
func validateTunnelConnectIPConstraints(req map[string]interface{}) error {
|
||||
outNodes := asMapSlice(req["outNodeId"])
|
||||
if len(outNodes) > 1 {
|
||||
for _, item := range outNodes {
|
||||
if strings.TrimSpace(asString(item["connectIp"])) != "" {
|
||||
return fmt.Errorf("多出口隧道不支持设置自定义连接IP")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for hopIdx, hopRaw := range asAnySlice(req["chainNodes"]) {
|
||||
hopNodes := asMapSlice(hopRaw)
|
||||
if len(hopNodes) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range hopNodes {
|
||||
if strings.TrimSpace(asString(item["connectIp"])) != "" {
|
||||
return fmt.Errorf("转发链第%d跳有多个节点时不支持设置自定义连接IP", hopIdx+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {
|
||||
if req == nil || state == nil {
|
||||
return
|
||||
@@ -2508,7 +2592,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
createdChains = append(createdChains, chainNode.NodeID)
|
||||
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])
|
||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, chainNode.NodeID)
|
||||
@@ -2520,7 +2604,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
|
||||
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, outNode.NodeID)
|
||||
@@ -2529,6 +2613,44 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, nil
|
||||
}
|
||||
|
||||
func retryTunnelServiceAddWithCleanup(add func() error, cleanup func() error, wait time.Duration) error {
|
||||
if add == nil {
|
||||
return errors.New("invalid tunnel service add callback")
|
||||
}
|
||||
err := add()
|
||||
if err == nil || !isAddressAlreadyInUseError(err) {
|
||||
return err
|
||||
}
|
||||
if cleanup == nil {
|
||||
return err
|
||||
}
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
return cleanupErr
|
||||
}
|
||||
if wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
return add()
|
||||
}
|
||||
|
||||
func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []map[string]interface{}) error {
|
||||
if h == nil {
|
||||
return errors.New("invalid tunnel service context")
|
||||
}
|
||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||
return retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
|
||||
return err
|
||||
},
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
|
||||
return err
|
||||
},
|
||||
tunnelServiceBindRetryDelay,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
|
||||
if h == nil || tunnelID <= 0 {
|
||||
return
|
||||
@@ -3016,38 +3138,62 @@ func parsePorts(portRange string) ([]int, error) {
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
type forwardPortReplaceEntry = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}
|
||||
|
||||
func buildForwardPortEntriesWithPreservedInIP(entryNodeIDs []int64, oldPorts []forwardPortRecord, port int) []forwardPortReplaceEntry {
|
||||
preservedByNode := make(map[int64]string)
|
||||
for _, fp := range oldPorts {
|
||||
current, exists := preservedByNode[fp.NodeID]
|
||||
if !exists {
|
||||
preservedByNode[fp.NodeID] = fp.InIP
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(current) == "" && strings.TrimSpace(fp.InIP) != "" {
|
||||
preservedByNode[fp.NodeID] = fp.InIP
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
||||
for _, nid := range entryNodeIDs {
|
||||
entries = append(entries, forwardPortReplaceEntry{
|
||||
NodeID: nid,
|
||||
Port: port,
|
||||
InIP: preservedByNode[nid],
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(entryNodes))
|
||||
entries := make([]forwardPortReplaceEntry, len(entryNodes))
|
||||
for i, nid := range entryNodes {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}{NodeID: nid, Port: port, InIP: inIp}
|
||||
entries[i] = forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIp}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPortsPreservingInIP(forwardID, tunnelID int64, port int, oldPorts []forwardPortRecord) error {
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodes, oldPorts, port)
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(ports))
|
||||
entries := make([]forwardPortReplaceEntry, len(ports))
|
||||
for i, fp := range ports {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
|
||||
entries[i] = forwardPortReplaceEntry{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
@@ -3078,11 +3224,13 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
return fmt.Errorf("userId or tunnelId missing")
|
||||
}
|
||||
|
||||
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=
|
||||
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, lookupErr :=
|
||||
h.repo.GetExistingUserTunnel(userID, tunnelID)
|
||||
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if err := h.validateSpeedLimitReference(speedID); err != nil {
|
||||
var err error
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3092,7 +3240,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
reqFlowReset := asInt64(req["flowResetTime"], -1)
|
||||
reqStatus := asInt(req["status"], -1)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
if lookupErr == sql.ErrNoRows {
|
||||
if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {
|
||||
uFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)
|
||||
if uErr == nil {
|
||||
@@ -3145,8 +3293,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
|
||||
newFlow := currentFlow
|
||||
@@ -3222,20 +3370,28 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateSpeedLimitReference(speedID *int64) error {
|
||||
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
||||
if speedID == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
exists, err := h.repo.SpeedLimitExists(*speedID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return errors.New("限速规则不存在")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return speedID, nil
|
||||
}
|
||||
|
||||
func sameSpeedLimitSelection(current sql.NullInt64, requested *int64) bool {
|
||||
if requested == nil {
|
||||
return !current.Valid
|
||||
}
|
||||
|
||||
return current.Valid && current.Int64 == *requested
|
||||
}
|
||||
|
||||
func asAnySlice(v interface{}) []interface{} {
|
||||
@@ -3384,6 +3540,13 @@ func nullableText(s string) interface{} {
|
||||
return s
|
||||
}
|
||||
|
||||
func nullableUnixMilli(v int64) interface{} {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func nullableInt(v *int64) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -3419,3 +3582,82 @@ func asInt64Slice(v interface{}) []int64 {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateLocalNodePort(node *nodeRecord, port int) error {
|
||||
if node == nil || node.IsRemote == 1 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
portRange := strings.TrimSpace(node.PortRange)
|
||||
if portRange == "" {
|
||||
return nil
|
||||
}
|
||||
minPort, maxPort := parsePortRangeMinMax(portRange)
|
||||
if minPort <= 0 || maxPort <= 0 {
|
||||
return nil
|
||||
}
|
||||
if port < minPort || port > maxPort {
|
||||
return fmt.Errorf("端口 %d 超出节点 %s 允许范围 %d-%d", port, node.Name, minPort, maxPort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, currentForwardID int64) error {
|
||||
if h == nil || h.repo == nil || node == nil || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied {
|
||||
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePortRangeMinMax(input string) (int, int) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return 0, 0
|
||||
}
|
||||
minPort, maxPort := 0, 0
|
||||
parts := strings.Split(input, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
r := strings.SplitN(part, "-", 2)
|
||||
if len(r) != 2 {
|
||||
continue
|
||||
}
|
||||
start, err1 := strconv.Atoi(strings.TrimSpace(r[0]))
|
||||
end, err2 := strconv.Atoi(strings.TrimSpace(r[1]))
|
||||
if err1 != nil || err2 != nil || start <= 0 || end <= 0 {
|
||||
continue
|
||||
}
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if minPort == 0 || start < minPort {
|
||||
minPort = start
|
||||
}
|
||||
if maxPort == 0 || end > maxPort {
|
||||
maxPort = end
|
||||
}
|
||||
continue
|
||||
}
|
||||
p, err := strconv.Atoi(part)
|
||||
if err != nil || p <= 0 {
|
||||
continue
|
||||
}
|
||||
if minPort == 0 || p < minPort {
|
||||
minPort = p
|
||||
}
|
||||
if maxPort == 0 || p > maxPort {
|
||||
maxPort = p
|
||||
}
|
||||
}
|
||||
return minPort, maxPort
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP(t *testing.T) {
|
||||
entryNodeIDs := []int64{10, 20, 30}
|
||||
oldPorts := []forwardPortRecord{
|
||||
{NodeID: 10, Port: 10001, InIP: ""},
|
||||
{NodeID: 10, Port: 10002, InIP: "10.0.0.10"},
|
||||
{NodeID: 20, Port: 10003, InIP: "10.0.0.20"},
|
||||
}
|
||||
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, 18080)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].NodeID != 10 || entries[0].Port != 18080 || entries[0].InIP != "10.0.0.10" {
|
||||
t.Fatalf("unexpected first entry: %+v", entries[0])
|
||||
}
|
||||
if entries[1].NodeID != 20 || entries[1].Port != 18080 || entries[1].InIP != "10.0.0.20" {
|
||||
t.Fatalf("unexpected second entry: %+v", entries[1])
|
||||
}
|
||||
if entries[2].NodeID != 30 || entries[2].Port != 18080 || entries[2].InIP != "" {
|
||||
t.Fatalf("unexpected third entry: %+v", entries[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP_EmptyOldPorts(t *testing.T) {
|
||||
entryNodeIDs := []int64{99}
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, nil, 17000)
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].NodeID != 99 || entries[0].Port != 17000 || entries[0].InIP != "" {
|
||||
t.Fatalf("unexpected entry: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ func (ForwardPort) TableName() string { return "forward_port" }
|
||||
type Node struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Remark sql.NullString `gorm:"column:remark;type:text"`
|
||||
Tags sql.NullString `gorm:"column:tags;type:text"`
|
||||
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
|
||||
Secret string `gorm:"type:varchar(100);not null"`
|
||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||
@@ -336,6 +339,9 @@ type UserBackup struct {
|
||||
type NodeBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||
@@ -573,6 +579,7 @@ type UserTunnelDetail struct {
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
Status int
|
||||
TunnelFlow int
|
||||
Flow int64
|
||||
InFlow int64
|
||||
|
||||
@@ -447,7 +447,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
|
||||
}
|
||||
var items []model.UserTunnelDetail
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
|
||||
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
|
||||
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
|
||||
Where("user_tunnel.user_id = ?", userID).
|
||||
@@ -634,7 +634,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
for _, n := range nodes {
|
||||
items = append(items, map[string]interface{}{
|
||||
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"remark": nullableString(n.Remark),
|
||||
"tags": nullableString(n.Tags),
|
||||
"expiryTime": nullableInt64(n.ExpiryTime),
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"serverIpV4": nullableString(n.ServerIPV4),
|
||||
"serverIpV6": nullableString(n.ServerIPV6),
|
||||
"extraIPs": nullableString(n.ExtraIPs),
|
||||
@@ -700,25 +703,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
type fwdRow struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
TrafficRatio float64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
}
|
||||
|
||||
var rows []fwdRow
|
||||
err := r.db.Model(&model.Forward{}).
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Order("forward.inx ASC, forward.id ASC").
|
||||
Find(&rows).Error
|
||||
@@ -735,7 +739,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
item := map[string]interface{}{
|
||||
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
|
||||
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
|
||||
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
||||
"tunnelTrafficRatio": row.TrafficRatio,
|
||||
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
||||
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
||||
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
||||
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
||||
@@ -767,9 +772,21 @@ func (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]inter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tunnelIDs := make([]int64, 0, len(rows))
|
||||
for _, rw := range rows {
|
||||
tunnelIDs = append(tunnelIDs, rw.ID)
|
||||
}
|
||||
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
|
||||
for _, rw := range rows {
|
||||
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
|
||||
if pr, ok := portRangeMap[rw.ID]; ok {
|
||||
item["portRangeMin"] = pr.min
|
||||
item["portRangeMax"] = pr.max
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -788,13 +805,146 @@ func (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tunnelIDs := make([]int64, 0, len(rows))
|
||||
for _, rw := range rows {
|
||||
tunnelIDs = append(tunnelIDs, rw.ID)
|
||||
}
|
||||
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
|
||||
for _, rw := range rows {
|
||||
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
|
||||
if pr, ok := portRangeMap[rw.ID]; ok {
|
||||
item["portRangeMin"] = pr.min
|
||||
item["portRangeMax"] = pr.max
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type tunnelPortRange struct {
|
||||
min int
|
||||
max int
|
||||
}
|
||||
|
||||
func (r *Repository) getTunnelEntryPortRanges(tunnelIDs []int64) map[int64]tunnelPortRange {
|
||||
result := make(map[int64]tunnelPortRange)
|
||||
if len(tunnelIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
type entryNode struct {
|
||||
TunnelID int64
|
||||
NodeID int64
|
||||
}
|
||||
var entries []entryNode
|
||||
r.db.Model(&model.ChainTunnel{}).
|
||||
Select("tunnel_id, node_id").
|
||||
Where("tunnel_id IN (?) AND chain_type = ?", tunnelIDs, "1").
|
||||
Find(&entries)
|
||||
|
||||
nodeIDs := make([]int64, 0, len(entries))
|
||||
nodeSet := make(map[int64]struct{})
|
||||
for _, e := range entries {
|
||||
if _, exists := nodeSet[e.NodeID]; !exists {
|
||||
nodeSet[e.NodeID] = struct{}{}
|
||||
nodeIDs = append(nodeIDs, e.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
type nodePort struct {
|
||||
ID int64
|
||||
Port string
|
||||
}
|
||||
var nodePorts []nodePort
|
||||
if len(nodeIDs) > 0 {
|
||||
r.db.Model(&model.Node{}).Select("id, port").Where("id IN (?)", nodeIDs).Find(&nodePorts)
|
||||
}
|
||||
|
||||
nodePortMap := make(map[int64]string)
|
||||
for _, np := range nodePorts {
|
||||
nodePortMap[np.ID] = np.Port
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
portSpec := nodePortMap[e.NodeID]
|
||||
if portSpec == "" {
|
||||
continue
|
||||
}
|
||||
minP, maxP := parsePortRangeMinMax(portSpec)
|
||||
if minP <= 0 || maxP <= 0 {
|
||||
continue
|
||||
}
|
||||
pr, exists := result[e.TunnelID]
|
||||
if !exists {
|
||||
result[e.TunnelID] = tunnelPortRange{min: minP, max: maxP}
|
||||
} else {
|
||||
if minP < pr.min {
|
||||
pr.min = minP
|
||||
}
|
||||
if maxP > pr.max {
|
||||
pr.max = maxP
|
||||
}
|
||||
result[e.TunnelID] = pr
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parsePortRangeMinMax(input string) (int, int) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return 0, 0
|
||||
}
|
||||
minPort, maxPort := 0, 0
|
||||
parts := strings.Split(input, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
r := strings.SplitN(part, "-", 2)
|
||||
if len(r) != 2 {
|
||||
continue
|
||||
}
|
||||
start, end := parseIntPort(r[0]), parseIntPort(r[1])
|
||||
if start <= 0 || end <= 0 {
|
||||
continue
|
||||
}
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if minPort == 0 || start < minPort {
|
||||
minPort = start
|
||||
}
|
||||
if maxPort == 0 || end > maxPort {
|
||||
maxPort = end
|
||||
}
|
||||
continue
|
||||
}
|
||||
p := parseIntPort(part)
|
||||
if p <= 0 {
|
||||
continue
|
||||
}
|
||||
if minPort == 0 || p < minPort {
|
||||
minPort = p
|
||||
}
|
||||
if maxPort == 0 || p > maxPort {
|
||||
maxPort = p
|
||||
}
|
||||
}
|
||||
return minPort, maxPort
|
||||
}
|
||||
|
||||
func parseIntPort(s string) int {
|
||||
var p int
|
||||
fmt.Sscanf(strings.TrimSpace(s), "%d", &p)
|
||||
return p
|
||||
}
|
||||
|
||||
func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -1669,11 +1819,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
|
||||
for _, n := range nodes {
|
||||
b := model.NodeBackup{
|
||||
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
||||
Remark: n.Remark.String, Tags: n.Tags.String,
|
||||
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
|
||||
CreatedTime: n.CreatedTime, Status: n.Status,
|
||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||
Inx: n.Inx, IsRemote: n.IsRemote,
|
||||
}
|
||||
if n.ExpiryTime.Valid {
|
||||
b.ExpiryTime = n.ExpiryTime.Int64
|
||||
}
|
||||
if n.UpdatedTime.Valid {
|
||||
b.UpdatedTime = n.UpdatedTime.Int64
|
||||
}
|
||||
@@ -2023,6 +2177,9 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
||||
item := model.Node{
|
||||
ID: n.ID,
|
||||
Name: n.Name,
|
||||
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
|
||||
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
|
||||
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
|
||||
Secret: n.Secret,
|
||||
ServerIP: n.ServerIP,
|
||||
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
|
||||
@@ -2047,7 +2204,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
||||
"name", "remark", "tags", "expiry_time", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
||||
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
|
||||
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
||||
}),
|
||||
@@ -2467,11 +2624,12 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────
|
||||
|
||||
const currentSchemaVersion = 4
|
||||
const currentSchemaVersion = 5
|
||||
|
||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
|
||||
var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns
|
||||
|
||||
func getSchemaVersion(db *gorm.DB) int {
|
||||
var v model.SchemaVersion
|
||||
@@ -2535,6 +2693,12 @@ func migrateSchema(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if ver < 5 {
|
||||
if err := migratePostgresTrafficInt64ColumnsFn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
setSchemaVersion(db, currentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
@@ -2599,6 +2763,87 @@ func migrateSpeedLimitTunnelBinding(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migratePostgresTrafficInt64Columns(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type trafficColumn struct {
|
||||
TableName string
|
||||
ColumnName string
|
||||
}
|
||||
|
||||
columns := []trafficColumn{
|
||||
{TableName: "user", ColumnName: "flow"},
|
||||
{TableName: "user", ColumnName: "in_flow"},
|
||||
{TableName: "user", ColumnName: "out_flow"},
|
||||
{TableName: "forward", ColumnName: "in_flow"},
|
||||
{TableName: "forward", ColumnName: "out_flow"},
|
||||
{TableName: "statistics_flow", ColumnName: "flow"},
|
||||
{TableName: "statistics_flow", ColumnName: "total_flow"},
|
||||
{TableName: "tunnel", ColumnName: "flow"},
|
||||
{TableName: "user_tunnel", ColumnName: "flow"},
|
||||
{TableName: "user_tunnel", ColumnName: "in_flow"},
|
||||
{TableName: "user_tunnel", ColumnName: "out_flow"},
|
||||
{TableName: "peer_share", ColumnName: "max_bandwidth"},
|
||||
{TableName: "peer_share", ColumnName: "current_flow"},
|
||||
}
|
||||
|
||||
for _, column := range columns {
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(db, column.TableName, column.ColumnName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
if tableName == "" || columnName == "" {
|
||||
return errors.New("empty table or column name")
|
||||
}
|
||||
|
||||
type columnRow struct {
|
||||
DataType string `gorm:"column:data_type"`
|
||||
}
|
||||
|
||||
var row columnRow
|
||||
if err := db.Raw(
|
||||
`SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
AND column_name = ?`,
|
||||
tableName, columnName,
|
||||
).Scan(&row).Error; err != nil {
|
||||
return fmt.Errorf("inspect %s.%s type: %w", tableName, columnName, err)
|
||||
}
|
||||
|
||||
if row.DataType == "" || strings.EqualFold(row.DataType, "bigint") {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(row.DataType, "integer") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec(fmt.Sprintf(
|
||||
"ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT",
|
||||
quoteSQLIdentifier(tableName),
|
||||
quoteSQLIdentifier(columnName),
|
||||
)).Error; err != nil {
|
||||
return fmt.Errorf("alter %s.%s to bigint: %w", tableName, columnName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePostgresIDDefaults(db *gorm.DB) error {
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
@@ -2753,18 +2998,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
seenPorts := make(map[int64]struct{})
|
||||
seenPairs := make(map[string]struct{})
|
||||
|
||||
var tunnelFirstIP string
|
||||
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
|
||||
tunnelIPs := strings.Split(tunnelInIP.String, ",")
|
||||
for _, ip := range tunnelIPs {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
tunnelFirstIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range fpRows {
|
||||
if !row.Port.Valid {
|
||||
continue
|
||||
@@ -2777,8 +3010,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
var ip string
|
||||
if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.InIP.String)
|
||||
} else if tunnelFirstIP != "" {
|
||||
ip = tunnelFirstIP
|
||||
} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.ServerIP.String)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,25 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
if nodeID <= 0 || port <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
|
||||
@@ -3,6 +3,7 @@ package repo
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gsqlite "github.com/glebarez/sqlite"
|
||||
@@ -250,3 +251,115 @@ func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsTrafficInt64MigrationForLegacySchema(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
called := 0
|
||||
originalMigrate := migratePostgresTrafficInt64ColumnsFn
|
||||
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
|
||||
called++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migratePostgresTrafficInt64ColumnsFn = originalMigrate
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("expected traffic bigint migration to run once, got %d", called)
|
||||
}
|
||||
|
||||
var schemaVersion int
|
||||
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
|
||||
t.Fatalf("query schema_version: %v", err)
|
||||
}
|
||||
if schemaVersion != currentSchemaVersion {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaReturnsTrafficInt64MigrationError(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
wantErr := errors.New("traffic bigint migration failed")
|
||||
originalMigrate := migratePostgresTrafficInt64ColumnsFn
|
||||
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
|
||||
return wantErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migratePostgresTrafficInt64ColumnsFn = originalMigrate
|
||||
})
|
||||
|
||||
err = migrateSchema(db)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlterPostgresColumnToBigIntIfNeededValidatesNames(t *testing.T) {
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(nil, "peer_share", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "nil db") {
|
||||
t.Fatalf("expected nil db error, got %v", err)
|
||||
}
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
|
||||
t.Fatalf("expected empty name error, got %v", err)
|
||||
}
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "peer_share", ""); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
|
||||
t.Fatalf("expected empty name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,12 +196,15 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
node := model.Node{
|
||||
Name: name,
|
||||
Remark: nullStringFromInterface(remark),
|
||||
Tags: nullStringFromInterface(tags),
|
||||
ExpiryTime: nullInt64FromInterface(expiryTime),
|
||||
Secret: secret,
|
||||
ServerIP: serverIP,
|
||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
||||
@@ -239,7 +242,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
|
||||
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -247,6 +250,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"name": name,
|
||||
"remark": nullStringFromInterface(remark),
|
||||
"tags": nullStringFromInterface(tags),
|
||||
"expiry_time": nullInt64FromInterface(expiryTime),
|
||||
"server_ip": serverIP,
|
||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||
@@ -720,6 +726,18 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateForwardPortBindIP(forwardID, nodeID int64, port int, inIP string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if forwardID <= 0 || nodeID <= 0 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Model(&model.ForwardPort{}).
|
||||
Where("forward_id = ? AND node_id = ? AND port = ?", forwardID, nodeID, port).
|
||||
Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
|
||||
if r == nil || r.db == nil {
|
||||
return
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -29,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
`, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
|
||||
@@ -116,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
if got := int64(idFloat); got != userForwardID {
|
||||
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
|
||||
}
|
||||
ratioFloat, ok := item["tunnelTrafficRatio"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"])
|
||||
}
|
||||
if ratioFloat != 2.5 {
|
||||
t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
|
||||
@@ -480,6 +489,113 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTunnelSaveIgnoresDeletedSpeedLimitContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(101, 'user_tunnel_speed_user_a', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user a: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "user-tunnel-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||
`, "user-tunnel-missing-speed-limit", 2048, now, 1).Error; err != nil {
|
||||
t.Fatalf("insert speed limit: %v", err)
|
||||
}
|
||||
speedID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-limit")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(31, 101, ?, ?, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID, speedID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
|
||||
t.Fatalf("delete speed limit: %v", err)
|
||||
}
|
||||
|
||||
t.Run("user tunnel update auto clears missing speed", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": 31,
|
||||
"flow": 99999,
|
||||
"num": 999,
|
||||
"expTime": int64(2727251700000),
|
||||
"flowResetTime": 1,
|
||||
"status": 1,
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", adminToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
assertCode(t, updateRes, 0)
|
||||
|
||||
var updatedSpeed sql.NullInt64
|
||||
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&updatedSpeed); err != nil {
|
||||
t.Fatalf("query updated user_tunnel speed_id: %v", err)
|
||||
}
|
||||
if updatedSpeed.Valid {
|
||||
t.Fatalf("expected updated user_tunnel speed_id to be NULL, got %d", updatedSpeed.Int64)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user tunnel batch assign auto clears missing speed", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`UPDATE user_tunnel SET speed_id = ? WHERE id = 31`, speedID).Error; err != nil {
|
||||
t.Fatalf("prepare user_tunnel speed_id for batch assign: %v", err)
|
||||
}
|
||||
|
||||
assignPayload := map[string]interface{}{
|
||||
"userId": 101,
|
||||
"tunnels": []map[string]interface{}{{
|
||||
"tunnelId": tunnelID,
|
||||
"speedId": speedID,
|
||||
}},
|
||||
}
|
||||
assignBody, err := json.Marshal(assignPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal assign payload: %v", err)
|
||||
}
|
||||
assignReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(assignBody))
|
||||
assignReq.Header.Set("Authorization", adminToken)
|
||||
assignReq.Header.Set("Content-Type", "application/json")
|
||||
assignRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignRes, assignReq)
|
||||
assertCode(t, assignRes, 0)
|
||||
|
||||
var assignedSpeed sql.NullInt64
|
||||
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&assignedSpeed); err != nil {
|
||||
t.Fatalf("query assigned user_tunnel speed_id: %v", err)
|
||||
}
|
||||
if assignedSpeed.Valid {
|
||||
t.Fatalf("expected assigned user_tunnel speed_id to be NULL, got %d", assignedSpeed.Int64)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
@@ -618,6 +734,105 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardUpdateIgnoresDeletedSpeedLimitContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-update-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "forward-update-missing-speed-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-update-missing-speed-node", "forward-update-missing-speed-secret", "10.32.0.1", "10.32.0.1", "", "42000-42010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, "forward-update-missing-speed-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 42001, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||
`, "forward-update-missing-speed-limit", 2048, now, 1).Error; err != nil {
|
||||
t.Fatalf("insert speed limit: %v", err)
|
||||
}
|
||||
speedID := mustLastInsertID(t, repo, "forward-update-missing-speed-limit")
|
||||
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
stopNode := startMockNodeSession(t, server.URL, "forward-update-missing-speed-secret")
|
||||
defer stopNode()
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "forward-update-missing-speed-target",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": speedID,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "forward-update-missing-speed-target")
|
||||
|
||||
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
|
||||
t.Fatalf("delete speed limit: %v", err)
|
||||
}
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "forward-update-missing-speed-target-updated",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", adminToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
assertCode(t, updateRes, 0)
|
||||
|
||||
storedSpeed := repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
|
||||
var updatedSpeed sql.NullInt64
|
||||
if err := storedSpeed.Scan(&updatedSpeed); err != nil {
|
||||
t.Fatalf("query updated forward speed_id: %v", err)
|
||||
}
|
||||
if updatedSpeed.Valid {
|
||||
t.Fatalf("expected updated speed_id to be NULL after missing speed limit, got %d", updatedSpeed.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
@@ -708,6 +923,462 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardUpdateRecoversFromAddressInUseContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(202, 'forward_bind_retry_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-bind-retry-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "forward-bind-retry-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "forward-bind-retry-target",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
counts := map[string]int{}
|
||||
var addServiceAddrs []string
|
||||
triggerConflict := false
|
||||
stopNode := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
key := strings.ToLower(strings.TrimSpace(cmdType))
|
||||
mu.Lock()
|
||||
counts[key]++
|
||||
attempt := counts[key]
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") || strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") {
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &services); err == nil {
|
||||
for _, svc := range services {
|
||||
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
|
||||
addServiceAddrs = append(addServiceAddrs, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shouldFail := false
|
||||
if triggerConflict {
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") && attempt == 1 {
|
||||
shouldFail = true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
|
||||
shouldFail = true
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if shouldFail {
|
||||
return true, "create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopNode()
|
||||
waitNodeStatus(t, repo, nodeID, 1)
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
mu.Lock()
|
||||
counts = map[string]int{}
|
||||
addServiceAddrs = nil
|
||||
triggerConflict = true
|
||||
mu.Unlock()
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "forward-bind-retry-target")
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "forward-bind-retry-target-updated",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.9.9.9:8443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", adminToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
assertCode(t, updateRes, 0)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
boundPort := mustQueryInt(t, repo, `SELECT port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||
if counts["updateservice"] != 1 {
|
||||
t.Fatalf("expected one UpdateService attempt, got %d (%v)", counts["updateservice"], counts)
|
||||
}
|
||||
if counts["deleteservice"] == 0 {
|
||||
t.Fatalf("expected DeleteService cleanup after address-in-use (%v)", counts)
|
||||
}
|
||||
if counts["addservice"] < 2 {
|
||||
t.Fatalf("expected AddService retry path to run at least twice total, got %d (%v)", counts["addservice"], counts)
|
||||
}
|
||||
foundBindAddr := false
|
||||
for _, addr := range addServiceAddrs {
|
||||
if addr == "10.42.0.9:"+strconv.Itoa(boundPort) {
|
||||
foundBindAddr = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundBindAddr {
|
||||
t.Fatalf("expected forward runtime to keep node listen addr 10.42.0.9:%d, got %v", boundPort, addServiceAddrs)
|
||||
}
|
||||
|
||||
storedRemoteAddr := mustQueryString(t, repo, `SELECT remote_addr FROM forward WHERE id = ?`, forwardID)
|
||||
if storedRemoteAddr != "9.9.9.9:8443" {
|
||||
t.Fatalf("expected remote_addr update to persist, got %q", storedRemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonNumber(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
secret := "contract-jwt-secret-perm"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user_perm', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "perm-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "perm-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "perm-node", "perm-secret", "10.0.0.20", "10.0.0.20", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, repo, "perm-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, 10, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, 2, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 1)
|
||||
`, "perm-speed-limit", 2048, tunnelID, "perm-tunnel", now, now).Error; err != nil {
|
||||
t.Fatalf("insert speed limit: %v", err)
|
||||
}
|
||||
speedID := mustLastInsertID(t, repo, "perm-speed-limit")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user_perm", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
stopNode := startMockNodeSession(t, server.URL, "perm-secret")
|
||||
defer stopNode()
|
||||
|
||||
t.Run("non-admin cannot set speedId on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-speed",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": speedID,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot set inPort out of range on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-port-out",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"inPort": 12345,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code >= 0 {
|
||||
t.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-admin can set inPort within range on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-port-in",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"inPort": 30005,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-ok",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "perm-forward-ok")
|
||||
|
||||
t.Run("non-admin cannot update speedId", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "5.6.7.8:443",
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot update inPort out of range", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated2",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "5.6.7.8:443",
|
||||
"inPort": 54321,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code >= 0 {
|
||||
t.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-admin can update inPort within range", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated3",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "5.6.7.8:443",
|
||||
"inPort": 30006,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated-ok",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.10.11.12:443",
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can update when request keeps existing speedId", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`UPDATE forward SET speed_id = ? WHERE id = ?`, speedID, forwardID).Error; err != nil {
|
||||
t.Fatalf("assign forward speed limit: %v", err)
|
||||
}
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-keep-speed",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.10.11.12:443",
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-null-values",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": nil,
|
||||
"inPort": 0,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can update with speedId null", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-null-speed",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.10.11.12:443",
|
||||
"speedId": nil,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -538,6 +539,134 @@ func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateRecoversFromAddressInUseContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-retry", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "tunnel-bind-retry")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-entry", "tunnel-bind-entry-secret", "10.41.0.1", "10.41.0.1", "", "43000-43010", "", "v1", 1, 1, 1, now, now, 1, "10.41.0.1", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert entry node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, r, "tunnel-bind-entry")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-exit", "tunnel-bind-exit-secret", "10.41.0.2", "10.41.0.2", "", "43100-43110", "eth0", "v1", 1, 1, 1, now, now, 1, "10.41.0.9", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert exit node: %v", err)
|
||||
}
|
||||
exitNodeID := mustLastInsertID(t, r, "tunnel-bind-exit")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 43001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(?, 3, ?, 43101, 'round', 1, 'tls', ?)
|
||||
`, tunnelID, exitNodeID, "10.41.0.99").Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
var commandMu sync.Mutex
|
||||
commandCounts := map[string]int{}
|
||||
var addServiceAddrs []string
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
commandCounts["entry:"+strings.ToLower(strings.TrimSpace(cmdType))]++
|
||||
return false, ""
|
||||
})
|
||||
defer stopEntry()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
key := "exit:" + strings.ToLower(strings.TrimSpace(cmdType))
|
||||
commandMu.Lock()
|
||||
commandCounts[key]++
|
||||
attempt := commandCounts[key]
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &services); err == nil {
|
||||
for _, svc := range services {
|
||||
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
|
||||
addServiceAddrs = append(addServiceAddrs, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commandMu.Unlock()
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
|
||||
return true, "listen tcp 10.41.0.99:43101: bind: address already in use"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopExit()
|
||||
waitNodeStatus(t, r, entryNodeID, 1)
|
||||
waitNodeStatus(t, r, exitNodeID, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"id": tunnelID,
|
||||
"name": "tunnel-bind-retry",
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"trafficRatio": 1.0,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 43101, "connectIp": "10.41.0.99"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if commandCounts["exit:addservice"] != 2 {
|
||||
t.Fatalf("expected exit AddService twice, got %d (%v)", commandCounts["exit:addservice"], sortedCommandCounts(commandCounts))
|
||||
}
|
||||
if commandCounts["exit:deleteservice"] == 0 {
|
||||
t.Fatalf("expected exit DeleteService retry cleanup to run (%v)", sortedCommandCounts(commandCounts))
|
||||
}
|
||||
if len(addServiceAddrs) < 2 {
|
||||
t.Fatalf("expected recorded AddService addresses, got %v", addServiceAddrs)
|
||||
}
|
||||
for _, addr := range addServiceAddrs {
|
||||
if addr != "10.41.0.99:43101" {
|
||||
t.Fatalf("expected connectIp to stay preferred in AddService addr, got %q", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
|
||||
t.Helper()
|
||||
|
||||
@@ -633,3 +762,112 @@ func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithCommandRecorder(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, string)) func() {
|
||||
t.Helper()
|
||||
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse provider url: %v", err)
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "https") {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = "/system-info"
|
||||
q := u.Query()
|
||||
q.Set("type", "1")
|
||||
q.Set("secret", nodeSecret)
|
||||
q.Set("version", "v1")
|
||||
q.Set("http", "1")
|
||||
q.Set("tls", "1")
|
||||
q.Set("socks", "1")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial mock node websocket: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
_, raw, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plain := raw
|
||||
var wrap struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
|
||||
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
|
||||
if cryptoErr == nil {
|
||||
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
|
||||
plain = []byte(dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"requestId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &cmd); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldFail := false
|
||||
failMsg := ""
|
||||
if onCommand != nil {
|
||||
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
|
||||
}
|
||||
|
||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||
respPayload := map[string]interface{}{
|
||||
"type": respType,
|
||||
"success": !shouldFail,
|
||||
"message": "OK",
|
||||
"requestId": cmd.RequestID,
|
||||
}
|
||||
if shouldFail {
|
||||
if strings.TrimSpace(failMsg) == "" {
|
||||
failMsg = "mock command failed"
|
||||
}
|
||||
respPayload["message"] = failMsg
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(respPayload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
|
||||
}
|
||||
}()
|
||||
|
||||
var stopOnce sync.Once
|
||||
return func() {
|
||||
stopOnce.Do(func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sortedCommandCounts(counts map[string]int) []string {
|
||||
items := make([]string, 0, len(counts))
|
||||
for key, value := range counts {
|
||||
items = append(items, fmt.Sprintf("%s=%d", key, value))
|
||||
}
|
||||
sort.Strings(items)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestUserTunnelListReturnsStoredStatusContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'user_tunnel_status_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel enabled: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel disabled: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert enabled user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert disabled user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"userId":201}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
items, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected array data, got %T", out.Data)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
|
||||
statusByTunnelID := make(map[int64]int, len(items))
|
||||
for _, item := range items {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", item)
|
||||
}
|
||||
tunnelID, ok := obj["tunnelId"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
|
||||
}
|
||||
status, ok := obj["status"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected status to be float64, got %T", obj["status"])
|
||||
}
|
||||
statusByTunnelID[int64(tunnelID)] = int(status)
|
||||
}
|
||||
|
||||
if statusByTunnelID[301] != 1 {
|
||||
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
|
||||
}
|
||||
if statusByTunnelID[302] != 0 {
|
||||
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
- [x] Review current forward import flow and confirm ny import uses tunnel selection
|
||||
- [x] Define ny compatibility update with tunnel-first behavior and auto port assignment fallback
|
||||
- [x] Update ny parser to accept alias fields and optional `listen_port`
|
||||
- [x] Keep import execution bound to selected tunnel and remove entry-selection dependency from ux copy
|
||||
- [x] Update ny import help text to document optional port auto assignment
|
||||
- [x] Add parser tests for alias-field compatibility and missing-port auto assignment
|
||||
- [x] Validate updated import parser tests locally
|
||||
@@ -0,0 +1,11 @@
|
||||
# 003 Forward Edit Bind IP Preserve
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm forward edit flow and identify why untouched listen IP gets overwritten.
|
||||
- [x] Update frontend forward edit submit logic to only send `inIp` when user explicitly changes listen IP.
|
||||
- [x] On tunnel switch in edit form, reset listen IP to default unless user reselects.
|
||||
- [x] Update backend forward update logic to preserve existing `forward_port.in_ip` when request omits `inIp` and tunnel is unchanged.
|
||||
- [x] Keep backend behavior explicit: if `inIp` is sent (including empty), apply requested value; if tunnel changed with no `inIp`, use default bind.
|
||||
- [x] Add regression tests for preserved bind-IP reconstruction helper behavior.
|
||||
- [x] Run focused frontend/backend checks for touched files.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 004 Forward Explicit Bind Self-Occupy Release
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm current forward edit/save failure path and lock strategy: explicit bind always stays explicit.
|
||||
- [x] Add repository query to detect whether a node+port is occupied by other forwards (excluding current forward).
|
||||
- [x] Enhance forward service sync to treat address-in-use as a recoverable case when only self occupies the port.
|
||||
- [x] On self-occupy conflict, proactively delete current forward services on target node and retry AddService.
|
||||
- [x] Keep hard failure when the same node+port is occupied by other forwards.
|
||||
- [x] Add focused unit tests for new error classification helpers.
|
||||
- [x] Run focused backend tests for touched handler/repo packages.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 005 Forward Invalid BindIP Fallback Default
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Split forward service bind failures into address-in-use and cannot-assign classes.
|
||||
- [x] Keep self-occupy release/rebind only for address-in-use conflicts.
|
||||
- [x] Add fallback path for cannot-assign: switch to default listener bind and retry service creation.
|
||||
- [x] Persist fallback result to DB by clearing `forward_port.in_ip` for affected node+port.
|
||||
- [x] Return non-blocking warning in forward update response when fallback occurs.
|
||||
- [x] Show warning toast in forward edit UI while still treating operation as success.
|
||||
- [x] Run focused backend tests for touched handler/repo packages.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 006 Forward Save Missing Speed Limit Auto Clear
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Locate forward create/update speed limit validation path that blocks save when speed rule is deleted.
|
||||
- [x] Change forward save behavior to auto-clear missing `speedId` instead of returning "限速规则不存在".
|
||||
- [x] Add contract test coverage for editing a forward after its referenced speed limit is deleted.
|
||||
- [x] Run focused contract tests for forward save behavior.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 007 User Tunnel Save Missing Speed Limit Auto Clear
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Locate user tunnel speed limit validation paths for assign/update flows.
|
||||
- [x] Change user tunnel save behavior to auto-clear missing `speedId` instead of failing.
|
||||
- [x] Add contract test coverage for user tunnel save when referenced speed limit is deleted.
|
||||
- [x] Run focused contract tests for user tunnel save behavior.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 008 Frontend Missing Speed Limit Consistency
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Review forward and user tunnel submit flows for missing speed limit behavior.
|
||||
- [x] Make frontend normalize deleted `speedId` to `null` before submit in both pages.
|
||||
- [x] Add consistent non-blocking warning toast when deleted speed rule is auto-cleared.
|
||||
- [x] Verify touched frontend files pass lint checks.
|
||||
@@ -0,0 +1,112 @@
|
||||
# 009: 普通用户转发权限限制
|
||||
|
||||
## 背景
|
||||
|
||||
当前系统允许普通用户在创建和编辑转发时设置:
|
||||
1. **限速规则** (`speedId`) - 应仅限管理员设置
|
||||
2. **自定义入口端口** (`inPort`) - 应仅限管理员设置
|
||||
|
||||
普通用户应只能使用系统自动分配的端口和默认不限速设置。
|
||||
|
||||
## 实施范围
|
||||
|
||||
| 操作 | 普通用户 | 管理员 |
|
||||
|------|----------|--------|
|
||||
| 创建转发 - 设置限速 | 禁止 | 允许 |
|
||||
| 创建转发 - 自定义端口 | 禁止 | 允许 |
|
||||
| 编辑转发 - 修改限速 | 禁止 | 允许 |
|
||||
| 编辑转发 - 修改端口 | 禁止 | 允许 |
|
||||
|
||||
## 修改位置
|
||||
|
||||
### 后端 (Go)
|
||||
|
||||
**文件**: `go-backend/internal/http/handler/mutations.go`
|
||||
|
||||
#### 1. `forwardCreate` handler (行 1147-1157)
|
||||
|
||||
在处理 speedId 和 inPort 之前添加权限检查:
|
||||
|
||||
```go
|
||||
if roleID != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. `forwardUpdate` handler (行 1264-1274)
|
||||
|
||||
在处理 speedId 和 inPort 之前添加权限检查:
|
||||
|
||||
```go
|
||||
if actorRole != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 前端 (React/TypeScript)
|
||||
|
||||
**文件**: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
已有变量 `isAdmin` (行 610: `const isAdmin = tokenRoleId === 0;`)
|
||||
|
||||
#### 1. 隐藏限速规则选择器 (行 4252-4282)
|
||||
|
||||
用条件渲染包裹:
|
||||
|
||||
```tsx
|
||||
{isAdmin && (
|
||||
<Select
|
||||
label="限速规则"
|
||||
// ... 现有属性
|
||||
>
|
||||
{/* ... */}
|
||||
</Select>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 2. 隐藏入口端口输入框 (行 4311-4328)
|
||||
|
||||
用条件渲染包裹:
|
||||
|
||||
```tsx
|
||||
{isAdmin && (
|
||||
<Input
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
// ... 现有属性
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 后端: `forwardCreate` 添加权限检查
|
||||
- [x] 后端: `forwardUpdate` 添加权限检查
|
||||
- [x] 前端: 隐藏限速规则选择器 (仅管理员可见)
|
||||
- [x] 前端: 隐藏入口端口输入框 (仅管理员可见)
|
||||
- [x] 后端: 添加契约测试验证权限限制
|
||||
- [x] 运行测试验证
|
||||
|
||||
## 测试验证
|
||||
|
||||
1. ✅ 契约测试已添加 `TestNonAdminCannotSetSpeedIdOrPort`
|
||||
2. ✅ 所有测试用例通过:
|
||||
- 普通用户创建转发时设置 speedId 被拒绝
|
||||
- 普通用户创建转发时设置 inPort 被拒绝
|
||||
- 普通用户创建转发时不设置 speedId/inPort 成功
|
||||
- 普通用户更新转发时设置 speedId 被拒绝
|
||||
- 普通用户更新转发时设置 inPort 被拒绝
|
||||
- 普通用户更新转发时不设置 speedId/inPort 成功
|
||||
@@ -0,0 +1,97 @@
|
||||
# 010 多入口/多出口/多跳自定义 IP 限制与回归
|
||||
|
||||
## 目标
|
||||
- 修复多入口转发列表只显示一个入口地址的问题。
|
||||
- 在 UI 和后端同时限制以下场景的自定义 IP:
|
||||
- 多入口转发禁止自定义监听 IP(`inIp`)。
|
||||
- 多出口隧道禁止自定义连接 IP(`connectIp`)。
|
||||
- 转发链单跳多节点禁止自定义连接 IP(`connectIp`)。
|
||||
|
||||
## 范围说明(基于当前实际)
|
||||
- 不改“隧道页面入口 IP 文本域”的行为(按确认:该字段是展示用途,不作为本次约束点)。
|
||||
- 本次仅覆盖已落地代码与可复现验证项。
|
||||
|
||||
## Checklist
|
||||
- [x] 修复 `resolveForwardIngress` 的错误回退逻辑(移除 `tunnelFirstIP` 覆盖)。
|
||||
- [x] 前端转发页:多入口隧道禁用“监听IP”选择并显示提示。
|
||||
- [x] 前端隧道页:多出口禁用“连接IP”选择并显示提示。
|
||||
- [x] 前端隧道页:转发链单跳多节点禁用“连接IP”选择并显示提示。
|
||||
- [x] 后端隧道创建/编辑增加 `connectIp` 约束校验(多出口、多节点跳)。
|
||||
- [x] 后端转发创建/编辑增加 `inIp` 约束校验(多入口)。
|
||||
- [x] 后端构建验证通过。
|
||||
- [x] 前端构建验证通过。
|
||||
- [x] 相关定向合约测试通过(forward/tunnel)。
|
||||
- [x] 全量 contract 测试执行并记录结果(存在与本次改动无关的既有失败)。
|
||||
- [ ] 数据迁移脚本(可选):将历史多入口/多出口/多节点的自定义 IP 清理为默认值。
|
||||
|
||||
## 实施记录
|
||||
|
||||
### 代码变更
|
||||
- `go-backend/internal/store/repo/repository.go`
|
||||
- 在 `resolveForwardIngress` 中移除 `tunnelFirstIP` 逻辑。
|
||||
- `in_ip` 为空时回退到每个入口节点自身 `server_ip`,避免多入口被合并为单入口展示。
|
||||
|
||||
- `vite-frontend/src/pages/forward.tsx`
|
||||
- 新增 `isCurrentTunnelMultiEntrance` 判断。
|
||||
- 多入口时禁用“监听IP”Select,并展示“多入口隧道使用节点默认IP”。
|
||||
|
||||
- `vite-frontend/src/pages/tunnel.tsx`
|
||||
- 转发链区域新增 `isMultiNodeGroup`,单跳多节点时禁用连接 IP 选择。
|
||||
- 出口区域新增 `isMultiExit`,多出口时禁用连接 IP 选择。
|
||||
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `tunnelCreate` / `tunnelUpdate` 调用 `validateTunnelConnectIPConstraints(req)`。
|
||||
- 新增 `validateTunnelConnectIPConstraints`:
|
||||
- 多出口+自定义 `connectIp` 拒绝。
|
||||
- 转发链单跳多节点+自定义 `connectIp` 拒绝。
|
||||
- `forwardCreate` / `forwardUpdate`:多入口+自定义 `inIp` 拒绝。
|
||||
|
||||
## 验证记录
|
||||
|
||||
### 1) 后端构建
|
||||
```bash
|
||||
cd go-backend
|
||||
go build ./internal/http/handler/...
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 2) 前端构建
|
||||
```bash
|
||||
cd vite-frontend
|
||||
npm run build
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 3) 后端包测试
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./internal/store/repo/...
|
||||
go test ./internal/http/handler/...
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 4) 定向合约测试(forward/tunnel)
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./tests/contract/... -run "TestForward.*|TestTunnel.*"
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 5) 全量合约测试(记录)
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./tests/contract/...
|
||||
```
|
||||
结果:所有测试通过。
|
||||
|
||||
### 6) 修复遗留的合约测试失败
|
||||
在测试过程中发现并修复了 `upsertUserTunnel` 函数的 bug:
|
||||
- **问题**:`normalizeSpeedLimitReference` 的返回值覆盖了 `GetExistingUserTunnel` 的错误,导致 `sql.ErrNoRows` 判断失效。
|
||||
- **修复**:将 `GetExistingUserTunnel` 的错误保存到 `lookupErr` 变量,避免被后续调用覆盖。
|
||||
- **影响范围**:仅影响 `userTunnelBatchAssign` 路径,不影响其他功能。
|
||||
- **验证**:两个失败的测试(`TestUserTunnelReassignmentKeepsStableID`、`TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract`)现在都通过。
|
||||
|
||||
## 完成状态
|
||||
- 本计划按当前实际范围已完成。
|
||||
- 所有合约测试通过(14/14)。
|
||||
- 任务 10(数据迁移)已纳入计划,当前为可选项,默认不执行。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 011 转发服务名升级兼容与节点滚动升级
|
||||
|
||||
## 目标
|
||||
- 修复旧版本升级后编辑转发/隧道出现 `service not found`(service不存在)的问题。
|
||||
- 在后端加入兼容自愈逻辑,允许旧命名与新命名共存过渡。
|
||||
- 给出低风险节点升级顺序,避免一次性全量切换带来的中断。
|
||||
|
||||
## Checklist
|
||||
- [x] 定位回归路径:服务名从 `forward_user_0` 迁移到真实 `user_tunnel_id` 后,与旧运行态不一致导致控制失败。
|
||||
- [x] 在 `UpdateService` 的兼容路径加入旧服务清理后重建逻辑。
|
||||
- [x] 在 `Pause/Resume` 控制路径加入首次 not found 后自愈重试逻辑。
|
||||
- [x] 增加回归测试覆盖兼容行为。
|
||||
- [x] 执行 `go-backend` 相关测试并记录结果。
|
||||
- [x] 输出运维侧“后端先行 + agent 灰度升级 + 批量重部署”操作步骤。
|
||||
|
||||
## 变更说明(实施中)
|
||||
- 后端控制面将在检测到升级期的服务名不一致时进行自动自愈,降低人工干预和手工重建成本。
|
||||
|
||||
## 测试记录
|
||||
- 命令:`cd go-backend && go test ./internal/http/handler/...`
|
||||
- 结果:通过。
|
||||
|
||||
## 运维升级顺序(推荐)
|
||||
1. 先发布本次后端兼容补丁(无需等待所有 agent 同步升级)。
|
||||
2. 按 10%-20% 灰度分批升级 agent(低风险节点 -> 非高峰节点 -> 全量)。
|
||||
3. 每批升级后执行一次“转发批量重部署”,将运行态统一到新服务命名。
|
||||
4. 观察日志中 `service .* not found` 是否清零,再推进下一批。
|
||||
5. 全量稳定后保留兼容逻辑至少一个小版本周期,再评估收敛。
|
||||
@@ -0,0 +1,158 @@
|
||||
# Plan 012: 允许用户自定义转发入口端口(限制在节点端口范围内)
|
||||
|
||||
**Issue**: #268
|
||||
**状态**: 已完成
|
||||
|
||||
## 背景
|
||||
|
||||
当前版本限制了普通用户自定义转发入口端口 (inPort) 的能力,导致:
|
||||
- 用户迁移数据后无法保留原有端口配置
|
||||
- 无法编辑转发配置
|
||||
- 需要重建所有转发,操作繁琐
|
||||
|
||||
## 实现方案
|
||||
|
||||
允许用户和管理员自定义转发入口端口,但强制在节点端口设置的范围内。
|
||||
|
||||
### 默认行为
|
||||
- 不填写端口 → 随机分配(在端口范围内)
|
||||
- 填写端口 → 使用指定端口(需在范围内且不冲突)
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### 1. 后端修改
|
||||
|
||||
- [x] **1.1 移除非管理员 inPort 权限限制**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 位置: `forwardCreate` 函数 (约 L1156-1167)
|
||||
- 位置: `forwardUpdate` 函数 (约 L1279-1291)
|
||||
- 操作: 删除 `roleID != 0` 时阻止 inPort 设置的逻辑
|
||||
- 状态: 代码中已无 inPort 权限限制
|
||||
|
||||
- [x] **1.2 添加本地节点端口范围验证函数**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 新增函数: `validateLocalNodePort(node *nodeRecord, port int) error`
|
||||
- 逻辑: 使用 `parsePortRangeSpec` 解析端口范围,验证 port 是否在范围内
|
||||
- 状态: 函数已存在于 L3517-3533
|
||||
|
||||
- [x] **1.3 修改 forwardCreate 端口验证**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 位置: `forwardCreate` 中 entry nodes 遍历处 (约 L1188-1197)
|
||||
- 操作:
|
||||
- 对远程节点使用现有 `validateRemoteNodePort`
|
||||
- 对本地节点使用新的 `validateLocalNodePort`
|
||||
- 若用户指定的端口超出节点范围,返回错误提示
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **1.4 修改 forwardUpdate 端口验证**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 位置: `forwardUpdate` 中 entry nodes 遍历处 (约 L1326-1335)
|
||||
- 操作: 同 1.3,添加本地节点端口范围验证
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **1.5 `ListUserAccessibleTunnels` 添加端口范围信息**
|
||||
- 文件: `go-backend/internal/store/repo/repository.go`
|
||||
- 位置: L751-775
|
||||
- 操作:
|
||||
- 查询隧道关联的入口节点 (通过 `chain_tunnel` 表 `chain_type=1`)
|
||||
- 获取入口节点的端口范围 (`node.port` 字段)
|
||||
- 使用 `parsePortRangeSpec` 解析并计算 min/max
|
||||
- 在返回的 map 中添加 `portRangeMin` 和 `portRangeMax` 字段
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **1.6 `ListEnabledTunnelSummaries` 添加端口范围信息**
|
||||
- 文件: `go-backend/internal/store/repo/repository.go`
|
||||
- 位置: L777-796
|
||||
- 操作: 同 1.5,为管理员视图也提供端口范围信息
|
||||
- 状态: 已实现
|
||||
|
||||
### 2. 前端修改
|
||||
|
||||
- [x] **2.1 为所有用户显示 inPort 输入框**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: 约 L4350-4369
|
||||
- 操作: 移除 `{isAdmin && (` 条件包装,改为所有用户可见
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.2 提交时包含 inPort(非仅管理员)**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: `handleSave` 函数 (约 L1435, L1447)
|
||||
- 操作: 移除 `...(isAdmin ? { inPort: form.inPort } : {})` 条件,直接包含 inPort
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.3 更新 Tunnel 接口添加 portRangeMin/Max**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: L123-131
|
||||
- 操作: 添加 `portRangeMin?: number; portRangeMax?: number;`
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.4 inPort 输入框显示端口范围提示**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: L4350-4369
|
||||
- 操作:
|
||||
- 基于 `form.tunnelId` 获取当前隧道的端口范围
|
||||
- 在 Input 的 `description` 中显示提示,如: `"指定入口端口,留空自动分配 (允许范围: 10000-20000)"`
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.5 前端端口范围验证**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: 验证函数 (L1271-1279)
|
||||
- 操作: 前端也做范围预检查,超出范围时显示错误
|
||||
- 状态: 已实现并修复语法错误
|
||||
|
||||
### 3. 测试修改
|
||||
|
||||
- [x] **3.1 更新权限测试**
|
||||
- 文件: `go-backend/tests/contract/forward_contract_test.go`
|
||||
- 位置: L1001-1119
|
||||
- 操作:
|
||||
- 修改 "non-admin cannot set inPort" 测试为允许设置
|
||||
- 新增 "non-admin inPort within range" 测试(通过)
|
||||
- 新增 "non-admin inPort out of range" 测试(失败)
|
||||
- 状态: 已更新
|
||||
|
||||
- [x] **3.2 新增端口范围验证测试**
|
||||
- 文件: `go-backend/tests/contract/forward_contract_test.go`
|
||||
- 操作:
|
||||
- 测试本地节点端口范围验证
|
||||
- 测试远程节点端口范围验证(已有 `validateRemoteNodePort` 相关测试可参考)
|
||||
- 状态: 已添加
|
||||
|
||||
---
|
||||
|
||||
## 关键代码位置
|
||||
|
||||
| 功能 | 文件 | 行号 |
|
||||
|------|------|------|
|
||||
| 前端 inPort 输入框 | `vite-frontend/src/pages/forward.tsx` | L4350-4369 |
|
||||
| 前端提交条件 | `vite-frontend/src/pages/forward.tsx` | L1435, L1447 |
|
||||
| 后端创建权限检查 | `go-backend/internal/http/handler/mutations.go` | L1156-1167 |
|
||||
| 后端更新权限检查 | `go-backend/internal/http/handler/mutations.go` | L1279-1291 |
|
||||
| 远程节点端口验证 | `go-backend/internal/http/handler/federation.go` | L562-574 |
|
||||
| 本地节点端口验证 | `go-backend/internal/http/handler/mutations.go` | L3517-3533 |
|
||||
| 端口范围解析 | `go-backend/internal/store/repo/repository_mutations.go` | L1370-1412 |
|
||||
| 用户隧道列表 | `go-backend/internal/store/repo/repository.go` | L751-775 |
|
||||
| 管理员隧道列表 | `go-backend/internal/store/repo/repository.go` | L777-796 |
|
||||
| 合约测试 | `go-backend/tests/contract/forward_contract_test.go` | L1001-1119 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. ✅ 普通用户可以在创建转发时指定 inPort
|
||||
2. ✅ 普通用户可以在编辑转发时修改 inPort
|
||||
3. ✅ 指定的端口必须在节点端口范围内,否则返回错误
|
||||
4. ✅ 留空 inPort 时行为不变(自动分配)
|
||||
5. ✅ 前端显示端口范围提示
|
||||
6. ✅ 所有合约测试通过
|
||||
|
||||
---
|
||||
|
||||
## 实施总结
|
||||
|
||||
该计划的大部分代码已在之前的开发中实现。本次实施主要完成了以下工作:
|
||||
|
||||
1. **修复前端验证代码语法错误** - `forward.tsx` 中 `validateForm` 函数的端口范围验证代码存在语法错误,已修复
|
||||
2. **更新测试用例** - 将原本期望权限拒绝的测试改为端口范围验证测试,并修正了测试中使用的端口号
|
||||
@@ -0,0 +1,13 @@
|
||||
# 013 Forward Delete NotFound Compatibility Fix
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm forward update failure path caused by delete fallback short-circuiting on the first not-found service name.
|
||||
- [x] Update forward service deletion logic to continue across all candidate runtime names until one is actually deleted or every candidate is exhausted.
|
||||
- [x] Add regression tests covering mixed not-found and legacy-name delete recovery during forward control/update flows.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 014 Forward Port Occupancy Validation
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm current forward create/update only validates node port range and misses DB-backed occupancy checks for local nodes.
|
||||
- [x] Add shared forward port occupancy validation for create/update paths before runtime dispatch.
|
||||
- [x] Add focused tests covering create/update validation when another forward already uses the same node+port.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 015 Forward Runtime Port Residual Cleanup
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm 2.1.6 used service names with `_0` runtime base while later versions may target resolved `user_tunnel_id`, leaving old runtime services behind after direct upgrade.
|
||||
- [x] Extend self-occupy recovery to clean residual candidate service names and retry update/add when the port is only occupied by self-owned legacy runtime services.
|
||||
- [x] Add regression tests covering address-in-use recovery with legacy `_0` runtime residue.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 016 Tunnel Runtime Bind Conflict Retry
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm tunnel `connectIp` precedence remains `connectIp > node tcp_listen_addr` for runtime service listen address.
|
||||
- [x] Add tunnel runtime `address already in use` recovery that deletes the stale service and retries `AddService`.
|
||||
- [x] Keep non-bind failures unchanged and avoid altering tunnel chain apply semantics.
|
||||
- [x] Add regression tests for tunnel service address precedence and bind-conflict retry behavior.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
- [ ] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
|
||||
- [ ] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
|
||||
- [x] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
|
||||
- [x] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateRecoversFromAddressInUseContract|TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract|TestForwardUpdateIgnoresDeletedSpeedLimitContract'`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/... && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
|
||||
- Result: passed.
|
||||
|
||||
## Investigation Note
|
||||
|
||||
- Forward update still has its own independent `address already in use` recovery path in `syncForwardServicesWithWarnings` / `rebindForwardServiceOnSelfOccupiedPort`; tunnel update linkage is not the only possible source of the symptom.
|
||||
- Tunnel update also triggers downstream forward `UpdateService` for bound forwards, so users can still observe the same error around a tunnel edit even when the failing runtime is on the tunnel side.
|
||||
- Real node output can collapse spaces into variants like `address alreadyin use` / `cannotassignrequestedaddress`; bind-conflict detection now normalizes whitespace before classifying the error.
|
||||
- Forward self-heal cleanup now deletes every candidate runtime name variant instead of stopping after the first successful delete, which avoids leaving sibling `_tcp`/`_udp` services behind to keep the port occupied.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 017 PR 284 UI Follow-up Fixes
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Review the current frontend route and component state related to PR 284 follow-up fixes.
|
||||
- [x] Restore the intended H5 simple-layout route behavior for panel sharing.
|
||||
- [x] Improve date text parsing to support separator-free and flexible formats without ambiguous fallbacks.
|
||||
- [x] Add config-page back navigation with a safer history fallback and shared icon usage.
|
||||
- [x] Run focused frontend verification for the updated files and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd vite-frontend && npm install`
|
||||
- Result: passed.
|
||||
- Command: `cd vite-frontend && npm run build`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 018 User Tunnel Disable Status Sync
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Inspect the user tunnel permission edit flow and identify why disabling an assigned tunnel appears ineffective.
|
||||
- [x] Return the real `user_tunnel.status` value from the admin permission list API instead of a hardcoded enabled state.
|
||||
- [x] Add contract coverage for the user tunnel permission list status mapping and run focused backend verification.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./tests/contract/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,21 @@
|
||||
# 019 Federation Share Traffic Bigint Migration
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Inspect federation share creation failure and identify the PostgreSQL `int4` overflow source.
|
||||
- [x] Audit other traffic-related legacy PostgreSQL columns that may still be `integer` despite Go models using `int64`.
|
||||
- [x] Add a schema migration that widens legacy traffic/quota columns from `integer` to `bigint`.
|
||||
- [x] Add migration tests covering the new schema version branch and error propagation.
|
||||
- [x] Run focused backend verification for the migration changes.
|
||||
|
||||
## Notes
|
||||
|
||||
- The reported failing value `536870912000` is 500 GiB in bytes and overflows PostgreSQL `int4`.
|
||||
- The fix widens historical PostgreSQL traffic columns in `user`, `forward`, `statistics_flow`, `tunnel`, `user_tunnel`, and `peer_share` to `BIGINT` when needed.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/store/repo/...`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./tests/contract/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,164 @@
|
||||
# 020 AJAX No-refresh UX
|
||||
|
||||
## Objective
|
||||
|
||||
- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite.
|
||||
- Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness.
|
||||
- Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue.
|
||||
- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads.
|
||||
- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface.
|
||||
- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`.
|
||||
|
||||
## Current State
|
||||
|
||||
- `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates.
|
||||
- `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior.
|
||||
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh.
|
||||
- Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records.
|
||||
- Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Prefer local state patching after successful mutations when the changed record set is known.
|
||||
- Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset.
|
||||
- Use polling only where realtime transport does not already exist.
|
||||
- Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic.
|
||||
- Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`.
|
||||
- [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication.
|
||||
- [x] Harden node realtime reconnection behavior in `vite-frontend/src/pages/node/use-node-realtime.ts` and define a fallback refresh path if websocket recovery fails.
|
||||
- [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers.
|
||||
- [x] Convert forward create/edit/delete/service-toggle flows in `vite-frontend/src/pages/forward.tsx` from whole-page refetches to local or targeted updates where safe.
|
||||
- [x] Convert tunnel create/edit/delete flows in `vite-frontend/src/pages/tunnel.tsx` from whole-page refetches to local or targeted updates where safe.
|
||||
- [x] Convert user create/edit/delete and user-tunnel permission mutation flows in `vite-frontend/src/pages/user.tsx` to local or targeted updates where safe.
|
||||
- [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations.
|
||||
- [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative.
|
||||
- [ ] Run focused frontend verification and record the result in this plan after implementation.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 - Dashboard auto-refresh and node realtime resilience
|
||||
|
||||
#### 1. Dashboard traffic/statistics auto-refresh
|
||||
|
||||
- Extract `loadPackageData()` and `loadAnnouncement()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` into stable callbacks so the hook can refresh data without re-running the whole mount sequence.
|
||||
- Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`.
|
||||
- Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates.
|
||||
- Pause polling when `document.visibilityState !== "visible"`, then trigger an immediate refresh when the tab becomes visible again.
|
||||
- Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker.
|
||||
|
||||
#### 2. Dashboard notification safety
|
||||
|
||||
- Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings.
|
||||
- Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle.
|
||||
- Ensure refreshes that only change traffic counters do not retrigger expiry toasts.
|
||||
|
||||
#### 3. Node realtime hardening
|
||||
|
||||
- Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget.
|
||||
- Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached.
|
||||
- If a fallback list refresh is introduced, merge incoming node metadata with existing `systemInfo`, `connectionStatus`, and upgrade-progress state so live metrics are not wiped during recovery.
|
||||
- Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`.
|
||||
|
||||
### Phase 2 - Local mutation updates and partial refreshes
|
||||
|
||||
#### 4. Shared list-patching helpers
|
||||
|
||||
- Add small reusable helpers for common state operations such as:
|
||||
- replace one item by `id`
|
||||
- remove one or many items by `id`
|
||||
- upsert a created or updated item into an ordered list
|
||||
- preserve derived UI-only fields during server payload merges
|
||||
- Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction.
|
||||
|
||||
#### 5. Forward page partial refresh conversion
|
||||
|
||||
- Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns.
|
||||
- Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure.
|
||||
- Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient.
|
||||
- Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state.
|
||||
- Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations.
|
||||
|
||||
#### 6. Tunnel page partial refresh conversion
|
||||
|
||||
- Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`.
|
||||
- Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload.
|
||||
- Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent.
|
||||
|
||||
#### 7. User page partial refresh conversion
|
||||
|
||||
- Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely.
|
||||
- Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations.
|
||||
- Respect server-side pagination semantics for the user list; if the server response does not provide enough data for a safe local patch, use a targeted page refetch rather than a full multi-dataset refresh.
|
||||
- Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues.
|
||||
|
||||
### Phase 3 - Batch progress UX and search/filter polish
|
||||
|
||||
#### 8. Batch progress UX
|
||||
|
||||
- Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations.
|
||||
- Review `vite-frontend/src/pages/forward/batch-actions.ts` and tunnel batch handlers to determine whether current APIs expose enough intermediate state for real progress.
|
||||
- If only final summary APIs are available, implement staged client-side progress feedback such as `processing X/Y`, current action label, success count, and failure count.
|
||||
- If the UX requirement cannot be met without backend support, document the missing backend contract and split the work into frontend and backend follow-ups.
|
||||
|
||||
#### 9. Search and filter responsiveness
|
||||
|
||||
- Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages.
|
||||
- Audit the user page separately because it depends on server-side pagination and keyword search.
|
||||
- If user-page instant filtering is desired, choose one of two explicit strategies:
|
||||
- keep server-side pagination authoritative and add debounce for keyword-triggered requests, or
|
||||
- load a larger local dataset only if product requirements accept the cost.
|
||||
- Do not silently mix partial client filtering with incomplete paginated datasets.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Repeated dashboard polling may spam expiry toasts.
|
||||
- Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes.
|
||||
- Node recovery refreshes may wipe websocket-derived metrics.
|
||||
- Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly.
|
||||
- Local mutation patching may desynchronize grouped, sorted, or selected views.
|
||||
- Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state.
|
||||
- Batch APIs may not expose progress details.
|
||||
- Mitigation: implement client-side staged progress where possible and document backend gaps where not.
|
||||
- User-page local updates may conflict with pagination semantics.
|
||||
- Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
- Dashboard:
|
||||
- Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload.
|
||||
- Confirm hidden-tab pause and visible-tab immediate refresh behavior.
|
||||
- Confirm expiry toasts do not repeat on every polling cycle.
|
||||
- Nodes:
|
||||
- Confirm websocket-driven online/offline transitions still work.
|
||||
- Simulate websocket interruption and verify reconnect or fallback refresh behavior.
|
||||
- Confirm recovery does not clear existing live metrics unexpectedly.
|
||||
- Forwards, tunnels, users:
|
||||
- Create, edit, delete, enable, disable, and reset flows without browser reload.
|
||||
- Confirm the affected rows update immediately and other unrelated rows stay stable.
|
||||
- Confirm selection state, ordering, and modal close behavior remain correct after local patching.
|
||||
- Batch actions:
|
||||
- Confirm visible progress or staged status feedback exists during long-running operations.
|
||||
- Confirm success and failure summaries remain accurate after completion.
|
||||
- Build:
|
||||
- Run `cd vite-frontend && npm run build`.
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
- Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain.
|
||||
- Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly.
|
||||
- If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd vite-frontend && npm install`
|
||||
- Result: passed.
|
||||
- Command: `cd vite-frontend && npm run build`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Node Remarks, Tags, and Expiry Plan
|
||||
|
||||
- [x] Review issue #246 and inspect current node backend/frontend flow
|
||||
- [x] Extend node persistence and API payloads with remark, tags, and expiry fields
|
||||
- [x] Update node management UI to edit, display, and search the new metadata
|
||||
- [x] Verify the backend and frontend still build successfully
|
||||
@@ -0,0 +1,6 @@
|
||||
# Node Expiry Highlights And Dashboard Reminders Plan
|
||||
|
||||
- [x] Review current node page and dashboard data flow for expiry-related hooks
|
||||
- [x] Add node expiry status helpers plus expiring-soon filter/highlight in node management
|
||||
- [x] Load node expiry data on the dashboard for admins and render reminder card
|
||||
- [x] Verify frontend build and mark the plan complete
|
||||
@@ -0,0 +1,6 @@
|
||||
# Forward Page Tunnel Traffic Ratio Plan
|
||||
|
||||
- [x] Review `/forward/list` data flow and rule page render points for tunnel ratio support
|
||||
- [x] Extend backend forward list payload with tunnel traffic ratio and cover it with a contract test
|
||||
- [x] Update forward page types, mapping, grouped metadata, and visible ratio UI across list modes
|
||||
- [x] Verify targeted backend tests and frontend build, then mark the plan complete
|
||||
@@ -188,7 +188,7 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<PanelSharingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ export const getUserPackageInfo = () =>
|
||||
export const createNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/create", data);
|
||||
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||
export const getDashboardNodeExpiryList = () =>
|
||||
Network.post<NodeApiItem[]>("/node/list", {});
|
||||
export const updateNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
|
||||
@@ -3,6 +3,9 @@ export interface NodeApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
tags?: string;
|
||||
expiryTime?: number;
|
||||
syncError?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -44,6 +47,7 @@ export interface ForwardApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
tunnelName?: string;
|
||||
tunnelTrafficRatio?: number;
|
||||
inIp?: string;
|
||||
inPort?: number;
|
||||
remoteAddr?: string;
|
||||
@@ -214,6 +218,9 @@ export interface NodeMutationPayload {
|
||||
name?: string;
|
||||
status?: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
tags?: string;
|
||||
expiryTime?: number;
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
|
||||
@@ -259,3 +259,29 @@ export const SettingsIcon = ({
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const BackIcon = ({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15 19l-7-7 7-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function AdminLayout({
|
||||
},
|
||||
{
|
||||
path: "/forward",
|
||||
label: "转发",
|
||||
label: "规则",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { BackIcon } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
@@ -25,13 +26,7 @@ export default function H5SimpleLayout({
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button isIconOnly size="sm" variant="light" onPress={handleBack}>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<BackIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<BrandLogo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
{
|
||||
path: "/forward",
|
||||
label: "转发",
|
||||
label: "规则",
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
updateAnnouncement,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { BackIcon, SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
|
||||
import {
|
||||
@@ -119,8 +119,8 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
||||
},
|
||||
{
|
||||
key: "forward_compact_mode",
|
||||
label: "转发页面精简模式",
|
||||
description: "开启后,转发页面列表使用 2.1.6-alpha8 样式(全局配置)",
|
||||
label: "规则页面精简模式",
|
||||
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
|
||||
type: "switch",
|
||||
},
|
||||
{
|
||||
@@ -153,7 +153,7 @@ const BACKUP_TYPE_OPTIONS = [
|
||||
{ value: "users", label: "用户" },
|
||||
{ value: "nodes", label: "节点" },
|
||||
{ value: "tunnels", label: "隧道" },
|
||||
{ value: "forwards", label: "转发" },
|
||||
{ value: "forwards", label: "规则" },
|
||||
{ value: "userTunnels", label: "用户隧道权限" },
|
||||
{ value: "speedLimits", label: "限速规则" },
|
||||
{ value: "tunnelGroups", label: "隧道分组" },
|
||||
@@ -234,6 +234,21 @@ export default function ConfigPage() {
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
|
||||
const canGoBack =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.history.state?.idx === "number" &&
|
||||
window.history.state.idx > 0;
|
||||
|
||||
const handleBack = () => {
|
||||
if (canGoBack) {
|
||||
navigate(-1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
navigate("/profile", { replace: true });
|
||||
};
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (!isAdmin()) {
|
||||
@@ -846,6 +861,16 @@ export default function ConfigPage() {
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="返回上一页"
|
||||
className="min-w-0 w-9 h-9"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleBack}
|
||||
>
|
||||
<BackIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<SettingsIcon className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">网站配置</h1>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
||||
import {
|
||||
useDashboardData,
|
||||
type DashboardForward as Forward,
|
||||
type DashboardNodeExpiryItem,
|
||||
type DashboardUserTunnel as UserTunnel,
|
||||
} from "@/pages/dashboard/use-dashboard-data";
|
||||
|
||||
@@ -34,6 +35,7 @@ export default function DashboardPage() {
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
nodeExpiryReminders,
|
||||
isAdmin,
|
||||
announcement,
|
||||
} = useDashboardData();
|
||||
@@ -70,6 +72,103 @@ export default function DashboardPage() {
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
const getNodeExpiryStatus = (expiryTime?: number) => {
|
||||
if (!expiryTime) {
|
||||
return {
|
||||
label: "永久有效",
|
||||
badgeClassName:
|
||||
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
|
||||
};
|
||||
}
|
||||
|
||||
const diffMs = expiryTime - Date.now();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 0) {
|
||||
return {
|
||||
label: "已过期",
|
||||
badgeClassName:
|
||||
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
|
||||
};
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return {
|
||||
label: "明天到期",
|
||||
badgeClassName:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${diffDays}天后到期`,
|
||||
badgeClassName:
|
||||
diffDays <= 7
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
|
||||
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
|
||||
};
|
||||
};
|
||||
|
||||
const parseNodeTags = (tags?: string) => {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
|
||||
const expiryStatus = getNodeExpiryStatus(node.expiryTime);
|
||||
const tags = parseNodeTags(node.tags);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{node.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-default-500">节点 ID: {node.id}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
|
||||
>
|
||||
{expiryStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
|
||||
{node.expiryTime
|
||||
? new Date(node.expiryTime).toLocaleString()
|
||||
: "未设置到期时间"}
|
||||
</div>
|
||||
|
||||
{node.remark?.trim() && (
|
||||
<p className="mt-3 line-clamp-2 text-xs leading-5 text-default-600 dark:text-default-400">
|
||||
{node.remark.trim()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={`${node.id}-${tag}`}
|
||||
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理24小时流量统计数据
|
||||
const processFlowChartData = () => {
|
||||
// 生成最近24小时的时间数组(从当前小时往前推24小时)
|
||||
@@ -616,7 +715,7 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-purple-100 dark:bg-purple-500/20"
|
||||
title="转发配额"
|
||||
title="规则配额"
|
||||
value={formatNumber(userInfo.num || 0)}
|
||||
/>
|
||||
|
||||
@@ -650,7 +749,7 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-orange-100 dark:bg-orange-500/20"
|
||||
title="已用转发"
|
||||
title="已用规则"
|
||||
value={forwardList.length}
|
||||
/>
|
||||
</div>
|
||||
@@ -661,6 +760,52 @@ export default function DashboardPage() {
|
||||
statisticsFlowsCount={statisticsFlows.length}
|
||||
/>
|
||||
|
||||
{isAdmin && nodeExpiryReminders.length > 0 && (
|
||||
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
节点到期提醒
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
展示 7 天内到期或已过期的节点,便于提前续费或清理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
|
||||
{nodeExpiryReminders.length} 个提醒
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
|
||||
</div>
|
||||
{nodeExpiryReminders.length > 6 && (
|
||||
<p className="mt-4 text-xs text-default-500">
|
||||
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 隧道权限 - 管理员不显示 */}
|
||||
{!isAdmin && (
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
@@ -753,7 +898,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-default-600 mb-1">
|
||||
转发配额
|
||||
规则配额
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">
|
||||
{formatNumber(tunnel.num)}
|
||||
@@ -761,7 +906,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-default-600 mb-1">
|
||||
已用转发
|
||||
已用规则
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">
|
||||
{getTunnelUsedForwards(tunnel.tunnelId)}
|
||||
@@ -784,7 +929,7 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 转发配置 */}
|
||||
{/* 规则配置 */}
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -801,7 +946,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
转发配置
|
||||
规则配置
|
||||
</h2>
|
||||
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
|
||||
{forwardList.length}
|
||||
@@ -825,7 +970,7 @@ export default function DashboardPage() {
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-default-500">暂无转发配置</p>
|
||||
<p className="text-default-500">暂无规则配置</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -839,7 +984,7 @@ export default function DashboardPage() {
|
||||
{group.tunnelName}
|
||||
</h3>
|
||||
<span className="px-2 py-1 bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 rounded-md text-sm">
|
||||
{group.forwards.length} 个转发
|
||||
{group.forwards.length} 个规则
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ForwardApiItem } from "@/api/types";
|
||||
import type { ForwardApiItem, NodeApiItem } from "@/api/types";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getAnnouncement,
|
||||
getDashboardNodeExpiryList,
|
||||
getUserPackageInfo,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
@@ -52,12 +53,38 @@ export interface DashboardStatisticsFlow {
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface DashboardNodeExpiryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
remark?: string;
|
||||
tags?: string;
|
||||
expiryTime?: number;
|
||||
}
|
||||
|
||||
const DASHBOARD_POLL_INTERVAL_MS = 5000;
|
||||
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
|
||||
"dashboard:last-expiration-notification";
|
||||
|
||||
const buildExpirationNotificationKey = (
|
||||
userInfo: DashboardUserInfo,
|
||||
tunnels: DashboardUserTunnel[],
|
||||
) => {
|
||||
const userExpTime = userInfo.expTime ?? "permanent";
|
||||
const tunnelExpirationKey = [...tunnels]
|
||||
.map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? "permanent"}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
return `user:${userExpTime};tunnels:${tunnelExpirationKey}`;
|
||||
};
|
||||
|
||||
interface DashboardDataState {
|
||||
loading: boolean;
|
||||
userInfo: DashboardUserInfo;
|
||||
userTunnels: DashboardUserTunnel[];
|
||||
forwardList: DashboardForward[];
|
||||
statisticsFlows: DashboardStatisticsFlow[];
|
||||
nodeExpiryReminders: DashboardNodeExpiryItem[];
|
||||
isAdmin: boolean;
|
||||
announcement: AnnouncementData | null;
|
||||
}
|
||||
@@ -66,8 +93,10 @@ const checkExpirationNotifications = (
|
||||
userInfo: DashboardUserInfo,
|
||||
tunnels: DashboardUserTunnel[],
|
||||
) => {
|
||||
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
|
||||
const lastNotified = localStorage.getItem("lastNotified");
|
||||
const notificationKey = buildExpirationNotificationKey(userInfo, tunnels);
|
||||
const lastNotified = localStorage.getItem(
|
||||
EXPIRATION_NOTIFICATION_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (lastNotified === notificationKey) {
|
||||
return;
|
||||
@@ -148,7 +177,7 @@ const checkExpirationNotifications = (
|
||||
});
|
||||
|
||||
if (hasNotification) {
|
||||
localStorage.setItem("lastNotified", notificationKey);
|
||||
localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,6 +203,28 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
||||
const now = Date.now();
|
||||
const warningWindowMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
return (items || [])
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
remark: typeof item.remark === "string" ? item.remark : "",
|
||||
tags: typeof item.tags === "string" ? item.tags : "",
|
||||
expiryTime:
|
||||
typeof item.expiryTime === "number" && item.expiryTime > 0
|
||||
? item.expiryTime
|
||||
: undefined,
|
||||
}))
|
||||
.filter((item) => {
|
||||
if (!item.expiryTime) return false;
|
||||
return item.expiryTime <= now + warningWindowMs;
|
||||
})
|
||||
.sort((a, b) => (a.expiryTime || 0) - (b.expiryTime || 0));
|
||||
};
|
||||
|
||||
export const useDashboardData = (): DashboardDataState => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
||||
@@ -184,64 +235,171 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||
DashboardStatisticsFlow[]
|
||||
>([]);
|
||||
const [nodeExpiryReminders, setNodeExpiryReminders] = useState<
|
||||
DashboardNodeExpiryItem[]
|
||||
>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const packageRequestInFlightRef = useRef(false);
|
||||
const nodeExpiryRequestInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
const applyPackageData = useCallback((data: {
|
||||
userInfo?: DashboardUserInfo;
|
||||
tunnelPermissions?: DashboardUserTunnel[];
|
||||
forwards?: ForwardApiItem[];
|
||||
statisticsFlows?: DashboardStatisticsFlow[];
|
||||
}) => {
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo || ({} as DashboardUserInfo),
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadPackageData = useCallback(
|
||||
async ({ silent = false, notifyOnError = false } = {}) => {
|
||||
if (packageRequestInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
packageRequestInFlightRef.current = true;
|
||||
|
||||
if (!silent && isMountedRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const loadPackageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUserPackageInfo();
|
||||
|
||||
if (res.code === 0) {
|
||||
const data = res.data;
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
} else {
|
||||
applyPackageData(res.data || {});
|
||||
} else if (notifyOnError) {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取套餐信息失败");
|
||||
if (notifyOnError) {
|
||||
toast.error("获取套餐信息失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
packageRequestInFlightRef.current = false;
|
||||
|
||||
if (!silent && isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyPackageData],
|
||||
);
|
||||
|
||||
const loadAnnouncement = useCallback(async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
} else {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
} catch {
|
||||
if (isMountedRef.current) {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNodeExpiryData = useCallback(async () => {
|
||||
if (nodeExpiryRequestInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
nodeExpiryRequestInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const res = await getDashboardNodeExpiryList();
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.code === 0 && Array.isArray(res.data)) {
|
||||
setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data));
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
nodeExpiryRequestInFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
const adminFlag = getAdminFlag();
|
||||
|
||||
setIsAdmin(adminFlag);
|
||||
|
||||
void loadPackageData({ notifyOnError: true });
|
||||
void loadAnnouncement();
|
||||
if (adminFlag) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadAnnouncement, loadNodeExpiryData, loadPackageData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void loadPackageData({ silent: true });
|
||||
if (isAdmin) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
setUserInfo({} as DashboardUserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
setIsAdmin(getAdminFlag());
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
loadPackageData();
|
||||
loadAnnouncement();
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
}, []);
|
||||
void loadPackageData({ silent: true });
|
||||
if (isAdmin) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
}, DASHBOARD_POLL_INTERVAL_MS);
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [isAdmin, loadNodeExpiryData, loadPackageData]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -249,6 +407,7 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
nodeExpiryReminders,
|
||||
isAdmin,
|
||||
announcement,
|
||||
};
|
||||
|
||||
+579
-378
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome {
|
||||
toastVariant: "success" | "error";
|
||||
toastMessage: string;
|
||||
shouldRefresh: boolean;
|
||||
progressPercent?: number;
|
||||
progressLabel?: string;
|
||||
closeDeleteModal?: boolean;
|
||||
closeChangeTunnelModal?: boolean;
|
||||
resetTargetTunnel?: boolean;
|
||||
@@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async (
|
||||
return {
|
||||
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
||||
closeDeleteModal: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async (
|
||||
: `成功停用 ${summary.successCount} 项`,
|
||||
),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `${enable ? "启用" : "停用"}完成:成功 ${summary.successCount} 项`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async (
|
||||
return {
|
||||
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async (
|
||||
return {
|
||||
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
||||
closeChangeTunnelModal: true,
|
||||
resetTargetTunnel: true,
|
||||
};
|
||||
|
||||
@@ -46,6 +46,33 @@ test("parseNyFormatData returns validation errors for invalid fields", () => {
|
||||
assert.match(result[1].error || "", /目标地址格式错误/);
|
||||
});
|
||||
|
||||
test("parseNyFormatData allows missing listen_port for auto assignment", () => {
|
||||
const input = '{"dest":["1.1.1.1:1000"],"name":"No Port"}';
|
||||
|
||||
const result = parseNyFormatData(input);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].error, undefined);
|
||||
assert.equal(result[0].parsed?.listen_port, null);
|
||||
});
|
||||
|
||||
test("parseNyFormatData supports ny alias fields", () => {
|
||||
const input =
|
||||
'{"dst":["2.2.2.2:2000"],"listenPort":"3000","forward_name":"Alias A"}\n{"target":"3.3.3.3:4000,4.4.4.4:5000","port":6000,"forwardName":"Alias B"}';
|
||||
|
||||
const result = parseNyFormatData(input);
|
||||
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0].error, undefined);
|
||||
assert.equal(result[1].error, undefined);
|
||||
assert.deepEqual(result[0].parsed?.dest, ["2.2.2.2:2000"]);
|
||||
assert.equal(result[0].parsed?.listen_port, 3000);
|
||||
assert.equal(result[0].parsed?.name, "Alias A");
|
||||
assert.deepEqual(result[1].parsed?.dest, ["3.3.3.3:4000", "4.4.4.4:5000"]);
|
||||
assert.equal(result[1].parsed?.listen_port, 6000);
|
||||
assert.equal(result[1].parsed?.name, "Alias B");
|
||||
});
|
||||
|
||||
test("convertNyItemToForwardInput maps ny fields correctly", () => {
|
||||
const mapped = convertNyItemToForwardInput({
|
||||
dest: ["1.1.1.1:1111", "2.2.2.2:2222"],
|
||||
@@ -60,3 +87,18 @@ test("convertNyItemToForwardInput maps ny fields correctly", () => {
|
||||
strategy: "fifo",
|
||||
});
|
||||
});
|
||||
|
||||
test("convertNyItemToForwardInput keeps null inPort for auto assignment", () => {
|
||||
const mapped = convertNyItemToForwardInput({
|
||||
dest: ["1.1.1.1:1111"],
|
||||
listen_port: null,
|
||||
name: "No Port",
|
||||
});
|
||||
|
||||
assert.deepEqual(mapped, {
|
||||
name: "No Port",
|
||||
inPort: null,
|
||||
remoteAddr: "1.1.1.1:1111",
|
||||
strategy: "fifo",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface NyImportItem {
|
||||
dest: string[];
|
||||
listen_port: number;
|
||||
listen_port: number | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,70 @@ export interface ParsedNyImportLine {
|
||||
|
||||
const ADDRESS_PATTERN = /^[^:]+:\d+$/;
|
||||
|
||||
const getAliasField = (
|
||||
item: Record<string, unknown>,
|
||||
aliases: string[],
|
||||
): unknown => {
|
||||
for (const alias of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, alias)) {
|
||||
return item[alias];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeDestList = (value: unknown): string[] | null => {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map((itemValue) =>
|
||||
typeof itemValue === "string" ? itemValue.trim() : "",
|
||||
);
|
||||
|
||||
if (normalized.some((itemValue) => itemValue === "")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const normalized = value
|
||||
.split(",")
|
||||
.map((itemValue) => itemValue.trim())
|
||||
.filter((itemValue) => itemValue !== "");
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeListenPort = (value: unknown): number | null | undefined => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? value : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Number.parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isValidListenPort = (value: unknown): value is number => {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
@@ -27,11 +91,19 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const dest = item.dest;
|
||||
const listenPort = item.listen_port;
|
||||
const name = item.name;
|
||||
const dest = getAliasField(item, ["dest", "dst", "target", "targets"]);
|
||||
const listenPortRaw = getAliasField(item, [
|
||||
"listen_port",
|
||||
"listenPort",
|
||||
"port",
|
||||
"in_port",
|
||||
"inPort",
|
||||
]);
|
||||
const name = getAliasField(item, ["name", "forward_name", "forwardName"]);
|
||||
const normalizedDest = normalizeDestList(dest);
|
||||
const normalizedListenPort = normalizeListenPort(listenPortRaw);
|
||||
|
||||
if (!Array.isArray(dest) || dest.length === 0) {
|
||||
if (!normalizedDest || normalizedDest.length === 0) {
|
||||
return { line, error: "dest数组为空或格式错误" };
|
||||
}
|
||||
|
||||
@@ -39,16 +111,15 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
||||
return { line, error: "name不能为空" };
|
||||
}
|
||||
|
||||
if (!isValidListenPort(listenPort)) {
|
||||
return { line, error: "listen_port必须为1-65535之间的数字" };
|
||||
if (normalizedListenPort === undefined) {
|
||||
return { line, error: "listen_port格式错误,应为1-65535之间的数字" };
|
||||
}
|
||||
|
||||
const normalizedDest = dest.map((itemValue) =>
|
||||
typeof itemValue === "string" ? itemValue.trim() : "",
|
||||
);
|
||||
|
||||
if (normalizedDest.some((itemValue) => itemValue === "")) {
|
||||
return { line, error: "dest中包含空地址" };
|
||||
if (
|
||||
normalizedListenPort !== null &&
|
||||
!isValidListenPort(normalizedListenPort)
|
||||
) {
|
||||
return { line, error: "listen_port必须为1-65535之间的数字" };
|
||||
}
|
||||
|
||||
const invalid = normalizedDest.find(
|
||||
@@ -63,7 +134,7 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
||||
line,
|
||||
parsed: {
|
||||
dest: normalizedDest,
|
||||
listen_port: listenPort,
|
||||
listen_port: normalizedListenPort,
|
||||
name: name.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,10 +65,15 @@ import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
inx?: number;
|
||||
name: string;
|
||||
remark?: string;
|
||||
tags?: string;
|
||||
expiryTime?: number;
|
||||
ip: string;
|
||||
serverIp: string;
|
||||
serverIpV4?: string;
|
||||
@@ -103,6 +108,9 @@ interface Node {
|
||||
interface NodeForm {
|
||||
id: number | null;
|
||||
name: string;
|
||||
remark: string;
|
||||
tags: string;
|
||||
expiryTime: number;
|
||||
serverHost: string;
|
||||
serverIpV4: string;
|
||||
serverIpV6: string;
|
||||
@@ -116,6 +124,95 @@ interface NodeForm {
|
||||
socks: number; // 0 关 1 开
|
||||
}
|
||||
|
||||
const formatNodeExpiry = (timestamp?: number): string => {
|
||||
if (!timestamp || timestamp <= 0) return "永久有效";
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const EXPIRING_SOON_DAYS = 7;
|
||||
|
||||
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
|
||||
|
||||
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
|
||||
|
||||
const getNodeExpiryMeta = (timestamp?: number) => {
|
||||
if (!timestamp || timestamp <= 0) {
|
||||
return {
|
||||
state: "permanent" as NodeExpiryState,
|
||||
label: "永久有效",
|
||||
tone: "default" as const,
|
||||
accentClassName: "",
|
||||
bannerClassName: "",
|
||||
isHighlighted: false,
|
||||
sortWeight: 3,
|
||||
};
|
||||
}
|
||||
|
||||
const diffMs = timestamp - Date.now();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 0) {
|
||||
return {
|
||||
state: "expired" as NodeExpiryState,
|
||||
label: "已过期",
|
||||
tone: "danger" as const,
|
||||
accentClassName:
|
||||
"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20",
|
||||
bannerClassName:
|
||||
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
|
||||
isHighlighted: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffDays <= EXPIRING_SOON_DAYS) {
|
||||
return {
|
||||
state: "expiringSoon" as NodeExpiryState,
|
||||
label: diffDays === 1 ? "明天到期" : `${diffDays}天后到期`,
|
||||
tone: "warning" as const,
|
||||
accentClassName:
|
||||
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
|
||||
bannerClassName:
|
||||
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
|
||||
isHighlighted: true,
|
||||
sortWeight: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "healthy" as NodeExpiryState,
|
||||
label: `${diffDays}天后到期`,
|
||||
tone: "success" as const,
|
||||
accentClassName: "",
|
||||
bannerClassName: "",
|
||||
isHighlighted: false,
|
||||
sortWeight: 2,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNodeTags = (tags?: string): string[] => {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const mergeNodeRealtimeState = (
|
||||
incomingNode: Node,
|
||||
existingNode?: Node,
|
||||
): Node => {
|
||||
return {
|
||||
...incomingNode,
|
||||
systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,
|
||||
copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,
|
||||
upgradeLoading:
|
||||
existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,
|
||||
rollbackLoading:
|
||||
existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
const SortableItem = ({
|
||||
id,
|
||||
children,
|
||||
@@ -165,6 +262,10 @@ export default function NodePage() {
|
||||
"node-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState<NodeFilterMode>(
|
||||
"node-expiry-filter-mode",
|
||||
"all",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
@@ -178,6 +279,9 @@ export default function NodePage() {
|
||||
const [form, setForm] = useState<NodeForm>({
|
||||
id: null,
|
||||
name: "",
|
||||
remark: "",
|
||||
tags: "",
|
||||
expiryTime: 0,
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
@@ -251,8 +355,13 @@ export default function NodePage() {
|
||||
});
|
||||
|
||||
// 加载节点列表
|
||||
const loadNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
|
||||
@@ -270,7 +379,13 @@ export default function NodePage() {
|
||||
copyLoading: false,
|
||||
}));
|
||||
|
||||
setNodeList(nodesData);
|
||||
setNodeList((prev) => {
|
||||
const previousById = new Map(prev.map((node) => [node.id, node]));
|
||||
|
||||
return nodesData.map((node) =>
|
||||
mergeNodeRealtimeState(node, previousById.get(node.id)),
|
||||
);
|
||||
});
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||
const hasDbOrdering = nodesData.some(
|
||||
@@ -292,12 +407,18 @@ export default function NodePage() {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
if (!silent) {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
if (!silent) {
|
||||
toast.error("网络错误,请重试");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -370,7 +491,7 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const { wsConnected, wsConnecting } = useNodeRealtime({
|
||||
const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({
|
||||
onMessage: handleWebSocketMessage,
|
||||
});
|
||||
|
||||
@@ -378,6 +499,22 @@ export default function NodePage() {
|
||||
loadNodes();
|
||||
}, [loadNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usingPollingFallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadNodes({ silent: true });
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void loadNodes({ silent: true });
|
||||
}, NODE_FALLBACK_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [loadNodes, usingPollingFallback]);
|
||||
|
||||
// 格式化速度
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
if (bytesPerSecond === 0) return "0 B/s";
|
||||
@@ -586,6 +723,9 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
remark: node.remark || "",
|
||||
tags: node.tags || "",
|
||||
expiryTime: node.expiryTime || 0,
|
||||
serverHost: normalizedHost,
|
||||
serverIpV4: normalizedV4,
|
||||
serverIpV6: normalizedV6,
|
||||
@@ -821,6 +961,9 @@ export default function NodePage() {
|
||||
const { serverHost, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
remark: form.remark.trim(),
|
||||
tags: form.tags.trim(),
|
||||
expiryTime: form.expiryTime,
|
||||
extraIPs: form.extraIPs,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
@@ -842,6 +985,9 @@ export default function NodePage() {
|
||||
? {
|
||||
...n,
|
||||
name: form.name,
|
||||
remark: form.remark.trim(),
|
||||
tags: form.tags.trim(),
|
||||
expiryTime: form.expiryTime,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
form.serverIpV6?.trim() ||
|
||||
@@ -878,6 +1024,9 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: null,
|
||||
name: "",
|
||||
remark: "",
|
||||
tags: "",
|
||||
expiryTime: 0,
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
@@ -1010,6 +1159,20 @@ export default function NodePage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const nodeExpiryStats = useMemo(() => {
|
||||
return nodeList.reduce(
|
||||
(acc, node) => {
|
||||
const meta = getNodeExpiryMeta(node.expiryTime);
|
||||
|
||||
if (meta.state === "expired") acc.expired += 1;
|
||||
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
|
||||
if (node.expiryTime && node.expiryTime > 0) acc.withExpiry += 1;
|
||||
return acc;
|
||||
},
|
||||
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
||||
);
|
||||
}, [nodeList]);
|
||||
|
||||
// 根据排序顺序获取节点列表
|
||||
const sortedNodes = useMemo((): Node[] => {
|
||||
if (!nodeList || nodeList.length === 0) return [];
|
||||
@@ -1022,13 +1185,40 @@ export default function NodePage() {
|
||||
filteredNodes = filteredNodes.filter(
|
||||
(n) =>
|
||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeFilterMode !== "all") {
|
||||
filteredNodes = filteredNodes.filter((node) => {
|
||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
|
||||
|
||||
switch (nodeFilterMode) {
|
||||
case "expiringSoon":
|
||||
return expiryMeta.state === "expiringSoon";
|
||||
case "expired":
|
||||
return expiryMeta.state === "expired";
|
||||
case "withExpiry":
|
||||
return !!node.expiryTime && node.expiryTime > 0;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
||||
const expiryDiff =
|
||||
getNodeExpiryMeta(a.expiryTime).sortWeight -
|
||||
getNodeExpiryMeta(b.expiryTime).sortWeight;
|
||||
|
||||
if (expiryDiff !== 0) {
|
||||
return expiryDiff;
|
||||
}
|
||||
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
@@ -1060,7 +1250,7 @@ export default function NodePage() {
|
||||
}
|
||||
|
||||
return sortedByDb;
|
||||
}, [nodeList, nodeOrder, searchKeyword]);
|
||||
}, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);
|
||||
|
||||
const sortableNodeIds = useMemo(
|
||||
() => sortedNodes.map((n) => n.id),
|
||||
@@ -1070,15 +1260,41 @@ export default function NodePage() {
|
||||
return (
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索节点名称或IP"
|
||||
placeholder="搜索名称、IP、备注或标签"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
className="w-full max-w-sm lg:w-56"
|
||||
label="到期筛选"
|
||||
selectedKeys={[nodeFilterMode]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as NodeFilterMode | undefined;
|
||||
|
||||
setNodeFilterMode(selected || "all");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="all" textValue="全部节点">
|
||||
全部节点
|
||||
</SelectItem>
|
||||
<SelectItem key="expiringSoon" textValue="7天内到期">
|
||||
7天内到期 ({nodeExpiryStats.expiringSoon})
|
||||
</SelectItem>
|
||||
<SelectItem key="expired" textValue="已过期">
|
||||
已过期 ({nodeExpiryStats.expired})
|
||||
</SelectItem>
|
||||
<SelectItem key="withExpiry" textValue="已设置到期时间">
|
||||
已设置到期时间 ({nodeExpiryStats.withExpiry})
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||
@@ -1162,7 +1378,11 @@ export default function NodePage() {
|
||||
className="mb-4"
|
||||
color="warning"
|
||||
description={
|
||||
wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..."
|
||||
wsConnecting
|
||||
? "监控连接中..."
|
||||
: usingPollingFallback
|
||||
? "监控连接已断开,已切换为列表自动刷新兜底模式。"
|
||||
: "监控连接已断开,正在重连..."
|
||||
}
|
||||
variant="flat"
|
||||
/>
|
||||
@@ -1185,13 +1405,14 @@ export default function NodePage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedNodes.map((node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
|
||||
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden ${expiryMeta.accentClassName}`}
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
@@ -1259,8 +1480,60 @@ export default function NodePage() {
|
||||
{getRemoteSyncErrorMessage(node.syncError)}
|
||||
</div>
|
||||
)}
|
||||
{expiryMeta.isHighlighted && (
|
||||
<div
|
||||
className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}
|
||||
>
|
||||
<span>节点到期提醒</span>
|
||||
<span>{expiryMeta.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{(node.remark?.trim() || node.tags?.trim()) && (
|
||||
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2">
|
||||
{node.remark?.trim() && (
|
||||
<div className="text-xs leading-5 text-default-700 whitespace-pre-wrap break-words">
|
||||
{node.remark.trim()}
|
||||
</div>
|
||||
)}
|
||||
{normalizeNodeTags(node.tags).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{normalizeNodeTags(node.tags).map((tag) => (
|
||||
<Chip
|
||||
key={`${node.id}-${tag}`}
|
||||
className="text-[11px]"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
#{tag}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.expiryTime && node.expiryTime > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">到期时间</span>
|
||||
<div className="text-right ml-2">
|
||||
<div className="text-xs text-warning-700 dark:text-warning-400">
|
||||
{formatNodeExpiry(node.expiryTime)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Chip
|
||||
className="text-[11px]"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
@@ -1562,8 +1835,54 @@ export default function NodePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
description="可记录供应商、用途、续费说明等补充信息"
|
||||
label="备注"
|
||||
maxRows={4}
|
||||
minRows={3}
|
||||
placeholder="例如: 搬瓦工年付,2026-12 续费,日本中转"
|
||||
value={form.remark}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, remark: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
description="多个标签用逗号分隔,可用于搜索过滤"
|
||||
label="标签"
|
||||
placeholder="例如: 年付,日本中转,测试机"
|
||||
value={form.tags}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, tags: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="留空表示永久有效,可用于记录节点到期时间"
|
||||
label="到期时间"
|
||||
type="datetime-local"
|
||||
value={
|
||||
form.expiryTime > 0
|
||||
? new Date(form.expiryTime).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expiryTime: e.target.value
|
||||
? new Date(e.target.value).getTime()
|
||||
: 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||
errorMessage={errors.serverHost}
|
||||
isInvalid={!!errors.serverHost}
|
||||
label="服务器域名/主机名"
|
||||
|
||||
@@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const MAX_STANDARD_RECONNECT_ATTEMPTS = 5;
|
||||
const STANDARD_RECONNECT_DELAY_MS = 3000;
|
||||
const MAX_STANDARD_RECONNECT_DELAY_MS = 15000;
|
||||
const FALLBACK_RECONNECT_DELAY_MS = 30000;
|
||||
|
||||
const getRealtimeWsUrl = (): string => {
|
||||
const baseUrl =
|
||||
axios.defaults.baseURL ||
|
||||
@@ -34,14 +39,13 @@ export const useNodeRealtime = ({
|
||||
}: UseNodeRealtimeOptions) => {
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [wsConnecting, setWsConnecting] = useState(false);
|
||||
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
|
||||
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
useEffect(() => {
|
||||
onMessageRef.current = onMessage;
|
||||
}, [onMessage]);
|
||||
@@ -58,6 +62,7 @@ export const useNodeRealtime = ({
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
setUsingPollingFallback(false);
|
||||
|
||||
if (!websocketRef.current) {
|
||||
return;
|
||||
@@ -103,6 +108,7 @@ export const useNodeRealtime = ({
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(true);
|
||||
setWsConnecting(false);
|
||||
setUsingPollingFallback(false);
|
||||
};
|
||||
|
||||
websocketRef.current.onmessage = (event) => {
|
||||
@@ -122,15 +128,27 @@ export const useNodeRealtime = ({
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
|
||||
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current += 1;
|
||||
const exhaustedStandardRetries =
|
||||
reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS;
|
||||
|
||||
setUsingPollingFallback(exhaustedStandardRetries);
|
||||
|
||||
const reconnectDelay = exhaustedStandardRetries
|
||||
? FALLBACK_RECONNECT_DELAY_MS
|
||||
: Math.min(
|
||||
STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current,
|
||||
MAX_STANDARD_RECONNECT_DELAY_MS,
|
||||
);
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
connect();
|
||||
}, 3000 * reconnectAttemptsRef.current);
|
||||
}, reconnectDelay);
|
||||
};
|
||||
} catch {
|
||||
setWsConnected(false);
|
||||
@@ -153,6 +171,7 @@ export const useNodeRealtime = ({
|
||||
return {
|
||||
wsConnected,
|
||||
wsConnecting,
|
||||
usingPollingFallback,
|
||||
reconnectRealtime: connect,
|
||||
disconnectRealtime: disconnect,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import { getConfigByName, updateConfig } from "@/api";
|
||||
import { BackIcon } from "@/components/icons";
|
||||
import {
|
||||
type UpdateReleaseChannel,
|
||||
getUpdateReleaseChannel,
|
||||
@@ -129,7 +130,7 @@ export const SettingsPage = () => {
|
||||
);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(`转发页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||
toast.success(`规则页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("forwardCompactModeChanged", {
|
||||
detail: { enabled },
|
||||
@@ -168,20 +169,7 @@ export const SettingsPage = () => {
|
||||
variant="light"
|
||||
onPress={() => navigate(-1)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15 19l-7-7 7-7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
<BackIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
面板设置
|
||||
@@ -232,10 +220,10 @@ export const SettingsPage = () => {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
转发页面精简模式
|
||||
规则页面精简模式
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后,转发页面列表使用 2.1.6-alpha8 样式。
|
||||
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
+515
-368
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user