mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 15:46:38 +08:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 901b903a70 | |||
| 930cc86614 | |||
| 101fd5805a | |||
| 938e9ff4fd | |||
| ebf412b9df | |||
| 9a85363e44 | |||
| 7b9b59644e | |||
| 4e088afb29 | |||
| 1b3ae44940 | |||
| a91abbfebd | |||
| 513591fe67 | |||
| 9412d24c02 | |||
| ab3ca019d2 | |||
| b892b2640e | |||
| 3da9b14bfe | |||
| 49ab2915ee | |||
| becf87118f | |||
| 608fbf74de | |||
| 8b9cdef0e4 | |||
| 3c10727e08 | |||
| 841d43344a | |||
| 5efe790937 | |||
| eec6cb4298 | |||
| 352fc82907 | |||
| 87722e461c | |||
| 701b4011cb | |||
| d128d2f657 | |||
| 400a40fe80 | |||
| 103290ed35 | |||
| 363e714603 | |||
| afd1258fcd | |||
| e69082a596 | |||
| d30363d164 | |||
| 8e1a87bf5a | |||
| 6180b5a198 | |||
| 61d95ab5d5 | |||
| c27be19915 | |||
| f62a35c3f9 | |||
| 2a1caf32c4 | |||
| fdcc30a493 | |||
| efaffb0475 | |||
| 4954526cbc | |||
| 9d50071915 | |||
| ceceee6ebd | |||
| 11051f5517 | |||
| ff2c7c4959 | |||
| 6364b96935 | |||
| 409f0a232a | |||
| f79994e0e0 | |||
| 9fdb16d035 | |||
| 53b632a6f7 | |||
| bf7b2a0740 | |||
| 8475bc27bb | |||
| 5d01572eff | |||
| a353faaa71 | |||
| e7b25004ba | |||
| 3826cb02c0 | |||
| a1fee8e432 | |||
| aafdb78482 | |||
| 16b545d8cd | |||
| 45065178b8 | |||
| 8ebde9dca9 | |||
| 0a1ec60750 | |||
| 9ec35d2f2f | |||
| c914040b7d | |||
| 822362c44c | |||
| 80f5935b76 | |||
| 433c8aab13 | |||
| 949dfcd42d | |||
| 1580e4ee10 | |||
| 32e4f0f514 | |||
| ca3a643ef7 | |||
| ce2b234843 | |||
| ac3506847c | |||
| 6e3d604618 | |||
| 4417ece7cd | |||
| bab4371ba7 | |||
| 960c97cee4 | |||
| 9d05d75fd6 | |||
| c137bdcc63 | |||
| b7065f6e99 | |||
| bd4e1f66cb | |||
| 9f0670f4d0 | |||
| 32e338d295 | |||
| f6a753baa3 | |||
| 322a10bb9d | |||
| 27c13d6c47 | |||
| f45f96063a | |||
| 1780be73b9 | |||
| addf8e2089 | |||
| 75cd60ea3e | |||
| fe42a77409 | |||
| ce9abf457f | |||
| 3c57a5ac84 | |||
| ff7c91d277 | |||
| 1498f3052d | |||
| 6b1264ae90 | |||
| 18445ec063 | |||
| 08bc91e5c9 | |||
| 2f424bea31 | |||
| bc75ed745d | |||
| 78fb9a31d6 | |||
| d3ed2e8856 | |||
| db21ce6bb4 | |||
| 76ad841231 | |||
| d0535707dc | |||
| 6458b5af00 | |||
| 555039e028 | |||
| 7134253b2c | |||
| 58d29b440a | |||
| 6a3a9add08 | |||
| 6d986524f1 | |||
| 92a8fed796 | |||
| b314192621 | |||
| 1e5f9bfb04 | |||
| 5972378897 | |||
| 455900ba41 | |||
| 85e57213ee | |||
| 1377061234 | |||
| ea21a7deef | |||
| 9b98194a0a | |||
| 2df061a19f | |||
| 46a60376c4 | |||
| 9de240f034 | |||
| 41ef814643 | |||
| 6c7b4817f9 | |||
| 7507507fd9 | |||
| ff94406945 | |||
| 9aa13c4dfb | |||
| 4a8c400944 | |||
| 18e7ec94a8 | |||
| 02f2a1c8b3 | |||
| 681a0bef48 |
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "TodoWrite",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-todo"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code pre-task"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-end"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code user-prompt-submit"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(./.entire/metadata/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# 功能请求:在规则页面显示隧道倍率
|
||||
|
||||
## 问题描述
|
||||
|
||||
当前规则(Forward)页面在列表中显示隧道名称,但**不显示隧道的流量倍率(trafficRatio)**。管理员在管理规则时无法快速查看该规则所使用的隧道倍率信息,需要跳转到隧道页面才能查看。
|
||||
|
||||
## 期望行为
|
||||
|
||||
在规则列表页面中,在隧道名称旁边或单独列显示该隧道的流量倍率(例如:`1x`, `0.5x`, `2x`)。
|
||||
|
||||
## 建议实现位置
|
||||
|
||||
### 前端修改
|
||||
|
||||
1. **`vite-frontend/src/pages/forward.tsx`**
|
||||
- 在 `Forward` interface 中添加 `tunnelTrafficRatio?: number` 字段
|
||||
- 在表格列中添加倍率显示(可以在隧道名称 Chip 旁边或单独一列)
|
||||
- 从 `userTunnel` 或 `getTunnelList` API 获取隧道倍率信息
|
||||
|
||||
2. **显示格式建议**
|
||||
```tsx
|
||||
<Chip className="...">
|
||||
{forward.tunnelName} ({forward.tunnelTrafficRatio}x)
|
||||
</Chip>
|
||||
```
|
||||
或者单独一列:
|
||||
```tsx
|
||||
<TableCell>
|
||||
{forward.tunnelTrafficRatio}x
|
||||
</TableCell>
|
||||
```
|
||||
|
||||
### 后端修改
|
||||
|
||||
1. **`go-backend/internal/http/handler/handler.go`**
|
||||
- 在 `forwardList` 接口返回中添加隧道的 `trafficRatio` 字段
|
||||
- 需要在查询 Forward 时 JOIN Tunnel 表获取倍率信息
|
||||
|
||||
2. **或者在前端加载规则后,批量获取隧道信息**
|
||||
- 调用 `getTunnelList` 获取所有隧道信息
|
||||
- 根据 `tunnelId` 匹配倍率
|
||||
|
||||
## 相关文件
|
||||
|
||||
- 前端:`vite-frontend/src/pages/forward.tsx`
|
||||
- 前端类型:`vite-frontend/src/api/types.ts`
|
||||
- 后端:`go-backend/internal/http/handler/handler.go`
|
||||
- 隧道类型定义:`vite-frontend/src/api/types.ts` (TunnelApiItem)
|
||||
|
||||
## 优先级
|
||||
|
||||
中等 - 不影响核心功能,但能提升管理效率
|
||||
|
||||
## 截图参考
|
||||
|
||||
隧道页面已显示倍率:
|
||||
- 位置:隧道卡片统计信息区域
|
||||
- 显示格式:`流量倍率 {trafficRatio}x`
|
||||
|
||||
---
|
||||
|
||||
**Labels**: `enhancement`, `frontend`, `backend`, `ui/ux`
|
||||
@@ -62,8 +62,12 @@ go-gost/ss/
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
|
||||
# OpenCode session metadata
|
||||
.entire/
|
||||
bin/
|
||||
tmp/
|
||||
.worktrees/
|
||||
*.swp
|
||||
*.bak
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Plan: 搭建开发环境
|
||||
|
||||
## 目标
|
||||
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
|
||||
|
||||
## 当前状态
|
||||
|
||||
### ✅ 已安装
|
||||
| 工具 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Node.js | v20.19.2 | vite-frontend |
|
||||
| npm | 9.2.0 | vite-frontend |
|
||||
| Go | 1.24.4 | go-gost |
|
||||
| Docker | 29.1.4 | 容器化部署 |
|
||||
|
||||
### ❌ 缺失
|
||||
| 工具 | 需求版本 | 用途 |
|
||||
|------|----------|------|
|
||||
| Java | 21 | springboot-backend |
|
||||
| Maven | 3.x | 构建后端 |
|
||||
| Docker Compose | v2 | 容器编排 |
|
||||
|
||||
---
|
||||
|
||||
## 执行任务
|
||||
|
||||
### Task 1: 安装 Java 21
|
||||
```bash
|
||||
apt-get update && apt-get install -y openjdk-21-jdk
|
||||
```
|
||||
**验证**: `java -version` 应显示 openjdk 21
|
||||
|
||||
### Task 2: 安装 Maven
|
||||
```bash
|
||||
apt-get install -y maven
|
||||
```
|
||||
**验证**: `mvn -v` 应显示 Maven 3.x
|
||||
|
||||
### Task 3: 安装 Docker Compose Plugin
|
||||
```bash
|
||||
apt-get install -y docker-compose-plugin
|
||||
```
|
||||
**验证**: `docker compose version` 应显示版本号
|
||||
|
||||
### Task 4: 安装前端依赖
|
||||
```bash
|
||||
cd /root/flux-panel/vite-frontend && npm install
|
||||
```
|
||||
**验证**: `node_modules/` 目录存在
|
||||
|
||||
### Task 5: 验证后端可构建
|
||||
```bash
|
||||
cd /root/flux-panel/springboot-backend && mvn clean compile -q
|
||||
```
|
||||
**验证**: 编译成功无错误
|
||||
|
||||
### Task 6: 验证 Go 模块
|
||||
```bash
|
||||
cd /root/flux-panel/go-gost && go mod download
|
||||
```
|
||||
**验证**: 依赖下载成功
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
- [ ] `java -version` → openjdk 21
|
||||
- [ ] `mvn -v` → Maven 3.x
|
||||
- [ ] `docker compose version` → v2.x
|
||||
- [ ] 前端: `npm run dev` 可启动
|
||||
- [ ] 后端: `mvn compile` 成功
|
||||
- [ ] Go: `go build .` 成功
|
||||
@@ -1,33 +0,0 @@
|
||||
# Issue #211: 转发自定义监听IP / 隧道指定连接IP
|
||||
|
||||
## 需求总结
|
||||
1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔)
|
||||
2. **转发**: 创建/编辑时可指定入口监听IP
|
||||
3. **隧道**: 配置出口节点时可指定连接IP
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### 后端
|
||||
- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段
|
||||
- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs
|
||||
- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip
|
||||
- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)
|
||||
- [x] 5. Handler - 转发创建/更新处理 inIp 参数
|
||||
- [x] 6. Handler - 隧道出口节点处理 connectIp 参数
|
||||
- [x] 7. Handler - 节点API返回 extraIPs 字段
|
||||
|
||||
### 前端
|
||||
- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入
|
||||
- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择
|
||||
- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入
|
||||
|
||||
---
|
||||
|
||||
## 完成进度
|
||||
- 开始时间: 2026-03-02
|
||||
- 完成时间: 2026-03-02
|
||||
- 完成任务: 10/10
|
||||
- 后端完成: ✅
|
||||
- 前端完成: ✅
|
||||
@@ -1,9 +1,9 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Commit:** 21008cc
|
||||
**Generated:** Tue Mar 24 2026
|
||||
**Commit:** 8ebde9d
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.5-rc15
|
||||
**Tag:** 2.1.9-rc10
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# 限速功能重构实施计划
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。
|
||||
|
||||
**核心变更**:
|
||||
1. 限速规则(SpeedLimit)与隧道的绑定关系改为可选
|
||||
2. 转发(Forward)支持独立的限速规则
|
||||
|
||||
---
|
||||
|
||||
## 二、实施计划清单
|
||||
|
||||
### 2.0 计划状态(审计更新:2026-02-26)
|
||||
|
||||
- 总体状态:**进行中(未验收通过)**
|
||||
- 已完成:模型、仓储查询、限速 CRUD、控制面优先级、限速页与类型改造、编译与测试通过
|
||||
- 未完成:**Forward 独立限速写入链路**(前端表单 -> API handler -> repository 落库 `forward.speed_id`)
|
||||
|
||||
### 2.1 后端模型层 (Model)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| M1 | SpeedLimit.TunnelID 改为 sql.NullInt64 (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M2 | SpeedLimit.TunnelName 改为 sql.NullString (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M3 | Forward 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M4 | ForwardRecord 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M5 | SpeedLimitBackup.TunnelID 改为指针类型 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M6 | ForwardBackup 添加 SpeedID *int64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
|
||||
### 2.2 后端仓储层 (Repository)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| R1 | ListSpeedLimits() 返回可空 tunnelId/tunnelName | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R2 | ListForwards() 返回 speedId 字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R3 | CreateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| R4 | UpdateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| R5 | GetSpeedLimitTunnelID() 返回 sql.NullInt64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| R6 | exportSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R7 | importSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R8 | GetSpeedLimitSpeed() 新增方法 | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
| R9 | ListForwardsByTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_control.go` | ✅ 完成 |
|
||||
| R10 | ListActiveForwardsByUser() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
| R11 | ListActiveForwardsByUserTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
| R12 | GetForwardRecord() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
|
||||
### 2.3 后端处理器层 (Handler)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| H1 | speedLimitCreate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| H2 | speedLimitUpdate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| H3 | speedLimitDelete 处理可空 tunnelID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
|
||||
### 2.4 后端控制平面 (Control Plane)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| C1 | syncForwardServices 优先使用 Forward.SpeedID | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
|
||||
| C2 | 回退到 UserTunnel 的 speed limit | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
|
||||
|
||||
### 2.5 前端类型定义 (TypeScript Types)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| T1 | SpeedLimitApiItem.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
| T2 | ForwardApiItem 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
| T3 | ForwardMutationPayload 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
| T4 | SpeedLimitMutationPayload.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
|
||||
### 2.6 前端页面组件
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| F1 | SpeedLimitRule 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F2 | SpeedLimitForm 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F3 | validateForm 移除 tunnelId 必填校验 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F4 | Select 组件改为可选 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F5 | 显示"未绑定"状态 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
|
||||
### 2.7 编译验证
|
||||
|
||||
| 序号 | 任务 | 状态 |
|
||||
|------|------|------|
|
||||
| B1 | Go 后端编译通过 | ✅ 完成 |
|
||||
| B2 | TypeScript 类型检查通过 | ✅ 完成 |
|
||||
| B3 | `go test ./...` 全量通过 | ✅ 完成 |
|
||||
| B4 | `go test ./tests/contract/... -run SpeedLimit` 通过 | ✅ 完成 |
|
||||
|
||||
### 2.8 Forward 独立限速写入链路补全(新增)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| N1 | forwardCreate 支持接收并校验可选 speedId,写入 Forward.SpeedID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| N2 | forwardUpdate 支持更新/清空 speedId,并触发服务重下发 | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| N3 | CreateForwardTx 支持落库 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| N4 | UpdateForward 支持更新 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| N5 | Forward 页面新增限速选择并透传 speedId | `vite-frontend/src/pages/forward.tsx` | ✅ 完成 |
|
||||
| N6 | Forward 相关契约测试补充 speedId 写入/清空断言 | `go-backend/tests/contract/forward_contract_test.go` | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 三、优先级说明
|
||||
|
||||
限速规则应用优先级:
|
||||
1. **Forward.SpeedID** - 转发级别的限速 (最高优先)
|
||||
2. **UserTunnel.SpeedID** - 用户隧道权限级别的限速 (回退)
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库兼容性
|
||||
|
||||
- SpeedLimit 表: `tunnel_id` 和 `tunnel_name` 字段改为可空 (GORM AutoMigrate 自动处理)
|
||||
- Forward 表: 新增 `speed_id` 可空字段 (GORM AutoMigrate 自动处理)
|
||||
|
||||
---
|
||||
|
||||
## 五、验证检查项
|
||||
|
||||
### 5.1 功能验证(审计后)
|
||||
|
||||
- [x] 创建不限速规则的限速 (不绑定隧道)
|
||||
- [x] 创建绑定隧道的限速 (兼容旧逻辑)
|
||||
- [x] 编辑限速规则,切换隧道绑定状态
|
||||
- [ ] 删除限速规则
|
||||
- [ ] 转发列表正确显示 speedId
|
||||
|
||||
### 5.2 API 验证(审计后)
|
||||
|
||||
- [x] GET /api/speed-limit/list 返回可选 tunnelId
|
||||
- [x] POST /api/speed-limit/create 接受可选 tunnelId
|
||||
- [x] POST /api/speed-limit/update 接受可选 tunnelId
|
||||
- [ ] GET /api/forward/list 返回 speedId
|
||||
|
||||
### 5.3 兼容性验证(审计后)
|
||||
|
||||
- [x] 现有绑定隧道的限速规则继续正常工作
|
||||
- [ ] 现有 UserTunnel 的限速继续正常工作
|
||||
- [ ] 备份/恢复功能正常
|
||||
|
||||
### 5.4 Forward 独立限速闭环验证(新增)
|
||||
|
||||
- [x] POST /api/forward/create 接受 speedId 并写入 `forward.speed_id`
|
||||
- [x] POST /api/forward/update 可更新/清空 speedId
|
||||
- [x] Forward 表单可选择限速并提交 speedId
|
||||
- [ ] `syncForwardServices` 实际使用 Forward.SpeedID 而非仅回退 UserTunnel.SpeedID
|
||||
@@ -33,13 +33,13 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
|
||||
#### 安装特定版本
|
||||
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
|
||||
|
||||
面板端(以 2.1.0 为例):
|
||||
面板端(以 2.1.9-beta6 为例):
|
||||
```bash
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
节点端(以 2.1.0 为例):
|
||||
节点端(以 2.1.9-beta6 为例):
|
||||
```bash
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
#### PostgreSQL 部署(Docker Compose)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Floating Save Button Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a floating save button (FAB) to the config page that appears when configuration changes are detected.
|
||||
|
||||
**Architecture:** Inline FAB implementation using framer-motion AnimatePresence for enter/exit animations. Fixed-position circular button with slide-up animation, reusing existing hasChanges state and handleSave function.
|
||||
|
||||
**Tech Stack:** React, framer-motion (v11.18.2), shadcn-bridge/heroui Button, Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `vite-frontend/src/pages/config.tsx` | Modify | Add FAB imports and component at page bottom |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add framer-motion Imports
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx:1-5`
|
||||
|
||||
- [ ] **Step 1: Add AnimatePresence and motion imports**
|
||||
|
||||
Add import statement after existing framer-motion imports (or at top if none exist).
|
||||
|
||||
Current imports at line 1-2:
|
||||
```typescript
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
```
|
||||
|
||||
Add new import after line 2:
|
||||
```typescript
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit import addition**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/config.tsx
|
||||
git commit -m "feat(config): add framer-motion imports for FAB animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add FAB Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx:1220-1224` (end of component)
|
||||
|
||||
- [ ] **Step 1: Add FAB at end of component (before closing div)**
|
||||
|
||||
Locate the end of `ConfigPage` component (line ~1223, the closing `</div>` after all modals).
|
||||
|
||||
Insert FAB component before the closing `</div>`:
|
||||
|
||||
```tsx
|
||||
{/* Floating Save Button (FAB) */}
|
||||
<AnimatePresence>
|
||||
{hasChanges && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="fixed bottom-6 right-6 z-50"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
size="lg"
|
||||
className="w-12 h-12 rounded-full shadow-lg"
|
||||
isLoading={saving}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{!saving && <SaveIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run dev server to verify**
|
||||
|
||||
```bash
|
||||
cd vite-frontend && npm run dev
|
||||
```
|
||||
|
||||
Manual verification checklist:
|
||||
- Open config page at http://localhost:3000/config
|
||||
- Modify any config field
|
||||
- Verify FAB appears with slide-up animation
|
||||
- Click FAB to save
|
||||
- Verify FAB disappears with slide-down animation after save
|
||||
- Scroll page and verify FAB stays fixed in viewport corner
|
||||
- Test on mobile viewport (resize browser or use dev tools)
|
||||
|
||||
- [ ] **Step 3: Commit FAB implementation**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/config.tsx
|
||||
git commit -m "feat(config): add floating save button (FAB) for issue #266"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Summary
|
||||
|
||||
| Requirement | Verification Method |
|
||||
|-------------|---------------------|
|
||||
| FAB hidden by default | Visual: no FAB on page load with no changes |
|
||||
| FAB appears on change | Visual: modify field → FAB slides up |
|
||||
| Fixed position | Visual: scroll page → FAB stays in corner |
|
||||
| Slide-up animation | Visual: observe animation timing/bounce |
|
||||
| Slide-down on save | Visual: click save → FAB slides down |
|
||||
| Loading state | Visual: click save → spinner shown during save |
|
||||
| Mobile compatibility | Visual: resize to mobile viewport → same behavior |
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [x] Spec coverage: All requirements from design doc covered (imports + FAB component, animation params, button style, interaction behavior)
|
||||
- [x] No placeholders: All code shown, no TBD/TODO
|
||||
- [x] Type consistency: SaveIcon (line 45-59), handleSave (line 372-434), hasChanges (line 214), saving (line 213) all exist in config.tsx
|
||||
@@ -0,0 +1,520 @@
|
||||
# GitHub 加速地址自定义配置实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速,配置影响全部下载场景。
|
||||
|
||||
**Architecture:** 使用现有 `vite_config` 表存储配置,后端 Handler 读取配置替换硬编码,前端复用现有配置项渲染逻辑,安装脚本支持环境变量和交互式询问。
|
||||
|
||||
**Tech Stack:** Go 1.24, React/TypeScript, Shell/Bash
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `go-backend/internal/http/handler/upgrade.go` | 修改 | 移除硬编码,添加配置读取函数 |
|
||||
| `go-backend/internal/http/handler/mutations.go` | 修改 | `nodeInstall` 函数使用动态配置 |
|
||||
| `vite-frontend/src/pages/config.tsx` | 修改 | 添加两个新配置项 |
|
||||
| `install.sh` | 修改 | 支持交互式询问和环境变量 |
|
||||
| `panel_install.sh` | 修改 | 支持交互式询问和环境变量 |
|
||||
| `test-install-scripts-proxy.sh` | 新增 | 覆盖代理交互与下载 URL 回归 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端 - upgrade.go 修改
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/upgrade.go`
|
||||
|
||||
- [x] **Step 1: 移除硬编码常量,添加配置读取函数**
|
||||
|
||||
在 `upgrade.go` 中,移除 `githubProxy` 常量,添加 `getGithubProxyConfig` 函数:
|
||||
|
||||
找到第 16-26 行:
|
||||
```go
|
||||
const (
|
||||
githubRepo = "Sagit-chu/flvx"
|
||||
githubProxy = "https://gcode.hostcentral.cc"
|
||||
githubAPIBase = "https://api.github.com"
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
batchWorkers = 5
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
)
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
const (
|
||||
githubRepo = "Sagit-chu/flvx"
|
||||
githubAPIBase = "https://api.github.com"
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
batchWorkers = 5
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
|
||||
defaultGithubProxyEnabled = true
|
||||
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
|
||||
)
|
||||
```
|
||||
|
||||
然后在 `releaseChannelLabel` 函数后(约第 71 行之后)添加新函数:
|
||||
```go
|
||||
// getGithubProxyConfig 获取 GitHub 加速配置
|
||||
// 返回: (是否开启加速, 加速地址)
|
||||
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
|
||||
enabled = defaultGithubProxyEnabled
|
||||
proxyURL = defaultGithubProxyURL
|
||||
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 读取开启状态
|
||||
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
|
||||
enabled = enabledCfg.Value != "false"
|
||||
}
|
||||
|
||||
// 读取加速地址
|
||||
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
|
||||
proxyURL = strings.TrimSpace(urlCfg.Value)
|
||||
// 确保 URL 格式正确
|
||||
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
|
||||
proxyURL = "https://" + proxyURL
|
||||
}
|
||||
proxyURL = strings.TrimSuffix(proxyURL, "/")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// buildGithubDownloadURL 构建 GitHub 下载地址
|
||||
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
|
||||
|
||||
if enabled {
|
||||
return fmt.Sprintf("%s/%s", proxyURL, base)
|
||||
}
|
||||
return base
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 修改 nodeUpgrade 函数使用动态配置**
|
||||
|
||||
找到第 152-159 行:
|
||||
```go
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
||||
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
||||
```
|
||||
|
||||
- [x] **Step 3: 修改 nodeBatchUpgrade 函数使用动态配置**
|
||||
|
||||
找到第 216-223 行:
|
||||
```go
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
||||
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
||||
```
|
||||
|
||||
- [x] **Step 4: 验证编译**
|
||||
|
||||
Run: `cd go-backend && go build ./...`
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
- [x] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/upgrade.go
|
||||
git commit -m "feat(backend): use configurable github proxy for node upgrades"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 - mutations.go 修改
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/mutations.go`
|
||||
|
||||
- [x] **Step 1: 修改 nodeInstall 函数使用动态配置**
|
||||
|
||||
找到第 456 行:
|
||||
```go
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
|
||||
var cmd string
|
||||
if enabled {
|
||||
cmd = fmt.Sprintf("curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
|
||||
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret)
|
||||
} else {
|
||||
cmd = fmt.Sprintf("curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
|
||||
githubRepo, version, version, processServerAddress(panelAddr), secret)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 验证编译**
|
||||
|
||||
Run: `cd go-backend && go build ./...`
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
- [x] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/mutations.go
|
||||
git commit -m "feat(backend): use configurable github proxy for node install command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 前端 - config.tsx 添加配置项
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
- [x] **Step 1: 在 CONFIG_ITEMS 数组中添加配置项**
|
||||
|
||||
找到第 158 行(`CONFIG_ITEMS` 数组的结束位置):
|
||||
```go
|
||||
{
|
||||
key: "cloudflare_secret_key",
|
||||
label: "Cloudflare Secret Key",
|
||||
placeholder: "请输入 Cloudflare Secret Key",
|
||||
description: "Cloudflare Turnstile 密钥",
|
||||
type: "input",
|
||||
dependsOn: "captcha_enabled",
|
||||
dependsValue: "true",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
在 `];` 之前添加:
|
||||
```typescript
|
||||
{
|
||||
key: "github_proxy_enabled",
|
||||
label: "开启 GitHub 加速",
|
||||
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
|
||||
type: "switch",
|
||||
},
|
||||
{
|
||||
key: "github_proxy_url",
|
||||
label: "加速地址",
|
||||
placeholder: "https://gcode.hostcentral.cc",
|
||||
description: "GitHub 下载加速代理地址,开启加速后生效",
|
||||
type: "input",
|
||||
dependsOn: "github_proxy_enabled",
|
||||
dependsValue: "true",
|
||||
},
|
||||
```
|
||||
|
||||
- [x] **Step 2: 在缓存键列表中添加新键**
|
||||
|
||||
找到第 179-190 行:
|
||||
```typescript
|
||||
const configKeys = [
|
||||
"app_name",
|
||||
"captcha_enabled",
|
||||
"cloudflare_site_key",
|
||||
"cloudflare_secret_key",
|
||||
"forward_compact_mode",
|
||||
"monitor_tunnel_quality_enabled",
|
||||
"ip",
|
||||
"panel_domain",
|
||||
"app_logo",
|
||||
"app_favicon",
|
||||
];
|
||||
```
|
||||
|
||||
在 `"app_favicon",` 之后添加:
|
||||
```typescript
|
||||
"github_proxy_enabled",
|
||||
"github_proxy_url",
|
||||
```
|
||||
|
||||
- [x] **Step 3: 验证前端编译**
|
||||
|
||||
Run: `cd vite-frontend && npm run build`
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
- [x] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/config.tsx
|
||||
git commit -m "feat(frontend): add github proxy config settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 安装脚本 - install.sh 修改
|
||||
|
||||
**Files:**
|
||||
- Modify: `install.sh`
|
||||
|
||||
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
|
||||
|
||||
找到第 28-32 行:
|
||||
```bash
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
```bash
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
|
||||
# 如果明确关闭加速
|
||||
if [[ "$PROXY_ENABLED" == "false" ]]; then
|
||||
echo "$url"
|
||||
return
|
||||
fi
|
||||
|
||||
# 默认开启加速
|
||||
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
|
||||
|
||||
# 处理 URL 格式
|
||||
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
|
||||
proxy="${proxy%/}"
|
||||
else
|
||||
proxy="https://${proxy}"
|
||||
fi
|
||||
|
||||
echo "${proxy}/${url}"
|
||||
}
|
||||
|
||||
# 询问加速配置(如果未由面板传入)
|
||||
ask_proxy_config() {
|
||||
if [[ -n "$PROXY_ENABLED" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " GitHub 加速配置"
|
||||
echo "==============================================="
|
||||
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
|
||||
case "$proxy_choice" in
|
||||
n|N)
|
||||
PROXY_ENABLED="false"
|
||||
echo "已关闭加速,将直连 GitHub"
|
||||
;;
|
||||
*)
|
||||
PROXY_ENABLED="true"
|
||||
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
|
||||
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
|
||||
echo "已开启加速: $PROXY_URL"
|
||||
;;
|
||||
esac
|
||||
echo "==============================================="
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 修改 install_flux_agent 函数添加询问**
|
||||
|
||||
找到第 211-214 行:
|
||||
```bash
|
||||
# 安装功能
|
||||
install_flux_agent() {
|
||||
echo "🚀 开始安装 flux_agent..."
|
||||
get_config_params
|
||||
```
|
||||
|
||||
替换为:
|
||||
```bash
|
||||
# 安装功能
|
||||
install_flux_agent() {
|
||||
echo "🚀 开始安装 flux_agent..."
|
||||
|
||||
# 询问加速配置(如果未由面板传入)
|
||||
ask_proxy_config
|
||||
|
||||
get_config_params
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add install.sh
|
||||
git commit -m "feat(script): add configurable github proxy for install.sh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 安装脚本 - panel_install.sh 修改
|
||||
|
||||
**Files:**
|
||||
- Modify: `panel_install.sh`
|
||||
|
||||
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
|
||||
|
||||
找到第 16-20 行:
|
||||
```bash
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
```bash
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
|
||||
# 如果明确关闭加速
|
||||
if [[ "$PROXY_ENABLED" == "false" ]]; then
|
||||
echo "$url"
|
||||
return
|
||||
fi
|
||||
|
||||
# 默认开启加速
|
||||
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
|
||||
|
||||
# 处理 URL 格式
|
||||
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
|
||||
proxy="${proxy%/}"
|
||||
else
|
||||
proxy="https://${proxy}"
|
||||
fi
|
||||
|
||||
echo "${proxy}/${url}"
|
||||
}
|
||||
|
||||
# 询问加速配置(如果未由面板传入)
|
||||
ask_proxy_config() {
|
||||
if [[ -n "$PROXY_ENABLED" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " GitHub 加速配置"
|
||||
echo "==============================================="
|
||||
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
|
||||
case "$proxy_choice" in
|
||||
n|N)
|
||||
PROXY_ENABLED="false"
|
||||
echo "已关闭加速,将直连 GitHub"
|
||||
;;
|
||||
*)
|
||||
PROXY_ENABLED="true"
|
||||
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
|
||||
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
|
||||
echo "已开启加速: $PROXY_URL"
|
||||
;;
|
||||
esac
|
||||
echo "==============================================="
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 修改 install_panel 函数添加询问**
|
||||
|
||||
找到第 375-378 行:
|
||||
```bash
|
||||
# 安装功能
|
||||
install_panel() {
|
||||
echo "🚀 开始安装面板..."
|
||||
check_docker
|
||||
get_config_params
|
||||
```
|
||||
|
||||
替换为:
|
||||
```bash
|
||||
# 安装功能
|
||||
install_panel() {
|
||||
echo "🚀 开始安装面板..."
|
||||
|
||||
# 询问加速配置(如果未由面板传入)
|
||||
ask_proxy_config
|
||||
|
||||
check_docker
|
||||
get_config_params
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add panel_install.sh
|
||||
git commit -m "feat(script): add configurable github proxy for panel_install.sh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 最终验证和提交
|
||||
|
||||
- [x] **Step 1: 验证后端编译**
|
||||
|
||||
Run: `cd go-backend && go build ./...`
|
||||
Expected: 编译成功
|
||||
|
||||
- [x] **Step 2: 验证前端编译**
|
||||
|
||||
Run: `cd vite-frontend && npm run build`
|
||||
Expected: 编译成功
|
||||
|
||||
- [x] **Step 3: 验证脚本语法**
|
||||
|
||||
Run: `bash -n install.sh && bash -n panel_install.sh && bash test-install-scripts-proxy.sh`
|
||||
Expected: 无语法错误,且脚本代理回归测试通过
|
||||
|
||||
- [ ] **Step 4: 推送所有提交**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 面板设置页面显示 GitHub 加速配置项
|
||||
2. 开关关闭后,下载地址直连 GitHub
|
||||
3. 自定义加速地址后,节点更新和安装命令使用自定义地址
|
||||
4. 安装脚本支持交互式询问加速配置
|
||||
5. 面板生成的安装命令包含加速配置环境变量
|
||||
@@ -0,0 +1,220 @@
|
||||
# Commercial White-Label Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow users with a valid license key to activate commercial white-label features, enabling them to remove FLVX branding and use their own app name, logos, and footer.
|
||||
**Architecture:** Backend API handles license validation and stores state (`is_commercial`). Both frontend and backend check this state to conditionally render or allow modifications to brand config.
|
||||
**Tech Stack:** Go (Backend API), React + Vite (Frontend UI).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend License Activation Endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Add license request struct**
|
||||
Add the `licenseActivateRequest` struct in `handler.go`.
|
||||
|
||||
```go
|
||||
type licenseActivateRequest struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `licenseActivate` handler method**
|
||||
Add the method to validate the key in `handler.go`.
|
||||
|
||||
```go
|
||||
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req licenseActivateRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(req.LicenseKey)
|
||||
if !strings.HasPrefix(key, "FLVX-") {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的商业授权码"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the route**
|
||||
In `handler.go` inside `Register(mux *http.ServeMux)`, add the route.
|
||||
|
||||
```go
|
||||
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go
|
||||
git commit -m "feat: add license activation endpoint"
|
||||
```
|
||||
|
||||
### Task 2: Backend Config Update Validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Add permission check in `updateConfigs`**
|
||||
In `updateConfigs`, fetch `isCommercial := h.repo.GetConfig("is_commercial")`. Inside the loop, check if the user is trying to update protected keys.
|
||||
|
||||
```go
|
||||
isCommercial, _ := h.repo.GetConfig("is_commercial")
|
||||
protectedKeys := map[string]bool{
|
||||
"app_name": true,
|
||||
"app_logo": true,
|
||||
"app_favicon": true,
|
||||
"hide_footer_brand": true,
|
||||
}
|
||||
```
|
||||
Inside `for k, v := range payload`:
|
||||
```go
|
||||
if protectedKeys[key] && isCommercial.Value != "true" {
|
||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add permission check in `updateSingleConfig`**
|
||||
In `updateSingleConfig`, do the same check before calling `normalizeAndValidateConfigValue`.
|
||||
|
||||
```go
|
||||
isCommercial, _ := h.repo.GetConfig("is_commercial")
|
||||
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial.Value != "true" {
|
||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go
|
||||
git commit -m "feat: add authorization check for commercial config keys"
|
||||
```
|
||||
|
||||
### Task 3: Frontend API & Site Config Update
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
- Modify: `vite-frontend/src/config/site.ts`
|
||||
|
||||
- [ ] **Step 1: Add `activateLicense` API**
|
||||
In `vite-frontend/src/api/index.ts`:
|
||||
|
||||
```typescript
|
||||
export const activateLicense = (licenseKey: string) =>
|
||||
Network.post("/license/activate", { license_key: licenseKey });
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `siteConfig` defaults**
|
||||
In `vite-frontend/src/config/site.ts`, inside `getInitialConfig()`, add properties.
|
||||
|
||||
```typescript
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
is_commercial: configCache.get("is_commercial") === "true",
|
||||
hide_footer_brand: configCache.get("hide_footer_brand") === "true",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `updateSiteConfig`**
|
||||
In `updateSiteConfig` inside `site.ts`, extract and update `is_commercial` and `hide_footer_brand`.
|
||||
|
||||
```typescript
|
||||
const isCommercial = resolvedConfigMap.is_commercial === "true";
|
||||
const hideFooterBrand = resolvedConfigMap.hide_footer_brand === "true";
|
||||
siteConfig.is_commercial = isCommercial;
|
||||
siteConfig.hide_footer_brand = hideFooterBrand;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add vite-frontend/src/api/index.ts vite-frontend/src/config/site.ts
|
||||
git commit -m "feat: add frontend api and update site config state for license"
|
||||
```
|
||||
|
||||
### Task 4: Frontend Footer Component Update
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/components/version-footer.tsx`
|
||||
|
||||
- [ ] **Step 1: Conditionally hide "Powered by FLVX"**
|
||||
In the render block, wrap the `Powered by FLVX` text.
|
||||
|
||||
```tsx
|
||||
{siteConfig.hide_footer_brand !== true && (
|
||||
<p className={poweredClassName}>
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
FLVX
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
```bash
|
||||
git add vite-frontend/src/components/version-footer.tsx
|
||||
git commit -m "feat: conditionally hide flvx footer brand"
|
||||
```
|
||||
|
||||
### Task 5: Frontend Settings Page UI Update
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
- [ ] **Step 1: Add config keys to initialization**
|
||||
In `getInitialConfigs`, add `"is_commercial"` and `"hide_footer_brand"` to `configKeys`.
|
||||
|
||||
- [ ] **Step 2: Add `hide_footer_brand` switch field**
|
||||
Add it to the `CONFIG_ITEMS` array.
|
||||
|
||||
```typescript
|
||||
{
|
||||
key: "hide_footer_brand",
|
||||
label: "隐藏页面底部 FLVX 版权信息",
|
||||
description: "需商业版授权才能生效",
|
||||
type: "switch",
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add license activation UI**
|
||||
Above the System Config Card (near `value="configs"`), add a new `Card` for "商业版授权". You will need a local state `licenseKey` and an `handleActivateLicense` function that calls `activateLicense(licenseKey)` and refetches configs on success.
|
||||
|
||||
- [ ] **Step 4: Disable brand settings when not commercial**
|
||||
In `renderConfigItem`, compute `isDisabled` and pass it to the `<Input>`, `<Switch>`, and `BrandUploading` UI. Update the logic to disable modifications and add a lock icon or a tooltip explaining that a commercial license is required.
|
||||
|
||||
```typescript
|
||||
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add vite-frontend/src/pages/config.tsx
|
||||
git commit -m "feat: ui settings for commercial white-label and license activation"
|
||||
```
|
||||
@@ -0,0 +1,347 @@
|
||||
# Commercial White-Label (Keygen) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement Keygen.sh license activation and periodic validation to manage commercial white-label features, replacing the temporary mock logic.
|
||||
**Architecture:** The backend generates a machine fingerprint, validates the license via the Keygen.sh API, and creates a machine associated with the license. A periodic job verifies the license status to support remote revocation.
|
||||
**Tech Stack:** Go (Backend API), Keygen.sh API.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generate and Store Machine Fingerprint
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Add `getOrCreateMachineFingerprint` helper function**
|
||||
Add a helper function in `handler.go` (or a dedicated license file) to get or generate the machine fingerprint. Use `github.com/google/uuid`.
|
||||
|
||||
```go
|
||||
import "github.com/google/uuid"
|
||||
|
||||
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
|
||||
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
if fp != "" {
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
newFp := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newFp, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go
|
||||
git commit -m "feat: add machine fingerprint generation"
|
||||
```
|
||||
|
||||
### Task 2: Create Keygen Client Package
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/license/keygen.go`
|
||||
|
||||
- [ ] **Step 1: Create Keygen client structs and interface**
|
||||
Create the file and define the request/response structs for Keygen's `/licenses/actions/validate-key` and `/machines` endpoints. Also define an interface for the client.
|
||||
|
||||
```go
|
||||
package license
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KeygenClient struct {
|
||||
AccountID string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewKeygenClient(accountID, token string) *KeygenClient {
|
||||
return &KeygenClient{
|
||||
AccountID: accountID,
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateResponse struct {
|
||||
Meta struct {
|
||||
Valid bool `json:"valid"`
|
||||
Code string `json:"code"`
|
||||
} `json:"meta"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ActivateMachineRequest struct {
|
||||
Data struct {
|
||||
Type string `json:"type"`
|
||||
Attributes struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
} `json:"attributes"`
|
||||
Relationships struct {
|
||||
License struct {
|
||||
Data struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
} `json:"license"`
|
||||
} `json:"relationships"`
|
||||
} `json:"data"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement `ValidateKey`**
|
||||
Add the `ValidateKey` method.
|
||||
|
||||
```go
|
||||
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"meta": map[string]string{
|
||||
"key": key,
|
||||
},
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var valResp ValidateResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &valResp, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `ActivateMachine`**
|
||||
Add the `ActivateMachine` method.
|
||||
|
||||
```go
|
||||
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
|
||||
|
||||
var reqBody ActivateMachineRequest
|
||||
reqBody.Data.Type = "machines"
|
||||
reqBody.Data.Attributes.Fingerprint = fingerprint
|
||||
reqBody.Data.Relationships.License.Data.Type = "licenses"
|
||||
reqBody.Data.Relationships.License.Data.ID = licenseID
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusConflict { // 409 usually means fingerprint already exists
|
||||
return nil // Machine might already be registered
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add go-backend/internal/license/keygen.go
|
||||
git commit -m "feat: add keygen.sh api client"
|
||||
```
|
||||
|
||||
### Task 3: Integrate Keygen into License Activation Endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Update `licenseActivate` logic**
|
||||
Modify `licenseActivate` to use the Keygen client instead of the mock logic. Note: For this implementation, we will use an environment variable `KEYGEN_ACCOUNT_ID`. We can use `os.Getenv` directly for simplicity, or hardcode a fallback if not present.
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-backend/internal/license"
|
||||
"os"
|
||||
)
|
||||
|
||||
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
||||
// ... (keep request parsing)
|
||||
|
||||
key := strings.TrimSpace(req.LicenseKey)
|
||||
if key == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
|
||||
if accountID == "" {
|
||||
// Fallback for mock/development if no keygen account configured
|
||||
if strings.HasPrefix(key, "FLVX-") {
|
||||
now := time.Now().UnixMilli()
|
||||
h.repo.UpsertConfig("license_key", key, now)
|
||||
h.repo.UpsertConfig("is_commercial", "true", now)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault("系统未配置 Keygen 账号 ID"))
|
||||
return
|
||||
}
|
||||
|
||||
fingerprint, err := h.getOrCreateMachineFingerprint()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
|
||||
return
|
||||
}
|
||||
|
||||
client := license.NewKeygenClient(accountID, "") // Token may be optional for validate-key depending on policy, or can be passed if needed
|
||||
|
||||
valResp, err := client.ValidateKey(key)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if !valResp.Meta.Valid {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
|
||||
return
|
||||
}
|
||||
|
||||
// Try to activate machine
|
||||
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go
|
||||
git commit -m "feat: integrate keygen into license activation endpoint"
|
||||
```
|
||||
|
||||
### Task 4: Add Periodic License Validation Job
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/jobs.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Add `validateLicenseJob` function in `jobs.go`**
|
||||
Create a new function that performs the background validation.
|
||||
|
||||
```go
|
||||
import "os"
|
||||
|
||||
func (h *Handler) validateLicenseJob() {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
|
||||
if accountID == "" {
|
||||
return // Skip if not configured
|
||||
}
|
||||
|
||||
key, _ := h.repo.GetViteConfigValue("license_key")
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
|
||||
if key == "" || isCommercial != "true" {
|
||||
return // Nothing to validate
|
||||
}
|
||||
|
||||
client := license.NewKeygenClient(accountID, "")
|
||||
valResp, err := client.ValidateKey(key)
|
||||
|
||||
if err != nil {
|
||||
// Network error or timeout. We implement a grace period by NOT revoking immediately here.
|
||||
// In a production system, you might count consecutive failures.
|
||||
// For now, we skip revocation on network errors.
|
||||
return
|
||||
}
|
||||
|
||||
if !valResp.Meta.Valid {
|
||||
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
|
||||
now := time.Now().UnixMilli()
|
||||
_ = h.repo.UpsertConfig("is_commercial", "false", now)
|
||||
// We could optionally clear brand configs here, or just let them be disabled in UI
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the job in `RunJobs`**
|
||||
In `handler.go` or `jobs.go`, wherever the periodic cron jobs are registered (usually `go h.runJobs()`), ensure `validateLicenseJob` is called periodically (e.g., every 12 hours). Look for `h.startCronJobs()` or similar in `handler.go`.
|
||||
|
||||
If a central `RunJobs` loop exists in `jobs.go` (like a `for` loop with a `time.Ticker`), add it there. If not, create a simple goroutine in `Register` or `NewHandler`.
|
||||
|
||||
*Assuming there's a `startJobs` or `Init` block in `handler.go`:*
|
||||
```go
|
||||
// Inside handler initialization or Register:
|
||||
go func() {
|
||||
ticker := time.NewTicker(12 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
h.validateLicenseJob()
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/jobs.go go-backend/internal/http/handler/handler.go
|
||||
git commit -m "feat: add periodic license validation job"
|
||||
```
|
||||
@@ -0,0 +1,262 @@
|
||||
# Announcement Popup Notification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a popup modal for announcements that automatically shows to users when a new or updated announcement is published.
|
||||
|
||||
**Architecture:** We will modify the Go backend to return `update_time` along with the announcement data. In the Vite frontend, we will store the user's `flvx_announcement_seen_time` in `localStorage`. If the fetched `update_time` is greater than the stored timestamp, we trigger a NextUI Modal displaying the announcement content.
|
||||
|
||||
**Tech Stack:** Go, Vite, React, TailwindCSS, NextUI.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update API Response in Go Backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Modify the `getAnnouncement` function in `go-backend/internal/http/handler/handler.go`.
|
||||
Find the response map inside `getAnnouncement` and add the `update_time` key:
|
||||
|
||||
```go
|
||||
if ann == nil {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
"update_time": 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
updateTime := ann.CreatedTime
|
||||
if ann.UpdatedTime.Valid {
|
||||
updateTime = ann.UpdatedTime.Int64
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
"update_time": updateTime,
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go
|
||||
git commit -m "feat(api): include update_time in announcement response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Frontend API Interface
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Modify the `AnnouncementData` interface in `vite-frontend/src/api/index.ts` to include `update_time`.
|
||||
|
||||
```typescript
|
||||
export interface AnnouncementData {
|
||||
content: string;
|
||||
enabled: number;
|
||||
update_time?: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/api/index.ts
|
||||
git commit -m "feat(ui): add update_time to AnnouncementData interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create AnnouncementModal Component
|
||||
|
||||
**Files:**
|
||||
- Create: `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Create `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx` with the following content:
|
||||
|
||||
```tsx
|
||||
import type { AnnouncementData } from "@/api";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface AnnouncementModalProps {
|
||||
announcement: AnnouncementData;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}
|
||||
|
||||
export const AnnouncementModal = ({
|
||||
announcement,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDontShowAgain,
|
||||
}: AnnouncementModalProps) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{announcement.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="flat" onPress={onDontShowAgain}>
|
||||
不再提示
|
||||
</Button>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/dashboard/components/announcement-modal.tsx
|
||||
git commit -m "feat(ui): create AnnouncementModal component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Integrate Modal State in Dashboard Custom Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
|
||||
|
||||
- [ ] **Step 1: Update the hook return type interface**
|
||||
|
||||
At the top of `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` where `DashboardData` is or similar, add the new properties (if it uses an explicit return type). If it's inferred, skip this. Wait, let's check the code:
|
||||
|
||||
```typescript
|
||||
isAnnouncementModalOpen: boolean;
|
||||
setIsAnnouncementModalOpen: (isOpen: boolean) => void;
|
||||
dismissAnnouncementModal: () => void;
|
||||
```
|
||||
Ensure they are added to the returned object at the bottom of the `useDashboardData` hook.
|
||||
|
||||
Find the `const loadAnnouncement` function.
|
||||
|
||||
- [ ] **Step 2: Write the minimal implementation**
|
||||
|
||||
First, add state at the top of the hook:
|
||||
```typescript
|
||||
const [isAnnouncementModalOpen, setIsAnnouncementModalOpen] = useState(false);
|
||||
```
|
||||
|
||||
Then, modify the `loadAnnouncement` logic inside `useDashboardData`:
|
||||
```typescript
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
|
||||
try {
|
||||
const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
|
||||
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
|
||||
const updateTime = res.data.update_time || 0;
|
||||
|
||||
if (updateTime > storedTime) {
|
||||
setIsAnnouncementModalOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to read localStorage for announcement state", err);
|
||||
setIsAnnouncementModalOpen(true);
|
||||
}
|
||||
} else {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
```
|
||||
|
||||
Add the dismiss handler inside the hook:
|
||||
```typescript
|
||||
const dismissAnnouncementModal = useCallback(() => {
|
||||
setIsAnnouncementModalOpen(false);
|
||||
if (announcement && announcement.update_time) {
|
||||
try {
|
||||
localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
|
||||
} catch (err) {
|
||||
console.warn("Failed to set localStorage for announcement state", err);
|
||||
}
|
||||
}
|
||||
}, [announcement]);
|
||||
```
|
||||
|
||||
Ensure these are included in the return object of the hook:
|
||||
```typescript
|
||||
isAnnouncementModalOpen,
|
||||
setIsAnnouncementModalOpen,
|
||||
dismissAnnouncementModal,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/dashboard/use-dashboard-data.ts
|
||||
git commit -m "feat(ui): manage announcement modal state in dashboard hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Modal to Dashboard Layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/dashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Import the modal component at the top:
|
||||
```tsx
|
||||
import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
|
||||
```
|
||||
|
||||
Add the new properties to the destructured `useDashboardData` object:
|
||||
```tsx
|
||||
isAnnouncementModalOpen,
|
||||
setIsAnnouncementModalOpen,
|
||||
dismissAnnouncementModal,
|
||||
```
|
||||
|
||||
Add the modal instance near the end of the dashboard rendering (just below `{announcement && <AnnouncementBanner ... />}` or inside the main `<div>`):
|
||||
```tsx
|
||||
{announcement && (
|
||||
<AnnouncementModal
|
||||
announcement={announcement}
|
||||
isOpen={isAnnouncementModalOpen}
|
||||
onClose={() => setIsAnnouncementModalOpen(false)}
|
||||
onDontShowAgain={dismissAnnouncementModal}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/dashboard.tsx
|
||||
git commit -m "feat(ui): add announcement modal to dashboard layout"
|
||||
```
|
||||
@@ -0,0 +1,140 @@
|
||||
# Flvx iOS 26 Liquid Glass UI Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Transform the Flvx frontend interface entirely into an "Apple iOS 26 Liquid Glass" visual style by utilizing high-radius squircles, heavy background blurs, mesh gradients, and highly semantic translucent containers.
|
||||
|
||||
**Architecture:** We will approach this from the ground up: first defining the global TailwindCSS design tokens and the base mesh-gradient layout, then systematically replacing the structural styling inside each React page component (`vite-frontend/src/pages/*.tsx`).
|
||||
|
||||
**Tech Stack:** React DOM, TailwindCSS (v4), shadcn-bridge (HeroUI), Vite
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Setup Global CSS Variables and App Shell
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/styles/globals.css` (or `index.css`)
|
||||
- Modify: `vite-frontend/tailwind.config.js`
|
||||
- Modify: `vite-frontend/src/App.tsx` (or `main.tsx` / `layouts` depending on structural entry point)
|
||||
|
||||
- [ ] **Step 1: Inject Liquid Glass theme variables**
|
||||
Open the main CSS file and add variables for the new blur radius, box-shadows, and background gradients.
|
||||
```css
|
||||
:root {
|
||||
--glass-bg: rgba(255, 255, 255, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.8);
|
||||
--glass-card: rgba(255, 255, 255, 0.7);
|
||||
--glass-overlay: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dark {
|
||||
--glass-bg: rgba(30, 30, 30, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.15);
|
||||
--glass-card: rgba(40, 40, 40, 0.6);
|
||||
}
|
||||
.bg-mesh-gradient {
|
||||
background: radial-gradient(at 0% 0%, #ff9a9e 0%, transparent 50%),
|
||||
radial-gradient(at 100% 0%, #fecfef 0%, transparent 50%),
|
||||
radial-gradient(at 100% 100%, #c2e9fb 0%, transparent 50%),
|
||||
radial-gradient(at 0% 100%, #a1c4fd 0%, transparent 50%);
|
||||
background-color: #f2f2f7;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update App Layout**
|
||||
Modify the root app container to use `.bg-mesh-gradient` and ensure the main container occupies `min-h-screen`.
|
||||
|
||||
### Task 2: Refactor Global Components (Card & Modal)
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/shadcn-bridge/heroui/card.tsx`
|
||||
- Modify: `vite-frontend/src/shadcn-bridge/heroui/modal.tsx`
|
||||
|
||||
- [ ] **Step 1: Liquid Card Base**
|
||||
Update the default className string for `Card` to incorporate: `backdrop-blur-3xl bg-white/60 dark:bg-zinc-900/60 border border-white/80 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]`.
|
||||
|
||||
- [ ] **Step 2: Modal Overlay Base**
|
||||
Update the default overlay className for `Modal` to use `bg-black/30 backdrop-blur-sm`, and its content panel to use the same `glass_card` classes as the Card component but with `rounded-3xl`.
|
||||
|
||||
### Task 3: Redesign Dashboard Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/dashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace hardcoded borders/bg with glass semantics**
|
||||
Find hardcoded `bg-white`, `border-gray-200`, `shadow-md` inside `DashboardPage` and `MetricCard`, replace with `bg-white/60 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] border-white/80 rounded-2xl`.
|
||||
- [ ] **Step 2: Adjust spacing**
|
||||
Ensure all metric cards have uniform `h-48` equivalent height and are strictly `rounded-2xl` with `p-6` padding.
|
||||
- [ ] **Step 3: Update Flow Chart Card**
|
||||
Replace standard grid backgrounds in the flow chart with transparent spacing and vibrant `bg-blue-500` squircle bars without harsh borders.
|
||||
|
||||
### Task 4: Redesign Node Management Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/node.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace standard List/Table view with Grid Cards**
|
||||
Update the node rendering map to output `glass_card` containers (`rounded-2xl`, blur, padding `p-6`).
|
||||
- [ ] **Step 2: Apply semantic status highlights**
|
||||
Refactor the Online/Offline badges into pill-shapes (`rounded-full`) using the defined semantic colors (e.g. `bg-green-500/20 text-green-600` with a 6px inner dot `bg-green-500`).
|
||||
- [ ] **Step 3: Embed Micro-charts**
|
||||
For CPU/RAM data inside the node card, switch standard progress bars to ultra-thin (height 4px) continuous lines utilizing standard brand colors.
|
||||
|
||||
### Task 5: Redesign Tunnels & Rules Configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/tunnel.tsx`
|
||||
- Modify: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
- [ ] **Step 1: Update Tunnel lists into nested Glass Panels**
|
||||
Encapsulate each tunnel configuration into a wide `glass_card`.
|
||||
- [ ] **Step 2: Create Visual Rule Tags**
|
||||
For the Forwarding rules, wrap the target IP/Port logic into visual badges: `bg-green-500/20` for Entry and `bg-blue-500/20` for Target.
|
||||
- [ ] **Step 3: Refactor the "Add Rule" Floating action**
|
||||
Ensure the plus button follows the squircle format (`rounded-full`) with a prominent diffused shadow (`shadow-[0_4px_12px_rgba(0,122,255,0.3)]`).
|
||||
|
||||
### Task 6: Redesign Monitor Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/monitor.tsx`
|
||||
|
||||
- [ ] **Step 1: Style the Top Hero Metrics**
|
||||
Replace flat stat boxes with high-contrast, large typography inside `glass_card` backgrounds.
|
||||
- [ ] **Step 2: Refactor Latency Indicators**
|
||||
Format the connection list rows as `bg-white/50 dark:bg-black/30` strips with pill-shaped status tags (`Healthy`, `Warning`, `Offline`) mapping exactly to the green/orange/red semantics from the design spec.
|
||||
|
||||
### Task 7: Redesign Group & Sharing Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/group.tsx`
|
||||
- Modify: `vite-frontend/src/pages/panel-sharing.tsx`
|
||||
|
||||
- [ ] **Step 1: Update Tab Switchers**
|
||||
Refactor the internal navigation tabs (e.g., "Tunnel Groups" vs "User Groups") into an encapsulated `p-1 rounded-xl bg-white/40 backdrop-blur-lg` container with animated active states (`shadow-sm bg-white`).
|
||||
- [ ] **Step 2: Style Share Cards**
|
||||
Transform flat panel sharing list items into rich `glass_card` entities. Highlight expiration dates with the accent text color.
|
||||
|
||||
### Task 8: Redesign Settings, Config, and User Management
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
- Modify: `vite-frontend/src/pages/settings.tsx`
|
||||
- Modify: `vite-frontend/src/pages/user.tsx`
|
||||
- Modify: `vite-frontend/src/pages/limit.tsx`
|
||||
|
||||
- [ ] **Step 1: Flatten Forms**
|
||||
Convert traditional input groups into `rounded-xl bg-white/50 border border-white/60` containers. Remove outer boxing for standard `label + input` pairs.
|
||||
- [ ] **Step 2: iOS Toggle Switches**
|
||||
Ensure that any `<Switch>` or `<Checkbox>` components use the new Accent brand color (`#007aff`) with full `rounded-full` geometry.
|
||||
- [ ] **Step 3: Refactor User Badges**
|
||||
In `user.tsx`, replace text-based role columns with circular Avatar badges (e.g., `w-10 h-10 rounded-full bg-blue-500 text-white` with the first two letters of the username).
|
||||
|
||||
### Task 9: Profile & Password Modal Restyling
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/profile.tsx`
|
||||
- Modify: `vite-frontend/src/pages/change-password.tsx`
|
||||
|
||||
- [ ] **Step 1: Apply Profile Card Structure**
|
||||
Create a split view on desktop using flex: Left side (Avatar + User Info + Admin Shortcuts), Right side (Password Form).
|
||||
- [ ] **Step 2: Restyle Auth Inputs**
|
||||
Ensure all password inputs use `bg-white/50 backdrop-blur-md border border-white/60` and the update button has heavy shadow-glow.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Floating Save Button Design
|
||||
|
||||
**Date:** 2026-04-01
|
||||
**Issue:** https://github.com/Sagit-chu/flvx/issues/266
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add a Floating Action Button (FAB) to the config page (`vite-frontend/src/pages/config.tsx`) that appears when configuration changes are detected, allowing users to save without scrolling to the top.
|
||||
|
||||
## Requirements
|
||||
|
||||
From Issue #266:
|
||||
|
||||
1. **Default hidden**: FAB not visible when no config changes
|
||||
2. **Show on change**: Auto-display when `hasChanges` becomes true
|
||||
3. **Fixed position**: Suspended at bottom-right corner, does not scroll with page
|
||||
4. **Mobile compatible**: Same behavior on desktop and mobile devices
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Implementation Approach
|
||||
|
||||
**Inline FAB in config.tsx** (not a reusable component)
|
||||
|
||||
- Rationale: Current need is limited to config page only
|
||||
- State management (`hasChanges`, `saving`) already exists in the page
|
||||
- framer-motion patterns already established in project
|
||||
- Avoids over-abstraction (YAGNI)
|
||||
|
||||
### 2. UI Structure
|
||||
|
||||
Position: `fixed bottom-6 right-6` (24px from viewport edges)
|
||||
|
||||
Visual layout:
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ [页面内容,可滚动] │
|
||||
│ │
|
||||
│ [●] │ ← FAB (fixed position)
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. Button Appearance
|
||||
|
||||
- Shape: Circular (`w-12 h-12 rounded-full`)
|
||||
- Color: Primary (matches existing save button)
|
||||
- Icon: SaveIcon (already defined in config.tsx)
|
||||
- Shadow: `shadow-lg` for visual hierarchy
|
||||
- Style: Icon-only (no text label)
|
||||
|
||||
### 4. Animation
|
||||
|
||||
Using framer-motion with `AnimatePresence`:
|
||||
|
||||
| Phase | Properties |
|
||||
|-------|------------|
|
||||
| `initial` | `{ y: 100, opacity: 0 }` - starts below viewport |
|
||||
| `animate` | `{ y: 0, opacity: 1 }` - slides up to position |
|
||||
| `exit` | `{ y: 100, opacity: 0 }` - slides back down on hide |
|
||||
|
||||
Transition config:
|
||||
```typescript
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
```
|
||||
|
||||
Spring parameters produce Material Design-like feel: smooth entrance, slight bounce settle.
|
||||
|
||||
### 5. Interaction Details
|
||||
|
||||
- **Click**: Calls existing `handleSave()` function
|
||||
- **Loading state**: Button shows Spinner when `saving === true`
|
||||
- **Hover**: Inherits Button component's primary color hover behavior
|
||||
- **z-index**: `z-50` (above page content, below modals)
|
||||
- **Prevent duplicate click**: Button disabled when `saving === true`
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Code Location
|
||||
|
||||
File: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
### Required Imports
|
||||
|
||||
```typescript
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
```
|
||||
|
||||
### FAB Component Structure
|
||||
|
||||
```tsx
|
||||
<AnimatePresence>
|
||||
{hasChanges && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="fixed bottom-6 right-6 z-50"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
size="lg"
|
||||
className="w-12 h-12 rounded-full shadow-lg"
|
||||
isLoading={saving}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{!saving && <SaveIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
### Placement
|
||||
|
||||
Insert FAB at the end of the component, before the closing `</div>` (after all Cards and Modals).
|
||||
|
||||
### Dependencies
|
||||
|
||||
- framer-motion: Already installed (v11.18.2)
|
||||
- Button: Already imported from `@/shadcn-bridge/heroui/button`
|
||||
- SaveIcon: Already defined in config.tsx
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
| State | FAB Visibility | Button Enabled |
|
||||
|-------|----------------|----------------|
|
||||
| `hasChanges = false` | Hidden (not rendered) | N/A |
|
||||
| `hasChanges = true, saving = false` | Visible, animating in | Yes |
|
||||
| `hasChanges = true, saving = true` | Visible | No (loading) |
|
||||
| Save success | Hidden (animating out) | N/A |
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
No special handling needed. `fixed bottom-6 right-6` works identically on:
|
||||
- Desktop browsers
|
||||
- Mobile browsers
|
||||
- H5/WebView mode
|
||||
|
||||
The FAB maintains consistent 24px margin from viewport edges regardless of screen size.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Multiple rapid toggles**: AnimatePresence handles gracefully - exit animation completes before new enter animation
|
||||
2. **Page unload with unsaved changes**: Not addressed in this design (separate concern)
|
||||
3. **FAB covers existing warning banner**: z-50 places FAB above the warning banner at line 1004-1013
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After implementation, verify:
|
||||
|
||||
- [ ] FAB appears when any config field is modified
|
||||
- [ ] FAB slides up from bottom on appearance
|
||||
- [ ] FAB slides down to bottom on disappearance
|
||||
- [ ] FAB fixed position during page scroll
|
||||
- [ ] FAB triggers save on click
|
||||
- [ ] FAB shows spinner during save
|
||||
- [ ] FAB disappears after successful save
|
||||
- [ ] FAB works on mobile viewport
|
||||
- [ ] FAB does not interfere with Modal dialogs
|
||||
@@ -0,0 +1,274 @@
|
||||
# GitHub 加速地址自定义配置设计
|
||||
|
||||
**日期**: 2026-04-01
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速功能。配置后,面板更新节点、生成安装命令以及安装脚本都使用配置的加速地址。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `gcode.hostcentral.cc` 硬编码在多个位置:
|
||||
- `go-backend/internal/http/handler/upgrade.go` - 节点升级下载 URL
|
||||
- `go-backend/internal/http/handler/mutations.go` - 节点安装命令生成
|
||||
- `install.sh` - 节点安装脚本
|
||||
- `panel_install.sh` - 面板安装脚本
|
||||
|
||||
用户无法自定义加速地址或关闭加速功能。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 面板设置中支持配置加速开关和加速地址
|
||||
2. 配置影响全部下载场景(面板端 + 安装脚本)
|
||||
3. 安装脚本支持交互式询问加速配置
|
||||
4. 面板生成的安装命令自动嵌入加速配置
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 后端
|
||||
- `go-backend/internal/http/handler/upgrade.go`
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
|
||||
### 前端
|
||||
- `vite-frontend/src/pages/config.tsx`
|
||||
- `vite-frontend/src/config/site.ts`(缓存配置键)
|
||||
|
||||
### 安装脚本
|
||||
- `install.sh`
|
||||
- `panel_install.sh`
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 1. 数据存储
|
||||
|
||||
使用现有 `vite_config` 表存储两个配置项:
|
||||
|
||||
| name | value | 说明 |
|
||||
|------|-------|------|
|
||||
| `github_proxy_enabled` | `"true"` / `"false"` | 是否开启加速,默认 `"true"` |
|
||||
| `github_proxy_url` | URL 字符串 | 加速地址,默认 `"https://gcode.hostcentral.cc"` |
|
||||
|
||||
### 2. 后端 Handler 修改
|
||||
|
||||
#### upgrade.go
|
||||
|
||||
移除硬编码常量,新增辅助函数:
|
||||
|
||||
```go
|
||||
// getGithubProxyConfig 获取 GitHub 加速配置
|
||||
// 返回: (是否开启, 加速地址)
|
||||
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
|
||||
enabled = true // 默认开启
|
||||
proxyURL = "https://gcode.hostcentral.cc" // 默认地址
|
||||
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 读取开启状态
|
||||
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
|
||||
enabled = enabledCfg.Value != "false"
|
||||
}
|
||||
|
||||
// 读取加速地址
|
||||
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
|
||||
proxyURL = strings.TrimSpace(urlCfg.Value)
|
||||
// 确保 URL 格式正确
|
||||
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
|
||||
proxyURL = "https://" + proxyURL
|
||||
}
|
||||
proxyURL = strings.TrimSuffix(proxyURL, "/")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// buildDownloadURL 构建下载地址
|
||||
func (h *Handler) buildDownloadURL(version, arch string) string {
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
base := fmt.Sprintf("https://github.com/%s/releases/download/%s/gost-%s", githubRepo, version, arch)
|
||||
|
||||
if enabled {
|
||||
return fmt.Sprintf("%s/%s", proxyURL, base)
|
||||
}
|
||||
return base
|
||||
}
|
||||
```
|
||||
|
||||
修改 `nodeUpgrade` 和 `nodeBatchUpgrade` 使用动态配置。
|
||||
|
||||
#### mutations.go
|
||||
|
||||
修改 `getNodeInstallCmd` 函数(约第 440-456 行):
|
||||
|
||||
```go
|
||||
func (h *Handler) getNodeInstallCmd(w http.ResponseWriter, r *http.Request) {
|
||||
// ... 现有逻辑 ...
|
||||
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
|
||||
var cmd string
|
||||
if enabled {
|
||||
cmd = fmt.Sprintf(
|
||||
"curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
|
||||
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret,
|
||||
)
|
||||
} else {
|
||||
cmd = fmt.Sprintf(
|
||||
"curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
|
||||
githubRepo, version, version, processServerAddress(panelAddr), secret,
|
||||
)
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(cmd))
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 前端修改
|
||||
|
||||
#### config.tsx
|
||||
|
||||
在 `CONFIG_ITEMS` 数组中添加配置项(约第 87-158 行之后):
|
||||
|
||||
```typescript
|
||||
{
|
||||
key: "github_proxy_enabled",
|
||||
label: "开启 GitHub 加速",
|
||||
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
|
||||
type: "switch",
|
||||
},
|
||||
{
|
||||
key: "github_proxy_url",
|
||||
label: "加速地址",
|
||||
placeholder: "https://gcode.hostcentral.cc",
|
||||
description: "GitHub 下载加速代理地址,开启加速后生效",
|
||||
type: "input",
|
||||
dependsOn: "github_proxy_enabled",
|
||||
dependsValue: "true",
|
||||
},
|
||||
```
|
||||
|
||||
在 `getInitialConfigs` 函数的 `configKeys` 数组中添加缓存键:
|
||||
|
||||
```typescript
|
||||
"github_proxy_enabled",
|
||||
"github_proxy_url",
|
||||
```
|
||||
|
||||
### 4. 安装脚本修改
|
||||
|
||||
#### install.sh
|
||||
|
||||
在脚本开头添加配置变量和环境变量读取:
|
||||
|
||||
```bash
|
||||
# 镜像加速配置(可由面板传入)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
```
|
||||
|
||||
修改 `maybe_proxy_url` 函数:
|
||||
|
||||
```bash
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
|
||||
# 如果明确关闭加速
|
||||
if [[ "$PROXY_ENABLED" == "false" ]]; then
|
||||
echo "$url"
|
||||
return
|
||||
fi
|
||||
|
||||
# 默认开启加速
|
||||
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
|
||||
|
||||
# 处理 URL 格式
|
||||
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
|
||||
proxy="${proxy%/}" # 移除末尾斜杠
|
||||
else
|
||||
proxy="https://${proxy}"
|
||||
fi
|
||||
|
||||
echo "${proxy}/${url}"
|
||||
}
|
||||
```
|
||||
|
||||
在 `install_flux_agent` 函数开头添加交互式询问:
|
||||
|
||||
```bash
|
||||
install_flux_agent() {
|
||||
echo "🚀 开始安装 flux_agent..."
|
||||
|
||||
# 询问加速配置(如果未由面板传入)
|
||||
if [[ -z "$PROXY_ENABLED" ]]; then
|
||||
echo ""
|
||||
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
|
||||
case "$proxy_choice" in
|
||||
n|N) PROXY_ENABLED="false" ;;
|
||||
*)
|
||||
PROXY_ENABLED="true"
|
||||
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
|
||||
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ... 现有安装逻辑 ...
|
||||
}
|
||||
```
|
||||
|
||||
#### panel_install.sh
|
||||
|
||||
类似修改,在 `install_panel` 函数开头添加询问逻辑。
|
||||
|
||||
### 5. 配置缓存
|
||||
|
||||
#### site.ts
|
||||
|
||||
在配置缓存键列表中添加新键(如果需要前端缓存加速配置)。
|
||||
|
||||
## 默认行为
|
||||
|
||||
- `github_proxy_enabled`: 默认 `"true"`(开启加速)
|
||||
- `github_proxy_url`: 默认 `"https://gcode.hostcentral.cc"`
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. **后端 API 测试**:
|
||||
- 未配置时使用默认值
|
||||
- 配置后正确读取并应用
|
||||
- 关闭加速后直连 GitHub
|
||||
|
||||
2. **前端 UI 测试**:
|
||||
- Switch 开关正确切换
|
||||
- 关闭加速时隐藏地址输入框
|
||||
- 保存配置后正确持久化
|
||||
|
||||
3. **安装脚本测试**:
|
||||
- 交互式询问正常工作
|
||||
- 环境变量传入时跳过询问
|
||||
- 加速关闭时直连 GitHub
|
||||
|
||||
4. **集成测试**:
|
||||
- 面板生成安装命令正确包含加速配置
|
||||
- 节点升级下载使用配置的加速地址
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|----------|
|
||||
| 用户输入无效加速地址 | 后端验证 URL 格式,前端添加格式提示 |
|
||||
| 旧版本安装脚本不兼容 | 保持 `maybe_proxy_url` 函数签名不变,仅修改内部逻辑 |
|
||||
| 配置缺失时行为不一致 | 在 `getGithubProxyConfig` 中提供合理的默认值 |
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [ ] 后端:upgrade.go 修改
|
||||
- [ ] 后端:mutations.go 修改
|
||||
- [ ] 前端:config.tsx 添加配置项
|
||||
- [ ] 脚本:install.sh 修改
|
||||
- [ ] 脚本:panel_install.sh 修改
|
||||
- [ ] 测试:验证功能正常
|
||||
@@ -0,0 +1,49 @@
|
||||
# FLVX 商业版白标授权功能设计方案
|
||||
|
||||
## 1. 目标
|
||||
通过在设置面板中引入商业版激活码(License Key),允许已授权的用户去除前端页面的 FLVX 品牌标识,并使用自己的 App Name、Logo、Favicon 和隐藏版权信息,从而实现“白标”定制。
|
||||
|
||||
## 2. 功能范围
|
||||
* **授权校验(服务端)**:提供一个激活码输入与验证的接口。初始版本采用**在线 Mock 验证**,后续可通过替换验证服务器地址实现真实的在线发卡与吊销逻辑。
|
||||
* **配置存储(服务端)**:一旦授权成功,在数据库(如 `vite_config` 或现有的配置表)中记录授权状态(例如 `license_key`、`is_commercial` 等),并放开商业白标相关字段的写入权限(`app_name`, `app_logo`, `app_favicon`, `hide_footer_brand`)。
|
||||
* **权限拦截(服务端)**:拦截未授权用户的请求,禁止他们更新相关的品牌字段。
|
||||
* **前端 UI(客户端)**:
|
||||
* 在配置页面(或单独的“授权/个性化” Tab)提供激活码输入框。
|
||||
* 如果未激活:界面仅展示默认品牌配置,并提示“需要商业授权以解锁自定义品牌”。
|
||||
* 如果已激活:展示站名、Logo、Favicon 的上传和替换表单,提供隐藏“Powered by FLVX”脚标的开关。
|
||||
|
||||
## 3. 架构设计
|
||||
|
||||
### 3.1 数据库/配置结构
|
||||
扩展配置系统中的以下字段:
|
||||
* `license_key` (String):存储用户激活的商业版密钥。
|
||||
* `is_commercial` (String/Boolean):标识是否为合法的商业授权状态("true" 或 "false")。
|
||||
* `hide_footer_brand` (String/Boolean):是否隐藏底部的 FLVX 信息。
|
||||
|
||||
注意:现有的 `app_name`, `app_logo`, `app_favicon` 字段将收紧修改权限。
|
||||
|
||||
### 3.2 服务端 API 变更
|
||||
* **新增 API `POST /api/license/activate` (或将逻辑集成到现有配置修改接口)**:
|
||||
* 接收 `{ "license_key": "FLVX-xxxx" }`。
|
||||
* **Mock 逻辑**:如果是 `FLVX-` 开头则视为合法。
|
||||
* 合法则更新系统配置,设置 `license_key` 并将状态标为 `is_commercial: "true"`。
|
||||
* **修改 API 权限校验(如保存系统设置的接口)**:
|
||||
* 当接收到更新 `app_name`、`app_logo`、`app_favicon`、`hide_footer_brand` 的请求时,检查当前系统中的 `is_commercial` 状态。
|
||||
* 如果未授权且尝试修改白标字段,返回错误(如 `403 Forbidden`)提示需要商业授权。
|
||||
|
||||
### 3.3 前端设计
|
||||
* **授权卡片**:在全局设置(Settings / Config)页加入「商业版授权」或「个性化」区块。
|
||||
* **表单按需显示**:使用配置中的 `is_commercial === "true"` 来控制相关表单组件的展示:
|
||||
* 如果未授权,白标字段(Logo、Favicon、App Name、Hide Footer)不可修改(呈 Disabled)或覆盖了一层“锁”图标。
|
||||
* 底部 Footer 组件读取 `hide_footer_brand === "true"` 决定是否渲染 `Powered by FLVX`。
|
||||
* **全局状态同步**:当用户激活或上传完 Logo 后,通过现有的 `syncLogo` / `syncFavicon` 等机制全局刷新外观。
|
||||
|
||||
## 4. 安全与降级
|
||||
* **本地缓存失效**:如果后台在线验证服务器(未来)判断该 key 被吊销,可以在后续获取 config 的接口中重置白标配置为空,强制回退到默认 FLVX 主题。
|
||||
* **接口防绕过**:所有跟商业字段相关的变更,必须经过后端 API 的鉴权,确保纯前端绕过是无效的。
|
||||
|
||||
## 5. 测试策略
|
||||
1. **输入非法激活码**,提示错误,白标设置项仍被锁定。
|
||||
2. **输入合法激活码 (`FLVX-...`)**,提示成功,白标设置项解锁。
|
||||
3. **成功后上传 Logo 和修改站名**,刷新页面,前端应正常应用新配置且没有 FLVX 标记。
|
||||
4. **接口测试**:在未授权状态下,尝试强行通过 API 更新 `app_logo`,接口应返回权限不足。
|
||||
@@ -0,0 +1,59 @@
|
||||
# FLVX 商业版 Keygen.sh 授权集成设计方案
|
||||
|
||||
## 1. 目标
|
||||
使用 [Keygen.sh](https://keygen.sh/) 替换当前 FLVX 中基于 Mock 的商业版授权验证逻辑。通过接入 Keygen.sh,实现安全、可控的许可证分发、设备绑定(防止一码多用)、定期验证以及远程吊销功能,为 FLVX 的商业化白标功能提供生产级支持。
|
||||
|
||||
## 2. Keygen.sh 核心概念映射
|
||||
* **Account (账户)**:您在 Keygen 注册的商户账号。
|
||||
* **Product (产品)**:在 Keygen 中创建一个名为 `FLVX Panel` 的产品。
|
||||
* **Policy (策略)**:定义授权规则。例如,创建一个 `White-Label Policy`,限制每个 License 只能绑定 **1 个 Machine**(即一个 FLVX 面板实例),并可配置有效期(如按年订阅或永久有效)。
|
||||
* **License (许可证)**:发给客户的激活码(Key),格式可自定义(如 `FLVX-XXXX-XXXX`)。
|
||||
* **Machine (机器/设备)**:运行 FLVX 的具体服务器或面板实例。为了防止一码多开,FLVX 激活时需要向 Keygen 注册一台 Machine。
|
||||
|
||||
## 3. 架构设计与集成流程
|
||||
|
||||
### 3.1 唯一设备标识 (Machine Fingerprint)
|
||||
为了在 Keygen 中标识不同的 FLVX 面板,FLVX 后端需要生成并持久化一个唯一的机器指纹(Fingerprint)。
|
||||
* **生成时机**:FLVX 首次启动或首次激活时,生成一个 UUID v4。
|
||||
* **存储**:保存在数据库 `vite_config` 表中,键名为 `machine_fingerprint`。
|
||||
|
||||
### 3.2 激活流程 (License Activation)
|
||||
当用户在前端输入激活码并点击“激活”时:
|
||||
1. **FLVX 后端验证 Key**:调用 Keygen API `POST /v1/accounts/{account}/licenses/actions/validate-key`,传入 `key`。
|
||||
2. **检查 License 状态**:如果返回 `valid: true`,说明 License 合法且未过期。
|
||||
3. **激活 Machine (设备绑定)**:
|
||||
* 调用 Keygen API `POST /v1/accounts/{account}/machines`。
|
||||
* 关联刚才验证的 `licenseId`,并传入 FLVX 的 `machine_fingerprint`。
|
||||
* *异常处理*:如果该 License 已绑定了其他 Machine(达到 Policy 上限),Keygen 会报错,FLVX 后端需返回“该授权码已在其他设备使用”。
|
||||
4. **持久化状态**:激活成功后,在本地数据库保存 `license_key`、`is_commercial: "true"`,以及从 Keygen 返回的额外信息(如过期时间 `license_expiry`)。
|
||||
|
||||
### 3.3 定期心跳与验证 (Periodic Validation)
|
||||
为了防止用户激活后断网或通过修改数据库绕过,以及实现**远程吊销**:
|
||||
* **定时任务**:FLVX 后端增加一个后台协程(如每天运行一次,或每 12 小时运行一次)。
|
||||
* **验证逻辑**:调用 Keygen API 验证当前的 `license_key` 和 `machine_fingerprint`。
|
||||
* **吊销/过期处理**:如果 Keygen 明确返回 License 已吊销(Suspended/Revoked/Banned)或已过期,或者当前 Machine 不再属于该 License,FLVX 后端需将 `is_commercial` 强制设为 `"false"`,并清空本地缓存,恢复官方品牌展示。
|
||||
* **宽限期 (Grace Period)**:考虑到用户服务器可能偶尔网络不通,如果请求 Keygen 超时或失败,不应立刻吊销。可设置一个宽限期(如连续 3 天请求失败才降级)。
|
||||
|
||||
## 4. 后端 API 改造计划 (`go-backend`)
|
||||
|
||||
### 4.1 新增环境变量/配置
|
||||
* `KEYGEN_ACCOUNT_ID`: 您的 Keygen 账户 ID(打包时可硬编码,或作为全局环境变量)。
|
||||
* (可选)`KEYGEN_PRODUCT_TOKEN` 或仅使用 License Key 进行验证(取决于 Keygen 验证方式的选择,推荐直接使用 License Key 进行无状态验证)。
|
||||
|
||||
### 4.2 改造 `/api/v1/license/activate`
|
||||
* 引入 HTTP 客户端向 `api.keygen.sh` 发起请求。
|
||||
* 实现上述提到的 Validate Key 和 Activate Machine 两步走逻辑。
|
||||
* 返回具体的错误信息给前端(例如:“授权码不存在”、“授权码已过期”、“激活设备数达上限”)。
|
||||
|
||||
## 5. 前端改造计划 (`vite-frontend`)
|
||||
前端在目前的 UI 基础上几乎不需要大改,只需配合后端的增强:
|
||||
1. **展示过期时间**:如果后端返回了 `license_expiry`,可以在“商业版授权”卡片中展示“授权有效期至:YYYY-MM-DD”。
|
||||
2. **错误提示优化**:透传后端返回的 Keygen 验证错误,给予用户明确的指引。
|
||||
3. **解绑/停用功能(可选)**:未来可增加“停用授权”按钮,调用后端接口在 Keygen 中删除 Machine 绑定,以便用户将 License 迁移到新的服务器。
|
||||
|
||||
## 6. 实施步骤建议
|
||||
1. 在 Keygen.sh 注册账号,创建 Product 和 Policy,生成测试用的 License Key。
|
||||
2. 在 FLVX 的 `go-backend` 中新建一个 `pkg/keygen` 或 `internal/license` 包,封装 Keygen API 的调用(Validate, Activate Machine)。
|
||||
3. 修改现有的 `licenseActivate` 接口,接入真正的验证逻辑。
|
||||
4. 添加定期验证的 Cron Job。
|
||||
5. 测试激活、吊销、过期、断网等各种场景。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Announcement Popup Notification Design
|
||||
|
||||
## Overview
|
||||
This feature implements a popup notification modal for important dashboard announcements to ensure users see them immediately, addressing GitHub Issue #169.
|
||||
|
||||
## Requirements
|
||||
1. Automatic display of a popup modal when opening the dashboard page if a new/updated announcement exists.
|
||||
2. Includes a "Don't show again" option to remember the user's choice to dismiss it.
|
||||
3. Smart triggering: Only pops up for *new* or *updated* announcements.
|
||||
4. Support Markdown formatting for the announcement content.
|
||||
5. Retain the existing permanent top banner as a fallback.
|
||||
|
||||
## Backend Changes (Go)
|
||||
The `/api/v1/announcement/get` API currently only returns `content` and `enabled`. It must be updated to return the timestamp of the last update to enable the frontend to detect changes.
|
||||
|
||||
1. **Repository (`internal/store/repo/repository.go`)**: Ensure `GetAnnouncement` retrieves `UpdatedTime` (or falls back to `CreatedTime`).
|
||||
2. **Handler (`internal/http/handler/handler.go`)**: Modify `getAnnouncement` to include an `update_time` (int64) field in its JSON response.
|
||||
|
||||
## Frontend Changes (Vite/React/Tailwind)
|
||||
1. **API Interface (`src/api/index.ts`)**:
|
||||
* Update `AnnouncementData` to include `update_time: number`.
|
||||
2. **Storage Mechanism**:
|
||||
* Use browser `localStorage` to persist the user's view state. Key: `flvx_announcement_seen_time`.
|
||||
3. **UI Component (`AnnouncementModal`)**:
|
||||
* Create a new modal component for the dashboard.
|
||||
* The modal content will render the markdown of the announcement.
|
||||
* It will feature two primary actions:
|
||||
* **"Close"**: Closes the modal temporarily for this session (does NOT update `localStorage`). It will pop up again on the next page load.
|
||||
* **"Don't show again"**: Closes the modal AND sets `localStorage.setItem('flvx_announcement_seen_time', announcement.update_time)`.
|
||||
4. **Integration (`src/pages/dashboard.tsx` & `use-dashboard-data.ts`)**:
|
||||
* Add state to manage the modal visibility (e.g., `isAnnouncementModalOpen`).
|
||||
* On data load, compare the fetched `update_time` with the stored `flvx_announcement_seen_time`. If the fetched time is greater (or if no stored time exists), set `isAnnouncementModalOpen(true)`.
|
||||
|
||||
## Error Handling and Edge Cases
|
||||
* If `localStorage` is unavailable or throws an error (e.g., Private Browsing mode restrictions), the modal may show repeatedly. The code should safely catch `localStorage` access errors.
|
||||
* If `update_time` is missing from an old database record, the backend should gracefully fall back to the creation time or a safe default (like 0) to ensure the logic doesn't break.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Flvx iOS 26 Liquid Glass UI Redesign Spec
|
||||
|
||||
## 1. Overview
|
||||
This document specifies the comprehensive UI/UX redesign of the Flvx frontend using an "Apple iOS 26 Liquid Glass" design language. The goal is to elevate the visual quality of the entire application, making it modern, spatially aware, and highly legible through extensive use of blur, translucency, squircle borders, and semantic contrast.
|
||||
|
||||
## 2. Scope
|
||||
The redesign covers 100% of the frontend routing pages and overlay components under `vite-frontend/src/pages/` and global UI modules:
|
||||
- Dashboard (`dashboard.tsx`)
|
||||
- Node Management (`node.tsx`)
|
||||
- Tunnel & Rule Configurations (`tunnel.tsx`, `forward.tsx`)
|
||||
- System Monitor (`monitor.tsx`)
|
||||
- User Management (`user.tsx`)
|
||||
- Speed Limit Management (`limit.tsx`)
|
||||
- Group Management (`group.tsx`)
|
||||
- Panel Sharing (`panel-sharing.tsx`)
|
||||
- Global Settings & Config (`config.tsx`, `settings.tsx`)
|
||||
- Profile & Change Password (`profile.tsx`, `change-password.tsx`)
|
||||
- All related Modals, Drawers, and floating UI (e.g., "Create Node", "Add Rule" forms).
|
||||
|
||||
## 3. Design System & Tokens
|
||||
The new UI replaces traditional solid-color borders and flat surfaces with the following spatial design tokens:
|
||||
|
||||
### 3.1. Corner Radii (Squircles)
|
||||
- **Outer Shell / Viewports**: 32px (`rounded-3xl` equivalent)
|
||||
- **Cards / Containers**: 24px (`rounded-2xl`)
|
||||
- **Buttons / Inputs**: 16px (`rounded-xl` or `rounded-full`)
|
||||
- **Badges / Tags**: 6px or fully rounded.
|
||||
|
||||
### 3.2. Backgrounds & Blurs
|
||||
- **Global Background**: A mesh gradient blending soft pinks and blues (`#ff9a9e`, `#fecfef`, `#a1c4fd`, `#c2e9fb`).
|
||||
- **Glass Base (Primary Containers)**: `backdrop-blur-3xl` with an ultra-thin white overlay (`rgba(255, 255, 255, 0.6)` or `#ffffff99`).
|
||||
- **Glass Inner Glow (Borders)**: 1px solid `rgba(255, 255, 255, 0.8)` (`#ffffffcc`).
|
||||
- **Modals Background Overlay**: 30% black overlay (`#0000004d`).
|
||||
|
||||
### 3.3. Semantic Colors
|
||||
- **Brand / Active / Primary / TCP**: Blue `#007aff`
|
||||
- **Healthy / Success / Online**: Green `#34c759`
|
||||
- **Warning / Wait / UDP**: Orange `#ff9500`
|
||||
- **Danger / Offline / Delete**: Red `#ff3b30`
|
||||
- **Secondary / Purple / Data**: Purple `#af52de`
|
||||
- **Text**: Primary (`#1d1d1f`), Secondary (`#86868b`).
|
||||
|
||||
### 3.4. Elevation & Shadow
|
||||
- Soft, highly diffused drop shadows rather than sharp lines: e.g., `box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1)`.
|
||||
|
||||
## 4. Implementation Strategy
|
||||
We will implement the redesign systematically across the React + TailwindCSS + shadcn/HeroUI stack:
|
||||
1. **CSS Variables / Tailwind Config**: Inject the new Liquid Glass design tokens (colors, extended radiuses, customized backdrop blurs, box shadows) into `tailwind.config.js` and `globals.css`.
|
||||
2. **Global App Shell**: Update the root layout (`index.tsx` or main `App` layout) to host the dynamic mesh gradient background and the new translucent sidebar.
|
||||
3. **Component Re-styling**:
|
||||
- Override HeroUI default card, input, and modal styles using custom `classNames`.
|
||||
- Update `MetricCard`, `PageEmptyState`, `PageLoadingState`, and other base components to support the `glass_card` spec.
|
||||
4. **Page-by-Page Integration**: Rewrite the JSX of each page to utilize the new layout structure, ensuring all existing interactive state and API logic is seamlessly preserved.
|
||||
5. **Modal System Update**: Apply the transparent `#0000004d` overlay and 480px width glassy card style to all global dialogs.
|
||||
|
||||
## 5. Success Criteria
|
||||
- [ ] No regression in business logic; all forms, interactions, and data rendering operate exactly as before.
|
||||
- [ ] The visual system consistently employs the `glass_bg`, `glass_card`, and corresponding squircle radiuses across 100% of the UI.
|
||||
- [ ] Modals and Overlays correctly blur the background mesh gradient.
|
||||
- [ ] All responsive layouts appropriately wrap the card components on smaller displays.
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO BACKEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
|
||||
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||
|
||||
+5
-5
@@ -5,16 +5,17 @@ go 1.24.0
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.7.3
|
||||
modernc.org/sqlite v1.37.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
@@ -28,9 +29,8 @@ require (
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.37.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
type nodeCommander interface {
|
||||
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
|
||||
}
|
||||
|
||||
const serviceMonitorReportInterval = 30 * time.Second // DB write interval per monitor
|
||||
|
||||
type Checker struct {
|
||||
repo *repo.Repository
|
||||
commander nodeCommander
|
||||
lastRun map[int64]int64
|
||||
inFlight map[int64]struct{}
|
||||
|
||||
// In-memory latest result per monitor (for real-time API reads)
|
||||
latestResults map[int64]*model.ServiceMonitorResult
|
||||
lastDBWrite map[int64]int64 // last DB write timestamp per monitorID
|
||||
|
||||
mu sync.RWMutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
checking int32 // atomic flag: 1 = runChecks running, 0 = idle
|
||||
}
|
||||
|
||||
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
|
||||
return &Checker{
|
||||
repo: repo,
|
||||
commander: commander,
|
||||
lastRun: make(map[int64]int64),
|
||||
inFlight: make(map[int64]struct{}),
|
||||
latestResults: make(map[int64]*model.ServiceMonitorResult),
|
||||
lastDBWrite: make(map[int64]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLatestCached returns the in-memory latest results (updated every 1s).
|
||||
// Returns nil if no results are cached.
|
||||
func (c *Checker) GetLatestCached() []*model.ServiceMonitorResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
results := make([]*model.ServiceMonitorResult, 0, len(c.latestResults))
|
||||
for _, r := range c.latestResults {
|
||||
results = append(results, r)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (c *Checker) Start(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
c.cancel = cancel
|
||||
c.mu.Unlock()
|
||||
|
||||
c.runChecks(ctx)
|
||||
|
||||
for {
|
||||
limits := c.loadServiceMonitorLimits()
|
||||
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
|
||||
if scanInterval <= 0 {
|
||||
scanInterval = 1 * time.Second
|
||||
}
|
||||
|
||||
timer := time.NewTimer(scanInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
c.runChecks(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Checker) Stop() {
|
||||
c.mu.Lock()
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.wg.Wait()
|
||||
}
|
||||
|
||||
func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult, error) {
|
||||
if c == nil {
|
||||
return nil, errors.New("checker not initialized")
|
||||
}
|
||||
if m == nil {
|
||||
return nil, errors.New("monitor is nil")
|
||||
}
|
||||
limits := c.loadServiceMonitorLimits()
|
||||
return c.executeCheck(m, time.Now().UnixMilli(), limits), nil
|
||||
}
|
||||
|
||||
func (c *Checker) runChecks(ctx context.Context) {
|
||||
// Skip if previous round is still running (interval < timeout guard)
|
||||
if !atomic.CompareAndSwapInt32(&c.checking, 0, 1) {
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&c.checking, 0)
|
||||
|
||||
if c == nil || c.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
limits := c.loadServiceMonitorLimits()
|
||||
monitors, err := c.repo.ListEnabledServiceMonitors()
|
||||
if err != nil {
|
||||
log.Printf("service monitor scheduler failed op=list_enabled err=%v", err)
|
||||
return
|
||||
}
|
||||
if len(monitors) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Use persisted result timestamps to avoid restart bursts.
|
||||
latest, err := c.repo.GetLatestServiceMonitorResults()
|
||||
if err != nil {
|
||||
log.Printf("service monitor scheduler failed op=get_latest_results err=%v", err)
|
||||
latest = nil
|
||||
}
|
||||
persistedLast := make(map[int64]int64, len(latest))
|
||||
for _, r := range latest {
|
||||
if r.MonitorID <= 0 || r.Timestamp <= 0 {
|
||||
continue
|
||||
}
|
||||
persistedLast[r.MonitorID] = r.Timestamp
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
due := make([]model.ServiceMonitor, 0, len(monitors))
|
||||
for _, m := range monitors {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
intervalSec := m.IntervalSec
|
||||
if intervalSec <= 0 {
|
||||
intervalSec = limits.DefaultIntervalSec
|
||||
}
|
||||
if intervalSec < limits.MinIntervalSec {
|
||||
intervalSec = limits.MinIntervalSec
|
||||
}
|
||||
intervalMs := int64(intervalSec) * 1000
|
||||
|
||||
c.mu.Lock()
|
||||
if _, ok := c.inFlight[m.ID]; ok {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
lastSeen := persistedLast[m.ID]
|
||||
if v := c.lastRun[m.ID]; v > lastSeen {
|
||||
lastSeen = v
|
||||
}
|
||||
if lastSeen > 0 && intervalMs > 0 && now-lastSeen < intervalMs {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
c.inFlight[m.ID] = struct{}{}
|
||||
// Use now as a best-effort guard against overlapping scans; the final
|
||||
// timestamp is updated again when the result is persisted.
|
||||
c.lastRun[m.ID] = now
|
||||
c.mu.Unlock()
|
||||
|
||||
due = append(due, m)
|
||||
}
|
||||
if len(due) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
workerLimit := limits.WorkerLimit
|
||||
if workerLimit <= 0 {
|
||||
workerLimit = 1
|
||||
}
|
||||
if workerLimit > len(due) {
|
||||
workerLimit = len(due)
|
||||
}
|
||||
|
||||
jobs := make(chan model.ServiceMonitor, len(due))
|
||||
for _, m := range due {
|
||||
jobs <- m
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
reportIntervalMs := int64(serviceMonitorReportInterval / time.Millisecond)
|
||||
|
||||
for i := 0; i < workerLimit; i++ {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m, ok := <-jobs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ts := time.Now().UnixMilli()
|
||||
result := c.executeCheck(&m, ts, limits)
|
||||
|
||||
// Always update in-memory cache for real-time reads
|
||||
c.mu.Lock()
|
||||
c.latestResults[m.ID] = result
|
||||
c.lastRun[m.ID] = result.Timestamp
|
||||
delete(c.inFlight, m.ID)
|
||||
|
||||
// Only write to DB every 30s per monitor
|
||||
lastWrite := c.lastDBWrite[m.ID]
|
||||
writeToDB := ts-lastWrite >= reportIntervalMs
|
||||
if writeToDB {
|
||||
c.lastDBWrite[m.ID] = ts
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if writeToDB {
|
||||
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
|
||||
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Checker) executeCheck(m *model.ServiceMonitor, timestamp int64, limits monitoring.ServiceMonitorLimits) *model.ServiceMonitorResult {
|
||||
result := &model.ServiceMonitorResult{
|
||||
MonitorID: m.ID,
|
||||
NodeID: m.NodeID,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
|
||||
timeoutSec := m.TimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = limits.DefaultTimeoutSec
|
||||
}
|
||||
if timeoutSec < limits.MinTimeoutSec {
|
||||
timeoutSec = limits.MinTimeoutSec
|
||||
}
|
||||
if timeoutSec > limits.MaxTimeoutSec {
|
||||
timeoutSec = limits.MaxTimeoutSec
|
||||
}
|
||||
|
||||
timeout := time.Duration(timeoutSec) * time.Second
|
||||
|
||||
// When nodeId is set, run checks on the specified node.
|
||||
if m.NodeID > 0 {
|
||||
c.checkOnNode(m, timeoutSec, timeout, result)
|
||||
return result
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(m.Type)) {
|
||||
case "tcp":
|
||||
c.checkTCP(m.Target, timeout, result)
|
||||
case "icmp":
|
||||
result.Success = 0
|
||||
result.ErrorMessage = "ICMP 监控必须指定执行节点"
|
||||
default:
|
||||
result.Success = 0
|
||||
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Checker) loadServiceMonitorLimits() monitoring.ServiceMonitorLimits {
|
||||
defaults := monitoring.DefaultServiceMonitorLimits()
|
||||
if c == nil || c.repo == nil {
|
||||
return defaults
|
||||
}
|
||||
cfg, err := c.repo.GetConfigsByNames([]string{
|
||||
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
|
||||
monitoring.ConfigServiceMonitorWorkerLimit,
|
||||
monitoring.ConfigServiceMonitorMinIntervalSec,
|
||||
monitoring.ConfigServiceMonitorDefaultIntervalSec,
|
||||
monitoring.ConfigServiceMonitorMinTimeoutSec,
|
||||
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
|
||||
monitoring.ConfigServiceMonitorMaxTimeoutSec,
|
||||
})
|
||||
if err != nil {
|
||||
return defaults
|
||||
}
|
||||
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
type serviceMonitorCheckRequest struct {
|
||||
MonitorID int64 `json:"monitorId"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
}
|
||||
|
||||
func (c *Checker) checkOnNode(m *model.ServiceMonitor, timeoutSec int, timeout time.Duration, result *model.ServiceMonitorResult) {
|
||||
if c == nil || m == nil || result == nil {
|
||||
return
|
||||
}
|
||||
if c.commander == nil {
|
||||
result.Success = 0
|
||||
result.ErrorMessage = "节点检查不可用"
|
||||
return
|
||||
}
|
||||
|
||||
checkType := strings.ToLower(strings.TrimSpace(m.Type))
|
||||
if checkType != "tcp" && checkType != "icmp" {
|
||||
result.Success = 0
|
||||
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(m.Target) == "" {
|
||||
result.Success = 0
|
||||
result.ErrorMessage = "检查目标为空"
|
||||
return
|
||||
}
|
||||
|
||||
req := serviceMonitorCheckRequest{
|
||||
MonitorID: m.ID,
|
||||
Type: checkType,
|
||||
Target: m.Target,
|
||||
TimeoutSec: timeoutSec,
|
||||
}
|
||||
|
||||
cmdTimeout := timeout
|
||||
if cmdTimeout < 2*time.Second {
|
||||
cmdTimeout = 2 * time.Second
|
||||
}
|
||||
cmdTimeout = cmdTimeout + 2*time.Second
|
||||
|
||||
cmdRes, err := c.commander.SendCommand(m.NodeID, "ServiceMonitorCheck", req, cmdTimeout)
|
||||
if err != nil {
|
||||
result.Success = 0
|
||||
result.ErrorMessage = err.Error()
|
||||
return
|
||||
}
|
||||
if cmdRes.Data == nil {
|
||||
result.Success = 0
|
||||
result.ErrorMessage = "节点返回为空"
|
||||
return
|
||||
}
|
||||
|
||||
if v, ok := cmdRes.Data["success"]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
if b {
|
||||
result.Success = 1
|
||||
} else {
|
||||
result.Success = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := cmdRes.Data["latencyMs"]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
result.LatencyMs = f
|
||||
}
|
||||
}
|
||||
if v, ok := cmdRes.Data["statusCode"]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
result.StatusCode = int(f)
|
||||
}
|
||||
}
|
||||
if v, ok := cmdRes.Data["errorMessage"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
result.ErrorMessage = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Checker) checkTCP(target string, timeout time.Duration, result *model.ServiceMonitorResult) {
|
||||
start := time.Now()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", target, timeout)
|
||||
latency := time.Since(start)
|
||||
|
||||
result.LatencyMs = float64(latency.Milliseconds())
|
||||
|
||||
if err != nil {
|
||||
result.Success = 0
|
||||
result.ErrorMessage = err.Error()
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
result.Success = 1
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
type fakeCommander struct {
|
||||
lastNodeID int64
|
||||
lastType string
|
||||
lastData interface{}
|
||||
res ws.CommandResult
|
||||
err error
|
||||
}
|
||||
|
||||
type delayedCommander struct {
|
||||
delayByMonitorID map[int64]time.Duration
|
||||
}
|
||||
|
||||
func (d *delayedCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
|
||||
_ = nodeID
|
||||
_ = cmdType
|
||||
if req, ok := data.(serviceMonitorCheckRequest); ok {
|
||||
if delay := d.delayByMonitorID[req.MonitorID]; delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
return ws.CommandResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"success": true,
|
||||
"latencyMs": float64(1),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
|
||||
f.lastNodeID = nodeID
|
||||
f.lastType = cmdType
|
||||
f.lastData = data
|
||||
return f.res, f.err
|
||||
}
|
||||
|
||||
func TestTCPHealthCheckViaMonitor(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
addr := listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("successful tcp check", func(t *testing.T) {
|
||||
checker := NewChecker(nil, nil)
|
||||
limits := checker.loadServiceMonitorLimits()
|
||||
now := time.Now().UnixMilli()
|
||||
monitor := &model.ServiceMonitor{
|
||||
Type: "tcp",
|
||||
Target: addr,
|
||||
TimeoutSec: 5,
|
||||
}
|
||||
result := checker.executeCheck(monitor, now, limits)
|
||||
if result.Success != 1 {
|
||||
t.Fatalf("expected success, got error: %s", result.ErrorMessage)
|
||||
}
|
||||
if result.LatencyMs < 0 {
|
||||
t.Fatalf("expected non-negative latency, got %f", result.LatencyMs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failed tcp check - connection refused", func(t *testing.T) {
|
||||
checker := NewChecker(nil, nil)
|
||||
limits := checker.loadServiceMonitorLimits()
|
||||
now := time.Now().UnixMilli()
|
||||
monitor := &model.ServiceMonitor{
|
||||
Type: "tcp",
|
||||
Target: "127.0.0.1:1",
|
||||
TimeoutSec: 1,
|
||||
}
|
||||
result := checker.executeCheck(monitor, now, limits)
|
||||
if result.Success == 1 {
|
||||
t.Fatalf("expected failure for connection refused")
|
||||
}
|
||||
if result.ErrorMessage == "" {
|
||||
t.Fatalf("expected error message")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckerRunChecks(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
tcpAddr := listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
monitors := []*model.ServiceMonitor{
|
||||
{
|
||||
Name: "TCP Monitor",
|
||||
Type: "tcp",
|
||||
Target: tcpAddr,
|
||||
IntervalSec: 60,
|
||||
TimeoutSec: 5,
|
||||
NodeID: 0,
|
||||
Enabled: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
},
|
||||
{
|
||||
Name: "TCP Monitor 2",
|
||||
Type: "tcp",
|
||||
Target: tcpAddr,
|
||||
IntervalSec: 60,
|
||||
TimeoutSec: 5,
|
||||
NodeID: 0,
|
||||
Enabled: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
},
|
||||
{
|
||||
Name: "Disabled Monitor",
|
||||
Type: "tcp",
|
||||
Target: "127.0.0.1:1",
|
||||
IntervalSec: 60,
|
||||
TimeoutSec: 5,
|
||||
NodeID: 0,
|
||||
Enabled: 0,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range monitors {
|
||||
if err := r.CreateServiceMonitor(m); err != nil {
|
||||
t.Fatalf("create monitor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
monitors[2].Enabled = 0
|
||||
if err := r.UpdateServiceMonitor(monitors[2]); err != nil {
|
||||
t.Fatalf("update disabled monitor: %v", err)
|
||||
}
|
||||
|
||||
enabledMonitors, err := r.ListEnabledServiceMonitors()
|
||||
if err != nil {
|
||||
t.Fatalf("list enabled monitors: %v", err)
|
||||
}
|
||||
if len(enabledMonitors) != 2 {
|
||||
t.Fatalf("expected 2 enabled monitors, got %d", len(enabledMonitors))
|
||||
}
|
||||
|
||||
checker := NewChecker(r, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
go checker.Start(ctx)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
results, err := r.GetServiceMonitorResults(monitors[0].ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("get tcp results: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatalf("expected at least one result for tcp monitor")
|
||||
}
|
||||
for _, res := range results {
|
||||
if res.Success != 1 {
|
||||
t.Fatalf("expected success for tcp monitor, got failure: %s", res.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
results2, err := r.GetServiceMonitorResults(monitors[1].ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("get tcp results 2: %v", err)
|
||||
}
|
||||
if len(results2) == 0 {
|
||||
t.Fatalf("expected at least one result for tcp monitor 2")
|
||||
}
|
||||
for _, res := range results2 {
|
||||
if res.Success != 1 {
|
||||
t.Fatalf("expected success for tcp monitor 2, got failure: %s", res.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
disabledResults, err := r.GetServiceMonitorResults(monitors[2].ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("get disabled results: %v", err)
|
||||
}
|
||||
if len(disabledResults) != 0 {
|
||||
t.Fatalf("expected no results for disabled monitor, got %d", len(disabledResults))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerUnsupportedType(t *testing.T) {
|
||||
checker := NewChecker(nil, nil)
|
||||
limits := checker.loadServiceMonitorLimits()
|
||||
now := time.Now().UnixMilli()
|
||||
monitor := &model.ServiceMonitor{
|
||||
Type: "http",
|
||||
Target: "https://example.com",
|
||||
TimeoutSec: 5,
|
||||
}
|
||||
result := checker.executeCheck(monitor, now, limits)
|
||||
if result.Success == 1 {
|
||||
t.Fatalf("expected failure for unsupported type")
|
||||
}
|
||||
if result.ErrorMessage == "" {
|
||||
t.Fatalf("expected error message for unsupported type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerDefaultTimeout(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
addr := listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
checker := NewChecker(nil, nil)
|
||||
limits := checker.loadServiceMonitorLimits()
|
||||
now := time.Now().UnixMilli()
|
||||
monitor := &model.ServiceMonitor{
|
||||
Type: "tcp",
|
||||
Target: addr,
|
||||
TimeoutSec: 0,
|
||||
}
|
||||
result := checker.executeCheck(monitor, now, limits)
|
||||
if result.Success != 1 {
|
||||
t.Fatalf("expected success with default timeout, got error: %s", result.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerStop(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
monitor := &model.ServiceMonitor{
|
||||
Name: "Test Monitor",
|
||||
Type: "tcp",
|
||||
Target: listener.Addr().String(),
|
||||
IntervalSec: 60,
|
||||
TimeoutSec: 5,
|
||||
NodeID: 0,
|
||||
Enabled: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := r.CreateServiceMonitor(monitor); err != nil {
|
||||
t.Fatalf("create monitor: %v", err)
|
||||
}
|
||||
|
||||
checker := NewChecker(r, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
go checker.Start(ctx)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
checker.Stop()
|
||||
|
||||
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("get results: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatalf("expected at least one result before stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerRunsOnNodeWhenNodeIDSet(t *testing.T) {
|
||||
fake := &fakeCommander{
|
||||
res: ws.CommandResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"success": false,
|
||||
"latencyMs": float64(12),
|
||||
"errorMessage": "unreachable",
|
||||
},
|
||||
},
|
||||
}
|
||||
checker := NewChecker(nil, fake)
|
||||
limits := checker.loadServiceMonitorLimits()
|
||||
now := time.Now().UnixMilli()
|
||||
monitor := &model.ServiceMonitor{
|
||||
ID: 99,
|
||||
Type: "icmp",
|
||||
Target: "8.8.8.8",
|
||||
TimeoutSec: 2,
|
||||
NodeID: 123,
|
||||
}
|
||||
res := checker.executeCheck(monitor, now, limits)
|
||||
if fake.lastNodeID != 123 {
|
||||
t.Fatalf("expected command to be sent to node 123, got %d", fake.lastNodeID)
|
||||
}
|
||||
if fake.lastType != "ServiceMonitorCheck" {
|
||||
t.Fatalf("expected ServiceMonitorCheck command, got %s", fake.lastType)
|
||||
}
|
||||
if res.Success != 0 {
|
||||
t.Fatalf("expected failed result from node check")
|
||||
}
|
||||
if res.ErrorMessage != "unreachable" {
|
||||
t.Fatalf("expected errorMessage unreachable, got %q", res.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerDoesNotBurstOnRestartWhenRecentResultsExist(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
monitor := &model.ServiceMonitor{
|
||||
Name: "recent-monitor",
|
||||
Type: "tcp",
|
||||
Target: "127.0.0.1:1",
|
||||
IntervalSec: 60,
|
||||
TimeoutSec: 1,
|
||||
NodeID: 0,
|
||||
Enabled: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := r.CreateServiceMonitor(monitor); err != nil {
|
||||
t.Fatalf("create monitor: %v", err)
|
||||
}
|
||||
if err := r.InsertServiceMonitorResult(&model.ServiceMonitorResult{
|
||||
MonitorID: monitor.ID,
|
||||
NodeID: 0,
|
||||
Timestamp: now - 10_000,
|
||||
Success: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed recent result: %v", err)
|
||||
}
|
||||
|
||||
checker := NewChecker(r, nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go checker.Start(ctx)
|
||||
// Give the initial scan a chance to run.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
checker.Stop()
|
||||
|
||||
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("get results: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected no immediate rerun (1 result), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerConcurrencyPreventsSlowMonitorBlockingOthers(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
// Force worker limit to at least 2 for this test.
|
||||
_ = r.UpsertConfig(monitoring.ConfigServiceMonitorWorkerLimit, "2", now)
|
||||
|
||||
slow := &model.ServiceMonitor{
|
||||
Name: "slow",
|
||||
Type: "icmp",
|
||||
Target: "8.8.8.8",
|
||||
IntervalSec: 60,
|
||||
TimeoutSec: 1,
|
||||
NodeID: 123,
|
||||
Enabled: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := r.CreateServiceMonitor(slow); err != nil {
|
||||
t.Fatalf("create slow monitor: %v", err)
|
||||
}
|
||||
fast := &model.ServiceMonitor{
|
||||
Name: "fast",
|
||||
Type: "icmp",
|
||||
Target: "1.1.1.1",
|
||||
IntervalSec: 60,
|
||||
TimeoutSec: 1,
|
||||
NodeID: 123,
|
||||
Enabled: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := r.CreateServiceMonitor(fast); err != nil {
|
||||
t.Fatalf("create fast monitor: %v", err)
|
||||
}
|
||||
|
||||
cmd := &delayedCommander{delayByMonitorID: map[int64]time.Duration{slow.ID: 800 * time.Millisecond}}
|
||||
checker := NewChecker(r, cmd)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go checker.Start(ctx)
|
||||
|
||||
// Fast monitor should complete even while slow one is still running.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
results, err := r.GetServiceMonitorResults(fast.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("get fast results: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatalf("expected fast monitor to have results without waiting for slow")
|
||||
}
|
||||
|
||||
cancel()
|
||||
checker.Stop()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
# BACKEND HTTP HANDLER KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
||||
|
||||
@@ -280,6 +280,16 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
for _, fp := range ports {
|
||||
if limiterID != nil && speed != nil {
|
||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||
// If the limiter push fails because the node is offline, skip it with a warning
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
node, _ := h.getNodeRecord(fp.NodeID)
|
||||
nodeName := fmt.Sprintf("%d", fp.NodeID)
|
||||
if node != nil && strings.TrimSpace(node.Name) != "" {
|
||||
nodeName = strings.TrimSpace(node.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -308,6 +318,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
}
|
||||
// When a node is offline, skip it with a warning instead of failing.
|
||||
// This lets users modify forward rules even when some entry nodes are down.
|
||||
if err != nil && isNodeOfflineOrTimeoutError(err) {
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", node.Name))
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||
}
|
||||
@@ -1561,10 +1577,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
}
|
||||
var serviceAddr string
|
||||
if bindIP != "" {
|
||||
if strings.Contains(bindIP, ":") {
|
||||
serviceAddr = processServerAddress(bindIP)
|
||||
trimmedBindIP := strings.TrimSpace(bindIP)
|
||||
if _, _, err := net.SplitHostPort(trimmedBindIP); err == nil {
|
||||
serviceAddr = processServerAddress(trimmedBindIP)
|
||||
} else {
|
||||
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", bindIP, port))
|
||||
serviceAddr = processServerAddress(net.JoinHostPort(strings.Trim(trimmedBindIP, "[]"), strconv.Itoa(port)))
|
||||
}
|
||||
} else {
|
||||
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port))
|
||||
|
||||
@@ -421,6 +421,63 @@ func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bindIP string
|
||||
port int
|
||||
wantAddr string
|
||||
}{
|
||||
{
|
||||
name: "pure ipv6 without port",
|
||||
bindIP: "2001:db8::1",
|
||||
port: 22000,
|
||||
wantAddr: "[2001:db8::1]:22000",
|
||||
},
|
||||
{
|
||||
name: "bracketed ipv6 without port",
|
||||
bindIP: "[2001:db8::2]",
|
||||
port: 22001,
|
||||
wantAddr: "[2001:db8::2]:22001",
|
||||
},
|
||||
{
|
||||
name: "bracketed ipv6 with port",
|
||||
bindIP: "[2001:db8::3]:8080",
|
||||
port: 55555,
|
||||
wantAddr: "[2001:db8::3]:8080",
|
||||
},
|
||||
{
|
||||
name: "ipv6 link-local with zone",
|
||||
bindIP: "fe80::1%eth0",
|
||||
port: 22002,
|
||||
wantAddr: "[fe80::1%eth0]:22002",
|
||||
},
|
||||
{
|
||||
name: "ipv6 localhost",
|
||||
bindIP: "::1",
|
||||
port: 22003,
|
||||
wantAddr: "[::1]:22003",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, false)
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, svc := range services {
|
||||
addr, _ := svc["addr"].(string)
|
||||
if addr != tt.wantAddr {
|
||||
t.Fatalf("expected addr %q, got %q", tt.wantAddr, addr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -345,21 +345,39 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
|
||||
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("expected v6 address for cross-version, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) {
|
||||
// v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("expected v4 address for cross-version, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) {
|
||||
// 真正不兼容:两个节点都没有任何 IP
|
||||
from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
|
||||
to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
|
||||
t.Fatal("expected error for nodes with no IP addresses")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type userTunnelPolicy struct {
|
||||
OutFlow int64
|
||||
ExpTime int64
|
||||
Status int
|
||||
Num int
|
||||
}
|
||||
|
||||
type gostConfigSnapshot struct {
|
||||
@@ -363,6 +364,16 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Num > 0 {
|
||||
currentForwardCount, err := h.repo.CountActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentForwardCount >= int64(user.Num) {
|
||||
return errors.New("转发数量已达上限")
|
||||
}
|
||||
}
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -392,6 +403,16 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
|
||||
return errors.New("该隧道流量已超额,禁止开启转发")
|
||||
}
|
||||
|
||||
if policy.Num > 0 {
|
||||
currentTunnelForwardCount, err := h.repo.CountActiveForwardsByUserTunnel(userID, tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentTunnelForwardCount >= int64(policy.Num) {
|
||||
return errors.New("该隧道转发数量已达上限")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -442,7 +463,7 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
|
||||
return &userTunnelPolicy{
|
||||
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
|
||||
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -16,17 +16,23 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/health"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/license"
|
||||
"go-backend/internal/metrics"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
type Handler struct {
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
metrics *metrics.IngestionService
|
||||
healthCheck *health.Checker
|
||||
|
||||
captchaMu sync.Mutex
|
||||
captchaTokens map[string]int64
|
||||
@@ -38,8 +44,12 @@ type Handler struct {
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
}
|
||||
|
||||
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -60,6 +70,10 @@ type configSingleRequest struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type licenseActivateRequest struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
NewUsername string `json:"newUsername"`
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
@@ -83,10 +97,32 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
metrics: metrics.NewIngestionService(repo),
|
||||
healthCheck: nil,
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
||||
h.qualityProber = newTunnelQualityProber(h)
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
|
||||
metricInfo := metrics.SystemInfo{
|
||||
Uptime: info.Uptime,
|
||||
BytesReceived: info.BytesReceived,
|
||||
BytesTransmitted: info.BytesTransmitted,
|
||||
CPUUsage: info.CPUUsage,
|
||||
MemoryUsage: info.MemoryUsage,
|
||||
DiskUsage: info.DiskUsage,
|
||||
Load1: info.Load1,
|
||||
Load5: info.Load5,
|
||||
Load15: info.Load15,
|
||||
TCPConns: info.TCPConns,
|
||||
UDPConns: info.UDPConns,
|
||||
NetInSpeed: info.NetInSpeed,
|
||||
NetOutSpeed: info.NetOutSpeed,
|
||||
}
|
||||
h.metrics.RecordNodeMetric(nodeID, metricInfo)
|
||||
})
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -107,6 +143,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
|
||||
@@ -135,6 +172,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete-preview", h.tunnelDeletePreview)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete-with-forwards", h.tunnelDeleteWithForwards)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete-preview", h.tunnelBatchDeletePreview)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete-with-forwards", h.tunnelBatchDeleteWithForwards)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
|
||||
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
|
||||
@@ -196,6 +237,24 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
|
||||
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
|
||||
|
||||
mux.HandleFunc("/api/v1/monitor/access", h.monitorAccessHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/quality", h.monitorTunnelQualityHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
|
||||
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
|
||||
mux.HandleFunc("/api/v1/monitor/services/update", h.monitorServiceUpdate)
|
||||
mux.HandleFunc("/api/v1/monitor/services/delete", h.monitorServiceDelete)
|
||||
mux.HandleFunc("/api/v1/monitor/services/run", h.monitorServiceRun)
|
||||
mux.HandleFunc("/api/v1/monitor/services/latest-results", h.monitorServiceLatestResultsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/limits", h.monitorServiceLimitsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/", h.monitorServiceResultsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/permission/list", h.monitorPermissionList)
|
||||
mux.HandleFunc("/api/v1/monitor/permission/assign", h.monitorPermissionAssign)
|
||||
mux.HandleFunc("/api/v1/monitor/permission/remove", h.monitorPermissionRemove)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||
mux.HandleFunc("/flow/upload", h.flowUpload)
|
||||
@@ -724,6 +783,8 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil && strings.TrimSpace(raw) != "" {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
h.recordTunnelMetricsFromFlowItems(node.ID, items, nowMs)
|
||||
for _, item := range items {
|
||||
h.processFlowItem(node.ID, item)
|
||||
}
|
||||
@@ -734,6 +795,94 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
|
||||
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
if fp != "" {
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
newFp := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newFp, nil
|
||||
}
|
||||
|
||||
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req licenseActivateRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(req.LicenseKey)
|
||||
if key == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
|
||||
|
||||
fingerprint, err := h.getOrCreateMachineFingerprint()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
|
||||
return
|
||||
}
|
||||
|
||||
client := license.NewKeygenClient(accountID, "")
|
||||
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if !valResp.Meta.Valid {
|
||||
if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" {
|
||||
// Needs machine activation
|
||||
client.Token = key
|
||||
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
|
||||
if err != nil {
|
||||
// Translate specific error messages or log them
|
||||
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded
|
||||
// we can consider the license valid for our simple usecase
|
||||
} else {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
expiry := valResp.Data.Attributes.Expiry
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
if err := h.repo.UpsertConfig("license_expiry", expiry, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -750,6 +899,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
protectedKeys := map[string]bool{
|
||||
"app_name": true,
|
||||
"app_logo": true,
|
||||
"app_favicon": true,
|
||||
"hide_footer_brand": true,
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for k, v := range payload {
|
||||
key := strings.TrimSpace(k)
|
||||
@@ -757,6 +914,11 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
if protectedKeys[key] && isCommercial != "true" {
|
||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||
return
|
||||
}
|
||||
|
||||
value, err := normalizeAndValidateConfigValue(key, v)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -789,6 +951,12 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
|
||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||
return
|
||||
}
|
||||
|
||||
value, err := normalizeAndValidateConfigValue(name, req.Value)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -834,11 +1002,32 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
}
|
||||
|
||||
return pngDataURLPrefix + payload, nil
|
||||
case monitorTunnelQualityEnabledConfigKey:
|
||||
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||
switch normalized {
|
||||
case "true", "false":
|
||||
return normalized, nil
|
||||
default:
|
||||
return "", fmt.Errorf("隧道质量检测开关配置值无效")
|
||||
}
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(monitorTunnelQualityEnabledConfigKey)
|
||||
if err != nil || cfg == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false"
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -1186,6 +1375,21 @@ func nullableNullInt64(v sql.NullInt64) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// flowCryptoCache caches AES crypto instances by secret to avoid per-request SHA256+GCM init.
|
||||
var flowCryptoCache sync.Map
|
||||
|
||||
func getOrCreateFlowCrypto(secret string) *security.AESCrypto {
|
||||
if v, ok := flowCryptoCache.Load(secret); ok {
|
||||
return v.(*security.AESCrypto)
|
||||
}
|
||||
c, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
flowCryptoCache.Store(secret, c)
|
||||
return c
|
||||
}
|
||||
|
||||
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
|
||||
defer body.Close()
|
||||
raw, err := io.ReadAll(body)
|
||||
@@ -1206,8 +1410,8 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
crypto := getOrCreateFlowCrypto(secret)
|
||||
if crypto == nil {
|
||||
return text, nil
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
@@ -1333,15 +1537,22 @@ func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if ann == nil {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
"update_time": 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
updateTime := ann.CreatedTime
|
||||
if ann.UpdatedTime.Valid {
|
||||
updateTime = ann.UpdatedTime.Int64
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
"update_time": updateTime,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/license"
|
||||
)
|
||||
|
||||
func (h *Handler) StartBackgroundJobs() {
|
||||
@@ -18,12 +20,68 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(3)
|
||||
h.jobsWG.Add(7)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
go h.runDailyMaintenanceLoop(ctx)
|
||||
go h.runNodeRenewalCycleLoop(ctx)
|
||||
go h.runMetricsIngestion(ctx)
|
||||
go h.runHealthChecks(ctx)
|
||||
go h.runTunnelQualityProber(ctx)
|
||||
go h.runValidateLicenseJob(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
ticker := time.NewTicker(12 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.validateLicenseJob()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) validateLicenseJob() {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
|
||||
|
||||
key, _ := h.repo.GetViteConfigValue("license_key")
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
|
||||
if key == "" || isCommercial != "true" {
|
||||
return // Nothing to validate
|
||||
}
|
||||
|
||||
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
client := license.NewKeygenClient(accountID, "")
|
||||
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
|
||||
if err != nil {
|
||||
// Network error or timeout. Grace period by not revoking immediately here.
|
||||
return
|
||||
}
|
||||
|
||||
if !valResp.Meta.Valid {
|
||||
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
|
||||
now := time.Now().UnixMilli()
|
||||
_ = h.repo.UpsertConfig("is_commercial", "false", now)
|
||||
} else {
|
||||
now := time.Now().UnixMilli()
|
||||
expiry := valResp.Data.Attributes.Expiry
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
@@ -47,6 +105,29 @@ func (h *Handler) StopBackgroundJobs() {
|
||||
h.jobsWG.Wait()
|
||||
}
|
||||
|
||||
func (h *Handler) runMetricsIngestion(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.metrics != nil {
|
||||
h.metrics.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runHealthChecks(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.healthCheck != nil {
|
||||
h.healthCheck.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
|
||||
@@ -0,0 +1,940 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMetricsRangeMs = int64(60 * 60 * 1000) // 1h
|
||||
maxMetricsRangeMs = int64(24 * 60 * 60 * 1000) // 24h
|
||||
)
|
||||
|
||||
func (h *Handler) resolveServiceMonitorLimits() monitoring.ServiceMonitorLimits {
|
||||
defaults := monitoring.DefaultServiceMonitorLimits()
|
||||
if h == nil || h.repo == nil {
|
||||
return defaults
|
||||
}
|
||||
cfg, err := h.repo.GetConfigsByNames([]string{
|
||||
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
|
||||
monitoring.ConfigServiceMonitorWorkerLimit,
|
||||
monitoring.ConfigServiceMonitorMinIntervalSec,
|
||||
monitoring.ConfigServiceMonitorDefaultIntervalSec,
|
||||
monitoring.ConfigServiceMonitorMinTimeoutSec,
|
||||
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
|
||||
monitoring.ConfigServiceMonitorMaxTimeoutSec,
|
||||
})
|
||||
if err != nil {
|
||||
return defaults
|
||||
}
|
||||
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
func (h *Handler) monitorNodeMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
prefix := "/api/v1/monitor/nodes/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
return
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
if strings.HasSuffix(rest, "/metrics/latest") {
|
||||
h.handleNodeMetricsLatest(w, r, strings.TrimSuffix(rest, "/metrics/latest"))
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(rest, "/metrics") {
|
||||
h.handleNodeMetrics(w, r, strings.TrimSuffix(rest, "/metrics"))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
}
|
||||
|
||||
type monitorNodeListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Inx int `json:"inx"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
Version string `json:"version"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := h.repo.ListMonitorNodes()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]monitorNodeListItem, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
updated := int64(0)
|
||||
if n.UpdatedTime.Valid {
|
||||
updated = n.UpdatedTime.Int64
|
||||
}
|
||||
items = append(items, monitorNodeListItem{
|
||||
ID: n.ID,
|
||||
Inx: n.Inx,
|
||||
Name: n.Name,
|
||||
Status: n.Status,
|
||||
Version: n.Version.String,
|
||||
UpdatedTime: updated,
|
||||
})
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
type monitorTunnelListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Inx int `json:"inx"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnels, err := h.repo.ListMonitorTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]monitorTunnelListItem, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
items = append(items, monitorTunnelListItem{
|
||||
ID: t.ID,
|
||||
Inx: t.Inx,
|
||||
Name: t.Name,
|
||||
Status: t.Status,
|
||||
UpdatedTime: t.UpdatedTime,
|
||||
})
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) handleNodeMetrics(w http.ResponseWriter, r *http.Request, nodeIDStr string) {
|
||||
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
|
||||
if err != nil || nodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if startMs <= 0 || endMs <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := h.repo.GetNodeMetrics(nodeID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(metrics))
|
||||
}
|
||||
|
||||
func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request, nodeIDStr string) {
|
||||
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
|
||||
if err != nil || nodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
|
||||
return
|
||||
}
|
||||
|
||||
metric, err := h.repo.GetLatestNodeMetric(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if metric == nil {
|
||||
response.WriteJSON(w, response.OK(nil))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(metric))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first
|
||||
if h.qualityProber != nil {
|
||||
items := h.qualityProber.GetAll()
|
||||
if len(items) > 0 {
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database (latest per tunnel)
|
||||
qualities, err := h.repo.GetLatestTunnelQualities()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
|
||||
for _, q := range qualities {
|
||||
snapshots = append(snapshots, tunnelQualitySnapshot{
|
||||
TunnelID: q.TunnelID,
|
||||
EntryToExitLatency: q.EntryToExitLatency,
|
||||
ExitToBingLatency: q.ExitToBingLatency,
|
||||
EntryToExitLoss: q.EntryToExitLoss,
|
||||
ExitToBingLoss: q.ExitToBingLoss,
|
||||
Success: q.Success == 1,
|
||||
ErrorMessage: q.ErrorMessage,
|
||||
Timestamp: q.Timestamp,
|
||||
ChainDetails: q.ChainDetails,
|
||||
})
|
||||
}
|
||||
response.WriteJSON(w, response.OK(snapshots))
|
||||
}
|
||||
|
||||
// monitorTunnelQualityHistory returns quality probe history for charting.
|
||||
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
|
||||
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
|
||||
func (h *Handler) monitorTunnelQualityHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/quality")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if startMs <= 0 || endMs <= 0 || endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.repo.GetTunnelQualityHistory(tunnelID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
prefix := "/api/v1/monitor/tunnels/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
return
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/quality
|
||||
if strings.HasSuffix(rest, "/quality") {
|
||||
h.monitorTunnelQualityHistory(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
|
||||
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if startMs <= 0 || endMs <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := h.repo.GetTunnelMetricsAggregated(tunnelID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(metrics))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
monitors, err := h.repo.ListServiceMonitors()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(monitors))
|
||||
}
|
||||
|
||||
type createServiceMonitorRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
IntervalSec int `json:"intervalSec"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
Enabled *int `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req createServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if monitorType != "tcp" && monitorType != "icmp" {
|
||||
response.WriteJSON(w, response.ErrDefault("类型必须是 tcp 或 icmp"))
|
||||
return
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(req.Target)
|
||||
if target == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("目标地址不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
limits := h.resolveServiceMonitorLimits()
|
||||
|
||||
intervalSec := req.IntervalSec
|
||||
if intervalSec <= 0 {
|
||||
intervalSec = limits.DefaultIntervalSec
|
||||
}
|
||||
if intervalSec < limits.MinIntervalSec {
|
||||
intervalSec = limits.MinIntervalSec
|
||||
}
|
||||
|
||||
timeoutSec := req.TimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = limits.DefaultTimeoutSec
|
||||
}
|
||||
if timeoutSec < limits.MinTimeoutSec {
|
||||
timeoutSec = limits.MinTimeoutSec
|
||||
}
|
||||
if timeoutSec > limits.MaxTimeoutSec {
|
||||
timeoutSec = limits.MaxTimeoutSec
|
||||
}
|
||||
|
||||
enabled := 1
|
||||
if req.Enabled != nil {
|
||||
if *req.Enabled == 0 || *req.Enabled == 1 {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if req.NodeID > 0 {
|
||||
n, err := h.repo.GetNodeByID(req.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if n == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
||||
return
|
||||
}
|
||||
}
|
||||
m := &model.ServiceMonitor{
|
||||
Name: name,
|
||||
Type: monitorType,
|
||||
Target: target,
|
||||
IntervalSec: intervalSec,
|
||||
TimeoutSec: timeoutSec,
|
||||
NodeID: req.NodeID,
|
||||
Enabled: enabled,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if m.Type == "icmp" && m.NodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
|
||||
return
|
||||
}
|
||||
// enabled is already normalized above.
|
||||
|
||||
if err := h.repo.CreateServiceMonitor(m); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(m))
|
||||
}
|
||||
|
||||
type updateServiceMonitorRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
IntervalSec int `json:"intervalSec"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
NodeID *int64 `json:"nodeId"`
|
||||
Enabled *int `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.repo.GetServiceMonitor(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("监控不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name != "" {
|
||||
existing.Name = name
|
||||
}
|
||||
|
||||
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if monitorType == "tcp" || monitorType == "icmp" {
|
||||
existing.Type = monitorType
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(req.Target)
|
||||
if target != "" {
|
||||
existing.Target = target
|
||||
}
|
||||
|
||||
limits := h.resolveServiceMonitorLimits()
|
||||
|
||||
if req.IntervalSec > 0 {
|
||||
intervalSec := req.IntervalSec
|
||||
if intervalSec < limits.MinIntervalSec {
|
||||
intervalSec = limits.MinIntervalSec
|
||||
}
|
||||
existing.IntervalSec = intervalSec
|
||||
}
|
||||
if req.TimeoutSec > 0 {
|
||||
timeoutSec := req.TimeoutSec
|
||||
if timeoutSec < limits.MinTimeoutSec {
|
||||
timeoutSec = limits.MinTimeoutSec
|
||||
}
|
||||
if timeoutSec > limits.MaxTimeoutSec {
|
||||
timeoutSec = limits.MaxTimeoutSec
|
||||
}
|
||||
existing.TimeoutSec = timeoutSec
|
||||
}
|
||||
|
||||
if req.NodeID != nil {
|
||||
existing.NodeID = *req.NodeID
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
if *req.Enabled == 0 || *req.Enabled == 1 {
|
||||
existing.Enabled = *req.Enabled
|
||||
}
|
||||
}
|
||||
|
||||
existing.UpdatedTime = time.Now().UnixMilli()
|
||||
if existing.Type == "icmp" && existing.NodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
|
||||
return
|
||||
}
|
||||
if existing.NodeID > 0 {
|
||||
n, err := h.repo.GetNodeByID(existing.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if n == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateServiceMonitor(existing); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(existing))
|
||||
}
|
||||
|
||||
type deleteServiceMonitorRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req deleteServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteServiceMonitor(req.ID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
if h.healthCheck == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("监控服务不可用"))
|
||||
return
|
||||
}
|
||||
|
||||
var req deleteServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
m, err := h.repo.GetServiceMonitor(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("监控不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.healthCheck.RunOnce(m)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.InsertServiceMonitorResult(res); err != nil {
|
||||
log.Printf("monitoring write failed op=service_monitor_result.manual_insert monitor_id=%d err=%v", res.MonitorID, err)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
monitorIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/services/", "/results")
|
||||
monitorID, err := strconv.ParseInt(monitorIDStr, 10, 64)
|
||||
if err != nil || monitorID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// If start/end time range is provided, use time-based query (mirrors node metrics / tunnel quality pattern).
|
||||
startStr := r.URL.Query().Get("start")
|
||||
endStr := r.URL.Query().Get("end")
|
||||
if startStr != "" && endStr != "" {
|
||||
startMs, err1 := strconv.ParseInt(startStr, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(endStr, 10, 64)
|
||||
if err1 != nil || err2 != nil || startMs <= 0 || endMs <= 0 || endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
results, err := h.repo.GetServiceMonitorResultsByTimeRange(monitorID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: count-based limit query (backward compat).
|
||||
limit := 100
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.repo.GetServiceMonitorResults(monitorID, limit)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first (updated every 1s)
|
||||
if h.healthCheck != nil {
|
||||
cached := h.healthCheck.GetLatestCached()
|
||||
if len(cached) > 0 {
|
||||
response.WriteJSON(w, response.OK(cached))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database
|
||||
results, err := h.repo.GetLatestServiceMonitorResults()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceLimitsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(h.resolveServiceMonitorLimits()))
|
||||
}
|
||||
|
||||
func extractPathParam(path, prefix, suffix string) string {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
if suffix != "" {
|
||||
rest = strings.TrimSuffix(rest, suffix)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
type monitorAccessData struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// monitorAccessHandler is a lightweight capability check for frontend navigation.
|
||||
// It does NOT replace authorization on the actual monitoring endpoints.
|
||||
func (h *Handler) monitorAccessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
userID, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
if roleID == 0 {
|
||||
response.WriteJSON(w, response.OK(monitorAccessData{Allowed: true}))
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := h.repo.HasMonitorPermission(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
data := monitorAccessData{Allowed: allowed}
|
||||
if !allowed {
|
||||
data.Reason = "need_admin_grant"
|
||||
}
|
||||
response.WriteJSON(w, response.OK(data))
|
||||
}
|
||||
|
||||
func (h *Handler) ensureAdminAccess(w http.ResponseWriter, r *http.Request) bool {
|
||||
_, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return false
|
||||
}
|
||||
if roleID != 0 {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) ensureMonitoringAccess(w http.ResponseWriter, r *http.Request) bool {
|
||||
userID, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return false
|
||||
}
|
||||
if roleID == 0 {
|
||||
return true
|
||||
}
|
||||
allowed, err := h.repo.HasMonitorPermission(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return false
|
||||
}
|
||||
if !allowed {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足:当前账户非管理员,且未被授予监控权限。请联系管理员在用户管理中授权监控权限。"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) monitorPermissionList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureAdminAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListMonitorPermissions()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
type monitorPermissionMutationRequest struct {
|
||||
UserID int64 `json:"userId"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorPermissionAssign(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureAdminAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req monitorPermissionMutationRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.repo.GetUserByID(req.UserID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("用户不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.InsertMonitorPermission(req.UserID, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) monitorPermissionRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureAdminAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req monitorPermissionMutationRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteMonitorPermission(req.UserID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -453,7 +453,16 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
|
||||
var cmd string
|
||||
if enabled {
|
||||
cmd = fmt.Sprintf("curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
|
||||
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret)
|
||||
} else {
|
||||
cmd = fmt.Sprintf("curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
|
||||
githubRepo, version, version, processServerAddress(panelAddr), secret)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(cmd))
|
||||
}
|
||||
|
||||
@@ -769,14 +778,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
ipPreference := asString(req["ipPreference"])
|
||||
localDomain := h.federationLocalDomain()
|
||||
|
||||
tx := h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
response.WriteJSON(w, response.Err(-2, tx.Error.Error()))
|
||||
return
|
||||
}
|
||||
defer func() { tx.Rollback() }()
|
||||
|
||||
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)
|
||||
runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -795,6 +797,14 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
applyTunnelPortsToRequest(req, runtimeState)
|
||||
|
||||
tx := h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
response.WriteJSON(w, response.Err(-2, tx.Error.Error()))
|
||||
return
|
||||
}
|
||||
defer func() { tx.Rollback() }()
|
||||
|
||||
if err := h.repo.UpdateTunnelTx(
|
||||
tx,
|
||||
id,
|
||||
@@ -832,7 +842,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
newEntryNodeIDs = append(newEntryNodeIDs, in.NodeID)
|
||||
}
|
||||
}
|
||||
if err := h.validateTunnelEntryPortConflictsForNewEntries(id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
|
||||
if err := h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -1020,8 +1030,22 @@ func (h *Handler) cleanupTunnelForwardRuntimesOnRemovedEntryNodes(tunnelID int64
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
|
||||
if h == nil || h.repo == nil || tunnelID <= 0 {
|
||||
func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecord, port int, currentForwardID int64) error {
|
||||
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied {
|
||||
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateTunnelEntryPortConflictsForNewEntriesTx(tx *gorm.DB, tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
|
||||
if h == nil || h.repo == nil || tx == nil || tunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1030,7 +1054,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
forwards, err := h.repo.ListForwardsByTunnelTx(tx, tunnelID)
|
||||
if err != nil || len(forwards) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -1040,7 +1064,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
oldPorts, portsErr := h.listForwardPorts(f.ID)
|
||||
oldPorts, portsErr := h.repo.ListForwardPortsTx(tx, f.ID)
|
||||
if portsErr != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1050,14 +1074,12 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
|
||||
}
|
||||
|
||||
for _, nodeID := range addedNodeIDs {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
node, nodeErr := h.repo.GetNodeRecordTx(tx, nodeID)
|
||||
if nodeErr != nil {
|
||||
continue
|
||||
}
|
||||
if err := validateLocalNodePort(node, port); err != nil {
|
||||
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, port, f.ID); err != nil {
|
||||
|
||||
if err := h.validateForwardPortAvailabilityTx(tx, node, port, f.ID); err != nil {
|
||||
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -1090,24 +1112,113 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port := pickForwardPortFromRecords(oldPorts)
|
||||
if port <= 0 {
|
||||
referencePort := pickForwardPortFromRecords(oldPorts)
|
||||
if referencePort <= 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: ""})
|
||||
// Build a map of existing node → port/inIP from old records.
|
||||
oldPortByNode := make(map[int64]forwardPortRecord)
|
||||
for _, fp := range oldPorts {
|
||||
if fp.NodeID > 0 {
|
||||
oldPortByNode[fp.NodeID] = fp
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
||||
for _, nid := range entryNodeIDs {
|
||||
if existing, ok := oldPortByNode[nid]; ok && existing.Port > 0 {
|
||||
// Existing entry node: keep its current port.
|
||||
inIP := existing.InIP
|
||||
if !allowInIP {
|
||||
inIP = ""
|
||||
}
|
||||
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: existing.Port, InIP: inIP})
|
||||
continue
|
||||
}
|
||||
|
||||
// New entry node: try to follow the reference port.
|
||||
port := h.resolvePortForNewEntryNode(nid, referencePort, f.ID)
|
||||
inIP := ""
|
||||
if allowInIP {
|
||||
// For single-entry tunnels, try to preserve inIP from old records.
|
||||
for _, fp := range oldPorts {
|
||||
if strings.TrimSpace(fp.InIP) != "" {
|
||||
inIP = fp.InIP
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIP})
|
||||
}
|
||||
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePortForNewEntryNode determines the port for a forward on a newly added
|
||||
// entry node. It tries to reuse referencePort (from existing entries); if that
|
||||
// port is out of range or already occupied, it picks a random available port
|
||||
// for this specific node.
|
||||
func (h *Handler) resolvePortForNewEntryNode(nodeID int64, referencePort int, forwardID int64) int {
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil {
|
||||
return referencePort
|
||||
}
|
||||
|
||||
// Check if referencePort is within the node's allowed range.
|
||||
if validateLocalNodePort(node, referencePort) == nil &&
|
||||
validateRemoteNodePort(node, referencePort) == nil {
|
||||
// In range — check availability.
|
||||
occupied, occErr := h.repo.HasOtherForwardOnNodePort(nodeID, referencePort, forwardID)
|
||||
if occErr == nil && !occupied {
|
||||
return referencePort
|
||||
}
|
||||
}
|
||||
|
||||
// referencePort doesn't work for this node; pick a random one.
|
||||
newPort := h.pickRandomPortForNode(nodeID)
|
||||
if newPort > 0 {
|
||||
return newPort
|
||||
}
|
||||
return referencePort // last resort fallback
|
||||
}
|
||||
|
||||
// pickRandomPortForNode picks a random available port from a single node's
|
||||
// port range, excluding ports already occupied by other forwards or chains.
|
||||
func (h *Handler) pickRandomPortForNode(nodeID int64) int {
|
||||
portRange, err := h.repo.GetNodePortRange(nodeID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if portRange == "" {
|
||||
portRange = "1000-65535"
|
||||
}
|
||||
|
||||
nodePorts, err := parsePorts(portRange)
|
||||
if err != nil || len(nodePorts) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
used, err := h.getUsedPorts(nodeID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var available []int
|
||||
for _, p := range nodePorts {
|
||||
if !used[p] {
|
||||
available = append(available, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(available) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
|
||||
return available[idx.Int64()]
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -1456,6 +1567,12 @@ func (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
userID, tunnelID, lookupErr := h.repo.GetUserTunnelUserAndTunnel(id)
|
||||
if lookupErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, lookupErr.Error()))
|
||||
return
|
||||
}
|
||||
h.cleanupForwardsForUserTunnel(userID, tunnelID)
|
||||
if err := h.repo.DeleteUserTunnel(id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1852,7 +1969,27 @@ func (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {
|
||||
h.forwardDelete(w, r)
|
||||
id := idFromBody(r, w)
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
_, _, _, err := h.resolveForwardAccess(r, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errForwardNotFound) {
|
||||
response.WriteJSON(w, response.ErrDefault("转发不存在"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Force delete: remove DB record without touching node services.
|
||||
// This is used when nodes are offline or service deletion fails.
|
||||
if err := h.deleteForwardByID(id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2170,13 +2307,24 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
||||
nd, ndErr := h.getNodeRecord(nid)
|
||||
if ndErr != nil {
|
||||
portRangeErr = ndErr
|
||||
continue
|
||||
portRangeOk = false
|
||||
break
|
||||
}
|
||||
if validateErr := validateRemoteNodePort(nd, p); validateErr != nil {
|
||||
portRangeOk = false
|
||||
portRangeErr = validateErr
|
||||
break
|
||||
}
|
||||
if validateErr := validateLocalNodePort(nd, p); validateErr != nil {
|
||||
portRangeOk = false
|
||||
portRangeErr = validateErr
|
||||
break
|
||||
}
|
||||
if validateErr := h.validateForwardPortAvailability(nd, p, id); validateErr != nil {
|
||||
portRangeOk = false
|
||||
portRangeErr = validateErr
|
||||
break
|
||||
}
|
||||
}
|
||||
if !portRangeOk {
|
||||
fail++
|
||||
@@ -2362,14 +2510,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
revokedPairs, revokeErr := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs)
|
||||
if revokeErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, pair := range revokedPairs {
|
||||
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
|
||||
}
|
||||
_ = h.syncPermissionsByUserGroup(req.GroupID)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -2413,9 +2565,12 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
var revokedPairs []repo.RevokedUserTunnelPair
|
||||
if exists {
|
||||
if err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
var revokeErr error
|
||||
revokedPairs, revokeErr = h.repo.RevokeGroupPermissionPairTx(tx, ug, tg)
|
||||
if revokeErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2424,6 +2579,9 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, pair := range revokedPairs {
|
||||
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -2586,7 +2744,11 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
}
|
||||
if !isRemote {
|
||||
var err error
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
if excludeTunnelID > 0 {
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
} else {
|
||||
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2621,7 +2783,11 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
}
|
||||
if !isRemote {
|
||||
var err error
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
if excludeTunnelID > 0 {
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
} else {
|
||||
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2643,6 +2809,22 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
}
|
||||
}
|
||||
|
||||
// When updating an existing tunnel (excludeTunnelID > 0), build a set of
|
||||
// node IDs that already belong to the tunnel so we can tolerate offline
|
||||
// nodes that the user is keeping or removing, while still rejecting newly
|
||||
// added offline nodes.
|
||||
existingNodeIDs := make(map[int64]struct{})
|
||||
if excludeTunnelID > 0 {
|
||||
var existIDs []int64
|
||||
if err := tx.Model(&model.ChainTunnel{}).
|
||||
Where("tunnel_id = ?", excludeTunnelID).
|
||||
Pluck("node_id", &existIDs).Error; err == nil {
|
||||
for _, eid := range existIDs {
|
||||
existingNodeIDs[eid] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(nodeIDs))
|
||||
for _, nodeID := range nodeIDs {
|
||||
if _, ok := seen[nodeID]; ok {
|
||||
@@ -2661,7 +2843,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
if node.IsRemote != 1 && node.Status != 1 {
|
||||
return nil, errors.New("部分节点不在线")
|
||||
// For tunnel updates, allow offline nodes that already belong to the
|
||||
// tunnel (user may be removing them). Only reject genuinely new offline nodes.
|
||||
_, isExisting := existingNodeIDs[nodeID]
|
||||
if excludeTunnelID <= 0 || !isExisting {
|
||||
return nil, errors.New("部分节点不在线")
|
||||
}
|
||||
}
|
||||
state.Nodes[nodeID] = node
|
||||
}
|
||||
@@ -3061,7 +3248,6 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, inNode := range state.InNodes {
|
||||
node := state.Nodes[inNode.NodeID]
|
||||
targets := state.OutNodes
|
||||
if len(state.ChainHops) > 0 {
|
||||
targets = state.ChainHops[0]
|
||||
@@ -3071,7 +3257,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, err
|
||||
}
|
||||
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||
@@ -3085,7 +3271,8 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
nextTargets = state.ChainHops[i+1]
|
||||
}
|
||||
for _, chainNode := range hop {
|
||||
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
node := state.Nodes[chainNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
continue
|
||||
}
|
||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
||||
@@ -3093,12 +3280,18 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, err
|
||||
}
|
||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdChains = append(createdChains, chainNode.NodeID)
|
||||
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, chainNode.NodeID)
|
||||
@@ -3106,11 +3299,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, outNode := range state.OutNodes {
|
||||
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
node := state.Nodes[outNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, outNode.NodeID)
|
||||
@@ -3201,6 +3398,13 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isNodeOfflineOrTimeoutError returns true when the error indicates a node
|
||||
// is unreachable (offline or timed out), matching the same patterns used by
|
||||
// shouldDeferTunnelRuntimeApplyError.
|
||||
func isNodeOfflineOrTimeoutError(err error) bool {
|
||||
return shouldDeferTunnelRuntimeApplyError(err)
|
||||
}
|
||||
|
||||
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
|
||||
fromNode := nodes[fromNodeID]
|
||||
if fromNode == nil {
|
||||
@@ -3327,6 +3531,7 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 同版本优先
|
||||
if fromV4 && toV4 {
|
||||
if host := pickNodeAddressV4(toNode); host != "" {
|
||||
return host, nil
|
||||
@@ -3337,6 +3542,17 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
// 跨版本支持:v6入v4出 / v4入v6出
|
||||
if fromV6 && toV4 {
|
||||
if host := pickNodeAddressV4(toNode); host != "" {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
if fromV4 && toV6 {
|
||||
if host := pickNodeAddressV6(toNode); host != "" {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
|
||||
}
|
||||
@@ -3445,7 +3661,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
port := asInt(n["port"], 0)
|
||||
if port <= 0 {
|
||||
var pickErr error
|
||||
port, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)
|
||||
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
|
||||
if pickErr != nil {
|
||||
return pickErr
|
||||
}
|
||||
@@ -3475,7 +3691,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
port := asInt(n["port"], 0)
|
||||
if port <= 0 {
|
||||
var pickErr error
|
||||
port, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)
|
||||
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
|
||||
if pickErr != nil {
|
||||
return pickErr
|
||||
}
|
||||
@@ -3879,6 +4095,27 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupForwardsForUserTunnel deletes all forwarding rules belonging to a
|
||||
// specific user+tunnel pair. It notifies nodes to remove the runtime services
|
||||
// first, then deletes the DB records. This is best-effort: individual failures
|
||||
// do not abort the overall cleanup so that remaining forwards are still cleaned.
|
||||
func (h *Handler) cleanupForwardsForUserTunnel(userID, tunnelID int64) {
|
||||
if userID <= 0 || tunnelID <= 0 {
|
||||
return
|
||||
}
|
||||
forwards, err := h.repo.ListForwardsByUserAndTunnel(userID, tunnelID)
|
||||
if err != nil || len(forwards) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range forwards {
|
||||
f := &forwards[i]
|
||||
if f.Status == 1 {
|
||||
_ = h.controlForwardServices(f, "DeleteService", true)
|
||||
}
|
||||
_ = h.deleteForwardByID(f.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
||||
if speedID == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const tunnelDeletePreviewSampleLimit = 5
|
||||
|
||||
const (
|
||||
tunnelDeleteActionReplace = "replace"
|
||||
tunnelDeleteActionDeleteForwards = "delete_forwards"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidTunnelDeleteTarget = errors.New("invalid tunnel delete target")
|
||||
)
|
||||
|
||||
type tunnelDeleteForwardPreviewItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
InPort int `json:"inPort"`
|
||||
}
|
||||
|
||||
type tunnelDeletePreviewData struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
TunnelName string `json:"tunnelName"`
|
||||
ForwardCount int `json:"forwardCount"`
|
||||
SampleForwards []tunnelDeleteForwardPreviewItem `json:"sampleForwards"`
|
||||
}
|
||||
|
||||
type tunnelBatchDeletePreviewData struct {
|
||||
TunnelCount int `json:"tunnelCount"`
|
||||
TotalForwardCount int `json:"totalForwardCount"`
|
||||
Items []tunnelDeletePreviewData `json:"items"`
|
||||
}
|
||||
|
||||
type tunnelDeleteWithForwardsRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Action string `json:"action"`
|
||||
TargetTunnelID int64 `json:"targetTunnelId"`
|
||||
}
|
||||
|
||||
type tunnelBatchDeleteWithForwardsRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Action string `json:"action"`
|
||||
TargetTunnelID int64 `json:"targetTunnelId"`
|
||||
}
|
||||
|
||||
type tunnelDeleteWithForwardsResult struct {
|
||||
ForwardCount int `json:"forwardCount"`
|
||||
MigratedCount int `json:"migratedCount"`
|
||||
DeletedForwardCount int `json:"deletedForwardCount"`
|
||||
PortAdjustedCount int `json:"portAdjustedCount"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelBatchDeleteWithForwardsResult struct {
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailCount int `json:"failCount"`
|
||||
Failures []batchFailureDetail `json:"failures,omitempty"`
|
||||
DeletedForwardCount int `json:"deletedForwardCount"`
|
||||
MigratedCount int `json:"migratedCount"`
|
||||
PortAdjustedCount int `json:"portAdjustedCount"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelForwardMigrationPlan struct {
|
||||
forward *forwardRecord
|
||||
oldPorts []forwardPortRecord
|
||||
targetTunnelID int64
|
||||
targetPort int
|
||||
keptNodeIDs []int64
|
||||
removedNodeIDs []int64
|
||||
portAdjusted bool
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDeletePreview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
id := idFromBody(r, w)
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := h.buildTunnelDeletePreview(id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "不存在") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(preview))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchDeletePreview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := h.buildTunnelBatchDeletePreview(req.IDs)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "不存在") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(preview))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req tunnelDeleteWithForwardsRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil || req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
action, err := normalizeTunnelDeleteAction(req.Action)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if action == tunnelDeleteActionReplace {
|
||||
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, failures, err := h.processTunnelDeleteWithForwards(req.ID, action, req.TargetTunnelID)
|
||||
if err != nil {
|
||||
if err == errInvalidTunnelDeleteTarget {
|
||||
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "目标隧道不能与当前隧道相同") || strings.Contains(err.Error(), "目标隧道不存在") || strings.Contains(err.Error(), "目标隧道已禁用") || strings.Contains(err.Error(), "隧道不存在") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
response.WriteJSON(w, response.R{
|
||||
Code: -2,
|
||||
Msg: "部分规则迁移失败",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Data: batchOperationResult{SuccessCount: 0, FailCount: len(failures), Failures: failures},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req tunnelBatchDeleteWithForwardsRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
action, err := normalizeTunnelDeleteAction(req.Action)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if action == tunnelDeleteActionReplace {
|
||||
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
normalizedIDs := normalizeTunnelIDs(req.IDs)
|
||||
if len(normalizedIDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if action == tunnelDeleteActionReplace {
|
||||
if req.TargetTunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
|
||||
return
|
||||
}
|
||||
for _, id := range normalizedIDs {
|
||||
if id == req.TargetTunnelID {
|
||||
response.WriteJSON(w, response.ErrDefault("目标隧道不能包含在删除列表中"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := tunnelBatchDeleteWithForwardsResult{}
|
||||
for _, tunnelID := range normalizedIDs {
|
||||
tunnelName, _ := h.repo.GetTunnelName(tunnelID)
|
||||
singleResult, failures, processErr := h.processTunnelDeleteWithForwards(tunnelID, action, req.TargetTunnelID)
|
||||
if processErr != nil {
|
||||
result.FailCount++
|
||||
result.Failures = appendBatchFailure(result.Failures, tunnelID, tunnelName, processErr)
|
||||
continue
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
result.FailCount++
|
||||
result.Failures = appendBatchFailureReason(
|
||||
result.Failures,
|
||||
tunnelID,
|
||||
tunnelName,
|
||||
summarizeTunnelDeleteRuleFailures(failures),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
result.SuccessCount++
|
||||
result.DeletedForwardCount += singleResult.DeletedForwardCount
|
||||
result.MigratedCount += singleResult.MigratedCount
|
||||
result.PortAdjustedCount += singleResult.PortAdjustedCount
|
||||
if len(singleResult.Warnings) > 0 {
|
||||
result.Warnings = append(result.Warnings, singleResult.Warnings...)
|
||||
}
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
func (h *Handler) buildTunnelDeletePreview(tunnelID int64) (*tunnelDeletePreviewData, error) {
|
||||
if _, err := h.getTunnelRecord(tunnelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
samples := make([]tunnelDeleteForwardPreviewItem, 0, minInt(len(forwards), tunnelDeletePreviewSampleLimit))
|
||||
for i, forward := range forwards {
|
||||
if i >= tunnelDeletePreviewSampleLimit {
|
||||
break
|
||||
}
|
||||
ports, portsErr := h.listForwardPorts(forward.ID)
|
||||
if portsErr != nil {
|
||||
return nil, portsErr
|
||||
}
|
||||
inPort := 0
|
||||
if len(ports) > 0 {
|
||||
inPort = ports[0].Port
|
||||
}
|
||||
samples = append(samples, tunnelDeleteForwardPreviewItem{
|
||||
ID: forward.ID,
|
||||
Name: forward.Name,
|
||||
UserID: forward.UserID,
|
||||
UserName: forward.UserName,
|
||||
InPort: inPort,
|
||||
})
|
||||
}
|
||||
|
||||
return &tunnelDeletePreviewData{
|
||||
TunnelID: tunnelID,
|
||||
TunnelName: tunnelName,
|
||||
ForwardCount: len(forwards),
|
||||
SampleForwards: samples,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildTunnelBatchDeletePreview(ids []int64) (*tunnelBatchDeletePreviewData, error) {
|
||||
normalizedIDs := normalizeTunnelIDs(ids)
|
||||
items := make([]tunnelDeletePreviewData, 0, len(normalizedIDs))
|
||||
totalForwardCount := 0
|
||||
for _, id := range normalizedIDs {
|
||||
preview, err := h.buildTunnelDeletePreview(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *preview)
|
||||
totalForwardCount += preview.ForwardCount
|
||||
}
|
||||
return &tunnelBatchDeletePreviewData{
|
||||
TunnelCount: len(items),
|
||||
TotalForwardCount: totalForwardCount,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeTunnelDeleteAction(action string) (string, error) {
|
||||
normalized := strings.TrimSpace(action)
|
||||
if normalized == "" {
|
||||
return tunnelDeleteActionDeleteForwards, nil
|
||||
}
|
||||
if normalized != tunnelDeleteActionReplace && normalized != tunnelDeleteActionDeleteForwards {
|
||||
return "", errors.New("invalid tunnel delete action")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTunnelIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
|
||||
if len(failures) == 0 {
|
||||
return "未知错误"
|
||||
}
|
||||
parts := make([]string, 0, minInt(len(failures), 3))
|
||||
for i, failure := range failures {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
name := strings.TrimSpace(failure.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("规则 #%d", failure.ID)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", name, strings.TrimSpace(failure.Reason)))
|
||||
}
|
||||
if len(failures) > 3 {
|
||||
parts = append(parts, fmt.Sprintf("另有 %d 条规则失败", len(failures)-3))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
|
||||
preview, err := h.buildTunnelDeletePreview(tunnelID)
|
||||
if err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
|
||||
result := tunnelDeleteWithForwardsResult{ForwardCount: preview.ForwardCount}
|
||||
if preview.ForwardCount == 0 {
|
||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
if action == tunnelDeleteActionDeleteForwards {
|
||||
result.DeletedForwardCount = preview.ForwardCount
|
||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
if targetTunnelID <= 0 {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errInvalidTunnelDeleteTarget
|
||||
}
|
||||
if targetTunnelID == tunnelID {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不能与当前隧道相同")
|
||||
}
|
||||
return h.processTunnelDeleteReplaceAction(tunnelID, targetTunnelID, result)
|
||||
}
|
||||
|
||||
func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int64, result tunnelDeleteWithForwardsResult) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
|
||||
targetTunnel, err := h.getTunnelRecord(targetTunnelID)
|
||||
if err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不存在")
|
||||
}
|
||||
if targetTunnel.Status != 1 {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道已禁用")
|
||||
}
|
||||
|
||||
plans, failures, err := h.planTunnelDeleteForwardMigrations(tunnelID, targetTunnelID)
|
||||
if err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return tunnelDeleteWithForwardsResult{}, failures, nil
|
||||
}
|
||||
|
||||
portAdjustedCount := 0
|
||||
warnings, execErr, execFailure := h.executeTunnelDeleteForwardMigrations(plans)
|
||||
for _, plan := range plans {
|
||||
if plan.portAdjusted {
|
||||
portAdjustedCount++
|
||||
}
|
||||
}
|
||||
if execErr != nil {
|
||||
failures = append(failures, execFailure)
|
||||
return tunnelDeleteWithForwardsResult{}, failures, nil
|
||||
}
|
||||
|
||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||
h.rollbackTunnelForwardMigrationPlans(plans)
|
||||
_ = h.redeployTunnelAndForwards(tunnelID)
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
|
||||
result.MigratedCount = len(plans)
|
||||
result.PortAdjustedCount = portAdjustedCount
|
||||
if len(warnings) > 0 {
|
||||
result.Warnings = warnings
|
||||
}
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
func (h *Handler) planTunnelDeleteForwardMigrations(sourceTunnelID, targetTunnelID int64) ([]tunnelForwardMigrationPlan, []batchFailureDetail, error) {
|
||||
forwards, err := h.listForwardsByTunnel(sourceTunnelID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(targetTunnelID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(entryNodes) == 0 {
|
||||
return nil, nil, errors.New("目标隧道缺少入口节点")
|
||||
}
|
||||
|
||||
plans := make([]tunnelForwardMigrationPlan, 0, len(forwards))
|
||||
failures := make([]batchFailureDetail, 0)
|
||||
reservedPorts := make(map[int64]map[int]bool)
|
||||
|
||||
for _, forward := range forwards {
|
||||
plan, planErr := h.planSingleTunnelDeleteForwardMigration(&forward, targetTunnelID, entryNodes, reservedPorts)
|
||||
if planErr != nil {
|
||||
failures = appendBatchFailure(failures, forward.ID, forward.Name, planErr)
|
||||
continue
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
|
||||
return plans, failures, nil
|
||||
}
|
||||
|
||||
func (h *Handler) planSingleTunnelDeleteForwardMigration(forward *forwardRecord, targetTunnelID int64, targetEntryNodes []int64, reservedPorts map[int64]map[int]bool) (tunnelForwardMigrationPlan, error) {
|
||||
if forward == nil {
|
||||
return tunnelForwardMigrationPlan{}, errors.New("转发不存在")
|
||||
}
|
||||
|
||||
oldPorts, err := h.listForwardPorts(forward.ID)
|
||||
if err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if len(oldPorts) == 0 {
|
||||
return tunnelForwardMigrationPlan{}, errors.New("转发入口端口不存在")
|
||||
}
|
||||
|
||||
minPort := h.repo.GetMinForwardPort(forward.ID)
|
||||
targetPort := 0
|
||||
if minPort.Valid {
|
||||
targetPort = int(minPort.Int64)
|
||||
}
|
||||
if targetPort <= 0 {
|
||||
targetPort = h.pickTunnelPort(targetTunnelID)
|
||||
}
|
||||
if targetPort <= 0 {
|
||||
targetPort = 10000
|
||||
}
|
||||
|
||||
hasCustomInIP := false
|
||||
for _, oldPort := range oldPorts {
|
||||
if strings.TrimSpace(oldPort.InIP) != "" {
|
||||
hasCustomInIP = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasCustomInIP && len(targetEntryNodes) > 1 {
|
||||
return tunnelForwardMigrationPlan{}, errors.New("多入口隧道的转发不支持保留自定义监听IP,请先手动调整该规则")
|
||||
}
|
||||
|
||||
for _, nodeID := range targetEntryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
return tunnelForwardMigrationPlan{}, nodeErr
|
||||
}
|
||||
if err := validateRemoteNodePort(node, targetPort); err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if err := validateLocalNodePort(node, targetPort); err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, targetPort, forward.ID); err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if reservedOnNode, ok := reservedPorts[nodeID]; ok && reservedOnNode[targetPort] {
|
||||
return tunnelForwardMigrationPlan{}, fmt.Errorf("目标隧道入口节点端口 %d 已被本次迁移中的其他规则占用", targetPort)
|
||||
}
|
||||
}
|
||||
|
||||
for _, nodeID := range targetEntryNodes {
|
||||
reservedOnNode := reservedPorts[nodeID]
|
||||
if reservedOnNode == nil {
|
||||
reservedOnNode = make(map[int]bool)
|
||||
reservedPorts[nodeID] = reservedOnNode
|
||||
}
|
||||
reservedOnNode[targetPort] = true
|
||||
}
|
||||
|
||||
oldNodeIDs := forwardPortNodeIDs(oldPorts)
|
||||
newNodeIDs := uniqueInt64s(targetEntryNodes)
|
||||
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
|
||||
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
|
||||
|
||||
previousPort := 0
|
||||
if len(oldPorts) > 0 {
|
||||
previousPort = oldPorts[0].Port
|
||||
}
|
||||
|
||||
return tunnelForwardMigrationPlan{
|
||||
forward: forward,
|
||||
oldPorts: oldPorts,
|
||||
targetTunnelID: targetTunnelID,
|
||||
targetPort: targetPort,
|
||||
keptNodeIDs: keptNodeIDs,
|
||||
removedNodeIDs: removedNodeIDs,
|
||||
portAdjusted: previousPort > 0 && previousPort != targetPort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) executeTunnelDeleteForwardMigrations(plans []tunnelForwardMigrationPlan) ([]string, error, batchFailureDetail) {
|
||||
warnings := make([]string, 0)
|
||||
completed := make([]tunnelForwardMigrationPlan, 0, len(plans))
|
||||
|
||||
for _, plan := range plans {
|
||||
migrationWarnings, err := h.applyTunnelDeleteForwardMigration(plan)
|
||||
if err != nil {
|
||||
h.rollbackTunnelForwardMigrationPlans(completed)
|
||||
return warnings, err, batchFailureDetail{ID: plan.forward.ID, Name: plan.forward.Name, Reason: normalizeBatchFailureReason(errString(err))}
|
||||
}
|
||||
warnings = append(warnings, migrationWarnings...)
|
||||
completed = append(completed, plan)
|
||||
}
|
||||
|
||||
return warnings, nil, batchFailureDetail{}
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelDeleteForwardMigration(plan tunnelForwardMigrationPlan) ([]string, error) {
|
||||
if plan.forward == nil {
|
||||
return nil, errors.New("转发不存在")
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateForwardTunnel(plan.forward.ID, plan.targetTunnelID, time.Now().UnixMilli()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.replaceForwardPorts(plan.forward.ID, plan.targetTunnelID, plan.targetPort, ""); err != nil {
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedForward, err := h.getForwardRecord(plan.forward.ID)
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
warnings := make([]string, 0)
|
||||
if len(plan.keptNodeIDs) > 0 {
|
||||
for _, nodeID := range plan.keptNodeIDs {
|
||||
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
|
||||
nodeLabel := fmt.Sprintf("%d", nodeID)
|
||||
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
|
||||
nodeLabel = strings.TrimSpace(n.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧转发监听失败: %v", nodeLabel, delErr))
|
||||
}
|
||||
}
|
||||
time.Sleep(tunnelServiceBindRetryDelay)
|
||||
}
|
||||
|
||||
syncWarnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
return nil, err
|
||||
}
|
||||
warnings = append(warnings, syncWarnings...)
|
||||
|
||||
if len(plan.removedNodeIDs) > 0 {
|
||||
for _, nodeID := range plan.removedNodeIDs {
|
||||
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
|
||||
nodeLabel := fmt.Sprintf("%d", nodeID)
|
||||
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
|
||||
nodeLabel = strings.TrimSpace(n.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧隧道残留服务失败: %v", nodeLabel, delErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigrationPlan) {
|
||||
for i := len(plans) - 1; i >= 0; i-- {
|
||||
plan := plans[i]
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
if err := h.deleteTunnelByID(tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestValidateTunnelEntryPortConflictsForNewEntriesDoesNotBlockOnSQLiteTx(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, port, created_time, status, tcp_listen_addr, udp_listen_addr, is_remote)
|
||||
VALUES
|
||||
('entry-old', 'secret-old', '10.0.0.1', '12000-12010', ?, 1, '[::]', '[::]', 0),
|
||||
('entry-new', 'secret-new', '10.0.0.2', '12000-12010', ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert nodes: %v", err)
|
||||
}
|
||||
var oldEntryID, newEntryID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-old'`).Scan(&oldEntryID).Error; err != nil {
|
||||
t.Fatalf("load old entry id: %v", err)
|
||||
}
|
||||
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-new'`).Scan(&newEntryID).Error; err != nil {
|
||||
t.Fatalf("load new entry id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference)
|
||||
VALUES('sqlite-tunnel', 1, 1, 'tls', 1, ?, ?, 1, 1, '')
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
var tunnelID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = 'sqlite-tunnel'`).Scan(&tunnelID).Error; err != nil {
|
||||
t.Fatalf("load tunnel id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, inx, protocol)
|
||||
VALUES(?, '1', ?, 1, 'tls')
|
||||
`, tunnelID, oldEntryID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, created_time, updated_time, status, inx)
|
||||
VALUES(1, 'tester', 'forward-a', ?, '127.0.0.1:8080', 'fifo', ?, ?, 1, 1)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
var forwardID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = 'forward-a'`).Scan(&forwardID).Error; err != nil {
|
||||
t.Fatalf("load forward id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward_port(forward_id, node_id, port)
|
||||
VALUES(?, ?, 12001)
|
||||
`, forwardID, oldEntryID).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
tx := r.BeginTx()
|
||||
if tx == nil {
|
||||
t.Fatal("begin tx: nil transaction")
|
||||
}
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin tx: %v", tx.Error)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneCh)
|
||||
errCh <- h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, tunnelID, []int64{oldEntryID}, []int64{oldEntryID, newEntryID})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
_ = tx.Rollback().Error
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
_ = tx.Rollback().Error
|
||||
<-doneCh
|
||||
t.Fatal("validation blocked while transaction was open on sqlite")
|
||||
}
|
||||
|
||||
if err := tx.Rollback().Error; err != nil {
|
||||
t.Fatalf("rollback tx: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
type tunnelTrafficDelta struct {
|
||||
bytesIn int64
|
||||
bytesOut int64
|
||||
}
|
||||
|
||||
func unixMilliBucketMinute(nowMs int64) int64 {
|
||||
if nowMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
const minuteMs = int64(time.Minute / time.Millisecond)
|
||||
return nowMs - (nowMs % minuteMs)
|
||||
}
|
||||
|
||||
func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowItem, nowMs int64) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
if nodeID <= 0 || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
bucketTs := unixMilliBucketMinute(nowMs)
|
||||
if bucketTs <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
||||
var skippedParse, skippedZero int
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.N)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
||||
if !ok {
|
||||
skippedParse++
|
||||
continue
|
||||
}
|
||||
if item.D == 0 && item.U == 0 {
|
||||
skippedZero++
|
||||
continue
|
||||
}
|
||||
d := forwardDeltas[forwardID]
|
||||
d.bytesIn += item.D
|
||||
d.bytesOut += item.U
|
||||
forwardDeltas[forwardID] = d
|
||||
}
|
||||
if len(forwardDeltas) == 0 {
|
||||
if len(items) > 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_forward_deltas node_id=%d items=%d skipped_parse=%d skipped_zero=%d", nodeID, len(items), skippedParse, skippedZero)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
forwardIDs := make([]int64, 0, len(forwardDeltas))
|
||||
for id := range forwardDeltas {
|
||||
forwardIDs = append(forwardIDs, id)
|
||||
}
|
||||
|
||||
forwardTunnelMap, err := h.repo.MapForwardIDsToTunnelIDs(forwardIDs)
|
||||
if err != nil {
|
||||
log.Printf("monitoring write skipped op=tunnel_metric.map_forward_to_tunnel node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
if len(forwardTunnelMap) == 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_tunnel_map node_id=%d forward_ids=%v", nodeID, forwardIDs)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelAgg := make(map[int64]tunnelTrafficDelta)
|
||||
for forwardID, delta := range forwardDeltas {
|
||||
tunnelID := forwardTunnelMap[forwardID]
|
||||
if tunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
a := tunnelAgg[tunnelID]
|
||||
a.bytesIn += delta.bytesIn
|
||||
a.bytesOut += delta.bytesOut
|
||||
tunnelAgg[tunnelID] = a
|
||||
}
|
||||
if len(tunnelAgg) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
metrics := make([]*model.TunnelMetric, 0, len(tunnelAgg))
|
||||
for tunnelID, delta := range tunnelAgg {
|
||||
if delta.bytesIn == 0 && delta.bytesOut == 0 {
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, &model.TunnelMetric{
|
||||
TunnelID: tunnelID,
|
||||
NodeID: nodeID,
|
||||
Timestamp: bucketTs,
|
||||
BytesIn: delta.bytesIn,
|
||||
BytesOut: delta.bytesOut,
|
||||
Connections: 0,
|
||||
Errors: 0,
|
||||
AvgLatencyMs: 0,
|
||||
})
|
||||
}
|
||||
if len(metrics) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
||||
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
|
||||
} else {
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelQualityProbeInterval = 1 * time.Second
|
||||
tunnelQualityProbeTimeout = 8 * time.Second
|
||||
tunnelQualityPingTimeoutMs = 5000
|
||||
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
|
||||
tunnelQualityPruneInterval = 10 * time.Minute
|
||||
tunnelQualityReportInterval = 30 * time.Second // DB save interval
|
||||
)
|
||||
|
||||
type TunnelQualityHop struct {
|
||||
FromNodeID int64 `json:"fromNodeId"`
|
||||
FromNodeName string `json:"fromNodeName"`
|
||||
ToNodeID int64 `json:"toNodeId"`
|
||||
ToNodeName string `json:"toNodeName"`
|
||||
Latency float64 `json:"latency"`
|
||||
Loss float64 `json:"loss"`
|
||||
TargetIP string `json:"targetIp,omitempty"`
|
||||
TargetPort int `json:"targetPort,omitempty"`
|
||||
}
|
||||
|
||||
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
|
||||
type tunnelQualitySnapshot struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
EntryToExitLatency float64 `json:"entryToExitLatency"`
|
||||
ExitToBingLatency float64 `json:"exitToBingLatency"`
|
||||
EntryToExitLoss float64 `json:"entryToExitLoss"`
|
||||
ExitToBingLoss float64 `json:"exitToBingLoss"`
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ChainDetails string `json:"chainDetails,omitempty"`
|
||||
|
||||
// internal fields for db reporting
|
||||
lastDBWrite int64 `json:"-"`
|
||||
}
|
||||
|
||||
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
|
||||
// Design mirrors health.Checker: background goroutine with worker pool + scheduled cleanup.
|
||||
type tunnelQualityProber struct {
|
||||
handler *Handler
|
||||
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
interval time.Duration
|
||||
lastPrune int64
|
||||
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
|
||||
}
|
||||
|
||||
// newTunnelQualityProber creates a new prober (not yet running).
|
||||
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
|
||||
return &tunnelQualityProber{
|
||||
handler: h,
|
||||
interval: tunnelQualityProbeInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the background probe loop (call from jobs.go).
|
||||
func (p *tunnelQualityProber) Start(ctx context.Context) {
|
||||
// Use the provided context so we stop with other background jobs.
|
||||
p.ctx, p.cancel = context.WithCancel(ctx)
|
||||
p.loop()
|
||||
}
|
||||
|
||||
// Stop halts the background probe loop.
|
||||
func (p *tunnelQualityProber) Stop() {
|
||||
if p == nil || p.cancel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
// GetAll returns all cached quality snapshots (latest per tunnel).
|
||||
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
|
||||
var items []tunnelQualitySnapshot
|
||||
p.cache.Range(func(_, value interface{}) bool {
|
||||
if snap, ok := value.(*tunnelQualitySnapshot); ok {
|
||||
items = append(items, *snap)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) loop() {
|
||||
// Initial delay to let the system boot up
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
// Run once immediately
|
||||
p.probeAll()
|
||||
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.probeAll()
|
||||
p.maybePrune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) isEnabled() bool {
|
||||
if p == nil || p.handler == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return p.handler.isTunnelQualityMonitoringEnabled()
|
||||
}
|
||||
|
||||
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
|
||||
func (p *tunnelQualityProber) maybePrune() {
|
||||
if !p.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
|
||||
return
|
||||
}
|
||||
p.lastPrune = now
|
||||
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
|
||||
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
|
||||
log.Printf("tunnel_quality_prober: prune err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeAll() {
|
||||
if !p.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if previous probe round is still running (interval < timeout guard)
|
||||
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&p.probing, 0)
|
||||
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDs, err := h.repo.ListEnabledTunnelIDs()
|
||||
if err != nil {
|
||||
log.Printf("tunnel_quality_prober: list enabled tunnels err=%v", err)
|
||||
return
|
||||
}
|
||||
if len(tunnelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Probe tunnels concurrently with a worker limit
|
||||
// (mirrors health.Checker worker pool pattern)
|
||||
const maxWorkers = 20
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(tid int64) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
p.probeTunnel(tid)
|
||||
}(tunnelID)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snap := &tunnelQualitySnapshot{
|
||||
TunnelID: tunnelID,
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
// Get tunnel chain info
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
snap.ErrorMessage = "隧道不存在"
|
||||
p.storeResult(snap)
|
||||
return
|
||||
}
|
||||
|
||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil || len(chainRows) == 0 {
|
||||
snap.ErrorMessage = "隧道配置不完整"
|
||||
p.storeResult(snap)
|
||||
return
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||
inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows)
|
||||
|
||||
options := diagnosisExecOptions{
|
||||
commandTimeout: tunnelQualityProbeTimeout,
|
||||
pingTimeoutMS: tunnelQualityPingTimeoutMs,
|
||||
timeoutMessage: "探测超时",
|
||||
}
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
// Port forwarding: entry → Bing only
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
snap.Success = true
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
// Tunnel forwarding: entry → exit + exit → Bing
|
||||
probeOK := true
|
||||
|
||||
if len(inNodes) > 0 && len(outNodes) > 0 {
|
||||
var hops []TunnelQualityHop
|
||||
var totalLat float64
|
||||
remainingSuccessProb := 1.0
|
||||
|
||||
nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped))
|
||||
nodesInPath = append(nodesInPath, inNodes[0])
|
||||
for _, midGroup := range midNodesGrouped {
|
||||
if len(midGroup) > 0 {
|
||||
nodesInPath = append(nodesInPath, midGroup[0])
|
||||
}
|
||||
}
|
||||
nodesInPath = append(nodesInPath, outNodes[0])
|
||||
|
||||
for i := 0; i < len(nodesInPath)-1; i++ {
|
||||
source := nodesInPath[i]
|
||||
target := nodesInPath[i+1]
|
||||
|
||||
hop := TunnelQualityHop{
|
||||
FromNodeID: source.NodeID,
|
||||
FromNodeName: source.NodeName,
|
||||
ToNodeID: target.NodeID,
|
||||
ToNodeName: target.NodeName,
|
||||
}
|
||||
|
||||
targetNode, nodeErr := h.getNodeRecord(target.NodeID)
|
||||
if nodeErr != nil || targetNode == nil {
|
||||
snap.ErrorMessage = "节点 " + target.NodeName + " 不可用"
|
||||
probeOK = false
|
||||
hop.Latency = -1
|
||||
hop.Loss = 100
|
||||
hops = append(hops, hop)
|
||||
break
|
||||
}
|
||||
|
||||
fromNode, _ := h.getNodeRecord(source.NodeID)
|
||||
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, target.Port, ipPreference, target.ConnectIP)
|
||||
if resolveErr != nil {
|
||||
snap.ErrorMessage = "解析节点 " + target.NodeName + " 失败: " + resolveErr.Error()
|
||||
probeOK = false
|
||||
hop.Latency = -1
|
||||
hop.Loss = 100
|
||||
hops = append(hops, hop)
|
||||
break
|
||||
}
|
||||
|
||||
hop.TargetIP = targetIP
|
||||
hop.TargetPort = targetPort
|
||||
|
||||
lat, loss, err := p.tcpPingNode(source.NodeID, targetIP, targetPort, options)
|
||||
if err == nil {
|
||||
hop.Latency = lat
|
||||
hop.Loss = loss
|
||||
totalLat += lat
|
||||
remainingSuccessProb *= (1.0 - loss/100.0)
|
||||
hops = append(hops, hop)
|
||||
} else {
|
||||
probeOK = false
|
||||
hop.Latency = -1
|
||||
hop.Loss = 100
|
||||
hops = append(hops, hop)
|
||||
if snap.ErrorMessage == "" {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if probeOK {
|
||||
snap.EntryToExitLatency = totalLat
|
||||
snap.EntryToExitLoss = (1.0 - remainingSuccessProb) * 100.0
|
||||
} else {
|
||||
snap.EntryToExitLatency = -1
|
||||
snap.EntryToExitLoss = 100
|
||||
}
|
||||
|
||||
if len(hops) > 0 {
|
||||
if b, err := json.Marshal(hops); err == nil {
|
||||
snap.ChainDetails = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit → Bing
|
||||
if len(outNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(outNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
} else {
|
||||
if snap.ErrorMessage == "" {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
probeOK = false
|
||||
}
|
||||
}
|
||||
|
||||
snap.Success = probeOK
|
||||
default:
|
||||
// Unknown type: entry → Bing
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
snap.Success = true
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.storeResult(snap)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
|
||||
h := p.handler
|
||||
if h == nil {
|
||||
return 0, 100, nil
|
||||
}
|
||||
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
return 0, 100, nodeErr
|
||||
}
|
||||
|
||||
var pingData map[string]interface{}
|
||||
var pingErr error
|
||||
if node != nil && node.IsRemote == 1 {
|
||||
pingData, pingErr = h.tcpPingViaRemoteNode(node, ip, port, options)
|
||||
} else {
|
||||
pingData, pingErr = h.tcpPingViaNode(nodeID, ip, port, options)
|
||||
}
|
||||
if pingErr != nil {
|
||||
return 0, 100, pingErr
|
||||
}
|
||||
|
||||
avgTime := asFloat(pingData["averageTime"], 0)
|
||||
packetLoss := asFloat(pingData["packetLoss"], 100)
|
||||
|
||||
return avgTime, packetLoss, nil
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
|
||||
if snap == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Update in-memory cache (latest per tunnel)
|
||||
// Retain the lastDBWrite timestamp if it exists, so we only DB write every 30s
|
||||
var lastWrite int64
|
||||
if existing, ok := p.cache.Load(snap.TunnelID); ok {
|
||||
if eg, ok := existing.(*tunnelQualitySnapshot); ok {
|
||||
lastWrite = eg.lastDBWrite
|
||||
}
|
||||
}
|
||||
snap.lastDBWrite = lastWrite
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
writeToDB := false
|
||||
if now-snap.lastDBWrite >= int64(tunnelQualityReportInterval/time.Millisecond) {
|
||||
writeToDB = true
|
||||
snap.lastDBWrite = now
|
||||
}
|
||||
|
||||
p.cache.Store(snap.TunnelID, snap)
|
||||
|
||||
if !writeToDB {
|
||||
return
|
||||
}
|
||||
|
||||
// Persist to database (history)
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
successInt := 0
|
||||
if snap.Success {
|
||||
successInt = 1
|
||||
}
|
||||
|
||||
q := &model.TunnelQuality{
|
||||
TunnelID: snap.TunnelID,
|
||||
EntryToExitLatency: snap.EntryToExitLatency,
|
||||
ExitToBingLatency: snap.ExitToBingLatency,
|
||||
EntryToExitLoss: snap.EntryToExitLoss,
|
||||
ExitToBingLoss: snap.ExitToBingLoss,
|
||||
Success: successInt,
|
||||
ErrorMessage: snap.ErrorMessage,
|
||||
Timestamp: snap.Timestamp,
|
||||
ChainDetails: snap.ChainDetails,
|
||||
}
|
||||
if err := h.repo.InsertTunnelQuality(q); err != nil {
|
||||
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
const (
|
||||
githubRepo = "Sagit-chu/flvx"
|
||||
githubProxy = "https://gcode.hostcentral.cc"
|
||||
githubAPIBase = "https://api.github.com"
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
@@ -23,6 +22,9 @@ const (
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
|
||||
defaultGithubProxyEnabled = true
|
||||
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -70,6 +72,39 @@ func releaseChannelLabel(channel string) string {
|
||||
return "正式版"
|
||||
}
|
||||
|
||||
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
|
||||
enabled = defaultGithubProxyEnabled
|
||||
proxyURL = defaultGithubProxyURL
|
||||
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
|
||||
enabled = enabledCfg.Value != "false"
|
||||
}
|
||||
|
||||
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
|
||||
proxyURL = strings.TrimSpace(urlCfg.Value)
|
||||
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
|
||||
proxyURL = "https://" + proxyURL
|
||||
}
|
||||
proxyURL = strings.TrimSuffix(proxyURL, "/")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
|
||||
|
||||
if enabled {
|
||||
return fmt.Sprintf("%s/%s", proxyURL, base)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
@@ -149,14 +184,8 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
||||
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
||||
|
||||
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
|
||||
"downloadUrl": downloadURL,
|
||||
@@ -213,14 +242,8 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
||||
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
||||
|
||||
type upgradeResult struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -101,6 +101,10 @@ func shouldSkip(path string) bool {
|
||||
}
|
||||
|
||||
func requiresAdmin(path string) bool {
|
||||
if strings.HasPrefix(path, "/api/v1/monitor/permission/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/group/") {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KeygenClient struct {
|
||||
AccountID string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewKeygenClient(accountID, token string) *KeygenClient {
|
||||
return &KeygenClient{
|
||||
AccountID: accountID,
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateResponse struct {
|
||||
Meta struct {
|
||||
Valid bool `json:"valid"`
|
||||
Code string `json:"code"`
|
||||
} `json:"meta"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Attributes struct {
|
||||
Expiry string `json:"expiry"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ActivateMachineRequest struct {
|
||||
Data struct {
|
||||
Type string `json:"type"`
|
||||
Attributes struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
} `json:"attributes"`
|
||||
Relationships struct {
|
||||
License struct {
|
||||
Data struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
} `json:"license"`
|
||||
} `json:"relationships"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
|
||||
|
||||
meta := map[string]interface{}{
|
||||
"key": key,
|
||||
}
|
||||
|
||||
if fingerprint != "" {
|
||||
meta["scope"] = map[string]interface{}{
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.Token != "" {
|
||||
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
|
||||
req.Header.Set("Authorization", "License "+c.Token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var valResp ValidateResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &valResp, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"meta": map[string]string{
|
||||
"key": key,
|
||||
},
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.Token != "" {
|
||||
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
|
||||
req.Header.Set("Authorization", "License "+c.Token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var valResp ValidateResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &valResp, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
|
||||
|
||||
var reqBody ActivateMachineRequest
|
||||
reqBody.Data.Type = "machines"
|
||||
reqBody.Data.Attributes.Fingerprint = fingerprint
|
||||
reqBody.Data.Relationships.License.Data.Type = "licenses"
|
||||
reqBody.Data.Relationships.License.Data.ID = licenseID
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.Token != "" {
|
||||
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
|
||||
req.Header.Set("Authorization", "License "+c.Token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") {
|
||||
// Machine already registered to this license or limit reached because it's already us.
|
||||
// The subsequent ValidateKey check will determine if the existing machine is actually us.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type SystemInfo struct {
|
||||
Uptime uint64 `json:"uptime"`
|
||||
BytesReceived uint64 `json:"bytes_received"`
|
||||
BytesTransmitted uint64 `json:"bytes_transmitted"`
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
TCPConns int64 `json:"tcp_conns"`
|
||||
UDPConns int64 `json:"udp_conns"`
|
||||
NetInSpeed int64 `json:"net_in_speed"`
|
||||
NetOutSpeed int64 `json:"net_out_speed"`
|
||||
}
|
||||
|
||||
type IngestionService struct {
|
||||
repo *repo.Repository
|
||||
nodeBuffer []*model.NodeMetric
|
||||
nodeBufferMu sync.Mutex
|
||||
flushInterval time.Duration
|
||||
retentionDays int
|
||||
}
|
||||
|
||||
func NewIngestionService(repo *repo.Repository) *IngestionService {
|
||||
return &IngestionService{
|
||||
repo: repo,
|
||||
nodeBuffer: make([]*model.NodeMetric, 0, 500),
|
||||
flushInterval: 30 * time.Second,
|
||||
retentionDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IngestionService) Start(ctx context.Context) {
|
||||
flushTicker := time.NewTicker(s.flushInterval)
|
||||
defer flushTicker.Stop()
|
||||
|
||||
pruneTicker := time.NewTicker(1 * time.Hour)
|
||||
defer pruneTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.flushNodeMetrics()
|
||||
return
|
||||
case <-flushTicker.C:
|
||||
s.flushNodeMetrics()
|
||||
case <-pruneTicker.C:
|
||||
s.pruneMetrics()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IngestionService) RecordNodeMetric(nodeID int64, info SystemInfo) {
|
||||
m := &model.NodeMetric{
|
||||
NodeID: nodeID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
CPUUsage: info.CPUUsage,
|
||||
MemUsage: info.MemoryUsage,
|
||||
DiskUsage: info.DiskUsage,
|
||||
NetInBytes: int64(info.BytesReceived),
|
||||
NetOutBytes: int64(info.BytesTransmitted),
|
||||
NetInSpeed: info.NetInSpeed,
|
||||
NetOutSpeed: info.NetOutSpeed,
|
||||
Load1: info.Load1,
|
||||
Load5: info.Load5,
|
||||
Load15: info.Load15,
|
||||
TCPConns: info.TCPConns,
|
||||
UDPConns: info.UDPConns,
|
||||
Uptime: int64(info.Uptime),
|
||||
}
|
||||
|
||||
s.nodeBufferMu.Lock()
|
||||
s.nodeBuffer = append(s.nodeBuffer, m)
|
||||
shouldFlush := len(s.nodeBuffer) >= 200
|
||||
s.nodeBufferMu.Unlock()
|
||||
|
||||
if shouldFlush {
|
||||
go s.flushNodeMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IngestionService) flushNodeMetrics() {
|
||||
s.nodeBufferMu.Lock()
|
||||
if len(s.nodeBuffer) == 0 {
|
||||
s.nodeBufferMu.Unlock()
|
||||
return
|
||||
}
|
||||
buffer := s.nodeBuffer
|
||||
s.nodeBuffer = make([]*model.NodeMetric, 0, 500)
|
||||
s.nodeBufferMu.Unlock()
|
||||
|
||||
if s.repo == nil {
|
||||
return
|
||||
}
|
||||
if err := s.repo.InsertNodeMetricBatch(buffer); err != nil {
|
||||
log.Printf("monitoring write failed op=node_metric.flush count=%d err=%v", len(buffer), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetrics() {
|
||||
cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli()
|
||||
if s.repo == nil {
|
||||
return
|
||||
}
|
||||
if err := s.repo.PruneNodeMetrics(cutoff); err != nil {
|
||||
log.Printf("monitoring prune failed op=node_metric cutoff=%d err=%v", cutoff, err)
|
||||
}
|
||||
if err := s.repo.PruneTunnelMetrics(cutoff); err != nil {
|
||||
log.Printf("monitoring prune failed op=tunnel_metric cutoff=%d err=%v", cutoff, err)
|
||||
}
|
||||
if err := s.repo.PruneServiceMonitorResults(cutoff); err != nil {
|
||||
log.Printf("monitoring prune failed op=service_monitor_result cutoff=%d err=%v", cutoff, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IngestionService) GetLatestMetric(nodeID int64) (*model.NodeMetric, error) {
|
||||
return s.repo.GetLatestNodeMetric(nodeID)
|
||||
}
|
||||
|
||||
func (s *IngestionService) GetMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) {
|
||||
return s.repo.GetNodeMetrics(nodeID, startMs, endMs)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestRecordNodeMetric(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
|
||||
info := SystemInfo{
|
||||
Uptime: 86400,
|
||||
BytesReceived: 1024000,
|
||||
BytesTransmitted: 2048000,
|
||||
CPUUsage: 45.5,
|
||||
MemoryUsage: 60.2,
|
||||
DiskUsage: 30.1,
|
||||
Load1: 1.5,
|
||||
Load5: 1.2,
|
||||
Load15: 0.9,
|
||||
TCPConns: 100,
|
||||
UDPConns: 50,
|
||||
NetInSpeed: 51200,
|
||||
NetOutSpeed: 102400,
|
||||
}
|
||||
|
||||
svc.RecordNodeMetric(1, info)
|
||||
svc.flushNodeMetrics()
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric, got %d", len(metrics))
|
||||
}
|
||||
|
||||
m := metrics[0]
|
||||
if m.CPUUsage != 45.5 {
|
||||
t.Fatalf("expected CPUUsage 45.5, got %f", m.CPUUsage)
|
||||
}
|
||||
if m.MemUsage != 60.2 {
|
||||
t.Fatalf("expected MemUsage 60.2, got %f", m.MemUsage)
|
||||
}
|
||||
if m.DiskUsage != 30.1 {
|
||||
t.Fatalf("expected DiskUsage 30.1, got %f", m.DiskUsage)
|
||||
}
|
||||
if m.Load1 != 1.5 {
|
||||
t.Fatalf("expected Load1 1.5, got %f", m.Load1)
|
||||
}
|
||||
if m.TCPConns != 100 {
|
||||
t.Fatalf("expected TCPConns 100, got %d", m.TCPConns)
|
||||
}
|
||||
if m.UDPConns != 50 {
|
||||
t.Fatalf("expected UDPConns 50, got %d", m.UDPConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordNodeMetricAutoFlush(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
|
||||
info := SystemInfo{
|
||||
CPUUsage: 50.0,
|
||||
MemoryUsage: 60.0,
|
||||
DiskUsage: 30.0,
|
||||
}
|
||||
|
||||
for i := 0; i < 250; i++ {
|
||||
svc.RecordNodeMetric(1, info)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
if len(metrics) < 200 {
|
||||
t.Fatalf("expected at least 200 metrics after auto-flush, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestionServiceStart(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
svc.flushInterval = 100 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
info := SystemInfo{
|
||||
CPUUsage: 45.0,
|
||||
MemoryUsage: 55.0,
|
||||
DiskUsage: 35.0,
|
||||
}
|
||||
|
||||
go svc.Start(ctx)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
svc.RecordNodeMetric(1, info)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
if len(metrics) == 0 {
|
||||
t.Fatalf("expected metrics after service run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestMetric(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
info1 := SystemInfo{CPUUsage: 40.0, MemoryUsage: 50.0, DiskUsage: 30.0}
|
||||
svc.RecordNodeMetric(1, info1)
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
info2 := SystemInfo{CPUUsage: 60.0, MemoryUsage: 70.0, DiskUsage: 40.0}
|
||||
svc.RecordNodeMetric(1, info2)
|
||||
|
||||
svc.flushNodeMetrics()
|
||||
|
||||
latest, err := svc.GetLatestMetric(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get latest: %v", err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatalf("expected latest metric")
|
||||
}
|
||||
if latest.CPUUsage != 60.0 {
|
||||
t.Fatalf("expected latest CPUUsage 60.0, got %f", latest.CPUUsage)
|
||||
}
|
||||
|
||||
_ = now
|
||||
|
||||
latestNone, err := svc.GetLatestMetric(999)
|
||||
if err != nil {
|
||||
t.Fatalf("get latest for non-existent: %v", err)
|
||||
}
|
||||
if latestNone != nil {
|
||||
t.Fatalf("expected nil for non-existent node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetricsWithTimeRange(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
info := SystemInfo{
|
||||
CPUUsage: float64(40 + i*5),
|
||||
MemoryUsage: 50.0,
|
||||
DiskUsage: 30.0,
|
||||
}
|
||||
svc.RecordNodeMetric(1, info)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
svc.flushNodeMetrics()
|
||||
|
||||
metrics, err := svc.GetMetrics(1, now-60000, now+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 5 {
|
||||
t.Fatalf("expected 5 metrics, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneMetrics(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
svc.retentionDays = 1
|
||||
|
||||
info := SystemInfo{CPUUsage: 50.0, MemoryUsage: 60.0, DiskUsage: 30.0}
|
||||
|
||||
svc.RecordNodeMetric(1, info)
|
||||
svc.flushNodeMetrics()
|
||||
|
||||
svc.pruneMetrics()
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric (not pruned), got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleNodes(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
|
||||
info := SystemInfo{
|
||||
CPUUsage: 50.0,
|
||||
MemoryUsage: 60.0,
|
||||
DiskUsage: 30.0,
|
||||
}
|
||||
|
||||
svc.RecordNodeMetric(1, info)
|
||||
svc.RecordNodeMetric(2, info)
|
||||
svc.RecordNodeMetric(3, info)
|
||||
|
||||
svc.flushNodeMetrics()
|
||||
|
||||
for nodeID := int64(1); nodeID <= 3; nodeID++ {
|
||||
metrics, err := r.GetNodeMetrics(nodeID, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics for node %d: %v", nodeID, err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric for node %d, got %d", nodeID, len(metrics))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroValues(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
|
||||
info := SystemInfo{}
|
||||
|
||||
svc.RecordNodeMetric(1, info)
|
||||
svc.flushNodeMetrics()
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric, got %d", len(metrics))
|
||||
}
|
||||
|
||||
m := metrics[0]
|
||||
if m.CPUUsage != 0 || m.MemUsage != 0 || m.DiskUsage != 0 {
|
||||
t.Fatalf("expected zero values, got CPU=%f Mem=%f Disk=%f", m.CPUUsage, m.MemUsage, m.DiskUsage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ServiceMonitorLimits struct {
|
||||
CheckerScanIntervalSec int `json:"checkerScanIntervalSec"`
|
||||
WorkerLimit int `json:"workerLimit"`
|
||||
|
||||
MinIntervalSec int `json:"minIntervalSec"`
|
||||
DefaultIntervalSec int `json:"defaultIntervalSec"`
|
||||
|
||||
MinTimeoutSec int `json:"minTimeoutSec"`
|
||||
DefaultTimeoutSec int `json:"defaultTimeoutSec"`
|
||||
MaxTimeoutSec int `json:"maxTimeoutSec"`
|
||||
}
|
||||
|
||||
const (
|
||||
ConfigServiceMonitorCheckerScanIntervalSec = "service_monitor_checker_scan_interval_sec"
|
||||
ConfigServiceMonitorWorkerLimit = "service_monitor_worker_limit"
|
||||
ConfigServiceMonitorMinIntervalSec = "service_monitor_min_interval_sec"
|
||||
ConfigServiceMonitorDefaultIntervalSec = "service_monitor_default_interval_sec"
|
||||
ConfigServiceMonitorMinTimeoutSec = "service_monitor_min_timeout_sec"
|
||||
ConfigServiceMonitorDefaultTimeoutSec = "service_monitor_default_timeout_sec"
|
||||
ConfigServiceMonitorMaxTimeoutSec = "service_monitor_max_timeout_sec"
|
||||
)
|
||||
|
||||
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
|
||||
return ServiceMonitorLimits{
|
||||
CheckerScanIntervalSec: 1,
|
||||
WorkerLimit: 20,
|
||||
MinIntervalSec: 1,
|
||||
DefaultIntervalSec: 1,
|
||||
MinTimeoutSec: 1,
|
||||
DefaultTimeoutSec: 5,
|
||||
MaxTimeoutSec: 60,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceMonitorLimitsFromConfigMap parses limits from vite_config values.
|
||||
// Missing/invalid values fall back to defaults.
|
||||
func ServiceMonitorLimitsFromConfigMap(cfg map[string]string) ServiceMonitorLimits {
|
||||
limits := DefaultServiceMonitorLimits()
|
||||
if cfg == nil {
|
||||
return limits
|
||||
}
|
||||
|
||||
limits.CheckerScanIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorCheckerScanIntervalSec, limits.CheckerScanIntervalSec)
|
||||
limits.WorkerLimit = parseConfigInt(cfg, ConfigServiceMonitorWorkerLimit, limits.WorkerLimit)
|
||||
limits.MinIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorMinIntervalSec, limits.MinIntervalSec)
|
||||
limits.DefaultIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultIntervalSec, limits.DefaultIntervalSec)
|
||||
limits.MinTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMinTimeoutSec, limits.MinTimeoutSec)
|
||||
limits.DefaultTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultTimeoutSec, limits.DefaultTimeoutSec)
|
||||
limits.MaxTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMaxTimeoutSec, limits.MaxTimeoutSec)
|
||||
|
||||
return normalizeServiceMonitorLimits(limits)
|
||||
}
|
||||
|
||||
func normalizeServiceMonitorLimits(limits ServiceMonitorLimits) ServiceMonitorLimits {
|
||||
if limits.CheckerScanIntervalSec <= 0 {
|
||||
limits.CheckerScanIntervalSec = 30
|
||||
}
|
||||
if limits.WorkerLimit <= 0 {
|
||||
limits.WorkerLimit = 5
|
||||
}
|
||||
if limits.WorkerLimit > 50 {
|
||||
limits.WorkerLimit = 50
|
||||
}
|
||||
|
||||
if limits.MinIntervalSec <= 0 {
|
||||
limits.MinIntervalSec = limits.CheckerScanIntervalSec
|
||||
}
|
||||
if limits.MinIntervalSec < limits.CheckerScanIntervalSec {
|
||||
limits.MinIntervalSec = limits.CheckerScanIntervalSec
|
||||
}
|
||||
if limits.DefaultIntervalSec <= 0 {
|
||||
limits.DefaultIntervalSec = 60
|
||||
}
|
||||
if limits.DefaultIntervalSec < limits.MinIntervalSec {
|
||||
limits.DefaultIntervalSec = limits.MinIntervalSec
|
||||
}
|
||||
|
||||
if limits.MinTimeoutSec <= 0 {
|
||||
limits.MinTimeoutSec = 1
|
||||
}
|
||||
if limits.DefaultTimeoutSec <= 0 {
|
||||
limits.DefaultTimeoutSec = 5
|
||||
}
|
||||
if limits.DefaultTimeoutSec < limits.MinTimeoutSec {
|
||||
limits.DefaultTimeoutSec = limits.MinTimeoutSec
|
||||
}
|
||||
if limits.MaxTimeoutSec <= 0 {
|
||||
limits.MaxTimeoutSec = 60
|
||||
}
|
||||
if limits.MaxTimeoutSec < limits.DefaultTimeoutSec {
|
||||
limits.MaxTimeoutSec = limits.DefaultTimeoutSec
|
||||
}
|
||||
|
||||
return limits
|
||||
}
|
||||
|
||||
func parseConfigInt(cfg map[string]string, key string, fallback int) int {
|
||||
v := strings.TrimSpace(cfg[key])
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -235,6 +235,16 @@ type GroupPermissionGrant struct {
|
||||
|
||||
func (GroupPermissionGrant) TableName() string { return "group_permission_grant" }
|
||||
|
||||
// MonitorPermission grants a non-admin user access to monitoring endpoints.
|
||||
// One row per user_id.
|
||||
type MonitorPermission struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_monitor_permission_user" json:"userId"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
|
||||
}
|
||||
|
||||
func (MonitorPermission) TableName() string { return "monitor_permission" }
|
||||
|
||||
type ViteConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
|
||||
@@ -641,3 +651,84 @@ type UserForwardDetail struct {
|
||||
Status int
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type NodeMetric struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_node_metric_node_time,priority:1" json:"nodeId"`
|
||||
Timestamp int64 `gorm:"not null;index:idx_node_metric_node_time,priority:2;index:idx_node_metric_time" json:"timestamp"`
|
||||
CPUUsage float64 `gorm:"column:cpu_usage" json:"cpuUsage"`
|
||||
MemUsage float64 `gorm:"column:mem_usage" json:"memoryUsage"`
|
||||
DiskUsage float64 `gorm:"column:disk_usage" json:"diskUsage"`
|
||||
NetInBytes int64 `gorm:"column:net_in_bytes" json:"netInBytes"`
|
||||
NetOutBytes int64 `gorm:"column:net_out_bytes" json:"netOutBytes"`
|
||||
NetInSpeed int64 `gorm:"column:net_in_speed" json:"netInSpeed"`
|
||||
NetOutSpeed int64 `gorm:"column:net_out_speed" json:"netOutSpeed"`
|
||||
Load1 float64 `gorm:"column:load1" json:"load1"`
|
||||
Load5 float64 `gorm:"column:load5" json:"load5"`
|
||||
Load15 float64 `gorm:"column:load15" json:"load15"`
|
||||
TCPConns int64 `gorm:"column:tcp_conns" json:"tcpConns"`
|
||||
UDPConns int64 `gorm:"column:udp_conns" json:"udpConns"`
|
||||
Uptime int64 `gorm:"column:uptime" json:"uptime"`
|
||||
}
|
||||
|
||||
func (NodeMetric) TableName() string { return "node_metric" }
|
||||
|
||||
type TunnelMetric struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
|
||||
Timestamp int64 `gorm:"not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
|
||||
BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"`
|
||||
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
|
||||
Connections int64 `gorm:"column:connections" json:"connections"`
|
||||
Errors int64 `gorm:"column:errors" json:"errors"`
|
||||
AvgLatencyMs float64 `gorm:"column:avg_latency_ms" json:"avgLatencyMs"`
|
||||
}
|
||||
|
||||
func (TunnelMetric) TableName() string { return "tunnel_metric" }
|
||||
|
||||
type ServiceMonitor struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"`
|
||||
Target string `gorm:"type:text;not null" json:"target"`
|
||||
IntervalSec int `gorm:"column:interval_sec;not null;default:60" json:"intervalSec"`
|
||||
TimeoutSec int `gorm:"column:timeout_sec;not null;default:5" json:"timeoutSec"`
|
||||
NodeID int64 `gorm:"column:node_id;index" json:"nodeId"`
|
||||
Enabled int `gorm:"not null;default:1" json:"enabled"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null" json:"updatedTime"`
|
||||
}
|
||||
|
||||
func (ServiceMonitor) TableName() string { return "service_monitor" }
|
||||
|
||||
type ServiceMonitorResult struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
MonitorID int64 `gorm:"column:monitor_id;not null;index:idx_monitor_result_monitor_time,priority:1" json:"monitorId"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index" json:"nodeId"`
|
||||
Timestamp int64 `gorm:"not null;index:idx_monitor_result_monitor_time,priority:2" json:"timestamp"`
|
||||
Success int `gorm:"not null" json:"success"`
|
||||
LatencyMs float64 `gorm:"column:latency_ms" json:"latencyMs"`
|
||||
StatusCode int `gorm:"column:status_code" json:"statusCode"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage"`
|
||||
}
|
||||
|
||||
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
|
||||
|
||||
// TunnelQuality stores periodic probe results for a tunnel.
|
||||
// Unlike the old upsert model, rows accumulate for history/charting.
|
||||
// Old rows are pruned periodically (default: keep 24h).
|
||||
type TunnelQuality struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_quality_tunnel_time,priority:1" json:"tunnelId"`
|
||||
EntryToExitLatency float64 `gorm:"column:entry_to_exit_latency" json:"entryToExitLatency"`
|
||||
ExitToBingLatency float64 `gorm:"column:exit_to_bing_latency" json:"exitToBingLatency"`
|
||||
EntryToExitLoss float64 `gorm:"column:entry_to_exit_loss" json:"entryToExitLoss"`
|
||||
ExitToBingLoss float64 `gorm:"column:exit_to_bing_loss" json:"exitToBingLoss"`
|
||||
Success int `gorm:"not null;default:1" json:"success"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
|
||||
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
|
||||
ChainDetails string `gorm:"column:chain_details;type:text" json:"chainDetails,omitempty"`
|
||||
}
|
||||
|
||||
func (TunnelQuality) TableName() string { return "tunnel_quality" }
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +49,11 @@ type UserGroupBackup = model.UserGroupBackup
|
||||
type PermissionBackup = model.PermissionBackup
|
||||
type PermissionGrantBackup = model.PermissionGrantBackup
|
||||
type ImportResult = model.ImportResult
|
||||
type NodeMetric = model.NodeMetric
|
||||
type TunnelMetric = model.TunnelMetric
|
||||
type ServiceMonitor = model.ServiceMonitor
|
||||
type ServiceMonitorResult = model.ServiceMonitorResult
|
||||
type TunnelQuality = model.TunnelQuality
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -176,12 +183,18 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.UserGroupUser{},
|
||||
&model.GroupPermission{},
|
||||
&model.GroupPermissionGrant{},
|
||||
&model.MonitorPermission{},
|
||||
&model.ViteConfig{},
|
||||
&model.PeerShare{},
|
||||
&model.PeerShareRuntime{},
|
||||
&model.FederationTunnelBinding{},
|
||||
&model.Announcement{},
|
||||
&model.SchemaVersion{},
|
||||
&model.NodeMetric{},
|
||||
&model.TunnelMetric{},
|
||||
&model.ServiceMonitor{},
|
||||
&model.ServiceMonitorResult{},
|
||||
&model.TunnelQuality{},
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "sqlite" {
|
||||
@@ -394,6 +407,24 @@ func (r *Repository) ListConfigs() (map[string]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetConfigsByNames(names []string) (map[string]string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
var configs []model.ViteConfig
|
||||
if err := r.db.Select("name", "value").Where("name IN ?", names).Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(configs))
|
||||
for _, c := range configs {
|
||||
result[c.Name] = c.Value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertConfig(name, value string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
@@ -648,9 +679,11 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"version": nullableString(n.Version),
|
||||
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
|
||||
"status": n.Status, "isRemote": n.IsRemote,
|
||||
"remoteUrl": nullableString(n.RemoteURL),
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"remoteUrl": nullableString(n.RemoteURL),
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"expiryReminderDismissed": n.ExpiryReminderDismissed,
|
||||
"interfaceName": nullableString(n.InterfaceName),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
@@ -2689,12 +2722,13 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────
|
||||
|
||||
const currentSchemaVersion = 5
|
||||
const currentSchemaVersion = 6
|
||||
|
||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
|
||||
var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns
|
||||
var migrateTunnelMetricBucketUniqueIndexFn = migrateTunnelMetricBucketUniqueIndex
|
||||
|
||||
func getSchemaVersion(db *gorm.DB) int {
|
||||
var v model.SchemaVersion
|
||||
@@ -2764,6 +2798,12 @@ func migrateSchema(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if ver < 6 {
|
||||
if err := migrateTunnelMetricBucketUniqueIndexFn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
setSchemaVersion(db, currentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
@@ -2867,6 +2907,130 @@ func migratePostgresTrafficInt64Columns(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateTunnelMetricBucketUniqueIndex(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&model.TunnelMetric{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// Only do the heavier dedupe work when needed.
|
||||
var dupGroups int64
|
||||
q := `
|
||||
SELECT COUNT(1) AS cnt
|
||||
FROM (
|
||||
SELECT 1
|
||||
FROM tunnel_metric
|
||||
GROUP BY tunnel_id, node_id, timestamp
|
||||
HAVING COUNT(*) > 1
|
||||
) t
|
||||
`
|
||||
if err := tx.Raw(q).Scan(&dupGroups).Error; err != nil {
|
||||
return fmt.Errorf("inspect tunnel_metric duplicates: %w", err)
|
||||
}
|
||||
|
||||
if dupGroups > 0 {
|
||||
switch tx.Dialector.Name() {
|
||||
case "postgres":
|
||||
sql := `
|
||||
WITH agg AS (
|
||||
SELECT MIN(id) AS keep_id,
|
||||
tunnel_id,
|
||||
node_id,
|
||||
timestamp,
|
||||
SUM(bytes_in) AS bytes_in,
|
||||
SUM(bytes_out) AS bytes_out,
|
||||
SUM(connections) AS connections,
|
||||
SUM(errors) AS errors,
|
||||
AVG(avg_latency_ms) AS avg_latency_ms
|
||||
FROM tunnel_metric
|
||||
GROUP BY tunnel_id, node_id, timestamp
|
||||
HAVING COUNT(*) > 1
|
||||
), updated AS (
|
||||
UPDATE tunnel_metric tm
|
||||
SET bytes_in = agg.bytes_in,
|
||||
bytes_out = agg.bytes_out,
|
||||
connections = agg.connections,
|
||||
errors = agg.errors,
|
||||
avg_latency_ms = agg.avg_latency_ms
|
||||
FROM agg
|
||||
WHERE tm.id = agg.keep_id
|
||||
RETURNING tm.id
|
||||
)
|
||||
DELETE FROM tunnel_metric tm
|
||||
USING agg
|
||||
WHERE tm.tunnel_id = agg.tunnel_id
|
||||
AND tm.node_id = agg.node_id
|
||||
AND tm.timestamp = agg.timestamp
|
||||
AND tm.id <> agg.keep_id
|
||||
`
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("dedupe tunnel_metric buckets: %w", err)
|
||||
}
|
||||
default:
|
||||
// SQLite (and other) path.
|
||||
if err := tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error; err != nil {
|
||||
return fmt.Errorf("prepare tunnel_metric dedupe table: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
CREATE TEMP TABLE tunnel_metric_dedupe AS
|
||||
SELECT MIN(id) AS keep_id,
|
||||
tunnel_id,
|
||||
node_id,
|
||||
timestamp,
|
||||
SUM(bytes_in) AS bytes_in,
|
||||
SUM(bytes_out) AS bytes_out,
|
||||
SUM(connections) AS connections,
|
||||
SUM(errors) AS errors,
|
||||
AVG(avg_latency_ms) AS avg_latency_ms
|
||||
FROM tunnel_metric
|
||||
GROUP BY tunnel_id, node_id, timestamp
|
||||
HAVING COUNT(*) > 1
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("build tunnel_metric dedupe table: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
UPDATE tunnel_metric
|
||||
SET bytes_in = (SELECT bytes_in FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
bytes_out = (SELECT bytes_out FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
connections = (SELECT connections FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
errors = (SELECT errors FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
avg_latency_ms = (SELECT avg_latency_ms FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id)
|
||||
WHERE id IN (SELECT keep_id FROM tunnel_metric_dedupe)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("update tunnel_metric deduped rows: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
DELETE FROM tunnel_metric
|
||||
WHERE id IN (
|
||||
SELECT tm.id
|
||||
FROM tunnel_metric tm
|
||||
JOIN tunnel_metric_dedupe d
|
||||
ON tm.tunnel_id = d.tunnel_id
|
||||
AND tm.node_id = d.node_id
|
||||
AND tm.timestamp = d.timestamp
|
||||
WHERE tm.id <> d.keep_id
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("delete tunnel_metric duplicates: %w", err)
|
||||
}
|
||||
_ = tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error
|
||||
}
|
||||
}
|
||||
|
||||
// Uniqueness is required for safe upsert on (tunnel_id, node_id, timestamp).
|
||||
if err := tx.Exec(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uidx_tunnel_metric_bucket ON tunnel_metric(tunnel_id, node_id, timestamp)`,
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("create tunnel_metric unique index: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
@@ -3080,7 +3244,7 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
|
||||
pair := formatForwardIngressAddress(ip, row.Port.Int64)
|
||||
if _, ok := seenPairs[pair]; !ok {
|
||||
seenPairs[pair] = struct{}{}
|
||||
entries = append(entries, pair)
|
||||
@@ -3097,6 +3261,17 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
return strings.Join(entries, ","), inPort, nil
|
||||
}
|
||||
|
||||
func formatForwardIngressAddress(host string, port int64) string {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || port <= 0 {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.FormatInt(port, 10))
|
||||
}
|
||||
|
||||
func nullableString(v sql.NullString) interface{} {
|
||||
if v.Valid {
|
||||
return v.String
|
||||
@@ -3140,3 +3315,395 @@ var osMkdirAll = func(path string) error {
|
||||
|
||||
// Suppress unused import warning for log
|
||||
var _ = log.Printf
|
||||
|
||||
func (r *Repository) InsertNodeMetric(m *model.NodeMetric) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) InsertNodeMetricBatch(metrics []*model.NodeMetric) error {
|
||||
if r == nil || r.db == nil || len(metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(metrics, 100).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rangeMs := endMs - startMs
|
||||
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
|
||||
const targetPoints = 500 // target number of chart points for downsampled data
|
||||
|
||||
// For short ranges, return raw data (full resolution).
|
||||
if rangeMs <= maxRawRangeMs {
|
||||
var metrics []model.NodeMetric
|
||||
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||
Order("timestamp ASC").
|
||||
Limit(5000).
|
||||
Find(&metrics).Error
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
// For longer ranges, downsample via SQL aggregation to keep the response small and fast.
|
||||
bucketMs := rangeMs / targetPoints
|
||||
if bucketMs < 1000 {
|
||||
bucketMs = 1000 // minimum 1-second buckets
|
||||
}
|
||||
|
||||
bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs)
|
||||
groupExpr := fmt.Sprintf("timestamp / %d", bucketMs)
|
||||
|
||||
var metrics []model.NodeMetric
|
||||
err := r.db.Model(&model.NodeMetric{}).
|
||||
Select(
|
||||
fmt.Sprintf(
|
||||
"%d AS node_id, "+
|
||||
"CAST(%s AS BIGINT) AS timestamp, "+
|
||||
"AVG(cpu_usage) AS cpu_usage, "+
|
||||
"AVG(mem_usage) AS mem_usage, "+
|
||||
"AVG(disk_usage) AS disk_usage, "+
|
||||
"CAST(AVG(net_in_bytes) AS BIGINT) AS net_in_bytes, "+
|
||||
"CAST(AVG(net_out_bytes) AS BIGINT) AS net_out_bytes, "+
|
||||
"CAST(AVG(net_in_speed) AS BIGINT) AS net_in_speed, "+
|
||||
"CAST(AVG(net_out_speed) AS BIGINT) AS net_out_speed, "+
|
||||
"AVG(load1) AS load1, "+
|
||||
"AVG(load5) AS load5, "+
|
||||
"AVG(load15) AS load15, "+
|
||||
"CAST(AVG(tcp_conns) AS BIGINT) AS tcp_conns, "+
|
||||
"CAST(AVG(udp_conns) AS BIGINT) AS udp_conns, "+
|
||||
"CAST(MAX(uptime) AS BIGINT) AS uptime",
|
||||
nodeID, bucketExpr,
|
||||
),
|
||||
).
|
||||
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||
Group(groupExpr).
|
||||
Order("timestamp ASC").
|
||||
Limit(targetPoints + 100). // safety margin
|
||||
Scan(&metrics).Error
|
||||
|
||||
if metrics == nil {
|
||||
metrics = make([]model.NodeMetric, 0)
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
func (r *Repository) GetLatestNodeMetric(nodeID int64) (*model.NodeMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var m model.NodeMetric
|
||||
err := r.db.Where("node_id = ?", nodeID).Order("timestamp DESC").First(&m).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *Repository) PruneNodeMetrics(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.NodeMetric{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) InsertTunnelMetric(m *model.TunnelMetric) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) InsertTunnelMetricBatch(metrics []*model.TunnelMetric) error {
|
||||
if r == nil || r.db == nil || len(metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(metrics, 100).Error
|
||||
}
|
||||
|
||||
// UpsertTunnelMetricBuckets adds the provided metric deltas into per-minute buckets.
|
||||
// Requires a unique index on (tunnel_id, node_id, timestamp) for safe upserts.
|
||||
func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) error {
|
||||
if r == nil || r.db == nil || len(metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Postgres rejects a single INSERT ... ON CONFLICT when the input contains
|
||||
// duplicate conflict keys. Pre-aggregate within this batch to keep inserts safe.
|
||||
type bucketKey struct {
|
||||
tunnelID int64
|
||||
nodeID int64
|
||||
timestamp int64
|
||||
}
|
||||
|
||||
agg := make(map[bucketKey]*model.TunnelMetric, len(metrics))
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.TunnelID <= 0 || m.NodeID <= 0 || m.Timestamp <= 0 {
|
||||
continue
|
||||
}
|
||||
if m.BytesIn == 0 && m.BytesOut == 0 && m.Connections == 0 && m.Errors == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
k := bucketKey{tunnelID: m.TunnelID, nodeID: m.NodeID, timestamp: m.Timestamp}
|
||||
if existing, ok := agg[k]; ok {
|
||||
existing.BytesIn += m.BytesIn
|
||||
existing.BytesOut += m.BytesOut
|
||||
existing.Connections += m.Connections
|
||||
existing.Errors += m.Errors
|
||||
if existing.AvgLatencyMs == 0 && m.AvgLatencyMs != 0 {
|
||||
existing.AvgLatencyMs = m.AvgLatencyMs
|
||||
}
|
||||
continue
|
||||
}
|
||||
cp := *m
|
||||
agg[k] = &cp
|
||||
}
|
||||
if len(agg) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([]*model.TunnelMetric, 0, len(agg))
|
||||
for _, v := range agg {
|
||||
rows = append(rows, v)
|
||||
}
|
||||
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("tunnel_metric.connections + excluded.connections"),
|
||||
"errors": gorm.Expr("tunnel_metric.errors + excluded.errors"),
|
||||
// avg_latency_ms is not additive; keep the existing bucket value.
|
||||
}),
|
||||
}).CreateInBatches(rows, 100).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelMetrics(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var metrics []model.TunnelMetric
|
||||
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Order("timestamp DESC").
|
||||
Limit(5000).
|
||||
Find(&metrics).Error
|
||||
if len(metrics) > 1 {
|
||||
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
|
||||
metrics[i], metrics[j] = metrics[j], metrics[i]
|
||||
}
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
// GetTunnelMetricsAggregated returns tunnel-level aggregated series (one point per timestamp).
|
||||
// Storage remains per (tunnel_id, node_id, timestamp) for future drill-down.
|
||||
func (r *Repository) GetTunnelMetricsAggregated(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var metrics []model.TunnelMetric
|
||||
err := r.db.Model(&model.TunnelMetric{}).
|
||||
Select(
|
||||
"tunnel_id, 0 AS node_id, timestamp, "+
|
||||
"SUM(bytes_in) AS bytes_in, "+
|
||||
"SUM(bytes_out) AS bytes_out, "+
|
||||
"SUM(connections) AS connections, "+
|
||||
"SUM(errors) AS errors, "+
|
||||
"AVG(avg_latency_ms) AS avg_latency_ms",
|
||||
).
|
||||
Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Group("tunnel_id, timestamp").
|
||||
Order("timestamp ASC").
|
||||
Limit(5000).
|
||||
Scan(&metrics).Error
|
||||
if metrics == nil {
|
||||
metrics = make([]model.TunnelMetric, 0)
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
func (r *Repository) PruneTunnelMetrics(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelMetric{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListServiceMonitors() ([]model.ServiceMonitor, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var monitors []model.ServiceMonitor
|
||||
err := r.db.Order("id ASC").Find(&monitors).Error
|
||||
return monitors, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListEnabledServiceMonitors() ([]model.ServiceMonitor, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var monitors []model.ServiceMonitor
|
||||
err := r.db.Where("enabled = 1 AND type IN (?)", []string{"tcp", "icmp"}).Order("id ASC").Find(&monitors).Error
|
||||
return monitors, err
|
||||
}
|
||||
|
||||
func (r *Repository) GetServiceMonitor(id int64) (*model.ServiceMonitor, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var m model.ServiceMonitor
|
||||
err := r.db.First(&m, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateServiceMonitor(m *model.ServiceMonitor) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateServiceMonitor(m *model.ServiceMonitor) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Save(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteServiceMonitor(id int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep API/UI semantics simple: deleting a monitor also deletes its history.
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("monitor_id = ?", id).Delete(&model.ServiceMonitorResult{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.ServiceMonitor{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) InsertServiceMonitorResult(result *model.ServiceMonitorResult) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(result).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]model.ServiceMonitorResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var results []model.ServiceMonitorResult
|
||||
err := r.db.Where("monitor_id = ?", monitorID).
|
||||
Order("timestamp DESC").
|
||||
Limit(limit).
|
||||
Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetServiceMonitorResultsByTimeRange returns results for a monitor within [startMs, endMs].
|
||||
// Mirrors GetNodeMetrics / GetTunnelMetrics pattern for time-range based charting.
|
||||
func (r *Repository) GetServiceMonitorResultsByTimeRange(monitorID int64, startMs, endMs int64) ([]model.ServiceMonitorResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var results []model.ServiceMonitorResult
|
||||
err := r.db.Where("monitor_id = ? AND timestamp >= ? AND timestamp <= ?", monitorID, startMs, endMs).
|
||||
Order("timestamp DESC").
|
||||
Limit(5000).
|
||||
Find(&results).Error
|
||||
if len(results) > 1 {
|
||||
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
|
||||
results[i], results[j] = results[j], results[i]
|
||||
}
|
||||
}
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetLatestServiceMonitorResults returns the newest result per monitor_id.
|
||||
// This is intended for list rendering (avoid N+1 queries).
|
||||
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []model.ServiceMonitorResult
|
||||
|
||||
// Prefer a window-function query (works on modern SQLite + Postgres).
|
||||
q1 := `
|
||||
SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY timestamp DESC, id DESC) AS rn
|
||||
FROM service_monitor_result
|
||||
) t
|
||||
WHERE rn = 1
|
||||
ORDER BY monitor_id ASC
|
||||
`
|
||||
if err := r.db.Raw(q1).Scan(&results).Error; err == nil {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Fallback: just return newest rows (best-effort). This avoids hard failure on older SQLite builds.
|
||||
// Note: This may not include all monitors if the table is extremely large and skewed.
|
||||
results = nil
|
||||
q2 := `
|
||||
SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message
|
||||
FROM service_monitor_result
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT 5000
|
||||
`
|
||||
err := r.db.Raw(q2).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(results))
|
||||
out := make([]model.ServiceMonitorResult, 0, len(results))
|
||||
for _, row := range results {
|
||||
if row.MonitorID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[row.MonitorID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.MonitorID] = struct{}{}
|
||||
out = append(out, row)
|
||||
}
|
||||
// Keep response stable for the frontend.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].MonitorID < out[j].MonitorID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) PruneServiceMonitorResults(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.ServiceMonitorResult{}).Error
|
||||
}
|
||||
|
||||
@@ -30,8 +30,15 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
return r.ListForwardsByTunnelTx(r.db, tunnelID)
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -57,6 +64,7 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -95,8 +103,15 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
return r.ListForwardPortsTx(r.db, forwardID)
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardPortsTx(tx *gorm.DB, forwardID int64) ([]model.ForwardPortRecord, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var ports []model.ForwardPort
|
||||
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
|
||||
err := tx.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,16 +126,24 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
return r.HasOtherForwardOnNodePortTx(r.db, nodeID, port, currentForwardID)
|
||||
}
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePortTx(tx *gorm.DB, nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New("database unavailable")
|
||||
}
|
||||
if nodeID <= 0 || port <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
err := tx.Model(&model.ForwardPort{}).
|
||||
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
@@ -130,6 +153,7 @@ func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentFo
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
|
||||
@@ -80,6 +80,37 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -161,6 +192,64 @@ func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// MapForwardIDsToTunnelIDs returns a mapping from forward.id to forward.tunnel_id.
|
||||
// Missing forward IDs are omitted from the returned map.
|
||||
func (r *Repository) MapForwardIDsToTunnelIDs(forwardIDs []int64) (map[int64]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(forwardIDs) == 0 {
|
||||
return map[int64]int64{}, nil
|
||||
}
|
||||
|
||||
// Deduplicate and filter invalid IDs.
|
||||
ids := make([]int64, 0, len(forwardIDs))
|
||||
seen := make(map[int64]struct{}, len(forwardIDs))
|
||||
for _, id := range forwardIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[int64]int64{}, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id"`
|
||||
}
|
||||
|
||||
out := make(map[int64]int64, len(ids))
|
||||
const chunkSize = 500
|
||||
for start := 0; start < len(ids); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
|
||||
var rows []row
|
||||
if err := r.db.Model(&model.Forward{}).
|
||||
Select("id", "tunnel_id").
|
||||
Where("id IN ?", ids[start:end]).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ID <= 0 || r.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
out[r.ID] = r.TunnelID
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
@@ -173,6 +262,24 @@ func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountActiveForwardsByUser(userID int64) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Forward{}).Where("user_id = ? AND status = 1", userID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *Repository) CountActiveForwardsByUserTunnel(userID, tunnelID int64) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Forward{}).Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *Repository) GetSpeedLimitSpeed(id int64) (int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var nodes []model.Node
|
||||
err := r.db.Select("id", "inx", "name", "status", "version", "updated_time").
|
||||
Where("is_remote = ?", 0).
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&nodes).Error
|
||||
return nodes, err
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *Repository) InsertMonitorPermission(userID int64, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
row := model.MonitorPermission{UserID: userID, CreatedTime: now}
|
||||
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteMonitorPermission(userID int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("user_id = ?", userID).Delete(&model.MonitorPermission{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) HasMonitorPermission(userID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.MonitorPermission{}).Where("user_id = ?", userID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListMonitorPermissions() ([]model.MonitorPermission, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var items []model.MonitorPermission
|
||||
err := r.db.Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) ListMonitorTunnels() ([]model.Tunnel, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var tunnels []model.Tunnel
|
||||
err := r.db.Select("id", "inx", "name", "status", "updated_time").
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&tunnels).Error
|
||||
return tunnels, err
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestGetTunnelMetricsAggregatedSumsAcrossNodes(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
|
||||
if err := r.InsertTunnelMetric(&model.TunnelMetric{
|
||||
TunnelID: 1,
|
||||
NodeID: 1,
|
||||
Timestamp: ts,
|
||||
BytesIn: 100,
|
||||
BytesOut: 200,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tunnel metric n1: %v", err)
|
||||
}
|
||||
if err := r.InsertTunnelMetric(&model.TunnelMetric{
|
||||
TunnelID: 1,
|
||||
NodeID: 2,
|
||||
Timestamp: ts,
|
||||
BytesIn: 300,
|
||||
BytesOut: 400,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tunnel metric n2: %v", err)
|
||||
}
|
||||
|
||||
metrics, err := r.GetTunnelMetricsAggregated(1, ts-1000, ts+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get aggregated tunnel metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 aggregated point, got %d", len(metrics))
|
||||
}
|
||||
if metrics[0].Timestamp != ts {
|
||||
t.Fatalf("expected timestamp %d, got %d", ts, metrics[0].Timestamp)
|
||||
}
|
||||
if metrics[0].BytesIn != 400 {
|
||||
t.Fatalf("expected bytesIn 400, got %d", metrics[0].BytesIn)
|
||||
}
|
||||
if metrics[0].BytesOut != 600 {
|
||||
t.Fatalf("expected bytesOut 600, got %d", metrics[0].BytesOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertTunnelMetricBucketsAggregatesDuplicateKeysInBatch(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
|
||||
items := []*model.TunnelMetric{
|
||||
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 10, BytesOut: 20},
|
||||
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 30, BytesOut: 40},
|
||||
}
|
||||
if err := r.UpsertTunnelMetricBuckets(items); err != nil {
|
||||
t.Fatalf("upsert buckets: %v", err)
|
||||
}
|
||||
|
||||
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 stored row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].BytesIn != 40 {
|
||||
t.Fatalf("expected bytesIn 40, got %d", rows[0].BytesIn)
|
||||
}
|
||||
if rows[0].BytesOut != 60 {
|
||||
t.Fatalf("expected bytesOut 60, got %d", rows[0].BytesOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertTunnelMetricBucketsIsSafeUnderConcurrency(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
|
||||
const workers = 20
|
||||
const perWorkerIn = int64(5)
|
||||
const perWorkerOut = int64(7)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = r.UpsertTunnelMetricBuckets([]*model.TunnelMetric{{
|
||||
TunnelID: 1,
|
||||
NodeID: 1,
|
||||
Timestamp: ts,
|
||||
BytesIn: perWorkerIn,
|
||||
BytesOut: perWorkerOut,
|
||||
}})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 stored row, got %d", len(rows))
|
||||
}
|
||||
|
||||
wantIn := int64(workers) * perWorkerIn
|
||||
wantOut := int64(workers) * perWorkerOut
|
||||
if rows[0].BytesIn != wantIn {
|
||||
t.Fatalf("expected bytesIn %d, got %d", wantIn, rows[0].BytesIn)
|
||||
}
|
||||
if rows[0].BytesOut != wantOut {
|
||||
t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -453,6 +455,14 @@ func (r *Repository) IsRemoteNodeTx(tx *gorm.DB, nodeID int64) (bool, error) {
|
||||
}
|
||||
|
||||
func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
|
||||
return r.pickNodePortTx(tx, nodeID, allocated, excludeTunnelID, false)
|
||||
}
|
||||
|
||||
func (r *Repository) PickRandomNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
|
||||
return r.pickNodePortTx(tx, nodeID, allocated, excludeTunnelID, true)
|
||||
}
|
||||
|
||||
func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64, randomPick bool) (int, error) {
|
||||
if tx == nil {
|
||||
return 0, errors.New("database unavailable")
|
||||
}
|
||||
@@ -505,6 +515,7 @@ func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
|
||||
}
|
||||
}
|
||||
|
||||
var available []int
|
||||
for _, candidate := range candidates {
|
||||
if candidate <= 0 {
|
||||
continue
|
||||
@@ -512,11 +523,25 @@ func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
|
||||
if _, ok := used[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
allocated[nodeID] = candidate
|
||||
return candidate, nil
|
||||
available = append(available, candidate)
|
||||
}
|
||||
|
||||
return 0, errors.New("节点端口已满,无可用端口")
|
||||
if len(available) == 0 {
|
||||
return 0, errors.New("节点端口已满,无可用端口")
|
||||
}
|
||||
if !randomPick {
|
||||
allocated[nodeID] = available[0]
|
||||
return available[0], nil
|
||||
}
|
||||
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
|
||||
if err != nil {
|
||||
allocated[nodeID] = available[0]
|
||||
return available[0], nil
|
||||
}
|
||||
port := available[idx.Int64()]
|
||||
allocated[nodeID] = port
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelIPPreference(tunnelID int64) string {
|
||||
@@ -997,9 +1022,16 @@ func (r *Repository) DeleteGroupPermissionByIDTx(tx *gorm.DB, id int64) error {
|
||||
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
|
||||
// RevokedUserTunnelPair holds the (userID, tunnelID) of a deleted user_tunnel row,
|
||||
// so the handler layer can clean up associated forwarding rules.
|
||||
type RevokedUserTunnelPair struct {
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) ([]RevokedUserTunnelPair, error) {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||
for _, uid := range currentUserIDs {
|
||||
@@ -1018,7 +1050,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
}
|
||||
}
|
||||
if len(removedUserIDs) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type grantRow struct {
|
||||
@@ -1026,6 +1058,8 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
CreatedByGroup int
|
||||
}
|
||||
|
||||
var revoked []RevokedUserTunnelPair
|
||||
|
||||
for _, userID := range removedUserIDs {
|
||||
var rows []grantRow
|
||||
if err := tx.Model(&model.GroupPermissionGrant{}).
|
||||
@@ -1033,7 +1067,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
Joins("JOIN user_tunnel ON user_tunnel.id = group_permission_grant.user_tunnel_id").
|
||||
Where("group_permission_grant.user_group_id = ? AND user_tunnel.user_id = ?", userGroupID, userID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
@@ -1046,28 +1080,32 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
userTunnelIDs := tx.Model(&model.UserTunnel{}).Select("id").Where("user_id = ?", userID)
|
||||
if err := tx.Where("user_group_id = ? AND user_tunnel_id IN (?)", userGroupID, userTunnelIDs).
|
||||
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int64
|
||||
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
if remaining == 0 {
|
||||
var ut model.UserTunnel
|
||||
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
|
||||
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
|
||||
}
|
||||
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) error {
|
||||
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) ([]RevokedUserTunnelPair, error) {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
|
||||
type grantRow struct {
|
||||
@@ -1080,7 +1118,7 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
|
||||
Select("user_tunnel_id, created_by_group").
|
||||
Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
@@ -1092,22 +1130,27 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
|
||||
|
||||
if err := tx.Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
||||
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var revoked []RevokedUserTunnelPair
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int64
|
||||
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
if remaining == 0 {
|
||||
var ut model.UserTunnel
|
||||
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
|
||||
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
|
||||
}
|
||||
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
// InsertTunnelQuality appends a tunnel quality probe result.
|
||||
// (Follows the same pattern as InsertServiceMonitorResult.)
|
||||
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if q == nil || q.TunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(q).Error
|
||||
}
|
||||
|
||||
// GetTunnelQualityHistory returns quality probe results for a tunnel
|
||||
// within a time range, ordered by timestamp ascending.
|
||||
// (Mirrors GetServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var results []model.TunnelQuality
|
||||
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Order("timestamp ASC").
|
||||
Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
|
||||
// (Mirrors GetLatestServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []model.TunnelQuality
|
||||
|
||||
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
|
||||
q := `
|
||||
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
|
||||
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
|
||||
FROM tunnel_quality
|
||||
) t
|
||||
WHERE rn = 1
|
||||
ORDER BY tunnel_id ASC
|
||||
`
|
||||
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Fallback for older SQLite
|
||||
results = nil
|
||||
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(results))
|
||||
out := make([]model.TunnelQuality, 0, len(results))
|
||||
for _, row := range results {
|
||||
if row.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[row.TunnelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.TunnelID] = struct{}{}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
|
||||
// (Mirrors PruneServiceMonitorResults pattern.)
|
||||
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
|
||||
}
|
||||
|
||||
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
|
||||
func (r *Repository) ListEnabledTunnelIDs() ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.Tunnel{}).Where("status = ?", 1).Pluck("id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type nodeSession struct {
|
||||
nodeID int64
|
||||
secret string
|
||||
conn *connWrap
|
||||
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
|
||||
}
|
||||
|
||||
type commandResponse struct {
|
||||
@@ -72,6 +73,7 @@ type Server struct {
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
onNodeOnline func(nodeID int64)
|
||||
onNodeMetric func(nodeID int64, info SystemInfo)
|
||||
|
||||
mu sync.RWMutex
|
||||
admins map[*connWrap]struct{}
|
||||
@@ -80,6 +82,22 @@ type Server struct {
|
||||
pending map[string]pendingRequest
|
||||
}
|
||||
|
||||
type SystemInfo struct {
|
||||
Uptime uint64 `json:"uptime"`
|
||||
BytesReceived uint64 `json:"bytes_received"`
|
||||
BytesTransmitted uint64 `json:"bytes_transmitted"`
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
TCPConns int64 `json:"tcp_conns"`
|
||||
UDPConns int64 `json:"udp_conns"`
|
||||
NetInSpeed int64 `json:"net_in_speed"`
|
||||
NetOutSpeed int64 `json:"net_out_speed"`
|
||||
}
|
||||
|
||||
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
|
||||
if s == nil {
|
||||
return
|
||||
@@ -89,6 +107,15 @@ func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) SetNodeMetricHook(fn func(nodeID int64, info SystemInfo)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.onNodeMetric = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
|
||||
return &Server{
|
||||
repo: repo,
|
||||
@@ -185,7 +212,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
_ = old.conn.conn.Close()
|
||||
delete(s.byConn, old.conn.conn)
|
||||
}
|
||||
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
|
||||
// 初始化 AES 加密器并缓存(仅创建一次)
|
||||
var nodeCrypto *security.AESCrypto
|
||||
if strings.TrimSpace(secret) != "" {
|
||||
nodeCrypto, _ = security.NewAESCrypto(secret)
|
||||
}
|
||||
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw, crypto: nodeCrypto}
|
||||
s.nodes[nodeID] = ns
|
||||
s.byConn[conn] = ns
|
||||
s.mu.Unlock()
|
||||
@@ -225,18 +257,100 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
return
|
||||
}
|
||||
|
||||
msg := decryptIfNeeded(payload, secret)
|
||||
msg := decryptIfNeeded(payload, ns.crypto, secret)
|
||||
s.tryResolvePending(nodeID, msg)
|
||||
|
||||
var parsed struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" {
|
||||
s.broadcastTyped(nodeID, "upgrade_progress", msg)
|
||||
} else {
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" {
|
||||
switch parsed.Type {
|
||||
case "metric":
|
||||
// Agent 新版指标消息:{type:"metric", data:{...}}
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg), &envelope); err == nil && len(envelope.Data) > 0 {
|
||||
// 解析 SystemInfo 并调用 hook
|
||||
var sysInfo SystemInfo
|
||||
if json.Unmarshal(envelope.Data, &sysInfo) == nil {
|
||||
s.mu.RLock()
|
||||
onMetric := s.onNodeMetric
|
||||
s.mu.RUnlock()
|
||||
if onMetric != nil {
|
||||
go onMetric(nodeID, sysInfo)
|
||||
}
|
||||
}
|
||||
// 广播内层 data 给前端(保持平坦结构兼容性)
|
||||
s.broadcastTyped(nodeID, "metric", string(envelope.Data))
|
||||
}
|
||||
continue
|
||||
case "UpgradeProgress":
|
||||
s.broadcastTyped(nodeID, "upgrade_progress", msg)
|
||||
continue
|
||||
default:
|
||||
// Unknown typed messages still get broadcast so future
|
||||
// agent message types are not silently lost.
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版 Agent:无 type 字段的系统信息消息
|
||||
if looksLikeSystemInfoMessage(msg) {
|
||||
var sysInfo SystemInfo
|
||||
if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil {
|
||||
s.mu.RLock()
|
||||
onMetric := s.onNodeMetric
|
||||
s.mu.RUnlock()
|
||||
if onMetric != nil {
|
||||
go onMetric(nodeID, sysInfo)
|
||||
}
|
||||
s.broadcastTyped(nodeID, "metric", msg)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeSystemInfoMessage(msg string) bool {
|
||||
// Keep this as a cheap heuristic so that arbitrary JSON objects don't get
|
||||
// misclassified as metrics (SystemInfo unmarshal would otherwise succeed with
|
||||
// all-zero values).
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(msg, "{") {
|
||||
return false
|
||||
}
|
||||
|
||||
keys := []string{
|
||||
"\"uptime\"",
|
||||
"\"cpu_usage\"",
|
||||
"\"memory_usage\"",
|
||||
"\"disk_usage\"",
|
||||
"\"bytes_received\"",
|
||||
"\"bytes_transmitted\"",
|
||||
"\"net_in_speed\"",
|
||||
"\"net_out_speed\"",
|
||||
"\"tcp_conns\"",
|
||||
"\"udp_conns\"",
|
||||
"\"load1\"",
|
||||
"\"load5\"",
|
||||
"\"load15\"",
|
||||
}
|
||||
matched := 0
|
||||
for _, k := range keys {
|
||||
if strings.Contains(msg, k) {
|
||||
matched++
|
||||
if matched >= 3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (CommandResult, error) {
|
||||
@@ -285,13 +399,8 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
|
||||
}
|
||||
|
||||
messageData := rawCmd
|
||||
if strings.TrimSpace(ns.secret) != "" {
|
||||
crypto, err := security.NewAESCrypto(ns.secret)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(rawCmd)
|
||||
if ns.crypto != nil {
|
||||
encrypted, err := ns.crypto.Encrypt(rawCmd)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
@@ -341,6 +450,11 @@ func (s *Server) tryResolvePending(nodeID int64, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
// 快速短路:指标消息永远不含 requestId,跳过完整 JSON 解析
|
||||
if !strings.Contains(message, "\"requestId\"") {
|
||||
return
|
||||
}
|
||||
|
||||
var resp commandResponse
|
||||
if err := json.Unmarshal([]byte(message), &resp); err != nil {
|
||||
return
|
||||
@@ -458,18 +572,22 @@ func (s *Server) broadcastToAdmins(message string) {
|
||||
}
|
||||
}
|
||||
|
||||
func decryptIfNeeded(payload []byte, secret string) string {
|
||||
func decryptIfNeeded(payload []byte, crypto *security.AESCrypto, secret string) string {
|
||||
text := string(payload)
|
||||
var wrap encryptedMessage
|
||||
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
|
||||
return text
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
// 优先使用缓存的 crypto 实例
|
||||
c := crypto
|
||||
if c == nil && strings.TrimSpace(secret) != "" {
|
||||
c, _ = security.NewAESCrypto(secret)
|
||||
}
|
||||
if c == nil {
|
||||
return text
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
plain, err := c.Decrypt(wrap.Data)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
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 TestForwardCreateBlockedWhenUserNumLimitExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(100)
|
||||
tunnelID := 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(?, 'num_limit_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 2, ?, ?, 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(?, 'num_limit_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(1, ?, 'num_limit_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %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(2, ?, 'num_limit_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 2: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_limit_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_forward","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 num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
if !strings.Contains(out.Msg, "转发数量已达上限") {
|
||||
t.Fatalf("expected forward count limit message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResumeBlockedWhenUserNumLimitExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(101)
|
||||
tunnelID := int64(1)
|
||||
pausedForwardID := int64(3)
|
||||
|
||||
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(?, 'num_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 2, ?, ?, 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(?, 'num_resume_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(1, ?, 'num_resume_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %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(2, ?, 'num_resume_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 2: %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(?, ?, 'num_resume_user', 'paused_forward', ?, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, pausedForwardID, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert paused forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_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":3}`))
|
||||
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 num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
if !strings.Contains(out.Msg, "转发数量已达上限") {
|
||||
t.Fatalf("expected forward count limit message, got %q", out.Msg)
|
||||
}
|
||||
|
||||
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, pausedForwardID)
|
||||
if status != 0 {
|
||||
t.Fatalf("expected forward status to remain 0, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateBlockedWhenUserTunnelNumLimitExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(102)
|
||||
tunnelID := 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_num_limit_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_num_limit_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, 1, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel with num=1: %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, ?, 'ut_num_limit_user', 'existing_tunnel_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "ut_num_limit_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_tunnel_forward","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 user_tunnel num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
if !strings.Contains(out.Msg, "隧道转发数量已达上限") {
|
||||
t.Fatalf("expected tunnel forward count limit message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateAllowedWhenBelowUserNumLimit(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(103)
|
||||
tunnelID := 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(?, 'num_ok_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 3, ?, ?, 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(?, 'num_ok_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 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('num-ok-entry', 'num-ok-secret', '10.50.0.1', '10.50.0.1', '', '10000-10010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert entry node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, repo, "num-ok-entry")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 10001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).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(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(1, ?, 'num_ok_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_ok_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_forward_ok","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 success (code=0) when below num limit, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateAllowedWhenNumZero(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(104)
|
||||
tunnelID := 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(?, 'num_zero_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 0, ?, ?, 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(?, 'num_zero_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 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('num-zero-entry', 'num-zero-secret', '10.60.0.1', '10.60.0.1', '', '11000-11010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert entry node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, repo, "num-zero-entry")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 11001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).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(10, ?, ?, NULL, 0, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel with num=0: %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, ?, 'num_zero_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %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(2, ?, 'num_zero_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 2: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_zero_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_forward_zero","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 success (code=0) when num=0 (unlimited), got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue313-entry-a", "10.100.0.1", "2000-2010")
|
||||
entryB1 := insertNode("issue313-entry-b1", "10.100.0.2", "2000-2010")
|
||||
entryB2 := insertNode("issue313-entry-b2", "10.100.0.3", "2000-2010")
|
||||
chainA := insertNode("issue313-chain-a", "10.100.0.4", "3000-3010")
|
||||
@@ -51,7 +50,7 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
|
||||
`, tunnelAID, entryA).Error; err != nil {
|
||||
`, tunnelAID, entryB2).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel entry a: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
@@ -109,10 +108,16 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
|
||||
}
|
||||
forwardAID := mustLastInsertID(t, repo, "issue313-forward-a")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryA, 2000).Error; err != nil {
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil {
|
||||
t.Fatalf("insert forward_port a: %v", err)
|
||||
}
|
||||
|
||||
// Simulate legacy dirty data: tunnel A already occupies port 2000 on entryB2.
|
||||
// When tunnel B adds entryB2, the inherited forward port should conflict cross-tunnel.
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil {
|
||||
t.Fatalf("insert forward_port a on entryB2: %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(3132, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
@@ -171,7 +176,8 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
|
||||
t.Fatalf("expected update failure due to cross-tunnel port conflict, got success with code 0")
|
||||
}
|
||||
|
||||
if !bytes.Contains(res.Body.Bytes(), []byte("端口")) && !bytes.Contains(res.Body.Bytes(), []byte("占用")) {
|
||||
msgBytes := []byte(out.Msg)
|
||||
if !bytes.Contains(msgBytes, []byte("端口")) && !bytes.Contains(msgBytes, []byte("占用")) {
|
||||
t.Fatalf("expected port conflict error message, got %q", out.Msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIssue349_ForwardListFormatsIPv6EntryAddressesContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue349-tunnel", 1.0, 1, "tcp", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "issue349-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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue349-entry-node-a", "entry-secret-a", "2001:db8::10", "", "2001:db8::10", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node a: %v", err)
|
||||
}
|
||||
nodeAID := mustLastInsertID(t, repo, "issue349-entry-node-a")
|
||||
|
||||
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue349-entry-node-b", "entry-secret-b", "2001:db8::30", "", "2001:db8::30", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 1).Error; err != nil {
|
||||
t.Fatalf("insert node b: %v", err)
|
||||
}
|
||||
nodeBID := mustLastInsertID(t, repo, "issue349-entry-node-b")
|
||||
|
||||
if err := repo.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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 1, "admin_user", "issue349-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, repo, "issue349-forward")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeAID, 32001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port a: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port, in_ip) VALUES(?, ?, ?, ?)`, forwardID, nodeBID, 32002, "2001:db8::20").Error; err != nil {
|
||||
t.Fatalf("insert forward_port b: %v", err)
|
||||
}
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
rows := mustContractSlice(t, out.Data, "forward list data")
|
||||
var target map[string]interface{}
|
||||
for _, row := range rows {
|
||||
item, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(item["id"]) == forwardID {
|
||||
target = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
|
||||
}
|
||||
|
||||
if got := contractValueAsString(target["inIp"]); got != "[2001:db8::10]:32001,[2001:db8::20]:32002" {
|
||||
t.Fatalf("expected bracketed IPv6 entry list, got %q", got)
|
||||
}
|
||||
if got := contractValueAsInt64(target["inPort"]); got != 32001 {
|
||||
t.Fatalf("expected first entry port 32001, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -705,7 +705,7 @@ func TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract(t *testi
|
||||
}
|
||||
|
||||
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)
|
||||
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "51000-51010", 1)
|
||||
exitNodeID := insertNode("issue281-exit", "issue281-exit-secret", "10.51.0.3", "53000-53010", 2)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
@@ -881,8 +881,8 @@ func TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract(t *testing.T)
|
||||
}
|
||||
|
||||
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)
|
||||
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,21 +50,18 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected create failure when nodes are offline")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "节点") {
|
||||
t.Fatalf("expected node-related error, got %q", out.Msg)
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected create success (runtime deferred when nodes offline), got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
tunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel")
|
||||
if tunnelCount != 0 {
|
||||
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
|
||||
if tunnelCount != 1 {
|
||||
t.Fatalf("expected tunnel record preserved (runtime deferred), found %d records", tunnelCount)
|
||||
}
|
||||
|
||||
chainCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel`)
|
||||
if chainCount != 0 {
|
||||
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
|
||||
if chainCount != 3 {
|
||||
t.Fatalf("expected 3 chain_tunnel records preserved (in/chain/out), found %d records", chainCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
storeRepo "go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestTunnelDeletePreviewIncludesDependentRulesContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "preview-source-tunnel", "preview-source-node", "21000-21010")
|
||||
seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "preview-forward", 21001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-preview", map[string]interface{}{"id": sourceTunnelID})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected preview data object, got %T", out.Data)
|
||||
}
|
||||
if contractValueAsInt64(data["tunnelId"]) != sourceTunnelID {
|
||||
t.Fatalf("unexpected tunnelId: %#v", data["tunnelId"])
|
||||
}
|
||||
if contractValueAsInt64(data["forwardCount"]) != 1 {
|
||||
t.Fatalf("expected forwardCount=1, got %#v", data["forwardCount"])
|
||||
}
|
||||
|
||||
samples, ok := data["sampleForwards"].([]interface{})
|
||||
if !ok || len(samples) != 1 {
|
||||
t.Fatalf("expected one sample forward, got %#v", data["sampleForwards"])
|
||||
}
|
||||
first, ok := samples[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected sample object, got %T", samples[0])
|
||||
}
|
||||
if first["name"] != "preview-forward" {
|
||||
t.Fatalf("unexpected sample name: %#v", first["name"])
|
||||
}
|
||||
if contractValueAsInt64(first["inPort"]) != 21001 {
|
||||
t.Fatalf("unexpected sample inPort: %#v", first["inPort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "delete-source-tunnel", "delete-source-node", "22000-22010")
|
||||
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "delete-forward", 22001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
|
||||
"id": sourceTunnelID,
|
||||
"action": "delete_forwards",
|
||||
})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 0 {
|
||||
t.Fatalf("expected tunnel deleted, got count=%d", count)
|
||||
}
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID); count != 0 {
|
||||
t.Fatalf("expected forward deleted, got count=%d", count)
|
||||
}
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ?`, forwardID); count != 0 {
|
||||
t.Fatalf("expected forward ports deleted, got count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-source-tunnel", "replace-source-node", "23000-23010")
|
||||
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "replace-forward", 23001)
|
||||
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-target-tunnel", "replace-target-node", "23000-23010")
|
||||
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "occupied-forward", 23001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
|
||||
"id": sourceTunnelID,
|
||||
"action": "replace",
|
||||
"targetTunnelId": targetTunnelID,
|
||||
})
|
||||
if out.Code != -2 {
|
||||
t.Fatalf("expected failure code -2, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
result := mustTunnelDeleteFailureResult(t, out)
|
||||
if contractValueAsInt64(result["failCount"]) != 1 {
|
||||
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
|
||||
}
|
||||
assertBatchFailureNameAndReason(t, result, "replace-forward", "节点 replace-target-node 端口 23001 已被其他转发占用")
|
||||
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 1 {
|
||||
t.Fatalf("expected source tunnel kept, got count=%d", count)
|
||||
}
|
||||
if tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID); tunnelAfter != sourceTunnelID {
|
||||
t.Fatalf("expected forward tunnel unchanged, got %d", tunnelAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelBatchDeletePreviewIncludesTotalsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
tunnelA, nodeA := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-a", "batch-preview-node-a", "24000-24010")
|
||||
tunnelB, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-b", "batch-preview-node-b", "24100-24110")
|
||||
seedTunnelDeleteForward(t, repo, now, tunnelA, nodeA, "batch-preview-forward", 24001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-preview", map[string]interface{}{
|
||||
"ids": []int64{tunnelA, tunnelB},
|
||||
})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected preview object, got %T", out.Data)
|
||||
}
|
||||
if contractValueAsInt64(data["tunnelCount"]) != 2 {
|
||||
t.Fatalf("expected tunnelCount=2, got %#v", data["tunnelCount"])
|
||||
}
|
||||
if contractValueAsInt64(data["totalForwardCount"]) != 1 {
|
||||
t.Fatalf("expected totalForwardCount=1, got %#v", data["totalForwardCount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelBatchDeleteWithForwardsReturnsTunnelLevelFailuresContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelA, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-a", "batch-replace-source-node-a", "25000-25010")
|
||||
sourceTunnelB, sourceNodeB := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-b", "batch-replace-source-node-b", "25100-25110")
|
||||
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-target", "batch-replace-target-node", "25000-25010")
|
||||
|
||||
seedTunnelDeleteForward(t, repo, now, sourceTunnelB, sourceNodeB, "batch-replace-forward-b", 25002)
|
||||
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "batch-replace-occupied", 25002)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-with-forwards", map[string]interface{}{
|
||||
"ids": []int64{sourceTunnelA, sourceTunnelB},
|
||||
"action": "replace",
|
||||
"targetTunnelId": targetTunnelID,
|
||||
})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
result := mustTunnelDeleteFailureResult(t, out)
|
||||
if contractValueAsInt64(result["successCount"]) != 1 {
|
||||
t.Fatalf("expected successCount=1, got %#v", result["successCount"])
|
||||
}
|
||||
if contractValueAsInt64(result["failCount"]) != 1 {
|
||||
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
|
||||
}
|
||||
assertBatchFailureNameAndReason(t, result, "batch-replace-source-b", "batch-replace-forward-b: 节点 batch-replace-target-node 端口 25002 已被其他转发占用")
|
||||
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelA); count != 0 {
|
||||
t.Fatalf("expected source tunnel A deleted, got count=%d", count)
|
||||
}
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelB); count != 1 {
|
||||
t.Fatalf("expected source tunnel B kept, got count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTunnelDeleteTunnelWithNode(t *testing.T, repo *storeRepo.Repository, now int64, tunnelName, nodeName, portRange string) (int64, int64) {
|
||||
t.Helper()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, status, created_time, updated_time, in_ip, inx, ip_preference)
|
||||
VALUES(?, 1.0, 1, 'tls', 1, 1, ?, ?, NULL, 0, '')
|
||||
`, tunnelName, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel %s: %v", tunnelName, err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, tunnelName)
|
||||
|
||||
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(?, ?, '10.0.0.1', '10.0.0.1', '', ?, '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, nodeName, nodeName+"-secret", portRange, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", nodeName, err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, nodeName)
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 0, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel for %s: %v", tunnelName, err)
|
||||
}
|
||||
|
||||
return tunnelID, nodeID
|
||||
}
|
||||
|
||||
func seedTunnelDeleteForward(t *testing.T, repo *storeRepo.Repository, now int64, tunnelID, nodeID int64, forwardName string, port int) int64 {
|
||||
t.Helper()
|
||||
|
||||
if err := repo.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, 'contract-user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, forwardName, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward %s: %v", forwardName, err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, repo, forwardName)
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
|
||||
t.Fatalf("insert forward_port for %s: %v", forwardName, err)
|
||||
}
|
||||
|
||||
return forwardID
|
||||
}
|
||||
|
||||
func mustTunnelDeleteFailureResult(t *testing.T, out response.R) map[string]interface{} {
|
||||
t.Helper()
|
||||
result, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected result object, got %T", out.Data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestFlowUploadInsertsTunnelMetrics(t *testing.T) {
|
||||
secret := "monitoring-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
node := &model.Node{
|
||||
Name: "node-1",
|
||||
Secret: "node-secret",
|
||||
ServerIP: "127.0.0.1",
|
||||
Port: "10000-10010",
|
||||
TCPListenAddr: "[::]",
|
||||
UDPListenAddr: "[::]",
|
||||
CreatedTime: now,
|
||||
Status: 1,
|
||||
}
|
||||
if err := repo.DB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
tunnel := &model.Tunnel{
|
||||
Name: "tunnel-1",
|
||||
TrafficRatio: 1.0,
|
||||
Type: 1,
|
||||
Protocol: "tls",
|
||||
Flow: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
}
|
||||
if err := repo.DB().Create(tunnel).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
|
||||
forward := &model.Forward{
|
||||
UserID: 123,
|
||||
UserName: "user-123",
|
||||
Name: "forward-1",
|
||||
TunnelID: tunnel.ID,
|
||||
RemoteAddr: "1.1.1.1:80",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
}
|
||||
if err := repo.DB().Create(forward).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
|
||||
serviceName := jsonNumber(forward.ID) + "_123_0"
|
||||
body, _ := json.Marshal([]map[string]interface{}{{
|
||||
"n": serviceName,
|
||||
"u": 200,
|
||||
"d": 100,
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", res.Code)
|
||||
}
|
||||
|
||||
metrics, err := repo.GetTunnelMetrics(tunnel.ID, 0, now+60_000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 tunnel metric row, got %d", len(metrics))
|
||||
}
|
||||
|
||||
if metrics[0].TunnelID != tunnel.ID {
|
||||
t.Fatalf("expected tunnelId %d, got %d", tunnel.ID, metrics[0].TunnelID)
|
||||
}
|
||||
if metrics[0].NodeID != node.ID {
|
||||
t.Fatalf("expected nodeId %d, got %d", node.ID, metrics[0].NodeID)
|
||||
}
|
||||
if metrics[0].BytesIn != 100 {
|
||||
t.Fatalf("expected bytesIn 100, got %d", metrics[0].BytesIn)
|
||||
}
|
||||
if metrics[0].BytesOut != 200 {
|
||||
t.Fatalf("expected bytesOut 200, got %d", metrics[0].BytesOut)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
|
||||
+3
-1
@@ -119,7 +119,9 @@ func main() {
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
|
||||
distro := socket.DetectDistro()
|
||||
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
|
||||
defer wsReporter.Stop()
|
||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X API KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Gin-based management API for reading/writing config and controlling services at runtime.
|
||||
|
||||
|
||||
@@ -624,6 +624,11 @@ func resumeService(ctx *gin.Context) {
|
||||
existingSvc.Close()
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
@@ -1039,8 +1044,13 @@ func resumeServices(ctx *gin.Context) {
|
||||
str.service.Close()
|
||||
registry.ServiceRegistry().Unregister(str.name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if str.serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 重新解析并启动服务
|
||||
svc, err := parser.ParseService(str.serviceConfig)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X CONFIG KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# GOST CONNECTOR KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Connection initiators (clients) for various protocols in GOST forwarding.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X DIALERS KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Outbound dialers (client-side connection establishment) used by connectors/handlers.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X HANDLERS KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Protocol handlers (server-side request handling) used by services defined in the GOST config.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X LISTENERS KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# GO-GOST REGISTRY KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Wed Feb 04 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -63,11 +62,9 @@ func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
|
||||
type Option func(opts *options)
|
||||
|
||||
func init() {
|
||||
_, err := LoadConfig("config.json")
|
||||
fmt.Println("config.json loaded")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// NOTE: This package can be imported by tests/tools that don't have a local
|
||||
// config.json. Missing config should not crash the process.
|
||||
_, _ = LoadConfig("config.json")
|
||||
needWrap = isTls+isSocks+isHttp > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# GOST SOCKET KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sun Feb 15 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
WebSocket reporter and socket utilities for panel integration.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
)
|
||||
|
||||
// DetectDistro returns the Linux distribution name (e.g. "ubuntu", "centos",
|
||||
// "debian"). Falls back to "linux" when detection fails.
|
||||
func DetectDistro() string {
|
||||
info, err := host.Info()
|
||||
if err != nil || info == nil {
|
||||
return "linux"
|
||||
}
|
||||
platform := strings.ToLower(strings.TrimSpace(info.Platform))
|
||||
if platform == "" {
|
||||
return "linux"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
@@ -397,8 +397,13 @@ func resumeServices(req resumeServicesRequest) error {
|
||||
str.service.Close()
|
||||
registry.ServiceRegistry().Unregister(str.name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if str.serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 重新解析并启动服务
|
||||
svc, err := parser.ParseService(str.serviceConfig)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync" // 新增:用于管理连接状态的互斥锁
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
@@ -25,34 +26,67 @@ import (
|
||||
"github.com/go-gost/x/service"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/load"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
psnet "github.com/shirou/gopsutil/v3/net"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.org/x/net/ipv6"
|
||||
)
|
||||
|
||||
// SystemInfo 系统信息结构体
|
||||
type SystemInfo struct {
|
||||
Uptime uint64 `json:"uptime"` // 开机时间 (秒)
|
||||
BytesReceived uint64 `json:"bytes_received"` // 接收字节数
|
||||
BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数
|
||||
CPUUsage float64 `json:"cpu_usage"` // CPU使用率(百分比)
|
||||
MemoryUsage float64 `json:"memory_usage"` // 内存使用率(百分比)
|
||||
Uptime uint64 `json:"uptime"`
|
||||
BytesReceived uint64 `json:"bytes_received"`
|
||||
BytesTransmitted uint64 `json:"bytes_transmitted"`
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
TCPConns int64 `json:"tcp_conns"`
|
||||
UDPConns int64 `json:"udp_conns"`
|
||||
NetInSpeed int64 `json:"net_in_speed"`
|
||||
NetOutSpeed int64 `json:"net_out_speed"`
|
||||
}
|
||||
|
||||
// NetworkStats 网络统计信息
|
||||
type NetworkStats struct {
|
||||
BytesReceived uint64 `json:"bytes_received"` // 接收字节数
|
||||
BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数
|
||||
BytesReceived uint64 `json:"bytes_received"`
|
||||
BytesTransmitted uint64 `json:"bytes_transmitted"`
|
||||
BytesRecvDelta uint64 `json:"bytes_recv_delta"`
|
||||
BytesSentDelta uint64 `json:"bytes_sent_delta"`
|
||||
}
|
||||
|
||||
// CPUInfo CPU信息
|
||||
type CPUInfo struct {
|
||||
Usage float64 `json:"usage"` // CPU使用率(百分比)
|
||||
Usage float64 `json:"usage"`
|
||||
}
|
||||
|
||||
// MemoryInfo 内存信息
|
||||
type MemoryInfo struct {
|
||||
Usage float64 `json:"usage"` // 内存使用率(百分比)
|
||||
Usage float64 `json:"usage"`
|
||||
}
|
||||
|
||||
// DiskInfo 磁盘信息
|
||||
type DiskInfo struct {
|
||||
Usage float64 `json:"usage"`
|
||||
}
|
||||
|
||||
// LoadInfo 负载信息
|
||||
type LoadInfo struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
|
||||
// ConnectionInfo 连接信息
|
||||
type ConnectionInfo struct {
|
||||
TCPConns int64 `json:"tcp_conns"`
|
||||
UDPConns int64 `json:"udp_conns"`
|
||||
}
|
||||
|
||||
// CommandMessage 命令消息结构体
|
||||
@@ -91,9 +125,31 @@ type TcpPingResponse struct {
|
||||
RequestId string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceMonitorCheckRequest service monitor check request.
|
||||
type ServiceMonitorCheckRequest struct {
|
||||
MonitorID int64 `json:"monitorId"`
|
||||
Type string `json:"type"` // tcp|icmp
|
||||
Target string `json:"target"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
}
|
||||
|
||||
// ServiceMonitorCheckResult node-executed check output.
|
||||
// CommandResponse.Success indicates command execution status.
|
||||
// Actual check success is represented by this struct.
|
||||
type ServiceMonitorCheckResult struct {
|
||||
MonitorID int64 `json:"monitorId"`
|
||||
Success bool `json:"success"`
|
||||
LatencyMs float64 `json:"latencyMs"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
reporterReadWait = 60 * time.Second
|
||||
reporterWriteWait = 5 * time.Second
|
||||
wsPingInterval = 20 * time.Second // 独立 WebSocket ping 间隔
|
||||
initialBackoff = 2 * time.Second // 重连初始退避
|
||||
maxBackoff = 2 * time.Minute // 重连最大退避
|
||||
)
|
||||
|
||||
type WebSocketReporter struct {
|
||||
@@ -103,15 +159,15 @@ type WebSocketReporter struct {
|
||||
version string // 保存版本号
|
||||
preferredWSScheme string
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
curBackoff 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加密器
|
||||
connecting bool // 正在连接状态
|
||||
connMutex sync.Mutex // 连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // AES加密器
|
||||
}
|
||||
|
||||
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
@@ -133,8 +189,8 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
|
||||
|
||||
return &WebSocketReporter{
|
||||
url: serverURL,
|
||||
reconnectTime: 5 * time.Second, // 重连间隔
|
||||
pingInterval: 2 * time.Second, // 发送间隔改为2秒
|
||||
curBackoff: initialBackoff, // 当前退避间隔
|
||||
pingInterval: 1 * time.Second, // 指标上报间隔(每秒采集)
|
||||
configInterval: 10 * time.Minute, // 配置上报间隔
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -152,10 +208,17 @@ func (w *WebSocketReporter) Start() {
|
||||
// Stop 停止WebSocket报告器
|
||||
func (w *WebSocketReporter) Stop() {
|
||||
w.cancel()
|
||||
w.connMutex.Lock()
|
||||
if w.conn != nil {
|
||||
w.conn.Close()
|
||||
}
|
||||
w.connMutex.Unlock()
|
||||
}
|
||||
|
||||
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
|
||||
func backoffWithJitter(base time.Duration) time.Duration {
|
||||
jitter := time.Duration(float64(base) * (0.75 + rand.Float64()*0.5))
|
||||
return jitter
|
||||
}
|
||||
|
||||
// run 主运行循环
|
||||
@@ -172,23 +235,32 @@ func (w *WebSocketReporter) run() {
|
||||
|
||||
if needConnect {
|
||||
if err := w.connect(); err != nil {
|
||||
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, w.reconnectTime)
|
||||
wait := backoffWithJitter(w.curBackoff)
|
||||
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, wait)
|
||||
// 指数退避:翻倍当前退避间隔,上限 maxBackoff
|
||||
w.curBackoff *= 2
|
||||
if w.curBackoff > maxBackoff {
|
||||
w.curBackoff = maxBackoff
|
||||
}
|
||||
select {
|
||||
case <-time.After(w.reconnectTime):
|
||||
case <-time.After(wait):
|
||||
continue
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
// 连接成功:重置退避
|
||||
w.curBackoff = initialBackoff
|
||||
}
|
||||
|
||||
// 连接成功,开始发送消息
|
||||
if w.connected {
|
||||
w.handleConnection()
|
||||
} else {
|
||||
wait := backoffWithJitter(w.curBackoff)
|
||||
// 如果连接失败,等待重试
|
||||
select {
|
||||
case <-time.After(w.reconnectTime):
|
||||
case <-time.After(wait):
|
||||
continue
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
@@ -276,7 +348,7 @@ func buildWebSocketCandidates(addr string, secret string, version string, http i
|
||||
normalizedAddr = strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
|
||||
query := "/system-info?type=1&secret=" + url.QueryEscape(secret) + "&version=" + url.QueryEscape(version) +
|
||||
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
|
||||
schemes := []string{"wss", "ws"}
|
||||
@@ -421,15 +493,34 @@ func (w *WebSocketReporter) handleConnection() {
|
||||
// 启动消息接收goroutine
|
||||
go w.receiveMessages()
|
||||
|
||||
// 主发送循环
|
||||
ticker := time.NewTicker(w.pingInterval)
|
||||
defer ticker.Stop()
|
||||
// 指标上报 ticker
|
||||
metricTicker := time.NewTicker(w.pingInterval)
|
||||
defer metricTicker.Stop()
|
||||
|
||||
// 独立 WebSocket keepalive ping ticker
|
||||
pingTicker := time.NewTicker(wsPingInterval)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
|
||||
case <-pingTicker.C:
|
||||
// 发送 WebSocket ping 保活,独立于指标上报
|
||||
w.connMutex.Lock()
|
||||
conn := w.conn
|
||||
isConnected := w.connected
|
||||
w.connMutex.Unlock()
|
||||
if !isConnected || conn == nil {
|
||||
return
|
||||
}
|
||||
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(reporterWriteWait)); err != nil {
|
||||
fmt.Printf("❌ 发送WebSocket ping失败: %v,准备重连\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-metricTicker.C:
|
||||
// 检查连接状态
|
||||
w.connMutex.Lock()
|
||||
isConnected := w.connected
|
||||
@@ -449,11 +540,35 @@ func (w *WebSocketReporter) handleConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
var lastNetBytesReceived uint64
|
||||
var lastNetBytesTransmitted uint64
|
||||
var lastNetTime int64
|
||||
|
||||
var connInfoCached ConnectionInfo
|
||||
var connInfoCachedAt int64
|
||||
var connInfoCachedMu sync.Mutex
|
||||
|
||||
// collectSystemInfo 收集系统信息
|
||||
func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
|
||||
networkStats := getNetworkStats()
|
||||
cpuInfo := getCPUInfo()
|
||||
memoryInfo := getMemoryInfo()
|
||||
diskInfo := getDiskInfo()
|
||||
loadInfo := getLoadInfo()
|
||||
connInfo := getConnectionInfo()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
var netInSpeed, netOutSpeed int64
|
||||
if lastNetTime > 0 {
|
||||
deltaMs := now - lastNetTime
|
||||
if deltaMs > 0 {
|
||||
netInSpeed = int64(float64(networkStats.BytesRecvDelta) * 1000 / float64(deltaMs))
|
||||
netOutSpeed = int64(float64(networkStats.BytesSentDelta) * 1000 / float64(deltaMs))
|
||||
}
|
||||
}
|
||||
lastNetBytesReceived = networkStats.BytesReceived
|
||||
lastNetBytesTransmitted = networkStats.BytesTransmitted
|
||||
lastNetTime = now
|
||||
|
||||
return SystemInfo{
|
||||
Uptime: getUptime(),
|
||||
@@ -461,9 +576,48 @@ func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
|
||||
BytesTransmitted: networkStats.BytesTransmitted,
|
||||
CPUUsage: cpuInfo.Usage,
|
||||
MemoryUsage: memoryInfo.Usage,
|
||||
DiskUsage: diskInfo.Usage,
|
||||
Load1: loadInfo.Load1,
|
||||
Load5: loadInfo.Load5,
|
||||
Load15: loadInfo.Load15,
|
||||
TCPConns: connInfo.TCPConns,
|
||||
UDPConns: connInfo.UDPConns,
|
||||
NetInSpeed: netInSpeed,
|
||||
NetOutSpeed: netOutSpeed,
|
||||
}
|
||||
}
|
||||
|
||||
// encryptPayload 加密 JSON 数据,返回加密后的消息字节(若加密失败则回退到原始数据)
|
||||
func (w *WebSocketReporter) encryptPayload(jsonData []byte) []byte {
|
||||
if w.aesCrypto == nil {
|
||||
return jsonData
|
||||
}
|
||||
|
||||
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
|
||||
return jsonData
|
||||
}
|
||||
|
||||
encryptedMessage := map[string]interface{}{
|
||||
"encrypted": true,
|
||||
"data": encryptedData,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
messageData, err := json.Marshal(encryptedMessage)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
|
||||
return jsonData
|
||||
}
|
||||
return messageData
|
||||
}
|
||||
|
||||
// metricEnvelope wraps SystemInfo with a type field for fast identification on the panel side.
|
||||
type metricEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Data SystemInfo `json:"data"`
|
||||
}
|
||||
|
||||
// sendSystemInfo 发送系统信息
|
||||
func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
|
||||
w.connMutex.Lock()
|
||||
@@ -473,42 +627,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
|
||||
return fmt.Errorf("连接未建立")
|
||||
}
|
||||
|
||||
// 转换为JSON
|
||||
jsonData, err := json.Marshal(sysInfo)
|
||||
// 使用 type:"metric" 信封包装,Panel 可通过 type 字段直接识别指标消息
|
||||
envelope := metricEnvelope{Type: "metric", Data: sysInfo}
|
||||
jsonData, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化系统信息失败: %v", err)
|
||||
}
|
||||
|
||||
var messageData []byte
|
||||
messageData := w.encryptPayload(jsonData)
|
||||
|
||||
// 如果有加密器,则加密数据
|
||||
if w.aesCrypto != nil {
|
||||
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
} else {
|
||||
// 创建加密消息包装器
|
||||
encryptedMessage := map[string]interface{}{
|
||||
"encrypted": true,
|
||||
"data": encryptedData,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
messageData, err = json.Marshal(encryptedMessage)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messageData = jsonData
|
||||
}
|
||||
|
||||
// 设置写入超时
|
||||
w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
if err := w.conn.WriteMessage(websocket.TextMessage, messageData); err != nil {
|
||||
w.connected = false // 标记连接已断开
|
||||
w.connected = false
|
||||
return fmt.Errorf("写入消息失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -517,23 +648,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
|
||||
|
||||
// receiveMessages 接收服务端发送的消息
|
||||
func (w *WebSocketReporter) receiveMessages() {
|
||||
// 获取连接引用一次即可,连接生命周期由 handleConnection 管理
|
||||
w.connMutex.Lock()
|
||||
conn := w.conn
|
||||
w.connMutex.Unlock()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
default:
|
||||
w.connMutex.Lock()
|
||||
conn := w.conn
|
||||
connected := w.connected
|
||||
w.connMutex.Unlock()
|
||||
|
||||
if conn == nil || !connected {
|
||||
return
|
||||
}
|
||||
|
||||
// 设置读取超时
|
||||
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
@@ -621,12 +748,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
|
||||
if cmdMsg.Type != "call" {
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
// 所有命令统一异步执行,避免阻塞消息接收循环
|
||||
go w.routeCommand(cmdMsg)
|
||||
}
|
||||
} else {
|
||||
// 处理普通消息
|
||||
@@ -637,12 +760,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
return
|
||||
}
|
||||
if cmdMsg.Type != "call" {
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
// 所有命令统一异步执行,避免阻塞消息接收循环
|
||||
go w.routeCommand(cmdMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +845,13 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
response.Data = tcpPingResult
|
||||
// needSaveConfig = false (默认值)
|
||||
|
||||
// Service monitor check (read-only)
|
||||
case "ServiceMonitorCheck":
|
||||
var checkResult ServiceMonitorCheckResult
|
||||
checkResult, err = w.handleServiceMonitorCheck(cmd.Data)
|
||||
response.Type = "ServiceMonitorCheckResponse"
|
||||
response.Data = checkResult
|
||||
|
||||
// Protocol blocking switches
|
||||
case "SetProtocol":
|
||||
err = w.handleSetProtocol(cmd.Data)
|
||||
@@ -1309,30 +1435,7 @@ func (w *WebSocketReporter) sendResponse(response CommandResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
var messageData []byte
|
||||
|
||||
// 如果有加密器,则加密数据
|
||||
if w.aesCrypto != nil {
|
||||
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 加密响应失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
} else {
|
||||
// 创建加密消息包装器
|
||||
encryptedMessage := map[string]interface{}{
|
||||
"encrypted": true,
|
||||
"data": encryptedData,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
messageData, err = json.Marshal(encryptedMessage)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 序列化加密响应失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messageData = jsonData
|
||||
}
|
||||
messageData := w.encryptPayload(jsonData)
|
||||
|
||||
// 检查消息大小,如果超过10MB则记录警告
|
||||
if len(messageData) > 10*1024*1024 {
|
||||
@@ -1381,17 +1484,21 @@ func getNetworkStats() NetworkStats {
|
||||
return stats
|
||||
}
|
||||
|
||||
// 汇总所有非回环接口的流量
|
||||
for _, io := range ioCounters {
|
||||
// 跳过回环接口
|
||||
if io.Name == "lo" || strings.HasPrefix(io.Name, "lo") {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.BytesReceived += io.BytesRecv
|
||||
stats.BytesTransmitted += io.BytesSent
|
||||
}
|
||||
|
||||
if lastNetBytesReceived > 0 && stats.BytesReceived >= lastNetBytesReceived {
|
||||
stats.BytesRecvDelta = stats.BytesReceived - lastNetBytesReceived
|
||||
}
|
||||
if lastNetBytesTransmitted > 0 && stats.BytesTransmitted >= lastNetBytesTransmitted {
|
||||
stats.BytesSentDelta = stats.BytesTransmitted - lastNetBytesTransmitted
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -1399,8 +1506,8 @@ func getNetworkStats() NetworkStats {
|
||||
func getCPUInfo() CPUInfo {
|
||||
var cpuInfo CPUInfo
|
||||
|
||||
// 获取CPU使用率
|
||||
percentages, err := cpu.Percent(time.Second, false)
|
||||
// 获取CPU使用率 (non-blocking)
|
||||
percentages, err := cpu.Percent(0, false)
|
||||
if err == nil && len(percentages) > 0 {
|
||||
cpuInfo.Usage = percentages[0]
|
||||
}
|
||||
@@ -1422,6 +1529,69 @@ func getMemoryInfo() MemoryInfo {
|
||||
return memInfo
|
||||
}
|
||||
|
||||
// getDiskInfo 获取磁盘信息
|
||||
func getDiskInfo() DiskInfo {
|
||||
var diskInfo DiskInfo
|
||||
|
||||
usage, err := disk.Usage("/")
|
||||
if err != nil {
|
||||
return diskInfo
|
||||
}
|
||||
|
||||
diskInfo.Usage = usage.UsedPercent
|
||||
|
||||
return diskInfo
|
||||
}
|
||||
|
||||
// getLoadInfo 获取负载信息
|
||||
func getLoadInfo() LoadInfo {
|
||||
var loadInfo LoadInfo
|
||||
|
||||
avg, err := load.Avg()
|
||||
if err != nil {
|
||||
return loadInfo
|
||||
}
|
||||
|
||||
loadInfo.Load1 = avg.Load1
|
||||
loadInfo.Load5 = avg.Load5
|
||||
loadInfo.Load15 = avg.Load15
|
||||
|
||||
return loadInfo
|
||||
}
|
||||
|
||||
// getConnectionInfo 获取连接信息
|
||||
func getConnectionInfo() ConnectionInfo {
|
||||
now := time.Now().UnixMilli()
|
||||
const refreshEveryMs = int64((15 * time.Second) / time.Millisecond)
|
||||
|
||||
connInfoCachedMu.Lock()
|
||||
if connInfoCachedAt > 0 && now-connInfoCachedAt < refreshEveryMs {
|
||||
v := connInfoCached
|
||||
connInfoCachedMu.Unlock()
|
||||
return v
|
||||
}
|
||||
connInfoCachedMu.Unlock()
|
||||
|
||||
var connInfo ConnectionInfo
|
||||
|
||||
connStats, err := psnet.Connections("tcp")
|
||||
if err == nil {
|
||||
connInfo.TCPConns = int64(len(connStats))
|
||||
}
|
||||
|
||||
udpStats, err := psnet.Connections("udp")
|
||||
if err == nil {
|
||||
connInfo.UDPConns = int64(len(udpStats))
|
||||
}
|
||||
|
||||
connInfoCachedMu.Lock()
|
||||
connInfoCached = connInfo
|
||||
connInfoCachedAt = now
|
||||
connInfoCachedMu.Unlock()
|
||||
|
||||
return connInfo
|
||||
}
|
||||
|
||||
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
|
||||
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
|
||||
|
||||
@@ -1503,6 +1673,210 @@ func (w *WebSocketReporter) handleTcpPing(data interface{}) (TcpPingResponse, er
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// handleServiceMonitorCheck executes a service monitor check on this node.
|
||||
// It always returns a result (command execution is considered successful even if the check fails).
|
||||
func (w *WebSocketReporter) handleServiceMonitorCheck(data interface{}) (ServiceMonitorCheckResult, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ServiceMonitorCheckResult{}, fmt.Errorf("序列化检查数据失败: %v", err)
|
||||
}
|
||||
|
||||
var req ServiceMonitorCheckRequest
|
||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||
return ServiceMonitorCheckResult{}, fmt.Errorf("解析检查请求失败: %v", err)
|
||||
}
|
||||
|
||||
checkType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
target := strings.TrimSpace(req.Target)
|
||||
res := ServiceMonitorCheckResult{MonitorID: req.MonitorID}
|
||||
|
||||
if checkType != "tcp" && checkType != "icmp" {
|
||||
res.Success = false
|
||||
res.ErrorMessage = "不支持的检查类型"
|
||||
return res, nil
|
||||
}
|
||||
if target == "" {
|
||||
res.Success = false
|
||||
res.ErrorMessage = "检查目标为空"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
timeoutSec := req.TimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 5
|
||||
}
|
||||
timeout := time.Duration(timeoutSec) * time.Second
|
||||
|
||||
start := time.Now()
|
||||
|
||||
switch checkType {
|
||||
case "tcp":
|
||||
// Validate and normalize host:port.
|
||||
_, _, splitErr := net.SplitHostPort(target)
|
||||
if splitErr != nil {
|
||||
res.Success = false
|
||||
res.ErrorMessage = "无效的TCP目标"
|
||||
res.LatencyMs = float64(time.Since(start).Milliseconds())
|
||||
return res, nil
|
||||
}
|
||||
conn, dialErr := net.DialTimeout("tcp", target, timeout)
|
||||
res.LatencyMs = float64(time.Since(start).Milliseconds())
|
||||
if dialErr != nil {
|
||||
res.Success = false
|
||||
res.ErrorMessage = dialErr.Error()
|
||||
return res, nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
res.Success = true
|
||||
return res, nil
|
||||
|
||||
case "icmp":
|
||||
rtt, pingErr := icmpPing(target, timeout)
|
||||
res.LatencyMs = float64(rtt.Milliseconds())
|
||||
if pingErr != nil {
|
||||
res.Success = false
|
||||
res.ErrorMessage = pingErr.Error()
|
||||
return res, nil
|
||||
}
|
||||
res.Success = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res.Success = false
|
||||
res.ErrorMessage = "未知错误"
|
||||
res.LatencyMs = float64(time.Since(start).Milliseconds())
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func icmpPing(target string, timeout time.Duration) (time.Duration, error) {
|
||||
start := time.Now()
|
||||
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return time.Since(start), fmt.Errorf("无效的ICMP目标")
|
||||
}
|
||||
// Avoid accepting URL-like targets.
|
||||
if strings.Contains(target, "://") {
|
||||
return time.Since(start), fmt.Errorf("无效的ICMP目标")
|
||||
}
|
||||
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
|
||||
target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]")
|
||||
}
|
||||
|
||||
ipAddr, err := net.ResolveIPAddr("ip", target)
|
||||
if err != nil || ipAddr == nil || ipAddr.IP == nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("unknown address")
|
||||
}
|
||||
return time.Since(start), fmt.Errorf("解析目标失败: %v", err)
|
||||
}
|
||||
|
||||
isV4 := ipAddr.IP.To4() != nil
|
||||
listenAddr := "0.0.0.0"
|
||||
proto := 1
|
||||
var echoType icmp.Type = ipv4.ICMPTypeEcho
|
||||
var echoReplyType icmp.Type = ipv4.ICMPTypeEchoReply
|
||||
networks := []string{"udp4", "ip4:icmp"}
|
||||
if !isV4 {
|
||||
listenAddr = "::"
|
||||
proto = 58
|
||||
echoType = ipv6.ICMPTypeEchoRequest
|
||||
echoReplyType = ipv6.ICMPTypeEchoReply
|
||||
networks = []string{"udp6", "ip6:ipv6-icmp"}
|
||||
}
|
||||
|
||||
var conn *icmp.PacketConn
|
||||
selectedNetwork := ""
|
||||
var lastErr error
|
||||
for _, nw := range networks {
|
||||
c, err := icmp.ListenPacket(nw, listenAddr)
|
||||
if err == nil {
|
||||
conn = c
|
||||
selectedNetwork = nw
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if conn == nil {
|
||||
if lastErr != nil {
|
||||
return time.Since(start), fmt.Errorf("创建ICMP连接失败: %v", lastErr)
|
||||
}
|
||||
return time.Since(start), fmt.Errorf("创建ICMP连接失败")
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
id := os.Getpid() & 0xffff
|
||||
seq := 1
|
||||
|
||||
wm := icmp.Message{
|
||||
Type: echoType,
|
||||
Code: 0,
|
||||
Body: &icmp.Echo{
|
||||
ID: id,
|
||||
Seq: seq,
|
||||
Data: []byte("FLVX-PING"),
|
||||
},
|
||||
}
|
||||
wb, err := wm.Marshal(nil)
|
||||
if err != nil {
|
||||
return time.Since(start), err
|
||||
}
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
var dst net.Addr
|
||||
if strings.HasPrefix(selectedNetwork, "udp") {
|
||||
dst = &net.UDPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone}
|
||||
} else {
|
||||
dst = &net.IPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone}
|
||||
}
|
||||
|
||||
if _, err := conn.WriteTo(wb, dst); err != nil {
|
||||
return time.Since(start), err
|
||||
}
|
||||
|
||||
addrIP := func(a net.Addr) net.IP {
|
||||
switch v := a.(type) {
|
||||
case *net.IPAddr:
|
||||
return v.IP
|
||||
case *net.UDPAddr:
|
||||
return v.IP
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
rb := make([]byte, 1500)
|
||||
for {
|
||||
n, peer, err := conn.ReadFrom(rb)
|
||||
if err != nil {
|
||||
return time.Since(start), err
|
||||
}
|
||||
if p := addrIP(peer); p != nil && !p.Equal(ipAddr.IP) {
|
||||
continue
|
||||
}
|
||||
rm, err := icmp.ParseMessage(proto, rb[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if rm.Type != echoReplyType {
|
||||
continue
|
||||
}
|
||||
echo, ok := rm.Body.(*icmp.Echo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if echo.Seq != seq {
|
||||
continue
|
||||
}
|
||||
// For non-privileged endpoints, the kernel may choose the ID.
|
||||
if !strings.HasPrefix(selectedNetwork, "udp") && echo.ID != id {
|
||||
continue
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
}
|
||||
|
||||
// tcpPingHost 执行TCP连接测试,返回平均连接时间和失败率
|
||||
func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float64, error) {
|
||||
var totalTime float64
|
||||
|
||||
+69
-5
@@ -25,10 +25,62 @@ get_architecture() {
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
|
||||
if [[ "$PROXY_ENABLED" == "false" ]]; then
|
||||
echo "$url"
|
||||
return
|
||||
fi
|
||||
|
||||
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
|
||||
|
||||
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
|
||||
proxy="${proxy%/}"
|
||||
else
|
||||
proxy="https://${proxy%/}"
|
||||
fi
|
||||
|
||||
echo "${proxy}/${url}"
|
||||
}
|
||||
|
||||
ask_proxy_config() {
|
||||
if [[ -n "$PROXY_ENABLED" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "$PROXY_URL" ]]; then
|
||||
PROXY_ENABLED="true"
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " GitHub 加速配置"
|
||||
echo "==============================================="
|
||||
if ! read -r -p "是否开启 GitHub 加速? (Y/n): " proxy_choice; then
|
||||
proxy_choice=""
|
||||
fi
|
||||
case "$proxy_choice" in
|
||||
n|N)
|
||||
PROXY_ENABLED="false"
|
||||
echo "已关闭加速,将直连 GitHub"
|
||||
;;
|
||||
*)
|
||||
PROXY_ENABLED="true"
|
||||
if ! read -r -p "加速地址 (默认 gcode.hostcentral.cc): " input_url; then
|
||||
input_url=""
|
||||
fi
|
||||
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
|
||||
echo "已开启加速: $PROXY_URL"
|
||||
;;
|
||||
esac
|
||||
echo "==============================================="
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
@@ -81,9 +133,14 @@ build_download_url() {
|
||||
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||
}
|
||||
|
||||
# 解析版本并构建下载地址
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||
ensure_download_url_initialized() {
|
||||
if [[ -n "${DOWNLOAD_URL:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
RESOLVED_VERSION=$(resolve_version) || return 1
|
||||
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -210,6 +267,10 @@ done
|
||||
# 安装功能
|
||||
install_flux_agent() {
|
||||
echo "🚀 开始安装 flux_agent..."
|
||||
|
||||
ask_proxy_config
|
||||
ensure_download_url_initialized || exit 1
|
||||
|
||||
get_config_params
|
||||
|
||||
# 检查并安装 tcpkill
|
||||
@@ -308,6 +369,9 @@ update_flux_agent() {
|
||||
echo "❌ flux_agent 未安装,请先选择安装。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ask_proxy_config
|
||||
ensure_download_url_initialized || return 1
|
||||
|
||||
echo "📥 使用下载地址: $DOWNLOAD_URL"
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-17
|
||||
@@ -1,29 +0,0 @@
|
||||
## Context
|
||||
|
||||
FLVX is a distributed system consisting of a central management panel (Backend + Frontend) and multiple forwarding agents (Nodes). The backend manages configuration, users, and billing, while agents handle the actual traffic forwarding using a modified GOST v3 stack. Communication between the panel and agents is secured and synchronized.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Document the high-level architecture of the system.
|
||||
- Describe the data model for users, tunnels, and nodes.
|
||||
- Explain the communication protocol between Panel and Agent.
|
||||
- Detail the authentication and authorization mechanisms.
|
||||
|
||||
**Non-Goals:**
|
||||
- Refactoring the existing architecture.
|
||||
- Detailed code-level documentation of every function.
|
||||
- Changing the database schema.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Architecture**: The system follows a client-server model where the Panel acts as the server and Agents act as clients that pull configuration and push status.
|
||||
- **Data Model**: Core entities are Users, Nodes (Agents), Tunnels (Groups of rules), and Forwarding Rules.
|
||||
- **Communication**: Agents use a heartbeat mechanism to report status and fetch configuration updates. The protocol uses AES encryption with a pre-shared key (Node Secret).
|
||||
- **Authentication**: JWT for Frontend-Backend communication; API Key (Node Secret) for Agent-Backend communication.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Security**: The security of the agent communication relies heavily on the secrecy of the Node Secret.
|
||||
- **Scalability**: Centralized management might become a bottleneck with a very large number of agents.
|
||||
- **Complexity**: Synchronizing state across distributed agents introduces complexity in handling failures and inconsistencies.
|
||||
@@ -1,28 +0,0 @@
|
||||
## Why
|
||||
|
||||
The current system lacks formal specification documents describing its capabilities. This makes it difficult for new developers to understand the intended behavior and for existing developers to ensure consistency when adding new features. Documenting the existing functionality will serve as a baseline for future changes and help in identifying gaps or inconsistencies.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Create formal specification documents for core system capabilities.
|
||||
- Document user management features (roles, limits).
|
||||
- Document tunnel and forwarding management (protocols, rules).
|
||||
- Document agent interactions and management.
|
||||
- Document system-level configurations.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `user-management`: Authentication, user roles, and resource limits.
|
||||
- `tunnel-management`: Creation and management of traffic tunnels (TCP/UDP).
|
||||
- `forwarding-rules`: Configuration of port forwarding and tunnel forwarding rules, including rate limiting.
|
||||
- `agent-management`: Management of forwarding agents, including installation and configuration synchronization.
|
||||
- `system-config`: Global system settings and configurations.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None, as this is a documentation effort for existing features. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Documentation**: New spec files in `openspec/specs/`.
|
||||
- **No Code Changes**: This change is purely documentation-focused.
|
||||
@@ -1,29 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Agent Registration
|
||||
The system SHALL require new agents (Nodes) to register using a unique node key/secret.
|
||||
|
||||
#### Scenario: Node Connection
|
||||
- **WHEN** a new agent starts up with a valid configuration
|
||||
- **THEN** it connects to the backend and is registered as active.
|
||||
|
||||
### Requirement: Heartbeat Monitoring
|
||||
The system SHALL monitor the status of all registered agents using periodic heartbeats.
|
||||
|
||||
#### Scenario: Agent Status
|
||||
- **WHEN** an agent sends periodic heartbeats
|
||||
- **THEN** the system updates its last-seen timestamp and marks it as online.
|
||||
|
||||
### Requirement: Configuration Sync
|
||||
The system MUST synchronize configuration changes (tunnels, rules) to agents securely and reliably.
|
||||
|
||||
#### Scenario: Push Config
|
||||
- **WHEN** a configuration change is made in the panel
|
||||
- **THEN** the agent receives the updated configuration via the next heartbeat or push mechanism.
|
||||
|
||||
### Requirement: Version Management
|
||||
The system SHOULD track the version of the agent software running on each node.
|
||||
|
||||
#### Scenario: Version Reporting
|
||||
- **WHEN** an agent connects
|
||||
- **THEN** it reports its version number to the backend for tracking.
|
||||
@@ -1,22 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Port Forwarding Rules
|
||||
The system SHALL support configuring port forwarding rules, defining the listening port on the node and the destination IP/port.
|
||||
|
||||
#### Scenario: Rule Configuration
|
||||
- **WHEN** an admin creates a port forwarding rule
|
||||
- **THEN** the rule is stored and synchronized to the assigned node.
|
||||
|
||||
### Requirement: Rate Limiting
|
||||
The system SHALL support configuring bandwidth rate limits for tunnels and users.
|
||||
|
||||
#### Scenario: Bandwidth Restriction
|
||||
- **WHEN** a rate limit is applied to a user
|
||||
- **THEN** their total bandwidth usage does not exceed the specified limit across all their tunnels.
|
||||
|
||||
### Requirement: Traffic Accounting
|
||||
The system MUST track incoming and outgoing traffic volume for each tunnel and user for billing and quota enforcement.
|
||||
|
||||
#### Scenario: Traffic Calculation
|
||||
- **WHEN** traffic flows through a tunnel
|
||||
- **THEN** the system increments the user's traffic usage counter accurately.
|
||||
@@ -1,22 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Site Settings
|
||||
The system SHALL allow customization of the site title, logo, and other branding elements.
|
||||
|
||||
#### Scenario: Update Branding
|
||||
- **WHEN** an administrator changes the site logo
|
||||
- **THEN** the new logo is displayed across the interface.
|
||||
|
||||
### Requirement: Notification Settings
|
||||
The system SHALL support configuring notifications for user registration, traffic limits, and other events.
|
||||
|
||||
#### Scenario: User Limit Alert
|
||||
- **WHEN** a user approaches their traffic quota
|
||||
- **THEN** a notification is sent to the user/admin.
|
||||
|
||||
### Requirement: Backup & Restore
|
||||
The system SHOULD provide a mechanism to backup and restore database configurations.
|
||||
|
||||
#### Scenario: Restore Database
|
||||
- **WHEN** initiating a restore operation
|
||||
- **THEN** the system accepts a valid backup file and overwrites the current database state.
|
||||
@@ -1,22 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tunnel Creation
|
||||
The system SHALL allow administrators to create tunnels, specifying protocols (TCP, UDP), listening ports, and destination endpoints.
|
||||
|
||||
#### Scenario: Create TCP Tunnel
|
||||
- **WHEN** an admin creates a new TCP tunnel configuration
|
||||
- **THEN** the backend stores the tunnel definition and assigns it to a node.
|
||||
|
||||
### Requirement: Tunnel Forwarding Configuration
|
||||
The system SHALL support both standard port forwarding (listening on a port and forwarding to a destination) and tunnel forwarding modes.
|
||||
|
||||
#### Scenario: Configure Port Forwarding
|
||||
- **WHEN** configuring a tunnel for port forwarding
|
||||
- **THEN** traffic arriving at the specified port is forwarded to the destination IP:port.
|
||||
|
||||
### Requirement: Tunnel Assignment
|
||||
The system SHALL allow tunnels to be assigned to specific users, tracking their usage against the user's quota.
|
||||
|
||||
#### Scenario: User Tunnel Usage
|
||||
- **WHEN** a user is assigned a tunnel
|
||||
- **THEN** traffic passing through that tunnel is accounted for under the user's usage.
|
||||
@@ -1,29 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User Registration
|
||||
The system SHALL allow new users to register an account with a username and password.
|
||||
|
||||
#### Scenario: Successful Registration
|
||||
- **WHEN** a user submits valid registration details
|
||||
- **THEN** a new user account is created and the user can log in.
|
||||
|
||||
### Requirement: User Authentication
|
||||
The system MUST authenticate users using JWT tokens. The `Authorization` header MUST contain the raw token without a `Bearer` prefix.
|
||||
|
||||
#### Scenario: Valid Login
|
||||
- **WHEN** a user provides correct credentials
|
||||
- **THEN** the system returns a valid JWT token.
|
||||
|
||||
### Requirement: Role Management
|
||||
The system SHALL support different user roles, specifically Administrator and Regular User, with distinct permissions.
|
||||
|
||||
#### Scenario: Admin Access
|
||||
- **WHEN** an administrator logs in
|
||||
- **THEN** they have access to system-wide settings and all user management functions.
|
||||
|
||||
### Requirement: Resource Quotas
|
||||
The system SHALL allow administrators to set traffic limits and connection limits for individual users.
|
||||
|
||||
#### Scenario: Traffic Limit Enforcement
|
||||
- **WHEN** a user exceeds their traffic quota
|
||||
- **THEN** the system prevents further traffic forwarding for that user.
|
||||
@@ -1,30 +0,0 @@
|
||||
## 1. User Management Verification
|
||||
|
||||
- [ ] 1.1 Verify User Registration logic in backend
|
||||
- [ ] 1.2 Verify JWT Authentication implementation
|
||||
- [ ] 1.3 Verify Role Management checks
|
||||
- [ ] 1.4 Verify Quota Enforcement logic
|
||||
|
||||
## 2. Tunnel Management Verification
|
||||
|
||||
- [ ] 2.1 Verify Tunnel Creation API
|
||||
- [ ] 2.2 Verify Forwarding Configuration parsing
|
||||
- [ ] 2.3 Verify Tunnel Assignment logic
|
||||
|
||||
## 3. Forwarding Rules Verification
|
||||
|
||||
- [ ] 3.1 Verify Port Forwarding rule processing
|
||||
- [ ] 3.2 Verify Rate Limiting implementation (token bucket/leaky bucket?)
|
||||
- [ ] 3.3 Verify Traffic Accounting mechanisms
|
||||
|
||||
## 4. Agent Management Verification
|
||||
|
||||
- [ ] 4.1 Verify Agent Registration handshake
|
||||
- [ ] 4.2 Verify Heartbeat processing
|
||||
- [ ] 4.3 Verify Config Sync protocol
|
||||
|
||||
## 5. System Config Verification
|
||||
|
||||
- [ ] 5.1 Verify Site Settings API
|
||||
- [ ] 5.2 Verify Notification triggers
|
||||
- [ ] 5.3 Verify Backup/Restore functionality
|
||||
@@ -1,20 +0,0 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@@ -1,52 +0,0 @@
|
||||
# Project Overview
|
||||
|
||||
**Name**: FLVX (Flux Panel)
|
||||
**Description**: Traffic forwarding management system built on a forked GOST v3 stack. It provides a web-based panel for managing traffic tunnels, users, and forwarding rules.
|
||||
**Repository**: Monorepo containing Admin API, Web UI, and Forwarding Agent.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (`go-backend/`)
|
||||
- **Language**: Go
|
||||
- **Database**: SQLite (default), PostgreSQL (supported)
|
||||
- **Framework**: Standard library `net/http` (no heavy framework)
|
||||
- **ORM**: None (Raw SQL via `database/sql`)
|
||||
|
||||
### Frontend (`vite-frontend/`)
|
||||
- **Framework**: React
|
||||
- **Build Tool**: Vite (using `rolldown-vite` experimental bundler)
|
||||
- **UI Library**: HeroUI
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Mode**: Hybrid (Desktop + Mobile WebView support)
|
||||
|
||||
### Agent (`go-gost/`)
|
||||
- **Language**: Go
|
||||
- **Base**: Fork of `gost` v3
|
||||
- **Extensions**: Custom extensions in `go-gost/x/`
|
||||
|
||||
### Infrastructure
|
||||
- **Containerization**: Docker, Docker Compose (v4/v6)
|
||||
- **CI/CD**: GitHub Actions
|
||||
- **Installers**: Shell scripts (`panel_install.sh`, `install.sh`)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Panel**: Central management server (Go Backend + React Frontend).
|
||||
- **Agent**: Forwarding node running on remote servers.
|
||||
- **Communication**:
|
||||
- Frontend -> Backend: REST API (JWT Auth, raw token in header).
|
||||
- Agent -> Backend: AES-encrypted heartbeat/config sync.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Authentication**: `Authorization` header expects raw JWT token (do NOT add `Bearer ` prefix).
|
||||
- **API Response**: Standard envelope `{code, msg, data, ts}` (code 0 = success).
|
||||
- **Database**: Backend uses raw SQL queries. Do not introduce an ORM.
|
||||
- **File Structure**: Flat monorepo with language-prefixed directories (`go-backend`, `go-gost`).
|
||||
- **Protobuf**: Do not edit generated `.pb.go` files manually.
|
||||
|
||||
## Development
|
||||
|
||||
- **Backend Build**: `cd go-backend && make build`
|
||||
- **Frontend Dev**: `cd vite-frontend && npm run dev`
|
||||
- **Agent Run**: `cd go-gost && go run .`
|
||||
+71
-5
@@ -13,10 +13,62 @@ REPO="Sagit-chu/flux-panel"
|
||||
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
|
||||
PINNED_VERSION=""
|
||||
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
|
||||
if [[ "$PROXY_ENABLED" == "false" ]]; then
|
||||
echo "$url"
|
||||
return
|
||||
fi
|
||||
|
||||
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
|
||||
|
||||
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
|
||||
proxy="${proxy%/}"
|
||||
else
|
||||
proxy="https://${proxy%/}"
|
||||
fi
|
||||
|
||||
echo "${proxy}/${url}"
|
||||
}
|
||||
|
||||
ask_proxy_config() {
|
||||
if [[ -n "$PROXY_ENABLED" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "$PROXY_URL" ]]; then
|
||||
PROXY_ENABLED="true"
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " GitHub 加速配置"
|
||||
echo "==============================================="
|
||||
if ! read -r -p "是否开启 GitHub 加速? (Y/n): " proxy_choice; then
|
||||
proxy_choice=""
|
||||
fi
|
||||
case "$proxy_choice" in
|
||||
n|N)
|
||||
PROXY_ENABLED="false"
|
||||
echo "已关闭加速,将直连 GitHub"
|
||||
;;
|
||||
*)
|
||||
PROXY_ENABLED="true"
|
||||
if ! read -r -p "加速地址 (默认 gcode.hostcentral.cc): " input_url; then
|
||||
input_url=""
|
||||
fi
|
||||
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
|
||||
echo "已开启加速: $PROXY_URL"
|
||||
;;
|
||||
esac
|
||||
echo "==============================================="
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
@@ -70,9 +122,14 @@ set_compose_urls_by_version() {
|
||||
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v6.yml")
|
||||
}
|
||||
|
||||
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
set_compose_urls_by_version "$RESOLVED_VERSION"
|
||||
ensure_compose_urls_initialized() {
|
||||
if [[ -n "${DOCKER_COMPOSEV4_URL:-}" && -n "${DOCKER_COMPOSEV6_URL:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
RESOLVED_VERSION=$(resolve_version) || return 1
|
||||
set_compose_urls_by_version "$RESOLVED_VERSION"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -374,6 +431,10 @@ get_config_params() {
|
||||
# 安装功能
|
||||
install_panel() {
|
||||
echo "🚀 开始安装面板..."
|
||||
|
||||
ask_proxy_config
|
||||
ensure_compose_urls_initialized || return 1
|
||||
|
||||
check_docker
|
||||
get_config_params
|
||||
|
||||
@@ -425,6 +486,7 @@ EOF
|
||||
# 更新功能
|
||||
update_panel() {
|
||||
echo "🔄 开始更新面板..."
|
||||
ask_proxy_config
|
||||
check_docker
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
@@ -506,6 +568,8 @@ migrate_to_postgres() {
|
||||
|
||||
if [[ ! -f "docker-compose.yml" ]]; then
|
||||
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载..."
|
||||
ask_proxy_config
|
||||
ensure_compose_urls_initialized || return 1
|
||||
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
||||
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
||||
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
|
||||
@@ -581,6 +645,8 @@ uninstall_panel() {
|
||||
|
||||
if [[ ! -f "docker-compose.yml" ]]; then
|
||||
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载以完成卸载..."
|
||||
ask_proxy_config
|
||||
ensure_compose_urls_initialized || return 1
|
||||
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
||||
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
||||
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 013 - 隧道入口端口校验不严格修复
|
||||
|
||||
## Issue
|
||||
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
|
||||
|
||||
## 修复方案
|
||||
|
||||
在 `syncTunnelForwardsEntryPorts` 中实现逐节点端口分配:
|
||||
|
||||
- **旧入口节点**:保留原端口不变
|
||||
- **新入口节点**:通过 `resolvePortForNewEntryNode` 决策:
|
||||
- 参考端口在范围内且未被占用 → 跟随设置一样的端口
|
||||
- 参考端口超出范围或被占用 → 通过 `pickRandomPortForNode` 为该节点单独随机分配
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 1. 实现 `pickRandomPortForNode` 辅助方法(单节点端口随机分配)
|
||||
- [x] 2. 实现 `resolvePortForNewEntryNode` 方法(端口决策逻辑)
|
||||
- [x] 3. 重写 `syncTunnelForwardsEntryPorts` 为逐节点分配
|
||||
- [x] 4. 移除不再需要的 `isPortValidForAllEntryNodes`
|
||||
- [x] 5. 构建通过 + 全量测试通过
|
||||
@@ -0,0 +1,104 @@
|
||||
# 037 - Monitoring: Node Metrics + Service Health Checks
|
||||
|
||||
## Context
|
||||
This worktree introduces a monitoring feature set:
|
||||
- Node runtime metrics streamed via WebSocket (agent -> panel -> admin clients)
|
||||
- Metrics ingestion + retention in panel DB
|
||||
- Service monitoring (TCP/ICMP checks only) + result storage
|
||||
- Frontend monitor view (charts + monitor CRUD + run + results)
|
||||
- Dedicated monitor page (`/monitor`) that works for authorized non-admin users
|
||||
|
||||
The initial implementation landed without a plan doc and had several correctness issues (API JSON shape mismatch, wrong time units, contract test hangs under SQLite single-connection mode, etc.). This plan documents what exists, what was fixed, and what is still incomplete/needs decisions.
|
||||
|
||||
## Goals
|
||||
- Metrics endpoints return stable JSON fields matching frontend types.
|
||||
- Contract tests cover metrics + monitor CRUD and are deterministic.
|
||||
- WebSocket metric messages update node cards correctly.
|
||||
- Monitoring view queries the correct time range and renders timestamps correctly.
|
||||
- go-gost/x unit tests do not depend on a local config.json.
|
||||
|
||||
## Non-goals (for this plan)
|
||||
- A full monitor scheduling system (jitter/backoff/concurrency budgets/per-monitor next-run) beyond the current simple loop.
|
||||
- Building a full alerting pipeline (notifications, thresholds, paging).
|
||||
|
||||
## Current Status (as of this worktree)
|
||||
- Backend models updated with JSON tags for monitoring structs.
|
||||
- Handler endpoints for metrics + service monitors added.
|
||||
- Metrics ingestion service implemented with buffering + retention pruning.
|
||||
- Health checker implemented (panel-side when `nodeId == 0`; node-executed via WS when `nodeId > 0`) and background jobs wired.
|
||||
- Frontend monitor view added; build passes.
|
||||
- Contract tests for monitoring added.
|
||||
- Monitoring endpoints are accessible by admin users and non-admin users explicitly authorized by admin (via `monitor_permission`).
|
||||
- Frontend exposes monitoring via a dedicated `/monitor` page; admin can grant/revoke monitoring permission from the User permissions modal.
|
||||
- Frontend includes tunnel metrics charts (backed by `/api/v1/monitor/tunnels` list + `/api/v1/monitor/tunnels/:id/metrics`).
|
||||
|
||||
## Known Semantics Gaps (need decisions)
|
||||
- `service_monitor.intervalSec` is best-effort (checker ticks every 30s; intervals shorter than that won't run faster).
|
||||
- `service_monitor_result.success` is stored as int (0/1). Frontend currently treats it as number; decide if API should expose boolean.
|
||||
|
||||
## Admin Authorization API
|
||||
Monitoring permission management (admin-only):
|
||||
- `GET /api/v1/monitor/permission/list`
|
||||
- `POST /api/v1/monitor/permission/assign` body: `{ "userId": 123 }`
|
||||
- `POST /api/v1/monitor/permission/remove` body: `{ "userId": 123 }`
|
||||
|
||||
## Checklist
|
||||
|
||||
### Phase 1: Correctness + Contracts
|
||||
- [x] Align monitoring JSON response fields with frontend/contract expectations (add json tags or DTO mapping).
|
||||
- [x] Fix frontend monitor time range query (use ms start/end; avoid `start=60`).
|
||||
- [x] Fix frontend timestamp rendering (treat timestamp as UnixMilli).
|
||||
- [x] Fix node realtime metric speed field compatibility (support snake_case speed fields).
|
||||
- [x] Fix SQLite contract hang by ensuring tunnel-entry precheck uses tx-safe DB reads (no nested connection acquisition).
|
||||
- [x] Ensure monitoring contract tests pass.
|
||||
|
||||
### Phase 2: Semantics Alignment (Decide + Implement)
|
||||
- [x] Decide "service monitors run where":
|
||||
- Option B: node-executed when `nodeId > 0` (chosen)
|
||||
- [ ] Define interval semantics:
|
||||
- Per-monitor next-run scheduling vs global scan loop
|
||||
- Backoff on failures
|
||||
- Maximum monitors + runtime cost guardrails
|
||||
- [ ] Standardize API type for `success`:
|
||||
- Keep int for backward compatibility, or
|
||||
- Return boolean in API responses (DTO) while storing int in DB
|
||||
|
||||
### Phase 2.1: Partial Implementation (No Semantics Decision Yet)
|
||||
- [x] Honor `intervalSec` best-effort in panel-side checker (min cadence still bound by global loop).
|
||||
|
||||
### Phase 2.2: Node-Executed Checks
|
||||
- [x] Add a WebSocket command for node-executed monitor checks (`ServiceMonitorCheck`).
|
||||
- [x] Panel health checker dispatches checks to the specified node when `nodeId > 0`.
|
||||
- [x] Allow unrestricted targets by policy; restrict monitoring endpoints to admin + explicitly authorized users.
|
||||
- [x] Remove HTTP checks; service monitoring supports only `tcp` and `icmp`.
|
||||
|
||||
### Phase 3: Hardening + Performance
|
||||
- [x] Add query limits/guards for metrics endpoints (max range, max rows) to avoid accidental full-history pulls.
|
||||
- [ ] Consider indexing review and retention configurability (env or config table).
|
||||
- [ ] Review concurrency: ingestion buffer flush goroutine spawning and DB write pressure.
|
||||
- [x] Add minimal UI affordances: time range selector, empty/error states, and service monitor run/results UI.
|
||||
- [x] Ensure monitoring UI works for authorized non-admin users (dedicated `/monitor` page; no reliance on admin-only `/node/*`).
|
||||
|
||||
### Phase 4: Hygiene
|
||||
- [x] Add `.entire/metadata/` to `.gitignore` (should never be committed).
|
||||
- [ ] Add a short developer note in docs/README if needed (API endpoints + semantics).
|
||||
|
||||
## Test Plan
|
||||
Backend:
|
||||
```bash
|
||||
cd go-backend && go test ./... -count=1
|
||||
cd go-backend && go test ./tests/contract -count=1 -timeout 120s
|
||||
```
|
||||
|
||||
Agent fork:
|
||||
```bash
|
||||
cd go-gost/x && go test ./... -count=1
|
||||
```
|
||||
|
||||
Frontend:
|
||||
```bash
|
||||
cd vite-frontend && npm run build
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Node-executed checks can be used for internal probing by design; access is restricted to administrators.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 038 - Monitoring Bug Fixes + Optimizations
|
||||
|
||||
## Context
|
||||
Monitoring in FLVX currently spans:
|
||||
- Agent -> panel WebSocket realtime system metrics (CPU/mem/disk/net/load/conns)
|
||||
- Panel-side ingestion + retention pruning (`node_metric`)
|
||||
- Service monitors (TCP/ICMP) with scheduled checks + stored results
|
||||
- Frontend monitor page (`/monitor`) with charts + monitor CRUD/run/results
|
||||
|
||||
While the feature set works end-to-end, there are a few correctness footguns and a couple of obvious performance hot spots (agent-side sampling cost and frontend N+1 polling patterns).
|
||||
|
||||
## Goals
|
||||
- Service monitor updates do not accidentally clear `nodeId` / `enabled` when fields are omitted.
|
||||
- Checker cadence is explicit (intervals below the scan cadence are clamped / best-effort).
|
||||
- Reduce frontend requests for service monitor status (avoid per-monitor polling).
|
||||
- Reduce agent sampling overhead and DB write volume without breaking UI expectations.
|
||||
- Avoid misclassifying arbitrary JSON as a metric message on the WS channel.
|
||||
|
||||
## Non-goals
|
||||
- A full scheduler (per-monitor next-run queue, jitter/backoff, concurrency budgets).
|
||||
- Alerting/notifications.
|
||||
- Implementing full tunnel-metrics ingestion (connections/errors/latency) beyond current endpoints.
|
||||
|
||||
## Checklist
|
||||
|
||||
### Phase 1: Backend Correctness + Hardening
|
||||
- [x] Make `/api/v1/monitor/services/update` treat `nodeId` and `enabled` as optional fields (no accidental zeroing).
|
||||
- [x] Clamp `intervalSec` to a minimum that matches the checker scan cadence (and apply the same clamp in the checker).
|
||||
- [x] Add `GET /api/v1/monitor/services/latest-results` returning the latest result per monitor (for frontend list rendering).
|
||||
- [x] WS metric parsing: only treat messages as metrics when they look like a system-metric payload.
|
||||
|
||||
### Phase 2: Frontend UX + Request Reduction
|
||||
- [x] Fix “立即检查” toast severity (failure should be an error toast).
|
||||
- [x] Use `latest-results` endpoint to render service monitor status without N+1 polling.
|
||||
- [x] Add a small hint when chart data is truncated by backend row limits.
|
||||
|
||||
### Phase 3: Agent Sampling Optimizations
|
||||
- [x] Reduce default WS metric send interval (2s -> 5s).
|
||||
- [x] Make CPU sampling non-blocking and cache heavy metrics (e.g. connection counts) to reduce per-sample cost.
|
||||
|
||||
## Test Plan
|
||||
Backend:
|
||||
```bash
|
||||
cd go-backend && go test ./... -count=1
|
||||
```
|
||||
|
||||
Agent fork:
|
||||
```bash
|
||||
cd go-gost/x && go test ./... -count=1
|
||||
```
|
||||
|
||||
Frontend (best-effort in this environment):
|
||||
```bash
|
||||
cd vite-frontend && npm run build
|
||||
```
|
||||
|
||||
## Rollout Notes
|
||||
- Agent sampling interval change reduces metric resolution and DB growth; charts remain usable and realtime UI remains responsive.
|
||||
- Existing monitors with very small `intervalSec` are best-effort; effective cadence remains bounded by the checker scan loop.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 039 - Monitoring: Tunnel Metrics Ingestion
|
||||
|
||||
## Context
|
||||
The `/monitor` UI includes tunnel metric charts backed by:
|
||||
- `GET /api/v1/monitor/tunnels` (list)
|
||||
- `GET /api/v1/monitor/tunnels/:id/metrics` (timeseries)
|
||||
|
||||
The backend has the `tunnel_metric` table + query endpoints, but there is no production code path that writes tunnel metrics. As a result, tunnel charts are typically empty.
|
||||
|
||||
## Goal
|
||||
Persist tunnel traffic timeseries based on agent flow uploads (`POST /flow/upload`).
|
||||
|
||||
## Scope
|
||||
- Write `tunnel_metric` rows from flow uploads.
|
||||
- Keep write volume bounded (aggregate per minute).
|
||||
- Provide contract coverage that a flow upload creates tunnel metrics.
|
||||
|
||||
## Non-goals
|
||||
- Populate connections/errors/latency for tunnel metrics (remain 0 for now).
|
||||
- A full aggregation pipeline across multiple nodes per tunnel at query time.
|
||||
|
||||
UI note:
|
||||
- The tunnel chart only exposes the Traffic view for now; other tabs are hidden.
|
||||
|
||||
## Design
|
||||
- Agent reports per-service traffic deltas via `/flow/upload` with items `{n,u,d}`.
|
||||
- Backend derives `forward_id` from service name (`<forwardID>_<userID>_<userTunnelID>[...suffix]`).
|
||||
- Map `forward_id -> tunnel_id` in batch.
|
||||
- Aggregate per `(node_id, tunnel_id, minute_bucket)` and upsert into `tunnel_metric` using an UPDATE-then-INSERT fallback.
|
||||
|
||||
## Checklist
|
||||
- [x] Add repository helper: map forward IDs to tunnel IDs.
|
||||
- [x] Add repository helper: upsert per-minute tunnel metric buckets.
|
||||
- [x] Extend `/flow/upload` handler to record tunnel metrics from incoming items.
|
||||
- [x] Add contract test verifying flow upload produces tunnel metrics.
|
||||
- [x] Run backend tests.
|
||||
|
||||
## Test Plan
|
||||
```bash
|
||||
cd go-backend && go test ./... -count=1
|
||||
```
|
||||
@@ -0,0 +1,178 @@
|
||||
# GOST UDP vs Realm UDP Analysis
|
||||
|
||||
**Goal:** Compare GOST UDP forwarding through tunnels with Realm's UDP implementation to understand why users report GOST UDP forwarding has problems while Realm works correctly.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [x] Analyze GOST UDP tunnel architecture
|
||||
- [x] Analyze Realm UDP relay architecture
|
||||
- [x] Identify architectural differences
|
||||
- [x] Identify potential issues in GOST implementation
|
||||
- [ ] Document findings and recommendations
|
||||
|
||||
---
|
||||
|
||||
## GOST UDP Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **UDP Relay** (`x/internal/net/udp/relay.go`)
|
||||
- Simple bidirectional packet copying between two `net.PacketConn` interfaces
|
||||
- Uses two goroutines: one for each direction
|
||||
- Sequential packet processing (no batching)
|
||||
- No idle timeout or association tracking
|
||||
|
||||
2. **UDP over Tunnel** (`x/handler/relay/bind.go`, `x/handler/socks/v5/udp_tun.go`)
|
||||
- Wraps UDP data with SOCKS5-style framing via `UDPTunServerConn()`
|
||||
- Adds address headers to each packet
|
||||
- Uses smux multiplexing for tunnel connections
|
||||
|
||||
3. **SOCKS5 UDP Framing** (`x/internal/util/socks/conn.go`, `x/internal/util/relay/conn.go`)
|
||||
- `udpTunConn.ReadFrom()`: Parses SOCKS5 UDP header to extract target address
|
||||
- `udpTunConn.WriteTo()`: Wraps data with SOCKS5 UDP header including:
|
||||
- RSV (data length, 2 bytes)
|
||||
- Frag (0xff for tunnel relay, 1 byte)
|
||||
- Address (4/16/1+n bytes for IPv4/IPv6/domain)
|
||||
|
||||
4. **Multiplexing** (`x/internal/util/mux/mux.go`)
|
||||
- Uses smux v1.5.31 for connection multiplexing
|
||||
- Adds stream framing and flow control overhead
|
||||
|
||||
### Data Flow (UDP over Tunnel)
|
||||
|
||||
```
|
||||
Client UDP packet
|
||||
↓
|
||||
[Local GOST] SOCKS5 UDP framing (add ~10-26 bytes)
|
||||
↓
|
||||
smux stream (add framing, flow control)
|
||||
↓
|
||||
TCP tunnel to remote
|
||||
↓
|
||||
[Remote GOST] smux demux
|
||||
↓
|
||||
SOCKS5 UDP deframing
|
||||
↓
|
||||
Forward to target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Realm UDP Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Association Model** (`realm_core/src/udp/middle.rs`)
|
||||
- Per-client socket associations stored in `SockMap`
|
||||
- Creates dedicated remote socket per client address
|
||||
- Spawns `send_back` task for return path
|
||||
- Association timeout for cleanup
|
||||
|
||||
2. **Batched I/O** (`realm_core/src/udp/batched.rs`)
|
||||
- Uses `recvmmsg/sendmmsg` on Linux
|
||||
- Up to 128 packets per batch
|
||||
- Significantly higher throughput for high-PPS traffic
|
||||
|
||||
3. **No Protocol Overhead**
|
||||
- Plain UDP forwarding without adding protocol headers
|
||||
- No SOCKS5 framing or mux layer
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Client UDP packet
|
||||
↓
|
||||
[Realm] Direct relay via association socket
|
||||
↓
|
||||
Target server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Architectural Differences
|
||||
|
||||
| Aspect | GOST | Realm |
|
||||
|--------|------|-------|
|
||||
| **UDP over Tunnel** | SOCKS5 framing + smux multiplexing | N/A (direct relay only) |
|
||||
| **Protocol Overhead** | Extra ~10-26 bytes per packet | None |
|
||||
| **I/O Model** | Standard Go net.PacketConn | Batched I/O (recvmmsg/sendmmsg) |
|
||||
| **Connection Tracking** | Via mux session | SockMap with timeout |
|
||||
| **Association Model** | None (bidirectional copy only) | Per-client socket association |
|
||||
| **Idle Timeout** | None on UDP relay | Association timeout |
|
||||
| **Throughput** | Single packet per syscall | Up to 128 packets per syscall |
|
||||
|
||||
---
|
||||
|
||||
## Identified Potential Issues in GOST
|
||||
|
||||
### 1. No Batch I/O Support
|
||||
- **Impact**: High PPS (packets per second) traffic incurs syscall overhead
|
||||
- **Realm advantage**: `recvmmsg/sendmmsg` batches up to 128 packets
|
||||
- **Evidence**: Realm's `batched.rs` implements this; GOST uses standard `ReadFrom/WriteTo`
|
||||
|
||||
### 2. Protocol Overhead Per Packet
|
||||
- **Impact**: Bandwidth waste, extra processing for framing/deframing
|
||||
- **Overhead**: SOCKS5 UDP header adds ~10 bytes (IPv4) to ~26 bytes (IPv6) per packet
|
||||
- **Evidence**: `x/internal/util/socks/conn.go:63-78` shows framing overhead
|
||||
|
||||
### 3. Mux Layer Overhead
|
||||
- **Impact**: Latency and throughput degradation
|
||||
- **smux adds**: Stream framing, flow control, potential backpressure
|
||||
- **Evidence**: `x/internal/util/mux/mux.go` wraps all tunnel connections
|
||||
|
||||
### 4. No Association Tracking
|
||||
- **Impact**: Cannot properly handle NAT translation for return traffic
|
||||
- **Realm approach**: `SockMap` tracks client→remote socket mapping
|
||||
- **Evidence**: GOST's `udp.Relay` just copies packets bidirectionally
|
||||
|
||||
### 5. No Idle Timeout on UDP Relay
|
||||
- **Impact**: Stale connections may persist indefinitely
|
||||
- **Evidence**: `udp.Relay.Run()` blocks until error or context cancel
|
||||
- **Contrast**: Realm has association timeout for cleanup
|
||||
|
||||
### 6. Sequential Packet Processing
|
||||
- **Impact**: Cannot pipeline multiple packets
|
||||
- **Evidence**: `relay.go:44-77` shows sequential `ReadFrom` → `WriteTo` loop
|
||||
- **Contrast**: Realm's batched I/O handles multiple packets concurrently
|
||||
|
||||
### 7. Read Deadline on Underlying TCP
|
||||
- **Impact**: Could cause unexpected connection termination
|
||||
- **Default**: 15-second read timeout set on connections
|
||||
- **Evidence**: `x/handler/relay/metadata.go:readTimeout` defaults to 15s
|
||||
- **Issue**: UDP relay may not handle deadline properly
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Consider Direct UDP Mode**: For scenarios where tunnel is not required, use direct UDP relay (no SOCKS5 framing)
|
||||
|
||||
2. **Add Association Tracking**: Implement client→remote socket mapping with timeout
|
||||
|
||||
3. **Investigate Batch I/O**: Consider using `recvmmsg/sendmmsg` equivalent in Go (via `x/net` or raw syscalls)
|
||||
|
||||
4. **Add Idle Timeout**: Implement timeout-based cleanup for UDP associations
|
||||
|
||||
5. **Reduce Framing Overhead**: Consider more compact framing for tunnel UDP
|
||||
|
||||
---
|
||||
|
||||
## Files Analyzed
|
||||
|
||||
### GOST Files
|
||||
- `go-gost/x/internal/net/udp/relay.go` - Core UDP relay logic
|
||||
- `go-gost/x/internal/util/mux/mux.go` - smux multiplexing
|
||||
- `go-gost/x/internal/util/socks/conn.go` - SOCKS5 UDP framing
|
||||
- `go-gost/x/internal/util/relay/conn.go` - Relay UDP framing
|
||||
- `go-gost/x/handler/relay/bind.go` - Relay BIND handler
|
||||
- `go-gost/x/handler/socks/v5/udp_tun.go` - SOCKS5 UDP tunnel handler
|
||||
- `go-gost/x/handler/tunnel/bind.go` - Tunnel BIND handler
|
||||
- `go-gost/x/connector/tunnel/bind.go` - Tunnel connector BIND
|
||||
- `go-gost/x/connector/tunnel/conn.go` - Tunnel connection types
|
||||
- `go-gost/x/connector/tunnel/listener.go` - Tunnel bind listener
|
||||
|
||||
### Realm Files (fetched from GitHub)
|
||||
- `realm_core/src/udp/mod.rs` - UDP relay entry point
|
||||
- `realm_core/src/udp/middle.rs` - Association and relay logic with SockMap
|
||||
- `realm_core/src/udp/socket.rs` - UDP socket binding and association
|
||||
- `realm_core/src/udp/batched.rs` - Batched I/O implementation
|
||||
@@ -0,0 +1,40 @@
|
||||
# 040 - Service Monitor Limits Config + UI Hints
|
||||
|
||||
## Goal
|
||||
Make service monitor interval/timeout constraints configurable (instead of hard-coded clamps) and make the UI clearly communicate the effective limits.
|
||||
|
||||
## Current Pain
|
||||
- Backend clamps `intervalSec` and `timeoutSec` with hard-coded constants.
|
||||
- Checker scan cadence is also hard-coded, so users can set values that will never be honored.
|
||||
- Frontend form does not explain allowed ranges or why values may change.
|
||||
|
||||
## Approach
|
||||
- Add frontend-configurable limits stored in `vite_config` (with safe defaults matching current behavior).
|
||||
- Backend always normalizes using the configured limits.
|
||||
- Expose the current limits via a monitoring endpoint so the UI can render accurate hints.
|
||||
- Frontend shows min/max and validates before submit.
|
||||
- Admin can edit the limits on `/config`.
|
||||
|
||||
## Config Keys (vite_config)
|
||||
- `service_monitor_checker_scan_interval_sec` (default: 30)
|
||||
- `service_monitor_min_interval_sec` (default: 30; auto-raised to at least scan interval)
|
||||
- `service_monitor_default_interval_sec` (default: 60)
|
||||
- `service_monitor_min_timeout_sec` (default: 1)
|
||||
- `service_monitor_default_timeout_sec` (default: 5)
|
||||
- `service_monitor_max_timeout_sec` (default: 60)
|
||||
|
||||
## Checklist
|
||||
Backend:
|
||||
- [x] Introduce shared `ServiceMonitorLimits` config loader.
|
||||
- [x] Use limits for create/update normalization.
|
||||
- [x] Use limits in checker (scan interval + timeout clamp).
|
||||
- [x] Add `GET /api/v1/monitor/services/limits` to return current limits.
|
||||
|
||||
Frontend:
|
||||
- [x] Fetch limits once and render input descriptions.
|
||||
- [x] Validate interval/timeout client-side and show inline errors.
|
||||
- [x] Add `/config` items to edit the `vite_config` keys.
|
||||
|
||||
Verification:
|
||||
- [x] `cd go-backend && go test ./... -count=1`
|
||||
- [x] `cd vite-frontend && npm run lint && npm run build`
|
||||
@@ -0,0 +1,224 @@
|
||||
# 041 - Monitoring Reliability, Realtime, and UX Hardening
|
||||
|
||||
## Context
|
||||
Current monitoring support in FLVX already covers three major areas:
|
||||
- Node runtime metrics from agent WebSocket telemetry, buffered into `node_metric`, exposed by `/api/v1/monitor/nodes*`, and rendered on `/monitor`.
|
||||
- Tunnel metrics derived from `/flow/upload`, stored in `tunnel_metric`, exposed by `/api/v1/monitor/tunnels*`, and rendered on `/monitor`.
|
||||
- Service monitoring for `tcp` and `icmp`, including CRUD, scheduled checks, manual run, history, and non-admin authorization via `monitor_permission`.
|
||||
|
||||
The feature set is usable, but the audit found several correctness, reliability, and UX gaps:
|
||||
- The monitor page is not truly realtime and can lag DB ingestion by tens of seconds.
|
||||
- Tunnel metrics are only partially implemented and are not aggregated correctly for multi-node tunnels.
|
||||
- Some monitoring writes fail silently, which can hide data-loss and retention issues.
|
||||
- Service monitor scheduling is functional but too naive for larger monitor sets and restart scenarios.
|
||||
- The monitoring UI exposes incomplete semantics, weak freshness cues, and inconsistent permission/error affordances.
|
||||
- Several monitoring endpoints and edge cases still lack direct automated coverage.
|
||||
|
||||
This plan collects all currently known monitoring follow-up work into one implementation document.
|
||||
|
||||
## Goals
|
||||
- Make node monitoring data freshness explicit and reduce stale or misleading chart behavior.
|
||||
- Make tunnel metrics correct for multi-node tunnels and align schema/query/UI semantics.
|
||||
- Harden service monitor scheduling, persistence, and cleanup behavior.
|
||||
- Improve observability so monitoring ingestion and result writes never fail silently.
|
||||
- Upgrade the monitoring UI so operators can understand status, freshness, scope, and failures at a glance.
|
||||
- Expand automated coverage for all monitoring APIs and the highest-risk aggregation/scheduler cases.
|
||||
|
||||
## Non-goals
|
||||
- Add a full alerting or notification pipeline.
|
||||
- Add brand-new monitor protocols beyond the current `tcp` and `icmp` scope.
|
||||
- Build a large analytics dashboard outside the existing monitoring page structure.
|
||||
- Introduce frontend test infrastructure for broad component/unit testing unless required by an implementation step.
|
||||
|
||||
## Audit Findings To Address
|
||||
- Node metrics on `/monitor` are DB-polled rather than realtime-streamed.
|
||||
- Node metrics are buffered for 30s, so charts can lag behind observed node state.
|
||||
- Tunnel metrics are stored per `(tunnel_id, node_id, timestamp)` but queried and rendered as if they were already tunnel-level aggregates.
|
||||
- Tunnel metric minute-bucket upsert uses update-then-insert without uniqueness guarantees.
|
||||
- Tunnel metrics only populate `bytesIn` and `bytesOut`; `connections`, `errors`, and `avgLatencyMs` are placeholder values.
|
||||
- Node/tunnel/service-monitor writes can fail silently due to ignored errors.
|
||||
- Service monitor scheduler is serial and uses in-memory `lastRun`, causing restart skew and slow-monitor head-of-line blocking.
|
||||
- Deleting a service monitor does not clean up related historical results.
|
||||
- `expectedCode` exists on the model but is not implemented in behavior or UX.
|
||||
- The monitoring page/menu is exposed before permission is known, leading to avoidable denied-entry UX.
|
||||
- Service monitor UI does not clearly show latest result freshness, last check time, or whether a displayed row is stale.
|
||||
- Chart labels and units are not operator-friendly for long time windows and network-heavy views.
|
||||
- Monitoring API coverage is incomplete for list, permission, limits, latest-results, multi-node tunnel aggregation, and concurrency paths.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Node Monitoring Freshness and Realtime Model
|
||||
- Keep the existing WebSocket node telemetry stream as the source of live state.
|
||||
- Preserve DB-backed metrics queries for historical charts, but explicitly separate them from live cards/status.
|
||||
- On `/monitor`, add a lightweight realtime subscription path reusing the existing admin WebSocket feed already used by the node page.
|
||||
- Use realtime events for:
|
||||
- node online/offline state,
|
||||
- a small “latest value” strip or summary above charts,
|
||||
- freshness timestamp display.
|
||||
- Keep charts historical and DB-backed by default, but add a visible freshness hint such as:
|
||||
- `历史图表,最近落库延迟约 0-30s`, or
|
||||
- `最近入库时间: ...`.
|
||||
- Do not remove buffered ingestion immediately; first make lag transparent in UI and observable in logs/metrics.
|
||||
- Optional second-step optimization: reduce flush interval or add a bounded flush-on-latest-view mode if DB pressure remains acceptable.
|
||||
|
||||
### 2. Tunnel Metrics Data Model and Query Semantics
|
||||
- Decide and document one API contract:
|
||||
- `GET /api/v1/monitor/tunnels/:id/metrics` must return tunnel-level aggregated series for the selected time range, not raw per-node rows.
|
||||
- Keep storage per `(tunnel_id, node_id, timestamp)` because it is useful for future drill-down.
|
||||
- Change query behavior so the tunnel metrics endpoint aggregates rows by timestamp across all nodes for the tunnel:
|
||||
- `SUM(bytes_in)`,
|
||||
- `SUM(bytes_out)`,
|
||||
- `SUM(connections)`,
|
||||
- `SUM(errors)`,
|
||||
- `AVG` or weighted-average strategy for latency, if latency is later implemented.
|
||||
- Return a single point per timestamp to the frontend.
|
||||
- If future node drill-down is needed, add a separate endpoint rather than mixing per-node rows into the current chart API.
|
||||
|
||||
### 3. Tunnel Metric Upsert Safety
|
||||
- Replace the current update-then-insert fallback with a uniqueness-backed upsert strategy.
|
||||
- Add a unique index on `(tunnel_id, node_id, timestamp)`.
|
||||
- Implement DB-safe upsert behavior compatible with SQLite and PostgreSQL via GORM clauses or equivalent dialect-safe SQL.
|
||||
- Preserve additive semantics for traffic counters inside the bucket.
|
||||
- Add concurrency coverage proving that parallel uploads for the same bucket do not create duplicate rows.
|
||||
|
||||
### 4. Tunnel Metric Scope Clarification
|
||||
- Short term: make the UI and API explicitly traffic-only where the backend only has traffic truth.
|
||||
- Remove or hide unsupported tunnel metric modes from the current UX until real data exists.
|
||||
- Do not expose zero-filled placeholders as if they were valid telemetry.
|
||||
- Keep schema fields if future support is planned, but label them as unimplemented in code comments and avoid rendering them as live features.
|
||||
|
||||
### 5. Service Monitor Scheduler Hardening
|
||||
- Replace the current fully serial best-effort loop with bounded concurrency:
|
||||
- retain a global scan loop or next-run calculation,
|
||||
- collect monitors due for execution,
|
||||
- execute them with a configurable worker limit,
|
||||
- avoid one slow node/target delaying all others.
|
||||
- Move scheduling semantics from pure in-memory `lastRun` toward persisted or history-derived next-run safety:
|
||||
- on restart, do not fire an uncontrolled burst for all monitors if they just ran;
|
||||
- use latest persisted result timestamp or a persisted scheduler state to calculate due-ness.
|
||||
- Keep interval clamping behavior aligned with configured limits.
|
||||
- Continue supporting local panel execution for `tcp` and node execution for `tcp`/`icmp`.
|
||||
|
||||
### 6. Service Monitor Data Lifecycle
|
||||
- Define monitor deletion semantics explicitly:
|
||||
- either cascade-delete historical `service_monitor_result` rows when a monitor is deleted, or
|
||||
- retain them intentionally and exclude orphan rows from latest/list endpoints.
|
||||
- Preferred approach: delete associated results with the monitor so the UI/API model stays simple.
|
||||
- Either implement `expectedCode` fully or remove it from the model/API surface for now.
|
||||
- Because service monitoring currently supports only `tcp` and `icmp`, and no HTTP checks are implemented, `expectedCode` should likely be removed from the data model/API until a real HTTP monitor exists.
|
||||
|
||||
### 7. Observability and Failure Handling
|
||||
- Stop swallowing monitoring persistence errors.
|
||||
- For all node/tunnel/service-monitor writes:
|
||||
- log structured errors with entity identifiers and operation names,
|
||||
- increment internal counters if an existing metrics/logging primitive exists,
|
||||
- keep request/loop behavior resilient, but make failure visible.
|
||||
- Apply this to:
|
||||
- node metric batch flush,
|
||||
- tunnel metric bucket writes,
|
||||
- scheduled service monitor result writes,
|
||||
- manual service monitor result writes,
|
||||
- pruning failures.
|
||||
- Avoid user-facing hard failures for background ingestion, but surface operational diagnostics in logs.
|
||||
|
||||
### 8. Monitoring UI Semantics and Navigation
|
||||
- Keep `/monitor` accessible only to authenticated users, but improve pre-entry affordances:
|
||||
- hide or disable the navigation item for users without monitor permission when role/permission data is known,
|
||||
- or show a locked state with explanation instead of allowing a full denied page transition.
|
||||
- Preserve the backend permission check as the source of truth.
|
||||
- On the page itself, upgrade semantics:
|
||||
- distinguish `enabled/disabled` from `healthy/unhealthy` in the service monitor table,
|
||||
- show `last checked at`,
|
||||
- show `latest result` separately from monitor switch state,
|
||||
- show whether the latest displayed result is stale.
|
||||
- Add an at-a-glance monitoring summary near the top:
|
||||
- online/offline node counts,
|
||||
- monitors healthy/unhealthy/disabled counts,
|
||||
- latest data freshness text.
|
||||
|
||||
### 9. Monitoring UI Readability Improvements
|
||||
- Improve chart axis labeling for long ranges:
|
||||
- use date + time formatting for 24h windows,
|
||||
- keep shorter labels for short ranges.
|
||||
- Format bytes and rates into human-readable units (`KB/s`, `MB/s`, `GB`) instead of raw integers.
|
||||
- Expose clear empty states and fetch-error states instead of silent failures.
|
||||
- Make tunnel charts explicitly say `流量趋势` if only traffic is supported.
|
||||
- Show `statusCode` in the results modal only if the corresponding monitor type ever uses it; otherwise omit it.
|
||||
|
||||
### 10. API and Test Coverage Expansion
|
||||
- Add contract coverage for:
|
||||
- `GET /api/v1/monitor/nodes`,
|
||||
- `GET /api/v1/monitor/tunnels`,
|
||||
- `GET /api/v1/monitor/services/latest-results`,
|
||||
- `GET /api/v1/monitor/services/limits`,
|
||||
- monitor permission list/assign/remove endpoints.
|
||||
- Add backend tests for:
|
||||
- multi-node tunnel aggregation returning one point per timestamp,
|
||||
- tunnel upsert concurrency safety,
|
||||
- service monitor restart/due scheduling semantics,
|
||||
- service monitor delete cleanup behavior,
|
||||
- background write failure logging where practical.
|
||||
- Keep existing build/test targets green for backend, agent, and frontend.
|
||||
|
||||
## Checklist
|
||||
|
||||
### Phase 1: Correctness Fixes
|
||||
- [x] Aggregate `GET /api/v1/monitor/tunnels/:id/metrics` by timestamp across all node rows for the selected tunnel.
|
||||
- [x] Add a unique index for tunnel metric minute buckets and replace the race-prone update-then-insert flow with safe upsert logic.
|
||||
- [x] Stop exposing unsupported tunnel metric dimensions (`connections`, `errors`, `latency`) as active UI features while the backend still stores placeholders.
|
||||
- [x] Define and implement service monitor deletion cleanup so history does not leave orphaned result rows.
|
||||
- [x] Remove or fully implement `expectedCode`; do not keep dead monitoring fields in the live API/model contract.
|
||||
|
||||
### Phase 2: Reliability and Scheduling
|
||||
- [x] Replace serial service monitor execution with bounded-concurrency execution for due monitors.
|
||||
- [x] Persist or derive service monitor next-run behavior so process restarts do not trigger uncontrolled immediate reruns.
|
||||
- [x] Ensure monitor scheduler semantics remain aligned with configured min interval and checker scan cadence.
|
||||
- [x] Add structured logging for all monitoring persistence failures and prune failures.
|
||||
- [x] Audit all ignored monitoring write errors and convert them into visible operational diagnostics.
|
||||
|
||||
### Phase 3: Realtime and Freshness UX
|
||||
- [x] Reuse the existing admin WebSocket stream on `/monitor` for live node status and latest-value freshness indicators.
|
||||
- [x] Add visible chart freshness metadata so users know historical charts are DB-backed and may lag ingestion.
|
||||
- [x] Decide whether to reduce node metric flush interval after instrumentation confirms acceptable DB impact (decision: keep 30s default for now; revisit after observing DB write rate and UI staleness in production).
|
||||
- [x] Add a monitoring summary strip showing online nodes, unhealthy monitors, and latest data time.
|
||||
|
||||
### Phase 4: Monitoring Page UX Cleanup
|
||||
- [x] Separate service monitor switch state (`启用/禁用`) from probe health (`成功/失败`).
|
||||
- [x] Add `last checked at` to the service monitor list and results modal context.
|
||||
- [x] Mark stale results clearly when the latest result is older than the configured interval budget.
|
||||
- [x] Format traffic and speed values in human-readable units instead of raw bytes.
|
||||
- [x] Improve chart time labels for 24h windows to include date context.
|
||||
- [x] Replace silent frontend fetch failures with explicit inline error or toast handling.
|
||||
- [x] Rename or relabel tunnel chart UI to make its current scope unambiguous.
|
||||
|
||||
### Phase 5: Permission and Navigation UX
|
||||
- [x] Avoid showing a fully interactive monitor nav entry to users who lack monitoring permission once permission state is known.
|
||||
- [x] Preserve backend authorization as the final gate and keep denied responses intact.
|
||||
- [x] Improve denied-state copy so users understand whether they need admin grant vs role change.
|
||||
|
||||
### Phase 6: Automated Coverage
|
||||
- [x] Add contract tests for monitor node list, tunnel list, latest service monitor results, limits, and permission endpoints.
|
||||
- [x] Add contract or repository tests for multi-node tunnel aggregation correctness.
|
||||
- [x] Add concurrency tests for tunnel metric upsert safety.
|
||||
- [x] Add scheduler tests covering restart behavior, due monitor selection, and slow-monitor isolation.
|
||||
- [x] Keep existing monitoring contract tests passing after all changes.
|
||||
|
||||
## Implementation Notes
|
||||
- Prefer backward-compatible API changes where possible, but favor correctness over preserving misleading tunnel metric semantics.
|
||||
- Do not introduce a fake realtime chart if the data source remains DB-backed; label it honestly.
|
||||
- If permission visibility requires an extra frontend capability call, keep it lightweight and cacheable.
|
||||
- If schema/index changes are introduced, they must remain compatible with both SQLite and PostgreSQL.
|
||||
|
||||
## Final Verification Targets
|
||||
- [x] `GET /api/v1/monitor/tunnels/:id/metrics` returns one aggregated point per timestamp even when multiple nodes report the same tunnel bucket.
|
||||
- [x] Parallel `/flow/upload` calls for the same tunnel/node/minute do not create duplicate bucket rows.
|
||||
- [x] `/monitor` clearly distinguishes live state from historical persisted charts and surfaces data freshness to the operator.
|
||||
- [x] Service monitor list shows enabled state, latest health result, latest check time, and stale-state semantics correctly.
|
||||
- [x] Deleting a service monitor no longer leaves dangling historical data in list-facing APIs.
|
||||
- [x] Monitoring ingestion/result write failures are visible in logs and no longer fail silently.
|
||||
- [x] Non-admin users without monitoring permission do not get a confusing monitor-entry experience, while granted users continue to access monitoring successfully.
|
||||
- [x] Backend monitoring contract tests pass.
|
||||
- [x] New repository/scheduler tests pass.
|
||||
- [x] `cd go-backend && go test ./... -count=1` passes.
|
||||
- [x] `cd go-gost/x && go test ./socket/... -count=1` passes.
|
||||
- [x] `cd vite-frontend && npm run build` passes.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Node Card Info Popover
|
||||
|
||||
## Goal
|
||||
|
||||
Restore node card's remark and renewal info display to the 2.1.8-beta9 style: an info button (ℹ️) in the CardHeader that shows a hover popover with the info, instead of the current inline display in the CardBody.
|
||||
|
||||
## Status: Completed
|
||||
|
||||
PR #327 merged
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `infoPopoverPlacement` state and `updateInfoPopoverPlacement` callback
|
||||
- [x] Add info button with popover to CardHeader
|
||||
- [x] Restore drag handle with touch support
|
||||
- [x] Remove inline info display from CardBody
|
||||
- [x] Fix build errors (JSX structure and unused variables)
|
||||
- [x] Create PR and merge
|
||||
@@ -0,0 +1,49 @@
|
||||
# 042 - SQLite 隧道编辑添加入口节点卡死排查
|
||||
|
||||
## Issue
|
||||
- 现象:SQLite 数据库下,编辑已有隧道并新增入口节点时接口卡住;PostgreSQL 下同样操作正常。
|
||||
- 初步判断:`tunnelUpdate` 在事务尚未提交时触发了额外 repository 读查询,SQLite 配置 `MaxOpenConns(1)`,容易在同一请求内形成自锁等待。
|
||||
|
||||
## Goal
|
||||
- 找出 SQLite 与 PostgreSQL 行为差异的根因。
|
||||
- 修复隧道编辑新增入口节点时的阻塞问题,同时不破坏现有的入口端口冲突校验。
|
||||
- 补充最小回归测试,锁定“事务内校验不可再次占用根连接”的场景。
|
||||
|
||||
## Checklist
|
||||
- [x] 复核 `tunnelUpdate` 在新增入口节点路径上的调用链,确认事务内哪些查询绕过了 `tx`。
|
||||
- [x] 为相关 repository 查询补齐 `Tx` 版本,避免 SQLite 单连接下的自锁等待。
|
||||
- [x] 调整 handler 中入口端口冲突校验,保证事务内全程复用同一个 `tx`。
|
||||
- [x] 增加针对 SQLite 的回归测试,验证事务内校验不会阻塞。
|
||||
- [x] 运行相关 handler/backend 测试并记录结果。
|
||||
|
||||
## Notes
|
||||
- 重点关注 `validateTunnelEntryPortConflictsForNewEntries`:当前它在 `tx.Commit()` 前执行,但内部调用 `ListForwardsByTunnel` / `ListForwardPorts` / `HasOtherForwardOnNodePort` 等非事务查询。
|
||||
- SQLite 在 `go-backend/internal/store/repo/repository.go` 中显式设置了 `SetMaxOpenConns(1)`,因此这种模式在 SQLite 下会比 PostgreSQL 更容易表现为“卡死”。
|
||||
|
||||
## 实际改动
|
||||
- `go-backend/internal/store/repo/repository_control.go`
|
||||
- 新增 `ListForwardsByTunnelTx`、`ListForwardPortsTx`、`HasOtherForwardOnNodePortTx`,并让原有非事务方法复用统一实现。
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `tunnelUpdate` 在事务内执行入口端口冲突校验时显式传入当前 `tx`。
|
||||
- `validateTunnelEntryPortConflictsForNewEntries` 改为全程使用事务查询。
|
||||
- 新增 `validateForwardPortAvailabilityTx`,避免事务内回落到根连接查询。
|
||||
- `go-backend/internal/http/handler/tunnel_entry_sqlite_test.go`
|
||||
- 新增 SQLite 回归测试,验证开启事务后执行新增入口校验不会阻塞。
|
||||
|
||||
## 测试结果
|
||||
|
||||
### 通过
|
||||
```bash
|
||||
cd go-backend && go test ./internal/http/handler/...
|
||||
```
|
||||
- 结果:通过。
|
||||
|
||||
### 额外检查
|
||||
```bash
|
||||
cd go-backend && go test ./tests/contract/...
|
||||
```
|
||||
- 结果:未全绿;当前失败集中在既有的入口端口语义合同用例:
|
||||
- `TestIssue313_EntryPortCrossTunnelConflictContract`
|
||||
- `TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract`
|
||||
- `TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract`
|
||||
- 备注:这些失败反映的是“新增/切换入口时端口校验预期”与现有合同用例之间的行为差异,不是本次 SQLite 事务自锁修复本身的编译或阻塞问题。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user