mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e96a8de72 | |||
| 69faeaa9a6 | |||
| e8bfe52104 | |||
| 9767cc3247 | |||
| e8a7f999c8 | |||
| 4d4f5f8b1f | |||
| d2a425d761 | |||
| 673d38a089 | |||
| 2e8c0530a9 | |||
| 32ee511eac | |||
| f410640862 | |||
| 6427b830ea | |||
| 5e7bf3ba5c | |||
| 27d6691232 | |||
| cc4b8a916a | |||
| d9dd5131b2 | |||
| a98c9f4f59 | |||
| 5cb935e0e5 | |||
| 6f59e4be0c | |||
| 647446a2a2 | |||
| 42d6249af5 | |||
| de9ab51def | |||
| a2ec08f033 | |||
| f8809d73fb | |||
| 413081f72a | |||
| e5339a8072 | |||
| fbb4d82a44 | |||
| 508a37a84c | |||
| d60655045a | |||
| 31ef861504 | |||
| f1bdb2e2ef | |||
| 61b71a11c7 | |||
| 4c69ff491d | |||
| 0ad4904e20 | |||
| bd30b61018 | |||
| e0dd70a054 | |||
| 4966a8aad1 | |||
| 3e11549370 | |||
| addf83a249 | |||
| c3e35fd416 | |||
| 775dfe19f1 | |||
| db3b2f651b | |||
| 669323f926 | |||
| 7202b69e4e | |||
| 31977a62e6 | |||
| 87479c2ac1 | |||
| ffda0fb71a | |||
| 9c0e7341c3 | |||
| 1db5452be9 | |||
| c10f894afd | |||
| 7fb75baa73 | |||
| 15e6cd69eb | |||
| f6eb88d75e | |||
| f45b580984 | |||
| 4f50c47550 |
@@ -0,0 +1,30 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_32f7e3b05ffeK8IxNvZAZISGWO
|
||||
Commit Message: 编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
### Prompt 2
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
### Prompt 3
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
### Prompt 4
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
### Prompt 5
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
## Summary
|
||||
|
||||
已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
---
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
---
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
---
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
|
||||
---
|
||||
|
||||
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
|
||||
@@ -0,0 +1 @@
|
||||
已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr
|
||||
@@ -0,0 +1,6 @@
|
||||
✅ 已完成!
|
||||
- 提交: `e7548e7` - style(frontend): format code and improve node card layout
|
||||
- 推送到: `origin/opencode/quiet-canyon`
|
||||
- PR 创建成功: https://github.com/Sagit-chu/flvx/pull/297
|
||||
|
||||
注意: 有 12 个未跟踪的 `.entire/metadata/ses_*` 文件,这些看起来是会话临时文件,未包含在提交中。
|
||||
@@ -0,0 +1,17 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_32fb9de62ffeXG3aOGlPDusGE8
|
||||
Commit Message: 规则标签页下,精简模式,和非精简模式一样要显示规则数量
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
规则标签页下,精简模式,和非精简模式一样要显示规则数量
|
||||
|
||||
## Summary
|
||||
|
||||
I have successfully modified `vite-frontend/src/pages/forward.tsx` to include the rule count ("{sortedForwards.length} 条规则") when the forward page is in the `compactMode` (精简模式) and `viewMode === "grouped"`. This ensures consistency with the regular view modes where the total rule count is already displayed.
|
||||
|
||||
The fix was committed and pushed to the repository in the recent step:
|
||||
`fix(frontend): show rule count in compact view mode for forward pages`
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
这个没改对,页面没变化,请检查
|
||||
@@ -0,0 +1,4 @@
|
||||
I have successfully modified `vite-frontend/src/pages/forward.tsx` to include the rule count ("{sortedForwards.length} 条规则") when the forward page is in the `compactMode` (精简模式) and `viewMode === "grouped"`. This ensures consistency with the regular view modes where the total rule count is already displayed.
|
||||
|
||||
The fix was committed and pushed to the repository in the recent step:
|
||||
`fix(frontend): show rule count in compact view mode for forward pages`
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
节点添加备注和下次续费后,卡片会被撑开,和其他卡片高度不统一,有什么优化方法,或者隐藏一些内容
|
||||
|
||||
---
|
||||
|
||||
1. 统一限制卡片高度
|
||||
2. 限制「备注」只显示 1 行
|
||||
3. 下次续费只显示距离到期
|
||||
@@ -0,0 +1,30 @@
|
||||
我已经详细查阅了节点卡片 (`vite-frontend/src/pages/node.tsx`) 的代码结构。由于现在处于**计划模式 (Plan Mode)**,我目前只能读取和分析,不能直接修改代码。
|
||||
|
||||
我已经为你准备了详细的修改方案,这套方案同时满足你提到的 3 个要求:
|
||||
|
||||
### 修改计划:
|
||||
|
||||
**1. 统一限制卡片高度**
|
||||
* **目标代码位置**:修改包裹 `Card` 的外层 `SortableItem` 的 `className`,以及 `Card` 内部的结构。
|
||||
* **具体修改**:
|
||||
* 给 `SortableItem` 内部渲染的 `div` 增加 `flex flex-col h-full`,使其占满整个 Grid 格子的高度。
|
||||
* 给 `Card` 增加 `h-full flex flex-col` 类名。
|
||||
* 在 `<CardBody>` 内部,将顶部的基础信息区域(包含备注、续费等)和底部的控制区域(系统监控及按钮)分开。给包含底部按钮区域的 div 添加 `mt-auto`,这样它就会自动被推到卡片的最底部,使所有的操作按钮和监控进度条在同一行水平对齐,从而强制拉伸中间的空白区域以保证卡片等高。
|
||||
|
||||
**2. 限制「备注」只显示 1 行**
|
||||
* **目标代码位置**:备注内容显示的 div (`{node.remark.trim()}`)。
|
||||
* **具体修改**:
|
||||
* 将包含备注文本的 `div` 上的 `whitespace-pre-wrap break-words` 替换为 `line-clamp-1 break-all`。
|
||||
* 在这个文本外层包一个 `title={node.remark.trim()}` 属性(原生 Tooltip),这样用户只要把鼠标悬停在被截断的备注上,就能看到完整的备注内容。
|
||||
|
||||
**3. 下次续费只显示距离到期**
|
||||
* **目标代码位置**:`{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && ( ... )}` 渲染逻辑内。
|
||||
* **具体修改**:
|
||||
* 精简现有的 3 行布局(具体时间、剩余时间 Chip、续费周期)。
|
||||
* 将其压缩为 1 行显示,只展示带颜色的 Chip(里面包含了“还有 x 天”的 `expiryMeta.label`)。
|
||||
* 为了保留完整信息,我们给这个 Chip 加上 `title` 属性,比如鼠标移上去会显示完整的:`下次续费:2026-12-01 (年付)`。
|
||||
* 这会把原先极度占用垂直空间的 3 行内容直接缩减到单行。
|
||||
|
||||
---
|
||||
|
||||
如果这个方案符合你的预期,你可以退出计划模式(或者直接回复同意执行),我会在下一次交互中立即为你实施这些代码修改!
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
提交全部变更并且push,创建pr并且合并,合并后推送新tag,在已有的tag 递增 alpha版本
|
||||
|
||||
---
|
||||
|
||||
提交全部变更并且push,创建pr并且合并,合并后推送新tag,在已有的tag 递增 alpha版本
|
||||
@@ -0,0 +1,3 @@
|
||||
当前没有新的变更需要提交。工作目录干净,只有 `.entire/metadata/` 临时文件。最新 tag 是 `2.1.8-alpha4`。
|
||||
|
||||
如有新变更需要提交,请先进行修改。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
提交全部变更并且push
|
||||
|
||||
---
|
||||
|
||||
合并pr,发布新tag 2.1.8-alpha3
|
||||
@@ -0,0 +1,3 @@
|
||||
完成:
|
||||
- PR #293 已合并
|
||||
- Tag `2.1.8-alpha3` 已发布
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
设计一下这个issue实现
|
||||
https://github.com/Sagit-chu/flvx/issues/280
|
||||
@@ -0,0 +1,52 @@
|
||||
我看过 issue #280,也把前后端相关实现路径过了一遍。这个需求适合做成“后端补字段,前端直接消费”的小改动,不建议只在前端二次拼装。
|
||||
|
||||
- 现在 `/forward/list` 的数据来自 `h.repo.ListForwards()`,对应查询在 `go-backend/internal/store/repo/repository.go:700`,当前只 `LEFT JOIN tunnel` 取了 `tunnel_name`,还没把 `tunnel.traffic_ratio` 带出来。
|
||||
- 前端规则页自己的 `Forward` 类型定义在 `vite-frontend/src/pages/forward.tsx:102`,`mapForwardApiItems` 在 `vite-frontend/src/pages/forward.tsx:507` 负责把接口结果映射成本地状态;这里目前也没有倍率字段。
|
||||
- 规则列表的主要展示点有 3 处:表格隧道列 `vite-frontend/src/pages/forward.tsx:3306`、卡片副标题 `vite-frontend/src/pages/forward.tsx:3469`、分组标题 `vite-frontend/src/pages/forward.tsx:2919`。如果只改一处,体验会不一致。
|
||||
- 隧道页已经有统一的倍率展示格式 `vite-frontend/src/pages/tunnel.tsx:1336`,直接沿用 `"{trafficRatio}x"` 最稳。
|
||||
|
||||
建议实现方案
|
||||
|
||||
- 后端优先:在 `go-backend/internal/store/repo/repository.go:705` 的 `fwdRow` 增加 `TrafficRatio float64`,并在 `Select(...)` 中补上 `COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio`。
|
||||
- 后端返回:在构造 `item` 的地方把 `trafficRatio` 以 `tunnelTrafficRatio` 键返回,保持前端语义清晰,避免和规则自身字段混淆。
|
||||
- 前端类型:给 `vite-frontend/src/api/types.ts:45` 的 `ForwardApiItem` 增加 `tunnelTrafficRatio?: number`;给 `vite-frontend/src/pages/forward.tsx:102` 的本地 `Forward` interface 同步增加该字段。
|
||||
- 前端映射:在 `vite-frontend/src/pages/forward.tsx:507` 的 `mapForwardApiItems` 里做默认值归一化,比如没有值时回退到 `1`,避免旧数据或异常响应导致显示空白。
|
||||
- 前端展示:优先把倍率放进“隧道名称展示块”里,而不是新增独立列。原因是当前表格列已经偏宽,issue 也接受“名称旁边显示”;卡片和分组视图也更容易保持一致。建议形式:
|
||||
- 表格:`TunnelName` 主文案 + 小号次级 `1x`
|
||||
- 卡片:副标题显示为 `隧道名 · 1x`
|
||||
- 分组标题:`隧道名` 后加一个轻量 Chip / muted 文本 `1x`
|
||||
|
||||
为什么不建议纯前端拼装
|
||||
|
||||
- `forward.tsx` 虽然在 `loadData` 里已经请求了 `getTunnelList()` 和 `userTunnel()`,但 `refreshForwardList()` 是独立刷新的,规则状态刷新时不保证隧道缓存始终同步,容易出现倍率滞后。
|
||||
- 当前分组逻辑主要基于规则数据本身,若依赖前端再按 `tunnelId` 做二次映射,会把一个本来单接口即可闭环的信息拆成两份状态,后面维护更麻烦。
|
||||
- 后端本来已经 `JOIN tunnel` 取名字,只是顺手再取一个倍率字段,成本最低。
|
||||
|
||||
推荐实施步骤
|
||||
|
||||
- 后端:扩展 `/forward/list` 查询与返回结构,默认倍率回退 `1.0`。
|
||||
- 合约测试:在 `go-backend/tests/contract/forward_contract_test.go` 增加对 `/forward/list` 返回 `tunnelTrafficRatio` 的断言,至少覆盖一个非默认值例如 `2.5`。
|
||||
- 前端类型:同步更新 `ForwardApiItem` 和页面内 `Forward`。
|
||||
- 前端映射:在 `mapForwardApiItems` 里规范化 `tunnelTrafficRatio`。
|
||||
- 前端 UI:统一更新表格、卡片、分组标题 3 处展示,避免 direct/grouped 两种模式信息不一致。
|
||||
- 回归验证:确认管理员和普通用户视角下,`/forward/list` 权限过滤不受影响,因为过滤逻辑在 `go-backend/internal/http/handler/handler.go:395` 之后仅按 `userId` 过滤,不会受新增字段影响。
|
||||
|
||||
风险和注意点
|
||||
|
||||
- `ForwardApiItem` 现在是宽松结构,短期不会因新增字段报错,但如果只改页面本地类型不改 API 类型,后面别处复用时会产生类型漂移。
|
||||
- 分组标题 `ForwardTunnelGroup` 目前只有 `tunnelName`,如果要在分组头也显示倍率,最好给分组结构补一个 `tunnelTrafficRatio?: number`,并在构建分组时从首条规则或同组规则里带出。
|
||||
- 需要统一倍率格式,避免出现 `1`, `1.0`, `1.00` 混用。建议做一个小格式化函数:整数显示 `1x`,小数保留必要精度如 `0.5x`、`1.25x`。
|
||||
- 若某条规则关联的隧道已删除,后端 `COALESCE(..., 1.0)` 能保证页面仍可显示默认 `1x`,不会变成空。
|
||||
|
||||
验收标准
|
||||
|
||||
- `/api/v1/forward/list` 每条规则都带 `tunnelTrafficRatio`。
|
||||
- 规则页 direct 表格模式能看到倍率。
|
||||
- grouped 卡片模式和分组标题也能看到倍率,信息一致。
|
||||
- 隧道缺失或旧数据情况下仍稳定显示 `1x`。
|
||||
- 普通用户只能看到自己的规则,但倍率字段仍正常返回。
|
||||
|
||||
如果你要我下一步继续,我建议按这个实现顺序做:
|
||||
1. 先补后端查询和 contract test
|
||||
2. 再补前端类型与统一展示
|
||||
3. 最后跑 `go test ./tests/contract/...` 和前端 build 做回归
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
提交全部变更并且push,并且修改pr内容关联
|
||||
https://github.com/Sagit-chu/flvx/issues/246
|
||||
@@ -0,0 +1,15 @@
|
||||
已完成。
|
||||
|
||||
- 提交了当前代码变更,commit 是 `31ef861`
|
||||
- 已 push 到远端分支 `opencode/quiet-canyon`
|
||||
- 已更新 PR `https://github.com/Sagit-chu/flvx/pull/293`
|
||||
- PR 标题改为 `feat(nodes): add node metadata and expiry reminders`
|
||||
- PR 内容已改成这次节点备注/标签/到期提醒相关说明,并加入 `Closes #246` 关联 issue
|
||||
|
||||
补充一点:
|
||||
- 工作区里还有未提交的 `.entire/metadata/...` 会话元数据文件,它们没有被加入这次提交
|
||||
|
||||
如果你要,我下一步可以继续:
|
||||
1. 帮你检查这条 PR 的 diff 和描述是否还需要再精简
|
||||
2. 帮你补充 reviewer-friendly 的 PR checklist
|
||||
3. 帮你查看 CI / checks 状态
|
||||
@@ -0,0 +1,14 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_332cb6fa9ffehf0BwC73iSeZUR
|
||||
Commit Message: 你好
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
你好
|
||||
|
||||
## Summary
|
||||
|
||||
你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"info": {
|
||||
"id": "ses_332cb6fa9ffehf0BwC73iSeZUR",
|
||||
"slug": "proud-pixel",
|
||||
"projectID": "03c3dd2e5791a80ef100ef53744e013d779d671c",
|
||||
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
|
||||
"title": "问候",
|
||||
"version": "1.2.21",
|
||||
"summary": {
|
||||
"additions": 0,
|
||||
"deletions": 0,
|
||||
"files": 0
|
||||
},
|
||||
"time": {
|
||||
"created": 1772969300054,
|
||||
"updated": 1772969309055
|
||||
}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"info": {
|
||||
"role": "user",
|
||||
"time": {
|
||||
"created": 1772969305082
|
||||
},
|
||||
"summary": {
|
||||
"diffs": []
|
||||
},
|
||||
"agent": "build",
|
||||
"model": {
|
||||
"providerID": "api",
|
||||
"modelID": "gpt-5.4"
|
||||
},
|
||||
"variant": "high",
|
||||
"id": "msg_27c12106663448898b7e25726a28ce04",
|
||||
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR"
|
||||
},
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "你好",
|
||||
"time": {
|
||||
"start": 1772969305.0665522,
|
||||
"end": 1772969305.066553
|
||||
},
|
||||
"id": "prt_ccd34a3fa0015dQBm2oO5f6vPN",
|
||||
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
|
||||
"messageID": "msg_27c12106663448898b7e25726a28ce04"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"info": {
|
||||
"role": "assistant",
|
||||
"time": {
|
||||
"created": 1772969305091,
|
||||
"completed": 1772969309611
|
||||
},
|
||||
"parentID": "msg_27c12106663448898b7e25726a28ce04",
|
||||
"modelID": "gpt-5.4",
|
||||
"providerID": "api",
|
||||
"mode": "build",
|
||||
"agent": "build",
|
||||
"path": {
|
||||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
|
||||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
|
||||
},
|
||||
"cost": 0,
|
||||
"tokens": {
|
||||
"total": 17138,
|
||||
"input": 2210,
|
||||
"output": 80,
|
||||
"reasoning": 45,
|
||||
"cache": {
|
||||
"read": 14848,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"variant": "high",
|
||||
"finish": "stop",
|
||||
"id": "msg_ccd34a4030014dwF4qM30pkR3k",
|
||||
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR"
|
||||
},
|
||||
"parts": [
|
||||
{
|
||||
"type": "step-start",
|
||||
"snapshot": "d23faf4e134b40b649cf9ec6e7a21a632c4c61b1",
|
||||
"id": "prt_ccd34b520001tv5ON6ERsriuUJ",
|
||||
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
|
||||
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。",
|
||||
"time": {
|
||||
"start": 1772969309550,
|
||||
"end": 1772969309550
|
||||
},
|
||||
"id": "prt_ccd34b521001WeBauxJnuorFa5",
|
||||
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
|
||||
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
|
||||
},
|
||||
{
|
||||
"type": "step-finish",
|
||||
"reason": "stop",
|
||||
"snapshot": "d23faf4e134b40b649cf9ec6e7a21a632c4c61b1",
|
||||
"cost": 0,
|
||||
"tokens": {
|
||||
"total": 17138,
|
||||
"input": 2210,
|
||||
"output": 80,
|
||||
"reasoning": 45,
|
||||
"cache": {
|
||||
"read": 14848,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"id": "prt_ccd34b56f001tUZXybdhjVzB22",
|
||||
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
|
||||
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
你好
|
||||
@@ -0,0 +1 @@
|
||||
你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。
|
||||
@@ -0,0 +1,14 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_332ce68b1ffe0R9vUQZ6yq5gUd
|
||||
Commit Message: 在agent.md中加上,尽量使用能使用的skills和mcp
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
在agent.md中加上,尽量使用能使用的skills和mcp
|
||||
|
||||
## Summary
|
||||
|
||||
已添加到 `AGENTS.md` 的 CONVENTIONS 部分。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
在agent.md中加上,尽量使用能使用的skills和mcp
|
||||
@@ -0,0 +1 @@
|
||||
已添加到 `AGENTS.md` 的 CONVENTIONS 部分。
|
||||
@@ -0,0 +1,11 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_332d83674ffeNMa9Z9UerwU3tq
|
||||
Commit Message: 提交全部变更并且push,创建pr
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
提交全部变更并且push,创建pr
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr
|
||||
@@ -0,0 +1,8 @@
|
||||
已完成!
|
||||
|
||||
**提交摘要:**
|
||||
- 10 个文件变更(939 行新增,173 行删除)
|
||||
- 新增计划文档 `plans/020-ajax-no-refresh-ux.md`
|
||||
- 新增工具函数 `vite-frontend/src/utils/list-state.ts`
|
||||
|
||||
**PR 已创建:** https://github.com/Sagit-chu/flvx/pull/293
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
You are auditing the FLVX frontend for GitHub issue #276 (AJAX no-refresh UX improvements). Read-only only. Focus on existing implementation status for: 1) dashboard traffic auto-refresh, 2) node status real-time updates, 3) form submissions without full page reload, 4) local/partial list refresh after CRUD/toggle, 5) batch operation progress UI, 6) search/filter instant response. Inspect relevant files under vite-frontend/src/pages and src/api. Return a concise report with: a) what already exists, b) what is missing or partial, c) specific file paths and functions to touch for an implementation plan, d) notable risks or edge cases. Do not edit anything.
|
||||
@@ -0,0 +1,230 @@
|
||||
Concise audit for issue #276, focused on `vite-frontend/src/pages` and `src/api`.
|
||||
|
||||
a) What already exists
|
||||
|
||||
1. Dashboard traffic auto-refresh
|
||||
- Dashboard data is loaded via async API calls in a hook, not by page reload:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
|
||||
- `useDashboardData()` calls `getUserPackageInfo()` and `getAnnouncement()`.
|
||||
- The 24h traffic chart is already rendered from `statisticsFlows`:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard.tsx`
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx`
|
||||
|
||||
2. Node status real-time updates
|
||||
- This is the strongest existing area.
|
||||
- WebSocket realtime hook already exists:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
|
||||
- `useNodeRealtime()`
|
||||
- Offline debounce exists:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-offline-timers.ts`
|
||||
- Node page consumes websocket messages and updates local state without reload:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
|
||||
- `handleWebSocketMessage()`
|
||||
- `loadNodes()`
|
||||
- Realtime also includes system info updates and upgrade progress updates.
|
||||
|
||||
3. Form submissions without full page reload
|
||||
- Broadly already implemented across main CRUD pages using async handlers + modal close + state refresh.
|
||||
- Examples:
|
||||
- Nodes: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
|
||||
- `handleSubmit()`
|
||||
- Tunnels: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
|
||||
- `handleSubmit()`
|
||||
- Forwards: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
|
||||
- `handleSubmit()`
|
||||
- Users: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
|
||||
- `handleSubmitUser()`
|
||||
- I did not find page reload patterns in these CRUD pages (`location.reload` / `window.location.reload` absent there).
|
||||
|
||||
4. Local/partial list refresh after CRUD/toggle
|
||||
- Some local optimistic/partial updates already exist:
|
||||
- Node delete updates local state directly:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
|
||||
- `confirmDelete()`
|
||||
- Node edit updates local item directly:
|
||||
- `handleSubmit()` edit branch
|
||||
- Forward service toggle uses optimistic local update + rollback on failure:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
|
||||
- `handleServiceToggle()`
|
||||
- Sorting persists locally and updates item `inx` in-memory after success:
|
||||
- Node `handleDragEnd()`
|
||||
- Tunnel `handleDragEnd()`
|
||||
- Forward `handleDragEnd()`
|
||||
|
||||
5. Batch operation progress UI
|
||||
- Node upgrade has actual per-node progress UI:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
|
||||
- `upgradeProgress` state
|
||||
- websocket `type === "upgrade_progress"` handling in `handleWebSocketMessage()`
|
||||
- `<Progress />` render around lines ~1307+
|
||||
- Forward batch actions are factored and return structured outcomes:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward/batch-actions.ts`
|
||||
|
||||
6. Search/filter instant response
|
||||
- Instant client-side filtering already exists in:
|
||||
- Nodes: `sortedNodes` memo filters by `searchKeyword`
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
|
||||
- Tunnels: `sortedTunnels`
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
|
||||
- Forwards: `orderedForwards`
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
|
||||
- Forward also has instant client-side filter dropdowns (`filterUserId`, `filterTunnelId`).
|
||||
|
||||
b) What is missing or partial
|
||||
|
||||
1. Dashboard traffic auto-refresh: missing
|
||||
- `useDashboardData()` only fetches once on mount.
|
||||
- No polling, websocket, refetch trigger, focus refresh, or manual refresh hook.
|
||||
- So “AJAX no-refresh” exists only for initial load, not ongoing refresh.
|
||||
|
||||
2. Node realtime: partial but good
|
||||
- Realtime status/system-info exists only for nodes.
|
||||
- It reconnects with limited retries (5 attempts) and then stops.
|
||||
- `loadNodes()` initially sets `systemInfo: null`, so a full reload can temporarily wipe metrics until WS catches up again.
|
||||
- No obvious fallback polling if websocket dies permanently.
|
||||
|
||||
3. Form submission no full reload: mostly done
|
||||
- CRUD forms are already async and modal-driven.
|
||||
- But post-submit refresh style is inconsistent:
|
||||
- some do local patch,
|
||||
- many do full page-level list refetch (`loadData()`, `loadUsers()`, `loadNodes()`).
|
||||
|
||||
4. Local/partial list refresh after CRUD/toggle: partial/inconsistent
|
||||
- Forward toggle is good and local.
|
||||
- Node edit/delete are relatively local.
|
||||
- But many operations still refetch the whole page dataset:
|
||||
- Tunnel create/update/delete -> `loadData()`
|
||||
- Forward create/update/delete -> `loadData()`
|
||||
- User create/update/delete -> `loadUsers()`
|
||||
- User tunnel assignment/update/remove -> `loadUserTunnels()`
|
||||
- This is AJAX and avoids full browser reload, but not “partial/local refresh” in the stronger UX sense.
|
||||
|
||||
5. Batch operation progress UI: largely missing outside node upgrade
|
||||
- Tunnel batch delete/redeploy: only loading spinner/toast + full reload, no per-item progress/result panel.
|
||||
- Forward batch actions: summarized via toast and optional refresh, but no visible progress timeline/progress list.
|
||||
- User batch tunnel assignment: no progress UI beyond loading state.
|
||||
|
||||
6. Search/filter instant response: mixed
|
||||
- Nodes/tunnels/forwards are instant client-side.
|
||||
- Users are not fully instant:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
|
||||
- `loadUsers()` is server-driven and tied to `useEffect([pagination.current, pagination.size, searchKeyword])`
|
||||
- Enter key also manually calls `handleSearch()`
|
||||
- So user search is AJAX but not purely local/instant in the same way.
|
||||
|
||||
c) Specific file paths and functions to touch for an implementation plan
|
||||
|
||||
Primary targets
|
||||
|
||||
1. Dashboard auto-refresh
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
|
||||
- `useDashboardData()`
|
||||
- extract `loadPackageData()` / `loadAnnouncement()` into stable callbacks
|
||||
- add polling or visibility/focus-based refetch
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard.tsx`
|
||||
- if adding manual refresh indicator/button, this is the page entry
|
||||
- API source already exists:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/api/index.ts`
|
||||
- `getUserPackageInfo()`, `getAnnouncement()`
|
||||
|
||||
2. Node realtime hardening / fallback
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
|
||||
- `useNodeRealtime()`
|
||||
- retry policy, reconnect strategy, possible fallback refresh trigger
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
|
||||
- `handleWebSocketMessage()`
|
||||
- `loadNodes()`
|
||||
- `handleSubmit()`
|
||||
- `confirmDelete()`
|
||||
- `handleBatchDelete()`
|
||||
|
||||
3. Replace whole-list reloads with local/partial updates
|
||||
- Tunnels:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
|
||||
- `loadData()`
|
||||
- `handleSubmit()`
|
||||
- `confirmDelete()`
|
||||
- `handleBatchDelete()`
|
||||
- `handleBatchRedeploy()`
|
||||
- Forwards:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
|
||||
- `loadData()`
|
||||
- `handleSubmit()`
|
||||
- `confirmDelete()`
|
||||
- `handleServiceToggle()`
|
||||
- `handleBatchDelete()`
|
||||
- `handleBatchToggleService()`
|
||||
- `handleBatchRedeploy()`
|
||||
- `handleBatchChangeTunnel()`
|
||||
- Users:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
|
||||
- `loadUsers()`
|
||||
- `handleSubmitUser()`
|
||||
- `handleConfirmDelete()`
|
||||
- `loadUserTunnels()`
|
||||
- `handleBatchAssignTunnel()`
|
||||
- `handleUpdateTunnel()`
|
||||
- `handleConfirmRemoveTunnel()`
|
||||
- `handleConfirmResetFlow()`
|
||||
- `handleConfirmResetTunnelFlow()`
|
||||
|
||||
4. Batch progress/result UX
|
||||
- Nodes already have a model to copy:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
|
||||
- `upgradeProgress`
|
||||
- websocket `upgrade_progress`
|
||||
- Forward batch abstraction:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward/batch-actions.ts`
|
||||
- likely extend return shape if backend can expose richer progress/events
|
||||
- API endpoints involved:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/api/index.ts`
|
||||
- `batchDeleteForwards()`
|
||||
- `batchPauseForwards()`
|
||||
- `batchResumeForwards()`
|
||||
- `batchRedeployForwards()`
|
||||
- `batchChangeTunnel()`
|
||||
- `batchDeleteTunnels()`
|
||||
- `batchRedeployTunnels()`
|
||||
- `batchDeleteNodes()`
|
||||
- `batchUpgradeNodes()`
|
||||
|
||||
5. Search/filter behavior normalization
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
|
||||
- `loadUsers()`
|
||||
- `handleSearch()`
|
||||
- search input around lines ~718+
|
||||
- Nodes/tunnels/forwards already have memo-based client filtering and are the pattern to follow.
|
||||
|
||||
d) Notable risks / edge cases
|
||||
|
||||
1. Dashboard polling could spam toasts
|
||||
- `checkExpirationNotifications()` runs inside package load flow.
|
||||
- If polling is added naively, expiration toasts could repeat or localStorage notification keys could churn unexpectedly.
|
||||
|
||||
2. Dashboard chart semantics
|
||||
- `statisticsFlows` appears to be a snapshot list, not a stream API.
|
||||
- Frequent polling may redraw aggressively and may need dedupe/staleness handling.
|
||||
|
||||
3. Node websocket auth/security
|
||||
- Realtime URL sends token in query string (`secret=${getToken()}`) in:
|
||||
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
|
||||
- Any changes should be careful not to break backend expectations.
|
||||
|
||||
4. Realtime/local state merge complexity
|
||||
- `loadNodes()` normalizes nodes and clears `systemInfo`.
|
||||
- If partial refetch is introduced, merging fetched node metadata with live websocket state must avoid wiping online metrics/progress.
|
||||
|
||||
5. Batch APIs may not provide granular progress
|
||||
- Current batch helpers mostly return summary counts, not per-item progress.
|
||||
- True progress UI may require backend support, not only frontend work.
|
||||
|
||||
6. Forward toggle consistency
|
||||
- `handleServiceToggle()` optimistically flips `serviceRunning` and later patches `status`.
|
||||
- If backend returns partial failure or delayed actual state, UI may drift until a refresh.
|
||||
|
||||
7. User search behavior currently depends on backend pagination/search
|
||||
- Moving to fully instant local filtering may conflict with server-side pagination semantics and large datasets.
|
||||
|
||||
Bottom line
|
||||
- Already present: async modal CRUD, no browser reloads, strong node realtime, optimistic forward toggle, instant search/filter on node/tunnel/forward pages.
|
||||
- Missing/partial for #276: dashboard traffic auto-refresh, consistent partial/local state updates after CRUD, batch progress UI outside node upgrade, and more unified instant-search UX on the user page.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_332fdb603ffeYxhpzgAB64sSTN
|
||||
Commit Message: 开始实施
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
https://github.com/Sagit-chu/flvx/issues/276
|
||||
计划一下这个issue实现
|
||||
|
||||
### Prompt 2
|
||||
|
||||
写出 plans/020-ajax-no-refresh-ux.md 的完整实施计划内容
|
||||
|
||||
### Prompt 3
|
||||
|
||||
开始实施
|
||||
|
||||
## Summary
|
||||
|
||||
我先把 Phase 1 落地了,重点做了仪表盘自动刷新和节点实时连接兜底,并把计划文档同步更新了。
|
||||
|
||||
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:55` 现在加入了 5 秒静默轮询、标签页可见性恢复刷新,以及更稳定的加载回调拆分,避免每次刷新都走整套初始化逻辑。
|
||||
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:82` 把过期提醒的去重 key 改成基于用户/隧道到期信息的稳定标识,轮询刷新流量时不会反复弹 toast。
|
||||
- `vite-frontend/src/pages/node/use-node-realtime.ts:13` 补强了 WebSocket 重连策略:前几次按递增延迟重连,超过阈值后进入长间隔 fallback 模式,而不是彻底停掉。
|
||||
- `vite-frontend/src/pages/node.tsx:68` 增加了节点列表 merge 逻辑;fallback 刷新时会保留已有 `systemInfo`、升级/复制/回退 loading 状态,避免列表刷新把实时信息清空。
|
||||
- `vite-frontend/src/pages/node.tsx:1160` 节点页断线提示现在会区分“正在重连”和“已切换为列表自动刷新兜底模式”。
|
||||
- `vite-frontend/src/vite-env.d.ts:4` 补上了 `VITE_API_BASE` 类型声明,顺手消掉这次构建会碰到的环境变量类型报错。
|
||||
- `plans/020-ajax-no-refresh-ux.md:19` 已把本次完成的 3 个 Phase 1 checklist 勾掉。
|
||||
|
||||
验证情况:
|
||||
- 运行了 `cd vite-frontend && npm install`
|
||||
- 运行了 `cd vite-frontend && npm run build`
|
||||
- 前端构建已通过
|
||||
|
||||
这次额外生成了依赖文件:
|
||||
- `vite-frontend/package-lock.json`
|
||||
|
||||
下一步自然就是继续 Phase 2,把 `forward` / `tunnel` / `user` 的“操作后整页数据重拉”改成局部 patch。你要我继续的话,我建议从 `vite-frontend/src/pages/forward.tsx` 开始,因为它已经有一部分本地更新模式,改起来最顺。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
https://github.com/Sagit-chu/flvx/issues/276
|
||||
计划一下这个issue实现
|
||||
|
||||
---
|
||||
|
||||
写出 plans/020-ajax-no-refresh-ux.md 的完整实施计划内容
|
||||
|
||||
---
|
||||
|
||||
开始实施
|
||||
@@ -0,0 +1,19 @@
|
||||
我先把 Phase 1 落地了,重点做了仪表盘自动刷新和节点实时连接兜底,并把计划文档同步更新了。
|
||||
|
||||
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:55` 现在加入了 5 秒静默轮询、标签页可见性恢复刷新,以及更稳定的加载回调拆分,避免每次刷新都走整套初始化逻辑。
|
||||
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:82` 把过期提醒的去重 key 改成基于用户/隧道到期信息的稳定标识,轮询刷新流量时不会反复弹 toast。
|
||||
- `vite-frontend/src/pages/node/use-node-realtime.ts:13` 补强了 WebSocket 重连策略:前几次按递增延迟重连,超过阈值后进入长间隔 fallback 模式,而不是彻底停掉。
|
||||
- `vite-frontend/src/pages/node.tsx:68` 增加了节点列表 merge 逻辑;fallback 刷新时会保留已有 `systemInfo`、升级/复制/回退 loading 状态,避免列表刷新把实时信息清空。
|
||||
- `vite-frontend/src/pages/node.tsx:1160` 节点页断线提示现在会区分“正在重连”和“已切换为列表自动刷新兜底模式”。
|
||||
- `vite-frontend/src/vite-env.d.ts:4` 补上了 `VITE_API_BASE` 类型声明,顺手消掉这次构建会碰到的环境变量类型报错。
|
||||
- `plans/020-ajax-no-refresh-ux.md:19` 已把本次完成的 3 个 Phase 1 checklist 勾掉。
|
||||
|
||||
验证情况:
|
||||
- 运行了 `cd vite-frontend && npm install`
|
||||
- 运行了 `cd vite-frontend && npm run build`
|
||||
- 前端构建已通过
|
||||
|
||||
这次额外生成了依赖文件:
|
||||
- `vite-frontend/package-lock.json`
|
||||
|
||||
下一步自然就是继续 Phase 2,把 `forward` / `tunnel` / `user` 的“操作后整页数据重拉”改成局部 patch。你要我继续的话,我建议从 `vite-frontend/src/pages/forward.tsx` 开始,因为它已经有一部分本地更新模式,改起来最顺。
|
||||
@@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
||||
|
||||
## CONVENTIONS
|
||||
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
|
||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -311,6 +312,14 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep paused forwards paused after UpdateService/AddService, since agent-side UpdateService
|
||||
// always restarts services.
|
||||
if forward.Status != 1 {
|
||||
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
@@ -359,7 +368,12 @@ func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord,
|
||||
return fmt.Errorf("端口 %d 已被其他转发占用", port)
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServiceBasesOnNode(node.ID, bases); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -377,41 +391,45 @@ func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward delete context")
|
||||
}
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.deleteForwardServiceBasesOnNode(nodeID, bases)
|
||||
|
||||
}
|
||||
|
||||
func (h *Handler) forwardServiceBaseCandidates(forward *forwardRecord) ([]string, error) {
|
||||
if h == nil || forward == nil {
|
||||
return nil, errors.New("invalid forward service base context")
|
||||
}
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, 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)
|
||||
return buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs), nil
|
||||
|
||||
var lastErr error
|
||||
for _, base := range bases {
|
||||
names := buildForwardControlServiceNames(base, "DeleteService")
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
|
||||
return deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"services": names,
|
||||
"services": []string{name},
|
||||
}
|
||||
_, cmdErr := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
|
||||
if cmdErr == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = cmdErr
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return nil
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
|
||||
@@ -517,6 +535,22 @@ func controlForwardServiceCommand(bases []string, commandType string, send func(
|
||||
return false, lastNotFoundErr, nil
|
||||
}
|
||||
|
||||
func deleteForwardServiceCandidates(bases []string, send func(name string) error) error {
|
||||
for _, base := range bases {
|
||||
for _, name := range append([]string{base + "_tcp", base + "_udp", base}, []string{}...) {
|
||||
err := send(name)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldSelfHealForwardServiceControl(commandType string) bool {
|
||||
cmd := strings.ToLower(strings.TrimSpace(commandType))
|
||||
return cmd == "pauseservice" || cmd == "resumeservice"
|
||||
@@ -1454,10 +1488,11 @@ func isAlreadyExistsMessage(message string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
if isAddressAlreadyInUseMessage(msg) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
|
||||
compact := compactErrorMessage(msg)
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") || strings.Contains(compact, "alreadyexists")
|
||||
}
|
||||
|
||||
func isBindAddressInUseError(err error) bool {
|
||||
@@ -1482,7 +1517,10 @@ func isAddressAlreadyInUseMessage(msg string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "address already in use")
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(compactErrorMessage(msg), "addressalreadyinuse")
|
||||
}
|
||||
|
||||
func isCannotAssignRequestedAddressError(err error) bool {
|
||||
@@ -1493,7 +1531,18 @@ func isCannotAssignRequestedAddressError(err error) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "cannot assign requested address")
|
||||
if strings.Contains(msg, "cannot assign requested address") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(compactErrorMessage(msg), "cannotassignrequestedaddress")
|
||||
}
|
||||
|
||||
func compactErrorMessage(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
@@ -1572,13 +1621,21 @@ func buildForwarderNodes(targets []string) []map[string]interface{} {
|
||||
}
|
||||
|
||||
func processServerAddress(serverAddr string) string {
|
||||
serverAddr = strings.TrimSpace(serverAddr)
|
||||
serverAddr = normalizeServerAddressInput(serverAddr)
|
||||
if serverAddr == "" {
|
||||
return serverAddr
|
||||
}
|
||||
if strings.HasPrefix(serverAddr, "[") {
|
||||
return serverAddr
|
||||
}
|
||||
// If the input is a bare IPv6 host (no port), bracket it.
|
||||
// IPv6-with-port must be provided in bracket form: [::1]:443.
|
||||
if looksLikeIPv6(serverAddr) {
|
||||
if ip := net.ParseIP(serverAddr); ip != nil && ip.To4() == nil {
|
||||
return "[" + serverAddr + "]"
|
||||
}
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(serverAddr, ":")
|
||||
if idx < 0 {
|
||||
if looksLikeIPv6(serverAddr) {
|
||||
@@ -1597,6 +1654,27 @@ func processServerAddress(serverAddr string) string {
|
||||
return serverAddr
|
||||
}
|
||||
|
||||
func normalizeServerAddressInput(serverAddr string) string {
|
||||
serverAddr = strings.TrimSpace(serverAddr)
|
||||
if serverAddr == "" {
|
||||
return serverAddr
|
||||
}
|
||||
|
||||
if idx := strings.Index(serverAddr, "://"); idx > 0 {
|
||||
if parsed, err := url.Parse(serverAddr); err == nil {
|
||||
if host := strings.TrimSpace(parsed.Host); host != "" {
|
||||
return host
|
||||
}
|
||||
}
|
||||
serverAddr = serverAddr[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(serverAddr, "/?#"); idx >= 0 {
|
||||
serverAddr = serverAddr[:idx]
|
||||
}
|
||||
return strings.TrimSpace(serverAddr)
|
||||
}
|
||||
|
||||
func looksLikeIPv6(address string) bool {
|
||||
return strings.Count(address, ":") >= 2
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
|
||||
@@ -122,6 +124,112 @@ func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_0" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("all-missing delete should be tolerated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
|
||||
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
|
||||
want := []string{"46_9_123", "46_9_77", "46_9_0"}
|
||||
if !reflect.DeepEqual(bases, want) {
|
||||
t.Fatalf("expected %v, got %v", want, bases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
|
||||
bases := []string{"46_9_123", "46_9_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
|
||||
bases := []string{"57_7_7", "57_7_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
switch name {
|
||||
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
|
||||
return nil
|
||||
default:
|
||||
return errors.New("service " + name + " not found")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
|
||||
h := &Handler{repo: nil}
|
||||
node := &nodeRecord{ID: 9, Name: "test-node"}
|
||||
_ = h
|
||||
_ = node
|
||||
|
||||
rawRepo, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
h = &Handler{repo: rawRepo}
|
||||
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
|
||||
t.Fatalf("insert forward port: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
|
||||
if err == nil {
|
||||
t.Fatalf("expected occupancy error")
|
||||
}
|
||||
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("same forward should be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
@@ -148,9 +256,15 @@ func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
if !isAlreadyExistsMessage("服务已存在") {
|
||||
t.Fatalf("expected Chinese already exists message to be tolerated")
|
||||
}
|
||||
if !isAlreadyExistsMessage("service demo alreadyexists") {
|
||||
t.Fatalf("missing-space alreadyexists should be tolerated")
|
||||
}
|
||||
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
|
||||
t.Fatalf("address already in use must not be treated as already exists")
|
||||
}
|
||||
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
|
||||
t.Fatalf("alreadyin-use variant must not be treated as already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBindAddressInUseError(t *testing.T) {
|
||||
@@ -172,6 +286,9 @@ func TestIsAddressAlreadyInUseError(t *testing.T) {
|
||||
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
|
||||
t.Fatalf("missing-space alreadyin-use variant should be detected")
|
||||
}
|
||||
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
|
||||
}
|
||||
@@ -181,11 +298,83 @@ func TestIsCannotAssignRequestedAddressError(t *testing.T) {
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
|
||||
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
|
||||
}
|
||||
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should not be treated as cannot-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
if addCalls == 1 {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry to succeed, got %v", err)
|
||||
}
|
||||
if addCalls != 2 {
|
||||
t.Fatalf("expected 2 add attempts, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 1 {
|
||||
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
return errors.New("network timeout")
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if addCalls != 1 {
|
||||
t.Fatalf("expected 1 add attempt, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 0 {
|
||||
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
|
||||
cleanupErr := errors.New("delete failed")
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
},
|
||||
func() error {
|
||||
return cleanupErr
|
||||
},
|
||||
0,
|
||||
)
|
||||
if !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
@@ -231,3 +420,68 @@ func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "https with path",
|
||||
in: "https://panel.example.com:8443/api/v1",
|
||||
want: "panel.example.com:8443",
|
||||
},
|
||||
{
|
||||
name: "wss with query",
|
||||
in: "wss://panel.example.com:443/system-info?x=1",
|
||||
want: "panel.example.com:443",
|
||||
},
|
||||
{
|
||||
name: "http without port",
|
||||
in: "http://panel.example.com",
|
||||
want: "panel.example.com",
|
||||
},
|
||||
{
|
||||
name: "manual host with trailing path",
|
||||
in: "panel.example.com:8080/path",
|
||||
want: "panel.example.com:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := processServerAddress(tt.in); got != tt.want {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessServerAddress_NormalizesIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ipv6 host only",
|
||||
in: "2001:db8::1",
|
||||
want: "[2001:db8::1]",
|
||||
},
|
||||
{
|
||||
name: "ipv6 host and port",
|
||||
in: "https://[2001:db8::1]:8443/path",
|
||||
want: "[2001:db8::1]:8443",
|
||||
},
|
||||
{
|
||||
name: "already bracketed",
|
||||
in: "[2001:db8::2]:9000",
|
||||
want: "[2001:db8::2]:9000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := processServerAddress(tt.in); got != tt.want {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,19 @@ func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "10.8.0.5:21002" {
|
||||
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -43,6 +44,11 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
if forward, err := h.getForwardRecord(forwardID); err == nil && forward != nil {
|
||||
if quota, quotaErr := h.repo.AddTunnelQuotaUsage(forward.TunnelID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
||||
h.enforceTunnelQuotaIfNeeded(forward.TunnelID, quota)
|
||||
}
|
||||
}
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
@@ -327,6 +333,70 @@ func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, now int64) error {
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("invalid flow policy context")
|
||||
}
|
||||
if userID <= 0 || tunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
if user.Status != 1 {
|
||||
return errors.New("账号已禁用")
|
||||
}
|
||||
if user.ExpTime > 0 && user.ExpTime <= now {
|
||||
return errors.New("账号已过期")
|
||||
}
|
||||
|
||||
flowLimit := user.Flow * bytesPerGB
|
||||
current := user.InFlow + user.OutFlow
|
||||
if flowLimit < current {
|
||||
return errors.New("流量已超额,禁止开启转发")
|
||||
}
|
||||
if err := h.ensureTunnelForwardAllowedByQuota(tunnelID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userTunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
policy, err := h.getUserTunnelPolicy(userTunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if policy.Status != 1 {
|
||||
return errors.New("该隧道已禁用")
|
||||
}
|
||||
if policy.ExpTime > 0 && policy.ExpTime <= now {
|
||||
return errors.New("该隧道已过期")
|
||||
}
|
||||
|
||||
utFlowLimit := policy.Flow * bytesPerGB
|
||||
utCurrent := policy.InFlow + policy.OutFlow
|
||||
if utCurrent >= utFlowLimit {
|
||||
return errors.New("该隧道流量已超额,禁止开启转发")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
|
||||
@@ -132,6 +132,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
||||
mux.HandleFunc("/api/v1/tunnel/quota/reset", h.tunnelQuotaReset)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
|
||||
@@ -571,7 +572,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
"userId": t.UserID,
|
||||
"tunnelId": t.TunnelID,
|
||||
"tunnelName": t.TunnelName,
|
||||
"status": 1,
|
||||
"status": t.Status,
|
||||
"flow": t.Flow,
|
||||
"num": t.Num,
|
||||
"expTime": t.ExpTime,
|
||||
|
||||
@@ -18,11 +18,12 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(2)
|
||||
h.jobsWG.Add(3)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
go h.runDailyMaintenanceLoop(ctx)
|
||||
go h.runNodeRenewalCycleLoop(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
@@ -135,6 +136,7 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
|
||||
}
|
||||
|
||||
h.resetMonthlyFlow(now)
|
||||
h.resetTunnelQuotaWindows(now)
|
||||
h.disableExpiredUsers(now.UnixMilli())
|
||||
h.disableExpiredUserTunnels(now.UnixMilli())
|
||||
}
|
||||
@@ -176,3 +178,39 @@ func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
|
||||
_ = h.repo.DisableUserTunnel(item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
for {
|
||||
wait := durationUntilNextNodeRenewalCycle(time.Now())
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
h.runNodeRenewalCycleJob(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration {
|
||||
next := now.Truncate(6 * time.Hour).Add(6 * time.Hour)
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
func (h *Handler) runNodeRenewalCycleJob(now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = advanced
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/renewal-test.db"
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
nodeID := int64(101)
|
||||
err = r.DB().Exec(`
|
||||
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert test node: %v", err)
|
||||
}
|
||||
|
||||
quarterNodeID := int64(102)
|
||||
err = r.DB().Exec(`
|
||||
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert test node: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.runNodeRenewalCycleJob(now)
|
||||
|
||||
var anchor sql.NullInt64
|
||||
err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("query expiry_time: %v", err)
|
||||
}
|
||||
|
||||
expectedAnchor := now.AddDate(0, 2, 0).UnixMilli()
|
||||
if !anchor.Valid || anchor.Int64 != expectedAnchor {
|
||||
t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64)
|
||||
}
|
||||
}
|
||||
@@ -143,3 +143,44 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetAndExpiryJobResetsTunnelQuotaAndReEnablesTunnel(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "secret")
|
||||
now := time.Date(2026, 3, 12, 0, 0, 5, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'quota-reset-tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
||||
`, 11*int64(1024*1024*1024), 11*int64(1024*1024*1024), nowMs, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert tunnel quota: %v", err)
|
||||
}
|
||||
|
||||
h.runResetAndExpiryJob(now)
|
||||
|
||||
tunnelStatus := mustQueryInt(t, r, `SELECT status FROM tunnel WHERE id = 1`)
|
||||
if tunnelStatus != 1 {
|
||||
t.Fatalf("expected tunnel re-enabled after quota reset, got %d", tunnelStatus)
|
||||
}
|
||||
dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM tunnel_quota WHERE tunnel_id = 1`)
|
||||
if dailyUsed != 0 {
|
||||
t.Fatalf("expected daily quota usage reset, got %d", dailyUsed)
|
||||
}
|
||||
quotaDisabled := mustQueryInt(t, r, `SELECT disabled_by_quota FROM tunnel_quota WHERE tunnel_id = 1`)
|
||||
if quotaDisabled != 0 {
|
||||
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const tunnelServiceBindRetryDelay = 150 * time.Millisecond
|
||||
|
||||
func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -259,6 +261,9 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(""),
|
||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
|
||||
asInt(req["http"], 0),
|
||||
asInt(req["tls"], 0),
|
||||
asInt(req["socks"], 0),
|
||||
@@ -324,6 +329,9 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(asString(req["extraIPs"])),
|
||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
|
||||
newHTTP,
|
||||
newTLS,
|
||||
newSocks,
|
||||
@@ -477,6 +485,8 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
typeVal := asInt(req["type"], 1)
|
||||
flow := asInt64(req["flow"], 1)
|
||||
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
|
||||
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
|
||||
status := asInt(req["status"], 1)
|
||||
trafficRatio := asFloat(req["trafficRatio"], 1.0)
|
||||
inIP := asString(req["inIp"])
|
||||
@@ -572,6 +582,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
tunnelID := tunnel.ID
|
||||
if err := h.repo.SaveTunnelQuotaConfigTx(tx, tunnelID, dailyQuotaGB, monthlyQuotaGB, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
runtimeState.TunnelID = tunnelID
|
||||
var federationBindings []repo.FederationTunnelBinding
|
||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||
@@ -676,12 +690,15 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
||||
return
|
||||
}
|
||||
oldEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id)
|
||||
|
||||
h.cleanupTunnelRuntime(id)
|
||||
h.cleanupFederationRuntime(id)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
typeVal := asInt(req["type"], 1)
|
||||
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
|
||||
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
|
||||
ipPreference := asString(req["ipPreference"])
|
||||
localDomain := h.federationLocalDomain()
|
||||
|
||||
@@ -726,6 +743,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.SaveTunnelQuotaConfigTx(tx, id, dailyQuotaGB, monthlyQuotaGB, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -747,6 +768,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
newEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id)
|
||||
if !sameInt64Set(oldEntryNodeIDs, newEntryNodeIDs) {
|
||||
h.cleanupTunnelForwardRuntimesOnRemovedEntryNodes(id, oldEntryNodeIDs, newEntryNodeIDs)
|
||||
h.syncTunnelForwardsEntryPorts(id, newEntryNodeIDs)
|
||||
}
|
||||
|
||||
if typeVal == 2 {
|
||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||
if applyErr != nil {
|
||||
@@ -771,6 +798,146 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func sameInt64Set(a, b []int64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return true
|
||||
}
|
||||
m := make(map[int64]int, len(a))
|
||||
for _, v := range a {
|
||||
m[v]++
|
||||
}
|
||||
for _, v := range b {
|
||||
c, ok := m[v]
|
||||
if !ok || c <= 0 {
|
||||
return false
|
||||
}
|
||||
if c == 1 {
|
||||
delete(m, v)
|
||||
continue
|
||||
}
|
||||
m[v] = c - 1
|
||||
}
|
||||
return len(m) == 0
|
||||
}
|
||||
|
||||
func pickForwardPortFromRecords(ports []forwardPortRecord) int {
|
||||
min := 0
|
||||
for _, fp := range ports {
|
||||
if fp.Port <= 0 {
|
||||
continue
|
||||
}
|
||||
if min == 0 || fp.Port < min {
|
||||
min = fp.Port
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
func uniqueInt64s(input []int64) []int64 {
|
||||
if len(input) <= 1 {
|
||||
return input
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(input))
|
||||
out := make([]int64, 0, len(input))
|
||||
for _, v := range input {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func diffInt64s(base, subtract []int64) []int64 {
|
||||
if len(base) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(subtract))
|
||||
for _, v := range subtract {
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
out := make([]int64, 0, len(base))
|
||||
for _, v := range base {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return uniqueInt64s(out)
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupTunnelForwardRuntimesOnRemovedEntryNodes(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) {
|
||||
if h == nil || h.repo == nil || tunnelID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
removedNodeIDs := diffInt64s(oldEntryNodeIDs, newEntryNodeIDs)
|
||||
if len(removedNodeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil || len(forwards) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range forwards {
|
||||
f := &forwards[i]
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
for _, nodeID := range removedNodeIDs {
|
||||
_ = h.deleteForwardServicesOnNode(f, nodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []int64) {
|
||||
if h == nil || h.repo == nil || tunnelID <= 0 {
|
||||
return
|
||||
}
|
||||
entryNodeIDs = uniqueInt64s(entryNodeIDs)
|
||||
if len(entryNodeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil || len(forwards) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
allowInIP := len(entryNodeIDs) == 1
|
||||
for i := range forwards {
|
||||
f := &forwards[i]
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
oldPorts, err := h.listForwardPorts(f.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port := pickForwardPortFromRecords(oldPorts)
|
||||
if port <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var entries []forwardPortReplaceEntry
|
||||
if allowInIP {
|
||||
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
|
||||
} else {
|
||||
entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
||||
for _, nid := range entryNodeIDs {
|
||||
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""})
|
||||
}
|
||||
}
|
||||
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -1144,9 +1311,17 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if tunnel.Status != 1 {
|
||||
if reason, quotaErr := h.tunnelQuotaBlockReason(tunnelID, time.Now().UnixMilli()); quotaErr == nil && reason != "" {
|
||||
response.WriteJSON(w, response.ErrDefault(reason))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault("隧道已禁用,无法创建转发"))
|
||||
return
|
||||
}
|
||||
if err := h.ensureUserTunnelForwardAllowed(userID, tunnelID, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
name := asString(req["name"])
|
||||
remoteAddr := asString(req["remoteAddr"])
|
||||
if name == "" || remoteAddr == "" {
|
||||
@@ -1158,13 +1333,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
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"])
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
@@ -1194,6 +1362,14 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := validateLocalNodePort(node, port); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, port, 0); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
inx := h.repo.NextIndex("forward")
|
||||
@@ -1260,6 +1436,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if tunnel.Status != 1 {
|
||||
if reason, quotaErr := h.tunnelQuotaBlockReason(tunnelID, time.Now().UnixMilli()); quotaErr == nil && reason != "" {
|
||||
response.WriteJSON(w, response.ErrDefault(reason))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault("隧道已禁用,无法更新转发"))
|
||||
return
|
||||
}
|
||||
@@ -1276,20 +1456,13 @@ 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
|
||||
}
|
||||
}
|
||||
rawSpeedID, hasSpeedID := req["speedId"]
|
||||
requestedSpeedID := asAnyToInt64Ptr(rawSpeedID)
|
||||
if actorRole != 0 && hasSpeedID && requestedSpeedID != nil && !sameSpeedLimitSelection(forward.SpeedID, requestedSpeedID) {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
speedID := requestedSpeedID
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -1332,6 +1505,14 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := validateLocalNodePort(node, port); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, port, id); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
|
||||
@@ -1434,11 +1615,16 @@ func (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
_ = h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli())
|
||||
_ = h.repo.UpdateForwardStatus(id, 1, now)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1561,17 +1747,22 @@ func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s := 0
|
||||
f := 0
|
||||
now := time.Now().UnixMilli()
|
||||
for _, id := range ids {
|
||||
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
||||
if accessErr != nil {
|
||||
f++
|
||||
continue
|
||||
}
|
||||
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
|
||||
f++
|
||||
continue
|
||||
}
|
||||
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
|
||||
f++
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli()); err != nil {
|
||||
if err := h.repo.UpdateForwardStatus(id, 1, now); err != nil {
|
||||
f++
|
||||
} else {
|
||||
s++
|
||||
@@ -2582,7 +2773,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
createdChains = append(createdChains, chainNode.NodeID)
|
||||
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])
|
||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, chainNode.NodeID)
|
||||
@@ -2594,7 +2785,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
|
||||
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, outNode.NodeID)
|
||||
@@ -2603,6 +2794,44 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, nil
|
||||
}
|
||||
|
||||
func retryTunnelServiceAddWithCleanup(add func() error, cleanup func() error, wait time.Duration) error {
|
||||
if add == nil {
|
||||
return errors.New("invalid tunnel service add callback")
|
||||
}
|
||||
err := add()
|
||||
if err == nil || !isAddressAlreadyInUseError(err) {
|
||||
return err
|
||||
}
|
||||
if cleanup == nil {
|
||||
return err
|
||||
}
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
return cleanupErr
|
||||
}
|
||||
if wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
return add()
|
||||
}
|
||||
|
||||
func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []map[string]interface{}) error {
|
||||
if h == nil {
|
||||
return errors.New("invalid tunnel service context")
|
||||
}
|
||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||
return retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
|
||||
return err
|
||||
},
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
|
||||
return err
|
||||
},
|
||||
tunnelServiceBindRetryDelay,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
|
||||
if h == nil || tunnelID <= 0 {
|
||||
return
|
||||
@@ -3338,6 +3567,14 @@ func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
||||
return speedID, nil
|
||||
}
|
||||
|
||||
func sameSpeedLimitSelection(current sql.NullInt64, requested *int64) bool {
|
||||
if requested == nil {
|
||||
return !current.Valid
|
||||
}
|
||||
|
||||
return current.Valid && current.Int64 == *requested
|
||||
}
|
||||
|
||||
func asAnySlice(v interface{}) []interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -3484,6 +3721,22 @@ func nullableText(s string) interface{} {
|
||||
return s
|
||||
}
|
||||
|
||||
func nullableUnixMilli(v int64) interface{} {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeNodeRenewalCycle(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "month", "quarter", "year":
|
||||
return strings.ToLower(strings.TrimSpace(v))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func nullableInt(v *int64) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -3519,3 +3772,82 @@ func asInt64Slice(v interface{}) []int64 {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateLocalNodePort(node *nodeRecord, port int) error {
|
||||
if node == nil || node.IsRemote == 1 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
portRange := strings.TrimSpace(node.PortRange)
|
||||
if portRange == "" {
|
||||
return nil
|
||||
}
|
||||
minPort, maxPort := parsePortRangeMinMax(portRange)
|
||||
if minPort <= 0 || maxPort <= 0 {
|
||||
return nil
|
||||
}
|
||||
if port < minPort || port > maxPort {
|
||||
return fmt.Errorf("端口 %d 超出节点 %s 允许范围 %d-%d", port, node.Name, minPort, maxPort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, currentForwardID int64) error {
|
||||
if h == nil || h.repo == nil || node == nil || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied {
|
||||
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePortRangeMinMax(input string) (int, int) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return 0, 0
|
||||
}
|
||||
minPort, maxPort := 0, 0
|
||||
parts := strings.Split(input, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
r := strings.SplitN(part, "-", 2)
|
||||
if len(r) != 2 {
|
||||
continue
|
||||
}
|
||||
start, err1 := strconv.Atoi(strings.TrimSpace(r[0]))
|
||||
end, err2 := strconv.Atoi(strings.TrimSpace(r[1]))
|
||||
if err1 != nil || err2 != nil || start <= 0 || end <= 0 {
|
||||
continue
|
||||
}
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if minPort == 0 || start < minPort {
|
||||
minPort = start
|
||||
}
|
||||
if maxPort == 0 || end > maxPort {
|
||||
maxPort = end
|
||||
}
|
||||
continue
|
||||
}
|
||||
p, err := strconv.Atoi(part)
|
||||
if err != nil || p <= 0 {
|
||||
continue
|
||||
}
|
||||
if minPort == 0 || p < minPort {
|
||||
minPort = p
|
||||
}
|
||||
if maxPort == 0 || p > maxPort {
|
||||
maxPort = p
|
||||
}
|
||||
}
|
||||
return minPort, maxPort
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func isTunnelQuotaExceeded(view *model.TunnelQuotaView) bool {
|
||||
if view == nil {
|
||||
return false
|
||||
}
|
||||
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*bytesPerGB {
|
||||
return true
|
||||
}
|
||||
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*bytesPerGB {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelQuotaBlockReason(tunnelID int64, now int64) (string, error) {
|
||||
if h == nil || h.repo == nil || tunnelID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
quota, err := h.repo.GetTunnelQuotaView(tunnelID, time.UnixMilli(now))
|
||||
if err != nil || quota == nil {
|
||||
return "", err
|
||||
}
|
||||
if quota.DisabledByQuota == 1 || isTunnelQuotaExceeded(quota) {
|
||||
return "该隧道流量配额已超额,禁止开启转发", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (h *Handler) enforceTunnelQuotaIfNeeded(tunnelID int64, quota *model.TunnelQuotaView) {
|
||||
if h == nil || h.repo == nil || tunnelID <= 0 || quota == nil {
|
||||
return
|
||||
}
|
||||
if quota.DisabledByQuota == 1 || !isTunnelQuotaExceeded(quota) {
|
||||
return
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pausedIDs := make([]int64, 0, len(forwards))
|
||||
now := time.Now().UnixMilli()
|
||||
for i := range forwards {
|
||||
if forwards[i].Status != 1 {
|
||||
continue
|
||||
}
|
||||
if err := h.controlForwardServices(&forwards[i], "PauseService", false); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpdateForwardStatus(forwards[i].ID, 0, now); err != nil {
|
||||
continue
|
||||
}
|
||||
pausedIDs = append(pausedIDs, forwards[i].ID)
|
||||
}
|
||||
_ = h.repo.UpdateTunnelStatus(tunnelID, 0, now)
|
||||
_ = h.repo.MarkTunnelQuotaDisabled(tunnelID, pausedIDs, now)
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelQuotaRelease(release *repo.TunnelQuotaRelease, now int64) {
|
||||
if h == nil || h.repo == nil || release == nil || release.TunnelID <= 0 || !release.EnableTunnel {
|
||||
return
|
||||
}
|
||||
_ = h.repo.UpdateTunnelStatus(release.TunnelID, 1, now)
|
||||
for _, forwardID := range release.ForwardIDs {
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
continue
|
||||
}
|
||||
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = h.repo.UpdateForwardStatus(forwardID, 1, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resetTunnelQuotaWindows(now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
releases, err := h.repo.RollTunnelQuotaWindows(now)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
for i := range releases {
|
||||
h.applyTunnelQuotaRelease(&releases[i], nowMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelQuotaReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.TunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
||||
return
|
||||
}
|
||||
release, err := h.repo.ResetTunnelQuotaUsage(req.TunnelID, req.Scope, time.Now())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
h.applyTunnelQuotaRelease(release, nowMs)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) ensureTunnelForwardAllowedByQuota(tunnelID int64, now int64) error {
|
||||
reason, err := h.tunnelQuotaBlockReason(tunnelID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reason != "" {
|
||||
return errors.New(reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -60,6 +60,9 @@ func (ForwardPort) TableName() string { return "forward_port" }
|
||||
type Node struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Remark sql.NullString `gorm:"column:remark;type:text"`
|
||||
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
|
||||
RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"`
|
||||
Secret string `gorm:"type:varchar(100);not null"`
|
||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||
@@ -126,6 +129,23 @@ type Tunnel struct {
|
||||
|
||||
func (Tunnel) TableName() string { return "tunnel" }
|
||||
|
||||
type TunnelQuota struct {
|
||||
TunnelID int64 `gorm:"column:tunnel_id;primaryKey"`
|
||||
DailyLimitGB int64 `gorm:"column:daily_limit_gb;not null;default:0"`
|
||||
MonthlyLimitGB int64 `gorm:"column:monthly_limit_gb;not null;default:0"`
|
||||
DailyUsedBytes int64 `gorm:"column:daily_used_bytes;not null;default:0"`
|
||||
MonthlyUsedBytes int64 `gorm:"column:monthly_used_bytes;not null;default:0"`
|
||||
DayKey int64 `gorm:"column:day_key;not null;default:0"`
|
||||
MonthKey int64 `gorm:"column:month_key;not null;default:0"`
|
||||
DisabledByQuota int `gorm:"column:disabled_by_quota;not null;default:0"`
|
||||
DisabledAt int64 `gorm:"column:disabled_at;not null;default:0"`
|
||||
PausedForwardIDs string `gorm:"column:paused_forward_ids;type:text;not null;default:''"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (TunnelQuota) TableName() string { return "tunnel_quota" }
|
||||
|
||||
type ChainTunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
@@ -336,6 +356,9 @@ type UserBackup struct {
|
||||
type NodeBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
||||
RenewalCycle string `json:"renewalCycle,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||
@@ -360,19 +383,23 @@ type NodeBackup struct {
|
||||
}
|
||||
|
||||
type TunnelBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrafficRatio float64 `json:"trafficRatio"`
|
||||
Type int `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
Flow int64 `json:"flow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
InIP string `json:"inIp,omitempty"`
|
||||
Inx int `json:"inx"`
|
||||
IPPreference string `json:"ipPreference,omitempty"`
|
||||
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrafficRatio float64 `json:"trafficRatio"`
|
||||
Type int `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
Flow int64 `json:"flow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
InIP string `json:"inIp,omitempty"`
|
||||
Inx int `json:"inx"`
|
||||
IPPreference string `json:"ipPreference,omitempty"`
|
||||
DailyQuotaGB int64 `json:"dailyQuotaGB,omitempty"`
|
||||
MonthlyQuotaGB int64 `json:"monthlyQuotaGB,omitempty"`
|
||||
DisabledByQuota int `json:"disabledByQuota,omitempty"`
|
||||
QuotaDisabledAt int64 `json:"quotaDisabledAt,omitempty"`
|
||||
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
|
||||
}
|
||||
|
||||
type ChainTunnelBackup struct {
|
||||
@@ -510,6 +537,19 @@ type TunnelRecord struct {
|
||||
TrafficRatio float64
|
||||
}
|
||||
|
||||
type TunnelQuotaView struct {
|
||||
TunnelID int64
|
||||
DailyLimitGB int64
|
||||
MonthlyLimitGB int64
|
||||
DailyUsedBytes int64
|
||||
MonthlyUsedBytes int64
|
||||
DayKey int64
|
||||
MonthKey int64
|
||||
DisabledByQuota int
|
||||
DisabledAt int64
|
||||
PausedForwardIDs string
|
||||
}
|
||||
|
||||
// ForwardPortRecord is a forward port mapping used by control plane.
|
||||
type ForwardPortRecord struct {
|
||||
NodeID int64
|
||||
@@ -573,6 +613,7 @@ type UserTunnelDetail struct {
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
Status int
|
||||
TunnelFlow int
|
||||
Flow int64
|
||||
InFlow int64
|
||||
|
||||
@@ -167,6 +167,7 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.SpeedLimit{},
|
||||
&model.StatisticsFlow{},
|
||||
&model.Tunnel{},
|
||||
&model.TunnelQuota{},
|
||||
&model.ChainTunnel{},
|
||||
&model.UserTunnel{},
|
||||
&model.TunnelGroup{},
|
||||
@@ -260,7 +261,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
|
||||
if m.HasTable(&model.Node{}) {
|
||||
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
|
||||
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "ExpiryTime", "RenewalCycle"} {
|
||||
if m.HasColumn(&model.Node{}, field) {
|
||||
continue
|
||||
}
|
||||
@@ -447,7 +448,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
|
||||
}
|
||||
var items []model.UserTunnelDetail
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
|
||||
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
|
||||
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
|
||||
Where("user_tunnel.user_id = ?", userID).
|
||||
@@ -634,7 +635,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
for _, n := range nodes {
|
||||
items = append(items, map[string]interface{}{
|
||||
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"remark": nullableString(n.Remark),
|
||||
"expiryTime": nullableInt64(n.ExpiryTime),
|
||||
"renewalCycle": nullableString(n.RenewalCycle),
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"serverIpV4": nullableString(n.ServerIPV4),
|
||||
"serverIpV6": nullableString(n.ServerIPV6),
|
||||
"extraIPs": nullableString(n.ExtraIPs),
|
||||
@@ -700,25 +704,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
type fwdRow struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
TrafficRatio float64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
}
|
||||
|
||||
var rows []fwdRow
|
||||
err := r.db.Model(&model.Forward{}).
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Order("forward.inx ASC, forward.id ASC").
|
||||
Find(&rows).Error
|
||||
@@ -735,7 +740,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
item := map[string]interface{}{
|
||||
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
|
||||
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
|
||||
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
||||
"tunnelTrafficRatio": row.TrafficRatio,
|
||||
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
||||
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
||||
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
||||
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
||||
@@ -767,9 +773,21 @@ func (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]inter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tunnelIDs := make([]int64, 0, len(rows))
|
||||
for _, rw := range rows {
|
||||
tunnelIDs = append(tunnelIDs, rw.ID)
|
||||
}
|
||||
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
|
||||
for _, rw := range rows {
|
||||
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
|
||||
if pr, ok := portRangeMap[rw.ID]; ok {
|
||||
item["portRangeMin"] = pr.min
|
||||
item["portRangeMax"] = pr.max
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -788,13 +806,146 @@ func (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tunnelIDs := make([]int64, 0, len(rows))
|
||||
for _, rw := range rows {
|
||||
tunnelIDs = append(tunnelIDs, rw.ID)
|
||||
}
|
||||
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
|
||||
for _, rw := range rows {
|
||||
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
|
||||
if pr, ok := portRangeMap[rw.ID]; ok {
|
||||
item["portRangeMin"] = pr.min
|
||||
item["portRangeMax"] = pr.max
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type tunnelPortRange struct {
|
||||
min int
|
||||
max int
|
||||
}
|
||||
|
||||
func (r *Repository) getTunnelEntryPortRanges(tunnelIDs []int64) map[int64]tunnelPortRange {
|
||||
result := make(map[int64]tunnelPortRange)
|
||||
if len(tunnelIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
type entryNode struct {
|
||||
TunnelID int64
|
||||
NodeID int64
|
||||
}
|
||||
var entries []entryNode
|
||||
r.db.Model(&model.ChainTunnel{}).
|
||||
Select("tunnel_id, node_id").
|
||||
Where("tunnel_id IN (?) AND chain_type = ?", tunnelIDs, "1").
|
||||
Find(&entries)
|
||||
|
||||
nodeIDs := make([]int64, 0, len(entries))
|
||||
nodeSet := make(map[int64]struct{})
|
||||
for _, e := range entries {
|
||||
if _, exists := nodeSet[e.NodeID]; !exists {
|
||||
nodeSet[e.NodeID] = struct{}{}
|
||||
nodeIDs = append(nodeIDs, e.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
type nodePort struct {
|
||||
ID int64
|
||||
Port string
|
||||
}
|
||||
var nodePorts []nodePort
|
||||
if len(nodeIDs) > 0 {
|
||||
r.db.Model(&model.Node{}).Select("id, port").Where("id IN (?)", nodeIDs).Find(&nodePorts)
|
||||
}
|
||||
|
||||
nodePortMap := make(map[int64]string)
|
||||
for _, np := range nodePorts {
|
||||
nodePortMap[np.ID] = np.Port
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
portSpec := nodePortMap[e.NodeID]
|
||||
if portSpec == "" {
|
||||
continue
|
||||
}
|
||||
minP, maxP := parsePortRangeMinMax(portSpec)
|
||||
if minP <= 0 || maxP <= 0 {
|
||||
continue
|
||||
}
|
||||
pr, exists := result[e.TunnelID]
|
||||
if !exists {
|
||||
result[e.TunnelID] = tunnelPortRange{min: minP, max: maxP}
|
||||
} else {
|
||||
if minP < pr.min {
|
||||
pr.min = minP
|
||||
}
|
||||
if maxP > pr.max {
|
||||
pr.max = maxP
|
||||
}
|
||||
result[e.TunnelID] = pr
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parsePortRangeMinMax(input string) (int, int) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return 0, 0
|
||||
}
|
||||
minPort, maxPort := 0, 0
|
||||
parts := strings.Split(input, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
r := strings.SplitN(part, "-", 2)
|
||||
if len(r) != 2 {
|
||||
continue
|
||||
}
|
||||
start, end := parseIntPort(r[0]), parseIntPort(r[1])
|
||||
if start <= 0 || end <= 0 {
|
||||
continue
|
||||
}
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if minPort == 0 || start < minPort {
|
||||
minPort = start
|
||||
}
|
||||
if maxPort == 0 || end > maxPort {
|
||||
maxPort = end
|
||||
}
|
||||
continue
|
||||
}
|
||||
p := parseIntPort(part)
|
||||
if p <= 0 {
|
||||
continue
|
||||
}
|
||||
if minPort == 0 || p < minPort {
|
||||
minPort = p
|
||||
}
|
||||
if maxPort == 0 || p > maxPort {
|
||||
maxPort = p
|
||||
}
|
||||
}
|
||||
return minPort, maxPort
|
||||
}
|
||||
|
||||
func parseIntPort(s string) int {
|
||||
var p int
|
||||
fmt.Sscanf(strings.TrimSpace(s), "%d", &p)
|
||||
return p
|
||||
}
|
||||
|
||||
func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -807,6 +958,7 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
||||
|
||||
tunnelMap := make(map[int64]map[string]interface{})
|
||||
orderedIDs := make([]int64, 0, len(tunnels))
|
||||
tunnelIDs := make([]int64, 0, len(tunnels))
|
||||
|
||||
for _, t := range tunnels {
|
||||
tunnelMap[t.ID] = map[string]interface{}{
|
||||
@@ -820,6 +972,24 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
||||
"chainNodes": make([][]map[string]interface{}, 0),
|
||||
}
|
||||
orderedIDs = append(orderedIDs, t.ID)
|
||||
tunnelIDs = append(tunnelIDs, t.ID)
|
||||
}
|
||||
|
||||
quotaMap, err := r.ListTunnelQuotaViewsByTunnelIDs(tunnelIDs, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for tunnelID, quota := range quotaMap {
|
||||
item := tunnelMap[tunnelID]
|
||||
if item == nil || quota == nil {
|
||||
continue
|
||||
}
|
||||
item["dailyQuotaGB"] = quota.DailyLimitGB
|
||||
item["monthlyQuotaGB"] = quota.MonthlyLimitGB
|
||||
item["dailyUsedBytes"] = quota.DailyUsedBytes
|
||||
item["monthlyUsedBytes"] = quota.MonthlyUsedBytes
|
||||
item["disabledByQuota"] = quota.DisabledByQuota
|
||||
item["quotaDisabledAt"] = quota.DisabledAt
|
||||
}
|
||||
|
||||
// Build node IP map
|
||||
@@ -1669,11 +1839,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
|
||||
for _, n := range nodes {
|
||||
b := model.NodeBackup{
|
||||
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
||||
Remark: n.Remark.String, RenewalCycle: n.RenewalCycle.String,
|
||||
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
|
||||
CreatedTime: n.CreatedTime, Status: n.Status,
|
||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||
Inx: n.Inx, IsRemote: n.IsRemote,
|
||||
}
|
||||
if n.ExpiryTime.Valid {
|
||||
b.ExpiryTime = n.ExpiryTime.Int64
|
||||
}
|
||||
if n.UpdatedTime.Valid {
|
||||
b.UpdatedTime = n.UpdatedTime.Int64
|
||||
}
|
||||
@@ -1708,6 +1882,14 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
|
||||
if err := r.db.Order("inx ASC, id ASC").Find(&tunnels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quotaIDs := make([]int64, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
quotaIDs = append(quotaIDs, t.ID)
|
||||
}
|
||||
quotaMap, err := r.ListTunnelQuotaViewsByTunnelIDs(quotaIDs, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.TunnelBackup, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
b := model.TunnelBackup{
|
||||
@@ -1716,6 +1898,12 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
|
||||
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
|
||||
Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,
|
||||
}
|
||||
if quota := quotaMap[t.ID]; quota != nil {
|
||||
b.DailyQuotaGB = quota.DailyLimitGB
|
||||
b.MonthlyQuotaGB = quota.MonthlyLimitGB
|
||||
b.DisabledByQuota = quota.DisabledByQuota
|
||||
b.QuotaDisabledAt = quota.DisabledAt
|
||||
}
|
||||
if t.InIP.Valid {
|
||||
b.InIP = t.InIP.String
|
||||
}
|
||||
@@ -2023,6 +2211,9 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
||||
item := model.Node{
|
||||
ID: n.ID,
|
||||
Name: n.Name,
|
||||
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
|
||||
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
|
||||
RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""},
|
||||
Secret: n.Secret,
|
||||
ServerIP: n.ServerIP,
|
||||
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
|
||||
@@ -2047,7 +2238,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
||||
"name", "remark", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
||||
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
|
||||
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
||||
}),
|
||||
@@ -2086,6 +2277,26 @@ func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, e
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
quotaItem := model.TunnelQuota{
|
||||
TunnelID: t.ID,
|
||||
DailyLimitGB: t.DailyQuotaGB,
|
||||
MonthlyLimitGB: t.MonthlyQuotaGB,
|
||||
DisabledByQuota: t.DisabledByQuota,
|
||||
DisabledAt: t.QuotaDisabledAt,
|
||||
DayKey: int64(time.Now().Year()*10000 + int(time.Now().Month())*100 + time.Now().Day()),
|
||||
MonthKey: int64(time.Now().Year()*100 + int(time.Now().Month())),
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
err = tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tunnel_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"daily_limit_gb", "monthly_limit_gb", "disabled_by_quota", "disabled_at", "updated_time",
|
||||
}),
|
||||
}).Create("aItem).Error
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
for _, ct := range t.ChainTunnels {
|
||||
chainItem := model.ChainTunnel{
|
||||
ID: ct.ID,
|
||||
@@ -2467,11 +2678,12 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────
|
||||
|
||||
const currentSchemaVersion = 4
|
||||
const currentSchemaVersion = 5
|
||||
|
||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
|
||||
var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns
|
||||
|
||||
func getSchemaVersion(db *gorm.DB) int {
|
||||
var v model.SchemaVersion
|
||||
@@ -2535,6 +2747,12 @@ func migrateSchema(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if ver < 5 {
|
||||
if err := migratePostgresTrafficInt64ColumnsFn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
setSchemaVersion(db, currentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
@@ -2599,6 +2817,87 @@ func migrateSpeedLimitTunnelBinding(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migratePostgresTrafficInt64Columns(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type trafficColumn struct {
|
||||
TableName string
|
||||
ColumnName string
|
||||
}
|
||||
|
||||
columns := []trafficColumn{
|
||||
{TableName: "user", ColumnName: "flow"},
|
||||
{TableName: "user", ColumnName: "in_flow"},
|
||||
{TableName: "user", ColumnName: "out_flow"},
|
||||
{TableName: "forward", ColumnName: "in_flow"},
|
||||
{TableName: "forward", ColumnName: "out_flow"},
|
||||
{TableName: "statistics_flow", ColumnName: "flow"},
|
||||
{TableName: "statistics_flow", ColumnName: "total_flow"},
|
||||
{TableName: "tunnel", ColumnName: "flow"},
|
||||
{TableName: "user_tunnel", ColumnName: "flow"},
|
||||
{TableName: "user_tunnel", ColumnName: "in_flow"},
|
||||
{TableName: "user_tunnel", ColumnName: "out_flow"},
|
||||
{TableName: "peer_share", ColumnName: "max_bandwidth"},
|
||||
{TableName: "peer_share", ColumnName: "current_flow"},
|
||||
}
|
||||
|
||||
for _, column := range columns {
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(db, column.TableName, column.ColumnName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
if tableName == "" || columnName == "" {
|
||||
return errors.New("empty table or column name")
|
||||
}
|
||||
|
||||
type columnRow struct {
|
||||
DataType string `gorm:"column:data_type"`
|
||||
}
|
||||
|
||||
var row columnRow
|
||||
if err := db.Raw(
|
||||
`SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
AND column_name = ?`,
|
||||
tableName, columnName,
|
||||
).Scan(&row).Error; err != nil {
|
||||
return fmt.Errorf("inspect %s.%s type: %w", tableName, columnName, err)
|
||||
}
|
||||
|
||||
if row.DataType == "" || strings.EqualFold(row.DataType, "bigint") {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(row.DataType, "integer") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec(fmt.Sprintf(
|
||||
"ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT",
|
||||
quoteSQLIdentifier(tableName),
|
||||
quoteSQLIdentifier(columnName),
|
||||
)).Error; err != nil {
|
||||
return fmt.Errorf("alter %s.%s to bigint: %w", tableName, columnName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePostgresIDDefaults(db *gorm.DB) error {
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
|
||||
@@ -3,13 +3,61 @@ package repo
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gsqlite "github.com/glebarez/sqlite"
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy node table: %v", err)
|
||||
}
|
||||
|
||||
if err := prepareSQLiteLegacyColumns(db); err != nil {
|
||||
t.Fatalf("prepareSQLiteLegacyColumns: %v", err)
|
||||
}
|
||||
|
||||
m := db.Migrator()
|
||||
for _, field := range []string{"Remark", "ExpiryTime", "RenewalCycle"} {
|
||||
if !m.HasColumn(&model.Node{}, field) {
|
||||
t.Fatalf("expected node.%s column to exist", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
@@ -250,3 +298,115 @@ func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsTrafficInt64MigrationForLegacySchema(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
called := 0
|
||||
originalMigrate := migratePostgresTrafficInt64ColumnsFn
|
||||
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
|
||||
called++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migratePostgresTrafficInt64ColumnsFn = originalMigrate
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("expected traffic bigint migration to run once, got %d", called)
|
||||
}
|
||||
|
||||
var schemaVersion int
|
||||
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
|
||||
t.Fatalf("query schema_version: %v", err)
|
||||
}
|
||||
if schemaVersion != currentSchemaVersion {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaReturnsTrafficInt64MigrationError(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
wantErr := errors.New("traffic bigint migration failed")
|
||||
originalMigrate := migratePostgresTrafficInt64ColumnsFn
|
||||
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
|
||||
return wantErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migratePostgresTrafficInt64ColumnsFn = originalMigrate
|
||||
})
|
||||
|
||||
err = migrateSchema(db)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlterPostgresColumnToBigIntIfNeededValidatesNames(t *testing.T) {
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(nil, "peer_share", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "nil db") {
|
||||
t.Fatalf("expected nil db error, got %v", err)
|
||||
}
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
|
||||
t.Fatalf("expected empty name error, got %v", err)
|
||||
}
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "peer_share", ""); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
|
||||
t.Fatalf("expected empty name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package repo
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -196,12 +197,15 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
node := model.Node{
|
||||
Name: name,
|
||||
Remark: nullStringFromInterface(remark),
|
||||
ExpiryTime: nullInt64FromInterface(expiryTime),
|
||||
RenewalCycle: nullStringFromInterface(renewalCycle),
|
||||
Secret: secret,
|
||||
ServerIP: serverIP,
|
||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
||||
@@ -239,7 +243,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
|
||||
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -247,6 +251,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"name": name,
|
||||
"remark": nullStringFromInterface(remark),
|
||||
"expiry_time": nullInt64FromInterface(expiryTime),
|
||||
"renewal_cycle": nullStringFromInterface(renewalCycle),
|
||||
"server_ip": serverIP,
|
||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||
@@ -1477,3 +1484,59 @@ func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64
|
||||
}
|
||||
return affectedGroupIDs, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AdvanceNodeRenewalCycles(now int64) (int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var nodes []model.Node
|
||||
if err := r.db.Where("renewal_cycle IS NOT NULL AND renewal_cycle != '' AND expiry_time IS NOT NULL").Find(&nodes).Error; err != nil {
|
||||
return 0, fmt.Errorf("list nodes with renewal cycle: %w", err)
|
||||
}
|
||||
|
||||
advanced := 0
|
||||
for _, node := range nodes {
|
||||
if !node.ExpiryTime.Valid || node.ExpiryTime.Int64 <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
cycleMonths := 0
|
||||
switch node.RenewalCycle.String {
|
||||
case "month":
|
||||
cycleMonths = 1
|
||||
case "quarter":
|
||||
cycleMonths = 3
|
||||
case "year":
|
||||
cycleMonths = 12
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
anchorTime := node.ExpiryTime.Int64
|
||||
for anchorTime <= now {
|
||||
nextAnchor := advanceByMonths(anchorTime, cycleMonths)
|
||||
if nextAnchor <= anchorTime {
|
||||
break
|
||||
}
|
||||
anchorTime = nextAnchor
|
||||
}
|
||||
|
||||
if anchorTime == node.ExpiryTime.Int64 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.db.Model(&model.Node{}).Where("id = ?", node.ID).Update("expiry_time", anchorTime).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
advanced++
|
||||
}
|
||||
|
||||
return advanced, nil
|
||||
}
|
||||
|
||||
func advanceByMonths(timestamp int64, months int) int64 {
|
||||
t := time.Unix(timestamp/1000, 0)
|
||||
next := t.AddDate(0, months, 0)
|
||||
return next.UnixMilli()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const tunnelQuotaBytesPerGB int64 = 1024 * 1024 * 1024
|
||||
|
||||
type TunnelQuotaRelease struct {
|
||||
TunnelID int64
|
||||
ForwardIDs []int64
|
||||
EnableTunnel bool
|
||||
}
|
||||
|
||||
func tunnelQuotaWindowKeys(now time.Time) (int64, int64) {
|
||||
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day()), int64(now.Year()*100 + int(now.Month()))
|
||||
}
|
||||
|
||||
func cloneTunnelQuotaView(q model.TunnelQuota) *model.TunnelQuotaView {
|
||||
return &model.TunnelQuotaView{
|
||||
TunnelID: q.TunnelID,
|
||||
DailyLimitGB: q.DailyLimitGB,
|
||||
MonthlyLimitGB: q.MonthlyLimitGB,
|
||||
DailyUsedBytes: q.DailyUsedBytes,
|
||||
MonthlyUsedBytes: q.MonthlyUsedBytes,
|
||||
DayKey: q.DayKey,
|
||||
MonthKey: q.MonthKey,
|
||||
DisabledByQuota: q.DisabledByQuota,
|
||||
DisabledAt: q.DisabledAt,
|
||||
PausedForwardIDs: q.PausedForwardIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTunnelQuotaView(view *model.TunnelQuotaView, now time.Time) *model.TunnelQuotaView {
|
||||
if view == nil {
|
||||
return nil
|
||||
}
|
||||
dayKey, monthKey := tunnelQuotaWindowKeys(now)
|
||||
out := *view
|
||||
if out.DayKey != dayKey {
|
||||
out.DayKey = dayKey
|
||||
out.DailyUsedBytes = 0
|
||||
}
|
||||
if out.MonthKey != monthKey {
|
||||
out.MonthKey = monthKey
|
||||
out.MonthlyUsedBytes = 0
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func tunnelQuotaExceeded(view *model.TunnelQuotaView) bool {
|
||||
if view == nil {
|
||||
return false
|
||||
}
|
||||
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*tunnelQuotaBytesPerGB {
|
||||
return true
|
||||
}
|
||||
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*tunnelQuotaBytesPerGB {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parsePausedForwardIDs(raw string) []int64 {
|
||||
parts := strings.Split(strings.TrimSpace(raw), ",")
|
||||
out := make([]int64, 0, len(parts))
|
||||
seen := make(map[int64]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinPausedForwardIDs(ids []int64) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
parts = append(parts, strconv.FormatInt(id, 10))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (r *Repository) loadOrCreateTunnelQuotaTx(tx *gorm.DB, tunnelID int64, now time.Time) (*model.TunnelQuota, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
dayKey, monthKey := tunnelQuotaWindowKeys(now)
|
||||
q := &model.TunnelQuota{}
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tunnel_id = ?", tunnelID).First(q).Error
|
||||
if err == nil {
|
||||
return q, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
q = &model.TunnelQuota{
|
||||
TunnelID: tunnelID,
|
||||
DayKey: dayKey,
|
||||
MonthKey: monthKey,
|
||||
CreatedTime: nowMs,
|
||||
UpdatedTime: nowMs,
|
||||
PausedForwardIDs: "",
|
||||
}
|
||||
if err := tx.Create(q).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func applyTunnelQuotaWindowRoll(q *model.TunnelQuota, now time.Time) bool {
|
||||
if q == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
dayKey, monthKey := tunnelQuotaWindowKeys(now)
|
||||
if q.DayKey != dayKey {
|
||||
q.DayKey = dayKey
|
||||
q.DailyUsedBytes = 0
|
||||
changed = true
|
||||
}
|
||||
if q.MonthKey != monthKey {
|
||||
q.MonthKey = monthKey
|
||||
q.MonthlyUsedBytes = 0
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (r *Repository) SaveTunnelQuotaConfigTx(tx *gorm.DB, tunnelID, dailyLimitGB, monthlyLimitGB int64, now int64) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
return errors.New("tunnel id is required")
|
||||
}
|
||||
if dailyLimitGB < 0 || monthlyLimitGB < 0 {
|
||||
return errors.New("quota limit cannot be negative")
|
||||
}
|
||||
current := time.UnixMilli(now)
|
||||
q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"daily_limit_gb": dailyLimitGB,
|
||||
"monthly_limit_gb": monthlyLimitGB,
|
||||
"updated_time": now,
|
||||
}
|
||||
if q.DayKey == 0 || q.MonthKey == 0 {
|
||||
dayKey, monthKey := tunnelQuotaWindowKeys(current)
|
||||
updates["day_key"] = dayKey
|
||||
updates["month_key"] = monthKey
|
||||
}
|
||||
return tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListTunnelQuotaViewsByTunnelIDs(tunnelIDs []int64, now time.Time) (map[int64]*model.TunnelQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
out := make(map[int64]*model.TunnelQuotaView)
|
||||
if len(tunnelIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []model.TunnelQuota
|
||||
if err := r.db.Where("tunnel_id IN ?", tunnelIDs).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.TunnelID] = normalizeTunnelQuotaView(cloneTunnelQuotaView(row), now)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelQuotaView(tunnelID int64, now time.Time) (*model.TunnelQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var row model.TunnelQuota
|
||||
err := r.db.Where("tunnel_id = ?", tunnelID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeTunnelQuotaView(cloneTunnelQuotaView(row), now), nil
|
||||
}
|
||||
|
||||
func (r *Repository) AddTunnelQuotaUsage(tunnelID int64, usedBytes int64, now time.Time) (*model.TunnelQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := &model.TunnelQuotaView{}
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyTunnelQuotaWindowRoll(q, now)
|
||||
if usedBytes > 0 {
|
||||
q.DailyUsedBytes += usedBytes
|
||||
q.MonthlyUsedBytes += usedBytes
|
||||
}
|
||||
q.UpdatedTime = now.UnixMilli()
|
||||
if err := tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{
|
||||
"daily_used_bytes": q.DailyUsedBytes,
|
||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||
"day_key": q.DayKey,
|
||||
"month_key": q.MonthKey,
|
||||
"updated_time": q.UpdatedTime,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
*result = *cloneTunnelQuotaView(*q)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeTunnelQuotaView(result, now), nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkTunnelQuotaDisabled(tunnelID int64, pausedForwardIDs []int64, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
return errors.New("tunnel id is required")
|
||||
}
|
||||
return r.db.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{
|
||||
"disabled_by_quota": 1,
|
||||
"disabled_at": now,
|
||||
"paused_forward_ids": joinPausedForwardIDs(pausedForwardIDs),
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ResetTunnelQuotaUsage(tunnelID int64, scope string, now time.Time) (*TunnelQuotaRelease, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
return nil, errors.New("tunnel id is required")
|
||||
}
|
||||
scope = strings.TrimSpace(strings.ToLower(scope))
|
||||
if scope == "" {
|
||||
scope = "all"
|
||||
}
|
||||
if scope != "daily" && scope != "monthly" && scope != "all" {
|
||||
return nil, fmt.Errorf("unsupported quota reset scope: %s", scope)
|
||||
}
|
||||
var release *TunnelQuotaRelease
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyTunnelQuotaWindowRoll(q, now)
|
||||
switch scope {
|
||||
case "daily":
|
||||
q.DailyUsedBytes = 0
|
||||
case "monthly":
|
||||
q.MonthlyUsedBytes = 0
|
||||
case "all":
|
||||
q.DailyUsedBytes = 0
|
||||
q.MonthlyUsedBytes = 0
|
||||
}
|
||||
q.UpdatedTime = now.UnixMilli()
|
||||
release = &TunnelQuotaRelease{TunnelID: tunnelID}
|
||||
if q.DisabledByQuota == 1 && !tunnelQuotaExceeded(cloneTunnelQuotaView(*q)) {
|
||||
release.EnableTunnel = true
|
||||
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
||||
q.DisabledByQuota = 0
|
||||
q.DisabledAt = 0
|
||||
q.PausedForwardIDs = ""
|
||||
}
|
||||
return tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{
|
||||
"daily_used_bytes": q.DailyUsedBytes,
|
||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||
"day_key": q.DayKey,
|
||||
"month_key": q.MonthKey,
|
||||
"disabled_by_quota": q.DisabledByQuota,
|
||||
"disabled_at": q.DisabledAt,
|
||||
"paused_forward_ids": q.PausedForwardIDs,
|
||||
"updated_time": q.UpdatedTime,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RollTunnelQuotaWindows(now time.Time) ([]TunnelQuotaRelease, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var releases []TunnelQuotaRelease
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var rows []model.TunnelQuota
|
||||
if err := tx.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
for _, row := range rows {
|
||||
q := row
|
||||
changed := applyTunnelQuotaWindowRoll(&q, now)
|
||||
release := TunnelQuotaRelease{TunnelID: q.TunnelID}
|
||||
if q.DisabledByQuota == 1 && !tunnelQuotaExceeded(cloneTunnelQuotaView(q)) {
|
||||
release.EnableTunnel = true
|
||||
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
||||
q.DisabledByQuota = 0
|
||||
q.DisabledAt = 0
|
||||
q.PausedForwardIDs = ""
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
q.UpdatedTime = nowMs
|
||||
if err := tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", q.TunnelID).Updates(map[string]interface{}{
|
||||
"daily_used_bytes": q.DailyUsedBytes,
|
||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||
"day_key": q.DayKey,
|
||||
"month_key": q.MonthKey,
|
||||
"disabled_by_quota": q.DisabledByQuota,
|
||||
"disabled_at": q.DisabledAt,
|
||||
"paused_forward_ids": q.PausedForwardIDs,
|
||||
"updated_time": q.UpdatedTime,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if release.EnableTunnel {
|
||||
releases = append(releases, release)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateTunnelStatus(tunnelID int64, status int, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
return errors.New("tunnel id is required")
|
||||
}
|
||||
return r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const contractBytesPerGB int64 = 1024 * 1024 * 1024
|
||||
|
||||
func TestForwardResumeBlockedWhenUserFlowExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(2)
|
||||
tunnelID := int64(1)
|
||||
forwardID := int64(1)
|
||||
|
||||
flowGB := int64(120)
|
||||
used := flowGB*contractBytesPerGB + 1
|
||||
|
||||
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(?, 'flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
|
||||
`, userID, flowGB, used, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, 'flow_user', 'flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "flow_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when flow exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "流量") {
|
||||
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
|
||||
}
|
||||
|
||||
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
|
||||
if status != 0 {
|
||||
t.Fatalf("expected forward status to remain 0, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResumeBlockedWhenUserTunnelFlowExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(2)
|
||||
tunnelID := int64(1)
|
||||
forwardID := int64(1)
|
||||
|
||||
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(?, 'ut_flow_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, userID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'ut_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
utFlowGB := int64(120)
|
||||
utUsed := utFlowGB * contractBytesPerGB
|
||||
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(10, ?, ?, NULL, 99999, ?, ?, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID, utFlowGB, utUsed).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, 'ut_flow_user', 'ut_flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "ut_flow_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when tunnel flow exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "隧道") || !strings.Contains(out.Msg, "流量") {
|
||||
t.Fatalf("expected tunnel flow exceeded message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateBlockedWhenFlowExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(2)
|
||||
tunnelID := int64(1)
|
||||
|
||||
flowGB := int64(120)
|
||||
used := flowGB*contractBytesPerGB + 1
|
||||
|
||||
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(?, 'create_flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
|
||||
`, userID, flowGB, used, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'create_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "create_flow_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"n","remoteAddr":"1.1.1.1:53"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when flow exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "流量") {
|
||||
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -29,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
`, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
|
||||
@@ -116,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
if got := int64(idFloat); got != userForwardID {
|
||||
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
|
||||
}
|
||||
ratioFloat, ok := item["tunnelTrafficRatio"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"])
|
||||
}
|
||||
if ratioFloat != 2.5 {
|
||||
t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
|
||||
@@ -914,6 +923,164 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardUpdateRecoversFromAddressInUseContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(202, 'forward_bind_retry_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-bind-retry-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "forward-bind-retry-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "forward-bind-retry-target",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
counts := map[string]int{}
|
||||
var addServiceAddrs []string
|
||||
triggerConflict := false
|
||||
stopNode := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
key := strings.ToLower(strings.TrimSpace(cmdType))
|
||||
mu.Lock()
|
||||
counts[key]++
|
||||
attempt := counts[key]
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") || strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") {
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &services); err == nil {
|
||||
for _, svc := range services {
|
||||
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
|
||||
addServiceAddrs = append(addServiceAddrs, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shouldFail := false
|
||||
if triggerConflict {
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") && attempt == 1 {
|
||||
shouldFail = true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
|
||||
shouldFail = true
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if shouldFail {
|
||||
return true, "create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopNode()
|
||||
waitNodeStatus(t, repo, nodeID, 1)
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
mu.Lock()
|
||||
counts = map[string]int{}
|
||||
addServiceAddrs = nil
|
||||
triggerConflict = true
|
||||
mu.Unlock()
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "forward-bind-retry-target")
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "forward-bind-retry-target-updated",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.9.9.9:8443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", adminToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
assertCode(t, updateRes, 0)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
boundPort := mustQueryInt(t, repo, `SELECT port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||
if counts["updateservice"] != 1 {
|
||||
t.Fatalf("expected one UpdateService attempt, got %d (%v)", counts["updateservice"], counts)
|
||||
}
|
||||
if counts["deleteservice"] == 0 {
|
||||
t.Fatalf("expected DeleteService cleanup after address-in-use (%v)", counts)
|
||||
}
|
||||
if counts["addservice"] < 2 {
|
||||
t.Fatalf("expected AddService retry path to run at least twice total, got %d (%v)", counts["addservice"], counts)
|
||||
}
|
||||
foundBindAddr := false
|
||||
for _, addr := range addServiceAddrs {
|
||||
if addr == "10.42.0.9:"+strconv.Itoa(boundPort) {
|
||||
foundBindAddr = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundBindAddr {
|
||||
t.Fatalf("expected forward runtime to keep node listen addr 10.42.0.9:%d, got %v", boundPort, addServiceAddrs)
|
||||
}
|
||||
|
||||
storedRemoteAddr := mustQueryString(t, repo, `SELECT remote_addr FROM forward WHERE id = ?`, forwardID)
|
||||
if storedRemoteAddr != "9.9.9.9:8443" {
|
||||
t.Fatalf("expected remote_addr update to persist, got %q", storedRemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonNumber(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -998,9 +1165,9 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot set inPort on create", func(t *testing.T) {
|
||||
t.Run("non-admin cannot set inPort out of range on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-port",
|
||||
"name": "perm-forward-port-out",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
@@ -1015,7 +1182,33 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口")
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code >= 0 {
|
||||
t.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-admin can set inPort within range on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-port-in",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"inPort": 30005,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
|
||||
@@ -1059,7 +1252,7 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot update inPort", func(t *testing.T) {
|
||||
t.Run("non-admin cannot update inPort out of range", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated2",
|
||||
@@ -1076,7 +1269,33 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口")
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code >= 0 {
|
||||
t.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-admin can update inPort within range", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated3",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "5.6.7.8:443",
|
||||
"inPort": 30006,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
|
||||
@@ -1098,6 +1317,30 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can update when request keeps existing speedId", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`UPDATE forward SET speed_id = ? WHERE id = ?`, speedID, forwardID).Error; err != nil {
|
||||
t.Fatalf("assign forward speed limit: %v", err)
|
||||
}
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-keep-speed",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.10.11.12:443",
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-null-values",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -538,6 +539,549 @@ func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateRecoversFromAddressInUseContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-retry", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "tunnel-bind-retry")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-entry", "tunnel-bind-entry-secret", "10.41.0.1", "10.41.0.1", "", "43000-43010", "", "v1", 1, 1, 1, now, now, 1, "10.41.0.1", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert entry node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, r, "tunnel-bind-entry")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-exit", "tunnel-bind-exit-secret", "10.41.0.2", "10.41.0.2", "", "43100-43110", "eth0", "v1", 1, 1, 1, now, now, 1, "10.41.0.9", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert exit node: %v", err)
|
||||
}
|
||||
exitNodeID := mustLastInsertID(t, r, "tunnel-bind-exit")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 43001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(?, 3, ?, 43101, 'round', 1, 'tls', ?)
|
||||
`, tunnelID, exitNodeID, "10.41.0.99").Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
var commandMu sync.Mutex
|
||||
commandCounts := map[string]int{}
|
||||
var addServiceAddrs []string
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
commandCounts["entry:"+strings.ToLower(strings.TrimSpace(cmdType))]++
|
||||
return false, ""
|
||||
})
|
||||
defer stopEntry()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
key := "exit:" + strings.ToLower(strings.TrimSpace(cmdType))
|
||||
commandMu.Lock()
|
||||
commandCounts[key]++
|
||||
attempt := commandCounts[key]
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &services); err == nil {
|
||||
for _, svc := range services {
|
||||
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
|
||||
addServiceAddrs = append(addServiceAddrs, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commandMu.Unlock()
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
|
||||
return true, "listen tcp 10.41.0.99:43101: bind: address already in use"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopExit()
|
||||
waitNodeStatus(t, r, entryNodeID, 1)
|
||||
waitNodeStatus(t, r, exitNodeID, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"id": tunnelID,
|
||||
"name": "tunnel-bind-retry",
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"trafficRatio": 1.0,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 43101, "connectIp": "10.41.0.99"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if commandCounts["exit:addservice"] != 2 {
|
||||
t.Fatalf("expected exit AddService twice, got %d (%v)", commandCounts["exit:addservice"], sortedCommandCounts(commandCounts))
|
||||
}
|
||||
if commandCounts["exit:deleteservice"] == 0 {
|
||||
t.Fatalf("expected exit DeleteService retry cleanup to run (%v)", sortedCommandCounts(commandCounts))
|
||||
}
|
||||
if len(addServiceAddrs) < 2 {
|
||||
t.Fatalf("expected recorded AddService addresses, got %v", addServiceAddrs)
|
||||
}
|
||||
for _, addr := range addServiceAddrs {
|
||||
if addr != "10.41.0.99:43101" {
|
||||
t.Fatalf("expected connectIp to stay preferred in AddService addr, got %q", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'issue281_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue281-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "issue281-tunnel")
|
||||
|
||||
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
oldEntryNodeID := insertNode("issue281-old-entry", "issue281-old-entry-secret", "10.51.0.1", "51000-51010", 0)
|
||||
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "52000-52010", 1)
|
||||
exitNodeID := insertNode("issue281-exit", "issue281-exit-secret", "10.51.0.3", "53000-53010", 2)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 51001, 'round', 1, 'tls')
|
||||
`, tunnelID, oldEntryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert old entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 53001, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.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(281, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'issue281_user', 'issue281-forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "issue281-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, oldEntryNodeID, 51001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardBase := fmt.Sprintf("%d_%d_%d", forwardID, 2, 281)
|
||||
|
||||
var commandMu sync.Mutex
|
||||
oldEntryDeleteNames := make([]string, 0)
|
||||
newEntryUpdateNames := make([]string, 0)
|
||||
|
||||
recordForwardServiceNames := func(data json.RawMessage, list *[]string) {
|
||||
var serviceList []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &serviceList); err == nil {
|
||||
for _, service := range serviceList {
|
||||
name, _ := service["name"].(string)
|
||||
if strings.HasPrefix(strings.TrimSpace(name), forwardBase) {
|
||||
*list = append(*list, name)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
if rawServices, ok := payload["services"].([]interface{}); ok {
|
||||
for _, raw := range rawServices {
|
||||
name, _ := raw.(string)
|
||||
if strings.HasPrefix(strings.TrimSpace(name), forwardBase) {
|
||||
*list = append(*list, name)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
stopOldEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-old-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "DeleteService") {
|
||||
recordForwardServiceNames(data, &oldEntryDeleteNames)
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopOldEntry()
|
||||
|
||||
stopNewEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-new-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") || strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
|
||||
recordForwardServiceNames(data, &newEntryUpdateNames)
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopNewEntry()
|
||||
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
return false, ""
|
||||
})
|
||||
defer stopExit()
|
||||
|
||||
waitNodeStatus(t, r, oldEntryNodeID, 1)
|
||||
waitNodeStatus(t, r, newEntryNodeID, 1)
|
||||
waitNodeStatus(t, r, exitNodeID, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"id": tunnelID,
|
||||
"name": "issue281-tunnel",
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"trafficRatio": 1.0,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": newEntryNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 53001},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
nodeAfter, portAfter := mustQueryInt64Int(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||
if nodeAfter != newEntryNodeID || portAfter != 51001 {
|
||||
t.Fatalf("expected forward_port rebound to node=%d port=51001, got node=%d port=%d", newEntryNodeID, nodeAfter, portAfter)
|
||||
}
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if len(newEntryUpdateNames) == 0 {
|
||||
t.Fatalf("expected new entry node to receive forward runtime sync for %s", forwardBase)
|
||||
}
|
||||
if len(oldEntryDeleteNames) == 0 {
|
||||
t.Fatalf("expected old entry node to receive forward DeleteService cleanup for %s, got none", forwardBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||
|
||||
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "55000-55010", 1)
|
||||
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "56000-56010", 2)
|
||||
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryA).Error; err != nil {
|
||||
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.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(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardBase := fmt.Sprintf("%d_%d_%d", forwardID, 2, 282)
|
||||
recorder := newForwardRuntimeCommandRecorder(forwardBase)
|
||||
|
||||
stopEntryA := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-a-secret", recorder.handler("entry-a"))
|
||||
defer stopEntryA()
|
||||
stopEntryB := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-b-secret", recorder.handler("entry-b"))
|
||||
defer stopEntryB()
|
||||
stopEntryC := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-c-secret", recorder.handler("entry-c"))
|
||||
defer stopEntryC()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-exit-secret", recorder.handler("exit"))
|
||||
defer stopExit()
|
||||
|
||||
waitNodeStatus(t, r, entryA, 1)
|
||||
waitNodeStatus(t, r, entryB, 1)
|
||||
waitNodeStatus(t, r, entryC, 1)
|
||||
waitNodeStatus(t, r, exitNodeID, 1)
|
||||
|
||||
updateTunnelEntries := func(entries []map[string]interface{}) {
|
||||
payload := map[string]interface{}{
|
||||
"id": tunnelID,
|
||||
"name": "issue281-transition-tunnel",
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"trafficRatio": 1.0,
|
||||
"status": 1,
|
||||
"inNodeId": entries,
|
||||
"chainNodes": []interface{}{},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
|
||||
updateTunnelEntries([]map[string]interface{}{
|
||||
{"nodeId": entryA, "protocol": "tls", "strategy": "round"},
|
||||
{"nodeId": entryB, "protocol": "tls", "strategy": "round"},
|
||||
})
|
||||
|
||||
afterMulti := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
|
||||
if len(afterMulti) != 2 || afterMulti[entryA] != 54001 || afterMulti[entryB] != 54001 {
|
||||
t.Fatalf("expected forward_port on entryA+entryB with port 54001, got %v", afterMulti)
|
||||
}
|
||||
if recorder.syncCount("entry-b") == 0 {
|
||||
t.Fatalf("expected entry-b to receive forward runtime sync for %s", forwardBase)
|
||||
}
|
||||
if recorder.deleteCount("entry-a") != 0 {
|
||||
t.Fatalf("expected no cleanup on retained entry-a during single->multi transition, got %v", recorder.deleteNames("entry-a"))
|
||||
}
|
||||
|
||||
updateTunnelEntries([]map[string]interface{}{
|
||||
{"nodeId": entryC, "protocol": "tls", "strategy": "round"},
|
||||
})
|
||||
|
||||
afterSingle := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
|
||||
if len(afterSingle) != 1 || afterSingle[entryC] != 54001 {
|
||||
t.Fatalf("expected forward_port on entryC with port 54001, got %v", afterSingle)
|
||||
}
|
||||
if recorder.deleteCount("entry-a") == 0 {
|
||||
t.Fatalf("expected cleanup on removed entry-a during multi->single transition, got %v", recorder.deleteNames("entry-a"))
|
||||
}
|
||||
if recorder.deleteCount("entry-b") == 0 {
|
||||
t.Fatalf("expected cleanup on removed entry-b during multi->single transition, got %v", recorder.deleteNames("entry-b"))
|
||||
}
|
||||
if recorder.syncCount("entry-c") == 0 {
|
||||
t.Fatalf("expected entry-c to receive forward runtime sync for %s", forwardBase)
|
||||
}
|
||||
}
|
||||
|
||||
type forwardRuntimeCommandRecorder struct {
|
||||
prefix string
|
||||
|
||||
mu sync.Mutex
|
||||
deletes map[string][]string
|
||||
syncNames map[string][]string
|
||||
}
|
||||
|
||||
func newForwardRuntimeCommandRecorder(prefix string) *forwardRuntimeCommandRecorder {
|
||||
return &forwardRuntimeCommandRecorder{
|
||||
prefix: strings.TrimSpace(prefix),
|
||||
deletes: make(map[string][]string),
|
||||
syncNames: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *forwardRuntimeCommandRecorder) handler(node string) func(string, json.RawMessage) (bool, string) {
|
||||
return func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
names := collectForwardServiceNames(data, r.prefix)
|
||||
if len(names) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "DeleteService") {
|
||||
r.deletes[node] = append(r.deletes[node], names...)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") || strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
|
||||
r.syncNames[node] = append(r.syncNames[node], names...)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
func (r *forwardRuntimeCommandRecorder) deleteCount(node string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.deletes[node])
|
||||
}
|
||||
|
||||
func (r *forwardRuntimeCommandRecorder) syncCount(node string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.syncNames[node])
|
||||
}
|
||||
|
||||
func (r *forwardRuntimeCommandRecorder) deleteNames(node string) []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.deletes[node]...)
|
||||
}
|
||||
|
||||
func collectForwardServiceNames(data json.RawMessage, prefix string) []string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := make([]string, 0)
|
||||
var serviceList []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &serviceList); err == nil {
|
||||
for _, service := range serviceList {
|
||||
name, _ := service["name"].(string)
|
||||
if strings.HasPrefix(strings.TrimSpace(name), prefix) {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
if rawServices, ok := payload["services"].([]interface{}); ok {
|
||||
for _, raw := range rawServices {
|
||||
name, _ := raw.(string)
|
||||
if strings.HasPrefix(strings.TrimSpace(name), prefix) {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
|
||||
t.Helper()
|
||||
|
||||
@@ -633,3 +1177,112 @@ func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithCommandRecorder(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, string)) func() {
|
||||
t.Helper()
|
||||
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse provider url: %v", err)
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "https") {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = "/system-info"
|
||||
q := u.Query()
|
||||
q.Set("type", "1")
|
||||
q.Set("secret", nodeSecret)
|
||||
q.Set("version", "v1")
|
||||
q.Set("http", "1")
|
||||
q.Set("tls", "1")
|
||||
q.Set("socks", "1")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial mock node websocket: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
_, raw, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plain := raw
|
||||
var wrap struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
|
||||
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
|
||||
if cryptoErr == nil {
|
||||
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
|
||||
plain = []byte(dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"requestId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &cmd); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldFail := false
|
||||
failMsg := ""
|
||||
if onCommand != nil {
|
||||
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
|
||||
}
|
||||
|
||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||
respPayload := map[string]interface{}{
|
||||
"type": respType,
|
||||
"success": !shouldFail,
|
||||
"message": "OK",
|
||||
"requestId": cmd.RequestID,
|
||||
}
|
||||
if shouldFail {
|
||||
if strings.TrimSpace(failMsg) == "" {
|
||||
failMsg = "mock command failed"
|
||||
}
|
||||
respPayload["message"] = failMsg
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(respPayload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
|
||||
}
|
||||
}()
|
||||
|
||||
var stopOnce sync.Once
|
||||
return func() {
|
||||
stopOnce.Do(func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sortedCommandCounts(counts map[string]int) []string {
|
||||
items := make([]string, 0, len(counts))
|
||||
for key, value := range counts {
|
||||
items = append(items, fmt.Sprintf("%s=%d", key, value))
|
||||
}
|
||||
sort.Strings(items)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestForwardCreateBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
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, 'quota_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'quota_tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_quota: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(2, "quota_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(`{"tunnelId":1,"name":"quota-forward","remoteAddr":"1.1.1.1:53"}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when tunnel quota exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "配额") {
|
||||
t.Fatalf("expected quota error, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResumeBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
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, 'quota_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'quota_resume_tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(1, 2, 'quota_resume_user', 'quota_resume_forward', 1, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '1', ?, ?)
|
||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_quota: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(2, "quota_resume_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when tunnel quota exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "配额") {
|
||||
t.Fatalf("expected quota error, got %q", out.Msg)
|
||||
}
|
||||
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 1`)
|
||||
if status != 0 {
|
||||
t.Fatalf("expected forward to remain paused, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelQuotaResetReEnablesTunnel(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'quota_reset_tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_quota: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(1, "admin", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/quota/reset", bytes.NewBufferString(`{"tunnelId":1,"scope":"all"}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected reset success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
tunnelStatus := mustQueryInt(t, repo, `SELECT status FROM tunnel WHERE id = 1`)
|
||||
if tunnelStatus != 1 {
|
||||
t.Fatalf("expected tunnel to be re-enabled, got %d", tunnelStatus)
|
||||
}
|
||||
quotaDisabled := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM tunnel_quota WHERE tunnel_id = 1`)
|
||||
if quotaDisabled != 0 {
|
||||
t.Fatalf("expected quota disable flag cleared, got %d", quotaDisabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestUserTunnelListReturnsStoredStatusContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'user_tunnel_status_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel enabled: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel disabled: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert enabled user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert disabled user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"userId":201}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
items, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected array data, got %T", out.Data)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
|
||||
statusByTunnelID := make(map[int64]int, len(items))
|
||||
for _, item := range items {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", item)
|
||||
}
|
||||
tunnelID, ok := obj["tunnelId"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
|
||||
}
|
||||
status, ok := obj["status"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected status to be float64, got %T", obj["status"])
|
||||
}
|
||||
statusByTunnelID[int64(tunnelID)] = int(status)
|
||||
}
|
||||
|
||||
if statusByTunnelID[301] != 1 {
|
||||
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
|
||||
}
|
||||
if statusByTunnelID[302] != 0 {
|
||||
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -109,12 +109,12 @@ func main() {
|
||||
// 加载配置文件
|
||||
config, err := LoadConfig("config.json")
|
||||
if err != nil {
|
||||
fmt.Println("❌ 配置加载失败: %v\n", err)
|
||||
fmt.Printf("❌ 配置加载失败: %v\n", err)
|
||||
fmt.Println("请确保当前目录存在 config.json 文件")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✅ 配置加载成功 - addr: %s", config.Addr)
|
||||
fmt.Printf("✅ 配置加载成功 - addr: %s\n", config.Addr)
|
||||
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
@@ -18,6 +20,15 @@ import (
|
||||
var httpReportURL string
|
||||
var configReportURL string
|
||||
var httpAESCrypto *crypto.AESCrypto // 新增:HTTP上报加密器
|
||||
var reportURLPreferenceMutex sync.RWMutex
|
||||
var preferredUploadURL string
|
||||
var preferredConfigURL string
|
||||
var reportDo = func(ctx context.Context, req *http.Request, timeout time.Duration) (*http.Response, error) {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
return client.Do(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// TrafficReportItem 流量报告项(压缩格式)
|
||||
type TrafficReportItem struct {
|
||||
@@ -27,8 +38,17 @@ type TrafficReportItem struct {
|
||||
}
|
||||
|
||||
func SetHTTPReportURL(addr string, secret string) {
|
||||
httpReportURL = "http://" + addr + "/flow/upload?secret=" + secret
|
||||
configReportURL = "http://" + addr + "/flow/config?secret=" + secret
|
||||
uploadURLs, configURLs := buildReportURLCandidates(addr, secret)
|
||||
if len(uploadURLs) > 0 {
|
||||
httpReportURL = strings.Join(uploadURLs, ",")
|
||||
}
|
||||
if len(configURLs) > 0 {
|
||||
configReportURL = strings.Join(configURLs, ",")
|
||||
}
|
||||
reportURLPreferenceMutex.Lock()
|
||||
preferredUploadURL = ""
|
||||
preferredConfigURL = ""
|
||||
reportURLPreferenceMutex.Unlock()
|
||||
|
||||
// 创建 AES 加密器
|
||||
var err error
|
||||
@@ -41,8 +61,173 @@ func SetHTTPReportURL(addr string, secret string) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildReportURLCandidates(addr string, secret string) (upload []string, config []string) {
|
||||
normalizedAddr, explicitScheme := normalizeReportAddress(addr)
|
||||
if normalizedAddr == "" {
|
||||
normalizedAddr = strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
schemes := []string{"https", "http"}
|
||||
if mappedScheme := mapToHTTPScheme(explicitScheme); mappedScheme == "http" {
|
||||
schemes = []string{"http", "https"}
|
||||
}
|
||||
|
||||
upload = []string{
|
||||
schemes[0] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
|
||||
schemes[1] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
|
||||
}
|
||||
config = []string{
|
||||
schemes[0] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
|
||||
schemes[1] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
|
||||
}
|
||||
return upload, config
|
||||
}
|
||||
|
||||
func normalizeReportAddress(addr string) (string, string) {
|
||||
raw := strings.TrimSpace(addr)
|
||||
if raw == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
scheme := ""
|
||||
if idx := strings.Index(raw, "://"); idx > 0 {
|
||||
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
if host := strings.TrimSpace(parsed.Host); host != "" {
|
||||
return host, scheme
|
||||
}
|
||||
}
|
||||
raw = raw[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
return strings.TrimSpace(raw), scheme
|
||||
}
|
||||
|
||||
func mapToHTTPScheme(scheme string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(scheme)) {
|
||||
case "https", "wss":
|
||||
return "https"
|
||||
case "http", "ws":
|
||||
return "http"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func loadPreferredURL(preferred *string) string {
|
||||
if preferred == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
reportURLPreferenceMutex.RLock()
|
||||
defer reportURLPreferenceMutex.RUnlock()
|
||||
return *preferred
|
||||
}
|
||||
|
||||
func storePreferredURL(preferred *string, value string) {
|
||||
if preferred == nil {
|
||||
return
|
||||
}
|
||||
|
||||
reportURLPreferenceMutex.Lock()
|
||||
defer reportURLPreferenceMutex.Unlock()
|
||||
*preferred = value
|
||||
}
|
||||
|
||||
func prioritizeURLs(urls []string, preferred string) []string {
|
||||
ordered := append([]string(nil), urls...)
|
||||
if preferred == "" || len(ordered) < 2 {
|
||||
return ordered
|
||||
}
|
||||
|
||||
for i, targetURL := range ordered {
|
||||
if targetURL == preferred {
|
||||
if i > 0 {
|
||||
ordered[0], ordered[i] = ordered[i], ordered[0]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
func postJSONWithFallback(ctx context.Context, urls []string, requestBody []byte, userAgent string, timeout time.Duration, preferred *string) (bool, error) {
|
||||
if len(urls) == 0 {
|
||||
return false, fmt.Errorf("上报URL未设置")
|
||||
}
|
||||
|
||||
orderedURLs := prioritizeURLs(urls, loadPreferredURL(preferred))
|
||||
|
||||
var errs []string
|
||||
for i, targetURL := range orderedURLs {
|
||||
req, err := http.NewRequest("POST", targetURL, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s => 创建请求失败: %v", targetURL, err))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 创建请求失败: %v\n", targetURL, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := reportDo(ctx, req, timeout)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s => 请求失败: %v", targetURL, err))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 请求失败: %v\n", targetURL, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
_, readErr := responseBytes.ReadFrom(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s => 读取响应失败: %v", targetURL, readErr))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 读取响应失败: %v\n", targetURL, readErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errs = append(errs, fmt.Sprintf("%s => HTTP响应错误: %d %s", targetURL, resp.StatusCode, resp.Status))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => HTTP响应错误: %d %s\n", targetURL, resp.StatusCode, resp.Status)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
responseText := strings.TrimSpace(responseBytes.String())
|
||||
if responseText == "ok" {
|
||||
if i > 0 {
|
||||
fmt.Printf("↪️ HTTP上报已自动回退到: %s\n", targetURL)
|
||||
}
|
||||
storePreferredURL(preferred, targetURL)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
errs = append(errs, fmt.Sprintf("%s => 服务器响应: %s (期望: ok)", targetURL, responseText))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 服务器响应: %s (期望: ok)\n", targetURL, responseText)
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("发送HTTP请求失败: %s", strings.Join(errs, " | "))
|
||||
}
|
||||
|
||||
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
|
||||
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
|
||||
if httpReportURL == "" {
|
||||
return false, fmt.Errorf("流量上报URL未设置")
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reportItems)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("序列化报告数据失败: %v", err)
|
||||
@@ -73,46 +258,16 @@ func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem
|
||||
requestBody = jsonData
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", httpReportURL, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "GOST-Traffic-Reporter/1.0")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
var responseBytes bytes.Buffer
|
||||
_, err = responseBytes.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("读取响应内容失败: %v", err)
|
||||
}
|
||||
|
||||
responseText := strings.TrimSpace(responseBytes.String())
|
||||
|
||||
// 检查响应是否为"ok"
|
||||
if responseText == "ok" {
|
||||
return true, nil
|
||||
} else {
|
||||
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
|
||||
}
|
||||
return postJSONWithFallback(
|
||||
ctx,
|
||||
strings.Split(httpReportURL, ","),
|
||||
requestBody,
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
&preferredUploadURL,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// sendConfigReport 发送配置报告到HTTP接口
|
||||
func sendConfigReport(ctx context.Context) (bool, error) {
|
||||
if configReportURL == "" {
|
||||
@@ -150,43 +305,14 @@ func sendConfigReport(ctx context.Context) (bool, error) {
|
||||
requestBody = configData
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", configReportURL, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Config-Reporter/1.0")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second, // 配置上报可以稍长一些
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
var responseBytes bytes.Buffer
|
||||
_, err = responseBytes.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("读取响应内容失败: %v", err)
|
||||
}
|
||||
|
||||
responseText := strings.TrimSpace(responseBytes.String())
|
||||
|
||||
// 检查响应是否为"ok"
|
||||
if responseText == "ok" {
|
||||
return true, nil
|
||||
} else {
|
||||
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
|
||||
}
|
||||
return postJSONWithFallback(
|
||||
ctx,
|
||||
strings.Split(configReportURL, ","),
|
||||
requestBody,
|
||||
"Config-Reporter/1.0",
|
||||
10*time.Second,
|
||||
&preferredConfigURL,
|
||||
)
|
||||
}
|
||||
|
||||
// StartConfigReporter 启动配置定时上报器(每10分钟上报一次)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildReportURLCandidatesSecureFirst(t *testing.T) {
|
||||
upload, config := buildReportURLCandidates("panel.example.com:443", "abc")
|
||||
|
||||
if len(upload) != 2 {
|
||||
t.Fatalf("expected 2 upload candidates, got %d", len(upload))
|
||||
}
|
||||
if len(config) != 2 {
|
||||
t.Fatalf("expected 2 config candidates, got %d", len(config))
|
||||
}
|
||||
|
||||
if upload[0] != "https://panel.example.com:443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[0]: %s", upload[0])
|
||||
}
|
||||
if upload[1] != "http://panel.example.com:443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[1]: %s", upload[1])
|
||||
}
|
||||
if config[0] != "https://panel.example.com:443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[0]: %s", config[0])
|
||||
}
|
||||
if config[1] != "http://panel.example.com:443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[1]: %s", config[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportURLCandidatesNormalizeSchemeAddr(t *testing.T) {
|
||||
upload, config := buildReportURLCandidates("https://panel.example.com:8443/path", "abc")
|
||||
|
||||
if upload[0] != "https://panel.example.com:8443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[0]: %s", upload[0])
|
||||
}
|
||||
if upload[1] != "http://panel.example.com:8443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[1]: %s", upload[1])
|
||||
}
|
||||
if config[0] != "https://panel.example.com:8443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[0]: %s", config[0])
|
||||
}
|
||||
if config[1] != "http://panel.example.com:8443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[1]: %s", config[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJSONWithFallbackUsesHTTPAfterHTTPSFailure(t *testing.T) {
|
||||
orig := reportDo
|
||||
defer func() { reportDo = orig }()
|
||||
|
||||
var calls []string
|
||||
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
|
||||
calls = append(calls, req.URL.String())
|
||||
if strings.HasPrefix(req.URL.String(), "https://") {
|
||||
return nil, errors.New("tls handshake failed")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ok, err := postJSONWithFallback(
|
||||
context.Background(),
|
||||
[]string{
|
||||
"https://panel.example.com:443/flow/upload?secret=abc",
|
||||
"http://panel.example.com:443/flow/upload?secret=abc",
|
||||
},
|
||||
[]byte(`[]`),
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
nil,
|
||||
)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("expected fallback success, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", len(calls))
|
||||
}
|
||||
if !strings.HasPrefix(calls[0], "https://") || !strings.HasPrefix(calls[1], "http://") {
|
||||
t.Fatalf("unexpected call order: %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJSONWithFallbackRemembersDetectedURL(t *testing.T) {
|
||||
orig := reportDo
|
||||
defer func() { reportDo = orig }()
|
||||
|
||||
targets := []string{
|
||||
"https://panel.example.com:443/flow/upload?secret=abc",
|
||||
"http://panel.example.com:443/flow/upload?secret=abc",
|
||||
}
|
||||
|
||||
var preferred string
|
||||
var calls []string
|
||||
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
|
||||
calls = append(calls, req.URL.String())
|
||||
if strings.HasPrefix(req.URL.String(), "https://") {
|
||||
return nil, errors.New("tls handshake failed")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ok, err := postJSONWithFallback(
|
||||
context.Background(),
|
||||
targets,
|
||||
[]byte(`[]`),
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
&preferred,
|
||||
)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("expected first call success, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if preferred != targets[1] {
|
||||
t.Fatalf("expected preferred url to be remembered as %s, got %s", targets[1], preferred)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls on first attempt, got %d", len(calls))
|
||||
}
|
||||
|
||||
calls = nil
|
||||
ok, err = postJSONWithFallback(
|
||||
context.Background(),
|
||||
targets,
|
||||
[]byte(`[]`),
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
&preferred,
|
||||
)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("expected second call success, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected second call to use remembered url once, got %d calls", len(calls))
|
||||
}
|
||||
if !strings.HasPrefix(calls[0], "http://") {
|
||||
t.Fatalf("expected remembered http url first, got %s", calls[0])
|
||||
}
|
||||
}
|
||||
@@ -97,20 +97,25 @@ const (
|
||||
)
|
||||
|
||||
type WebSocketReporter struct {
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
pingInterval time.Duration
|
||||
configInterval time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
connected bool
|
||||
connecting bool // 新增:正在连接状态
|
||||
connMutex sync.Mutex // 新增:连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // 新增:AES加密器
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
preferredWSScheme string
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
pingInterval time.Duration
|
||||
configInterval time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
connected bool
|
||||
connecting bool // 新增:正在连接状态
|
||||
connMutex sync.Mutex // 新增:连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // 新增:AES加密器
|
||||
}
|
||||
|
||||
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
return dialer.Dial(rawURL, nil)
|
||||
}
|
||||
|
||||
// NewWebSocketReporter 创建一个新的WebSocket报告器
|
||||
@@ -223,21 +228,14 @@ func (w *WebSocketReporter) connect() error {
|
||||
json.Unmarshal(b, &cfg)
|
||||
}
|
||||
|
||||
// 使用最新的配置重新构建 URL
|
||||
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
|
||||
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
|
||||
|
||||
u, err := url.Parse(currentURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析URL失败: %v", err)
|
||||
}
|
||||
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
|
||||
|
||||
dialer := websocket.DefaultDialer
|
||||
dialer.HandshakeTimeout = 10 * time.Second
|
||||
|
||||
conn, _, err := dialer.Dial(u.String(), nil)
|
||||
conn, usedURL, err := dialWebSocketWithFallback(dialer, candidates)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接WebSocket失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果在连接过程中已经有连接了,关闭新连接
|
||||
@@ -248,6 +246,9 @@ func (w *WebSocketReporter) connect() error {
|
||||
|
||||
w.conn = conn
|
||||
w.connected = true
|
||||
if scheme := detectWebSocketScheme(usedURL); scheme != "" {
|
||||
w.preferredWSScheme = scheme
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
conn.SetPingHandler(func(appData string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
@@ -265,10 +266,145 @@ func (w *WebSocketReporter) connect() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
|
||||
fmt.Printf("✅ WebSocket连接建立成功 (%s, http=%d, tls=%d, socks=%d)\n", sanitizeWebSocketURL(usedURL), cfg.Http, cfg.Tls, cfg.Socks)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWebSocketCandidates(addr string, secret string, version string, http int, tls int, socks int, preferredScheme string) []string {
|
||||
normalizedAddr, explicitScheme := normalizeReporterAddress(addr)
|
||||
if normalizedAddr == "" {
|
||||
normalizedAddr = strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
|
||||
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
|
||||
schemes := []string{"wss", "ws"}
|
||||
if mappedScheme := mapToWebSocketScheme(explicitScheme); mappedScheme != "" {
|
||||
if mappedScheme == "ws" {
|
||||
schemes = []string{"ws", "wss"}
|
||||
}
|
||||
} else if preferredScheme == "ws" {
|
||||
schemes = []string{"ws", "wss"}
|
||||
}
|
||||
|
||||
return []string{
|
||||
schemes[0] + "://" + normalizedAddr + query,
|
||||
schemes[1] + "://" + normalizedAddr + query,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReporterAddress(addr string) (string, string) {
|
||||
raw := strings.TrimSpace(addr)
|
||||
if raw == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
scheme := ""
|
||||
if idx := strings.Index(raw, "://"); idx > 0 {
|
||||
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
if host := strings.TrimSpace(parsed.Host); host != "" {
|
||||
return host, scheme
|
||||
}
|
||||
}
|
||||
raw = raw[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
return strings.TrimSpace(raw), scheme
|
||||
}
|
||||
|
||||
func mapToWebSocketScheme(scheme string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(scheme)) {
|
||||
case "wss", "https":
|
||||
return "wss"
|
||||
case "ws", "http":
|
||||
return "ws"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func detectWebSocketScheme(rawURL string) string {
|
||||
if strings.HasPrefix(rawURL, "wss://") {
|
||||
return "wss"
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "ws://") {
|
||||
return "ws"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dialWebSocketWithFallback(dialer *websocket.Dialer, candidates []string) (*websocket.Conn, string, error) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, "", fmt.Errorf("WebSocket候选地址为空")
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for i, targetURL := range candidates {
|
||||
conn, resp, err := wsDial(dialer, targetURL)
|
||||
if err == nil {
|
||||
if i > 0 {
|
||||
fmt.Printf("↪️ WebSocket已自动回退成功: %s\n", sanitizeWebSocketURL(targetURL))
|
||||
}
|
||||
return conn, targetURL, nil
|
||||
}
|
||||
errMsg := formatWebSocketDialError(err, resp)
|
||||
errs = append(errs, fmt.Sprintf("%s => %s", sanitizeWebSocketURL(targetURL), errMsg))
|
||||
if i < len(candidates)-1 {
|
||||
fmt.Printf(
|
||||
"⚠️ WebSocket连接失败,准备从 %s 回退到 %s: %s\n",
|
||||
strings.ToUpper(detectWebSocketScheme(targetURL)),
|
||||
strings.ToUpper(detectWebSocketScheme(candidates[i+1])),
|
||||
errMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("连接WebSocket失败(已尝试%d种协议): %s", len(candidates), strings.Join(errs, " | "))
|
||||
}
|
||||
|
||||
func sanitizeWebSocketURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
if q.Get("secret") != "" {
|
||||
q.Set("secret", "***")
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func formatWebSocketDialError(err error, resp *http.Response) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if resp == nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%s (HTTP %s)", err, resp.Status)
|
||||
if resp.Body == nil {
|
||||
return msg
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if readErr != nil {
|
||||
return msg
|
||||
}
|
||||
bodyText := strings.TrimSpace(string(body))
|
||||
if bodyText == "" {
|
||||
return msg
|
||||
}
|
||||
return fmt.Sprintf("%s, body=%q", msg, bodyText)
|
||||
}
|
||||
|
||||
// handleConnection 处理WebSocket连接
|
||||
func (w *WebSocketReporter) handleConnection() {
|
||||
defer func() {
|
||||
@@ -1290,7 +1426,8 @@ func getMemoryInfo() MemoryInfo {
|
||||
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
|
||||
|
||||
// 构建初始 WebSocket URL
|
||||
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "")
|
||||
fullURL := candidates[0]
|
||||
|
||||
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) {
|
||||
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "")
|
||||
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(candidates))
|
||||
}
|
||||
if !strings.HasPrefix(candidates[0], "wss://") {
|
||||
t.Fatalf("expected first candidate to start with wss://, got %s", candidates[0])
|
||||
}
|
||||
if !strings.HasPrefix(candidates[1], "ws://") {
|
||||
t.Fatalf("expected second candidate to start with ws://, got %s", candidates[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketCandidatesUsesPreferredScheme(t *testing.T) {
|
||||
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "ws")
|
||||
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(candidates))
|
||||
}
|
||||
if !strings.HasPrefix(candidates[0], "ws://") {
|
||||
t.Fatalf("expected preferred ws:// candidate first, got %s", candidates[0])
|
||||
}
|
||||
if !strings.HasPrefix(candidates[1], "wss://") {
|
||||
t.Fatalf("expected fallback wss:// candidate second, got %s", candidates[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketCandidatesNormalizesSchemePrefixedAddr(t *testing.T) {
|
||||
candidates := buildWebSocketCandidates("https://panel.example.com:443/path?q=1", "abc", "2.0.2", 0, 0, 0, "")
|
||||
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(candidates))
|
||||
}
|
||||
if !strings.HasPrefix(candidates[0], "wss://panel.example.com:443/") {
|
||||
t.Fatalf("expected normalized wss candidate, got %s", candidates[0])
|
||||
}
|
||||
if !strings.HasPrefix(candidates[1], "ws://panel.example.com:443/") {
|
||||
t.Fatalf("expected normalized ws fallback candidate, got %s", candidates[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialWebSocketWithFallbackTriesWSAfterWSSFailure(t *testing.T) {
|
||||
orig := wsDial
|
||||
defer func() { wsDial = orig }()
|
||||
|
||||
var attempts []string
|
||||
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
attempts = append(attempts, rawURL)
|
||||
if strings.HasPrefix(rawURL, "wss://") {
|
||||
return nil, nil, errors.New("tls failed")
|
||||
}
|
||||
return &websocket.Conn{}, nil, nil
|
||||
}
|
||||
|
||||
_, usedURL, err := dialWebSocketWithFallback(
|
||||
&websocket.Dialer{},
|
||||
[]string{
|
||||
"wss://panel.example.com/system-info?type=1&secret=abc",
|
||||
"ws://panel.example.com/system-info?type=1&secret=abc",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback success, got err=%v", err)
|
||||
}
|
||||
if !strings.HasPrefix(usedURL, "ws://") {
|
||||
t.Fatalf("expected fallback ws:// url, got %s", usedURL)
|
||||
}
|
||||
if len(attempts) != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", len(attempts))
|
||||
}
|
||||
if !strings.HasPrefix(attempts[0], "wss://") || !strings.HasPrefix(attempts[1], "ws://") {
|
||||
t.Fatalf("unexpected attempt order: %#v", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWebSocketScheme(t *testing.T) {
|
||||
if detectWebSocketScheme("wss://panel.example.com/system-info") != "wss" {
|
||||
t.Fatalf("expected wss detection")
|
||||
}
|
||||
if detectWebSocketScheme("ws://panel.example.com/system-info") != "ws" {
|
||||
t.Fatalf("expected ws detection")
|
||||
}
|
||||
if detectWebSocketScheme("http://panel.example.com/system-info") != "" {
|
||||
t.Fatalf("expected empty detection for non-websocket scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeWebSocketURL(t *testing.T) {
|
||||
raw := "wss://panel.example.com/system-info?type=1&secret=abc&version=2.0.2"
|
||||
sanitized := sanitizeWebSocketURL(raw)
|
||||
|
||||
if strings.Contains(sanitized, "secret=abc") {
|
||||
t.Fatalf("expected secret to be masked, got %s", sanitized)
|
||||
}
|
||||
if !strings.Contains(sanitized, "secret=%2A%2A%2A") {
|
||||
t.Fatalf("expected masked secret in url, got %s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
|
||||
err := errors.New("websocket: bad handshake")
|
||||
resp := &http.Response{
|
||||
Status: "403 Forbidden",
|
||||
Body: io.NopCloser(strings.NewReader("forbidden")),
|
||||
}
|
||||
|
||||
msg := formatWebSocketDialError(err, resp)
|
||||
if !strings.Contains(msg, "HTTP 403 Forbidden") {
|
||||
t.Fatalf("expected status in message, got %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "forbidden") {
|
||||
t.Fatalf("expected response body in message, got %s", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
# Plan 012: 允许用户自定义转发入口端口(限制在节点端口范围内)
|
||||
|
||||
**Issue**: #268
|
||||
**状态**: 已完成
|
||||
|
||||
## 背景
|
||||
|
||||
当前版本限制了普通用户自定义转发入口端口 (inPort) 的能力,导致:
|
||||
- 用户迁移数据后无法保留原有端口配置
|
||||
- 无法编辑转发配置
|
||||
- 需要重建所有转发,操作繁琐
|
||||
|
||||
## 实现方案
|
||||
|
||||
允许用户和管理员自定义转发入口端口,但强制在节点端口设置的范围内。
|
||||
|
||||
### 默认行为
|
||||
- 不填写端口 → 随机分配(在端口范围内)
|
||||
- 填写端口 → 使用指定端口(需在范围内且不冲突)
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### 1. 后端修改
|
||||
|
||||
- [x] **1.1 移除非管理员 inPort 权限限制**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 位置: `forwardCreate` 函数 (约 L1156-1167)
|
||||
- 位置: `forwardUpdate` 函数 (约 L1279-1291)
|
||||
- 操作: 删除 `roleID != 0` 时阻止 inPort 设置的逻辑
|
||||
- 状态: 代码中已无 inPort 权限限制
|
||||
|
||||
- [x] **1.2 添加本地节点端口范围验证函数**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 新增函数: `validateLocalNodePort(node *nodeRecord, port int) error`
|
||||
- 逻辑: 使用 `parsePortRangeSpec` 解析端口范围,验证 port 是否在范围内
|
||||
- 状态: 函数已存在于 L3517-3533
|
||||
|
||||
- [x] **1.3 修改 forwardCreate 端口验证**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 位置: `forwardCreate` 中 entry nodes 遍历处 (约 L1188-1197)
|
||||
- 操作:
|
||||
- 对远程节点使用现有 `validateRemoteNodePort`
|
||||
- 对本地节点使用新的 `validateLocalNodePort`
|
||||
- 若用户指定的端口超出节点范围,返回错误提示
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **1.4 修改 forwardUpdate 端口验证**
|
||||
- 文件: `go-backend/internal/http/handler/mutations.go`
|
||||
- 位置: `forwardUpdate` 中 entry nodes 遍历处 (约 L1326-1335)
|
||||
- 操作: 同 1.3,添加本地节点端口范围验证
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **1.5 `ListUserAccessibleTunnels` 添加端口范围信息**
|
||||
- 文件: `go-backend/internal/store/repo/repository.go`
|
||||
- 位置: L751-775
|
||||
- 操作:
|
||||
- 查询隧道关联的入口节点 (通过 `chain_tunnel` 表 `chain_type=1`)
|
||||
- 获取入口节点的端口范围 (`node.port` 字段)
|
||||
- 使用 `parsePortRangeSpec` 解析并计算 min/max
|
||||
- 在返回的 map 中添加 `portRangeMin` 和 `portRangeMax` 字段
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **1.6 `ListEnabledTunnelSummaries` 添加端口范围信息**
|
||||
- 文件: `go-backend/internal/store/repo/repository.go`
|
||||
- 位置: L777-796
|
||||
- 操作: 同 1.5,为管理员视图也提供端口范围信息
|
||||
- 状态: 已实现
|
||||
|
||||
### 2. 前端修改
|
||||
|
||||
- [x] **2.1 为所有用户显示 inPort 输入框**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: 约 L4350-4369
|
||||
- 操作: 移除 `{isAdmin && (` 条件包装,改为所有用户可见
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.2 提交时包含 inPort(非仅管理员)**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: `handleSave` 函数 (约 L1435, L1447)
|
||||
- 操作: 移除 `...(isAdmin ? { inPort: form.inPort } : {})` 条件,直接包含 inPort
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.3 更新 Tunnel 接口添加 portRangeMin/Max**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: L123-131
|
||||
- 操作: 添加 `portRangeMin?: number; portRangeMax?: number;`
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.4 inPort 输入框显示端口范围提示**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: L4350-4369
|
||||
- 操作:
|
||||
- 基于 `form.tunnelId` 获取当前隧道的端口范围
|
||||
- 在 Input 的 `description` 中显示提示,如: `"指定入口端口,留空自动分配 (允许范围: 10000-20000)"`
|
||||
- 状态: 已实现
|
||||
|
||||
- [x] **2.5 前端端口范围验证**
|
||||
- 文件: `vite-frontend/src/pages/forward.tsx`
|
||||
- 位置: 验证函数 (L1271-1279)
|
||||
- 操作: 前端也做范围预检查,超出范围时显示错误
|
||||
- 状态: 已实现并修复语法错误
|
||||
|
||||
### 3. 测试修改
|
||||
|
||||
- [x] **3.1 更新权限测试**
|
||||
- 文件: `go-backend/tests/contract/forward_contract_test.go`
|
||||
- 位置: L1001-1119
|
||||
- 操作:
|
||||
- 修改 "non-admin cannot set inPort" 测试为允许设置
|
||||
- 新增 "non-admin inPort within range" 测试(通过)
|
||||
- 新增 "non-admin inPort out of range" 测试(失败)
|
||||
- 状态: 已更新
|
||||
|
||||
- [x] **3.2 新增端口范围验证测试**
|
||||
- 文件: `go-backend/tests/contract/forward_contract_test.go`
|
||||
- 操作:
|
||||
- 测试本地节点端口范围验证
|
||||
- 测试远程节点端口范围验证(已有 `validateRemoteNodePort` 相关测试可参考)
|
||||
- 状态: 已添加
|
||||
|
||||
---
|
||||
|
||||
## 关键代码位置
|
||||
|
||||
| 功能 | 文件 | 行号 |
|
||||
|------|------|------|
|
||||
| 前端 inPort 输入框 | `vite-frontend/src/pages/forward.tsx` | L4350-4369 |
|
||||
| 前端提交条件 | `vite-frontend/src/pages/forward.tsx` | L1435, L1447 |
|
||||
| 后端创建权限检查 | `go-backend/internal/http/handler/mutations.go` | L1156-1167 |
|
||||
| 后端更新权限检查 | `go-backend/internal/http/handler/mutations.go` | L1279-1291 |
|
||||
| 远程节点端口验证 | `go-backend/internal/http/handler/federation.go` | L562-574 |
|
||||
| 本地节点端口验证 | `go-backend/internal/http/handler/mutations.go` | L3517-3533 |
|
||||
| 端口范围解析 | `go-backend/internal/store/repo/repository_mutations.go` | L1370-1412 |
|
||||
| 用户隧道列表 | `go-backend/internal/store/repo/repository.go` | L751-775 |
|
||||
| 管理员隧道列表 | `go-backend/internal/store/repo/repository.go` | L777-796 |
|
||||
| 合约测试 | `go-backend/tests/contract/forward_contract_test.go` | L1001-1119 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. ✅ 普通用户可以在创建转发时指定 inPort
|
||||
2. ✅ 普通用户可以在编辑转发时修改 inPort
|
||||
3. ✅ 指定的端口必须在节点端口范围内,否则返回错误
|
||||
4. ✅ 留空 inPort 时行为不变(自动分配)
|
||||
5. ✅ 前端显示端口范围提示
|
||||
6. ✅ 所有合约测试通过
|
||||
|
||||
---
|
||||
|
||||
## 实施总结
|
||||
|
||||
该计划的大部分代码已在之前的开发中实现。本次实施主要完成了以下工作:
|
||||
|
||||
1. **修复前端验证代码语法错误** - `forward.tsx` 中 `validateForm` 函数的端口范围验证代码存在语法错误,已修复
|
||||
2. **更新测试用例** - 将原本期望权限拒绝的测试改为端口范围验证测试,并修正了测试中使用的端口号
|
||||
@@ -0,0 +1,13 @@
|
||||
# 013 Forward Delete NotFound Compatibility Fix
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm forward update failure path caused by delete fallback short-circuiting on the first not-found service name.
|
||||
- [x] Update forward service deletion logic to continue across all candidate runtime names until one is actually deleted or every candidate is exhausted.
|
||||
- [x] Add regression tests covering mixed not-found and legacy-name delete recovery during forward control/update flows.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 014 Forward Port Occupancy Validation
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm current forward create/update only validates node port range and misses DB-backed occupancy checks for local nodes.
|
||||
- [x] Add shared forward port occupancy validation for create/update paths before runtime dispatch.
|
||||
- [x] Add focused tests covering create/update validation when another forward already uses the same node+port.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 015 Forward Runtime Port Residual Cleanup
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm 2.1.6 used service names with `_0` runtime base while later versions may target resolved `user_tunnel_id`, leaving old runtime services behind after direct upgrade.
|
||||
- [x] Extend self-occupy recovery to clean residual candidate service names and retry update/add when the port is only occupied by self-owned legacy runtime services.
|
||||
- [x] Add regression tests covering address-in-use recovery with legacy `_0` runtime residue.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 016 Tunnel Runtime Bind Conflict Retry
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm tunnel `connectIp` precedence remains `connectIp > node tcp_listen_addr` for runtime service listen address.
|
||||
- [x] Add tunnel runtime `address already in use` recovery that deletes the stale service and retries `AddService`.
|
||||
- [x] Keep non-bind failures unchanged and avoid altering tunnel chain apply semantics.
|
||||
- [x] Add regression tests for tunnel service address precedence and bind-conflict retry behavior.
|
||||
- [x] Run focused backend handler tests and record the result.
|
||||
- [ ] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
|
||||
- [ ] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
|
||||
- [x] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
|
||||
- [x] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/...`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateRecoversFromAddressInUseContract|TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract|TestForwardUpdateIgnoresDeletedSpeedLimitContract'`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./internal/http/handler/... && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
|
||||
- Result: passed.
|
||||
|
||||
## Investigation Note
|
||||
|
||||
- Forward update still has its own independent `address already in use` recovery path in `syncForwardServicesWithWarnings` / `rebindForwardServiceOnSelfOccupiedPort`; tunnel update linkage is not the only possible source of the symptom.
|
||||
- Tunnel update also triggers downstream forward `UpdateService` for bound forwards, so users can still observe the same error around a tunnel edit even when the failing runtime is on the tunnel side.
|
||||
- Real node output can collapse spaces into variants like `address alreadyin use` / `cannotassignrequestedaddress`; bind-conflict detection now normalizes whitespace before classifying the error.
|
||||
- Forward self-heal cleanup now deletes every candidate runtime name variant instead of stopping after the first successful delete, which avoids leaving sibling `_tcp`/`_udp` services behind to keep the port occupied.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 017 PR 284 UI Follow-up Fixes
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Review the current frontend route and component state related to PR 284 follow-up fixes.
|
||||
- [x] Restore the intended H5 simple-layout route behavior for panel sharing.
|
||||
- [x] Improve date text parsing to support separator-free and flexible formats without ambiguous fallbacks.
|
||||
- [x] Add config-page back navigation with a safer history fallback and shared icon usage.
|
||||
- [x] Run focused frontend verification for the updated files and record the result.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd vite-frontend && npm install`
|
||||
- Result: passed.
|
||||
- Command: `cd vite-frontend && npm run build`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 018 User Tunnel Disable Status Sync
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Inspect the user tunnel permission edit flow and identify why disabling an assigned tunnel appears ineffective.
|
||||
- [x] Return the real `user_tunnel.status` value from the admin permission list API instead of a hardcoded enabled state.
|
||||
- [x] Add contract coverage for the user tunnel permission list status mapping and run focused backend verification.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./tests/contract/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,21 @@
|
||||
# 019 Federation Share Traffic Bigint Migration
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Inspect federation share creation failure and identify the PostgreSQL `int4` overflow source.
|
||||
- [x] Audit other traffic-related legacy PostgreSQL columns that may still be `integer` despite Go models using `int64`.
|
||||
- [x] Add a schema migration that widens legacy traffic/quota columns from `integer` to `bigint`.
|
||||
- [x] Add migration tests covering the new schema version branch and error propagation.
|
||||
- [x] Run focused backend verification for the migration changes.
|
||||
|
||||
## Notes
|
||||
|
||||
- The reported failing value `536870912000` is 500 GiB in bytes and overflows PostgreSQL `int4`.
|
||||
- The fix widens historical PostgreSQL traffic columns in `user`, `forward`, `statistics_flow`, `tunnel`, `user_tunnel`, and `peer_share` to `BIGINT` when needed.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd go-backend && go test ./internal/store/repo/...`
|
||||
- Result: passed.
|
||||
- Command: `cd go-backend && go test ./tests/contract/...`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,164 @@
|
||||
# 020 AJAX No-refresh UX
|
||||
|
||||
## Objective
|
||||
|
||||
- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite.
|
||||
- Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness.
|
||||
- Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue.
|
||||
- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads.
|
||||
- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface.
|
||||
- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`.
|
||||
|
||||
## Current State
|
||||
|
||||
- `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates.
|
||||
- `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior.
|
||||
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh.
|
||||
- Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records.
|
||||
- Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Prefer local state patching after successful mutations when the changed record set is known.
|
||||
- Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset.
|
||||
- Use polling only where realtime transport does not already exist.
|
||||
- Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic.
|
||||
- Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`.
|
||||
- [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication.
|
||||
- [x] Harden node realtime reconnection behavior in `vite-frontend/src/pages/node/use-node-realtime.ts` and define a fallback refresh path if websocket recovery fails.
|
||||
- [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers.
|
||||
- [x] Convert forward create/edit/delete/service-toggle flows in `vite-frontend/src/pages/forward.tsx` from whole-page refetches to local or targeted updates where safe.
|
||||
- [x] Convert tunnel create/edit/delete flows in `vite-frontend/src/pages/tunnel.tsx` from whole-page refetches to local or targeted updates where safe.
|
||||
- [x] Convert user create/edit/delete and user-tunnel permission mutation flows in `vite-frontend/src/pages/user.tsx` to local or targeted updates where safe.
|
||||
- [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations.
|
||||
- [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative.
|
||||
- [ ] Run focused frontend verification and record the result in this plan after implementation.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 - Dashboard auto-refresh and node realtime resilience
|
||||
|
||||
#### 1. Dashboard traffic/statistics auto-refresh
|
||||
|
||||
- Extract `loadPackageData()` and `loadAnnouncement()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` into stable callbacks so the hook can refresh data without re-running the whole mount sequence.
|
||||
- Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`.
|
||||
- Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates.
|
||||
- Pause polling when `document.visibilityState !== "visible"`, then trigger an immediate refresh when the tab becomes visible again.
|
||||
- Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker.
|
||||
|
||||
#### 2. Dashboard notification safety
|
||||
|
||||
- Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings.
|
||||
- Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle.
|
||||
- Ensure refreshes that only change traffic counters do not retrigger expiry toasts.
|
||||
|
||||
#### 3. Node realtime hardening
|
||||
|
||||
- Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget.
|
||||
- Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached.
|
||||
- If a fallback list refresh is introduced, merge incoming node metadata with existing `systemInfo`, `connectionStatus`, and upgrade-progress state so live metrics are not wiped during recovery.
|
||||
- Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`.
|
||||
|
||||
### Phase 2 - Local mutation updates and partial refreshes
|
||||
|
||||
#### 4. Shared list-patching helpers
|
||||
|
||||
- Add small reusable helpers for common state operations such as:
|
||||
- replace one item by `id`
|
||||
- remove one or many items by `id`
|
||||
- upsert a created or updated item into an ordered list
|
||||
- preserve derived UI-only fields during server payload merges
|
||||
- Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction.
|
||||
|
||||
#### 5. Forward page partial refresh conversion
|
||||
|
||||
- Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns.
|
||||
- Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure.
|
||||
- Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient.
|
||||
- Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state.
|
||||
- Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations.
|
||||
|
||||
#### 6. Tunnel page partial refresh conversion
|
||||
|
||||
- Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`.
|
||||
- Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload.
|
||||
- Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent.
|
||||
|
||||
#### 7. User page partial refresh conversion
|
||||
|
||||
- Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely.
|
||||
- Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations.
|
||||
- Respect server-side pagination semantics for the user list; if the server response does not provide enough data for a safe local patch, use a targeted page refetch rather than a full multi-dataset refresh.
|
||||
- Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues.
|
||||
|
||||
### Phase 3 - Batch progress UX and search/filter polish
|
||||
|
||||
#### 8. Batch progress UX
|
||||
|
||||
- Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations.
|
||||
- Review `vite-frontend/src/pages/forward/batch-actions.ts` and tunnel batch handlers to determine whether current APIs expose enough intermediate state for real progress.
|
||||
- If only final summary APIs are available, implement staged client-side progress feedback such as `processing X/Y`, current action label, success count, and failure count.
|
||||
- If the UX requirement cannot be met without backend support, document the missing backend contract and split the work into frontend and backend follow-ups.
|
||||
|
||||
#### 9. Search and filter responsiveness
|
||||
|
||||
- Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages.
|
||||
- Audit the user page separately because it depends on server-side pagination and keyword search.
|
||||
- If user-page instant filtering is desired, choose one of two explicit strategies:
|
||||
- keep server-side pagination authoritative and add debounce for keyword-triggered requests, or
|
||||
- load a larger local dataset only if product requirements accept the cost.
|
||||
- Do not silently mix partial client filtering with incomplete paginated datasets.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Repeated dashboard polling may spam expiry toasts.
|
||||
- Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes.
|
||||
- Node recovery refreshes may wipe websocket-derived metrics.
|
||||
- Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly.
|
||||
- Local mutation patching may desynchronize grouped, sorted, or selected views.
|
||||
- Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state.
|
||||
- Batch APIs may not expose progress details.
|
||||
- Mitigation: implement client-side staged progress where possible and document backend gaps where not.
|
||||
- User-page local updates may conflict with pagination semantics.
|
||||
- Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
- Dashboard:
|
||||
- Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload.
|
||||
- Confirm hidden-tab pause and visible-tab immediate refresh behavior.
|
||||
- Confirm expiry toasts do not repeat on every polling cycle.
|
||||
- Nodes:
|
||||
- Confirm websocket-driven online/offline transitions still work.
|
||||
- Simulate websocket interruption and verify reconnect or fallback refresh behavior.
|
||||
- Confirm recovery does not clear existing live metrics unexpectedly.
|
||||
- Forwards, tunnels, users:
|
||||
- Create, edit, delete, enable, disable, and reset flows without browser reload.
|
||||
- Confirm the affected rows update immediately and other unrelated rows stay stable.
|
||||
- Confirm selection state, ordering, and modal close behavior remain correct after local patching.
|
||||
- Batch actions:
|
||||
- Confirm visible progress or staged status feedback exists during long-running operations.
|
||||
- Confirm success and failure summaries remain accurate after completion.
|
||||
- Build:
|
||||
- Run `cd vite-frontend && npm run build`.
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
- Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain.
|
||||
- Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly.
|
||||
- If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly.
|
||||
|
||||
## Test Record
|
||||
|
||||
- Command: `cd vite-frontend && npm install`
|
||||
- Result: passed.
|
||||
- Command: `cd vite-frontend && npm run build`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Node Remarks, Tags, and Expiry Plan
|
||||
|
||||
- [x] Review issue #246 and inspect current node backend/frontend flow
|
||||
- [x] Extend node persistence and API payloads with remark, tags, and expiry fields
|
||||
- [x] Update node management UI to edit, display, and search the new metadata
|
||||
- [x] Verify the backend and frontend still build successfully
|
||||
@@ -0,0 +1,6 @@
|
||||
# Node Expiry Highlights And Dashboard Reminders Plan
|
||||
|
||||
- [x] Review current node page and dashboard data flow for expiry-related hooks
|
||||
- [x] Add node expiry status helpers plus expiring-soon filter/highlight in node management
|
||||
- [x] Load node expiry data on the dashboard for admins and render reminder card
|
||||
- [x] Verify frontend build and mark the plan complete
|
||||
@@ -0,0 +1,6 @@
|
||||
# Forward Page Tunnel Traffic Ratio Plan
|
||||
|
||||
- [x] Review `/forward/list` data flow and rule page render points for tunnel ratio support
|
||||
- [x] Extend backend forward list payload with tunnel traffic ratio and cover it with a contract test
|
||||
- [x] Update forward page types, mapping, grouped metadata, and visible ratio UI across list modes
|
||||
- [x] Verify targeted backend tests and frontend build, then mark the plan complete
|
||||
@@ -0,0 +1,7 @@
|
||||
# Node Renewal Cycle And Schema Fix Plan
|
||||
|
||||
- [x] Review the node schema migration path and current expiry implementation
|
||||
- [x] Backfill legacy node tables with the new metadata columns so old SQLite installs do not fail
|
||||
- [x] Replace one-off node expiry UX with recurring renewal cycle fields (month/quarter/year)
|
||||
- [x] Update node reminders and dashboard cards to use recurring renewal calculations
|
||||
- [x] Verify backend and frontend changes, then complete the plan
|
||||
@@ -0,0 +1,7 @@
|
||||
# Node Renewal Auto-Advance Plan
|
||||
|
||||
- [x] Review existing background job infrastructure and decide integration points
|
||||
- [x] Add Repository method to advance node renewal anchor times
|
||||
- [x] Add backend background worker that runs every 6 hours to advance overdue cycles
|
||||
- [x] Add unit tests for renewal cycle advancement logic
|
||||
- [x] Run backend verification and update plan checklist
|
||||
@@ -0,0 +1,5 @@
|
||||
# Node Full-Stack Tags Removal Plan
|
||||
|
||||
- [x] Remove node tags usage from frontend node management and dashboard views
|
||||
- [x] Remove node tags fields from backend models, handlers, repository, and backup logic
|
||||
- [x] Verify frontend build and backend tests pass after the removal
|
||||
@@ -0,0 +1,5 @@
|
||||
# PR 292 Node Page Merge Conflict Resolution Plan
|
||||
|
||||
- [x] Review the conflicted node page and identify all overlapping feature areas from main and PR #292
|
||||
- [x] Merge tab split, per-tab search, remote usage cards, expiry filters, and renewal indicators into `vite-frontend/src/pages/node.tsx`
|
||||
- [x] Build `vite-frontend` and fix any integration issues from the merged result
|
||||
@@ -0,0 +1,19 @@
|
||||
# 028 - Sync Forward Ports On Tunnel Entry Change
|
||||
|
||||
## Goal
|
||||
When a tunnel's entry nodes change, automatically keep all forwards under that tunnel aligned by rebuilding `forward_port` rows to match the latest entry node set.
|
||||
|
||||
## Scope
|
||||
- Backend only: update tunnel mutation flow to sync forward entry mappings.
|
||||
- Preserve existing forward port and bind IP behavior:
|
||||
- Keep the existing forward port (choose the current min port in `forward_port`).
|
||||
- Preserve `in_ip` only when the tunnel has a single entry node; clear `in_ip` for multi-entry tunnels.
|
||||
|
||||
## Checklist
|
||||
- [x] Capture old entry node IDs before tunnel update commits.
|
||||
- [x] After commit, compare old/new entry node sets.
|
||||
- [x] If changed, rebuild `forward_port` for all forwards in the tunnel.
|
||||
- [x] Run `go test ./...` in `go-backend`.
|
||||
|
||||
## Notes
|
||||
- Runtime redeploy/downlink is handled elsewhere; this change focuses on DB-level consistency of forward entry mappings.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 029 - Issue 281 Contract Repro
|
||||
|
||||
## Goal
|
||||
Add a contract test that reproduces issue #281: after changing a tunnel's entry node, forward runtime cleanup does not remove the stale service from the old entry node.
|
||||
|
||||
## Checklist
|
||||
- [x] Review existing contract test helpers for mock node command recording.
|
||||
- [x] Add a contract test that updates a tunnel entry node while a forward is bound to the tunnel.
|
||||
- [x] Assert the new entry node receives forward sync commands and the old entry node does not receive forward cleanup, reproducing the bug.
|
||||
- [x] Run the focused contract test and capture the failure.
|
||||
|
||||
## Test Record
|
||||
- Command: `cd go-backend && go test ./tests/contract/... -run TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract`
|
||||
- Result: failed as expected with `expected old entry node to receive forward DeleteService cleanup for 1_2_281, got none`.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 030 - Fix Issue 281 Stale Forward Runtime Cleanup
|
||||
|
||||
## Goal
|
||||
When a tunnel's entry nodes change, remove forward runtime services from entry nodes that are no longer part of the tunnel before syncing the forward to its new entry nodes.
|
||||
|
||||
## Checklist
|
||||
- [x] Review the tunnel update flow and identify where old/new entry node sets are available.
|
||||
- [x] Add backend cleanup for forward runtimes on removed entry nodes.
|
||||
- [x] Keep existing forward port rebuild and forward resync behavior intact.
|
||||
- [x] Run focused contract regression tests for the issue 281 repro.
|
||||
|
||||
## Test Record
|
||||
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 031 - Entry Transition Regression Coverage
|
||||
|
||||
## Goal
|
||||
Expand issue #281 regression coverage to verify forward runtime cleanup and `forward_port` rebuilding across both single-entry to multi-entry and multi-entry to single-entry tunnel updates.
|
||||
|
||||
## Checklist
|
||||
- [x] Review the current issue 281 contract repro and reuse its mock-node recording helpers.
|
||||
- [x] Add a broader contract test that exercises both entry transition directions.
|
||||
- [x] Assert removed entry nodes receive forward cleanup and retained/new entry nodes receive forward sync.
|
||||
- [x] Run focused contract tests and record the result.
|
||||
|
||||
## Test Record
|
||||
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract|TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
|
||||
- Result: passed.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Issue 291 Tunnel Traffic Quota Plan
|
||||
|
||||
- [x] Confirm quota semantics with issue owner: use existing billed traffic accounting (`traffic_ratio * tunnel.flow`), overage disables the tunnel and pauses active forwards, reset re-enables the tunnel and auto-resumes affected forwards.
|
||||
- [x] Extend backend schema in `go-backend/internal/store/model/model.go` with a dedicated tunnel quota persistence model that stores per-tunnel daily/monthly limits, current billed usage, rollover keys, and quota-disable metadata in a SQLite/PostgreSQL-safe shape.
|
||||
- [x] Add repository support in `go-backend/internal/store/repo/` for reading quota settings, atomically rolling day/month windows forward, incrementing billed tunnel usage from flow uploads, checking overage state, marking quota-triggered disable state, clearing usage on manual reset, and listing quota data alongside tunnels.
|
||||
- [x] Wire billed tunnel usage accumulation into `go-backend/internal/http/handler/flow_policy.go` so each node-reported flow item updates both existing user/user_tunnel counters and the tunnel quota counters using the current billed flow scaling path.
|
||||
- [x] Implement quota enforcement in backend handlers: when a tunnel crosses quota, set `tunnel.status = 0`, mark it as quota-disabled, pause all active forwards under that tunnel, and persist enough state to distinguish quota shutdown from manual disable.
|
||||
- [x] Block forward lifecycle operations against quota-disabled or already-over-quota tunnels in `go-backend/internal/http/handler/mutations.go` and related flow-policy checks so create/resume paths fail fast with explicit quota messages.
|
||||
- [x] Extend the maintenance/reset job in `go-backend/internal/http/handler/jobs.go` to perform daily and monthly quota rollover resets, clear quota-disable flags when limits reset, and auto-resume forwards that were paused by quota enforcement.
|
||||
- [x] Add manual quota reset API support under `go-backend/internal/http/handler/handler.go` and `go-backend/internal/http/handler/mutations.go` for daily/monthly/all reset scopes, with backend logic to clear counters, re-enable the tunnel, and auto-resume forwards.
|
||||
- [x] Extend tunnel API payloads in `go-backend/internal/store/repo/repository.go` and handler responses so `tunnel/list` and `tunnel/get` expose quota configuration, usage, reset window state, and quota-disable reason without conflicting with existing `flow` semantics.
|
||||
- [x] Update backup/import-export structs and repository export/import helpers in `go-backend/internal/store/model/model.go` and `go-backend/internal/store/repo/repository.go` so tunnel quota configuration is preserved across backup/restore; only persist configuration and disable metadata, not stale rolling usage, unless implementation proves current-period restoration is necessary.
|
||||
- [x] Update frontend tunnel types and API helpers in `vite-frontend/src/api/types.ts`, `vite-frontend/src/types/index.ts`, and `vite-frontend/src/api/index.ts` to accept and submit tunnel quota fields with safe defaults for older payloads.
|
||||
- [x] Add quota management UI to `vite-frontend/src/pages/tunnel.tsx` for daily/monthly quota inputs, billed usage display, over-quota status, reset actions, and clear tunnel-disabled messaging while preserving existing layout and form conventions.
|
||||
- [x] Verify behavior with backend contract coverage in `go-backend/tests/contract/` for over-quota disable, create/resume blocking, scheduled reset rollover, manual reset, and auto-resume after reset; run targeted backend tests plus a frontend build validation after implementation. (`go test ./internal/http/handler/... ./tests/contract/...` passed; frontend `npm run build` is currently blocked by missing local dependencies/types in this environment.)
|
||||
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
dist
|
||||
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
@@ -188,7 +188,7 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<PanelSharingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
UserMutationPayload,
|
||||
NodeMutationPayload,
|
||||
TunnelMutationPayload,
|
||||
TunnelQuotaResetPayload,
|
||||
UserTunnelAssignPayload,
|
||||
UserTunnelListQuery,
|
||||
UserTunnelRemovePayload,
|
||||
@@ -65,6 +66,8 @@ export const getUserPackageInfo = () =>
|
||||
export const createNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/create", data);
|
||||
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||
export const getDashboardNodeExpiryList = () =>
|
||||
Network.post<NodeApiItem[]>("/node/list", {});
|
||||
export const updateNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
@@ -115,6 +118,8 @@ export const getTunnelById = (id: number) =>
|
||||
Network.post<TunnelApiItem>("/tunnel/get", { id });
|
||||
export const updateTunnel = (data: TunnelMutationPayload) =>
|
||||
Network.post("/tunnel/update", data);
|
||||
export const resetTunnelQuota = (data: TunnelQuotaResetPayload) =>
|
||||
Network.post("/tunnel/quota/reset", data);
|
||||
export const deleteTunnel = (id: number) =>
|
||||
Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) =>
|
||||
|
||||
@@ -3,6 +3,9 @@ export interface NodeApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||
syncError?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -33,6 +36,19 @@ export interface TunnelApiItem {
|
||||
name: string;
|
||||
type: number;
|
||||
status: number;
|
||||
flow?: number;
|
||||
trafficRatio?: number;
|
||||
inIp?: string;
|
||||
ipPreference?: string;
|
||||
inNodeId?: TunnelChainNodePayload[];
|
||||
outNodeId?: TunnelChainNodePayload[];
|
||||
chainNodes?: TunnelChainNodePayload[][];
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
quotaDisabledAt?: number;
|
||||
entryNodeId: number;
|
||||
exitNodeId: number;
|
||||
inx?: number;
|
||||
@@ -44,6 +60,7 @@ export interface ForwardApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
tunnelName?: string;
|
||||
tunnelTrafficRatio?: number;
|
||||
inIp?: string;
|
||||
inPort?: number;
|
||||
remoteAddr?: string;
|
||||
@@ -214,6 +231,9 @@ export interface NodeMutationPayload {
|
||||
name?: string;
|
||||
status?: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
@@ -243,6 +263,8 @@ export interface TunnelMutationPayload {
|
||||
status?: number;
|
||||
flow?: number;
|
||||
trafficRatio?: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
inIp?: string;
|
||||
ipPreference?: string;
|
||||
inNodeId?: TunnelChainNodePayload[];
|
||||
@@ -250,6 +272,11 @@ export interface TunnelMutationPayload {
|
||||
chainNodes?: TunnelChainNodePayload[][];
|
||||
}
|
||||
|
||||
export interface TunnelQuotaResetPayload {
|
||||
tunnelId: number;
|
||||
scope?: "daily" | "monthly" | "all";
|
||||
}
|
||||
|
||||
export interface UserTunnelAssignPayload {
|
||||
userId?: number;
|
||||
id?: number;
|
||||
|
||||
@@ -259,3 +259,29 @@ export const SettingsIcon = ({
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const BackIcon = ({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15 19l-7-7 7-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user