mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 00:34:23 +08:00
Compare commits
1 Commits
2.1.7
..
2.1.7-beta7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b224e4e9c |
@@ -1,32 +0,0 @@
|
||||
# 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
@@ -1 +0,0 @@
|
||||
普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
|
||||
@@ -1,19 +0,0 @@
|
||||
## 分析结果
|
||||
|
||||
### 当前状态
|
||||
- **角色系统**: `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
@@ -1 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta6
|
||||
@@ -1,5 +0,0 @@
|
||||
完成:
|
||||
- 已提交并推送变更
|
||||
- PR #259 已合并到 main
|
||||
- 已创建并推送 tag `2.1.7-beta6`
|
||||
- 已删除远程分支
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
转发选择限速后保存,然后把限速删除,再次编辑转发报错会报错限速不存在,请保存的时候限速不存在直接删除限速,不阻断提示限速不存在
|
||||
@@ -1,20 +0,0 @@
|
||||
我已经定位到问题点了,确实在后端 `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
@@ -1 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta5
|
||||
@@ -1,7 +0,0 @@
|
||||
✅ 已完成:
|
||||
- 提交变更: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
@@ -1 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta4
|
||||
@@ -1,6 +0,0 @@
|
||||
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
@@ -1,17 +0,0 @@
|
||||
转发编辑保存报错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后报错了
|
||||
@@ -1,23 +0,0 @@
|
||||
你这个新报错我确认了:这次**不是端口占用问题**,是 **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
@@ -1 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta3
|
||||
@@ -1,8 +0,0 @@
|
||||
完成!已成功:
|
||||
|
||||
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
@@ -1 +0,0 @@
|
||||
编辑转发后保存报错 下发失败: create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:16765: bind: cannot assign requested address,这个bug是因为增加了自定义IP功能后导致的,监听IP 不选择就会报错,应该根据原来的监听状态保存
|
||||
@@ -1,23 +0,0 @@
|
||||
你这个判断是对的,我已经把链路看清了,确实是“新增自定义监听 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
@@ -1,13 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,beta版本
|
||||
|
||||
---
|
||||
|
||||
不对,tag是2.1.7-beta2
|
||||
|
||||
---
|
||||
|
||||
2.1.8-beta1 标签没有被删除成功
|
||||
|
||||
---
|
||||
|
||||
2.1.8-alpha1 标签没有被删除成功
|
||||
@@ -1 +0,0 @@
|
||||
已删除 `2.1.8-alpha1` 标签。当前最新版本为 `2.1.7-beta2`。
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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
@@ -1,9 +0,0 @@
|
||||
转发导入,ny兼容格式,不要选择入口,改成选择隧道,修改兼容一下
|
||||
|
||||
---
|
||||
|
||||
自动分配端口(推荐)
|
||||
|
||||
---
|
||||
|
||||
实施
|
||||
@@ -1,20 +0,0 @@
|
||||
已实施完成,这次改动把 `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
@@ -1,5 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
|
||||
---
|
||||
|
||||
不对,撤销tag,发布2.1.7-beta1
|
||||
@@ -1,3 +0,0 @@
|
||||
已完成:
|
||||
- 删除标签 `2.1.8-alpha1`(本地+远程)
|
||||
- 创建并推送标签 `2.1.7-beta1` 到 `origin/main`
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
@@ -1,13 +0,0 @@
|
||||
完成!
|
||||
|
||||
**总结:**
|
||||
- ✅ 提交变更:`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 解析模块及测试文件
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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
@@ -1,5 +0,0 @@
|
||||
增加功能:转发规则批量导入格式,兼容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转发导入是选择隧道的,需要兼容一下,请设计计划
|
||||
@@ -1,30 +0,0 @@
|
||||
计划文档已设计完成。以下是计划摘要:
|
||||
|
||||
## 计划摘要
|
||||
|
||||
### 格式对比
|
||||
| 特性 | 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
@@ -1 +0,0 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
@@ -1,6 +0,0 @@
|
||||
✅ 完成:
|
||||
- **提交**: `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 支持带端口的地址、添加了全面的测试覆盖。
|
||||
@@ -245,12 +245,6 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
}
|
||||
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
|
||||
var limiterID *int64
|
||||
var speed *int
|
||||
@@ -266,11 +260,17 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
|
||||
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 nil, err
|
||||
}
|
||||
limiterID = utLimiterID
|
||||
speed = utSpeed
|
||||
}
|
||||
|
||||
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
|
||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
|
||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -290,11 +290,6 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
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) {
|
||||
@@ -359,12 +354,7 @@ func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord,
|
||||
return fmt.Errorf("端口 %d 已被其他转发占用", port)
|
||||
}
|
||||
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServiceBasesOnNode(node.ID, bases); err != nil {
|
||||
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -382,45 +372,41 @@ func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward delete context")
|
||||
}
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
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
|
||||
return err
|
||||
}
|
||||
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 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
|
||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
|
||||
return deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
var lastErr error
|
||||
for _, base := range bases {
|
||||
names := buildForwardControlServiceNames(base, "DeleteService")
|
||||
payload := map[string]interface{}{
|
||||
"services": []string{name},
|
||||
"services": names,
|
||||
}
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
|
||||
return err
|
||||
})
|
||||
_, cmdErr := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
|
||||
if cmdErr == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = cmdErr
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
|
||||
@@ -451,26 +437,40 @@ 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{}{}
|
||||
|
||||
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lastNotFoundErr error
|
||||
nodeHandled := false
|
||||
|
||||
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
|
||||
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
|
||||
return healErr
|
||||
for _, base := range bases {
|
||||
variants := []string{base + "_tcp", base + "_udp"}
|
||||
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||
variants = append(variants, base)
|
||||
}
|
||||
healed = true
|
||||
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,65 +488,6 @@ 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,
|
||||
@@ -1421,13 +1362,6 @@ 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)
|
||||
@@ -1479,11 +1413,10 @@ func isAlreadyExistsMessage(message string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if isAddressAlreadyInUseMessage(msg) {
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
return false
|
||||
}
|
||||
compact := compactErrorMessage(msg)
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") || strings.Contains(compact, "alreadyexists")
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
|
||||
}
|
||||
|
||||
func isBindAddressInUseError(err error) bool {
|
||||
@@ -1508,10 +1441,7 @@ 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")
|
||||
return strings.Contains(msg, "address already in use")
|
||||
}
|
||||
|
||||
func isCannotAssignRequestedAddressError(err error) bool {
|
||||
@@ -1522,18 +1452,7 @@ func isCannotAssignRequestedAddressError(err error) bool {
|
||||
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)), "")
|
||||
return strings.Contains(msg, "cannot assign requested address")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
|
||||
@@ -45,20 +43,6 @@ 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")
|
||||
@@ -71,184 +55,6 @@ 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")
|
||||
@@ -256,15 +62,9 @@ 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) {
|
||||
@@ -286,9 +86,6 @@ 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")
|
||||
}
|
||||
@@ -298,83 +95,11 @@ 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) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
|
||||
@@ -43,19 +43,6 @@ 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}
|
||||
|
||||
@@ -25,8 +25,6 @@ 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("请求失败"))
|
||||
@@ -1156,10 +1154,14 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if roleID != 0 {
|
||||
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
@@ -1189,14 +1191,6 @@ 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")
|
||||
@@ -1280,10 +1274,14 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
strategy = forward.Strategy
|
||||
}
|
||||
if actorRole != 0 {
|
||||
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
@@ -1328,14 +1326,6 @@ 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 {
|
||||
@@ -2586,7 +2576,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.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, chainNode.NodeID)
|
||||
@@ -2598,7 +2588,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
|
||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, outNode.NodeID)
|
||||
@@ -2607,44 +2597,6 @@ 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
|
||||
@@ -3561,82 +3513,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -767,21 +767,9 @@ 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 _, 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)
|
||||
for _, r := range rows {
|
||||
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -800,146 +788,13 @@ 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 _, 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)
|
||||
for _, r := range rows {
|
||||
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -916,164 +914,6 @@ 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)
|
||||
}
|
||||
@@ -1158,9 +998,9 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot set inPort out of range on create", func(t *testing.T) {
|
||||
t.Run("non-admin cannot set inPort on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-port-out",
|
||||
"name": "perm-forward-port",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
@@ -1175,33 +1015,7 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
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)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口")
|
||||
})
|
||||
|
||||
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
|
||||
@@ -1245,7 +1059,7 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot update inPort out of range", func(t *testing.T) {
|
||||
t.Run("non-admin cannot update inPort", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated2",
|
||||
@@ -1262,33 +1076,7 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
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)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口")
|
||||
})
|
||||
|
||||
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
|
||||
@@ -1309,45 +1097,4 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
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,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -539,134 +538,6 @@ 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()
|
||||
|
||||
@@ -762,112 +633,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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. 全量稳定后保留兼容逻辑至少一个小版本周期,再评估收敛。
|
||||
@@ -1,158 +0,0 @@
|
||||
# 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. **更新测试用例** - 将原本期望权限拒绝的测试改为端口范围验证测试,并修正了测试中使用的端口号
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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.
|
||||
@@ -188,7 +188,7 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<ProtectedRoute>
|
||||
<PanelSharingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -259,29 +259,3 @@ 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,7 +2,6 @@ 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";
|
||||
@@ -26,7 +25,13 @@ 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}>
|
||||
<BackIcon className="w-5 h-5" />
|
||||
<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>
|
||||
</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 { BackIcon, SettingsIcon } from "@/components/icons";
|
||||
import { 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,21 +234,6 @@ 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()) {
|
||||
@@ -861,16 +846,6 @@ 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>
|
||||
|
||||
@@ -616,7 +616,7 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-purple-100 dark:bg-purple-500/20"
|
||||
title="规则配额"
|
||||
title="转发配额"
|
||||
value={formatNumber(userInfo.num || 0)}
|
||||
/>
|
||||
|
||||
@@ -650,7 +650,7 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-orange-100 dark:bg-orange-500/20"
|
||||
title="已用规则"
|
||||
title="已用转发"
|
||||
value={forwardList.length}
|
||||
/>
|
||||
</div>
|
||||
@@ -753,7 +753,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 +761,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 +784,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 +801,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 +825,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 +839,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>
|
||||
|
||||
|
||||
+143
-193
@@ -128,8 +128,6 @@ interface Tunnel {
|
||||
inNodeId?: Array<{ nodeId: number }>;
|
||||
inNodePortSta?: number;
|
||||
inNodePortEnd?: number;
|
||||
portRangeMin?: number;
|
||||
portRangeMax?: number;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
@@ -677,34 +675,11 @@ export default function ForwardPage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTunnel = allTunnels.find(
|
||||
(tunnel) => tunnel.id === form.tunnelId,
|
||||
);
|
||||
const currentTunnel = allTunnels.find((tunnel) => tunnel.id === form.tunnelId);
|
||||
|
||||
return (currentTunnel?.inNodeId?.length || 0) > 1;
|
||||
}, [allTunnels, form.tunnelId]);
|
||||
|
||||
const currentTunnelPortRange = useMemo(() => {
|
||||
if (!form.tunnelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentTunnel = allTunnels.find(
|
||||
(tunnel) => tunnel.id === form.tunnelId,
|
||||
);
|
||||
|
||||
if (
|
||||
currentTunnel?.portRangeMin &&
|
||||
currentTunnel?.portRangeMax &&
|
||||
currentTunnel.portRangeMin > 0 &&
|
||||
currentTunnel.portRangeMax > 0
|
||||
) {
|
||||
return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [allTunnels, form.tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
diagnosisAbortRef.current?.abort();
|
||||
@@ -1199,7 +1174,7 @@ export default function ForwardPage() {
|
||||
saveOrder(FORWARD_ORDER_KEY, order);
|
||||
}
|
||||
} else {
|
||||
toast.error(forwardsRes.msg || "获取规则列表失败");
|
||||
toast.error(forwardsRes.msg || "获取转发列表失败");
|
||||
}
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
@@ -1281,18 +1256,21 @@ export default function ForwardPage() {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = "请输入规则名称";
|
||||
newErrors.name = "请输入转发名称";
|
||||
} else if (form.name.length < 2 || form.name.length > 50) {
|
||||
newErrors.name = "规则名称长度应在2-50个字符之间";
|
||||
newErrors.name = "转发名称长度应在2-50个字符之间";
|
||||
}
|
||||
|
||||
if (!form.tunnelId) {
|
||||
newErrors.tunnelId = "请选择关联隧道";
|
||||
}
|
||||
|
||||
if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {
|
||||
if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {
|
||||
newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;
|
||||
// 验证入口端口(可选,如果填写则验证)
|
||||
if (form.inPort !== null && form.inPort !== undefined) {
|
||||
const port = Number(form.inPort);
|
||||
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
newErrors.inPort = "端口必须在 1-65535 之间";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,7 +1308,7 @@ export default function ForwardPage() {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 新增规则
|
||||
// 新增转发
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false);
|
||||
setInIpTouched(false);
|
||||
@@ -1348,7 +1326,7 @@ export default function ForwardPage() {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 编辑规则
|
||||
// 编辑转发
|
||||
const handleEdit = (forward: Forward) => {
|
||||
setIsEdit(true);
|
||||
setInIpTouched(false);
|
||||
@@ -1374,7 +1352,7 @@ export default function ForwardPage() {
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// 确认删除规则
|
||||
// 确认删除转发
|
||||
const confirmDelete = async () => {
|
||||
if (!forwardToDelete) return;
|
||||
|
||||
@@ -1389,7 +1367,7 @@ export default function ForwardPage() {
|
||||
} else {
|
||||
// 删除失败,询问是否强制删除
|
||||
const confirmed = window.confirm(
|
||||
`常规删除失败:${res.msg || "删除失败"}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的规则服务。`,
|
||||
`常规删除失败:${res.msg || "删除失败"}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`,
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
@@ -1448,8 +1426,10 @@ export default function ForwardPage() {
|
||||
const speedLimitAutoCleared = isMissingSpeedLimit(form.speedId);
|
||||
|
||||
if (isEdit) {
|
||||
// 更新时确保包含必要字段
|
||||
const updateData = {
|
||||
id: form.id,
|
||||
userId: form.userId,
|
||||
name: form.name,
|
||||
tunnelId: form.tunnelId,
|
||||
inPort: form.inPort,
|
||||
@@ -1470,6 +1450,7 @@ export default function ForwardPage() {
|
||||
strategy: addressCount > 1 ? form.strategy : "fifo",
|
||||
speedId: normalizedSpeedId,
|
||||
};
|
||||
|
||||
res = await createForward(createData);
|
||||
}
|
||||
|
||||
@@ -1510,7 +1491,7 @@ export default function ForwardPage() {
|
||||
// 处理服务开关
|
||||
const handleServiceToggle = async (forward: Forward) => {
|
||||
if (forward.status !== 1 && forward.status !== 0) {
|
||||
toast.error("规则状态异常,无法操作");
|
||||
toast.error("转发状态异常,无法操作");
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1535,7 +1516,7 @@ export default function ForwardPage() {
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(targetState ? "服务已启动" : "服务已暂停");
|
||||
// 更新规则状态
|
||||
// 更新转发状态
|
||||
setForwards((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === forward.id ? { ...f, status: targetState ? 1 : 0 } : f,
|
||||
@@ -1561,7 +1542,7 @@ export default function ForwardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 诊断规则
|
||||
// 诊断转发
|
||||
const handleDiagnose = async (forward: Forward) => {
|
||||
diagnosisAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
@@ -1816,7 +1797,7 @@ export default function ForwardPage() {
|
||||
await copyToClipboard(allAddresses, "所有地址");
|
||||
};
|
||||
|
||||
// 导出规则数据
|
||||
// 导出转发数据
|
||||
const handleExport = () => {
|
||||
setSelectedTunnelForExport(null);
|
||||
setExportData("");
|
||||
@@ -1834,13 +1815,13 @@ export default function ForwardPage() {
|
||||
setExportLoading(true);
|
||||
|
||||
try {
|
||||
// 获取要导出的规则列表
|
||||
// 获取要导出的转发列表
|
||||
const forwardsToExport = sortedForwards.filter(
|
||||
(forward) => forward.tunnelId === selectedTunnelForExport,
|
||||
);
|
||||
|
||||
if (forwardsToExport.length === 0) {
|
||||
toast.error("所选隧道没有规则数据");
|
||||
toast.error("所选隧道没有转发数据");
|
||||
setExportLoading(false);
|
||||
|
||||
return;
|
||||
@@ -1863,10 +1844,10 @@ export default function ForwardPage() {
|
||||
|
||||
// 复制导出数据
|
||||
const copyExportData = async () => {
|
||||
await copyToClipboard(exportData, "规则数据");
|
||||
await copyToClipboard(exportData, "转发数据");
|
||||
};
|
||||
|
||||
// 导入规则数据
|
||||
// 导入转发数据
|
||||
const handleImport = () => {
|
||||
setImportData("");
|
||||
setImportResults([]);
|
||||
@@ -1988,7 +1969,7 @@ export default function ForwardPage() {
|
||||
{
|
||||
line,
|
||||
success: false,
|
||||
message: "格式错误:需要至少包含目标地址和规则名称",
|
||||
message: "格式错误:需要至少包含目标地址和转发名称",
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
@@ -2002,7 +1983,7 @@ export default function ForwardPage() {
|
||||
{
|
||||
line,
|
||||
success: false,
|
||||
message: "目标地址和规则名称不能为空",
|
||||
message: "目标地址和转发名称不能为空",
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
@@ -2208,27 +2189,15 @@ export default function ForwardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
let oldIndex: number;
|
||||
let newIndex: number;
|
||||
let currentOrder: number[];
|
||||
|
||||
if (compactMode) {
|
||||
currentOrder = sortedForwards.map((f) => f.id);
|
||||
oldIndex = currentOrder.indexOf(activeId);
|
||||
newIndex = currentOrder.indexOf(overId);
|
||||
} else {
|
||||
currentOrder = forwardOrder;
|
||||
oldIndex = forwardOrder.indexOf(activeId);
|
||||
newIndex = forwardOrder.indexOf(overId);
|
||||
}
|
||||
const oldIndex = forwardOrder.indexOf(activeId);
|
||||
const newIndex = forwardOrder.indexOf(overId);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
|
||||
const newOrder = arrayMove(forwardOrder, oldIndex, newIndex);
|
||||
|
||||
if (!compactMode) {
|
||||
setForwardOrder(newOrder);
|
||||
saveOrder(FORWARD_ORDER_KEY, newOrder);
|
||||
}
|
||||
setForwardOrder(newOrder);
|
||||
|
||||
saveOrder(FORWARD_ORDER_KEY, newOrder);
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
@@ -2416,7 +2385,7 @@ export default function ForwardPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
// 根据排序顺序获取规则列表
|
||||
// 根据排序顺序获取转发列表
|
||||
const orderedForwards = useMemo((): Forward[] => {
|
||||
// 确保 forwards 数组存在且有效
|
||||
if (!forwards || forwards.length === 0) {
|
||||
@@ -2451,7 +2420,7 @@ export default function ForwardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 确保过滤后的规则列表有效
|
||||
// 确保过滤后的转发列表有效
|
||||
if (!filteredForwards || filteredForwards.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -2485,7 +2454,7 @@ export default function ForwardPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// 添加不在排序列表中的规则(新添加的)
|
||||
// 添加不在排序列表中的转发(新添加的)
|
||||
filteredForwards.forEach((forward) => {
|
||||
if (!forwardOrder.includes(forward.id)) {
|
||||
localSortedForwards.push(forward);
|
||||
@@ -2526,10 +2495,6 @@ export default function ForwardPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (forwards.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSameGroupOrderMap(groupOrderMap, sanitizedGroupOrderMap)) {
|
||||
setGroupOrderMap(sanitizedGroupOrderMap);
|
||||
persistGroupOrderToLocal(sanitizedGroupOrderMap);
|
||||
@@ -2549,7 +2514,6 @@ export default function ForwardPage() {
|
||||
}, [
|
||||
groupPreferenceHydrated,
|
||||
tokenUserId,
|
||||
forwards,
|
||||
groupOrderMap,
|
||||
sanitizedGroupOrderMap,
|
||||
collapsedTunnelGroups,
|
||||
@@ -2789,7 +2753,7 @@ export default function ForwardPage() {
|
||||
<span className={titleClassName}>{tunnel.tunnelName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={countClassName}>{tunnel.items.length} 条规则</span>
|
||||
<span className={countClassName}>{tunnel.items.length} 条转发</span>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors"
|
||||
title="拖拽分组排序"
|
||||
@@ -2812,7 +2776,7 @@ export default function ForwardPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// 可拖拽的规则卡片组件
|
||||
// 可拖拽的转发卡片组件
|
||||
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
|
||||
const {
|
||||
attributes,
|
||||
@@ -3313,7 +3277,7 @@ export default function ForwardPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染规则卡片
|
||||
// 渲染转发卡片
|
||||
const renderForwardCard = (forward: Forward, listeners?: any) => {
|
||||
const statusDisplay = getStatusDisplay(forward.status);
|
||||
const strategyDisplay = getStrategyDisplay(forward.strategy);
|
||||
@@ -3606,7 +3570,7 @@ export default function ForwardPage() {
|
||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索规则名称、地址或用户名"
|
||||
placeholder="搜索转发名称、地址或用户名"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
@@ -3827,38 +3791,36 @@ export default function ForwardPage() {
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<Table
|
||||
aria-label="全部转发列表"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
aria-label="全部规则列表"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{selectMode && (
|
||||
<TableColumn className="w-14">选择</TableColumn>
|
||||
)}
|
||||
<TableColumn className="w-10 pl-4" />
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>隧道</TableColumn>
|
||||
<TableColumn>入口</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>策略</TableColumn>
|
||||
<TableColumn>总流量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn className="text-right">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="暂无规则配置"
|
||||
items={sortedForwards}
|
||||
>
|
||||
{(forward) => (
|
||||
<TableHeader>
|
||||
{selectMode && (
|
||||
<TableColumn className="w-14">选择</TableColumn>
|
||||
)}
|
||||
<TableColumn className="w-10 pl-4" />
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>隧道</TableColumn>
|
||||
<TableColumn>入口</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>策略</TableColumn>
|
||||
<TableColumn>总流量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn className="text-right">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
|
||||
{(forward) => (
|
||||
<SortableContext
|
||||
key={forward.id}
|
||||
items={sortableForwardIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<SortableCompactTableRow
|
||||
formatFlow={formatFlow}
|
||||
formatInAddress={formatInAddress}
|
||||
@@ -3875,62 +3837,52 @@ export default function ForwardPage() {
|
||||
showAddressModal={showAddressModal}
|
||||
toggleSelect={toggleSelect}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SortableContext>
|
||||
</SortableContext>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无规则配置
|
||||
暂无转发配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何规则配置,点击上方按钮开始创建
|
||||
还没有创建任何转发配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
) : sortedForwards.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-1 mb-3">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
全部规则
|
||||
</span>
|
||||
<span className="text-xs text-default-600">
|
||||
{sortedForwards.length} 条规则
|
||||
</span>
|
||||
</div>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={() => {}}
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={() => {}}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedForwards.map((forward) =>
|
||||
forward && forward.id ? (
|
||||
<SortableForwardCard key={forward.id} forward={forward} />
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedForwards.map((forward) =>
|
||||
forward && forward.id ? (
|
||||
<SortableForwardCard key={forward.id} forward={forward} />
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无规则配置
|
||||
暂无转发配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何规则配置,点击上方按钮开始创建
|
||||
还没有创建任何转发配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -3963,7 +3915,7 @@ export default function ForwardPage() {
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-default-600">
|
||||
{groupForwardCount} 条规则
|
||||
{groupForwardCount} 条转发
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4018,7 +3970,7 @@ export default function ForwardPage() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table
|
||||
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
|
||||
aria-label={`${group.userName}-${tunnel.tunnelName}转发列表`}
|
||||
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
@@ -4092,7 +4044,7 @@ export default function ForwardPage() {
|
||||
</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="暂无规则配置"
|
||||
emptyContent="暂无转发配置"
|
||||
items={tunnel.items}
|
||||
>
|
||||
{(forward) => (
|
||||
@@ -4144,10 +4096,10 @@ export default function ForwardPage() {
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无规则配置
|
||||
暂无转发配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何规则配置,点击上方按钮开始创建
|
||||
还没有创建任何转发配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -4179,7 +4131,7 @@ export default function ForwardPage() {
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-default-600">
|
||||
{groupForwardCount} 条规则
|
||||
{groupForwardCount} 条转发
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4264,10 +4216,10 @@ export default function ForwardPage() {
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无规则配置
|
||||
暂无转发配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何规则配置,点击上方按钮开始创建
|
||||
还没有创建任何转发配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -4287,10 +4239,10 @@ export default function ForwardPage() {
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
{isEdit ? "编辑规则" : "新增规则"}
|
||||
{isEdit ? "编辑转发" : "新增转发"}
|
||||
</h2>
|
||||
<p className="text-small text-default-500">
|
||||
{isEdit ? "修改现有规则配置的信息" : "创建新的规则配置"}
|
||||
{isEdit ? "修改现有转发配置的信息" : "创建新的转发配置"}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
@@ -4298,8 +4250,8 @@ export default function ForwardPage() {
|
||||
<Input
|
||||
errorMessage={errors.name}
|
||||
isInvalid={!!errors.name}
|
||||
label="规则名称"
|
||||
placeholder="请输入规则名称"
|
||||
label="转发名称"
|
||||
placeholder="请输入转发名称"
|
||||
value={form.name}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
@@ -4366,28 +4318,26 @@ export default function ForwardPage() {
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
description={
|
||||
currentTunnelPortRange
|
||||
? `指定入口端口,留空自动分配 (允许范围: ${currentTunnelPortRange.min}-${currentTunnelPortRange.max})`
|
||||
: "指定入口端口,留空则从节点可用端口中自动分配"
|
||||
}
|
||||
errorMessage={errors.inPort}
|
||||
isInvalid={!!errors.inPort}
|
||||
label="入口端口"
|
||||
placeholder="留空则自动分配可用端口"
|
||||
type="number"
|
||||
value={form.inPort !== null ? form.inPort.toString() : ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
{isAdmin && (
|
||||
<Input
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
errorMessage={errors.inPort}
|
||||
isInvalid={!!errors.inPort}
|
||||
label="入口端口"
|
||||
placeholder="留空则自动分配可用端口"
|
||||
type="number"
|
||||
value={form.inPort !== null ? form.inPort.toString() : ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inPort: value ? parseInt(value) : null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inPort: value ? parseInt(value) : null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
description={
|
||||
@@ -4405,10 +4355,10 @@ export default function ForwardPage() {
|
||||
isCurrentTunnelMultiEntrance
|
||||
? "多入口隧道使用节点默认IP"
|
||||
: form.tunnelId
|
||||
? currentTunnelIpOptions.length > 0
|
||||
? "选择入口监听IP"
|
||||
: "当前隧道入口节点暂无可选IP"
|
||||
: "请先选择隧道"
|
||||
? currentTunnelIpOptions.length > 0
|
||||
? "选择入口监听IP"
|
||||
: "当前隧道入口节点暂无可选IP"
|
||||
: "请先选择隧道"
|
||||
}
|
||||
selectedKeys={[form.inIp || "__default__"]}
|
||||
variant="bordered"
|
||||
@@ -4477,7 +4427,7 @@ export default function ForwardPage() {
|
||||
isLoading={submitLoading}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
{isEdit ? "保存修改" : "创建规则"}
|
||||
{isEdit ? "保存修改" : "创建转发"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
@@ -4502,14 +4452,14 @@ export default function ForwardPage() {
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-default-600">
|
||||
确定要删除规则{" "}
|
||||
确定要删除转发{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
"{forwardToDelete?.name}"
|
||||
</span>{" "}
|
||||
吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-2">
|
||||
此操作无法撤销,删除后该规则将永久消失。
|
||||
此操作无法撤销,删除后该转发将永久消失。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
@@ -4584,9 +4534,9 @@ export default function ForwardPage() {
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">导出规则数据</h2>
|
||||
<h2 className="text-xl font-bold">导出转发数据</h2>
|
||||
<p className="text-small text-default-500">
|
||||
格式:目标地址|规则名称|入口端口
|
||||
格式:目标地址|转发名称|入口端口
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody className="pb-6">
|
||||
@@ -4736,11 +4686,11 @@ export default function ForwardPage() {
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">导入规则数据</h2>
|
||||
<h2 className="text-xl font-bold">导入转发数据</h2>
|
||||
{importFormat === "flvx" ? (
|
||||
<>
|
||||
<p className="text-small text-default-500">
|
||||
格式:目标地址|规则名称|入口端口,每行一个,入口端口留空将自动分配可用端口
|
||||
格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口
|
||||
</p>
|
||||
<p className="text-small text-default-400">
|
||||
目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)
|
||||
@@ -4824,8 +4774,8 @@ export default function ForwardPage() {
|
||||
minRows={8}
|
||||
placeholder={
|
||||
importFormat === "flvx"
|
||||
? "请输入要导入的规则数据,格式:目标地址|规则名称|入口端口"
|
||||
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"规则1"};listen_port可省略自动分配'
|
||||
? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
|
||||
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"};listen_port可省略自动分配'
|
||||
}
|
||||
value={importData}
|
||||
variant="flat"
|
||||
@@ -4965,7 +4915,7 @@ export default function ForwardPage() {
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
|
||||
<h2 className="text-xl font-bold">规则诊断结果</h2>
|
||||
<h2 className="text-xl font-bold">转发诊断结果</h2>
|
||||
{currentDiagnosisForward && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-small text-default-500 truncate flex-1 min-w-0">
|
||||
@@ -4977,7 +4927,7 @@ export default function ForwardPage() {
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
规则服务
|
||||
转发服务
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
@@ -5536,7 +5486,7 @@ export default function ForwardPage() {
|
||||
<ModalHeader>确认删除</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
确定要删除选中的 {selectedIds.size} 项规则吗?此操作不可撤销。
|
||||
确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
@@ -5567,7 +5517,7 @@ export default function ForwardPage() {
|
||||
<ModalHeader>隧道</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="mb-4">
|
||||
将选中的 {selectedIds.size} 项规则迁移到新隧道:
|
||||
将选中的 {selectedIds.size} 项转发迁移到新隧道:
|
||||
</p>
|
||||
<Select
|
||||
label="目标隧道"
|
||||
|
||||
@@ -1563,7 +1563,7 @@ export default function NodePage() {
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
|
||||
errorMessage={errors.serverHost}
|
||||
isInvalid={!!errors.serverHost}
|
||||
label="服务器域名/主机名"
|
||||
|
||||
@@ -9,7 +9,6 @@ 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,
|
||||
@@ -130,7 +129,7 @@ export const SettingsPage = () => {
|
||||
);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(`规则页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||
toast.success(`转发页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("forwardCompactModeChanged", {
|
||||
detail: { enabled },
|
||||
@@ -169,7 +168,20 @@ export const SettingsPage = () => {
|
||||
variant="light"
|
||||
onPress={() => navigate(-1)}
|
||||
>
|
||||
<BackIcon className="w-5 h-5" />
|
||||
<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>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
面板设置
|
||||
@@ -220,10 +232,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
|
||||
|
||||
+233
-255
@@ -1435,7 +1435,7 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
||||
description="支持多个IP,每行一个地址,为空时使用入口节点ip"
|
||||
errorMessage={errors.inIp}
|
||||
isInvalid={!!errors.inIp}
|
||||
label="入口IP"
|
||||
@@ -1843,10 +1843,10 @@ export default function TunnelPage() {
|
||||
isMultiNodeGroup
|
||||
? "多节点跳使用节点默认IP"
|
||||
: groupSelectedNodeIds.length === 0
|
||||
? "请先选择节点"
|
||||
: groupIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
? "请先选择节点"
|
||||
: groupIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
selectedGroupConnectIp || "__default__",
|
||||
@@ -1906,259 +1906,243 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
{/* 节点选择 - 移动端100%,桌面端50% */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
disabledKeys={[
|
||||
...nodes
|
||||
.filter((node) => node.status !== 1)
|
||||
.map((node) => node.id.toString()),
|
||||
...form.inNodeId.map((ct) =>
|
||||
ct.nodeId.toString(),
|
||||
),
|
||||
...getSelectedChainNodeIds().map((id) =>
|
||||
id.toString(),
|
||||
),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
errorMessage={errors.outNodeId}
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
placeholder="请选择出口节点(可多选)"
|
||||
selectedKeys={
|
||||
form.outNodeId
|
||||
? form.outNodeId
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
: []
|
||||
}
|
||||
selectionMode="multiple"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedIds = toSelectedNodeIds(keys);
|
||||
{/* 节点选择 - 移动端100%,桌面端50% */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
disabledKeys={[
|
||||
...nodes
|
||||
.filter((node) => node.status !== 1)
|
||||
.map((node) => node.id.toString()),
|
||||
...form.inNodeId.map((ct) =>
|
||||
ct.nodeId.toString(),
|
||||
),
|
||||
...getSelectedChainNodeIds().map((id) =>
|
||||
id.toString(),
|
||||
),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
errorMessage={errors.outNodeId}
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
placeholder="请选择出口节点(可多选)"
|
||||
selectedKeys={
|
||||
form.outNodeId
|
||||
? form.outNodeId
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
: []
|
||||
}
|
||||
selectionMode="multiple"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedIds = toSelectedNodeIds(keys);
|
||||
|
||||
setForm((prev) => {
|
||||
const currentOutNodes =
|
||||
prev.outNodeId || [];
|
||||
const protocol =
|
||||
currentOutNodes[0]?.protocol || "tls";
|
||||
const strategy =
|
||||
currentOutNodes[0]?.strategy || "round";
|
||||
const realNodes = currentOutNodes.filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
);
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
const protocol =
|
||||
currentOutNodes[0]?.protocol || "tls";
|
||||
const strategy =
|
||||
currentOutNodes[0]?.strategy || "round";
|
||||
const realNodes = currentOutNodes.filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: mergeOrderedNodes(
|
||||
realNodes,
|
||||
selectedIds,
|
||||
(nodeId) => ({
|
||||
nodeId,
|
||||
chainType: 3,
|
||||
protocol,
|
||||
strategy,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
textValue={`${node.name}`}
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: mergeOrderedNodes(
|
||||
realNodes,
|
||||
selectedIds,
|
||||
(nodeId) => ({
|
||||
nodeId,
|
||||
chainType: 3,
|
||||
protocol,
|
||||
strategy,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
textValue={`${node.name}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{node.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
color={
|
||||
node.status === 1
|
||||
? "success"
|
||||
: "default"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{node.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
color={
|
||||
node.status === 1
|
||||
? "success"
|
||||
: "default"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{node.status === 1
|
||||
? "在线"
|
||||
: "离线"}
|
||||
</Chip>
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{getSelectedChainNodeIds().includes(
|
||||
node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{node.status === 1 ? "在线" : "离线"}
|
||||
</Chip>
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{getSelectedChainNodeIds().includes(
|
||||
node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 协议选择 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
errorMessage={errors.protocol}
|
||||
isInvalid={!!errors.protocol}
|
||||
label="协议"
|
||||
placeholder="选择协议"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "tls";
|
||||
{/* 协议选择 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
errorMessage={errors.protocol}
|
||||
isInvalid={!!errors.protocol}
|
||||
label="协议"
|
||||
placeholder="选择协议"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "tls";
|
||||
|
||||
return form.outNodeId[0].protocol || "tls";
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(
|
||||
keys,
|
||||
)[0] as string;
|
||||
return form.outNodeId[0].protocol || "tls";
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes =
|
||||
prev.outNodeId || [];
|
||||
const currentStrategy =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].strategy ||
|
||||
"round"
|
||||
: "round";
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
const currentStrategy =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].strategy || "round"
|
||||
: "round";
|
||||
|
||||
if (currentOutNodes.length === 0) {
|
||||
// 如果还没有出口节点,创建一个占位节点保存设置
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: selectedKey,
|
||||
strategy: currentStrategy,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (currentOutNodes.length === 0) {
|
||||
// 如果还没有出口节点,创建一个占位节点保存设置
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: selectedKey,
|
||||
strategy: currentStrategy,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 更新所有出口节点的协议
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map(
|
||||
(ct) => ({
|
||||
...ct,
|
||||
protocol: selectedKey,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="tls">TLS</SelectItem>
|
||||
<SelectItem key="wss">WSS</SelectItem>
|
||||
<SelectItem key="tcp">TCP</SelectItem>
|
||||
<SelectItem key="mtls">MTLS</SelectItem>
|
||||
<SelectItem key="mwss">MWSS</SelectItem>
|
||||
<SelectItem key="mtcp">MTCP</SelectItem>
|
||||
</Select>
|
||||
// 更新所有出口节点的协议
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
protocol: selectedKey,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="tls">TLS</SelectItem>
|
||||
<SelectItem key="wss">WSS</SelectItem>
|
||||
<SelectItem key="tcp">TCP</SelectItem>
|
||||
<SelectItem key="mtls">MTLS</SelectItem>
|
||||
<SelectItem key="mwss">MWSS</SelectItem>
|
||||
<SelectItem key="mtcp">MTCP</SelectItem>
|
||||
</Select>
|
||||
|
||||
{/* 负载策略 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
label="负载策略"
|
||||
placeholder="选择策略"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "round";
|
||||
{/* 负载策略 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
label="负载策略"
|
||||
placeholder="选择策略"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "round";
|
||||
|
||||
return (
|
||||
form.outNodeId[0].strategy || "round"
|
||||
);
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(
|
||||
keys,
|
||||
)[0] as string;
|
||||
return form.outNodeId[0].strategy || "round";
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes =
|
||||
prev.outNodeId || [];
|
||||
const currentProtocol =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].protocol || "tls"
|
||||
: "tls";
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
const currentProtocol =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].protocol || "tls"
|
||||
: "tls";
|
||||
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: currentProtocol,
|
||||
strategy: selectedKey,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: currentProtocol,
|
||||
strategy: selectedKey,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map(
|
||||
(ct) => ({
|
||||
...ct,
|
||||
strategy: selectedKey,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="fifo">主备</SelectItem>
|
||||
<SelectItem key="round">轮询</SelectItem>
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
strategy: selectedKey,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="fifo">主备</SelectItem>
|
||||
<SelectItem key="round">轮询</SelectItem>
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 连接IP - 出口节点 */}
|
||||
@@ -2195,13 +2179,9 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(
|
||||
keys,
|
||||
)[0] as string;
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const value =
|
||||
selectedKey === "__default__"
|
||||
? ""
|
||||
: selectedKey;
|
||||
selectedKey === "__default__" ? "" : selectedKey;
|
||||
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
@@ -2231,9 +2211,7 @@ export default function TunnelPage() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">
|
||||
默认连接IP
|
||||
</SelectItem>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
{commonOutIpOptions.map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
@@ -2909,7 +2887,7 @@ export default function TunnelPage() {
|
||||
<ModalBody>
|
||||
<p>
|
||||
确定要删除选中的 {selectedIds.size}{" "}
|
||||
项隧道吗?此操作不可撤销,相关规则也将被删除。
|
||||
项隧道吗?此操作不可撤销,相关转发也将被删除。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -845,7 +845,7 @@ export default function UserPage() {
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">规则数量</span>
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">
|
||||
{user.num}
|
||||
</span>
|
||||
@@ -1001,7 +1001,7 @@ export default function UserPage() {
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="规则数量"
|
||||
label="转发数量"
|
||||
max="99999"
|
||||
min="1"
|
||||
type="number"
|
||||
@@ -1138,7 +1138,7 @@ export default function UserPage() {
|
||||
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-default-500 bg-default-100 dark:bg-default-50 p-3 rounded-lg border border-default-200 dark:border-default-100/30">
|
||||
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
|
||||
流量限制、转发数量、到期时间、流量重置时间将自动继承用户设置
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
|
||||
@@ -1296,7 +1296,7 @@ export default function UserPage() {
|
||||
<TableHeader>
|
||||
<TableColumn>隧道名称</TableColumn>
|
||||
<TableColumn>流量统计</TableColumn>
|
||||
<TableColumn>规则数量</TableColumn>
|
||||
<TableColumn>转发数量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn>限速规则</TableColumn>
|
||||
<TableColumn>重置时间</TableColumn>
|
||||
@@ -1453,7 +1453,7 @@ export default function UserPage() {
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="规则数量"
|
||||
label="转发数量"
|
||||
max="99999"
|
||||
min="1"
|
||||
type="number"
|
||||
@@ -1668,7 +1668,7 @@ export default function UserPage() {
|
||||
的权限吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-1">
|
||||
删除后该用户将无法使用此隧道创建规则,此操作不可撤销。{" "}
|
||||
删除后该用户将无法使用此隧道创建转发,此操作不可撤销。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,33 +38,15 @@ function parseDateText(value: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const digitsOnly = trimmed.replace(/\D/g, "");
|
||||
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
if (digitsOnly.length !== 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(digitsOnly.slice(0, 4));
|
||||
const month = Number(digitsOnly.slice(4, 6));
|
||||
const day = Number(digitsOnly.slice(6, 8));
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { day, month, year };
|
||||
}
|
||||
|
||||
const numberParts = trimmed.match(/\d+/g);
|
||||
|
||||
if (!numberParts || numberParts.length !== 3 || numberParts[0].length !== 4) {
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(numberParts[0]);
|
||||
const month = Number(numberParts[1]);
|
||||
const day = Number(numberParts[2]);
|
||||
const year = Number(matched[1]);
|
||||
const month = Number(matched[2]);
|
||||
const day = Number(matched[3]);
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user