Compare commits

...

11 Commits

Author SHA1 Message Date
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
sagit 2ff52e3275 feat: 2.1.7-beta4 release - forward service stability and UI enhancements (#257)
## Summary

This PR consolidates multiple features and fixes for the 2.1.7-beta4
release:

**Forward Service Stability:**
- Handle forward service rebind on self-occupied port conflicts
- Preserve bind IP when editing forward without explicit inIp change

**Import Enhancements:**
- Add ny format support for forward import with node-based tunnel
matching
- Add ny import compatibility with auto port assignment

**Custom IP Selection:**
- Add custom IP selection for nodes, tunnels, and forwards
- Use configured connectIp for tunnel chain diagnosis

**UI Improvements:**
- Add tunnel group collapse and drag sorting in full mode
- Add global compact mode with alpha8 list layout
- Expose forward compact mode switch in settings

**Infrastructure:**
- Add comprehensive migration test for legacy columns

## Commits

- 7efb49b fix: handle forward service rebind on self-occupied port
- 1450b25 fix: preserve bind IP when editing forward without explicit
inIp change
- 7c54192 feat: add ny import compatibility with auto port assignment
- 7ba6877 refactor: simplify forward import tunnel selection
- ef613c1 feat: add ny format support for forward import with node-based
tunnel matching
- 1c10347 fix: use configured connectIp for tunnel chain diagnosis
- e383359 fix: apply custom IP binding to forward and tunnel chain
services
- 9cf9f4f feat: add comprehensive migration test for legacy columns
- b819341 feat: add custom IP selection for nodes, tunnels, and forwards
- 634c6cd feat(forward): add tunnel group collapse and drag sorting in
full mode
- 98a9e5c fix(config): expose forward compact mode switch in settings
- 77e4387 feat(forward): add global compact mode with alpha8 list layout
2026-03-03 20:54:26 +08:00
sagitchu 7efb49bdab fix: handle forward service rebind on self-occupied port
When UpdateService encounters bind address conflicts (port already in use),
the handler now automatically deletes existing forward services and retries
the AddService operation. This resolves issues where a forward's own stale
listener prevents the update.

- Add isBindAddressInUseError() to detect port bind conflicts
- Add rebindForwardServiceOnSelfOccupiedPort() for automatic cleanup and retry
- Add HasOtherForwardOnNodePort() repository method to verify port ownership
- Add unit tests for bind conflict detection
2026-03-03 20:53:55 +08:00
sagit a00b20abf3 feat: forward management enhancements and bind IP preservation (#256)
## Summary
- Fix bind IP preservation when editing forwards without explicit inIp
changes
- Add ny format import support with node-based tunnel matching and auto
port assignment
- Add custom IP selection for nodes, tunnels, and forwards
- Add tunnel group collapse and drag sorting in full mode
- Add global compact mode with alpha8 list layout
- Various bug fixes and improvements

## Test plan
- [x] Unit tests for forward port replacement with preserved InIP
- [x] Manual testing of forward edit flow
- [x] Verified bind IP is preserved when editing forwards without
touching the inIp field
2026-03-03 20:23:29 +08:00
sagitchu 1450b25475 fix: preserve bind IP when editing forward without explicit inIp change
- Add replaceForwardPortsPreservingInIP to maintain existing InIP values
- Track inIpTouched state in frontend to distinguish user changes
- Only send inIp in update request when user explicitly changed it
- Add unit tests for forward port replacement with preserved InIP
2026-03-03 20:22:58 +08:00
65 changed files with 94947 additions and 225 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 支持带端口的地址、添加了全面的测试覆盖。
+162 -10
View File
@@ -223,21 +223,27 @@ 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)
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
var limiterID *int64
@@ -258,7 +264,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
var utSpeed *int
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
return nil, err
}
limiterID = utLimiterID
speed = utSpeed
@@ -267,28 +273,138 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
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" {
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
}
if err != nil {
return fmt.Errorf("节点 %s 下发失败: %w", node.Name, 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 warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
}
return warnings, nil
}
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
if h == nil || forward == nil || tunnel == nil || node == nil {
return "", errors.New("invalid bind fallback context")
}
if fp.Port <= 0 {
return "", errors.New("invalid forward port")
}
explicitBindIP := strings.TrimSpace(fp.InIP)
if explicitBindIP == "" {
return "", errors.New("default bind address cannot be assigned")
}
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
return "", err
}
time.Sleep(150 * time.Millisecond)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
return "", err
}
if err := h.repo.UpdateForwardPortBindIP(forward.ID, node.ID, fp.Port, ""); err != nil {
return "", err
}
warning := fmt.Sprintf("节点 %s 监听IP %s 不在主机网卡地址中,已自动回退为默认监听IP", strings.TrimSpace(node.Name), explicitBindIP)
return warning, nil
}
func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord, node *nodeRecord, port int, services []map[string]interface{}) error {
if h == nil || forward == nil || node == nil {
return errors.New("invalid self-occupy rebind context")
}
if port <= 0 {
return errors.New("invalid forward port")
}
hasOtherForward, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, forward.ID)
if err != nil {
return err
}
if hasOtherForward {
return fmt.Errorf("端口 %d 已被其他转发占用", port)
}
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
if err != nil {
return err
}
return nil
}
func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int64) error {
if h == nil || forward == nil {
return errors.New("invalid forward delete context")
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
if err != nil {
return err
}
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
var lastErr error
for _, base := range bases {
names := buildForwardControlServiceNames(base, "DeleteService")
payload := map[string]interface{}{
"services": names,
}
_, cmdErr := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
if cmdErr == nil {
return nil
}
lastErr = cmdErr
}
if lastErr != nil {
return lastErr
}
return nil
}
@@ -1303,6 +1419,42 @@ func isAlreadyExistsMessage(message string) bool {
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
}
func isBindAddressInUseError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if msg == "" {
return false
}
return isAddressAlreadyInUseMessage(msg) || strings.Contains(msg, "cannot assign requested address")
}
func isAddressAlreadyInUseError(err error) bool {
if err == nil {
return false
}
return isAddressAlreadyInUseMessage(strings.ToLower(strings.TrimSpace(err.Error())))
}
func isAddressAlreadyInUseMessage(msg string) bool {
if msg == "" {
return false
}
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")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"reflect"
"testing"
)
@@ -66,6 +67,39 @@ func TestIsAlreadyExistsMessage(t *testing.T) {
}
}
func TestIsBindAddressInUseError(t *testing.T) {
if !isBindAddressInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if !isBindAddressInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if isBindAddressInUseError(errors.New("service demo already exists")) {
t.Fatalf("already exists should not be treated as bind conflict")
}
if isBindAddressInUseError(nil) {
t.Fatalf("nil error should not be treated as bind conflict")
}
}
func TestIsAddressAlreadyInUseError(t *testing.T) {
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if isAddressAlreadyInUseError(errors.New("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: "[::]"}
+155 -55
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 {
@@ -1285,8 +1312,17 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
port = h.pickTunnelPort(tunnelID)
}
}
inIp := asString(req["inIp"])
hasInIP := false
inIp := ""
if rawInIP, ok := req["inIp"]; ok {
hasInIP = true
inIp = asString(rawInIP)
}
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
if hasInIP && strings.TrimSpace(inIp) != "" && len(fwdEntryNodes) > 1 {
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
return
}
for _, nodeID := range fwdEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
@@ -1302,7 +1338,14 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {
if hasInIP {
err = h.replaceForwardPorts(id, tunnelID, port, inIp)
} else if tunnelID != forward.TunnelID {
err = h.replaceForwardPorts(id, tunnelID, port, "")
} else {
err = h.replaceForwardPortsPreservingInIP(id, tunnelID, port, oldPorts)
}
if err != nil {
h.rollbackForwardMutation(forward, oldPorts)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1313,11 +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())
}
@@ -2160,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
@@ -3016,38 +3090,62 @@ func parsePorts(portRange string) ([]int, error) {
return ports, nil
}
type forwardPortReplaceEntry = struct {
NodeID int64
Port int
InIP string
}
func buildForwardPortEntriesWithPreservedInIP(entryNodeIDs []int64, oldPorts []forwardPortRecord, port int) []forwardPortReplaceEntry {
preservedByNode := make(map[int64]string)
for _, fp := range oldPorts {
current, exists := preservedByNode[fp.NodeID]
if !exists {
preservedByNode[fp.NodeID] = fp.InIP
continue
}
if strings.TrimSpace(current) == "" && strings.TrimSpace(fp.InIP) != "" {
preservedByNode[fp.NodeID] = fp.InIP
}
}
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
entries = append(entries, forwardPortReplaceEntry{
NodeID: nid,
Port: port,
InIP: preservedByNode[nid],
})
}
return entries
}
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
entries := make([]struct {
NodeID int64
Port int
InIP string
}, len(entryNodes))
entries := make([]forwardPortReplaceEntry, len(entryNodes))
for i, nid := range entryNodes {
entries[i] = struct {
NodeID int64
Port int
InIP string
}{NodeID: nid, Port: port, InIP: inIp}
entries[i] = forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIp}
}
return h.repo.ReplaceForwardPorts(forwardID, entries)
}
func (h *Handler) replaceForwardPortsPreservingInIP(forwardID, tunnelID int64, port int, oldPorts []forwardPortRecord) error {
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
entries := buildForwardPortEntriesWithPreservedInIP(entryNodes, oldPorts, port)
return h.repo.ReplaceForwardPorts(forwardID, entries)
}
func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {
entries := make([]struct {
NodeID int64
Port int
InIP string
}, len(ports))
entries := make([]forwardPortReplaceEntry, len(ports))
for i, fp := range ports {
entries[i] = struct {
NodeID int64
Port int
InIP string
}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
entries[i] = forwardPortReplaceEntry{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
}
return h.repo.ReplaceForwardPorts(forwardID, entries)
}
@@ -3078,11 +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
}
@@ -3092,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 {
@@ -3145,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
@@ -3222,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{} {
@@ -0,0 +1,39 @@
package handler
import "testing"
func TestBuildForwardPortEntriesWithPreservedInIP(t *testing.T) {
entryNodeIDs := []int64{10, 20, 30}
oldPorts := []forwardPortRecord{
{NodeID: 10, Port: 10001, InIP: ""},
{NodeID: 10, Port: 10002, InIP: "10.0.0.10"},
{NodeID: 20, Port: 10003, InIP: "10.0.0.20"},
}
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, 18080)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
if entries[0].NodeID != 10 || entries[0].Port != 18080 || entries[0].InIP != "10.0.0.10" {
t.Fatalf("unexpected first entry: %+v", entries[0])
}
if entries[1].NodeID != 20 || entries[1].Port != 18080 || entries[1].InIP != "10.0.0.20" {
t.Fatalf("unexpected second entry: %+v", entries[1])
}
if entries[2].NodeID != 30 || entries[2].Port != 18080 || entries[2].InIP != "" {
t.Fatalf("unexpected third entry: %+v", entries[2])
}
}
func TestBuildForwardPortEntriesWithPreservedInIP_EmptyOldPorts(t *testing.T) {
entryNodeIDs := []int64{99}
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, nil, 17000)
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].NodeID != 99 || entries[0].Port != 17000 || entries[0].InIP != "" {
t.Fatalf("unexpected entry: %+v", entries[0])
}
}
@@ -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)
}
@@ -111,6 +111,25 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
return rows, nil
}
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
if nodeID <= 0 || port <= 0 {
return false, nil
}
var count int64
err := r.db.Model(&model.ForwardPort{}).
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
@@ -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 @@
# 003 Forward Edit Bind IP Preserve
## Checklist
- [x] Confirm forward edit flow and identify why untouched listen IP gets overwritten.
- [x] Update frontend forward edit submit logic to only send `inIp` when user explicitly changes listen IP.
- [x] On tunnel switch in edit form, reset listen IP to default unless user reselects.
- [x] Update backend forward update logic to preserve existing `forward_port.in_ip` when request omits `inIp` and tunnel is unchanged.
- [x] Keep backend behavior explicit: if `inIp` is sent (including empty), apply requested value; if tunnel changed with no `inIp`, use default bind.
- [x] Add regression tests for preserved bind-IP reconstruction helper behavior.
- [x] Run focused frontend/backend checks for touched files.
@@ -0,0 +1,11 @@
# 004 Forward Explicit Bind Self-Occupy Release
## Checklist
- [x] Confirm current forward edit/save failure path and lock strategy: explicit bind always stays explicit.
- [x] Add repository query to detect whether a node+port is occupied by other forwards (excluding current forward).
- [x] Enhance forward service sync to treat address-in-use as a recoverable case when only self occupies the port.
- [x] On self-occupy conflict, proactively delete current forward services on target node and retry AddService.
- [x] Keep hard failure when the same node+port is occupied by other forwards.
- [x] Add focused unit tests for new error classification helpers.
- [x] Run focused backend tests for touched handler/repo packages.
@@ -0,0 +1,11 @@
# 005 Forward Invalid BindIP Fallback Default
## Checklist
- [x] Split forward service bind failures into address-in-use and cannot-assign classes.
- [x] Keep self-occupy release/rebind only for address-in-use conflicts.
- [x] Add fallback path for cannot-assign: switch to default listener bind and retry service creation.
- [x] Persist fallback result to DB by clearing `forward_port.in_ip` for affected node+port.
- [x] Return non-blocking warning in forward update response when fallback occurs.
- [x] Show warning toast in forward edit UI while still treating operation as success.
- [x] Run focused backend tests for touched handler/repo packages.
@@ -0,0 +1,8 @@
# 006 Forward Save Missing Speed Limit Auto Clear
## Checklist
- [x] Locate forward create/update speed limit validation path that blocks save when speed rule is deleted.
- [x] Change forward save behavior to auto-clear missing `speedId` instead of returning "限速规则不存在".
- [x] Add contract test coverage for editing a forward after its referenced speed limit is deleted.
- [x] Run focused contract tests for forward save behavior.
@@ -0,0 +1,8 @@
# 007 User Tunnel Save Missing Speed Limit Auto Clear
## Checklist
- [x] Locate user tunnel speed limit validation paths for assign/update flows.
- [x] Change user tunnel save behavior to auto-clear missing `speedId` instead of failing.
- [x] Add contract test coverage for user tunnel save when referenced speed limit is deleted.
- [x] Run focused contract tests for user tunnel save behavior.
@@ -0,0 +1,8 @@
# 008 Frontend Missing Speed Limit Consistency
## Checklist
- [x] Review forward and user tunnel submit flows for missing speed limit behavior.
- [x] Make frontend normalize deleted `speedId` to `null` before submit in both pages.
- [x] Add consistent non-blocking warning toast when deleted speed rule is auto-cleared.
- [x] Verify touched frontend files pass lint checks.
@@ -0,0 +1,112 @@
# 009: 普通用户转发权限限制
## 背景
当前系统允许普通用户在创建和编辑转发时设置:
1. **限速规则** (`speedId`) - 应仅限管理员设置
2. **自定义入口端口** (`inPort`) - 应仅限管理员设置
普通用户应只能使用系统自动分配的端口和默认不限速设置。
## 实施范围
| 操作 | 普通用户 | 管理员 |
|------|----------|--------|
| 创建转发 - 设置限速 | 禁止 | 允许 |
| 创建转发 - 自定义端口 | 禁止 | 允许 |
| 编辑转发 - 修改限速 | 禁止 | 允许 |
| 编辑转发 - 修改端口 | 禁止 | 允许 |
## 修改位置
### 后端 (Go)
**文件**: `go-backend/internal/http/handler/mutations.go`
#### 1. `forwardCreate` handler (行 1147-1157)
在处理 speedId 和 inPort 之前添加权限检查:
```go
if roleID != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
return
}
}
```
#### 2. `forwardUpdate` handler (行 1264-1274)
在处理 speedId 和 inPort 之前添加权限检查:
```go
if actorRole != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
return
}
}
```
### 前端 (React/TypeScript)
**文件**: `vite-frontend/src/pages/forward.tsx`
已有变量 `isAdmin` (行 610: `const isAdmin = tokenRoleId === 0;`)
#### 1. 隐藏限速规则选择器 (行 4252-4282)
用条件渲染包裹:
```tsx
{isAdmin && (
<Select
label="限速规则"
// ... 现有属性
>
{/* ... */}
</Select>
)}
```
#### 2. 隐藏入口端口输入框 (行 4311-4328)
用条件渲染包裹:
```tsx
{isAdmin && (
<Input
description="指定入口端口,留空则从节点可用端口中自动分配"
// ... 现有属性
/>
)}
```
## 任务清单
- [x] 后端: `forwardCreate` 添加权限检查
- [x] 后端: `forwardUpdate` 添加权限检查
- [x] 前端: 隐藏限速规则选择器 (仅管理员可见)
- [x] 前端: 隐藏入口端口输入框 (仅管理员可见)
- [x] 后端: 添加契约测试验证权限限制
- [x] 运行测试验证
## 测试验证
1. ✅ 契约测试已添加 `TestNonAdminCannotSetSpeedIdOrPort`
2. ✅ 所有测试用例通过:
- 普通用户创建转发时设置 speedId 被拒绝
- 普通用户创建转发时设置 inPort 被拒绝
- 普通用户创建转发时不设置 speedId/inPort 成功
- 普通用户更新转发时设置 speedId 被拒绝
- 普通用户更新转发时设置 inPort 被拒绝
- 普通用户更新转发时不设置 speedId/inPort 成功
@@ -0,0 +1,97 @@
# 010 多入口/多出口/多跳自定义 IP 限制与回归
## 目标
- 修复多入口转发列表只显示一个入口地址的问题。
- 在 UI 和后端同时限制以下场景的自定义 IP:
- 多入口转发禁止自定义监听 IP(`inIp`)。
- 多出口隧道禁止自定义连接 IP(`connectIp`)。
- 转发链单跳多节点禁止自定义连接 IP(`connectIp`)。
## 范围说明(基于当前实际)
- 不改“隧道页面入口 IP 文本域”的行为(按确认:该字段是展示用途,不作为本次约束点)。
- 本次仅覆盖已落地代码与可复现验证项。
## Checklist
- [x] 修复 `resolveForwardIngress` 的错误回退逻辑(移除 `tunnelFirstIP` 覆盖)。
- [x] 前端转发页:多入口隧道禁用“监听IP”选择并显示提示。
- [x] 前端隧道页:多出口禁用“连接IP”选择并显示提示。
- [x] 前端隧道页:转发链单跳多节点禁用“连接IP”选择并显示提示。
- [x] 后端隧道创建/编辑增加 `connectIp` 约束校验(多出口、多节点跳)。
- [x] 后端转发创建/编辑增加 `inIp` 约束校验(多入口)。
- [x] 后端构建验证通过。
- [x] 前端构建验证通过。
- [x] 相关定向合约测试通过(forward/tunnel)。
- [x] 全量 contract 测试执行并记录结果(存在与本次改动无关的既有失败)。
- [ ] 数据迁移脚本(可选):将历史多入口/多出口/多节点的自定义 IP 清理为默认值。
## 实施记录
### 代码变更
- `go-backend/internal/store/repo/repository.go`
- 在 `resolveForwardIngress` 中移除 `tunnelFirstIP` 逻辑。
- `in_ip` 为空时回退到每个入口节点自身 `server_ip`,避免多入口被合并为单入口展示。
- `vite-frontend/src/pages/forward.tsx`
- 新增 `isCurrentTunnelMultiEntrance` 判断。
- 多入口时禁用“监听IP”Select,并展示“多入口隧道使用节点默认IP”。
- `vite-frontend/src/pages/tunnel.tsx`
- 转发链区域新增 `isMultiNodeGroup`,单跳多节点时禁用连接 IP 选择。
- 出口区域新增 `isMultiExit`,多出口时禁用连接 IP 选择。
- `go-backend/internal/http/handler/mutations.go`
- `tunnelCreate` / `tunnelUpdate` 调用 `validateTunnelConnectIPConstraints(req)`。
- 新增 `validateTunnelConnectIPConstraints`:
- 多出口+自定义 `connectIp` 拒绝。
- 转发链单跳多节点+自定义 `connectIp` 拒绝。
- `forwardCreate` / `forwardUpdate`:多入口+自定义 `inIp` 拒绝。
## 验证记录
### 1) 后端构建
```bash
cd go-backend
go build ./internal/http/handler/...
```
结果:通过。
### 2) 前端构建
```bash
cd vite-frontend
npm run build
```
结果:通过。
### 3) 后端包测试
```bash
cd go-backend
go test ./internal/store/repo/...
go test ./internal/http/handler/...
```
结果:通过。
### 4) 定向合约测试(forward/tunnel)
```bash
cd go-backend
go test ./tests/contract/... -run "TestForward.*|TestTunnel.*"
```
结果:通过。
### 5) 全量合约测试(记录)
```bash
cd go-backend
go test ./tests/contract/...
```
结果:所有测试通过。
### 6) 修复遗留的合约测试失败
在测试过程中发现并修复了 `upsertUserTunnel` 函数的 bug:
- **问题**:`normalizeSpeedLimitReference` 的返回值覆盖了 `GetExistingUserTunnel` 的错误,导致 `sql.ErrNoRows` 判断失效。
- **修复**:将 `GetExistingUserTunnel` 的错误保存到 `lookupErr` 变量,避免被后续调用覆盖。
- **影响范围**:仅影响 `userTunnelBatchAssign` 路径,不影响其他功能。
- **验证**:两个失败的测试(`TestUserTunnelReassignmentKeepsStableID`、`TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract`)现在都通过。
## 完成状态
- 本计划按当前实际范围已完成。
- 所有合约测试通过(14/14)。
- 任务 10(数据迁移)已纳入计划,当前为可选项,默认不执行。
+138 -61
View File
@@ -586,6 +586,7 @@ export default function ForwardPage() {
strategy: "fifo",
speedId: null,
});
const [inIpTouched, setInIpTouched] = useState(false);
// 表单验证错误
const [errors, setErrors] = useState<{ [key: string]: string }>({});
@@ -669,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();
@@ -1201,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),
@@ -1212,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);
@@ -1276,6 +1311,7 @@ export default function ForwardPage() {
// 新增转发
const handleAdd = () => {
setIsEdit(false);
setInIpTouched(false);
setForm({
name: "",
tunnelId: null,
@@ -1293,6 +1329,7 @@ export default function ForwardPage() {
// 编辑转发
const handleEdit = (forward: Forward) => {
setIsEdit(true);
setInIpTouched(false);
setForm({
id: forward.id,
userId: forward.userId,
@@ -1357,11 +1394,17 @@ export default function ForwardPage() {
const nextTunnelId = parseInt(tunnelId);
const options = tunnelInIpOptionMap.get(nextTunnelId) || [];
setForm((prev) => ({
...prev,
tunnelId: nextTunnelId,
inIp: options.includes(prev.inIp) ? prev.inIp : "",
}));
setInIpTouched(false);
setForm((prev) => {
const tunnelChanged = prev.tunnelId !== nextTunnelId;
return {
...prev,
tunnelId: nextTunnelId,
inIp: tunnelChanged ? "" : options.includes(prev.inIp) ? prev.inIp : "",
};
});
};
// 提交表单
@@ -1379,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,
inIp: form.inIp || undefined,
...(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);
@@ -1399,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();
@@ -4194,35 +4257,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={
@@ -4251,33 +4316,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"
@@ -4288,6 +4363,8 @@ export default function ForwardPage() {
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
setInIpTouched(true);
setForm((prev) => ({
...prev,
inIp: selectedKey === "__default__" ? "" : selectedKey,
@@ -115,7 +115,10 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
return { line, error: "listen_port格式错误,应为1-65535之间的数字" };
}
if (normalizedListenPort !== null && !isValidListenPort(normalizedListenPort)) {
if (
normalizedListenPort !== null &&
!isValidListenPort(normalizedListenPort)
) {
return { line, error: "listen_port必须为1-65535之间的数字" };
}
+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) {