Compare commits

..

14 Commits

Author SHA1 Message Date
sagit 2e1d75dc36 fix: add self-healing for forward service name migration (#270)
## Summary
- Fix `service not found` errors when upgrading from older versions
where service names migrated from placeholder IDs (`forward_user_0`) to
real `user_tunnel_id`
- Add fallback cleanup+rebuild logic on `UpdateService` when service not
found during upgrade transition
- Add self-healing retry on `Pause/Resume` operations when all service
variants are missing
- Refactor `controlForwardServicesOnNode` to support unit testing
- Add tests for the new helper functions

## Test plan
- [x] Unit tests pass: `cd go-backend && go test
./internal/http/handler/...`

## Upgrade path
1. Deploy this backend patch first (no need to wait for all agents)
2. Gradually upgrade agents in batches (10-20%)
3. Run "forward batch redeploy" after each batch to unify service naming
4. Monitor logs for `service .* not found` errors
2026-03-05 12:42:04 +08:00
sagitchu f496f58a4d fix: add self-healing for forward service name migration
When upgrading from older versions, service names changed from
placeholder IDs (forward_user_0) to real user_tunnel IDs, causing
service not found errors during control operations.

- Add fallback cleanup+rebuild logic on UpdateService when service
  not found during the upgrade transition period.
- Add self-healing retry on Pause/Resume when all variants are missing.
- Refactor controlForwardServicesOnNode to support unit testing.
- Add tests for shouldSelfHealForwardServiceControl and
  controlForwardServiceCommand helper functions.

Entire-Checkpoint: a7f0c3175d06
2026-03-05 12:40:20 +08:00
sagit 32474bec20 fix: resolve user tunnel early to use real ID in service name (#265)
## Summary
- Fix service name generation to use the actual user_tunnel ID instead
of 0
- Move user tunnel resolution before building service base name
- Add `buildForwardServiceBaseWithResolvedUserTunnel` helper function

## Details
Previously, the service base name was built with `userTunnelID=0` before
the actual user tunnel was resolved. This caused the runtime service
name to not carry the real user_tunnel ID.

The fix resolves the user tunnel early and passes the resolved ID to the
service name builder, ensuring proper service identification.
2026-03-04 19:35:47 +08:00
sagitchu 581cda7edc fix: resolve user tunnel early to use real ID in service name
- Move user tunnel resolution before building service base name
- Add buildForwardServiceBaseWithResolvedUserTunnel helper
- Ensure service names carry the actual user_tunnel ID instead of 0

Entire-Checkpoint: 9559e6447fda
2026-03-04 19:34:16 +08:00
sagit 96aebb8d61 fix: handle drag-and-drop order correctly in compact mode (#264) 2026-03-04 16:49:03 +08:00
sagit 735fd40786 fix: correct SortableContext nesting for table drag-and-drop (#263)
## Summary
- Fixed incorrect nesting of SortableContext component in forward table
- Moved SortableContext wrapper to properly wrap the entire Table
component instead of wrapping individual rows
- This ensures drag-and-drop functionality works correctly with the
table structure
2026-03-04 16:01:38 +08:00
sagitchu a3b0bf4898 fix: correct SortableContext nesting for table drag-and-drop
Entire-Checkpoint: 356ceb26d6bc
2026-03-04 16:00:07 +08:00
sagit 9703e4a081 feat: restrict user permissions and multi-node IP constraints (#262)
* feat: restrict user permissions and multi-node IP constraints

- Non-admin users cannot set speedId or inPort on forward create/update
- Multi-entrance tunnels disable custom listen IP for forwards
- Multi-exit tunnels disable custom connect IP
- Multi-node hop chains disable custom connect IP per hop
- Remove tunnel-first-IP fallback in forward ingress resolution
- Add contract tests for non-admin permission restrictions

Entire-Checkpoint: 133693290660

* fix: allow non-admin users to submit null speedId and zero inPort

- Backend: Check speedId is not nil before rejecting non-admin requests
- Backend: Only reject inPort if value > 0 for non-admin users
- Frontend: Only include speedId and inPort in payload for admin users
- Tests: Add contract tests for null speedId and zero inPort cases

* refactor: simplify forward mutation payload construction
2026-03-04 15:05:05 +08:00
sagit a43653f252 fix: permission checks for speedId and inPort + multi-node IP constraints (#261)
* feat: restrict user permissions and multi-node IP constraints

- Non-admin users cannot set speedId or inPort on forward create/update
- Multi-entrance tunnels disable custom listen IP for forwards
- Multi-exit tunnels disable custom connect IP
- Multi-node hop chains disable custom connect IP per hop
- Remove tunnel-first-IP fallback in forward ingress resolution
- Add contract tests for non-admin permission restrictions

Entire-Checkpoint: 133693290660

* fix: allow non-admin users to submit null speedId and zero inPort

- Backend: Check speedId is not nil before rejecting non-admin requests
- Backend: Only reject inPort if value > 0 for non-admin users
- Frontend: Only include speedId and inPort in payload for admin users
- Tests: Add contract tests for null speedId and zero inPort cases
2026-03-04 14:50:36 +08:00
sagit 348900de01 feat: restrict user permissions and multi-node IP constraints (#260)
- Non-admin users cannot set speedId or inPort on forward create/update
- Multi-entrance tunnels disable custom listen IP for forwards
- Multi-exit tunnels disable custom connect IP
- Multi-node hop chains disable custom connect IP per hop
- Remove tunnel-first-IP fallback in forward ingress resolution
- Add contract tests for non-admin permission restrictions

Entire-Checkpoint: 133693290660
2026-03-04 14:04:22 +08:00
sagit b93c259fac fix: preserve speed_limit and auto_clear when saving forwards and user tunnels (#259)
## Summary
- Add `speed_limit` and `auto_clear` fields to forward update mutation
to prevent data loss on save
- Update user tunnel save mutation to preserve these fields when editing
tunnels
- Add contract test to verify forward save preserves `speed_limit`
- Add plan documents (006, 007, 008) tracking the fix

## Changes
- `go-backend/internal/http/handler/mutations.go`: Add missing fields to
forward and user tunnel update logic
- `go-backend/tests/contract/forward_contract_test.go`: Add test case
for speed_limit preservation
- `vite-frontend/src/pages/forward.tsx`: Pass speed_limit and auto_clear
on save
- `vite-frontend/src/pages/user.tsx`: Pass speed_limit and auto_clear on
user tunnel save
2026-03-03 22:11:05 +08:00
sagitchu 2e3d5c9249 fix: preserve speed_limit and auto_clear when saving forwards and user tunnels
- Add speed_limit and auto_clear fields to forward update mutation
- Update user tunnel save to preserve these fields
- Add contract test for forward save preserving speed_limit
- Add plan documents for the fixes
2026-03-03 22:10:33 +08:00
sagit c8c1841058 feat: forward enhancements and auto-fallback for invalid bind IP (#258)
## Summary

This PR introduces comprehensive enhancements to the forward service
management system, including:

- **Auto-fallback for invalid bind IP**: When a forward service is
updated with a bind IP that doesn't exist on the host network
interfaces, the system automatically falls back to the default bind
address (listening on all interfaces) instead of failing. Users receive
warning toasts when fallback occurs.

- **Bind IP preservation**: Forward services now preserve their explicit
bind IP when editing without explicit inIp changes.

- **Port rebind handling**: Fixed forward service rebind when the port
is self-occupied by updating instead of adding.

- **NY format import support**: Added support for importing forwards in
NY format with node-based tunnel matching and auto port assignment.

- **Custom IP selection**: Enabled custom IP selection for nodes,
tunnels, and forwards with proper UI controls.

- **Compact mode**: Added global compact mode for forward list with
alpha8 layout and tunnel-group collapse/ordering.

## Changes

### Backend
- Added `syncForwardServicesWithWarnings` to collect fallback warnings
- Implemented `fallbackForwardPortToDefaultBind` for graceful
degradation
- Added `UpdateForwardPortBindIP` repository method to persist fallback
- Enhanced error detection for 'cannot assign requested address' errors
- Fixed bind IP preservation during forward edits
- Fixed port rebind on self-occupied addresses

### Frontend
- Added warning toast display when bind IP fallback occurs
- Implemented IP selection dropdowns for tunnels and forwards
- Added compact mode toggle in settings
- Enhanced forward list with tunnel-group collapse and drag sorting

### Tests
- Added comprehensive unit tests for error detection functions
- Added migration tests for legacy columns

## Commits Since Last Merge
- feat: auto-fallback to default bind IP when invalid bind address
detected
- fix: handle forward service rebind on self-occupied port
- fix: preserve bind IP when editing forward without explicit inIp
change
- feat: add ny import compatibility with auto port assignment
- refactor: simplify forward import tunnel selection
- feat: add ny format support for forward import with node-based tunnel
matching
- feat: custom IP selection and connectIp diagnosis fixes
- feat: add comprehensive migration test for legacy columns
- feat: add custom IP selection for nodes, tunnels, and forwards
- feat(forward): support tunnel-group collapse and ordering in full mode
- feat(forward): add global compact mode with alpha8 list layout
2026-03-03 21:34:49 +08:00
sagitchu 1c596fae4b feat: auto-fallback to default bind IP when invalid bind address detected
When a forward service is updated with a bind IP that doesn't exist on the
host network interfaces, the system now automatically falls back to the
default bind address (listening on all interfaces) instead of failing.

- Added syncForwardServicesWithWarnings to collect fallback warnings
- Implemented fallbackForwardPortToDefaultBind for graceful degradation
- Added UpdateForwardPortBindIP repository method to persist fallback
- Enhanced error detection for 'cannot assign requested address' errors
- Frontend displays warning toasts when fallback occurs
- Added comprehensive unit tests for new error detection functions
2026-03-03 21:34:12 +08:00
61 changed files with 94924 additions and 270 deletions
@@ -0,0 +1,32 @@
# Session Context
Session ID: ses_349a99cf4ffeQMqVmv7PqyI3y6
Commit Message: 普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
## Prompts
### Prompt 1
普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
## Summary
## 分析结果
### 当前状态
- **角色系统**: `roleID == 0` 为管理员,`roleID != 0` 为普通用户
- **问题**: 普通用户在创建/编辑转发时可设置 `speedId`(限速) 和 `inPort`(自定义端口)
### 实施方案
**后端修改** (`go-backend/internal/http/handler/mutations.go`):
- `forwardCreate`: 检查 `roleID != 0` 时拒绝 `speedId` 和 `inPort`
- `forwardUpdate`: 检查 `actorRole != 0` 时拒绝 `speedId` 和 `inPort`
**前端修改** (`vite-frontend/src/pages/forward.tsx`):
- 限速规则选择器和入口端口输入框使用 `{isAdmin && (...)}` 条件渲染
**测试**:
- 添加契约测试验证权限限制
计划文档: `plans/009-restrict-user-forward-permissions.md`
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
@@ -0,0 +1,19 @@
## 分析结果
### 当前状态
- **角色系统**: `roleID == 0` 为管理员,`roleID != 0` 为普通用户
- **问题**: 普通用户在创建/编辑转发时可设置 `speedId`(限速) 和 `inPort`(自定义端口)
### 实施方案
**后端修改** (`go-backend/internal/http/handler/mutations.go`):
- `forwardCreate`: 检查 `roleID != 0` 时拒绝 `speedId` 和 `inPort`
- `forwardUpdate`: 检查 `actorRole != 0` 时拒绝 `speedId` 和 `inPort`
**前端修改** (`vite-frontend/src/pages/forward.tsx`):
- 限速规则选择器和入口端口输入框使用 `{isAdmin && (...)}` 条件渲染
**测试**:
- 添加契约测试验证权限限制
计划文档: `plans/009-restrict-user-forward-permissions.md`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta6
@@ -0,0 +1,5 @@
完成:
- 已提交并推送变更
- PR #259 已合并到 main
- 已创建并推送 tag `2.1.7-beta6`
- 已删除远程分支
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
转发选择限速后保存,然后把限速删除,再次编辑转发报错会报错限速不存在,请保存的时候限速不存在直接删除限速,不阻断提示限速不存在
@@ -0,0 +1,20 @@
我已经定位到问题点了,确实在后端 `forward/update` 的限速校验会把“已被删除的限速ID”当成硬错误返回,导致你说的二次编辑报 `限速规则不存在`。
**定位结果**
- 触发点在 `go-backend/internal/http/handler/mutations.go:1259` 附近(`forwardUpdate`)和 `go-backend/internal/http/handler/mutations.go:1147` 附近(`forwardCreate`)。
- 当前逻辑是:只要请求里带了 `speedId`,且 `SpeedLimitExists=false`,就直接 `ErrDefault("限速规则不存在")` 返回。
- 前端编辑提交在 `vite-frontend/src/pages/forward.tsx:1403` 会一直带 `speedId`(包括编辑场景),所以很容易复现你说的问题。
**实施计划(不改动其他业务)**
- 新建计划文档:`plans/006-forward-save-missing-speed-limit-auto-clear.md`(按现有 001~005 递增)。
- 后端仅改转发保存逻辑,不改用户隧道限速逻辑:
- `forwardCreate`:若 `speedId` 不存在,自动当成 `nil`(即“不限速”)继续保存,不返回“限速不存在”错误。
- `forwardUpdate`:按“最终要写入的 speed_id”做一次兜底校验;若不存在则自动清空为 `NULL` 再继续保存,确保编辑不阻断。
- 不新增“限速不存在”阻断提示(符合你要求)。
- 增加/更新合约测试:`go-backend/tests/contract/forward_contract_test.go`
- 覆盖场景:转发绑定限速后,删除该限速,再编辑转发保存,应返回成功且 `forward.speed_id` 被清空。
- 验证命令(实施后执行):
- `cd go-backend && go test ./tests/contract/... -run Forward`
- 如有必要再跑 `cd go-backend && go test ./...`
如果你确认,我下一步就按这个方案进入实现。
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta5
@@ -0,0 +1,7 @@
✅ 已完成:
- 提交变更:feat: auto-fallback to default bind IP when invalid bind address detected
- 推送分支:opencode/mighty-nebula → origin
- 创建PR:#258(已自动合并)
- 发布tag:2.1.7-beta5
PR: https://github.com/Sagit-chu/flvx/pull/258
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta4
@@ -0,0 +1,6 @@
Tag `2.1.7-beta4` 已在 GitHub 远程创建成功。
**完成摘要:**
- 提交: `7efb49b` - fix: handle forward service rebind on self-occupied port
- PR: https://github.com/Sagit-chu/flvx/pull/257 (已合并)
- Tag: `2.1.7-beta4` 已推送到远程
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
转发编辑保存报错create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:16765: bind: cannot assign requested address
引发条件:当编辑选择入口IP原来是默认地址,选择成和默认地址一样的地址的时候报错
---
保持“只要用户选了IP就一定显式绑定”,如果选择的IP后发现端口占用,检测是否是当前转发占用的,如果是那就释放掉,避免原来是默认监听::所有地址后永远无法切换
---
按计划实现
---
节点 test-sg 下发失败: create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:56025: bind: cannot assign requested address
还是不行啊,由默认入口IP创建的转发,然后编辑选择了唯一的一个IP后报错了
@@ -0,0 +1,23 @@
你这个新报错我确认了:这次**不是端口占用问题**,是 **IP 本机不可绑定** 问题。
- 报错是 `bind: cannot assign requested address`,含义是节点机器网卡上没有 `13.228.170.187` 这个本地地址;释放旧服务也不会解决。
- 你说“唯一一个IP可选”,很可能来自前端当前候选逻辑把 `serverIp/serverIpV4/serverIpV6` 当成监听候选(`vite-frontend/src/pages/forward.tsx:612` 一带),但这些字段在 NAT/云环境下常是“对外地址”,不一定是本机可 bind 地址。
- 我之前加的自占用释放逻辑会在冲突时尝试删旧服务再加新服务,但对 `cannot assign requested address` 这种错误会一直失败(因为 IP 本身不可绑定)。
我建议下一步这样改(这次是关键):
- 后端只把 `address already in use` 走“自占用释放重绑”流程;`cannot assign requested address` 不走该流程。
位置:`go-backend/internal/http/handler/control_plane.go`
- 前端监听 IP 下拉改为“可绑定地址优先”:优先 `extraIPs` + 明确配置的监听地址,**不再默认把 `serverIp*` 作为可绑定监听IP**。
位置:`vite-frontend/src/pages/forward.tsx`
- 后端增加快速校验并返回更明确提示:
`监听IP不是节点本机地址,请在节点 extraIPs 配置可绑定IP后再选择`。
位置:`go-backend/internal/http/handler/control_plane.go` + repo 查询辅助。
- 你的临时可用绕过:到节点配置里把真实网卡 IP 填到 `extraIPs`,然后在编辑转发时选该 IP。
一个需要你拍板的点(推荐第1个):
1. **推荐**:前端不再展示 `serverIp/serverIpV4/serverIpV6` 为监听IP候选,只展示 `extraIPs`(和明确监听地址)。
2. 继续展示 `serverIp*`,但加“可能不可绑定”标签和失败提示。
你回我 `1` 或 `2`,我就按这个继续落地。
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta3
@@ -0,0 +1,8 @@
完成!已成功:
1. **提交变更**: 5个文件(修复绑定IP保留问题 + 新增单元测试)
2. **推送分支**: opencode/mighty-nebula → origin
3. **创建PR**: #256 (已合并)
4. **发布tag**: 2.1.7-beta3
PR #256: https://github.com/Sagit-chu/flvx/pull/256
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
编辑转发后保存报错 下发失败: create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:16765: bind: cannot assign requested address,这个bug是因为增加了自定义IP功能后导致的,监听IP 不选择就会报错,应该根据原来的监听状态保存
@@ -0,0 +1,23 @@
你这个判断是对的,我已经把链路看清了,确实是“新增自定义监听 IP 后,编辑保存把原监听状态覆盖掉”导致的。
- 前端编辑表单会把 `forward.inIp` 直接回填到 `form.inIp`(`vite-frontend/src/pages/forward.tsx:1302`)。
- 保存时会把 `form.inIp` 带到更新请求(`vite-frontend/src/pages/forward.tsx:1391`)。
- 后端更新时无论是否真的改了监听 IP,都会用请求里的 `inIp` 全量重建 `forward_port`(`go-backend/internal/http/handler/mutations.go:1288`、`go-backend/internal/http/handler/mutations.go:1305`、`go-backend/internal/http/handler/mutations.go:3019`)。
- 而列表里的 `forward.inIp` 可能是“展示回退值”(隧道 IP/节点 IP),不一定是原始 `forward_port.in_ip`(`go-backend/internal/store/repo/repository.go:731`、`go-backend/internal/store/repo/repository.go:2778`、`go-backend/internal/store/repo/repository.go:2780`)。
- 这样就会在“没手动选监听 IP”时,把服务改成绑定不可用地址,触发 `bind: cannot assign requested address`。
我建议的修复方案(实现时会做成双保险):
- 前端:编辑模式下仅当用户“主动改过监听 IP”时才提交 `inIp` 字段;未触碰则不传,保持原状态。
- 后端:`forward/update` 中如果请求没带 `inIp`,就保留原 `forward_port.in_ip`(不清空、不覆盖);若带了 `inIp`(包括空串),按用户意图更新。
- 回归测试:
- 后端新增用例:未传 `inIp` 时更新不改变监听绑定。
- 后端新增用例:显式传空串时清空监听绑定。
- (可选)前端补充一个轻量逻辑测试/至少手工验证路径。
另外按仓库规则,这次会新增计划文档为:`plans/003-forward-edit-bind-ip-preserve.md`(当前是只读 Plan Mode,我还没动文件)。
一个需要你拍板的小点(我给默认建议):
- **推荐默认**:编辑时如果改了隧道但没有手动选“监听IP”,按“默认监听(空)”处理,不继承旧隧道的监听 IP。
这样能避免把旧隧道的 IP 误带到新隧道。
你是否同意按这个规则?
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
提交全部变更并且push,创建pr合并,发布新tag,beta版本
---
不对,tag是2.1.7-beta2
---
2.1.8-beta1 标签没有被删除成功
---
2.1.8-alpha1 标签没有被删除成功
@@ -0,0 +1 @@
已删除 `2.1.8-alpha1` 标签。当前最新版本为 `2.1.7-beta2`。
@@ -0,0 +1,41 @@
# Session Context
Session ID: ses_34d1fa4b1ffeH4sVbjXkh5P6IY
Commit Message: 实施
## Prompts
### Prompt 1
转发导入,ny兼容格式,不要选择入口,改成选择隧道,修改兼容一下
### Prompt 2
自动分配端口(推荐)
### Prompt 3
实施
## Summary
已实施完成,这次改动把 `ny` 导入完全对齐到“选隧道”流程,并按你指定支持“端口缺失自动分配”。
- `ny` 解析兼容增强:支持字段别名、可选端口
- 修改 `vite-frontend/src/pages/forward/import-format.ts`
- `listen_port` 现在允许为 `null`(缺失即自动分配)
- 兼容字段:`dest/dst/target/targets`、`listen_port/listenPort/port/in_port/inPort`、`name/forward_name/forwardName`
- `dest` 支持数组或逗号分隔字符串,统一归一化
- 导入 UI 文案已同步更新(强调按所选隧道导入 + 端口可省略)
- 修改 `vite-frontend/src/pages/forward.tsx:4623`、`vite-frontend/src/pages/forward.tsx:4699`
- 测试已补齐并通过
- 修改 `vite-frontend/src/pages/forward/import-format.test.ts`
- 新增用例:缺失端口自动分配、别名字段兼容、`inPort: null` 映射
- 计划文档已新增并完成勾选
- `plans/002-ny-import-tunnel-selection-compat.md`
验证结果:
- `npm run build`(`vite-frontend`)通过
- `node --test src/pages/forward/import-format.test.ts` 7/7 通过
如果你要,我下一步可以直接帮你提交一个规范 commit。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
转发导入,ny兼容格式,不要选择入口,改成选择隧道,修改兼容一下
---
自动分配端口(推荐)
---
实施
@@ -0,0 +1,20 @@
已实施完成,这次改动把 `ny` 导入完全对齐到“选隧道”流程,并按你指定支持“端口缺失自动分配”。
- `ny` 解析兼容增强:支持字段别名、可选端口
- 修改 `vite-frontend/src/pages/forward/import-format.ts`
- `listen_port` 现在允许为 `null`(缺失即自动分配)
- 兼容字段:`dest/dst/target/targets`、`listen_port/listenPort/port/in_port/inPort`、`name/forward_name/forwardName`
- `dest` 支持数组或逗号分隔字符串,统一归一化
- 导入 UI 文案已同步更新(强调按所选隧道导入 + 端口可省略)
- 修改 `vite-frontend/src/pages/forward.tsx:4623`、`vite-frontend/src/pages/forward.tsx:4699`
- 测试已补齐并通过
- 修改 `vite-frontend/src/pages/forward/import-format.test.ts`
- 新增用例:缺失端口自动分配、别名字段兼容、`inPort: null` 映射
- 计划文档已新增并完成勾选
- `plans/002-ny-import-tunnel-selection-compat.md`
验证结果:
- `npm run build`(`vite-frontend`)通过
- `node --test src/pages/forward/import-format.test.ts` 7/7 通过
如果你要,我下一步可以直接帮你提交一个规范 commit。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
---
不对,撤销tag,发布2.1.7-beta1
@@ -0,0 +1,3 @@
已完成:
- 删除标签 `2.1.8-alpha1`(本地+远程)
- 创建并推送标签 `2.1.7-beta1` 到 `origin/main`
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
@@ -0,0 +1,13 @@
完成!
**总结:**
- ✅ 提交变更:`feat: add ny format support for forward import with node-based tunnel matching`
- ✅ 推送到远程分支 `opencode/mighty-nebula`
- ✅ 创建 PR #250 并自动合并到 main
- ✅ 发布新 tag `2.1.7-alpha9`
**新版本特性:**
- 新增 ny 格式转发数据导入支持(JSON格式,多目标地址负载均衡)
- 入口节点选择功能,自动匹配端口转发类型隧道
- 多隧道匹配时提供选择界面
- 新增 import-format.ts 解析模块及测试文件
@@ -0,0 +1,49 @@
# Session Context
Session ID: ses_34d7fcdb8ffekb7lvyJwVvoS8T
Commit Message: 增加功能:转发规则批量导入格式,兼容ny的导入格式
ny格式如下:
{"dest":["151.241.129.52:23609"],"list
## Prompts
### Prompt 1
增加功能:转发规则批量导入格式,兼容ny的导入格式
ny格式如下:
{"dest":["151.241.129.52:23609"],"listen_port":20224,"name":"灵玥-JP-Lpt【三网通用】"}{"dest":["64.81.33.2:24577"],"listen_port":41034,"name":"Yolo-US-Lpt【三网通用】"}
ny导入的时候是选择入口的,flvx转发导入是选择隧道的,需要兼容一下,请设计计划
## Summary
计划文档已设计完成。以下是计划摘要:
## 计划摘要
### 格式对比
| 特性 | flvx格式 | ny格式 |
|------|----------|--------|
| 格式 | 管道分隔文本 | 紧凑JSON对象 |
| 示例 | `ip:port\|name\|port` | `{"dest":["ip:port"],"listen_port":port,"name":"name"}` |
| 多目标 | 逗号分隔 | dest数组 |
| 选择器 | 选择隧道 | 选择入口节点→匹配隧道 |
### 核心设计
1. **格式选择器**:手动选择"flvx格式"或"ny格式"
2. **ny格式流程**:选择入口节点 → 匹配端口转发隧道 → 匹配0个报错、1个直接用、多个弹窗选
3. **不自动创建隧道**:无匹配时提示用户先创建
### 任务清单
1. 新增`importFormat`、`selectedEntryNode`、`matchedTunnels`等状态
2. 重构导入模态框UI,添加格式选择器
3. 实现ny格式紧凑JSON解析函数
4. 实现入口节点选择与隧道匹配逻辑
5. 多隧道匹配时的选择器弹窗
6. 整合ny格式到`executeImport`函数
7. 更新提示文案
### 改动范围
- `vite-frontend/src/pages/forward.tsx` - 主要改动文件
是否需要我调整计划内容?
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
增加功能:转发规则批量导入格式,兼容ny的导入格式
ny格式如下:
{"dest":["151.241.129.52:23609"],"listen_port":20224,"name":"灵玥-JP-Lpt【三网通用】"}{"dest":["64.81.33.2:24577"],"listen_port":41034,"name":"Yolo-US-Lpt【三网通用】"}
ny导入的时候是选择入口的,flvx转发导入是选择隧道的,需要兼容一下,请设计计划
@@ -0,0 +1,30 @@
计划文档已设计完成。以下是计划摘要:
## 计划摘要
### 格式对比
| 特性 | flvx格式 | ny格式 |
|------|----------|--------|
| 格式 | 管道分隔文本 | 紧凑JSON对象 |
| 示例 | `ip:port\|name\|port` | `{"dest":["ip:port"],"listen_port":port,"name":"name"}` |
| 多目标 | 逗号分隔 | dest数组 |
| 选择器 | 选择隧道 | 选择入口节点→匹配隧道 |
### 核心设计
1. **格式选择器**:手动选择"flvx格式"或"ny格式"
2. **ny格式流程**:选择入口节点 → 匹配端口转发隧道 → 匹配0个报错、1个直接用、多个弹窗选
3. **不自动创建隧道**:无匹配时提示用户先创建
### 任务清单
1. 新增`importFormat`、`selectedEntryNode`、`matchedTunnels`等状态
2. 重构导入模态框UI,添加格式选择器
3. 实现ny格式紧凑JSON解析函数
4. 实现入口节点选择与隧道匹配逻辑
5. 多隧道匹配时的选择器弹窗
6. 整合ny格式到`executeImport`函数
7. 更新提示文案
### 改动范围
- `vite-frontend/src/pages/forward.tsx` - 主要改动文件
是否需要我调整计划内容?
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
@@ -0,0 +1,6 @@
✅ 完成:
- **提交**: `fix: use configured connectIp for tunnel chain diagnosis` (1c10347)
- **PR**: #248 已合并 → https://github.com/Sagit-chu/flvx/pull/248
- **Tag**: `2.1.7-alpha8` 已创建并推送
变更内容:修复了隧道链诊断时使用配置的 connectIp、forward 绑定 IP 支持带端口的地址、添加了全面的测试覆盖。
+150 -45
View File
@@ -223,20 +223,32 @@ func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {
}
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {
_, err := h.syncForwardServicesWithWarnings(forward, method, allowFallbackAdd)
return err
}
func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method string, allowFallbackAdd bool) ([]string, error) {
if h == nil || forward == nil {
return errors.New("invalid forward sync context")
return nil, errors.New("invalid forward sync context")
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil {
return err
return nil, err
}
ports, err := h.listForwardPorts(forward.ID)
if err != nil {
return err
return nil, err
}
if len(ports) == 0 {
return errors.New("转发入口端口不存在")
return nil, errors.New("转发入口端口不存在")
}
warnings := make([]string, 0)
// Resolve user tunnel first so runtime service name can carry the real user_tunnel id.
userTunnelID, utLimiterID, utSpeed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
@@ -254,46 +266,81 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
if limiterID == nil {
// Fall back to UserTunnel speed limit
var utLimiterID *int64
var utSpeed *int
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
limiterID = utLimiterID
speed = utSpeed
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return err
return nil, err
}
for _, fp := range ports {
if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
return err
return nil, err
}
}
node, err := h.getNodeRecord(fp.NodeID)
if err != nil {
return err
return nil, err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
if isNotFoundError(err) {
if delErr := h.deleteForwardServicesOnNode(forward, node.ID); delErr != nil && !isNotFoundError(delErr) {
return warnings, fmt.Errorf("节点 %s 清理旧服务失败: %w", node.Name, delErr)
}
}
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
}
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isBindAddressInUseError(err) {
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isAddressAlreadyInUseError(err) {
err = h.rebindForwardServiceOnSelfOccupiedPort(forward, node, fp.Port, services)
}
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
var warning string
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol)
if err == nil && warning != "" {
warnings = append(warnings, warning)
}
}
if err != nil {
return fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
}
return nil
return warnings, nil
}
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
if h == nil || forward == nil || tunnel == nil || node == nil {
return "", errors.New("invalid bind fallback context")
}
if fp.Port <= 0 {
return "", errors.New("invalid forward port")
}
explicitBindIP := strings.TrimSpace(fp.InIP)
if explicitBindIP == "" {
return "", errors.New("default bind address cannot be assigned")
}
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
return "", err
}
time.Sleep(150 * time.Millisecond)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
return "", err
}
if err := h.repo.UpdateForwardPortBindIP(forward.ID, node.ID, fp.Port, ""); err != nil {
return "", err
}
warning := fmt.Sprintf("节点 %s 监听IP %s 不在主机网卡地址中,已自动回退为默认监听IP", strings.TrimSpace(node.Name), explicitBindIP)
return warning, nil
}
func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord, node *nodeRecord, port int, services []map[string]interface{}) error {
@@ -395,40 +442,26 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
seen := map[int64]struct{}{}
healed := false
for _, fp := range ports {
if _, ok := seen[fp.NodeID]; ok {
continue
}
seen[fp.NodeID] = struct{}{}
var lastNotFoundErr error
nodeHandled := false
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
for _, base := range bases {
variants := []string{base + "_tcp", base + "_udp"}
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
variants = append(variants, base)
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
candidateHandled := false
for _, name := range variants {
payload := map[string]interface{}{
"services": []string{name},
}
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)
if err == nil {
candidateHandled = true
continue
}
if !isNotFoundError(err) {
return err
}
lastNotFoundErr = err
}
if candidateHandled {
nodeHandled = true
break
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
}
@@ -446,6 +479,49 @@ 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 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,
@@ -1320,6 +1396,13 @@ func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {
return fmt.Sprintf("%d_%d_%d", forwardID, userID, userTunnelID)
}
func buildForwardServiceBaseWithResolvedUserTunnel(forwardID, userID, resolvedUserTunnelID int64) string {
if resolvedUserTunnelID <= 0 {
return buildForwardServiceBase(forwardID, userID, 0)
}
return buildForwardServiceBase(forwardID, userID, resolvedUserTunnelID)
}
func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {
orderedIDs := make([]int64, 0, len(userTunnelIDs)+2)
seen := make(map[int64]struct{}, len(userTunnelIDs)+2)
@@ -1385,8 +1468,30 @@ func isBindAddressInUseError(err error) bool {
if msg == "" {
return false
}
if strings.Contains(msg, "address already in use") {
return true
return isAddressAlreadyInUseMessage(msg) || strings.Contains(msg, "cannot assign requested address")
}
func isAddressAlreadyInUseError(err error) bool {
if err == nil {
return false
}
return isAddressAlreadyInUseMessage(strings.ToLower(strings.TrimSpace(err.Error())))
}
func isAddressAlreadyInUseMessage(msg string) bool {
if msg == "" {
return false
}
return strings.Contains(msg, "address already in use")
}
func isCannotAssignRequestedAddressError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if msg == "" {
return false
}
return strings.Contains(msg, "cannot assign requested address")
}
@@ -43,6 +43,20 @@ func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
if got != "12_34_56" {
t.Fatalf("expected 12_34_56, got %s", got)
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
if got != "12_34_0" {
t.Fatalf("expected 12_34_0, got %s", got)
}
}
func TestShouldTryLegacySingleService(t *testing.T) {
if !shouldTryLegacySingleService("PauseService") {
t.Fatalf("PauseService should require legacy fallback")
@@ -55,6 +69,78 @@ 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 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")
@@ -82,6 +168,24 @@ func TestIsBindAddressInUseError(t *testing.T) {
}
}
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("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
}
}
func TestIsCannotAssignRequestedAddressError(t *testing.T) {
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should not be treated as cannot-assign")
}
}
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
+97 -33
View File
@@ -456,6 +456,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if err := validateTunnelConnectIPConstraints(req); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
name := asString(req["name"])
if name == "" {
response.WriteJSON(w, response.ErrDefault("隧道名称不能为空"))
@@ -663,6 +667,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if err := validateTunnelConnectIPConstraints(req); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
id := asInt64(req["id"], 0)
if id <= 0 {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
@@ -1057,7 +1065,8 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
}
speedID := asAnyToInt64Ptr(req["speedId"])
if err := h.validateSpeedLimitReference(speedID); err != nil {
speedID, err := h.normalizeSpeedLimitReference(speedID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1144,17 +1153,24 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
return
}
if roleID != 0 {
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return
}
if inPortVal, ok := req["inPort"]; ok {
port := asInt(inPortVal, 0)
if port > 0 {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
return
}
}
}
speedID := asAnyToInt64Ptr(req["speedId"])
if speedID != nil {
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
if speedErr != nil {
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
return
}
if !exists {
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
return
}
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
port := asInt(req["inPort"], 0)
if port <= 0 {
@@ -1164,6 +1180,11 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
port = 10000
}
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
inIp := strings.TrimSpace(asString(req["inIp"]))
if inIp != "" && len(entryNodes) > 1 {
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
return
}
for _, nodeID := range entryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
@@ -1180,7 +1201,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if userName == "" {
userName = "user"
}
inIp := strings.TrimSpace(asString(req["inIp"]))
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -1256,17 +1276,24 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" {
strategy = forward.Strategy
}
if actorRole != 0 {
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
if inPortVal, ok := req["inPort"]; ok {
port := asInt(inPortVal, 0)
if port > 0 {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
return
}
}
}
speedID := asAnyToInt64Ptr(req["speedId"])
if speedID != nil {
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
if speedErr != nil {
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
return
}
if !exists {
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
return
}
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
newSpeedID := forward.SpeedID
if speedID != nil {
@@ -1292,6 +1319,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
inIp = asString(rawInIP)
}
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
if hasInIP && strings.TrimSpace(inIp) != "" && len(fwdEntryNodes) > 1 {
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
return
}
for _, nodeID := range fwdEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
@@ -1325,11 +1356,16 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
warnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
if err != nil {
h.rollbackForwardMutation(forward, oldPorts)
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if len(warnings) > 0 {
response.WriteJSON(w, response.OK(map[string]interface{}{"warnings": warnings}))
return
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2172,6 +2208,32 @@ func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, i
return strings.Join(ordered, ",")
}
func validateTunnelConnectIPConstraints(req map[string]interface{}) error {
outNodes := asMapSlice(req["outNodeId"])
if len(outNodes) > 1 {
for _, item := range outNodes {
if strings.TrimSpace(asString(item["connectIp"])) != "" {
return fmt.Errorf("多出口隧道不支持设置自定义连接IP")
}
}
}
for hopIdx, hopRaw := range asAnySlice(req["chainNodes"]) {
hopNodes := asMapSlice(hopRaw)
if len(hopNodes) <= 1 {
continue
}
for _, item := range hopNodes {
if strings.TrimSpace(asString(item["connectIp"])) != "" {
return fmt.Errorf("转发链第%d跳有多个节点时不支持设置自定义连接IP", hopIdx+1)
}
}
}
return nil
}
func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {
if req == nil || state == nil {
return
@@ -3114,11 +3176,13 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
return fmt.Errorf("userId or tunnelId missing")
}
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, lookupErr :=
h.repo.GetExistingUserTunnel(userID, tunnelID)
speedID := asAnyToInt64Ptr(req["speedId"])
if err := h.validateSpeedLimitReference(speedID); err != nil {
var err error
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
return err
}
@@ -3128,7 +3192,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
reqFlowReset := asInt64(req["flowResetTime"], -1)
reqStatus := asInt(req["status"], -1)
if err == sql.ErrNoRows {
if lookupErr == sql.ErrNoRows {
if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {
uFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)
if uErr == nil {
@@ -3181,8 +3245,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
return nil
}
if err != nil {
return err
if lookupErr != nil {
return lookupErr
}
newFlow := currentFlow
@@ -3258,20 +3322,20 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
return nil
}
func (h *Handler) validateSpeedLimitReference(speedID *int64) error {
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
if speedID == nil {
return nil
return nil, nil
}
exists, err := h.repo.SpeedLimitExists(*speedID)
if err != nil {
return err
return nil, err
}
if !exists {
return errors.New("限速规则不存在")
return nil, nil
}
return nil
return speedID, nil
}
func asAnySlice(v interface{}) []interface{} {
@@ -2753,18 +2753,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
seenPorts := make(map[int64]struct{})
seenPairs := make(map[string]struct{})
var tunnelFirstIP string
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
tunnelIPs := strings.Split(tunnelInIP.String, ",")
for _, ip := range tunnelIPs {
ip = strings.TrimSpace(ip)
if ip != "" {
tunnelFirstIP = ip
break
}
}
}
for _, row := range fpRows {
if !row.Port.Valid {
continue
@@ -2777,8 +2765,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
var ip string
if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" {
ip = strings.TrimSpace(row.InIP.String)
} else if tunnelFirstIP != "" {
ip = tunnelFirstIP
} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
ip = strings.TrimSpace(row.ServerIP.String)
}
@@ -720,6 +720,18 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
})
}
func (r *Repository) UpdateForwardPortBindIP(forwardID, nodeID int64, port int, inIP string) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if forwardID <= 0 || nodeID <= 0 || port <= 0 {
return nil
}
return r.db.Model(&model.ForwardPort{}).
Where("forward_id = ? AND node_id = ? AND port = ?", forwardID, nodeID, port).
Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error
}
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
if r == nil || r.db == nil {
return
@@ -480,6 +480,113 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
}
}
func TestUserTunnelSaveIgnoresDeletedSpeedLimitContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(101, 'user_tunnel_speed_user_a', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "user-tunnel-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-tunnel")
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "user-tunnel-missing-speed-limit", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-limit")
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(31, 101, ?, ?, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID, speedID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
t.Fatalf("delete speed limit: %v", err)
}
t.Run("user tunnel update auto clears missing speed", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": 31,
"flow": 99999,
"num": 999,
"expTime": int64(2727251700000),
"flowResetTime": 1,
"status": 1,
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/update", bytes.NewReader(updateBody))
updateReq.Header.Set("Authorization", adminToken)
updateReq.Header.Set("Content-Type", "application/json")
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
var updatedSpeed sql.NullInt64
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&updatedSpeed); err != nil {
t.Fatalf("query updated user_tunnel speed_id: %v", err)
}
if updatedSpeed.Valid {
t.Fatalf("expected updated user_tunnel speed_id to be NULL, got %d", updatedSpeed.Int64)
}
})
t.Run("user tunnel batch assign auto clears missing speed", func(t *testing.T) {
if err := repo.DB().Exec(`UPDATE user_tunnel SET speed_id = ? WHERE id = 31`, speedID).Error; err != nil {
t.Fatalf("prepare user_tunnel speed_id for batch assign: %v", err)
}
assignPayload := map[string]interface{}{
"userId": 101,
"tunnels": []map[string]interface{}{{
"tunnelId": tunnelID,
"speedId": speedID,
}},
}
assignBody, err := json.Marshal(assignPayload)
if err != nil {
t.Fatalf("marshal assign payload: %v", err)
}
assignReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(assignBody))
assignReq.Header.Set("Authorization", adminToken)
assignReq.Header.Set("Content-Type", "application/json")
assignRes := httptest.NewRecorder()
router.ServeHTTP(assignRes, assignReq)
assertCode(t, assignRes, 0)
var assignedSpeed sql.NullInt64
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&assignedSpeed); err != nil {
t.Fatalf("query assigned user_tunnel speed_id: %v", err)
}
if assignedSpeed.Valid {
t.Fatalf("expected assigned user_tunnel speed_id to be NULL, got %d", assignedSpeed.Int64)
}
})
}
func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
@@ -618,6 +725,105 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
}
}
func TestForwardUpdateIgnoresDeletedSpeedLimitContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-update-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-update-missing-speed-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-update-missing-speed-node", "forward-update-missing-speed-secret", "10.32.0.1", "10.32.0.1", "", "42000-42010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-update-missing-speed-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 42001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "forward-update-missing-speed-limit", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, repo, "forward-update-missing-speed-limit")
server := httptest.NewServer(router)
defer server.Close()
stopNode := startMockNodeSession(t, server.URL, "forward-update-missing-speed-secret")
defer stopNode()
createPayload := map[string]interface{}{
"name": "forward-update-missing-speed-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
createReq.Header.Set("Authorization", adminToken)
createReq.Header.Set("Content-Type", "application/json")
createRes := httptest.NewRecorder()
router.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
forwardID := mustLastInsertID(t, repo, "forward-update-missing-speed-target")
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
t.Fatalf("delete speed limit: %v", err)
}
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "forward-update-missing-speed-target-updated",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
updateReq.Header.Set("Authorization", adminToken)
updateReq.Header.Set("Content-Type", "application/json")
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
storedSpeed := repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
var updatedSpeed sql.NullInt64
if err := storedSpeed.Scan(&updatedSpeed); err != nil {
t.Fatalf("query updated forward speed_id: %v", err)
}
if updatedSpeed.Valid {
t.Fatalf("expected updated speed_id to be NULL after missing speed limit, got %d", updatedSpeed.Int64)
}
}
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
@@ -711,3 +917,225 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
secret := "contract-jwt-secret-perm"
router, repo := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'normal_user_perm', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "perm-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "perm-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "perm-node", "perm-secret", "10.0.0.20", "10.0.0.20", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "perm-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, 10, 99999, 0, 0, 1, 2727251700000, 1)
`, 2, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, ?, ?, ?, ?, 1)
`, "perm-speed-limit", 2048, tunnelID, "perm-tunnel", now, now).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, repo, "perm-speed-limit")
userToken, err := auth.GenerateToken(2, "normal_user_perm", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
stopNode := startMockNodeSession(t, server.URL, "perm-secret")
defer stopNode()
t.Run("non-admin cannot set speedId on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-speed",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"speedId": speedID,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
})
t.Run("non-admin cannot set inPort on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"inPort": 12345,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口")
})
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-ok",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
forwardID := mustLastInsertID(t, repo, "perm-forward-ok")
t.Run("non-admin cannot update speedId", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
})
t.Run("non-admin cannot update inPort", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated2",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"inPort": 54321,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口")
})
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated-ok",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can 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)
})
}
@@ -0,0 +1,11 @@
# 005 Forward Invalid BindIP Fallback Default
## Checklist
- [x] Split forward service bind failures into address-in-use and cannot-assign classes.
- [x] Keep self-occupy release/rebind only for address-in-use conflicts.
- [x] Add fallback path for cannot-assign: switch to default listener bind and retry service creation.
- [x] Persist fallback result to DB by clearing `forward_port.in_ip` for affected node+port.
- [x] Return non-blocking warning in forward update response when fallback occurs.
- [x] Show warning toast in forward edit UI while still treating operation as success.
- [x] Run focused backend tests for touched handler/repo packages.
@@ -0,0 +1,8 @@
# 006 Forward Save Missing Speed Limit Auto Clear
## Checklist
- [x] Locate forward create/update speed limit validation path that blocks save when speed rule is deleted.
- [x] Change forward save behavior to auto-clear missing `speedId` instead of returning "限速规则不存在".
- [x] Add contract test coverage for editing a forward after its referenced speed limit is deleted.
- [x] Run focused contract tests for forward save behavior.
@@ -0,0 +1,8 @@
# 007 User Tunnel Save Missing Speed Limit Auto Clear
## Checklist
- [x] Locate user tunnel speed limit validation paths for assign/update flows.
- [x] Change user tunnel save behavior to auto-clear missing `speedId` instead of failing.
- [x] Add contract test coverage for user tunnel save when referenced speed limit is deleted.
- [x] Run focused contract tests for user tunnel save behavior.
@@ -0,0 +1,8 @@
# 008 Frontend Missing Speed Limit Consistency
## Checklist
- [x] Review forward and user tunnel submit flows for missing speed limit behavior.
- [x] Make frontend normalize deleted `speedId` to `null` before submit in both pages.
- [x] Add consistent non-blocking warning toast when deleted speed rule is auto-cleared.
- [x] Verify touched frontend files pass lint checks.
@@ -0,0 +1,112 @@
# 009: 普通用户转发权限限制
## 背景
当前系统允许普通用户在创建和编辑转发时设置:
1. **限速规则** (`speedId`) - 应仅限管理员设置
2. **自定义入口端口** (`inPort`) - 应仅限管理员设置
普通用户应只能使用系统自动分配的端口和默认不限速设置。
## 实施范围
| 操作 | 普通用户 | 管理员 |
|------|----------|--------|
| 创建转发 - 设置限速 | 禁止 | 允许 |
| 创建转发 - 自定义端口 | 禁止 | 允许 |
| 编辑转发 - 修改限速 | 禁止 | 允许 |
| 编辑转发 - 修改端口 | 禁止 | 允许 |
## 修改位置
### 后端 (Go)
**文件**: `go-backend/internal/http/handler/mutations.go`
#### 1. `forwardCreate` handler (行 1147-1157)
在处理 speedId 和 inPort 之前添加权限检查:
```go
if roleID != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
return
}
}
```
#### 2. `forwardUpdate` handler (行 1264-1274)
在处理 speedId 和 inPort 之前添加权限检查:
```go
if actorRole != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
return
}
}
```
### 前端 (React/TypeScript)
**文件**: `vite-frontend/src/pages/forward.tsx`
已有变量 `isAdmin` (行 610: `const isAdmin = tokenRoleId === 0;`)
#### 1. 隐藏限速规则选择器 (行 4252-4282)
用条件渲染包裹:
```tsx
{isAdmin && (
<Select
label="限速规则"
// ... 现有属性
>
{/* ... */}
</Select>
)}
```
#### 2. 隐藏入口端口输入框 (行 4311-4328)
用条件渲染包裹:
```tsx
{isAdmin && (
<Input
description="指定入口端口,留空则从节点可用端口中自动分配"
// ... 现有属性
/>
)}
```
## 任务清单
- [x] 后端: `forwardCreate` 添加权限检查
- [x] 后端: `forwardUpdate` 添加权限检查
- [x] 前端: 隐藏限速规则选择器 (仅管理员可见)
- [x] 前端: 隐藏入口端口输入框 (仅管理员可见)
- [x] 后端: 添加契约测试验证权限限制
- [x] 运行测试验证
## 测试验证
1. ✅ 契约测试已添加 `TestNonAdminCannotSetSpeedIdOrPort`
2. ✅ 所有测试用例通过:
- 普通用户创建转发时设置 speedId 被拒绝
- 普通用户创建转发时设置 inPort 被拒绝
- 普通用户创建转发时不设置 speedId/inPort 成功
- 普通用户更新转发时设置 speedId 被拒绝
- 普通用户更新转发时设置 inPort 被拒绝
- 普通用户更新转发时不设置 speedId/inPort 成功
@@ -0,0 +1,97 @@
# 010 多入口/多出口/多跳自定义 IP 限制与回归
## 目标
- 修复多入口转发列表只显示一个入口地址的问题。
- 在 UI 和后端同时限制以下场景的自定义 IP:
- 多入口转发禁止自定义监听 IP(`inIp`)。
- 多出口隧道禁止自定义连接 IP(`connectIp`)。
- 转发链单跳多节点禁止自定义连接 IP(`connectIp`)。
## 范围说明(基于当前实际)
- 不改“隧道页面入口 IP 文本域”的行为(按确认:该字段是展示用途,不作为本次约束点)。
- 本次仅覆盖已落地代码与可复现验证项。
## Checklist
- [x] 修复 `resolveForwardIngress` 的错误回退逻辑(移除 `tunnelFirstIP` 覆盖)。
- [x] 前端转发页:多入口隧道禁用“监听IP”选择并显示提示。
- [x] 前端隧道页:多出口禁用“连接IP”选择并显示提示。
- [x] 前端隧道页:转发链单跳多节点禁用“连接IP”选择并显示提示。
- [x] 后端隧道创建/编辑增加 `connectIp` 约束校验(多出口、多节点跳)。
- [x] 后端转发创建/编辑增加 `inIp` 约束校验(多入口)。
- [x] 后端构建验证通过。
- [x] 前端构建验证通过。
- [x] 相关定向合约测试通过(forward/tunnel)。
- [x] 全量 contract 测试执行并记录结果(存在与本次改动无关的既有失败)。
- [ ] 数据迁移脚本(可选):将历史多入口/多出口/多节点的自定义 IP 清理为默认值。
## 实施记录
### 代码变更
- `go-backend/internal/store/repo/repository.go`
- 在 `resolveForwardIngress` 中移除 `tunnelFirstIP` 逻辑。
- `in_ip` 为空时回退到每个入口节点自身 `server_ip`,避免多入口被合并为单入口展示。
- `vite-frontend/src/pages/forward.tsx`
- 新增 `isCurrentTunnelMultiEntrance` 判断。
- 多入口时禁用“监听IP”Select,并展示“多入口隧道使用节点默认IP”。
- `vite-frontend/src/pages/tunnel.tsx`
- 转发链区域新增 `isMultiNodeGroup`,单跳多节点时禁用连接 IP 选择。
- 出口区域新增 `isMultiExit`,多出口时禁用连接 IP 选择。
- `go-backend/internal/http/handler/mutations.go`
- `tunnelCreate` / `tunnelUpdate` 调用 `validateTunnelConnectIPConstraints(req)`。
- 新增 `validateTunnelConnectIPConstraints`:
- 多出口+自定义 `connectIp` 拒绝。
- 转发链单跳多节点+自定义 `connectIp` 拒绝。
- `forwardCreate` / `forwardUpdate`:多入口+自定义 `inIp` 拒绝。
## 验证记录
### 1) 后端构建
```bash
cd go-backend
go build ./internal/http/handler/...
```
结果:通过。
### 2) 前端构建
```bash
cd vite-frontend
npm run build
```
结果:通过。
### 3) 后端包测试
```bash
cd go-backend
go test ./internal/store/repo/...
go test ./internal/http/handler/...
```
结果:通过。
### 4) 定向合约测试(forward/tunnel)
```bash
cd go-backend
go test ./tests/contract/... -run "TestForward.*|TestTunnel.*"
```
结果:通过。
### 5) 全量合约测试(记录)
```bash
cd go-backend
go test ./tests/contract/...
```
结果:所有测试通过。
### 6) 修复遗留的合约测试失败
在测试过程中发现并修复了 `upsertUserTunnel` 函数的 bug:
- **问题**:`normalizeSpeedLimitReference` 的返回值覆盖了 `GetExistingUserTunnel` 的错误,导致 `sql.ErrNoRows` 判断失效。
- **修复**:将 `GetExistingUserTunnel` 的错误保存到 `lookupErr` 变量,避免被后续调用覆盖。
- **影响范围**:仅影响 `userTunnelBatchAssign` 路径,不影响其他功能。
- **验证**:两个失败的测试(`TestUserTunnelReassignmentKeepsStableID`、`TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract`)现在都通过。
## 完成状态
- 本计划按当前实际范围已完成。
- 所有合约测试通过(14/14)。
- 任务 10(数据迁移)已纳入计划,当前为可选项,默认不执行。
@@ -0,0 +1,28 @@
# 011 转发服务名升级兼容与节点滚动升级
## 目标
- 修复旧版本升级后编辑转发/隧道出现 `service not found`(service不存在)的问题。
- 在后端加入兼容自愈逻辑,允许旧命名与新命名共存过渡。
- 给出低风险节点升级顺序,避免一次性全量切换带来的中断。
## Checklist
- [x] 定位回归路径:服务名从 `forward_user_0` 迁移到真实 `user_tunnel_id` 后,与旧运行态不一致导致控制失败。
- [x] 在 `UpdateService` 的兼容路径加入旧服务清理后重建逻辑。
- [x] 在 `Pause/Resume` 控制路径加入首次 not found 后自愈重试逻辑。
- [x] 增加回归测试覆盖兼容行为。
- [x] 执行 `go-backend` 相关测试并记录结果。
- [x] 输出运维侧“后端先行 + agent 灰度升级 + 批量重部署”操作步骤。
## 变更说明(实施中)
- 后端控制面将在检测到升级期的服务名不一致时进行自动自愈,降低人工干预和手工重建成本。
## 测试记录
- 命令:`cd go-backend && go test ./internal/http/handler/...`
- 结果:通过。
## 运维升级顺序(推荐)
1. 先发布本次后端兼容补丁(无需等待所有 agent 同步升级)。
2. 按 10%-20% 灰度分批升级 agent(低风险节点 -> 非高峰节点 -> 全量)。
3. 每批升级后执行一次“转发批量重部署”,将运行态统一到新服务命名。
4. 观察日志中 `service .* not found` 是否清零,再推进下一批。
5. 全量稳定后保留兼容逻辑至少一个小版本周期,再评估收敛。
+171 -94
View File
@@ -670,6 +670,16 @@ export default function ForwardPage() {
return tunnelInIpOptionMap.get(form.tunnelId) || [];
}, [form.tunnelId, tunnelInIpOptionMap]);
const isCurrentTunnelMultiEntrance = useMemo(() => {
if (!form.tunnelId) {
return false;
}
const currentTunnel = allTunnels.find((tunnel) => tunnel.id === form.tunnelId);
return (currentTunnel?.inNodeId?.length || 0) > 1;
}, [allTunnels, form.tunnelId]);
useEffect(() => {
return () => {
diagnosisAbortRef.current?.abort();
@@ -1202,6 +1212,10 @@ export default function ForwardPage() {
);
}, [speedLimits]);
const speedLimitIds = useMemo(() => {
return new Set(speedLimits.map((speedLimit) => speedLimit.id));
}, [speedLimits]);
const availableSpeedLimits = useMemo(() => {
return speedLimits.filter(
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
@@ -1213,7 +1227,27 @@ export default function ForwardPage() {
return null;
}
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
if (noLimitSpeedLimitIds.has(speedId)) {
return null;
}
if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) {
return null;
}
return speedId;
};
const isMissingSpeedLimit = (speedId?: number | null): boolean => {
if (speedId === null || speedId === undefined) {
return false;
}
if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) {
return false;
}
return !speedLimitIds.has(speedId);
};
const selectedSpeedId = normalizeSpeedId(form.speedId);
@@ -1388,19 +1422,19 @@ export default function ForwardPage() {
const addressCount = processedRemoteAddr.split(",").length;
let res: { code: number; msg: string };
const normalizedSpeedId = normalizeSpeedId(form.speedId);
const speedLimitAutoCleared = isMissingSpeedLimit(form.speedId);
if (isEdit) {
// 更新时确保包含必要字段
const updateData = {
id: form.id,
userId: form.userId,
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
...(isAdmin ? { inPort: form.inPort } : {}),
...(inIpTouched ? { inIp: form.inIp || "" } : {}),
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizeSpeedId(form.speedId),
...(isAdmin ? { speedId: normalizedSpeedId } : {}),
};
res = await updateForward(updateData);
@@ -1408,17 +1442,37 @@ export default function ForwardPage() {
const createData = {
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
...(isAdmin ? { inPort: form.inPort } : {}),
inIp: form.inIp || undefined,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizeSpeedId(form.speedId),
...(isAdmin ? { speedId: normalizedSpeedId } : {}),
};
res = await createForward(createData);
}
if (res.code === 0) {
const warningItems = Array.isArray((res as any).data?.warnings)
? (res as any).data.warnings
.map((item: unknown) =>
typeof item === "string" ? item.trim() : "",
)
.filter((item: string) => item)
: [];
warningItems.forEach((warning: string) => {
toast(warning, {
icon: "⚠️",
duration: 5000,
});
});
if (speedLimitAutoCleared) {
toast("所选限速规则不存在,已自动清除为不限速", {
icon: "⚠️",
duration: 5000,
});
}
toast.success(isEdit ? "修改成功" : "创建成功");
setModalOpen(false);
loadData();
@@ -2133,15 +2187,27 @@ export default function ForwardPage() {
}
}
const oldIndex = forwardOrder.indexOf(activeId);
const newIndex = forwardOrder.indexOf(overId);
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);
}
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const newOrder = arrayMove(forwardOrder, oldIndex, newIndex);
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
setForwardOrder(newOrder);
saveOrder(FORWARD_ORDER_KEY, newOrder);
if (!compactMode) {
setForwardOrder(newOrder);
saveOrder(FORWARD_ORDER_KEY, newOrder);
}
// 持久化到数据库
try {
@@ -3735,36 +3801,35 @@ export default function ForwardPage() {
sensors={sensors}
onDragEnd={handleDragEnd}
>
<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",
}}
<SortableContext
items={sortableForwardIds}
strategy={verticalListSortingStrategy}
>
<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}
>
<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) => (
<SortableCompactTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
@@ -3781,10 +3846,10 @@ export default function ForwardPage() {
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
)}
</TableBody>
</Table>
</SortableContext>
</DndContext>
</div>
) : (
@@ -4203,35 +4268,37 @@ export default function ForwardPage() {
}
/>
<Select
label="限速规则"
placeholder="不限速"
selectedKeys={
selectedSpeedId !== null
? [selectedSpeedId.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
{isAdmin && (
<Select
label="限速规则"
placeholder="不限速"
selectedKeys={
selectedSpeedId !== null
? [selectedSpeedId.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
setForm((prev) => ({
...prev,
speedId: selectedKey ? Number(selectedKey) : null,
}));
}}
>
{availableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
))}
</Select>
setForm((prev) => ({
...prev,
speedId: selectedKey ? Number(selectedKey) : null,
}));
}}
>
{availableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
))}
</Select>
)}
<Select
description={
@@ -4260,33 +4327,43 @@ export default function ForwardPage() {
))}
</Select>
<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;
{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="从入口节点IP中选择,留空使用默认"
description={
isCurrentTunnelMultiEntrance
? "多入口隧道不支持自定义监听IP,使用各节点默认IP"
: "从入口节点IP中选择,留空使用默认"
}
isDisabled={
!form.tunnelId || currentTunnelIpOptions.length === 0
!form.tunnelId ||
currentTunnelIpOptions.length === 0 ||
isCurrentTunnelMultiEntrance
}
label="监听IP"
placeholder={
form.tunnelId
isCurrentTunnelMultiEntrance
? "多入口隧道使用节点默认IP"
: form.tunnelId
? currentTunnelIpOptions.length > 0
? "选择入口监听IP"
: "当前隧道入口节点暂无可选IP"
+95 -82
View File
@@ -1604,6 +1604,8 @@ export default function TunnelPage() {
.map((ct) => ct.nodeId);
const groupIpOptions =
getCommonIpOptions(groupSelectedNodeIds);
const isMultiNodeGroup =
groupSelectedNodeIds.length > 1;
const selectedGroupConnectIp =
groupNodes.length > 0
? groupNodes[0].connectIp || ""
@@ -1826,14 +1828,21 @@ export default function TunnelPage() {
label: "text-xs",
value: "text-sm",
}}
description="按当前跳所选节点的共有IP进行选择,留空使用默认"
description={
isMultiNodeGroup
? "多节点跳不支持设置自定义连接IP,使用各节点默认IP"
: "按当前跳所选节点的共有IP进行选择,留空使用默认"
}
isDisabled={
groupSelectedNodeIds.length === 0 ||
groupIpOptions.length === 0
groupIpOptions.length === 0 ||
isMultiNodeGroup
}
label="连接IP"
placeholder={
groupSelectedNodeIds.length === 0
isMultiNodeGroup
? "多节点跳使用节点默认IP"
: groupSelectedNodeIds.length === 0
? "请先选择节点"
: groupIpOptions.length > 0
? "选择连接IP"
@@ -1886,7 +1895,17 @@ export default function TunnelPage() {
<Divider />
<h3 className="text-lg font-semibold">出口配置</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
{(() => {
const selectedOutNodeIds = (form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId);
const isMultiExit = selectedOutNodeIds.length > 1;
const commonOutIpOptions =
getCommonIpOptions(selectedOutNodeIds);
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
@@ -2124,88 +2143,82 @@ export default function TunnelPage() {
<SelectItem key="round">轮询</SelectItem>
<SelectItem key="rand">随机</SelectItem>
</Select>
</div>
</div>
{/* 连接IP - 出口节点 */}
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
description="按出口节点共同可用IP选择,留空使用默认"
isDisabled={
(form.outNodeId || []).filter(
(ct) => ct.nodeId !== -1,
).length === 0 ||
getCommonIpOptions(
(form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId),
).length === 0
}
label="连接IP"
placeholder={
(form.outNodeId || []).filter(
(ct) => ct.nodeId !== -1,
).length === 0
? "请先选择出口节点"
: getCommonIpOptions(
(form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId),
).length > 0
? "选择连接IP"
: "所选节点无共同可选IP"
}
selectedKeys={[
form.outNodeId && form.outNodeId.length > 0
? form.outNodeId[0].connectIp || "__default__"
: "__default__",
]}
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
const value =
selectedKey === "__default__" ? "" : selectedKey;
{/* 连接IP - 出口节点 */}
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
description={
isMultiExit
? "多出口隧道不支持设置自定义连接IP,使用各节点默认IP"
: "按出口节点共同可用IP选择,留空使用默认"
}
isDisabled={
selectedOutNodeIds.length === 0 ||
commonOutIpOptions.length === 0 ||
isMultiExit
}
label="连接IP"
placeholder={
isMultiExit
? "多出口隧道使用节点默认IP"
: selectedOutNodeIds.length === 0
? "请先选择出口节点"
: commonOutIpOptions.length > 0
? "选择连接IP"
: "所选节点无共同可选IP"
}
selectedKeys={[
form.outNodeId && form.outNodeId.length > 0
? form.outNodeId[0].connectIp || "__default__"
: "__default__",
]}
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
const value =
selectedKey === "__default__" ? "" : selectedKey;
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
if (currentOutNodes.length === 0) {
return {
...prev,
outNodeId: [
{
nodeId: -1,
chainType: 3,
protocol: "tls",
strategy: "round",
connectIp: value,
},
],
};
}
if (currentOutNodes.length === 0) {
return {
...prev,
outNodeId: [
{
nodeId: -1,
chainType: 3,
protocol: "tls",
strategy: "round",
connectIp: value,
},
],
};
}
return {
...prev,
outNodeId: currentOutNodes.map((ct) => ({
...ct,
connectIp: value,
})),
};
});
}}
>
<SelectItem key="__default__">默认连接IP</SelectItem>
{getCommonIpOptions(
(form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId),
).map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem>
))}
</Select>
return {
...prev,
outNodeId: currentOutNodes.map((ct) => ({
...ct,
connectIp: value,
})),
};
});
}}
>
<SelectItem key="__default__">默认连接IP</SelectItem>
{commonOutIpOptions.map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem>
))}
</Select>
</>
);
})()}
</>
)}
</div>
+48 -2
View File
@@ -227,12 +227,36 @@ export default function UserPage() {
);
}, [speedLimits]);
const speedLimitIds = useMemo(() => {
return new Set(speedLimits.map((speedLimit) => speedLimit.id));
}, [speedLimits]);
const normalizeSpeedId = (speedId?: number | null): number | null => {
if (speedId === null || speedId === undefined) {
return null;
}
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
if (noLimitSpeedLimitIds.has(speedId)) {
return null;
}
if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) {
return null;
}
return speedId;
};
const isMissingSpeedLimit = (speedId?: number | null): boolean => {
if (speedId === null || speedId === undefined) {
return false;
}
if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) {
return false;
}
return !speedLimitIds.has(speedId);
};
// 生命周期
@@ -446,11 +470,20 @@ export default function UserPage() {
setAssignLoading(true);
try {
let speedLimitAutoCleared = false;
const tunnelsToAssign: TunnelAssignItem[] = Array.from(
batchTunnelSelections.entries(),
).map(([tunnelId, speedId]) => ({
tunnelId,
speedId: normalizeSpeedId(speedId),
speedId: (() => {
const cleared = normalizeSpeedId(speedId);
if (isMissingSpeedLimit(speedId)) {
speedLimitAutoCleared = true;
}
return cleared;
})(),
}));
const response = await batchAssignUserTunnel({
@@ -459,6 +492,12 @@ export default function UserPage() {
});
if (response.code === 0) {
if (speedLimitAutoCleared) {
toast("所选限速规则不存在,已自动清除为不限速", {
icon: "⚠️",
duration: 5000,
});
}
toast.success(response.msg || "分配成功");
setBatchTunnelSelections(new Map());
loadUserTunnels(currentUser.id);
@@ -486,6 +525,7 @@ export default function UserPage() {
setEditTunnelLoading(true);
try {
const speedLimitAutoCleared = isMissingSpeedLimit(editTunnelForm.speedId);
const response = await updateUserTunnel({
id: editTunnelForm.id,
flow: editTunnelForm.flow,
@@ -497,6 +537,12 @@ export default function UserPage() {
});
if (response.code === 0) {
if (speedLimitAutoCleared) {
toast("所选限速规则不存在,已自动清除为不限速", {
icon: "⚠️",
duration: 5000,
});
}
toast.success("更新成功");
onEditTunnelModalClose();
if (currentUser) {