mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
436 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 032b0f0cfd | |||
| 7008717a49 | |||
| 8552a70355 | |||
| b2454e86c9 | |||
| 312c9a9c5c | |||
| e1324b8c8c | |||
| c034d0d41f | |||
| fd3ecc38ef | |||
| 2eee506716 | |||
| abf13bdac9 | |||
| f0facf6703 | |||
| 583a3834f9 | |||
| bd13477fa3 | |||
| 106a30bf9d | |||
| 465815cf34 | |||
| ec9fb77eb5 | |||
| 7d63dd4cc3 | |||
| 4cfa6adee7 | |||
| bc8f2ec8a1 | |||
| 9a37c2f603 | |||
| ff6d46ddaf | |||
| 723534faea | |||
| 73490a9be6 | |||
| 5a327459f7 | |||
| f307e7d5eb | |||
| fdd72979b6 | |||
| ad33791a26 | |||
| 6320b1f0c1 | |||
| 91d79b6b3a | |||
| fc7df6bd64 | |||
| 25dfb84324 | |||
| 4ebd6703fe | |||
| 1f53a39784 | |||
| 5ebd4c2a91 | |||
| 6c93d829c6 | |||
| 5d22d4cb06 | |||
| e5cd5af550 | |||
| cdcdfd8ff0 | |||
| 791773fd62 | |||
| 13764b4615 | |||
| 4c882d907b | |||
| 6033e39466 | |||
| 0f3242bf11 | |||
| d97d91801d | |||
| 727ef56c67 | |||
| a40150b136 | |||
| a923ec4785 | |||
| 42c5492c1d | |||
| 869d726b7a | |||
| 55a931510b | |||
| a259dd83b2 | |||
| cc0b8de2e1 | |||
| 58ef260755 | |||
| 521fe79b15 | |||
| 90012725cc | |||
| 615d9e67eb | |||
| a131b70613 | |||
| cbed4eab23 | |||
| c2745dcd56 | |||
| ad4109594a | |||
| efc8c75dcb | |||
| e5acc49186 | |||
| d98377a297 | |||
| 950e9a9ba8 | |||
| 3f3159aafd | |||
| 5e8d0682c0 | |||
| 3c0e833cfc | |||
| 60311d3e47 | |||
| b4192c9e94 | |||
| f05e9480ee | |||
| 9861b44107 | |||
| d8144821e6 | |||
| 023be27287 | |||
| e8d5687419 | |||
| 0fbe570597 | |||
| 4c52d7fec2 | |||
| 3373e5ade9 | |||
| bd27b94909 | |||
| a5a500bc0f | |||
| edfe2a2372 | |||
| 7a9ba8bd81 | |||
| 46394388b1 | |||
| dec337d46b | |||
| 9e8d27d98e | |||
| a2000e4d98 | |||
| 2b76a9f0be | |||
| 54d7dfb7c9 | |||
| 9f19d5fe15 | |||
| 2ca3849917 | |||
| 58d2e89147 | |||
| 87a1a34ad5 | |||
| a625884d61 | |||
| 799bb66fe5 | |||
| 3f374df724 | |||
| 9b923a2d0b | |||
| d9f28f53c7 | |||
| 1d08a1ccfc | |||
| db25ba2cbe | |||
| a498067261 | |||
| b5922dccf2 | |||
| c259645227 | |||
| bdc2c4ecbb | |||
| a070d0f4d3 | |||
| eecdd62d3a | |||
| e6d3b847bb | |||
| 0b49cd720f | |||
| 4f488ae7ef | |||
| 2b2b417f91 | |||
| 01b4c3e3eb | |||
| e7c967df00 | |||
| efaf920e51 | |||
| 9aff669c0e | |||
| 6e60f5cfbd | |||
| a03c320b89 | |||
| 61dba0ae57 | |||
| f3d6366471 | |||
| c431d79403 | |||
| 5107f59d94 | |||
| 431613cb6a | |||
| a6b218f3ee | |||
| d5d26d9cf9 | |||
| c1bc795674 | |||
| 30e1473f06 | |||
| eaf16bf17b | |||
| e995d70be7 | |||
| a968a10792 | |||
| ea156c33bc | |||
| 29407c90b6 | |||
| 37341af2d1 | |||
| bb48ab00bd | |||
| f6d2ab3270 | |||
| b382caa347 | |||
| 288c5d7152 | |||
| b42516cea1 | |||
| 74ed74ba00 | |||
| 7c1f5ca660 | |||
| 26013f8dfa | |||
| ab0f36ba7b | |||
| cde379a1f4 | |||
| 96fc790ed7 | |||
| f0351107dd | |||
| 534d36f0a1 | |||
| 7668e24f4b | |||
| c1f96180f5 | |||
| 630e012ec1 | |||
| 7f14bd30fa | |||
| 8611748c46 | |||
| 77b7f066f3 | |||
| 1970a74f6a | |||
| b66c4966ba | |||
| 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 | |||
| 67bf5be0f2 | |||
| efb613b0b5 | |||
| 8124e59de5 | |||
| ac8c293ff3 | |||
| 4e38b73cac | |||
| 8f336377f6 | |||
| 5f78dd66fc | |||
| f2ee939006 | |||
| 23d2060742 | |||
| bb0da0b769 | |||
| e51af4be1f | |||
| a82f3a75b0 | |||
| 7d07fe08b7 | |||
| 375877b223 | |||
| 004daeadb6 | |||
| 3bcb80d7a2 | |||
| 5ff9621227 | |||
| 84db9711bc | |||
| 2f97e892d5 | |||
| 17fd1e4ad4 | |||
| e56dd898ef | |||
| 06bb8b3b04 | |||
| 42a775c3bb | |||
| 05c3b5842e | |||
| 149e10ee66 | |||
| e194813f3b | |||
| f1cad30f44 | |||
| 2e05df288b | |||
| 3e5bb8fc0b | |||
| d1e3c59537 | |||
| 8b8ebb6092 | |||
| 0195a2a01b | |||
| ad9b336fb9 | |||
| 30d9552207 | |||
| 5e96a8de72 | |||
| 69faeaa9a6 | |||
| e8bfe52104 | |||
| 9767cc3247 | |||
| e8a7f999c8 | |||
| 4d4f5f8b1f | |||
| d2a425d761 | |||
| 673d38a089 | |||
| 2e8c0530a9 | |||
| 32ee511eac | |||
| f410640862 | |||
| 6427b830ea | |||
| 5e7bf3ba5c | |||
| 27d6691232 | |||
| cc4b8a916a | |||
| d9dd5131b2 | |||
| a98c9f4f59 | |||
| 5cb935e0e5 | |||
| 6f59e4be0c | |||
| 647446a2a2 | |||
| 42d6249af5 | |||
| de9ab51def | |||
| a2ec08f033 | |||
| f8809d73fb | |||
| 413081f72a | |||
| e5339a8072 | |||
| fbb4d82a44 | |||
| 508a37a84c | |||
| d60655045a | |||
| 31ef861504 | |||
| f1bdb2e2ef | |||
| 61b71a11c7 | |||
| 4c69ff491d | |||
| 0ad4904e20 | |||
| bd30b61018 | |||
| e0dd70a054 | |||
| 4966a8aad1 | |||
| 3e11549370 | |||
| addf83a249 | |||
| c3e35fd416 | |||
| 775dfe19f1 | |||
| db3b2f651b | |||
| 669323f926 | |||
| 7202b69e4e | |||
| 31977a62e6 | |||
| 87479c2ac1 | |||
| ffda0fb71a | |||
| 9c0e7341c3 | |||
| 1db5452be9 | |||
| c10f894afd | |||
| 7fb75baa73 | |||
| 15e6cd69eb | |||
| f6eb88d75e | |||
| f45b580984 | |||
| 4f50c47550 | |||
| 2e1d75dc36 | |||
| f496f58a4d | |||
| 32474bec20 | |||
| 581cda7edc | |||
| 96aebb8d61 | |||
| 735fd40786 | |||
| a3b0bf4898 | |||
| 9703e4a081 | |||
| a43653f252 | |||
| 348900de01 | |||
| b93c259fac | |||
| 2e3d5c9249 | |||
| c8c1841058 | |||
| 1c596fae4b | |||
| 2ff52e3275 | |||
| 7efb49bdab | |||
| a00b20abf3 | |||
| 1450b25475 | |||
| b815be54b8 | |||
| 75edeb9afa | |||
| 7c54192055 | |||
| 7ba68778c1 | |||
| 7b736b2e60 | |||
| ef613c1518 | |||
| b62df6ffa3 | |||
| be9d8773ce | |||
| 1c10347357 | |||
| 5bd21e2ac1 | |||
| e38335973d | |||
| 95929bf82e | |||
| 9cf9f4f1f7 | |||
| ae8dbdd77f | |||
| 05bd6a686d | |||
| b8193417f5 | |||
| 15e4508be4 | |||
| 634c6cd620 | |||
| 4eaecb289b | |||
| 98a9e5c666 | |||
| d244920dd4 | |||
| 77e4387b35 | |||
| 7a40ddb1ef | |||
| d33814e18c | |||
| cf51b305b0 | |||
| 9ffeb83753 | |||
| 2f40cf29d4 | |||
| a92eb168aa | |||
| de21a55f37 | |||
| b01dbdb6e5 | |||
| a645cc699b | |||
| 528f912aac | |||
| 8bf30a157f | |||
| 58abba7fc0 | |||
| d8cd4b404c | |||
| 9e979aa82a | |||
| 5caaaf6092 | |||
| f23d1c2afd | |||
| 5e00cbf131 | |||
| 975948dcf6 | |||
| a9eac6d01f | |||
| 6e8406f439 | |||
| db3577afa9 | |||
| 7285717e34 | |||
| de6911f219 | |||
| e5ce0501a2 | |||
| 25a87e25c5 | |||
| a628f31859 | |||
| aae138a8cf | |||
| d2645589da |
@@ -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`
|
||||
@@ -21,11 +21,14 @@ jobs:
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --legacy-peer-deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
backend:
|
||||
name: Build Go Backend
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Publish Skill to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION=$(node -p "require('./skills/flvx-api/package.json').version")
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Publishing skill version: $VERSION"
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: skills/flvx-api
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: Skill v${{ steps.version.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: skills/flvx-api/package.json
|
||||
@@ -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,115 +1,62 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
# AGENTS
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Commit:** 21008cc
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.5-rc15
|
||||
FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
|
||||
|
||||
## 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.
|
||||
## Structure
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
./
|
||||
├── go-gost/ # Go forwarding agent (forked gost + local x/)
|
||||
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
|
||||
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
|
||||
│ └── tests/contract/ # Integration/contract tests
|
||||
├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
|
||||
│ └── src/shadcn-bridge/heroui/ # HeroUI-compatible facade
|
||||
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
|
||||
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
|
||||
├── panel_install.sh # Panel installer/upgrader (downloads compose)
|
||||
├── install.sh # Node installer/upgrader (downloads gost binary)
|
||||
└── .github/workflows/ # CI: build/test + Docker push + release artifacts
|
||||
```
|
||||
| Dir | Role | Entry |
|
||||
|-----|------|-------|
|
||||
| `go-backend/` | Admin API (GORM + SQLite/PG, net/http) | `cmd/paneld/main.go` |
|
||||
| `go-gost/` | Forwarding agent (forked GOST) | `main.go` |
|
||||
| `go-gost/x/` | Protocol handlers/dialers/listeners (own module) | — |
|
||||
| `vite-frontend/` | React dashboard (shadcn bridge + Tailwind v4) | `src/App.tsx` |
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
|
||||
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
|
||||
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
|
||||
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
|
||||
| **Admin API** | `go-backend/` | Go Admin API (SQLite/PostgreSQL) |
|
||||
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
|
||||
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
|
||||
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
|
||||
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
|
||||
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
|
||||
| **Repository Layer** | `go-backend/internal/store/repo/` | GORM data access (repository.go 83k LOC) |
|
||||
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
|
||||
| **CI Workflows** | `.github/workflows/` | ci-build.yml, docker-build.yml, deploy-docs.yml |
|
||||
`go-gost/go.mod` uses `replace github.com/go-gost/x => ./x`.
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Role |
|
||||
|--------|------|----------|------|
|
||||
| `flvx` | Project | `.` | Root directory |
|
||||
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
|
||||
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
|
||||
| `main` | Func | `go-gost/main.go` | Agent Entry |
|
||||
| `Repository` | Struct | `go-backend/internal/store/repo/repository.go` | Data Access Layer |
|
||||
| `Handler` | Struct | `go-backend/internal/http/handler/handler.go` | HTTP Handlers |
|
||||
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
||||
## Commands
|
||||
|
||||
## CONVENTIONS
|
||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
|
||||
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
|
||||
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
|
||||
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
|
||||
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
|
||||
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
|
||||
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
# Panel (Docker)
|
||||
docker compose -f docker-compose-v4.yml up -d
|
||||
docker compose -f docker-compose-v6.yml up -d
|
||||
|
||||
# Release-based install scripts
|
||||
./panel_install.sh
|
||||
./install.sh
|
||||
|
||||
# Local dev (per subproject)
|
||||
# Backend
|
||||
(cd go-backend && go run ./cmd/paneld) # SERVER_ADDR defaults to :6365
|
||||
(cd go-backend && make build)
|
||||
(cd vite-frontend && npm run dev)
|
||||
(cd go-gost && go run .)
|
||||
|
||||
# Testing
|
||||
(cd go-backend && go test ./...)
|
||||
(cd go-backend && go test ./tests/contract/...)
|
||||
|
||||
# Frontend
|
||||
(cd vite-frontend && pnpm install)
|
||||
(cd vite-frontend && pnpm run dev) # host 0.0.0.0:3000
|
||||
(cd vite-frontend && pnpm run build) # tsc && vite build
|
||||
(cd vite-frontend && pnpm run lint) # eslint --fix (no typecheck command)
|
||||
|
||||
# Agent
|
||||
(cd go-gost && go run .)
|
||||
```
|
||||
|
||||
## UNIQUE STYLES
|
||||
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
|
||||
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
|
||||
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
|
||||
- **Experimental Bundler**: `vite-frontend` uses `rolldown-vite` (Rust-based) instead of standard Vite.
|
||||
- **Non-minified Builds**: `vite.config.ts` sets `minify: false`, `treeshake: false` for debugging.
|
||||
## Conventions
|
||||
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
|
||||
- CI uses UPX compression (`--best --lzma`) on Go binaries before release.
|
||||
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
|
||||
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
|
||||
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
|
||||
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
|
||||
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
|
||||
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
|
||||
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
|
||||
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
|
||||
- **Auth**: raw JWT in `Authorization` header — **no `Bearer` prefix** (both frontend and backend).
|
||||
- **API envelope**: all responses `{code, msg, data, ts}` (code 0 = success).
|
||||
- **Frontend UI**: import from `src/shadcn-bridge/heroui/*`, never `@heroui/*` or `@nextui-org/*`.
|
||||
- **Tailwind theme**: `globals.css` must import `tailwind-theme.pcss` or semantic classes break.
|
||||
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
|
||||
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
|
||||
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
|
||||
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't edit `install.sh` or `panel_install.sh` locally (CI overwrites on release).
|
||||
- Don't edit `go-gost/x/internal/util/grpc/proto/*.pb.go` (generated).
|
||||
- Don't add frontend tests (no Vitest/Jest configured).
|
||||
- Don't reintroduce `@heroui/*` or `@nextui-org/*` packages.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: `(cd go-backend && go test ./...)` — includes contract tests in `tests/contract/`.
|
||||
- Frontend: no test infrastructure.
|
||||
- CI runs one PostgreSQL contract test: env var `FLVX_POSTGRES_TEST_DSN`.
|
||||
|
||||
## Build quirks
|
||||
|
||||
- `vite-frontend` uses `rolldown-vite` (Rust bundler), not standard Vite.
|
||||
- `vite.config.ts`: `minify: false`, `treeshake: false` (debugging mode).
|
||||
- CI builds `go-gost` with `CGO_ENABLED=0` then compresses with UPX `--best --lzma`.
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
# FLVX
|
||||
|
||||
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||
> **联系我们**: [Telegram群组](https://t.me/flvxchannel)
|
||||
|
||||
|
||||
## 特性
|
||||
@@ -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)
|
||||
@@ -184,7 +184,6 @@ This fork (FLVX) is no longer a light patch on top of the upstream project. It h
|
||||
|
||||
| 网络 | 地址 |
|
||||
|------------|----------------------------------------------------------------------|
|
||||
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
|
||||
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
|
||||
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
| BNB(BEP20) | `0x271327ce49140e670eA0F772d9886BF90E9022Ee` |
|
||||
| TRC20 | `TARxZWggaxFqYgxGVBxPkyykgYKNmGndmE` |
|
||||
| polygon | `0x271327ce49140e670eA0F772d9886BF90E9022Ee` |
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# AI Skill 使用指南
|
||||
|
||||
让大模型直接操作 FLVX 面板的技能包。支持 OpenCode、OpenClaw、Claude Code 等工具。
|
||||
|
||||
## 安装
|
||||
|
||||
### 方式 1: npm (推荐)
|
||||
|
||||
```bash
|
||||
npm install -g @flvx/skill-api
|
||||
```
|
||||
|
||||
postinstall 脚本会自动链接到 `~/.agents/skills/flvx-api/`。
|
||||
|
||||
### 方式 2: 手动链接
|
||||
|
||||
```bash
|
||||
# 从 FLVX 源码
|
||||
cd /path/to/flvx
|
||||
mkdir -p ~/.agents/skills
|
||||
ln -sf $(pwd)/skills/flvx-api ~/.agents/skills/
|
||||
|
||||
# 或从 GitHub
|
||||
git clone https://github.com/Sagit-chu/flvx.git
|
||||
cd flvx
|
||||
ln -sf $(pwd)/skills/flvx-api ~/.agents/skills/
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
设置环境变量:
|
||||
|
||||
```bash
|
||||
export FLVX_BASE_URL="https://your-panel.example.com"
|
||||
export FLVX_USERNAME="admin"
|
||||
export FLVX_PASSWORD="your-password"
|
||||
```
|
||||
|
||||
或使用凭证文件:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.flvx
|
||||
cat > ~/.flvx/.env << 'EOF'
|
||||
export FLVX_BASE_URL="https://panel.example.com"
|
||||
export FLVX_USERNAME="admin"
|
||||
export FLVX_PASSWORD="your-password"
|
||||
EOF
|
||||
chmod 600 ~/.flvx/.env
|
||||
source ~/.flvx/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工具接入方法
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode 是命令行 AI 编程助手,支持通过 skills 扩展能力。
|
||||
|
||||
**安装 skill:**
|
||||
```bash
|
||||
npm install -g @flvx/skill-api
|
||||
```
|
||||
|
||||
**使用:**
|
||||
```bash
|
||||
export FLVX_BASE_URL="https://panel.example.com"
|
||||
export FLVX_USERNAME="admin"
|
||||
export FLVX_PASSWORD="your-password"
|
||||
|
||||
opencode
|
||||
```
|
||||
|
||||
**示例对话:**
|
||||
```
|
||||
你: 查看我的转发列表
|
||||
你: 创建一个转发到 192.168.1.100:80 使用隧道 1
|
||||
你: 检查节点状态
|
||||
你: 查看流量使用情况
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OpenClaw
|
||||
|
||||
OpenClaw 同样支持 skills 机制。
|
||||
|
||||
**安装 skill:**
|
||||
```bash
|
||||
npm install -g @flvx/skill-api
|
||||
|
||||
# 或手动链接
|
||||
mkdir -p ~/.openclaw/skills
|
||||
ln -sf /path/to/flvx/skills/flvx-api ~/.openclaw/skills/flvx-api
|
||||
```
|
||||
|
||||
**使用:**
|
||||
```bash
|
||||
openclaw
|
||||
|
||||
>>> 查看所有节点状态
|
||||
>>> 给用户 alice 分配 50GB 流量
|
||||
>>> 导出系统备份
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code 是 Anthropic 官方的命令行工具,支持通过 CLAUDE.md 扩展。
|
||||
|
||||
#### 方式 1: 项目级 CLAUDE.md
|
||||
|
||||
在项目根目录创建 `CLAUDE.md`:
|
||||
|
||||
```markdown
|
||||
# FLVX API Skill
|
||||
|
||||
你可以通过 REST API 操作 FLVX 面板。
|
||||
|
||||
## 环境变量
|
||||
- FLVX_BASE_URL: 面板地址
|
||||
- FLVX_USERNAME: 用户名
|
||||
- FLVX_PASSWORD: 密码
|
||||
|
||||
## 认证规则
|
||||
- Authorization 头使用原始 JWT token,不加 "Bearer " 前缀
|
||||
- 所有 API 使用 POST 方法
|
||||
|
||||
## 常用 API
|
||||
|
||||
### 登录获取 token
|
||||
POST /api/v1/user/login
|
||||
{"username": "...", "password": "..."}
|
||||
|
||||
### 查看转发列表
|
||||
POST /api/v1/forward/list
|
||||
Authorization: <token>
|
||||
{}
|
||||
|
||||
### 创建转发
|
||||
POST /api/v1/forward/create
|
||||
{"name": "xxx", "tunnelId": 1, "remoteAddr": "1.2.3.4:80"}
|
||||
|
||||
### 查看节点
|
||||
POST /api/v1/node/list
|
||||
{}
|
||||
```
|
||||
|
||||
**使用:**
|
||||
```bash
|
||||
cd /path/to/your/project
|
||||
claude
|
||||
```
|
||||
|
||||
#### 方式 2: 全局 CLAUDE.md
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude
|
||||
cat > ~/.claude/CLAUDE.md << 'EOF'
|
||||
# FLVX Panel Operations
|
||||
|
||||
使用 FLVX REST API 操作流量转发面板。
|
||||
|
||||
环境变量: FLVX_BASE_URL, FLVX_USERNAME, FLVX_PASSWORD
|
||||
调用方式: curl -X POST "$FLVX_BASE_URL/api/v1/..." -H "Authorization: $TOKEN"
|
||||
注意: Authorization 不要加 Bearer 前缀
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 方式 3: 复制 SKILL.md
|
||||
|
||||
```bash
|
||||
cat ~/.agents/skills/flvx-api/SKILL.md >> ~/.claude/CLAUDE.md
|
||||
```
|
||||
|
||||
**示例对话:**
|
||||
```
|
||||
>>> 帮我查看 FLVX 面板上有哪些节点
|
||||
>>> 创建一个名为 test 的转发,目标地址 10.0.0.1:80
|
||||
>>> 查看我的流量使用情况
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 覆盖
|
||||
|
||||
| 模块 | 操作 |
|
||||
|------|------|
|
||||
| 认证 | 登录、Token 管理 |
|
||||
| 用户 | 增删改查、流量重置、密码 |
|
||||
| 节点 | 增删改查、安装、升级、状态 |
|
||||
| 隧道 | 增删改查、用户分配 |
|
||||
| 转发 | 增删改查、暂停/恢复、诊断 |
|
||||
| 分组 | 用户/隧道分组、权限 |
|
||||
| 限速 | 增删改查 |
|
||||
| 联邦 | 节点共享、远程节点 |
|
||||
| 备份 | 导出/导入 |
|
||||
|
||||
## 安全提示
|
||||
|
||||
- ⚠️ 环境变量在进程列表中可见
|
||||
- 使用 `~/.flvx/.env` 文件并设置 `chmod 600`
|
||||
- 添加 `export HISTIGNORE="*FLVX_PASSWORD*"` 防止密码进入历史记录
|
||||
- Token 仅在会话内存中缓存,不写入磁盘
|
||||
|
||||
## 发布
|
||||
|
||||
维护者可通过以下方式发布新版本:
|
||||
|
||||
```bash
|
||||
# 方式 1: 推送 tag
|
||||
git tag skill-v2.1.6
|
||||
git push --tags
|
||||
|
||||
# 方式 2: GitHub Actions 手动触发
|
||||
# 在 Actions 页面运行 publish-skill workflow
|
||||
```
|
||||
|
||||
需要在 GitHub 仓库设置 `NPM_TOKEN` secret。
|
||||
@@ -18,6 +18,7 @@
|
||||
- [安装部署](./install.md)
|
||||
- [使用指南](./usage.md)
|
||||
- [PostgreSQL 数据库指南](./postgresql.md)
|
||||
- [AI Skill 接入](./ai-skill.md) - 让大模型直接操作面板
|
||||
- [常见问题](./faq.md)
|
||||
|
||||
## 免责声明
|
||||
|
||||
@@ -15,10 +15,15 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./:/opt/flvx-panel
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
|
||||
@@ -15,10 +15,15 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./:/opt/flvx-panel
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# Proxy Protocol 传输分析报告
|
||||
|
||||
**日期**: 2026-05-07
|
||||
**测试环境**: 20.118.172.127 (Server 1) ↔ 108.181.90.137 (Server 2)
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码流程分析
|
||||
|
||||
### 完整数据链路
|
||||
|
||||
```
|
||||
前端 (proxyProtocol: 0|1|2)
|
||||
→ 后端 handler mutations.go:1936
|
||||
→ 数据库存储 forward.proxy_protocol (model.go:50)
|
||||
→ 控制面 buildForwardServiceConfigs (control_plane.go:1791-1796)
|
||||
→ handler metadata: {"proxyProtocol": 2}
|
||||
→ Agent metadata 解析 (metadata.go:42)
|
||||
→ handler.go:256 WrapClientConn()
|
||||
→ conn.go:14 HeaderProxyFromAddrs(byte(ppv), src, dst)
|
||||
→ conn.go:15 header.WriteTo(c)
|
||||
→ 目标服务器收到 PROXY protocol header
|
||||
```
|
||||
|
||||
### 关键代码
|
||||
|
||||
**写入 PROXY header** (`go-gost/x/internal/net/proxyproto/conn.go`):
|
||||
```go
|
||||
func WrapClientConn(ppv int, src, dst net.Addr, c net.Conn) net.Conn {
|
||||
if ppv <= 0 {
|
||||
return c
|
||||
}
|
||||
header := proxyproto.HeaderProxyFromrs(byte(ppv), src, dst)
|
||||
header.WriteTo(c)
|
||||
return c
|
||||
}
|
||||
```
|
||||
|
||||
**Handler 调用** (`go-gost/x/handler/forward/local/handler.go:256`):
|
||||
```go
|
||||
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), conn.LocalAddr(), cc)
|
||||
```
|
||||
|
||||
- `src` = `conn.RemoteAddr()` → 客户端真实 IP ✅
|
||||
- `dst` = `conn.LocalAddr()` → agent 监听地址 ✅
|
||||
- `ppv` = 1 或 2 → 版本号正确 ✅
|
||||
|
||||
---
|
||||
|
||||
## 2. 实际传输测试结果
|
||||
|
||||
### 测试方法
|
||||
|
||||
1. 在 Server 2 启动 Python TCP 监听器,解析 PROXY protocol header
|
||||
2. 在 Server 1 用当前代码编译 gost,配置 `proxyProtocol: 2` 转发到 Server 2
|
||||
3. 通过 `nc` 发送测试数据,验证 Server 2 是否收到正确的 PROXY header
|
||||
|
||||
### 测试结果
|
||||
|
||||
| 版本 | 状态 | 接收到的 Header |
|
||||
|------|------|----------------|
|
||||
| **PPv2** | ✅ 成功 | `PP2 family=1 alen=12 SRC=127.0.0.1:45410 DST=127.0.0.1:20001` |
|
||||
| **PPv1** | ✅ 成功 | `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001` |
|
||||
|
||||
### 测试详情
|
||||
|
||||
**PPv2 原始数据**:
|
||||
```
|
||||
Got 28 bytes
|
||||
PP2 family=1 alen=12
|
||||
SRC=127.0.0.1:45410 DST=127.0.0.1:20001
|
||||
```
|
||||
|
||||
**PPv1 原始数据** (hex):
|
||||
```
|
||||
50524f58592054435034203132372e302e302e31203132372e302e302e312034333831362032303030310d0a
|
||||
```
|
||||
解码: `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001`
|
||||
|
||||
---
|
||||
|
||||
## 3. 单元测试结果
|
||||
|
||||
```
|
||||
go-gost/x/handler/forward/local/ → TestLocalForwardHandlerSendsProxyProtocolToTarget ✅
|
||||
go-backend/internal/http/handler/ → TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler ✅
|
||||
go-backend/internal/store/repo/ → TestGetForwardRecordIncludesProxyProtocol ✅
|
||||
```
|
||||
|
||||
全部通过 (3/3)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 发现的问题
|
||||
|
||||
### 问题 1: `WriteTo` 错误未检查
|
||||
|
||||
**位置**: `go-gost/x/internal/net/proxyproto/conn.go:15`
|
||||
|
||||
```go
|
||||
header.WriteTo(c) // 返回 (int64, error) 被忽略
|
||||
```
|
||||
|
||||
**影响**: 如果写入失败(连接已断开、网络错误等),后续数据传输会在没有 PROXY header 的情况下继续,目标服务器可能解析出错。
|
||||
|
||||
**建议**:
|
||||
```go
|
||||
if _, err := header.WriteTo(c); err != nil {
|
||||
return c // 或包装一个带错误的 conn
|
||||
}
|
||||
```
|
||||
|
||||
**严重程度**: 低(实际场景中,写入失败后 `Transport` 也会很快失败)
|
||||
|
||||
---
|
||||
|
||||
### 问题 2: 部署版本过旧
|
||||
|
||||
**服务器状态**:
|
||||
|
||||
| 服务器 | 组件 | 版本 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| 20.118.172.127 | flux_agent | UPX 压缩,无法读取版本 | ✅ 运行中 |
|
||||
| 20.118.172.127 | paneld | `/app/paneld` | ✅ 运行中 |
|
||||
| 20.118.172.127 | /usr/local/bin/gost | v3.0.0 (go1.23.4) | 旧版,不支持 handler metadata 中的 proxyProtocol |
|
||||
| 108.181.90.137 | flux_agent | 8.8MB | ✅ 运行中 |
|
||||
|
||||
**影响**: 旧版 gost 二进制不识别 handler metadata 中的 `proxyProtocol` 字段,PROXY protocol 功能在生产环境不可用。
|
||||
|
||||
**验证**: 用旧版 gost 测试时,目标服务器收到的原始数据为空,无 PROXY header。
|
||||
|
||||
---
|
||||
|
||||
### 问题 3: 数据库 Schema 缺失
|
||||
|
||||
**位置**: 20.118.172.127 的 `/app/data/gost.db`
|
||||
|
||||
**当前 forward 表 schema**:
|
||||
```sql
|
||||
CREATE TABLE `forward` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT,
|
||||
`user_id` integer NOT NULL,
|
||||
`user_name` varchar(100) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`tunnel_id` integer NOT NULL,
|
||||
`remote_addr` text NOT NULL,
|
||||
`strategy` varchar(100) NOT NULL DEFAULT "fifo",
|
||||
`in_flow` integer NOT NULL DEFAULT 0,
|
||||
`out_flow` integer NOT NULL DEFAULT 0,
|
||||
`created_time` integer NOT NULL,
|
||||
`updated_time` integer NOT NULL,
|
||||
`status` integer NOT NULL,
|
||||
`inx` integer NOT NULL DEFAULT 0,
|
||||
`speed_id` integer
|
||||
);
|
||||
```
|
||||
|
||||
**缺失字段**:
|
||||
- `proxy_protocol` — PROXY protocol 版本
|
||||
- `max_conn` — 最大连接数
|
||||
- `ip_max_conn` — 每 IP 最大连接数
|
||||
- `ip_speed_id` — 每 IP 限速 ID
|
||||
|
||||
**影响**: 后端无法存储和读取 proxy_protocol 配置,前端设置不会生效。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论
|
||||
|
||||
| 维度 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| **代码实现** | ✅ 正确 | 完整的写入链路,版本/地址正确 |
|
||||
| **单元测试** | ✅ 通过 | 3/3 测试覆盖 handler、repo、控制面 |
|
||||
| **实际传输 (新编译版)** | ✅ 成功 | PPv1 和 PPv2 均正确传输 |
|
||||
| **实际传输 (部署版)** | ❌ 不工作 | 旧版不支持 handler metadata 中的 proxyProtocol |
|
||||
| **数据库 Schema** | ❌ 缺字段 | 需要迁移添加 proxy_protocol 等列 |
|
||||
|
||||
**总结**: 代码实现正确,PROXY protocol 传输逻辑无误。但生产服务器运行的是旧版本,需要升级 backend 和 agent 才能启用此功能。
|
||||
@@ -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,482 @@
|
||||
# 最大连接数限制实现计划
|
||||
|
||||
> **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 (`[x]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 FLVX 中实现基于用户的全局最大连接数限制和基于单条规则的独立最大连接数限制功能。前端输入框为 0 或空时表示不限制。
|
||||
|
||||
**Architecture:** 采用“覆盖逻辑”(方案二)。
|
||||
1. 数据库层面:在 `user` 和 `forward` 表中各增加一个整型字段 `max_conn`,默认值为 0(表示不限制)。
|
||||
2. 后端接口层面:提供 API 更新该字段,在组装下发给 GOST 的配置时,判断规则的 `max_conn` 是否大于 0:
|
||||
- 如果规则 `max_conn > 0`,则为此规则动态生成一个唯一的连接限制器配置,并在下发服务的 `climiter` 字段中引用该限制器。
|
||||
- 如果规则 `max_conn == 0`,则检查该规则所属用户的 `max_conn`。
|
||||
- 如果用户 `max_conn > 0`,则引用以用户维度的连接限制器配置(如 `user_conn_limit_<user_id>`)。
|
||||
- 否则不下发 `climiter`。
|
||||
3. 后端服务控制平面:需要在下发服务前,将需要的连接限制器(Rule 或 User 维度)推送到节点上。
|
||||
- **重要发现:** 当前 `go-gost` 的 WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`) 仅支持 `TrafficLimiter` 的动态增删(如 `AddLimiters` 等),**不支持** `ConnLimiter`(即 `CLimiters`)。
|
||||
- **计划修改:** 我们需要先在 `go-gost` 侧(`go-gost/x/socket`)添加针对 `CLimiters` 的 WebSocket 指令(`AddCLimiters`, `UpdateCLimiters`, `DeleteCLimiters`)以及对应的处理函数(参考 `AddLimiters` 等的实现,调用现有的针对 `ConnLimiterRegistry` 的相关接口和配置存储逻辑,具体需要实现类似 `createLimiter` 到 `createConnLimiter` 的逻辑)。
|
||||
- 完成底层修改后,`go-backend` 再通过这些新增加的 WebSocket 指令,在 `ensureLimiterOnNode` 时下发最大连接数限制规则。
|
||||
4. 前端层面:在用户管理和规则管理页面增加输入框组件。
|
||||
|
||||
**Tech Stack:** Go, GORM, SQLite/PostgreSQL, React, Vite, TypeScript, TailwindCSS.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展 go-gost WebSocket 接口以支持 CLimiters
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/x/socket/limiter.go`
|
||||
- Modify: `go-gost/x/socket/websocket_reporter.go`
|
||||
|
||||
[x] **Step 1: 实现 `createConnLimiter` 等功能**
|
||||
|
||||
在 `go-gost/x/socket/limiter.go` 中参考现有 `createLimiter` 添加对 `CLimiters` 的支持:
|
||||
|
||||
```go
|
||||
func createConnLimiter(req createLimiterRequest) error {
|
||||
name := strings.TrimSpace(req.Data.Name)
|
||||
if name == "" {
|
||||
return errors.New("limiter name is required")
|
||||
}
|
||||
req.Data.Name = name
|
||||
|
||||
if registry.ConnLimiterRegistry().IsRegistered(name) {
|
||||
return errors.New("conn limiter " + name + " already exists")
|
||||
}
|
||||
|
||||
v := parser.ParseConnLimiter(&req.Data)
|
||||
|
||||
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
|
||||
return errors.New("conn limiter " + name + " already exists")
|
||||
}
|
||||
|
||||
if c := config.Global(); c != nil {
|
||||
c.CLimiters = append(c.CLimiters, &req.Data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateConnLimiter(req updateLimiterRequest) error {
|
||||
name := strings.TrimSpace(req.Limiter)
|
||||
req.Data.Name = name
|
||||
if registry.ConnLimiterRegistry().IsRegistered(name) {
|
||||
registry.ConnLimiterRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
v := parser.ParseConnLimiter(&req.Data)
|
||||
|
||||
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
|
||||
return errors.New("conn limiter " + name + " already exists")
|
||||
}
|
||||
|
||||
if c := config.Global(); c != nil {
|
||||
for i := range c.CLimiters {
|
||||
if c.CLimiters[i].Name == name {
|
||||
c.CLimiters[i] = &req.Data
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.CLimiters = append(c.CLimiters, &req.Data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteConnLimiter(req deleteLimiterRequest) error {
|
||||
name := strings.TrimSpace(req.Limiter)
|
||||
|
||||
if registry.ConnLimiterRegistry().IsRegistered(name) {
|
||||
registry.ConnLimiterRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
if c := config.Global(); c != nil {
|
||||
limiteres := c.CLimiters
|
||||
c.CLimiters = nil
|
||||
for _, s := range limiteres {
|
||||
if s.Name == name {
|
||||
continue
|
||||
}
|
||||
c.CLimiters = append(c.CLimiters, s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 2: 在 `WebSocketReporter` 注册命令**
|
||||
|
||||
在 `go-gost/x/socket/websocket_reporter.go` 的 `ProcessCommand` 中添加 case:
|
||||
|
||||
```go
|
||||
case "AddCLimiters":
|
||||
err = w.handleAddCLimiter(cmd.Data)
|
||||
response.Type = "AddCLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateCLimiters":
|
||||
err = w.handleUpdateCLimiter(cmd.Data)
|
||||
response.Type = "UpdateCLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteCLimiters":
|
||||
err = w.handleDeleteCLimiter(cmd.Data)
|
||||
response.Type = "DeleteCLimitersResponse"
|
||||
needSaveConfig = true
|
||||
```
|
||||
|
||||
[x] **Step 3: 实现 Handler 方法**
|
||||
|
||||
在 `go-gost/x/socket/websocket_reporter.go` 中添加:
|
||||
|
||||
```go
|
||||
func (w *WebSocketReporter) handleAddCLimiter(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var limiterConfig config.LimiterConfig
|
||||
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
|
||||
return fmt.Errorf("解析限流器配置失败: %v", err)
|
||||
}
|
||||
|
||||
req := createLimiterRequest{Data: limiterConfig}
|
||||
return createConnLimiter(req)
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleUpdateCLimiter(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var updateReq struct {
|
||||
Limiter string `json:"limiter"`
|
||||
Data config.LimiterConfig `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &updateReq); err != nil {
|
||||
var limiterConfig config.LimiterConfig
|
||||
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
|
||||
return fmt.Errorf("解析更新请求失败: %v", err)
|
||||
}
|
||||
updateReq.Limiter = limiterConfig.Name
|
||||
updateReq.Data = limiterConfig
|
||||
}
|
||||
|
||||
req := updateLimiterRequest{
|
||||
Limiter: updateReq.Limiter,
|
||||
Data: updateReq.Data,
|
||||
}
|
||||
return updateConnLimiter(req)
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleDeleteCLimiter(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var deleteReq deleteLimiterRequest
|
||||
|
||||
if err := json.Unmarshal(jsonData, &deleteReq); err != nil {
|
||||
var limiterName string
|
||||
if err := json.Unmarshal(jsonData, &limiterName); err != nil {
|
||||
return fmt.Errorf("解析删除请求失败: %v", err)
|
||||
}
|
||||
deleteReq.Limiter = limiterName
|
||||
}
|
||||
|
||||
return deleteConnLimiter(deleteReq)
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd go-gost
|
||||
git add x/socket/limiter.go x/socket/websocket_reporter.go
|
||||
git commit -m "feat: add CLimiters support for websocket reporter"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 数据库迁移与模型更新
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/store/model/model.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
|
||||
[x] **Step 1: 更新数据库模型**
|
||||
|
||||
在 `go-backend/internal/store/model/model.go` 的 `User` 和 `Forward` 结构体中添加 `MaxConn` 字段。
|
||||
|
||||
```go
|
||||
// 在 User 结构体中
|
||||
type User struct {
|
||||
// ...
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
// ...
|
||||
}
|
||||
|
||||
// 在 Forward 结构体中
|
||||
type Forward struct {
|
||||
// ...
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 2: 编写数据库迁移**
|
||||
|
||||
在 `go-backend/internal/store/repo/repository.go` 的 `AutoMigrate` 逻辑前(如果有自定义迁移)或利用 gorm 自动迁移机制,由于这是 autoMigrate,添加字段只要 `db.AutoMigrate(&model.User{}, &model.Forward{})` 被调用就能自动加上。确认已执行迁移。由于 `FLVX` 通常会自动执行迁移,只需修改模型即可。我们需要处理默认值,由于使用了 `default:0`,GORM 会处理新增字段的默认值,但为了安全起见,如果在旧环境中,可能直接 alter table。
|
||||
|
||||
```go
|
||||
// 无需手动编写 SQL,依赖现有的 gorm AutoMigrate 即可。
|
||||
```
|
||||
|
||||
[x] **Step 3: 运行并验证迁移通过**
|
||||
|
||||
Run: `make build` (在 go-backend 中),或者运行一个相关的存储单元测试。
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd go-backend
|
||||
git add internal/store/model/model.go
|
||||
git commit -m "feat: add max_conn field to user and forward models"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端控制平面 - 连接数限制器的组装与下发
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/control_plane.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository_control.go`
|
||||
|
||||
[x] **Step 1: 更新存储层以获取 User 的 MaxConn**
|
||||
|
||||
在 `go-backend/internal/store/repo/repository_control.go` 中:
|
||||
|
||||
需要一个方法获取 User,或者如果已经有,确保可以拿到 `MaxConn`。
|
||||
|
||||
[x] **Step 2: 编写下发 CLimiter 到节点的辅助函数**
|
||||
|
||||
在 `go-backend/internal/http/handler/control_plane.go`,参考 `ensureLimiterOnNode` 和 `upsertLimiterOnNode`:
|
||||
|
||||
```go
|
||||
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
|
||||
limitStr := fmt.Sprintf("$ %d", maxConn)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": limiterName,
|
||||
"limits": []string{limitStr},
|
||||
}
|
||||
|
||||
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
|
||||
if !isAlreadyExistsMessage(err.Error()) {
|
||||
return fmt.Errorf("连接限制器下发失败: %w", err)
|
||||
}
|
||||
updatePayload := map[string]interface{}{
|
||||
"limiter": limiterName,
|
||||
"data": payload,
|
||||
}
|
||||
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
|
||||
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 3: 更新组装配置逻辑以绑定 `climiter`**
|
||||
|
||||
在 `control_plane.go` 的 `syncForwardServicesWithWarnings` 及其辅助函数 `buildForwardServiceConfigs` 附近:
|
||||
|
||||
修改 `buildForwardServiceConfigs` 的签名,传入 `maxConn int` 和对应的 `cLimiterName string`。
|
||||
|
||||
```go
|
||||
func buildForwardServiceConfigs(baseName string, forward *model.Forward, tunnel *model.Tunnel, node *model.Node, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {
|
||||
// ... 现有逻辑
|
||||
// 在服务配置生成的部分增加:
|
||||
if cLimiterName != "" {
|
||||
service["climiter"] = cLimiterName
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 4: 在转发服务同步主流程中决定并下发 `climiter`**
|
||||
|
||||
在 `syncForwardServicesWithWarnings` (可能在多个重载/处理入口处,如 `ensureForwardServices`),查出转发所属 user 的 `MaxConn`,以及转发本身的 `MaxConn`。
|
||||
|
||||
```go
|
||||
// 获取 User
|
||||
user, err := h.repo.GetUser(forward.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cLimiterName string
|
||||
var maxConnToSet int
|
||||
|
||||
if forward.MaxConn > 0 {
|
||||
maxConnToSet = forward.MaxConn
|
||||
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
|
||||
} else if user != nil && user.MaxConn > 0 {
|
||||
maxConnToSet = user.MaxConn
|
||||
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
|
||||
}
|
||||
|
||||
if cLimiterName != "" {
|
||||
for _, fp := range ports {
|
||||
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 传递给 buildForwardServiceConfigs
|
||||
// ...
|
||||
```
|
||||
*(注意:需要确保更新涉及 `buildForwardServiceConfigs` 的所有调用点)*
|
||||
|
||||
[x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd go-backend
|
||||
git add internal/http/handler/control_plane.go internal/store/repo/repository_control.go
|
||||
git commit -m "feat: implement max conn limiter dispatching"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 后端接口 - 用户和规则的 CRUD 支持
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/admin_user.go`
|
||||
- Modify: `go-backend/internal/http/handler/forward.go`
|
||||
|
||||
[x] **Step 1: 用户接口更新**
|
||||
|
||||
在 `go-backend/internal/http/handler/admin_user.go`,修改用户创建和更新请求的结构体(如果有),接收 `MaxConn`,并在保存到数据库时赋值。
|
||||
|
||||
```go
|
||||
type CreateUserReq struct {
|
||||
// ...
|
||||
MaxConn *int `json:"maxConn"`
|
||||
}
|
||||
// 接收后:
|
||||
if req.MaxConn != nil {
|
||||
user.MaxConn = *req.MaxConn
|
||||
}
|
||||
```
|
||||
|
||||
在获取用户列表时,确保 `MaxConn` 返回给前端。
|
||||
|
||||
[x] **Step 2: 规则接口更新**
|
||||
|
||||
在 `go-backend/internal/http/handler/forward.go` 中,更新 `CreateForwardReq` 和 `UpdateForwardReq` 结构体,增加 `MaxConn`,并在创建/更新 Forward 时保存到数据库。
|
||||
|
||||
如果转发规则的 `MaxConn` 或相关信息改变,触发节点上的规则重载(重新下发服务)。这一步由于更改了数据库,复用现有的 `syncForwardServices` 就会带上最新的配置。
|
||||
|
||||
[x] **Step 3: 测试接口**
|
||||
|
||||
Run: 可以启动后使用 curl 测试。
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd go-backend
|
||||
git add internal/http/handler/admin_user.go internal/http/handler/forward.go
|
||||
git commit -m "feat: add maxConn to user and forward CRUD API"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 - 用户管理页面集成
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/types.ts`
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
- Modify: `vite-frontend/src/pages/users.tsx` (或者对应的用户管理页面文件)
|
||||
|
||||
[x] **Step 1: 类型更新**
|
||||
|
||||
在 `vite-frontend/src/api/types.ts` 中:
|
||||
为 `UserApiItem` 和相关的 mutation payload 增加 `maxConn?: number` 属性。
|
||||
|
||||
[x] **Step 2: UI 修改**
|
||||
|
||||
在用户创建/编辑弹窗中,增加“最大连接数”输入框:
|
||||
(假设使用 `@nextui-org/react` 的 `Input`)
|
||||
|
||||
```tsx
|
||||
<Input
|
||||
type="number"
|
||||
label="最大并发连接数"
|
||||
placeholder="0 或空表示不限制"
|
||||
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
|
||||
onValueChange={(val) => {
|
||||
const num = parseInt(val, 10);
|
||||
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
|
||||
}}
|
||||
/>
|
||||
```
|
||||
并在用户的表格列中展示 `最大连接数`(值为 0 显示“不限制”)。
|
||||
|
||||
[x] **Step 3: 运行 Vite 进行验证**
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd vite-frontend
|
||||
git add src/api/types.ts src/api/index.ts src/pages/users.tsx
|
||||
git commit -m "feat: add max conn UI to user management"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 - 转发规则页面集成
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
[x] **Step 1: 类型更新**
|
||||
|
||||
在 `api/types.ts` 中 `ForwardMutationPayload` 和 `ForwardApiItem` 中增加 `maxConn?: number`。
|
||||
|
||||
[x] **Step 2: UI 修改**
|
||||
|
||||
在 `vite-frontend/src/pages/forward.tsx` 的创建/编辑规则弹窗(在 "规则限速" 附近)增加“最大连接数”输入框:
|
||||
|
||||
```tsx
|
||||
<Input
|
||||
type="number"
|
||||
label="最大并发连接数"
|
||||
placeholder="0 或空表示不限制"
|
||||
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
|
||||
onValueChange={(val) => {
|
||||
const num = parseInt(val, 10);
|
||||
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
|
||||
}}
|
||||
description="此设置优先于用户的全局连接数限制。0 表示不限制(或使用用户的全局限制)。"
|
||||
/>
|
||||
```
|
||||
|
||||
如果是在列表/卡片中展示,可以增加一个小标签或者 Tooltip 显示其最大连接数设置。
|
||||
|
||||
[x] **Step 3: 验证**
|
||||
|
||||
在前端验证该功能能正确读写规则的连接限制字段。
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd vite-frontend
|
||||
git add src/pages/forward.tsx src/api/types.ts
|
||||
git commit -m "feat: add max conn UI to forward rules"
|
||||
cd ..
|
||||
```
|
||||
@@ -0,0 +1,171 @@
|
||||
# Allow Local Remote Address 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 global settings toggle that allows non-admin forward rules to target local/private addresses when explicitly enabled.
|
||||
|
||||
**Architecture:** Keep the existing remote-address safety validator as the default path for non-admin rule changes, but gate its use behind a single backend config lookup in forward create/update handlers. Surface the toggle through the existing `vite_config` settings page and prove behavior with backend contract tests first.
|
||||
|
||||
**Tech Stack:** Go `net/http` + GORM backend, React + TypeScript frontend settings page, Go contract tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Contract Coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/tests/contract/forward_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add contract tests that prove the desired behavior:
|
||||
|
||||
```go
|
||||
t.Run("local remote address is rejected when toggle is off", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "deny-local-remote",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8080",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, _ := json.Marshal(createPayload)
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
|
||||
var out response.R
|
||||
_ = json.NewDecoder(createRes.Body).Decode(&out)
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected local remote address to be rejected when toggle is off")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local remote address is allowed when toggle is on", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "allow_local_remote_addr", "1", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("enable allow_local_remote_addr: %v", err)
|
||||
}
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "allow-local-remote",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8080",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, _ := json.Marshal(createPayload)
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'`
|
||||
Expected: FAIL because backend still rejects local/private addresses unconditionally.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
Do not commit yet; combine with Task 2 after implementation passes.
|
||||
|
||||
### Task 2: Backend Toggle Implementation
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/mutations.go`
|
||||
|
||||
- [ ] **Step 1: Add a tiny config helper**
|
||||
|
||||
Add a helper near other handler helpers:
|
||||
|
||||
```go
|
||||
func (h *Handler) allowLocalRemoteAddr() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return false
|
||||
}
|
||||
cfg, err := h.repo.GetConfigByName("allow_local_remote_addr")
|
||||
if err != nil || cfg == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(cfg.Value) == "1"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Gate create/update validation behind the helper**
|
||||
|
||||
Replace the unconditional checks with:
|
||||
|
||||
```go
|
||||
if !h.allowLocalRemoteAddr() {
|
||||
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run contract tests to verify they pass**
|
||||
|
||||
Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Run full backend tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Settings Page Toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
- [ ] **Step 1: Add the config item to the settings schema**
|
||||
|
||||
Add a switch-style item for `allow_local_remote_addr` with warning copy about reduced safety.
|
||||
|
||||
- [ ] **Step 2: Ensure the key is included in config loading/saving paths**
|
||||
|
||||
Add `allow_local_remote_addr` anywhere the page enumerates config keys or groups persisted config values.
|
||||
|
||||
- [ ] **Step 3: Run frontend build**
|
||||
|
||||
Run: `pnpm run build`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Run frontend lint**
|
||||
|
||||
Run: `pnpm run lint`
|
||||
Expected: 0 errors; existing warnings may remain.
|
||||
|
||||
### Task 4: Final Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] **Step 1: Re-run backend contracts for the toggle**
|
||||
|
||||
Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Re-run full backend tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Re-run frontend build/lint**
|
||||
|
||||
Run: `pnpm run build && pnpm run lint`
|
||||
Expected: Build passes, lint has no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/mutations.go go-backend/tests/contract/forward_contract_test.go vite-frontend/src/pages/config.tsx docs/superpowers/specs/2026-04-26-allow-local-remote-addr-design.md docs/superpowers/plans/2026-04-26-allow-local-remote-addr.md
|
||||
git commit -m "feat: add allow-local-remote-address toggle"
|
||||
```
|
||||
@@ -0,0 +1,849 @@
|
||||
# flow/upload Batch Optimization 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:** Reduce `POST /flow/upload` database pressure by converting the hot path from per-item queries and per-item transactions to per-request aggregation, batched metadata reads, and batched writes, while preserving immediate quota disable / forward pause behavior inside the same upload.
|
||||
|
||||
**Architecture:** Parse one upload into a batch object in the handler layer, fetch one shared `forward+tunnel` metadata map, then reuse that map for flow accounting and tunnel metric aggregation. Replace `AddFlow` and `AddUserQuotaUsage` per-item transactions with one batched flow transaction and one batched quota transaction; run policy enforcement, orphan cleanup, and peer-share flow handling once per affected target instead of once per item.
|
||||
|
||||
**Tech Stack:** Go, net/http, GORM, SQLite/PostgreSQL, existing backend contract tests.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch.go`
|
||||
Responsibility: request-scoped parsing, aggregation, and application of one `/flow/upload` batch.
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch_test.go`
|
||||
Responsibility: unit coverage for batch aggregation semantics.
|
||||
- Create: `go-backend/internal/store/repo/repository_flow_batch_test.go`
|
||||
Responsibility: unit coverage for batched flow and quota persistence.
|
||||
- Create: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
Responsibility: contract coverage that repeated items still accumulate correctly and still disable quota immediately.
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
Responsibility: switch `/flow/upload` entrypoint to the new batch pipeline.
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_metrics_ingestion.go`
|
||||
Responsibility: accept pre-aggregated forward deltas plus shared forward metadata instead of reparsing the raw items.
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
Responsibility: add batched flow persistence primitives near the existing flow update code.
|
||||
- Modify: `go-backend/internal/store/repo/repository_flow.go`
|
||||
Responsibility: add shared flow-upload metadata query helpers.
|
||||
- Modify: `go-backend/internal/store/repo/repository_user_quota.go`
|
||||
Responsibility: add batched quota usage persistence that still returns normalized quota views for immediate enforcement.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Failing Tests For Batched flow/upload Semantics
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch_test.go`
|
||||
- Create: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing handler unit test**
|
||||
|
||||
Create `go-backend/internal/http/handler/flow_upload_batch_test.go` with a unit test that locks in the new aggregation contract.
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets(t *testing.T) {
|
||||
h := &Handler{}
|
||||
metas := map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {
|
||||
ForwardID: 20,
|
||||
TunnelID: 1,
|
||||
TrafficRatio: 2,
|
||||
TunnelFlow: 3,
|
||||
},
|
||||
}
|
||||
|
||||
batch := h.buildFlowUploadBatch([]flowItem{
|
||||
{N: "20_2_10", U: 70, D: 50},
|
||||
{N: "20_2_10_tcp", U: 40, D: 30},
|
||||
{N: "99_2_10", U: 12, D: 8},
|
||||
{N: "fed_svc_17", U: 9, D: 1},
|
||||
}, metas)
|
||||
|
||||
if len(batch.flowDeltas) != 1 {
|
||||
t.Fatalf("expected 1 flow delta, got %d", len(batch.flowDeltas))
|
||||
}
|
||||
delta := batch.flowDeltas[0]
|
||||
if delta.ForwardID != 20 || delta.UserID != 2 || delta.UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected flow delta identity: %#v", delta)
|
||||
}
|
||||
if delta.InFlow != 480 || delta.OutFlow != 660 {
|
||||
t.Fatalf("expected scaled flow in=480 out=660, got in=%d out=%d", delta.InFlow, delta.OutFlow)
|
||||
}
|
||||
if batch.quotaUsage[2] != 1140 {
|
||||
t.Fatalf("expected quota usage 1140, got %d", batch.quotaUsage[2])
|
||||
}
|
||||
if len(batch.policyTargets) != 1 {
|
||||
t.Fatalf("expected 1 policy target, got %d", len(batch.policyTargets))
|
||||
}
|
||||
if batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected policy target: %#v", batch.policyTargets[0])
|
||||
}
|
||||
traffic := batch.forwardTraffic[20]
|
||||
if traffic.bytesIn != 80 || traffic.bytesOut != 110 {
|
||||
t.Fatalf("expected raw traffic in=80 out=110, got in=%d out=%d", traffic.bytesIn, traffic.bytesOut)
|
||||
}
|
||||
if _, ok := batch.orphanServices["99_2_10"]; !ok {
|
||||
t.Fatalf("expected orphan service cleanup target for 99_2_10")
|
||||
}
|
||||
if item, ok := batch.peerShareForwardItems["20_2_10"]; !ok || item.U != 110 || item.D != 80 {
|
||||
t.Fatalf("expected merged peer-share forward item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
if item, ok := batch.peerShareRuntimeItems[17]; !ok || item.U != 9 || item.D != 1 {
|
||||
t.Fatalf("expected merged peer-share runtime item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the handler unit test to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets -v
|
||||
```
|
||||
|
||||
Expected: FAIL because `FlowUploadForwardMeta`, `buildFlowUploadBatch`, and the new batch fields do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write the contract test that guards current behavior**
|
||||
|
||||
Create `go-backend/tests/contract/flow_upload_batch_contract_test.go` so the optimization cannot weaken same-request quota enforcement.
|
||||
|
||||
```go
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately(t *testing.T) {
|
||||
secret := "monitoring-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
const bytesPerGB = int64(1024 * 1024 * 1024)
|
||||
|
||||
node := &model.Node{Name: "node-1", Secret: "node-secret", ServerIP: "127.0.0.1", Port: "10000-10010", TCPListenAddr: "[::]", UDPListenAddr: "[::]", CreatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'flow_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
tunnel := &model.Tunnel{Name: "tunnel-1", TrafficRatio: 1.0, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(tunnel).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnel.ID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
forward := &model.Forward{ID: 20, UserID: 2, UserName: "flow_user", Name: "forward-20", TunnelID: tunnel.ID, RemoteAddr: "1.1.1.1:80", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(forward).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) VALUES(2, 1, 0, ?, ?, ?, ?, 0, 0, '', ?, ?)`, bytesPerGB-100, bytesPerGB-100, dayKey, monthKey, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user_quota: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal([]map[string]interface{}{
|
||||
{"n": "20_2_10", "u": 70, "d": 50},
|
||||
{"n": "20_2_10_tcp", "u": 40, "d": 30},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected forward paused immediately, got status=%d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`); got != 1 {
|
||||
t.Fatalf("expected quota disabled flag=1, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT in_flow FROM forward WHERE id = 20`); got != 80 {
|
||||
t.Fatalf("expected forward in_flow=80, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT out_flow FROM forward WHERE id = 20`); got != 110 {
|
||||
t.Fatalf("expected forward out_flow=110, got %d", got)
|
||||
}
|
||||
metrics, err := repo.GetTunnelMetrics(tunnel.ID, 0, nowMs+60_000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 || metrics[0].BytesIn != 80 || metrics[0].BytesOut != 110 {
|
||||
t.Fatalf("expected one aggregated metric row, got %#v", metrics)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the contract test to verify the same-request guard stays green or reveals an existing regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -v
|
||||
```
|
||||
|
||||
Expected: this test may already PASS before the refactor because it locks in existing external behavior. Keep it either way; it is the guardrail for the optimization.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/flow_upload_batch_test.go go-backend/tests/contract/flow_upload_batch_contract_test.go
|
||||
git commit -m "test: cover flow upload batch semantics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add Batched Repository Primitives
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/store/repo/repository_flow.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository_user_quota.go`
|
||||
- Create: `go-backend/internal/store/repo/repository_flow_batch_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing repository tests**
|
||||
|
||||
Create `go-backend/internal/store/repo/repository_flow_batch_test.go` with coverage for both the shared metadata query and the batched counter/quota writes.
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "flow-batch.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 't1', 2.0, 1, 'tls', 3, ?, ?, 1, NULL, 0)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)`).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := r.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(20, 2, 'u2', 'f20', 1, '1.1.1.1:80', 'fifo', 0, 0, ?, ?, 1, 0)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
metas, err := r.GetFlowUploadForwardMetas([]int64{20, 99})
|
||||
if err != nil {
|
||||
t.Fatalf("get metas: %v", err)
|
||||
}
|
||||
if metas[20].TunnelID != 1 || metas[20].TrafficRatio != 2 || metas[20].TunnelFlow != 3 {
|
||||
t.Fatalf("unexpected meta for forward 20: %#v", metas[20])
|
||||
}
|
||||
if _, ok := metas[99]; ok {
|
||||
t.Fatalf("did not expect meta for missing forward 99")
|
||||
}
|
||||
|
||||
err = r.ApplyFlowUploadDeltasBatch([]FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 480, OutFlow: 660}})
|
||||
if err != nil {
|
||||
t.Fatalf("apply flow batch: %v", err)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM forward WHERE id = 20`); got != 480 {
|
||||
t.Fatalf("expected forward in_flow=480, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT out_flow FROM user WHERE id = 2`); got != 660 {
|
||||
t.Fatalf("expected user out_flow=660, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM user_tunnel WHERE id = 10`); got != 480 {
|
||||
t.Fatalf("expected user_tunnel in_flow=480, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserQuotaUsageBatchReturnsNormalizedViews(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "quota-batch.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
views, err := r.AddUserQuotaUsageBatch(map[int64]int64{2: 1140}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("batch quota update: %v", err)
|
||||
}
|
||||
if views[2] == nil || views[2].DailyUsedBytes != 1140 || views[2].MonthlyUsedBytes != 1140 {
|
||||
t.Fatalf("unexpected quota view: %#v", views[2])
|
||||
}
|
||||
}
|
||||
|
||||
func mustFlowBatchCount(t *testing.T, r *Repository, query string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var value int64
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&value); err != nil {
|
||||
t.Fatalf("query %q failed: %v", query, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the repository tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/store/repo -run 'TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch|TestAddUserQuotaUsageBatchReturnsNormalizedViews' -v
|
||||
```
|
||||
|
||||
Expected: FAIL because `GetFlowUploadForwardMetas`, `ApplyFlowUploadDeltasBatch`, `FlowUploadCounterDelta`, and `AddUserQuotaUsageBatch` do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement shared flow-upload metadata and batched persistence**
|
||||
|
||||
Update `go-backend/internal/store/repo/repository_flow.go`, `repository.go`, and `repository_user_quota.go` with the following concrete APIs. Add `sort` to the `repository_user_quota.go` import list.
|
||||
|
||||
```go
|
||||
// repository_flow.go
|
||||
type FlowUploadForwardMeta struct {
|
||||
ForwardID int64
|
||||
TunnelID int64
|
||||
TrafficRatio float64
|
||||
TunnelFlow int64
|
||||
}
|
||||
|
||||
func (r *Repository) GetFlowUploadForwardMetas(forwardIDs []int64) (map[int64]FlowUploadForwardMeta, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(forwardIDs) == 0 {
|
||||
return map[int64]FlowUploadForwardMeta{}, nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
type row struct {
|
||||
ForwardID int64 `gorm:"column:forward_id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id"`
|
||||
TrafficRatio float64 `gorm:"column:traffic_ratio"`
|
||||
TunnelFlow int64 `gorm:"column:tunnel_flow"`
|
||||
}
|
||||
var rows []row
|
||||
err := r.db.Table("forward AS f").
|
||||
Select("f.id AS forward_id, f.tunnel_id AS tunnel_id, t.traffic_ratio AS traffic_ratio, t.flow AS tunnel_flow").
|
||||
Joins("JOIN tunnel t ON t.id = f.tunnel_id").
|
||||
Where("f.id IN ?", ids).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]FlowUploadForwardMeta, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.TunnelFlow <= 0 {
|
||||
row.TunnelFlow = 1
|
||||
}
|
||||
if row.TrafficRatio <= 0 {
|
||||
row.TrafficRatio = 1
|
||||
}
|
||||
out[row.ForwardID] = FlowUploadForwardMeta{ForwardID: row.ForwardID, TunnelID: row.TunnelID, TrafficRatio: row.TrafficRatio, TunnelFlow: row.TunnelFlow}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// repository.go
|
||||
type FlowUploadCounterDelta struct {
|
||||
ForwardID int64
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
}
|
||||
|
||||
func (r *Repository) ApplyFlowUploadDeltasBatch(deltas []FlowUploadCounterDelta) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
forwardTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTunnelTotals := make(map[int64][2]int64, len(deltas))
|
||||
for _, delta := range deltas {
|
||||
if delta.ForwardID > 0 {
|
||||
current := forwardTotals[delta.ForwardID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
forwardTotals[delta.ForwardID] = current
|
||||
}
|
||||
if delta.UserID > 0 {
|
||||
current := userTotals[delta.UserID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
userTotals[delta.UserID] = current
|
||||
}
|
||||
if delta.UserTunnelID > 0 {
|
||||
current := userTunnelTotals[delta.UserTunnelID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
userTunnelTotals[delta.UserTunnelID] = current
|
||||
}
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for forwardID, total := range forwardTotals {
|
||||
if err := tx.Model(&model.Forward{}).Where("id = ?", forwardID).UpdateColumns(map[string]interface{}{"in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1])}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for userID, total := range userTotals {
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumns(map[string]interface{}{"in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1])}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for userTunnelID, total := range userTunnelTotals {
|
||||
if err := tx.Model(&model.UserTunnel{}).Where("id = ?", userTunnelID).UpdateColumns(map[string]interface{}{"in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1])}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// repository_user_quota.go
|
||||
func (r *Repository) AddUserQuotaUsageBatch(usages map[int64]int64, now time.Time) (map[int64]*model.UserQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(usages) == 0 {
|
||||
return map[int64]*model.UserQuotaView{}, nil
|
||||
}
|
||||
result := make(map[int64]*model.UserQuotaView, len(usages))
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
userIDs := make([]int64, 0, len(usages))
|
||||
for userID := range usages {
|
||||
if userID > 0 {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
for _, userID := range userIDs {
|
||||
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUserQuotaWindowRoll(q, now)
|
||||
if usages[userID] > 0 {
|
||||
q.DailyUsedBytes += usages[userID]
|
||||
q.MonthlyUsedBytes += usages[userID]
|
||||
}
|
||||
q.UpdatedTime = now.UnixMilli()
|
||||
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"daily_used_bytes": q.DailyUsedBytes, "monthly_used_bytes": q.MonthlyUsedBytes, "day_key": q.DayKey, "month_key": q.MonthKey, "updated_time": q.UpdatedTime}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result[userID] = normalizeUserQuotaView(cloneUserQuotaView(*q), now)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the repository tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/store/repo -run 'TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch|TestAddUserQuotaUsageBatchReturnsNormalizedViews' -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/store/repo/repository.go go-backend/internal/store/repo/repository_flow.go go-backend/internal/store/repo/repository_user_quota.go go-backend/internal/store/repo/repository_flow_batch_test.go
|
||||
git commit -m "refactor: batch flow upload persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Refactor flow/upload To Use One Parsed Batch
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_metrics_ingestion.go`
|
||||
- Modify: `go-backend/internal/http/handler/flow_upload_batch_test.go`
|
||||
- Modify: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the new handler batch implementation**
|
||||
|
||||
Create `go-backend/internal/http/handler/flow_upload_batch.go` and move the request-scoped aggregation there.
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type flowPolicyTarget struct {
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
}
|
||||
|
||||
type flowUploadBatch struct {
|
||||
flowDeltas []repo.FlowUploadCounterDelta
|
||||
quotaUsage map[int64]int64
|
||||
policyTargets []flowPolicyTarget
|
||||
forwardTraffic map[int64]tunnelTrafficDelta
|
||||
orphanServices map[string]struct{}
|
||||
peerShareForwardItems map[string]flowItem
|
||||
peerShareRuntimeItems map[int64]flowItem
|
||||
}
|
||||
|
||||
func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
|
||||
batch := flowUploadBatch{
|
||||
quotaUsage: make(map[int64]int64),
|
||||
forwardTraffic: make(map[int64]tunnelTrafficDelta),
|
||||
orphanServices: make(map[string]struct{}),
|
||||
peerShareForwardItems: make(map[string]flowItem),
|
||||
peerShareRuntimeItems: make(map[int64]flowItem),
|
||||
}
|
||||
policySeen := map[flowPolicyTarget]struct{}{}
|
||||
flowSeen := map[int64]int{}
|
||||
|
||||
for _, item := range items {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
continue
|
||||
}
|
||||
if runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName); ok {
|
||||
merged := batch.peerShareRuntimeItems[runtimeID]
|
||||
merged.N = serviceName
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareRuntimeItems[runtimeID] = merged
|
||||
continue
|
||||
}
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta, exists := metas[forwardID]
|
||||
if !exists {
|
||||
batch.orphanServices[serviceName] = struct{}{}
|
||||
continue
|
||||
}
|
||||
raw := batch.forwardTraffic[forwardID]
|
||||
raw.bytesIn += item.D
|
||||
raw.bytesOut += item.U
|
||||
batch.forwardTraffic[forwardID] = raw
|
||||
|
||||
scaledIn := int64(float64(item.D)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
scaledOut := int64(float64(item.U)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
if idx, ok := flowSeen[forwardID]; ok {
|
||||
batch.flowDeltas[idx].InFlow += scaledIn
|
||||
batch.flowDeltas[idx].OutFlow += scaledOut
|
||||
} else {
|
||||
flowSeen[forwardID] = len(batch.flowDeltas)
|
||||
batch.flowDeltas = append(batch.flowDeltas, repo.FlowUploadCounterDelta{ForwardID: forwardID, UserID: userID, UserTunnelID: userTunnelID, InFlow: scaledIn, OutFlow: scaledOut})
|
||||
}
|
||||
batch.quotaUsage[userID] += scaledIn + scaledOut
|
||||
target := flowPolicyTarget{UserID: userID, UserTunnelID: userTunnelID}
|
||||
if _, seen := policySeen[target]; !seen {
|
||||
policySeen[target] = struct{}{}
|
||||
batch.policyTargets = append(batch.policyTargets, target)
|
||||
}
|
||||
merged := batch.peerShareForwardItems[normalizeForwardRuntimeServiceName(serviceName)]
|
||||
merged.N = normalizeForwardRuntimeServiceName(serviceName)
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareForwardItems[normalizeForwardRuntimeServiceName(serviceName)] = merged
|
||||
}
|
||||
|
||||
sort.Slice(batch.policyTargets, func(i, j int) bool {
|
||||
if batch.policyTargets[i].UserID == batch.policyTargets[j].UserID {
|
||||
return batch.policyTargets[i].UserTunnelID < batch.policyTargets[j].UserTunnelID
|
||||
}
|
||||
return batch.policyTargets[i].UserID < batch.policyTargets[j].UserID
|
||||
})
|
||||
return batch
|
||||
}
|
||||
|
||||
func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
if err := h.repo.ApplyFlowUploadDeltasBatch(batch.flowDeltas); err != nil {
|
||||
log.Printf("flow upload write failed op=flow.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
quotaViews, err := h.repo.AddUserQuotaUsageBatch(batch.quotaUsage, now)
|
||||
if err != nil {
|
||||
log.Printf("flow upload write failed op=quota.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
for userID, quota := range quotaViews {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
for _, target := range batch.policyTargets {
|
||||
if target.UserID <= 0 || target.UserTunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
|
||||
}
|
||||
for serviceName := range batch.orphanServices {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
}
|
||||
for serviceName, item := range batch.peerShareForwardItems {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
|
||||
if ok {
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
}
|
||||
}
|
||||
for runtimeID, item := range batch.peerShareRuntimeItems {
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Switch the `/flow/upload` entrypoint and tunnel metric ingestion to the shared batch**
|
||||
|
||||
Modify `handler.go` and `tunnel_metrics_ingestion.go` so the raw JSON is parsed once and the same forward metadata powers both flow counters and tunnel metrics.
|
||||
|
||||
```go
|
||||
// handler.go
|
||||
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.URL.Query().Get("secret")
|
||||
node, _ := h.repo.GetNodeBySecret(secret)
|
||||
if node == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := readAndDecryptFlowBody(r.Body, secret)
|
||||
if err == nil && strings.TrimSpace(raw) != "" {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
now := time.Now()
|
||||
forwardIDs := collectFlowUploadForwardIDs(items)
|
||||
metas, metaErr := h.repo.GetFlowUploadForwardMetas(forwardIDs)
|
||||
if metaErr != nil {
|
||||
log.Printf("flow upload metadata lookup failed node_id=%d err=%v", node.ID, metaErr)
|
||||
metas = map[int64]repo.FlowUploadForwardMeta{}
|
||||
}
|
||||
batch := h.buildFlowUploadBatch(items, metas)
|
||||
h.recordTunnelMetricsFromForwardBatch(node.ID, batch.forwardTraffic, metas, now.UnixMilli())
|
||||
h.applyFlowUploadBatch(node.ID, batch, now)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// tunnel_metrics_ingestion.go
|
||||
func collectFlowUploadForwardIDs(items []flowItem) []int64 {
|
||||
ids := make([]int64, 0, len(items))
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(strings.TrimSpace(item.N))
|
||||
if !ok || forwardID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[forwardID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[forwardID] = struct{}{}
|
||||
ids = append(ids, forwardID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *Handler) recordTunnelMetricsFromForwardBatch(nodeID int64, forwardDeltas map[int64]tunnelTrafficDelta, metas map[int64]repo.FlowUploadForwardMeta, nowMs int64) {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 || len(forwardDeltas) == 0 {
|
||||
return
|
||||
}
|
||||
bucketTs := unixMilliBucketMinute(nowMs)
|
||||
if bucketTs <= 0 {
|
||||
return
|
||||
}
|
||||
tunnelAgg := make(map[int64]tunnelTrafficDelta)
|
||||
for forwardID, delta := range forwardDeltas {
|
||||
meta, ok := metas[forwardID]
|
||||
if !ok || meta.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
current := tunnelAgg[meta.TunnelID]
|
||||
current.bytesIn += delta.bytesIn
|
||||
current.bytesOut += delta.bytesOut
|
||||
tunnelAgg[meta.TunnelID] = current
|
||||
}
|
||||
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})
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run focused handler and contract tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets -v
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the full backend suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS across the backend module.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/tunnel_metrics_ingestion.go go-backend/internal/http/handler/flow_upload_batch.go go-backend/internal/http/handler/flow_upload_batch_test.go go-backend/tests/contract/flow_upload_batch_contract_test.go
|
||||
git commit -m "refactor: batch flow upload processing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final Verification And Performance Sanity Check
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Add a same-batch duplicate-item stress assertion**
|
||||
|
||||
Extend the contract test with a second request that repeats the same service name multiple times and assert the counters advance by exactly the summed amount.
|
||||
|
||||
```go
|
||||
body, err = json.Marshal([]map[string]interface{}{
|
||||
{"n": "20_2_10", "u": 10, "d": 20},
|
||||
{"n": "20_2_10", "u": 10, "d": 20},
|
||||
{"n": "20_2_10_tcp", "u": 10, "d": 20},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
|
||||
res = httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if got := mustQueryInt(t, repo, `SELECT in_flow FROM forward WHERE id = 20`); got != 140 {
|
||||
t.Fatalf("expected forward in_flow=140 after second request, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT out_flow FROM forward WHERE id = 20`); got != 140 {
|
||||
t.Fatalf("expected forward out_flow=140 after second request, got %d", got)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the targeted contract test again**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Re-run the full backend suite before claiming completion**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Optional local profiling sanity check**
|
||||
|
||||
Run a short local comparison before and after the change with the same repeated flow payload.
|
||||
|
||||
```bash
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -count=10
|
||||
```
|
||||
|
||||
Expected: the test remains stable across repeated runs and does not introduce flakiness.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/tests/contract/flow_upload_batch_contract_test.go
|
||||
git commit -m "test: harden flow upload batch regression coverage"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
# Monitoring Retention And Storage Display 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 configurable monitoring data retention and show database storage usage on the config page.
|
||||
|
||||
**Architecture:** Store retention in `vite_config` as `monitor_retention_days`, parse it through a focused monitoring helper, and reuse it from existing cleanup loops. Add a repository storage-summary helper, expose it via an admin-only API, and render it in the existing React config page.
|
||||
|
||||
**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL, Vite/React/TypeScript, existing shadcn bridge components.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `go-backend/internal/monitoring/retention.go` for retention constants, parsing, and validation.
|
||||
- Test: `go-backend/internal/monitoring/retention_test.go`.
|
||||
- Modify: `go-backend/internal/metrics/ingestion.go` and `go-backend/internal/metrics/ingestion_test.go` for config-driven cleanup.
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go` so `tunnel_quality` uses the same retention and still prunes when probing is disabled.
|
||||
- Create: `go-backend/internal/store/repo/repository_storage.go` and `go-backend/internal/store/repo/repository_storage_test.go` for database size summaries.
|
||||
- Modify: `go-backend/internal/store/repo/repository.go` to keep the SQLite DB path on `Repository`.
|
||||
- Create: `go-backend/internal/http/handler/storage.go` for the storage endpoint.
|
||||
- Modify: `go-backend/internal/http/handler/handler.go` to register `/api/v1/system/storage` and validate `monitor_retention_days`.
|
||||
- Modify: `go-backend/internal/http/middleware/auth.go` so `/api/v1/system/*` is admin-only.
|
||||
- Create: `go-backend/tests/contract/storage_contract_test.go` for endpoint auth/shape coverage.
|
||||
- Modify: `vite-frontend/src/api/types.ts`, `vite-frontend/src/api/index.ts`, and `vite-frontend/src/pages/config.tsx` for UI display.
|
||||
|
||||
Implementation should not create git commits unless the user explicitly requests them.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Retention Config Helper
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/monitoring/retention.go`
|
||||
- Create: `go-backend/internal/monitoring/retention_test.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `go-backend/internal/monitoring/retention_test.go`:
|
||||
|
||||
```go
|
||||
package monitoring
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMonitoringRetentionDaysFromConfigMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
want int
|
||||
}{
|
||||
{"missing uses default", nil, 7},
|
||||
{"valid custom", map[string]string{ConfigMonitorRetentionDays: "3"}, 3},
|
||||
{"trimmed custom", map[string]string{ConfigMonitorRetentionDays: " 30 "}, 30},
|
||||
{"invalid uses default", map[string]string{ConfigMonitorRetentionDays: "abc"}, 7},
|
||||
{"too small uses default", map[string]string{ConfigMonitorRetentionDays: "0"}, 7},
|
||||
{"too large uses default", map[string]string{ConfigMonitorRetentionDays: "3651"}, 7},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := MonitoringRetentionDaysFromConfigMap(tc.cfg); got != tc.want {
|
||||
t.Fatalf("expected %d, got %d", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMonitoringRetentionDays(t *testing.T) {
|
||||
for _, value := range []string{"1", "7", "3650", " 30 "} {
|
||||
if got, err := NormalizeMonitoringRetentionDays(value); err != nil || got == "" {
|
||||
t.Fatalf("expected %q valid, got value=%q err=%v", value, got, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "0", "-1", "3651", "abc", "1.5"} {
|
||||
if got, err := NormalizeMonitoringRetentionDays(value); err == nil {
|
||||
t.Fatalf("expected %q invalid, got value=%q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/monitoring -run 'TestMonitoringRetentionDaysFromConfigMap|TestNormalizeMonitoringRetentionDays' -count=1`
|
||||
|
||||
Expected: FAIL with undefined `ConfigMonitorRetentionDays`, `MonitoringRetentionDaysFromConfigMap`, and `NormalizeMonitoringRetentionDays`.
|
||||
|
||||
- [ ] **Step 3: Implement helper**
|
||||
|
||||
Create `go-backend/internal/monitoring/retention.go`:
|
||||
|
||||
```go
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigMonitorRetentionDays = "monitor_retention_days"
|
||||
DefaultMonitorRetentionDays = 7
|
||||
MinMonitorRetentionDays = 1
|
||||
MaxMonitorRetentionDays = 3650
|
||||
)
|
||||
|
||||
func MonitoringRetentionDaysFromConfigMap(cfg map[string]string) int {
|
||||
if cfg == nil {
|
||||
return DefaultMonitorRetentionDays
|
||||
}
|
||||
days, err := parseMonitoringRetentionDays(cfg[ConfigMonitorRetentionDays])
|
||||
if err != nil {
|
||||
return DefaultMonitorRetentionDays
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func NormalizeMonitoringRetentionDays(value string) (string, error) {
|
||||
days, err := parseMonitoringRetentionDays(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(days), nil
|
||||
}
|
||||
|
||||
func parseMonitoringRetentionDays(value string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, fmt.Errorf("监控数据保留天数不能为空")
|
||||
}
|
||||
days, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("监控数据保留天数必须是整数")
|
||||
}
|
||||
if days < MinMonitorRetentionDays || days > MaxMonitorRetentionDays {
|
||||
return 0, fmt.Errorf("监控数据保留天数必须在 %d 到 %d 之间", MinMonitorRetentionDays, MaxMonitorRetentionDays)
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate config updates**
|
||||
|
||||
In `go-backend/internal/http/handler/handler.go`, add this case to `normalizeAndValidateConfigValue`:
|
||||
|
||||
```go
|
||||
case monitoring.ConfigMonitorRetentionDays:
|
||||
return monitoring.NormalizeMonitoringRetentionDays(value)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `go test ./internal/monitoring ./internal/http/handler -run 'TestMonitoringRetention|TestNormalize|Test' -count=1`
|
||||
|
||||
Expected: PASS or only unrelated pre-existing failures, which must be investigated before continuing.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Use Retention Config In Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/metrics/ingestion.go`
|
||||
- Modify: `go-backend/internal/metrics/ingestion_test.go`
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go`
|
||||
|
||||
- [ ] **Step 1: Write failing cleanup test**
|
||||
|
||||
Append to `go-backend/internal/metrics/ingestion_test.go`, adding `go-backend/internal/store/model` to imports:
|
||||
|
||||
```go
|
||||
func TestPruneMetricsUsesConfiguredRetentionDays(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.UpsertConfig("monitor_retention_days", "2", now); err != nil {
|
||||
t.Fatalf("upsert retention config: %v", err)
|
||||
}
|
||||
|
||||
oldMetric := &model.NodeMetric{NodeID: 1, Timestamp: now - int64(3*24*time.Hour/time.Millisecond), CPUUsage: 10}
|
||||
newMetric := &model.NodeMetric{NodeID: 1, Timestamp: now - int64(1*24*time.Hour/time.Millisecond), CPUUsage: 20}
|
||||
if err := r.InsertNodeMetric(oldMetric); err != nil {
|
||||
t.Fatalf("insert old metric: %v", err)
|
||||
}
|
||||
if err := r.InsertNodeMetric(newMetric); err != nil {
|
||||
t.Fatalf("insert new metric: %v", err)
|
||||
}
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
svc.pruneMetricsAt(time.UnixMilli(now))
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, now-int64(4*24*time.Hour/time.Millisecond), now+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get node metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 || metrics[0].CPUUsage != 20 {
|
||||
t.Fatalf("expected only newer metric to remain, got %#v", metrics)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `go test ./internal/metrics -run TestPruneMetricsUsesConfiguredRetentionDays -count=1`
|
||||
|
||||
Expected: FAIL with undefined `pruneMetricsAt`.
|
||||
|
||||
- [ ] **Step 3: Implement config-driven prune**
|
||||
|
||||
In `go-backend/internal/metrics/ingestion.go`, import `go-backend/internal/monitoring` and replace `pruneMetrics` with:
|
||||
|
||||
```go
|
||||
func (s *IngestionService) retentionDaysFromConfig() int {
|
||||
if s == nil || s.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := s.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetrics() {
|
||||
s.pruneMetricsAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetricsAt(now time.Time) {
|
||||
cutoff := now.Add(-time.Duration(s.retentionDaysFromConfig()) * 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the unused `retentionDays` field from `IngestionService` and remove `svc.retentionDays = 1` from existing tests.
|
||||
|
||||
- [ ] **Step 4: Update tunnel quality pruning**
|
||||
|
||||
In `go-backend/internal/http/handler/tunnel_quality_prober.go`, import `go-backend/internal/monitoring`, remove `tunnelQualityRetention`, remove the `if !p.isEnabled() { return }` guard from `maybePrune`, and calculate cutoff with:
|
||||
|
||||
```go
|
||||
func (p *tunnelQualityProber) retentionDays() int {
|
||||
if p == nil || p.handler == nil || p.handler.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := p.handler.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
```
|
||||
|
||||
Then use:
|
||||
|
||||
```go
|
||||
cutoff := now - int64(time.Duration(p.retentionDays())*24*time.Hour/time.Millisecond)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run cleanup tests**
|
||||
|
||||
Run: `go test ./internal/metrics ./internal/http/handler -run 'TestPruneMetrics|TestPruneMetricsUsesConfiguredRetentionDays|TunnelQuality' -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add Storage Summary Backend API
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
- Create: `go-backend/internal/store/repo/repository_storage.go`
|
||||
- Create: `go-backend/internal/store/repo/repository_storage_test.go`
|
||||
- Create: `go-backend/internal/http/handler/storage.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
- Modify: `go-backend/internal/http/middleware/auth.go`
|
||||
- Create: `go-backend/tests/contract/storage_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing repository tests**
|
||||
|
||||
Create `go-backend/internal/store/repo/repository_storage_test.go`:
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestDatabaseStorageSummarySQLiteIncludesSize(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "storage.db")
|
||||
r, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := r.InsertNodeMetric(&model.NodeMetric{NodeID: 1, Timestamp: 123, CPUUsage: 1}); err != nil {
|
||||
t.Fatalf("insert metric: %v", err)
|
||||
}
|
||||
summary, err := r.DatabaseStorageSummary()
|
||||
if err != nil {
|
||||
t.Fatalf("storage summary: %v", err)
|
||||
}
|
||||
if summary.DBType != "sqlite" || summary.DatabaseSizeBytes <= 0 || summary.DatabaseSizeText == "" {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDatabaseSize(t *testing.T) {
|
||||
for _, tc := range []struct{ bytes int64; want string }{{0, "0 B"}, {512, "512 B"}, {1024, "1.0 KB"}, {1024 * 1024, "1.0 MB"}} {
|
||||
if got := formatDatabaseSize(tc.bytes); got != tc.want {
|
||||
t.Fatalf("formatDatabaseSize(%d)=%q want %q", tc.bytes, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `go test ./internal/store/repo -run 'TestDatabaseStorageSummarySQLiteIncludesSize|TestFormatDatabaseSize' -count=1`
|
||||
|
||||
Expected: FAIL with undefined `DatabaseStorageSummary` and `formatDatabaseSize`.
|
||||
|
||||
- [ ] **Step 3: Implement repository helper**
|
||||
|
||||
Modify `Repository` in `repository.go`:
|
||||
|
||||
```go
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
dbPath string
|
||||
}
|
||||
```
|
||||
|
||||
Return `&Repository{db: db, dbPath: path}` from `Open` and `&Repository{db: db}` from `OpenPostgres`.
|
||||
|
||||
Create `go-backend/internal/store/repo/repository_storage.go`:
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type DatabaseStorageSummary struct {
|
||||
DBType string `json:"dbType"`
|
||||
DatabaseSizeBytes int64 `json:"databaseSizeBytes"`
|
||||
DatabaseSizeText string `json:"databaseSizeText"`
|
||||
}
|
||||
|
||||
func (r *Repository) DatabaseStorageSummary() (DatabaseStorageSummary, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return DatabaseStorageSummary{}, errors.New("repository not initialized")
|
||||
}
|
||||
switch r.db.Dialector.Name() {
|
||||
case "sqlite":
|
||||
size, err := sqliteDatabaseFileSize(r.dbPath)
|
||||
if err != nil { return DatabaseStorageSummary{}, err }
|
||||
return DatabaseStorageSummary{"sqlite", size, formatDatabaseSize(size)}, nil
|
||||
case "postgres":
|
||||
var size int64
|
||||
if err := r.db.Raw("SELECT pg_database_size(current_database())").Scan(&size).Error; err != nil { return DatabaseStorageSummary{}, err }
|
||||
return DatabaseStorageSummary{"postgres", size, formatDatabaseSize(size)}, nil
|
||||
default:
|
||||
return DatabaseStorageSummary{}, fmt.Errorf("unsupported database dialect %q", r.db.Dialector.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteDatabaseFileSize(path string) (int64, error) {
|
||||
if path == "" || path == ":memory:" { return 0, nil }
|
||||
var total int64
|
||||
for _, candidate := range []string{path, path + "-wal", path + "-shm"} {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) { continue }
|
||||
return 0, err
|
||||
}
|
||||
if !info.IsDir() { total += info.Size() }
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func formatDatabaseSize(bytes int64) string {
|
||||
if bytes < 1024 { return fmt.Sprintf("%d B", bytes) }
|
||||
units := []string{"KB", "MB", "GB", "TB"}
|
||||
value := float64(bytes) / 1024
|
||||
for _, unit := range units {
|
||||
if value < 1024 || unit == "TB" { return fmt.Sprintf("%.1f %s", value, unit) }
|
||||
value /= 1024
|
||||
}
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add API handler and route**
|
||||
|
||||
Create `go-backend/internal/http/handler/storage.go`:
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func (h *Handler) storageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if h == nil || h.repo == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "repository not initialized"))
|
||||
return
|
||||
}
|
||||
summary, err := h.repo.DatabaseStorageSummary()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(summary))
|
||||
}
|
||||
```
|
||||
|
||||
Register in `Handler.Register`: `mux.HandleFunc("/api/v1/system/storage", h.storageSummary)`.
|
||||
|
||||
In `requiresAdmin`, add:
|
||||
|
||||
```go
|
||||
if strings.HasPrefix(path, "/api/v1/system/") {
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write contract test for auth and shape**
|
||||
|
||||
Create `go-backend/tests/contract/storage_contract_test.go` with a test that sends GET `/api/v1/system/storage` as non-admin and expects `403`, then as admin and expects `code == 0`, `dbType`, numeric `databaseSizeBytes`, and `databaseSizeText`.
|
||||
|
||||
- [ ] **Step 6: Run storage tests**
|
||||
|
||||
Run: `go test ./internal/store/repo ./tests/contract -run 'TestDatabaseStorageSummarySQLiteIncludesSize|TestFormatDatabaseSize|TestStorageSummaryRequiresAdminAndReturnsSize' -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add Frontend Config UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/types.ts`
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
- [ ] **Step 1: Add API type and function**
|
||||
|
||||
In `types.ts` add:
|
||||
|
||||
```ts
|
||||
export interface StorageSummaryApiData {
|
||||
dbType: string;
|
||||
databaseSizeBytes: number;
|
||||
databaseSizeText: string;
|
||||
}
|
||||
```
|
||||
|
||||
In `index.ts`, import `StorageSummaryApiData` and add:
|
||||
|
||||
```ts
|
||||
export const getStorageSummary = () =>
|
||||
Network.get<StorageSummaryApiData>("/system/storage");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add retention config item**
|
||||
|
||||
In `config.tsx`, add to `CONFIG_ITEMS` near monitoring:
|
||||
|
||||
```ts
|
||||
{
|
||||
key: "monitor_retention_days",
|
||||
label: "监控数据保留天数",
|
||||
placeholder: "7",
|
||||
description:
|
||||
"统一清理节点指标、隧道流量、服务监控结果和隧道质量历史;默认 7 天。",
|
||||
type: "input",
|
||||
},
|
||||
```
|
||||
|
||||
Add `"monitor_retention_days"` to `getInitialConfigs()` keys.
|
||||
|
||||
- [ ] **Step 3: Fetch and display database size**
|
||||
|
||||
In `config.tsx`, add state:
|
||||
|
||||
```ts
|
||||
const [storageSummary, setStorageSummary] = useState<string>("加载中...");
|
||||
```
|
||||
|
||||
Add a load effect:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
getStorageSummary()
|
||||
.then((response) => {
|
||||
if (!mounted) return;
|
||||
if (response.code === 0 && response.data?.databaseSizeText) {
|
||||
setStorageSummary(response.data.databaseSizeText);
|
||||
} else {
|
||||
setStorageSummary("获取失败");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setStorageSummary("获取失败");
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
Render inside the basic settings card before the save button:
|
||||
|
||||
```tsx
|
||||
<Divider className="my-2" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
数据库占用
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
当前后端数据库文件/实例占用空间,仅用于容量参考。
|
||||
</p>
|
||||
<div className="rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/10 px-4 py-3 text-sm font-semibold text-default-800 dark:text-default-200">
|
||||
{storageSummary}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build frontend**
|
||||
|
||||
Run: `pnpm run build` from `vite-frontend`.
|
||||
|
||||
Expected: TypeScript and Vite build pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Final Verification
|
||||
|
||||
**Files:**
|
||||
- All files changed by previous tasks.
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run: `go test ./...` from `go-backend`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run: `pnpm run build` from `vite-frontend`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Review diff**
|
||||
|
||||
Run: `git diff --stat` and `git diff -- docs/superpowers/specs/2026-04-28-monitoring-retention-storage-design.md docs/superpowers/plans/2026-04-28-monitoring-retention-storage.md go-backend vite-frontend`.
|
||||
|
||||
Expected: Diff is limited to retention config, storage summary, tests, and config UI.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: retention config, uniform cleanup, storage summary API, frontend display, validation, and verification are covered.
|
||||
- Placeholder scan: no TBD/TODO placeholders; the one contract-test step describes exact assertions even though the surrounding helper functions already exist in contract tests.
|
||||
- Type consistency: backend JSON fields match frontend `StorageSummaryApiData` exactly: `dbType`, `databaseSizeBytes`, `databaseSizeText`.
|
||||
@@ -0,0 +1,892 @@
|
||||
# Best Exit Current Display 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:** Show the currently applied `best` exit selection in the tunnel list information, including per-entry/per-final-hop details for multi-owner tunnels.
|
||||
|
||||
**Architecture:** Add a backend-only display layer that snapshots `bestExitManager` state and attaches `bestExitState` to existing `tunnelList`/`tunnelGet` responses. Render that state in the existing tunnel table/grid topology area using compact text and a native `title` detail tooltip. No routing, scoring, persistence, polling, or runtime update behavior changes.
|
||||
|
||||
**Tech Stack:** Go `net/http` handlers + existing repository methods, React/TypeScript in `vite-frontend/src/pages/tunnel.tsx`, Tailwind/shadcn bridge components already in the file.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `go-backend/internal/http/handler/tunnel_best_exit_display.go`: response DTOs, manager snapshot method, tunnel-response parsing helpers, and `Handler.attachBestExitStates`.
|
||||
- Create `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`: backend display-state unit tests.
|
||||
- Modify `go-backend/internal/http/handler/handler.go`: call `h.attachBestExitStatesOrLog(items)` in `tunnelList`.
|
||||
- Modify `go-backend/internal/http/handler/mutations.go`: call `h.attachBestExitStatesOrLog(items)` before returning a single tunnel in `tunnelGet`.
|
||||
- Modify `vite-frontend/src/pages/tunnel.tsx`: add `bestExitState` types, map API state, helper render functions, and table/grid display.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Snapshot And Display-State Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing backend display tests**
|
||||
|
||||
Create `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`:
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBestExitDecisionSnapshotIsDefensiveCopy(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
score := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30, NodeName: "exit-a"}, 10, 0, 20, 0)
|
||||
|
||||
m.observeScores(key, []bestExitCandidateScore{score}, now)
|
||||
snapshot, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected snapshot")
|
||||
}
|
||||
if snapshot.AppliedExitNodeID != 30 || snapshot.UpdatedAt != now.UnixMilli() {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
if len(snapshot.Scores) != 1 {
|
||||
t.Fatalf("expected one score in snapshot, got %+v", snapshot.Scores)
|
||||
}
|
||||
snapshot.Scores[0].ExitNodeID = 99
|
||||
|
||||
again, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected second snapshot")
|
||||
}
|
||||
if again.Scores[0].ExitNodeID != 30 {
|
||||
t.Fatalf("snapshot score mutation leaked into manager state: %+v", again.Scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForDirectMultiEntryOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(100, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 11}, 31, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
{"nodeId": int64(11)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if !state.Enabled || state.Summary != "多个出口" || state.Status != "applied" {
|
||||
t.Fatalf("unexpected state summary: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt != now.Add(time.Second).UnixMilli() {
|
||||
t.Fatalf("expected latest updatedAt, got %d", state.UpdatedAt)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "entry" || state.Items[0].OwnerNodeName != "入口 A" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "entry" || state.Items[1].OwnerNodeName != "入口 B" || state.Items[1].ExitNodeName != "日本节点" {
|
||||
t.Fatalf("unexpected second item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForFinalChainHopOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(200, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 20}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 21}, 30, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(88),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": int64(15), "inx": int64(0)}},
|
||||
{{"nodeId": int64(20), "inx": int64(1)}, {"nodeId": int64(21), "inx": int64(1)}},
|
||||
},
|
||||
}
|
||||
names := map[int64]string{20: "中转 M1", 21: "中转 M2", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Summary != "香港节点" || state.Status != "applied" {
|
||||
t.Fatalf("expected single-exit summary, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two final-hop owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "chain" || state.Items[0].OwnerNodeName != "中转 M1" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first chain owner item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "chain" || state.Items[1].OwnerNodeName != "中转 M2" || state.Items[1].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected second chain owner item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateWaitingWhenNoAppliedDecisionExists(t *testing.T) {
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, newBestExitManager(), testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected waiting best exit state")
|
||||
}
|
||||
if state.Summary != "等待探测" || state.Status != "waiting" {
|
||||
t.Fatalf("expected waiting state, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 1 || state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != "等待探测" {
|
||||
t.Fatalf("unexpected waiting item: %+v", state.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateSkipsNonBestAndSingleExitTunnels(t *testing.T) {
|
||||
nonBest := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": "round"},
|
||||
{"nodeId": int64(31), "strategy": "round"},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(nonBest, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected non-best tunnel to skip state, got %+v", state)
|
||||
}
|
||||
|
||||
singleExit := map[string]interface{}{
|
||||
"id": int64(78),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(singleExit, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected single-exit tunnel to skip state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func testBestExitNameLookup(names map[int64]string) bestExitNodeNameLookup {
|
||||
return func(nodeID int64) (string, bool) {
|
||||
name := names[nodeID]
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run backend display tests to verify failure**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL with undefined `snapshot`, `buildBestExitDisplayState`, and `bestExitNodeNameLookup`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend Display State Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/tunnel_best_exit_display.go`
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_best_exit.go`
|
||||
- Test: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`
|
||||
|
||||
- [ ] **Step 1: Implement display state and snapshot helpers**
|
||||
|
||||
Create `go-backend/internal/http/handler/tunnel_best_exit_display.go`:
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
bestExitDisplayStatusApplied = "applied"
|
||||
bestExitDisplayStatusWaiting = "waiting"
|
||||
bestExitDisplaySummaryMulti = "多个出口"
|
||||
bestExitDisplaySummaryWait = "等待探测"
|
||||
bestExitUnknownExitName = "未知出口"
|
||||
bestExitUnknownEntryName = "未知入口"
|
||||
bestExitUnknownChainName = "未知中转"
|
||||
)
|
||||
|
||||
type bestExitDecisionSnapshot struct {
|
||||
AppliedExitNodeID int64
|
||||
UpdatedAt int64
|
||||
Reason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitDisplayState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Items []bestExitDisplayItem `json:"items"`
|
||||
}
|
||||
|
||||
type bestExitDisplayItem struct {
|
||||
OwnerNodeID int64 `json:"ownerNodeId"`
|
||||
OwnerNodeName string `json:"ownerNodeName"`
|
||||
OwnerRole string `json:"ownerRole"`
|
||||
ExitNodeID int64 `json:"exitNodeId,omitempty"`
|
||||
ExitNodeName string `json:"exitNodeName"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type bestExitNodeNameLookup func(nodeID int64) (string, bool)
|
||||
|
||||
func (m *bestExitManager) snapshot(key bestExitOwnerKey) (bestExitDecisionSnapshot, bool) {
|
||||
if m == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisions[key]
|
||||
if d == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
updatedAt := int64(0)
|
||||
if !d.LastSwitchAt.IsZero() {
|
||||
updatedAt = d.LastSwitchAt.UnixMilli()
|
||||
}
|
||||
return bestExitDecisionSnapshot{
|
||||
AppliedExitNodeID: d.AppliedExitNodeID,
|
||||
UpdatedAt: updatedAt,
|
||||
Reason: d.LastReason,
|
||||
Scores: cloneBestExitScores(d.Scores),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (h *Handler) attachBestExitStates(items []map[string]interface{}) {
|
||||
if h == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
lookup := h.bestExitNodeNameLookup()
|
||||
for _, item := range items {
|
||||
state, ok := buildBestExitDisplayState(item, h.bestExit, lookup)
|
||||
if !ok {
|
||||
delete(item, "bestExitState")
|
||||
continue
|
||||
}
|
||||
item["bestExitState"] = state
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) bestExitNodeNameLookup() bestExitNodeNameLookup {
|
||||
cache := map[int64]string{}
|
||||
return func(nodeID int64) (string, bool) {
|
||||
if nodeID <= 0 || h == nil {
|
||||
return "", false
|
||||
}
|
||||
if name, ok := cache[nodeID]; ok {
|
||||
return name, name != ""
|
||||
}
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil || node == nil {
|
||||
cache[nodeID] = ""
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(node.Name)
|
||||
cache[nodeID] = name
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildBestExitDisplayState(tunnel map[string]interface{}, manager *bestExitManager, lookup bestExitNodeNameLookup) (*bestExitDisplayState, bool) {
|
||||
if tunnel == nil {
|
||||
return nil, false
|
||||
}
|
||||
tunnelID := asInt64(tunnel["id"], 0)
|
||||
outNodes := bestExitDisplayMapSlice(tunnel["outNodeId"])
|
||||
if tunnelID <= 0 || len(outNodes) <= 1 {
|
||||
return nil, false
|
||||
}
|
||||
if !isBestTunnelStrategy(asString(outNodes[0]["strategy"])) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
owners, ownerRole := bestExitDisplayOwners(tunnel)
|
||||
state := &bestExitDisplayState{
|
||||
Enabled: true,
|
||||
Summary: bestExitDisplaySummaryWait,
|
||||
Status: bestExitDisplayStatusWaiting,
|
||||
Items: make([]bestExitDisplayItem, 0, len(owners)),
|
||||
}
|
||||
|
||||
exitsByID := map[int64]map[string]interface{}{}
|
||||
for _, exit := range outNodes {
|
||||
if id := asInt64(exit["nodeId"], 0); id > 0 {
|
||||
exitsByID[id] = exit
|
||||
}
|
||||
}
|
||||
appliedExitIDs := map[int64]string{}
|
||||
appliedCount := 0
|
||||
latestUpdatedAt := int64(0)
|
||||
latestReason := ""
|
||||
for _, owner := range owners {
|
||||
ownerNodeID := asInt64(owner["nodeId"], 0)
|
||||
if ownerNodeID <= 0 {
|
||||
continue
|
||||
}
|
||||
item := bestExitDisplayItem{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
OwnerNodeName: bestExitDisplayNodeName(owner, ownerNodeID, lookup, bestExitUnknownOwnerName(ownerRole)),
|
||||
OwnerRole: ownerRole,
|
||||
ExitNodeName: bestExitDisplaySummaryWait,
|
||||
Reason: bestExitDisplayStatusWaiting,
|
||||
}
|
||||
if snapshot, ok := manager.snapshot(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}); ok && snapshot.AppliedExitNodeID > 0 {
|
||||
item.ExitNodeID = snapshot.AppliedExitNodeID
|
||||
item.ExitNodeName = bestExitDisplayNodeName(exitsByID[snapshot.AppliedExitNodeID], snapshot.AppliedExitNodeID, lookup, bestExitUnknownExitName)
|
||||
item.UpdatedAt = snapshot.UpdatedAt
|
||||
item.Reason = snapshot.Reason
|
||||
appliedExitIDs[item.ExitNodeID] = item.ExitNodeName
|
||||
appliedCount++
|
||||
if snapshot.UpdatedAt > latestUpdatedAt {
|
||||
latestUpdatedAt = snapshot.UpdatedAt
|
||||
latestReason = snapshot.Reason
|
||||
}
|
||||
}
|
||||
state.Items = append(state.Items, item)
|
||||
}
|
||||
|
||||
if appliedCount == 0 {
|
||||
return state, true
|
||||
}
|
||||
state.Status = bestExitDisplayStatusApplied
|
||||
state.UpdatedAt = latestUpdatedAt
|
||||
state.Reason = latestReason
|
||||
if len(appliedExitIDs) == 1 {
|
||||
for _, name := range appliedExitIDs {
|
||||
state.Summary = name
|
||||
}
|
||||
} else {
|
||||
state.Summary = bestExitDisplaySummaryMulti
|
||||
}
|
||||
return state, true
|
||||
}
|
||||
|
||||
func bestExitDisplayOwners(tunnel map[string]interface{}) ([]map[string]interface{}, string) {
|
||||
chainGroups := bestExitDisplayChainGroups(tunnel["chainNodes"])
|
||||
if len(chainGroups) > 0 {
|
||||
return chainGroups[len(chainGroups)-1], "chain"
|
||||
}
|
||||
return bestExitDisplayMapSlice(tunnel["inNodeId"]), "entry"
|
||||
}
|
||||
|
||||
func bestExitDisplayMapSlice(v interface{}) []map[string]interface{} {
|
||||
switch arr := v.(type) {
|
||||
case []map[string]interface{}:
|
||||
return arr
|
||||
case []interface{}:
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayChainGroups(v interface{}) [][]map[string]interface{} {
|
||||
switch groups := v.(type) {
|
||||
case [][]map[string]interface{}:
|
||||
return groups
|
||||
case []interface{}:
|
||||
out := make([][]map[string]interface{}, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
items := bestExitDisplayMapSlice(group)
|
||||
if len(items) > 0 {
|
||||
out = append(out, items)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayNodeName(source map[string]interface{}, nodeID int64, lookup bestExitNodeNameLookup, fallback string) string {
|
||||
if source != nil {
|
||||
for _, key := range []string{"nodeName", "name"} {
|
||||
if name := strings.TrimSpace(asString(source[key])); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
if lookup != nil {
|
||||
if name, ok := lookup(nodeID); ok && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func bestExitUnknownOwnerName(role string) string {
|
||||
if role == "chain" {
|
||||
return bestExitUnknownChainName
|
||||
}
|
||||
return bestExitUnknownEntryName
|
||||
}
|
||||
|
||||
func (h *Handler) attachBestExitStatesOrLog(items []map[string]interface{}) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
log.Printf("best_exit: attach display state failed: %v", recovered)
|
||||
}
|
||||
}()
|
||||
h.attachBestExitStates(items)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace direct attach calls with panic-safe wrapper**
|
||||
|
||||
Keep `attachBestExitStates` for tests, and use `attachBestExitStatesOrLog` from handlers in Task 3. This step only creates the function above; no handler wiring yet.
|
||||
|
||||
- [ ] **Step 3: Run backend display tests**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run gofmt**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/http/handler/tunnel_best_exit_display.go internal/http/handler/tunnel_best_exit_display_test.go
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit backend display implementation**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go
|
||||
git commit -m "feat: build best exit display state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Attach Best-Exit State To Tunnel List And Get Responses
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
- Modify: `go-backend/internal/http/handler/mutations.go`
|
||||
- Test: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing handler attach tests**
|
||||
|
||||
Append to `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`:
|
||||
|
||||
```go
|
||||
func TestAttachBestExitStatesAddsStateToBestTunnelOnly(t *testing.T) {
|
||||
h := &Handler{bestExit: newBestExitManager()}
|
||||
now := time.Unix(300, 0)
|
||||
h.bestExit.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
|
||||
items := []map[string]interface{}{
|
||||
{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": int64(78),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(12)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(40), "strategy": "round"},
|
||||
{"nodeId": int64(41), "strategy": "round"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h.attachBestExitStates(items)
|
||||
state, ok := items[0]["bestExitState"].(*bestExitDisplayState)
|
||||
if !ok {
|
||||
t.Fatalf("expected bestExitState on best tunnel, got %#v", items[0]["bestExitState"])
|
||||
}
|
||||
if state.Summary != bestExitUnknownExitName || state.Items[0].ExitNodeID != 30 {
|
||||
t.Fatalf("unexpected state with fallback names: %+v", state)
|
||||
}
|
||||
if _, exists := items[1]["bestExitState"]; exists {
|
||||
t.Fatalf("non-best tunnel should not have bestExitState: %+v", items[1])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run attach test to verify failure**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run TestAttachBestExitStatesAddsStateToBestTunnelOnly -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Wire tunnel list response**
|
||||
|
||||
In `go-backend/internal/http/handler/handler.go`, change `tunnelList` from:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.attachBestExitStatesOrLog(items)
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire single tunnel response**
|
||||
|
||||
In `go-backend/internal/http/handler/mutations.go`, change `tunnelGet` from:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
if asInt64(it["id"], 0) == id {
|
||||
response.WriteJSON(w, response.OK(it))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.attachBestExitStatesOrLog(items)
|
||||
for _, it := range items {
|
||||
if asInt64(it["id"], 0) == id {
|
||||
response.WriteJSON(w, response.OK(it))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run focused backend tests**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState|TestAttachBestExitStatesAddsStateToBestTunnelOnly' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run gofmt**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/http/handler/handler.go internal/http/handler/mutations.go internal/http/handler/tunnel_best_exit_display.go internal/http/handler/tunnel_best_exit_display_test.go
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit response wiring**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go
|
||||
git commit -m "feat: expose best exit display state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend Tunnel List Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/tunnel.tsx`
|
||||
|
||||
- [ ] **Step 1: Add TypeScript types**
|
||||
|
||||
In `vite-frontend/src/pages/tunnel.tsx`, add these interfaces after `interface ChainTunnel`:
|
||||
|
||||
```ts
|
||||
interface BestExitStateItem {
|
||||
ownerNodeId: number;
|
||||
ownerNodeName: string;
|
||||
ownerRole: "entry" | "chain";
|
||||
exitNodeId?: number;
|
||||
exitNodeName: string;
|
||||
updatedAt?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface BestExitState {
|
||||
enabled: boolean;
|
||||
summary: string;
|
||||
status: "applied" | "waiting";
|
||||
updatedAt?: number;
|
||||
reason?: string;
|
||||
items: BestExitStateItem[];
|
||||
}
|
||||
```
|
||||
|
||||
Then add the optional field to `interface Tunnel`:
|
||||
|
||||
```ts
|
||||
bestExitState?: BestExitState | null;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Preserve API state during mapping**
|
||||
|
||||
In `mapTunnelApiItems`, add `bestExitState` to the returned object:
|
||||
|
||||
```ts
|
||||
bestExitState:
|
||||
tunnel.bestExitState && typeof tunnel.bestExitState === "object"
|
||||
? {
|
||||
...tunnel.bestExitState,
|
||||
items: Array.isArray(tunnel.bestExitState.items)
|
||||
? tunnel.bestExitState.items
|
||||
: [],
|
||||
}
|
||||
: null,
|
||||
```
|
||||
|
||||
The mapped object should include this field before `createdTime` or immediately after it.
|
||||
|
||||
- [ ] **Step 3: Add render helpers**
|
||||
|
||||
Add these helper functions after `mapTunnelApiItems` and before `export default function TunnelPage()`:
|
||||
|
||||
```tsx
|
||||
const bestExitOwnerRoleText = (role: BestExitStateItem["ownerRole"]) => {
|
||||
return role === "chain" ? "中转" : "入口";
|
||||
};
|
||||
|
||||
const bestExitDetailTitle = (state?: BestExitState | null) => {
|
||||
if (!state?.enabled || !state.items?.length) {
|
||||
return "";
|
||||
}
|
||||
return state.items
|
||||
.map((item) => {
|
||||
const ownerName = item.ownerNodeName || `${bestExitOwnerRoleText(item.ownerRole)} ${item.ownerNodeId}`;
|
||||
const exitName = item.exitNodeName || "等待探测";
|
||||
return `${ownerName} -> ${exitName}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const renderBestExitState = (state?: BestExitState | null) => {
|
||||
if (!state?.enabled) {
|
||||
return null;
|
||||
}
|
||||
const title = bestExitDetailTitle(state);
|
||||
const isWaiting = state.status === "waiting";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-1 text-[11px] leading-4 ${
|
||||
isWaiting
|
||||
? "text-default-500"
|
||||
: "text-emerald-700 dark:text-emerald-300"
|
||||
}`}
|
||||
title={title || undefined}
|
||||
>
|
||||
最优出口:{state.summary || "等待探测"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render in table topology cell**
|
||||
|
||||
In the table topology `<TableCell>` around line 1674, change the cell content from:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
||||
{tunnel.inNodeId?.length || 0}入口
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.chainNodes?.length || 0
|
||||
: 0}
|
||||
跳
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-success-700 dark:text-success-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.outNodeId?.length || 0
|
||||
: tunnel.inNodeId?.length || 0}
|
||||
出口
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
||||
{tunnel.inNodeId?.length || 0}入口
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.chainNodes?.length || 0
|
||||
: 0}
|
||||
跳
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-success-700 dark:text-success-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.outNodeId?.length || 0
|
||||
: tunnel.inNodeId?.length || 0}
|
||||
出口
|
||||
</span>
|
||||
</div>
|
||||
{renderBestExitState(tunnel.bestExitState)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render in grid card topology section**
|
||||
|
||||
In the grid card topology section, after the closing `</div>` for the topology row at the end of the block containing `出口` and before the enclosing border section closes, add:
|
||||
|
||||
```tsx
|
||||
<div className="text-center">
|
||||
{renderBestExitState(tunnel.bestExitState)}
|
||||
</div>
|
||||
```
|
||||
|
||||
The result should put the best-exit summary under the entry -> hop -> exit row inside the topology section.
|
||||
|
||||
- [ ] **Step 6: Run frontend build**
|
||||
|
||||
Run from `vite-frontend`:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Expected: PASS with `tsc && vite build` completing successfully.
|
||||
|
||||
- [ ] **Step 7: Commit frontend display**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/tunnel.tsx
|
||||
git commit -m "feat: show current best exit in tunnel list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Full Verification And Review
|
||||
|
||||
**Files:**
|
||||
- Verify only.
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run from `vite-frontend`:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Inspect final diff**
|
||||
|
||||
Run from repository root:
|
||||
|
||||
```bash
|
||||
git diff --stat origin/main...HEAD
|
||||
git diff -- go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go vite-frontend/src/pages/tunnel.tsx
|
||||
```
|
||||
|
||||
Expected: Diff only adds best-exit display state, response attachment, frontend list display, and tests. It must not change best-exit scoring, switching, runtime chain update, or agent code.
|
||||
|
||||
- [ ] **Step 4: Request final code review**
|
||||
|
||||
Ask a reviewer to check:
|
||||
|
||||
```text
|
||||
Review the best-exit current display implementation. Confirm it only exposes current in-memory best-exit state in tunnel list/get responses and renders it in the tunnel list. Verify it does not change routing, scoring, switching, persistence, or polling behavior.
|
||||
```
|
||||
|
||||
Expected: No blocking findings.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: Backend response state is Task 2 and Task 3; direct vs final-hop owner semantics are covered by Task 1 tests; frontend list/grid display is Task 4; no polling and no routing changes are preserved by Task 5 review instructions.
|
||||
- Placeholder scan: The plan contains concrete files, function names, code blocks, commands, and expected outcomes.
|
||||
- Type consistency: `BestExitState`, `BestExitStateItem`, `bestExitDisplayState`, `bestExitDisplayItem`, `bestExitDecisionSnapshot`, and `bestExitNodeNameLookup` are defined before use and names match across tasks.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,536 @@
|
||||
# Dependabot Remediation 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:** Resolve the open Dependabot dependency alerts for `go-backend`, `vite-frontend`, `go-gost/x`, and `go-gost` without mixing in unrelated business security changes.
|
||||
|
||||
**Architecture:** Apply targeted dependency upgrades per module, verify each module before moving to the next, and keep commits scoped to one dependency group. `go-gost/x` is fixed before `go-gost` because the main agent module uses `replace github.com/go-gost/x => ./x`.
|
||||
|
||||
**Tech Stack:** Go modules, pnpm, Vite/Rolldown, GitHub CLI Dependabot alerts API.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `go-backend/go.mod`
|
||||
Responsibility: update `github.com/jackc/pgx/v5` to the patched version.
|
||||
- Modify: `go-backend/go.sum`
|
||||
Responsibility: reflect Go module checksum changes from the pgx upgrade.
|
||||
- Modify: `vite-frontend/package.json`
|
||||
Responsibility: update direct vulnerable npm dependency versions and configure `pnpm.overrides`.
|
||||
- Modify: `vite-frontend/pnpm-lock.yaml`
|
||||
Responsibility: resolve vulnerable npm transitive dependencies to patched versions.
|
||||
- Modify: `go-gost/x/go.mod`
|
||||
Responsibility: update vulnerable Go dependencies used by the local `github.com/go-gost/x` module.
|
||||
- Modify: `go-gost/x/go.sum`
|
||||
Responsibility: reflect checksum changes for `go-gost/x`.
|
||||
- Modify: `go-gost/x/dialer/dtls/dialer.go`
|
||||
Responsibility: migrate DTLS import path from `github.com/pion/dtls/v2` to `github.com/pion/dtls/v3`.
|
||||
- Modify: `go-gost/x/listener/dtls/listener.go`
|
||||
Responsibility: migrate DTLS import path from `github.com/pion/dtls/v2` to `github.com/pion/dtls/v3`.
|
||||
- Modify: `go-gost/go.mod`
|
||||
Responsibility: sync vulnerable dependency versions for the main agent module while preserving local `replace github.com/go-gost/x => ./x`.
|
||||
- Modify: `go-gost/go.sum`
|
||||
Responsibility: reflect checksum changes for the main agent module.
|
||||
|
||||
## Task 1: Capture Baseline Alerts
|
||||
|
||||
**Files:**
|
||||
- Read: GitHub Dependabot alerts API
|
||||
- Read: `go-backend/go.mod`
|
||||
- Read: `vite-frontend/package.json`
|
||||
- Read: `go-gost/x/go.mod`
|
||||
- Read: `go-gost/go.mod`
|
||||
|
||||
- [ ] **Step 1: Query current open Dependabot alerts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh api 'repos/Sagit-chu/flvx/dependabot/alerts?state=open&per_page=100' --paginate \
|
||||
--jq '.[] | [.number,.security_advisory.severity,.dependency.package.ecosystem,.dependency.manifest_path,.dependency.package.name,.security_vulnerability.vulnerable_version_range,(.security_vulnerability.first_patched_version.identifier // "")] | @tsv'
|
||||
```
|
||||
|
||||
Expected: output includes alerts for `github.com/jackc/pgx/v5`, `postcss`, `serialize-javascript`, `fast-uri`, `@babel/plugin-transform-modules-systemjs`, `github.com/sirupsen/logrus`, `github.com/quic-go/quic-go`, `github.com/quic-go/webtransport-go`, and `github.com/pion/dtls/v2`.
|
||||
|
||||
- [ ] **Step 2: Confirm starting versions in module manifests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'jackc/pgx|postcss|serialize-javascript|pion/dtls|quic-go|webtransport-go|sirupsen/logrus' \
|
||||
go-backend/go.mod vite-frontend/package.json go-gost/x/go.mod go-gost/go.mod
|
||||
```
|
||||
|
||||
Expected key lines:
|
||||
|
||||
```text
|
||||
go-backend/go.mod: github.com/jackc/pgx/v5 v5.7.3
|
||||
vite-frontend/package.json: "postcss": "8.5.6"
|
||||
vite-frontend/package.json: "serialize-javascript": "7.0.3"
|
||||
go-gost/x/go.mod: github.com/pion/dtls/v2 v2.2.6
|
||||
go-gost/x/go.mod: github.com/quic-go/quic-go v0.49.1
|
||||
go-gost/x/go.mod: github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66
|
||||
go-gost/x/go.mod: github.com/sirupsen/logrus v1.8.1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Confirm the DTLS advisory has no patched v2 release**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go list -m -versions github.com/pion/dtls/v2
|
||||
gh api 'advisories/GHSA-9f3f-wv7r-qc8r' --jq '{summary, vulnerabilities}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
github.com/pion/dtls/v2 ... v2.2.12
|
||||
```
|
||||
|
||||
Expected advisory facts:
|
||||
|
||||
```text
|
||||
github.com/pion/dtls/v2 vulnerable range <= 2.2.12 has no first_patched_version.
|
||||
github.com/pion/dtls/v3 patched versions include 3.0.11 and 3.1.1.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Do not commit baseline capture**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no files are changed by Task 1.
|
||||
|
||||
## Task 2: Fix go-backend pgx Alerts
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/go.mod`
|
||||
- Modify: `go-backend/go.sum`
|
||||
|
||||
- [ ] **Step 1: Upgrade pgx to the patched version**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go get github.com/jackc/pgx/v5@v5.9.2)
|
||||
```
|
||||
|
||||
Expected: `go-backend/go.mod` changes `github.com/jackc/pgx/v5` from `v5.7.3` to `v5.9.2`, and `go-backend/go.sum` updates checksums.
|
||||
|
||||
- [ ] **Step 2: Tidy backend module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go mod tidy)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify backend dependency version**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/jackc/pgx/v5' go-backend/go.mod
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
go-backend/go.mod: github.com/jackc/pgx/v5 v5.9.2
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
```
|
||||
|
||||
Expected: all backend packages pass.
|
||||
|
||||
- [ ] **Step 5: Commit backend dependency fix**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-backend/go.mod go-backend/go.sum
|
||||
git commit -m "fix: update backend pgx dependency"
|
||||
```
|
||||
|
||||
Expected: one commit containing only `go-backend/go.mod` and `go-backend/go.sum`.
|
||||
|
||||
## Task 3: Fix Frontend npm Alerts
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/package.json`
|
||||
- Modify: `vite-frontend/pnpm-lock.yaml`
|
||||
|
||||
- [ ] **Step 1: Update direct dependency and pnpm overrides in package.json**
|
||||
|
||||
Edit `vite-frontend/package.json` so the relevant entries are exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"postcss": "8.5.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@babel/plugin-transform-modules-systemjs": "7.29.4",
|
||||
"fast-uri": "3.1.2",
|
||||
"serialize-javascript": "7.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the existing top-level `"overrides"` block after adding `"pnpm.overrides"`. Keep all other existing dependencies and scripts unchanged.
|
||||
|
||||
- [ ] **Step 2: Regenerate pnpm lockfile**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm install)
|
||||
```
|
||||
|
||||
Expected: `vite-frontend/pnpm-lock.yaml` updates and install exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify vulnerable npm versions are absent**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'postcss@8\.5\.[0-9]:|"postcss":\s*"8\.5\.[0-9]"|serialize-javascript@[0-6]\.|serialize-javascript@7\.0\.[0-4]|fast-uri@3\.1\.[0-1]|plugin-transform-modules-systemjs@7\.29\.[0-3]' vite-frontend/pnpm-lock.yaml vite-frontend/package.json
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Verify patched npm versions are present**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'postcss@8\.5\.10|serialize-javascript@7\.0\.5|fast-uri@3\.1\.2|plugin-transform-modules-systemjs@7\.29\.4' vite-frontend/pnpm-lock.yaml vite-frontend/package.json
|
||||
```
|
||||
|
||||
Expected: output includes patched entries for `postcss@8.5.10`, `serialize-javascript@7.0.5`, `fast-uri@3.1.2`, and `@babel/plugin-transform-modules-systemjs@7.29.4`.
|
||||
|
||||
- [ ] **Step 5: Build frontend**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
Expected: TypeScript and Rolldown/Vite build complete successfully.
|
||||
|
||||
- [ ] **Step 6: Commit frontend dependency fix**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add vite-frontend/package.json vite-frontend/pnpm-lock.yaml
|
||||
git commit -m "fix: update frontend vulnerable dependencies"
|
||||
```
|
||||
|
||||
Expected: one commit containing only `vite-frontend/package.json` and `vite-frontend/pnpm-lock.yaml`.
|
||||
|
||||
## Task 4: Fix go-gost/x Non-DTLS Alerts
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/x/go.mod`
|
||||
- Modify: `go-gost/x/go.sum`
|
||||
|
||||
- [ ] **Step 1: Upgrade non-DTLS vulnerable Go dependencies**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go get github.com/sirupsen/logrus@v1.8.3 github.com/quic-go/quic-go@v0.57.0 github.com/quic-go/webtransport-go@v0.10.0)
|
||||
```
|
||||
|
||||
Expected: `go-gost/x/go.mod` resolves these dependencies to at least:
|
||||
|
||||
```text
|
||||
github.com/sirupsen/logrus v1.8.3
|
||||
github.com/quic-go/quic-go v0.57.0
|
||||
github.com/quic-go/webtransport-go v0.10.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tidy go-gost/x module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go mod tidy)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify go-gost/x non-DTLS dependency versions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/sirupsen/logrus|github.com/quic-go/quic-go|github.com/quic-go/webtransport-go' go-gost/x/go.mod
|
||||
```
|
||||
|
||||
Expected output contains versions at or above:
|
||||
|
||||
```text
|
||||
github.com/sirupsen/logrus v1.8.3
|
||||
github.com/quic-go/quic-go v0.57.0
|
||||
github.com/quic-go/webtransport-go v0.10.0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run go-gost/x tests after non-DTLS upgrades**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go test ./...)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0. If it fails, stop this task before committing and inspect the first compiler error. The only permitted follow-up edits in this task are direct API-compatibility changes in files named by the compiler under `go-gost/x`; rerun this command after each edit.
|
||||
|
||||
- [ ] **Step 5: Commit go-gost/x non-DTLS dependency fix**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-gost/x/go.mod go-gost/x/go.sum
|
||||
git commit -m "fix: update gost quic dependencies"
|
||||
```
|
||||
|
||||
Expected: one commit containing `go-gost/x/go.mod` and `go-gost/x/go.sum`, plus only the compiler-named `go-gost/x` files edited during Step 4.
|
||||
|
||||
## Task 5: Migrate go-gost/x DTLS From v2 To v3
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/x/go.mod`
|
||||
- Modify: `go-gost/x/go.sum`
|
||||
- Modify: `go-gost/x/dialer/dtls/dialer.go`
|
||||
- Modify: `go-gost/x/listener/dtls/listener.go`
|
||||
|
||||
- [ ] **Step 1: Update DTLS imports**
|
||||
|
||||
In `go-gost/x/dialer/dtls/dialer.go`, change:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v2"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v3"
|
||||
```
|
||||
|
||||
In `go-gost/x/listener/dtls/listener.go`, change:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v2"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
"github.com/pion/dtls/v3"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add patched DTLS v3 module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go get github.com/pion/dtls/v3@v3.0.11)
|
||||
```
|
||||
|
||||
Expected: `go-gost/x/go.mod` contains `github.com/pion/dtls/v3 v3.0.11` and no longer needs `github.com/pion/dtls/v2`.
|
||||
|
||||
- [ ] **Step 3: Tidy and format go-gost/x**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go mod tidy)
|
||||
gofmt -w go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 4: Verify v2 import and module are removed**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/pion/dtls/v2' go-gost/x
|
||||
rg -n 'github.com/pion/dtls/v3' go-gost/x/go.mod go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
first command: no output
|
||||
second command: output includes go.mod, dialer.go, and listener.go
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run go-gost/x tests after DTLS migration**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go test ./...)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0. If the compiler reports DTLS v3 API errors, edit only `go-gost/x/dialer/dtls/dialer.go` and `go-gost/x/listener/dtls/listener.go`, preserving the existing `dtls.Config`, `dtls.ClientWithContext`, and `dtls.Listen` flow, then rerun this command.
|
||||
|
||||
- [ ] **Step 6: Commit DTLS migration**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-gost/x/go.mod go-gost/x/go.sum go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
|
||||
git commit -m "fix: migrate gost dtls dependency"
|
||||
```
|
||||
|
||||
Expected: one commit containing the DTLS import migration and Go module updates.
|
||||
|
||||
## Task 6: Sync go-gost Main Module
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/go.mod`
|
||||
- Modify: `go-gost/go.sum`
|
||||
|
||||
- [ ] **Step 1: Upgrade main module vulnerable dependency requirements**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go get github.com/sirupsen/logrus@v1.8.3 github.com/quic-go/quic-go@v0.57.0 github.com/quic-go/webtransport-go@v0.10.0 github.com/pion/dtls/v3@v3.0.11)
|
||||
```
|
||||
|
||||
Expected: `go-gost/go.mod` resolves vulnerable dependencies to patched versions and preserves this replace directive:
|
||||
|
||||
```go
|
||||
replace github.com/go-gost/x => ./x
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tidy main agent module**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go mod tidy)
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Verify go-gost no longer references vulnerable DTLS v2**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/pion/dtls/v2' go-gost/go.mod go-gost/go.sum
|
||||
rg -n 'github.com/pion/dtls/v3|github.com/quic-go/quic-go|github.com/quic-go/webtransport-go|github.com/sirupsen/logrus|replace github.com/go-gost/x => ./x' go-gost/go.mod
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
first command: no output
|
||||
second command: output includes dtls/v3, quic-go, webtransport-go, logrus, and the local replace directive
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run go-gost tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go test ./...)
|
||||
```
|
||||
|
||||
Expected: all packages pass.
|
||||
|
||||
- [ ] **Step 5: Build go-gost binary**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go build .)
|
||||
```
|
||||
|
||||
Expected: build exits with code 0.
|
||||
|
||||
- [ ] **Step 6: Commit go-gost module sync**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add go-gost/go.mod go-gost/go.sum
|
||||
git commit -m "fix: sync gost main dependencies"
|
||||
```
|
||||
|
||||
Expected: one commit containing only `go-gost/go.mod` and `go-gost/go.sum`.
|
||||
|
||||
## Task 7: Final Dependabot Verification
|
||||
|
||||
**Files:**
|
||||
- Read: GitHub Dependabot alerts API
|
||||
- Read: Git working tree status
|
||||
|
||||
- [ ] **Step 1: Run all verification commands once more**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
(cd go-gost/x && go test ./...)
|
||||
(cd go-gost && go test ./...)
|
||||
(cd go-gost && go build .)
|
||||
```
|
||||
|
||||
Expected: every command exits with code 0.
|
||||
|
||||
- [ ] **Step 2: Query open Dependabot alerts after dependency updates**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh api 'repos/Sagit-chu/flvx/dependabot/alerts?state=open&per_page=100' --paginate \
|
||||
--jq 'group_by(.security_advisory.severity) | map({severity:.[0].security_advisory.severity,count:length})'
|
||||
```
|
||||
|
||||
Expected: counts are lower than the baseline from Task 1. If Dependabot has not rescanned yet, run the detailed query from Task 1 and confirm the manifest files now contain patched versions locally.
|
||||
|
||||
- [ ] **Step 3: Confirm no vulnerable dependency strings remain in manifests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'github.com/jackc/pgx/v5 v5\.7\.3|postcss\"\\s*:\\s*\"8\.5\.6|serialize-javascript\"\\s*:\\s*\"7\.0\.3|github.com/pion/dtls/v2|github.com/quic-go/quic-go v0\.49\.1|github.com/quic-go/webtransport-go v0\.8\.1|github.com/sirupsen/logrus v1\.8\.1' \
|
||||
go-backend/go.mod vite-frontend/package.json go-gost/x/go.mod go-gost/go.mod
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Confirm working tree contains only intentional changes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no uncommitted files from this Dependabot remediation remain. Pre-existing unrelated files may still appear; do not stage or revert them.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,133 @@
|
||||
# 允许转发到本地地址开关设计
|
||||
|
||||
**日期**: 2026-04-26
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
新增一个全局设置开关,控制规则目标地址是否允许指向本地/内网地址。默认关闭,保持当前安全策略不变;开启后,规则创建和编辑时允许将目标地址设置为 `127.0.0.1`、`10.x.x.x`、`172.16-31.x.x`、`192.168.x.x` 等本地或私网地址。
|
||||
|
||||
## 背景
|
||||
|
||||
当前后端在规则创建和编辑时会调用 `IsSafeRemoteAddr()`,统一禁止目标地址指向本地/内网地址,用来降低 SSRF / 开放代理风险。这一行为是全局硬编码的,无法按部署场景调整。
|
||||
|
||||
有些用户需要把规则转发到本机或内网服务,因此需要一个显式、全局的开关来放宽这条限制。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 在设置页提供一个全局开关控制该行为。
|
||||
2. 默认关闭,不改变现有安全默认值。
|
||||
3. 开启后,规则创建和编辑允许本地/内网目标地址。
|
||||
4. 不影响其他安全校验和其他业务流程。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 后端
|
||||
- `go-backend/internal/http/handler/security_utils.go`
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
### 前端
|
||||
- `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
### 测试
|
||||
- `go-backend/tests/contract/forward_contract_test.go` 或新增独立 contract test
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 1. 配置存储
|
||||
|
||||
使用现有 `vite_config` 表新增一个配置项:
|
||||
|
||||
| name | value | 说明 |
|
||||
|------|-------|------|
|
||||
| `allow_local_remote_addr` | `"1"` / `"0"` | 是否允许规则目标地址指向本地/内网地址 |
|
||||
|
||||
约定:
|
||||
- 未配置时按 `"0"` 处理
|
||||
- `"1"` 表示允许
|
||||
- 其他值一律按关闭处理
|
||||
|
||||
### 2. 后端行为
|
||||
|
||||
新增一个轻量辅助函数,用于读取该配置开关:
|
||||
|
||||
```go
|
||||
func (h *Handler) allowLocalRemoteAddr() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return false
|
||||
}
|
||||
cfg, err := h.repo.GetConfigByName("allow_local_remote_addr")
|
||||
if err != nil || cfg == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(cfg.Value) == "1"
|
||||
}
|
||||
```
|
||||
|
||||
在以下路径中应用:
|
||||
- `forwardCreate`
|
||||
- `forwardUpdate`
|
||||
|
||||
行为改为:
|
||||
- 当开关关闭时,继续执行 `IsSafeRemoteAddr(remoteAddr)`
|
||||
- 当开关开启时,跳过这条“本地/内网地址禁止”校验
|
||||
|
||||
这样可以把改动范围限定在规则创建/编辑,不改变其他依赖 `IsSafeRemoteAddr()` 的场景。
|
||||
|
||||
### 3. 前端设置页
|
||||
|
||||
在 `vite-frontend/src/pages/config.tsx` 增加一个全局开关配置项。
|
||||
|
||||
建议文案:
|
||||
|
||||
- 标签:`允许转发到本地地址`
|
||||
- 描述:`开启后,规则目标地址可指向 127.0.0.1、10.x.x.x、172.16-31.x.x、192.168.x.x 等本地或内网地址。默认关闭以降低开放代理风险。`
|
||||
|
||||
控件类型:
|
||||
- 使用现有设置页的布尔开关模式
|
||||
|
||||
默认显示策略:
|
||||
- 不依赖其他配置项
|
||||
- 直接显示在设置页的网络/安全相关区域;若现有页面没有单独分区,则先按现有配置项组织方式加入即可
|
||||
|
||||
### 4. 错误与兼容性
|
||||
|
||||
关闭开关时:
|
||||
- 保持现有错误行为,继续阻止本地/内网地址
|
||||
|
||||
开启开关时:
|
||||
- 仅放开“本地/内网地址禁止”这条限制
|
||||
- 仍保留地址格式解析失败等其他错误
|
||||
|
||||
### 5. 测试
|
||||
|
||||
需要补两类后端契约测试:
|
||||
|
||||
1. 开关关闭时拒绝本地/内网地址
|
||||
- 创建规则时使用本地/内网地址
|
||||
- 断言接口返回非 0 code
|
||||
|
||||
2. 开关开启时允许本地/内网地址
|
||||
- 先写入 `vite_config(name=allow_local_remote_addr, value=1)`
|
||||
- 创建或更新规则时使用相同地址
|
||||
- 断言接口成功
|
||||
|
||||
建议至少覆盖:
|
||||
- create 路径
|
||||
- update 路径
|
||||
- 多目标地址输入(逗号或换行分隔)中包含本地地址时的行为
|
||||
|
||||
## 风险与约束
|
||||
|
||||
1. 该开关会降低默认安全防护,应明确标注风险。
|
||||
2. 这是全局开关,不做用户级或规则级细分控制。
|
||||
3. 该开关只影响规则目标地址校验,不影响其他独立的安全策略。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 先补失败的后端契约测试
|
||||
2. 实现后端配置读取与创建/更新分支控制
|
||||
3. 在设置页增加开关
|
||||
4. 跑后端测试与前端构建验证
|
||||
@@ -0,0 +1,321 @@
|
||||
# 规则每 IP 连接数与限速设计
|
||||
|
||||
**日期**: 2026-04-27
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
在转发规则的高级设置中新增两类每客户端 IP 限制:每 IP 最大连接数、每 IP 带宽限速。保留现有总量限制语义不变,新增字段只在用户显式配置时生效。
|
||||
|
||||
实现优先复用 GOST 已有能力:`climiters` 的 `$$ N` 表示每个客户端 IP 独立最大连接数;`limiters` 支持 IP/CIDR 级带宽桶,可用 `0.0.0.0/0` 和 `::/0` 实现默认覆盖所有 IPv4/IPv6 客户端的每 IP 带宽限速。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 FLVX 已经支持规则级最大连接数和规则级限速,但这两个限制都是规则总量:
|
||||
|
||||
- `maxConn` 下发为 GOST `climiters` 的 `$ N`,限制整条规则的总并发连接数。
|
||||
- `speedId` 下发为 GOST `limiters` 的 `$ in out`,限制整条规则的总带宽。
|
||||
|
||||
用户需要的是按客户端 IP 隔离的限制,例如每个 IP 最多 5 个连接、每个 IP 最多 10 Mbps,而不是所有客户端共享同一个总量。
|
||||
|
||||
## GOST 能力确认
|
||||
|
||||
### 连接数限制
|
||||
|
||||
`go-gost/x/limiter/conn/conn.go` 已内置以下语义:
|
||||
|
||||
| Key | 含义 |
|
||||
|-----|------|
|
||||
| `$` | 全局连接数限制,所有客户端共享一个 limiter |
|
||||
| `$$` | 每个客户端 IP 独立连接数限制,每个 IP 创建自己的 limiter |
|
||||
| `IP` / `CIDR` | 指定 IP 或 CIDR 的连接数限制 |
|
||||
|
||||
因此每 IP 连接数无需新增 agent 限制器,只需后端下发 `$$ N`。
|
||||
|
||||
### 带宽限制
|
||||
|
||||
`go-gost/x/limiter/traffic/traffic.go` 已内置以下语义:
|
||||
|
||||
| Key | 含义 |
|
||||
|-----|------|
|
||||
| `$` | 服务级总带宽限制 |
|
||||
| `$$` | 连接级带宽限制 |
|
||||
| `IP` / `CIDR` | 客户端 IP 或 CIDR 级带宽限制 |
|
||||
|
||||
CIDR 级限制使用 generator,为命中的客户端 IP 创建独立 limiter。使用 `0.0.0.0/0` 和 `::/0` 可以覆盖所有 IPv4/IPv6 客户端,实现每 IP 带宽限速。
|
||||
|
||||
### 现有缺口
|
||||
|
||||
TCP listener 已在 Accept 后用客户端地址包装连接级 traffic limiter,路径可用于每 IP 带宽。UDP listener 当前只在 PacketConn 上应用服务级 limiter,没有在 `Accept()` 后按客户端 UDP pseudo-connection 包装 limiter,也没有挂接 connection limiter。因此要让 UDP 与 TCP 语义一致,需要补齐 UDP listener 的 per-client wrapper。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 保留现有 `maxConn` 和 `speedId` 的总量语义。
|
||||
2. 在规则上新增每 IP 最大连接数。
|
||||
3. 在规则上新增每 IP 带宽限速。
|
||||
4. 同一规则允许同时配置总量限制和每 IP 限制。
|
||||
5. 普通用户不能设置或修改限速规则字段,保持现有权限模型。
|
||||
6. TCP 和 UDP 入口都尽量遵循相同限制语义。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不新增按用户组、节点组、国家地区、ASN 的限制。
|
||||
2. 不新增请求频率限制;本次“每个 IP 限速”指带宽限速,不是新建连接频率。
|
||||
3. 不改变已有 speed limit 规则表的单位和含义。
|
||||
4. 不把用户级默认最大连接数改成每 IP 语义;用户级 `maxConn` 继续作为默认总连接数。
|
||||
|
||||
## 数据模型
|
||||
|
||||
在 `forward` 表新增两个字段:
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ip_max_conn` | int | `0` | 每 IP 最大连接数,`0` 表示不启用 |
|
||||
| `ip_speed_id` | nullable int64 | `NULL` | 每 IP 带宽限速规则 ID,`NULL` 表示不启用 |
|
||||
|
||||
Go 模型新增:
|
||||
|
||||
```go
|
||||
IPMaxConn int `gorm:"column:ip_max_conn;not null;default:0"`
|
||||
IPSpeedID sql.NullInt64 `gorm:"column:ip_speed_id"`
|
||||
```
|
||||
|
||||
字段会通过现有 auto-migrate 机制创建,保持 SQLite/PostgreSQL 兼容,不使用 SQLite 不兼容的 GORM tags。
|
||||
|
||||
## API 行为
|
||||
|
||||
### 创建规则
|
||||
|
||||
`/forward/create` 新增入参:
|
||||
|
||||
```json
|
||||
{
|
||||
"ipMaxConn": 5,
|
||||
"ipSpeedId": 123
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `ipMaxConn` 缺省或小于等于 `0` 时按 `0` 存储,不启用每 IP 连接数限制。
|
||||
- `ipSpeedId` 缺省或不存在时存为 `NULL`,不启用每 IP 带宽限速。
|
||||
- `ipSpeedId` 指向不存在的限速规则时按 `NULL` 处理,沿用现有 `speedId` 的容错策略。
|
||||
- 普通用户提交非空 `ipSpeedId` 时返回错误,保持与 `speedId` 一致的权限边界。
|
||||
|
||||
### 更新规则
|
||||
|
||||
`/forward/update` 新增入参:
|
||||
|
||||
```json
|
||||
{
|
||||
"ipMaxConn": 5,
|
||||
"ipSpeedId": 123
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 未提交 `ipMaxConn` 时保留原值;提交空值或 `0` 时清除每 IP 连接数限制。
|
||||
- 未提交 `ipSpeedId` 时保留原值;提交 `null` 时清除每 IP 带宽限速。
|
||||
- 普通用户不能把 `ipSpeedId` 改成不同的非空值。
|
||||
- 更新后重新同步运行时服务和 limiter。
|
||||
|
||||
### 列表返回
|
||||
|
||||
`/forward/list` 返回项新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"ipMaxConn": 5,
|
||||
"ipSpeedId": 123,
|
||||
"ipSpeedLimitName": "每IP 10Mbps"
|
||||
}
|
||||
```
|
||||
|
||||
`ipSpeedLimitName` 可选,但建议返回,便于前端显示缺失或已删除的限速规则。
|
||||
|
||||
## 后端运行时同步
|
||||
|
||||
### 连接数限制器
|
||||
|
||||
将现有连接限制器构建从单一总量扩展为组合规则。
|
||||
|
||||
当前行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rule_conn_limit_42",
|
||||
"limits": ["$ 100"]
|
||||
}
|
||||
```
|
||||
|
||||
新增行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rule_conn_limit_42",
|
||||
"limits": ["$ 100", "$$ 5"]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `maxConn > 0` 时追加 `$ maxConn`。
|
||||
- `ipMaxConn > 0` 时追加 `$$ ipMaxConn`。
|
||||
- 如果规则未配置 `maxConn` 且用户有 `MaxConn > 0`,继续继承用户级总连接数,追加 `$ user.MaxConn`。
|
||||
- 如果两者都没有,则不下发 `climiter`,服务不引用 `climiter`。
|
||||
- limiter 名称继续优先使用 `rule_conn_limit_<forwardID>`;只有用户级默认总连接数且规则没有任何连接限制时可继续使用 `user_conn_limit_<userID>`,避免不必要的 per-rule limiter。
|
||||
|
||||
### 带宽限制器
|
||||
|
||||
将现有规则限速从单一 `speedId` 扩展为组合 limiter。
|
||||
|
||||
当前行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "123",
|
||||
"limits": ["$ 1.3MB 1.3MB"]
|
||||
}
|
||||
```
|
||||
|
||||
新增每 IP 行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rule_traffic_limit_42",
|
||||
"limits": [
|
||||
"$ 1.3MB 1.3MB",
|
||||
"0.0.0.0/0 1.3MB 1.3MB",
|
||||
"::/0 1.3MB 1.3MB"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 只有总量 `speedId` 时,保持现有名称和下发路径,服务继续引用 `speedId` 字符串。
|
||||
- 只有每 IP `ipSpeedId` 时,创建 `rule_traffic_limit_<forwardID>`,只包含 IPv4/IPv6 CIDR 行。
|
||||
- 总量和每 IP 同时存在时,创建 `rule_traffic_limit_<forwardID>`,同时包含 `$` 和 CIDR 行。
|
||||
- 如果规则没有 `speedId`,则总量仍可继承 user tunnel 的 `speedId`,保持现有 fallback 语义;当继承的总量限速与 `ipSpeedId` 同时存在时,也使用 `rule_traffic_limit_<forwardID>` 组合 limiter。
|
||||
- 每 IP 限速不从 user tunnel 继承,只由规则字段控制。
|
||||
- `AddLimiters` 失败且提示已存在时,使用 `UpdateLimiters` 更新。
|
||||
|
||||
### 服务配置
|
||||
|
||||
`buildForwardServiceConfigs` 需要从当前 `limiterID *int64` / `cLimiterName string` 扩展为更明确的运行时限制描述,例如:
|
||||
|
||||
```go
|
||||
type forwardRuntimeLimiters struct {
|
||||
TrafficLimiter string
|
||||
ConnLimiter string
|
||||
}
|
||||
```
|
||||
|
||||
服务配置只关心最终引用的 limiter 名称:
|
||||
|
||||
- `service["limiter"] = runtimeLimiters.TrafficLimiter`
|
||||
- `service["climiter"] = runtimeLimiters.ConnLimiter`
|
||||
|
||||
这样可以把“如何构建 limiter payload”的逻辑和“如何构建 service JSON”的逻辑分开。
|
||||
|
||||
## Agent/GOST 调整
|
||||
|
||||
### WebSocket 命令
|
||||
|
||||
当前 agent WebSocket 已支持:
|
||||
|
||||
- `AddLimiters` / `UpdateLimiters` / `DeleteLimiters`
|
||||
- `AddCLimiters` / `UpdateCLimiters` / `DeleteCLimiters`
|
||||
|
||||
本设计无需新增命令类型。
|
||||
|
||||
### UDP listener
|
||||
|
||||
补齐 `go-gost/x/listener/udp/listener.go` 的 `Accept()` 包装逻辑,使 UDP pseudo-connection 与 TCP listener 一致:
|
||||
|
||||
- 对 `l.options.ConnLimiter` 按客户端地址应用连接数限制。
|
||||
- 对 `l.options.TrafficLimiter` 按 `conn.RemoteAddr().String()` 应用连接级 traffic wrapper。
|
||||
|
||||
需要注意 UDP pseudo-connection 的生命周期由内部 UDP listener 的 TTL/keepalive 控制;connection limiter 必须在 pseudo-connection 关闭时释放计数。
|
||||
|
||||
## 前端设计
|
||||
|
||||
在 `vite-frontend/src/pages/forward.tsx` 的规则高级设置中新增两个控件:
|
||||
|
||||
1. `每 IP 最大连接数`
|
||||
- 类型:number input。
|
||||
- 文案:`每个客户端 IP 可同时建立的最大连接数;0 或空表示不限制。`
|
||||
- 字段:`ipMaxConn`。
|
||||
|
||||
2. `每 IP 限速`
|
||||
- 类型:Select,复用现有限速规则列表。
|
||||
- 文案:`每个客户端 IP 独享该带宽限制;不选择表示不限制。`
|
||||
- 字段:`ipSpeedId`。
|
||||
- 只对管理员显示,保持与 `规则限速` 一致。
|
||||
|
||||
前端类型需要同步更新:
|
||||
|
||||
- `ForwardApiItem`
|
||||
- `ForwardMutationPayload`
|
||||
- `ForwardForm` 或页面内等价类型
|
||||
|
||||
## 错误处理与兼容性
|
||||
|
||||
1. 旧数据默认 `ip_max_conn=0`、`ip_speed_id=NULL`,行为与当前版本一致。
|
||||
2. 现有 agent 已支持 limiter 命令和 GOST limiter 语法;发布时需要包含 UDP 修复,才能让 TCP/UDP 都获得完整语义。
|
||||
3. 节点离线时沿用现有 warning 行为,规则仍可保存,在线节点跳过下发。
|
||||
4. 如果每 IP speed limit ID 被删除,更新时按 `NULL` 处理,列表页可提示或自动清除,和现有 `speedId` 行为一致。
|
||||
5. 如果 IPv6 CIDR 在某些监听路径未命中,IPv4 行仍正常生效;测试应覆盖 IPv4,IPv6 通过 payload 合同保证下发。
|
||||
|
||||
## 测试计划
|
||||
|
||||
### 后端 contract 测试
|
||||
|
||||
新增或扩展 `go-backend/tests/contract/max_conn_limit_contract_test.go`:
|
||||
|
||||
1. 创建规则时设置 `ipMaxConn=5`,断言 `AddCLimiters` payload 包含 `$$ 5`。
|
||||
2. 同时设置 `maxConn=100` 和 `ipMaxConn=5`,断言 payload 包含 `$ 100` 和 `$$ 5`。
|
||||
3. 用户级 `MaxConn` 存在且规则 `ipMaxConn=5` 时,断言 payload 包含 `$ userMaxConn` 和 `$$ 5`。
|
||||
|
||||
新增每 IP 限速 contract 测试:
|
||||
|
||||
1. 创建规则时设置 `ipSpeedId`,断言 `AddLimiters` payload 包含 `0.0.0.0/0 ...` 和 `::/0 ...`。
|
||||
2. 同时设置 `speedId` 和 `ipSpeedId`,断言组合 limiter 包含 `$ ...` 与两个 CIDR 行,服务引用 `rule_traffic_limit_<forwardID>`。
|
||||
3. 普通用户提交 `ipSpeedId` 返回错误。
|
||||
|
||||
### Repository/API 测试
|
||||
|
||||
1. `CreateForwardTx`、`UpdateForward`、列表查询读写 `ip_max_conn` 和 `ip_speed_id`。
|
||||
2. `/forward/list` 返回 `ipMaxConn`、`ipSpeedId`。
|
||||
|
||||
### GOST/x 测试
|
||||
|
||||
1. `go-gost/x/limiter/conn`:验证 `$$ N` 为不同 IP 创建独立 limiter。
|
||||
2. `go-gost/x/limiter/traffic`:验证 `0.0.0.0/0` 为不同 IPv4 创建独立 limiter。
|
||||
3. UDP listener:验证 Accept 返回的 UDP pseudo-connection 关闭后释放 connection limiter。
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd go-gost/x && go test ./limiter/... ./listener/udp/...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 后端模型、repo DTO、API 字段读写。
|
||||
2. 后端 limiter payload 构建与服务引用重构。
|
||||
3. Contract 测试覆盖连接数和带宽 payload。
|
||||
4. GOST UDP listener per-client wrapper 与相关测试。
|
||||
5. 前端高级设置表单和类型更新。
|
||||
6. 运行后端测试、GOST/x 相关测试、前端构建。
|
||||
|
||||
## 风险
|
||||
|
||||
1. UDP pseudo-connection 生命周期和 TCP 连接不同,连接数释放必须依赖 Close 包装正确执行。
|
||||
2. 总带宽和每 IP 带宽组合时 limiter 名称从纯 speed ID 变为 rule-level 名称,需要确保更新已有规则时不会留下错误引用。
|
||||
3. 旧节点如果没有 UDP wrapper 修复,TCP 生效但 UDP 每 IP 语义可能不完整;发布时应要求 agent 同步升级。
|
||||
4. 每 IP 带宽是每个入口节点本地独立限制,不是跨节点全局聚合限制。
|
||||
@@ -0,0 +1,74 @@
|
||||
# Monitoring Retention And Storage Display Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add an administrator-facing configuration for monitoring data retention and display the current database storage usage in the configuration page.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a single config key: `monitor_retention_days`.
|
||||
- Default retention is `7` days.
|
||||
- Apply the retention window uniformly to:
|
||||
- `node_metric`
|
||||
- `tunnel_metric`
|
||||
- `service_monitor_result`
|
||||
- `tunnel_quality`
|
||||
- Show database usage on the config page as a read-only operational value.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No per-table retention settings.
|
||||
- No manual purge button.
|
||||
- No database vacuum/compaction action.
|
||||
- No frontend test framework changes.
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Retention Config
|
||||
|
||||
- Store `monitor_retention_days` in `vite_config`, consistent with existing site settings.
|
||||
- Accept integer values from `1` through `3650`.
|
||||
- Missing or invalid stored values fall back to `7` days.
|
||||
- `normalizeAndValidateConfigValue` rejects invalid user-submitted values so bad config does not get saved through the API.
|
||||
|
||||
### Cleanup Flow
|
||||
|
||||
- `metrics.IngestionService.pruneMetrics()` reads `monitor_retention_days` from the repository each hourly cleanup cycle.
|
||||
- The computed cutoff is used for `node_metric`, `tunnel_metric`, and `service_monitor_result`.
|
||||
- `tunnel_quality` uses the same retention config.
|
||||
- `tunnel_quality` cleanup must run even when real-time tunnel quality probing is disabled; disabling probing should stop new probe writes, not stop cleanup.
|
||||
|
||||
### Database Storage API
|
||||
|
||||
- Add an admin-only API endpoint for storage summary, for example `/api/v1/system/storage`.
|
||||
- Response fields:
|
||||
- `dbType`: `sqlite` or `postgres`
|
||||
- `databaseSizeBytes`: raw byte count
|
||||
- `databaseSizeText`: human-readable formatted size
|
||||
- SQLite implementation reports the DB file size and includes `-wal` and `-shm` sidecar files when present.
|
||||
- PostgreSQL implementation uses `pg_database_size(current_database())`.
|
||||
- If size cannot be determined, return an API error rather than a misleading zero.
|
||||
|
||||
## Frontend Design
|
||||
|
||||
- Add `monitor_retention_days` to the config page.
|
||||
- Label: `监控数据保留天数`.
|
||||
- Description: `统一清理节点指标、隧道流量、服务监控结果和隧道质量历史;默认 7 天。`
|
||||
- Use a regular numeric input through the existing config rendering path.
|
||||
- Fetch database storage summary when the config page loads.
|
||||
- Display a read-only card/row named `数据库占用` with `databaseSizeText`.
|
||||
- If fetching fails, show `获取失败` and keep config editing usable.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Invalid retention values return a validation error on save.
|
||||
- Cleanup logs individual prune failures and continues with other tables, matching existing monitoring cleanup behavior.
|
||||
- Storage summary failures are non-blocking in the frontend.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend unit tests for retention config parsing and validation.
|
||||
- Backend tests proving custom retention is used by monitoring cleanup.
|
||||
- Backend API/repository test for SQLite storage size returning a non-negative byte count and formatted text.
|
||||
- Run `go test ./...` in `go-backend`.
|
||||
- Run `pnpm run build` in `vite-frontend`.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Best Exit Current Selection Display Design
|
||||
|
||||
## Goal
|
||||
|
||||
When a tunnel uses the `best` multi-exit strategy, show the currently applied best exit in the tunnel list information. Users should be able to see which exit is currently selected without opening logs or diagnosing the tunnel manually.
|
||||
|
||||
The display is informational only. It must not change routing, scoring, switching behavior, or the saved tunnel configuration.
|
||||
|
||||
## Current Context
|
||||
|
||||
- `3.0.0-beta6` adds `best` as a multi-exit strategy.
|
||||
- Runtime selection is stored in the backend `bestExitManager` in memory, keyed by `TunnelID + OwnerNodeID`.
|
||||
- Direct multi-entry tunnels make one independent best-exit decision per entry node.
|
||||
- Tunnels with intermediate chain hops make one independent best-exit decision per final-hop chain node before the exits.
|
||||
- `tunnelList` and `tunnelGet` currently return `repo.ListTunnels()` output directly, so frontend tunnel data only includes configured exits from the database, not the currently applied runtime choice.
|
||||
- The frontend tunnel page maps API items in `vite-frontend/src/pages/tunnel.tsx` and renders list information from that data.
|
||||
|
||||
## User Decisions
|
||||
|
||||
- Show the current best-exit choice in the tunnel list information.
|
||||
- Use a summary plus detail model for multiple owners.
|
||||
- Follow the existing tunnel list refresh cadence; do not add polling or a realtime stream in this phase.
|
||||
- Work text-only; no visual companion is needed.
|
||||
|
||||
## Approach
|
||||
|
||||
Extend the existing tunnel list/detail response with a lightweight runtime state object for `best` tunnels, then render that state beside the tunnel's exit/strategy information in the existing frontend list UI.
|
||||
|
||||
This keeps the display close to the data users already inspect and avoids a separate API or extra frontend request.
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Response Shape
|
||||
|
||||
Add a `bestExitState` object to each tunnel item returned by `tunnelList` and `tunnelGet` when the tunnel has a multi-exit group whose strategy is `best`.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"summary": "香港节点",
|
||||
"status": "applied",
|
||||
"updatedAt": 1777584000000,
|
||||
"reason": "current exit remains best",
|
||||
"items": [
|
||||
{
|
||||
"ownerNodeId": 10,
|
||||
"ownerNodeName": "入口 A",
|
||||
"ownerRole": "entry",
|
||||
"exitNodeId": 30,
|
||||
"exitNodeName": "香港节点",
|
||||
"updatedAt": 1777584000000,
|
||||
"reason": "current exit remains best"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If the tunnel is not using `best`, omit `bestExitState` or set it to `null`.
|
||||
|
||||
### Owner Semantics
|
||||
|
||||
The display must match the routing model:
|
||||
|
||||
- If there are no middle chain hops, each entry node is an owner.
|
||||
- If there are middle chain hops, each node in the final middle-hop group is an owner.
|
||||
|
||||
Each owner can have a different current best exit. The UI must not imply that a multi-owner tunnel has one global best exit when the owners differ.
|
||||
|
||||
### Summary Rules
|
||||
|
||||
- If all owners currently apply the same exit, `summary` is that exit node name.
|
||||
- If owners apply different exits, `summary` is `多个出口`.
|
||||
- If no applied decision exists yet, `summary` is `等待探测`.
|
||||
- If the tunnel has only one exit, `bestExitState` is not needed because there is no dynamic choice.
|
||||
|
||||
### State Source
|
||||
|
||||
Use the in-memory `bestExitManager` as the source of currently applied decisions.
|
||||
|
||||
Add a read-only snapshot method that returns defensive copies of decision state without exposing mutable internal slices. The handler should convert node IDs to display names from the existing tunnel response data first, then fall back to `h.getNodeRecord` only when the current response does not contain the node.
|
||||
|
||||
The feature should not persist current choices to the database in this phase. A panel restart may reset the displayed runtime state to `等待探测` until the prober initializes it again from the current saved first exit.
|
||||
|
||||
## Frontend Design
|
||||
|
||||
Extend the tunnel item type with optional `bestExitState`.
|
||||
|
||||
In the tunnel list, only render the current best-exit display when:
|
||||
|
||||
- `bestExitState.enabled === true`, or
|
||||
- the tunnel has an exit group with `strategy === "best"` and the backend returns a waiting state.
|
||||
|
||||
Display format:
|
||||
|
||||
- Single applied exit: `最优出口:香港节点`
|
||||
- Multiple applied exits: `最优出口:多个出口`
|
||||
- Waiting: `最优出口:等待探测`
|
||||
|
||||
For multiple owners, render the summary as compact secondary text in the topology/list information cell and set its native `title` attribute to newline-separated detail rows. This avoids adding a new UI dependency or a custom popover. Detail rows should use:
|
||||
|
||||
```text
|
||||
入口 A -> 香港节点
|
||||
入口 B -> 日本节点
|
||||
```
|
||||
|
||||
For tunnels with middle chain hops, label owners as chain nodes when useful:
|
||||
|
||||
```text
|
||||
中转 M1 -> 香港节点
|
||||
中转 M2 -> 日本节点
|
||||
```
|
||||
|
||||
Do not add a new periodic refresh. The display updates when the existing tunnel list is refreshed.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the manager has no decision for an owner, show that owner as `等待探测`.
|
||||
- If an exit node ID no longer exists in the current tunnel response, show `未知出口` for that item and keep the list usable.
|
||||
- If an owner node ID no longer exists, show `未知入口` or `未知中转` based on the owner role.
|
||||
- If the backend cannot compute state for one tunnel, omit `bestExitState` for that tunnel and log the error; do not fail the whole tunnel list response.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- `bestExitManager` snapshot returns applied exit IDs without exposing mutable manager state.
|
||||
- Direct multi-entry `best` tunnel produces one display item per entry owner.
|
||||
- Middle-hop tunnel produces one display item per final-hop owner.
|
||||
- Summary is the single exit name when all owners choose the same exit.
|
||||
- Summary is `多个出口` when owners choose different exits.
|
||||
- Summary is `等待探测` when no applied decision exists.
|
||||
- Non-`best` tunnels do not receive `bestExitState`.
|
||||
|
||||
Frontend verification:
|
||||
|
||||
- Tunnel list renders `最优出口:<name>` for a single applied exit.
|
||||
- Tunnel list renders `最优出口:多个出口` plus owner details for multiple applied exits.
|
||||
- Tunnel list renders `最优出口:等待探测` for waiting state.
|
||||
- `pnpm run build` passes.
|
||||
|
||||
Verification commands:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add a new realtime stream or polling loop.
|
||||
- Do not add a detailed best-exit scoring dashboard.
|
||||
- Do not persist current best-exit choices to the database.
|
||||
- Do not change switching thresholds, probing targets, or runtime chain update behavior.
|
||||
- Do not change existing non-`best` tunnel display behavior.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Best Exit Selection Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a multi-exit tunnel strategy named `best` that always sends new connections through the currently best-quality exit. The feature should prevent traffic from continuing to use an exit whose latency or packet loss has degraded while the exit is still technically online.
|
||||
|
||||
Existing connections must not be interrupted. Switching affects only new connections created after the runtime chain update is applied.
|
||||
|
||||
## Current Context
|
||||
|
||||
- Tunnel forwarding stores entry, chain, and exit nodes in `chain_tunnel`.
|
||||
- Multi-exit runtime chains are currently rendered as one GOST hop with multiple nodes.
|
||||
- GOST selectors support `fifo`, `round`, `rand`, and `hash`, plus fail filtering through `maxFails` and `failTimeout`.
|
||||
- The current fail filter only reacts to dial, handshake, or transport failures. It does not react to high latency when the exit is still reachable.
|
||||
- `tunnel_quality_prober` already runs panel-side TCP probes and stores tunnel quality history, but it currently probes representative nodes and does not drive runtime routing decisions.
|
||||
|
||||
## User Decisions
|
||||
|
||||
- Add a `best` option for multi-exit tunnels.
|
||||
- `best` means always choose the current best exit for new connections.
|
||||
- Score exits by end-to-end quality.
|
||||
- Keep the existing public probe target: `www.bing.com:443`.
|
||||
- Do not disrupt established connections.
|
||||
|
||||
## Approach
|
||||
|
||||
Implement `best` as a panel-driven control-plane strategy.
|
||||
|
||||
The database stores the user's intended strategy as `best`. When the panel renders runtime GOST config for a `best` exit group, it sends a GOST selector strategy of `fifo`. The panel dynamically sorts the candidate exits so the current best exit is first. GOST then chooses the first node for new connections.
|
||||
|
||||
This avoids adding active probing logic inside every GOST agent and reuses the existing panel-to-agent command path.
|
||||
|
||||
## Components
|
||||
|
||||
### Frontend
|
||||
|
||||
The tunnel form adds `最优` to the multi-exit load strategy selector.
|
||||
|
||||
- Label: `最优`
|
||||
- Value: `best`
|
||||
- Scope: tunnel forwarding exit groups, alongside `主备/fifo`, `轮询/round`, and `随机/rand`
|
||||
- Create and edit forms must submit and restore `best` unchanged.
|
||||
|
||||
### Backend Data Model
|
||||
|
||||
No schema change is required.
|
||||
|
||||
The existing `chain_tunnel.strategy` column stores `best`. Repository and handler paths should preserve the value in API responses and updates.
|
||||
|
||||
### Runtime Chain Rendering
|
||||
|
||||
When building runtime chain config:
|
||||
|
||||
- If the configured strategy is not `best`, keep existing behavior.
|
||||
- If the configured strategy is `best`, emit GOST selector strategy `fifo`.
|
||||
- Sort the target nodes using the panel's latest best-exit decision before rendering the node list.
|
||||
- If no quality decision exists yet, keep the saved node order.
|
||||
|
||||
This preserves the user's `best` intent in storage while using a GOST selector that can execute the panel's sorted decision.
|
||||
|
||||
### Quality Prober
|
||||
|
||||
Extend `tunnel_quality_prober` to evaluate all candidates in `best` exit groups.
|
||||
|
||||
For each chain owner node and candidate exit, measure:
|
||||
|
||||
- Chain owner node to candidate exit using TCP ping.
|
||||
- Candidate exit to `www.bing.com:443` using TCP ping.
|
||||
|
||||
For direct entry-to-exit tunnels, each entry node owns its own chain decision. For tunnels with intermediate chain hops, each node in the last hop group before the exits owns its own chain decision. This allows different entry or chain nodes to choose different best exits when their path quality differs.
|
||||
|
||||
### Scoring
|
||||
|
||||
Each exit candidate gets an end-to-end score for a specific chain owner node.
|
||||
|
||||
- Total latency is the sum of owner-to-exit latency and exit-to-Bing latency.
|
||||
- Total loss combines both legs by success probability: `1 - (1 - lossA) * (1 - lossB)`.
|
||||
- Failed or unreachable candidates are sorted behind successful candidates.
|
||||
- The score should heavily penalize packet loss so that low-latency but lossy exits are not selected over stable exits.
|
||||
|
||||
A practical scoring formula can be:
|
||||
|
||||
```text
|
||||
score = totalLatencyMs + (totalLossPercent * lossPenaltyMsPerPercent)
|
||||
```
|
||||
|
||||
Use `lossPenaltyMsPerPercent = 100` initially. For example, 5% loss adds 500ms to the score.
|
||||
|
||||
### Switching Rules
|
||||
|
||||
The panel should not update chains on every probe round.
|
||||
|
||||
Switch only when all conditions are true:
|
||||
|
||||
- The candidate best exit is different from the currently applied first exit.
|
||||
- The candidate is successful.
|
||||
- The candidate remains best for consecutive probe rounds.
|
||||
- The candidate beats the current exit by a minimum advantage threshold.
|
||||
- The chain owner node has passed a minimum switch cooldown.
|
||||
|
||||
Initial constants:
|
||||
|
||||
- Consecutive confirmations: 3 rounds.
|
||||
- Switch cooldown: 30 seconds per chain owner node.
|
||||
- Minimum advantage: the candidate score must improve by at least `max(20ms, currentScore * 0.15)`.
|
||||
|
||||
If all exits fail, keep the current runtime order and do not issue a destructive update.
|
||||
|
||||
### Runtime Update
|
||||
|
||||
When a `best` chain owner node changes best exit:
|
||||
|
||||
1. Rebuild that node's `chains_<tunnelID>` payload with the best exit first and remaining candidates sorted by quality for that node.
|
||||
2. Send `UpdateChains` to that chain owner node.
|
||||
3. Do not restart or update tunnel services.
|
||||
4. Record success or failure in logs and in the in-memory decision state.
|
||||
|
||||
This affects only future connections. Existing TCP connections keep using the `net.Conn` created before the update and continue through their original exit.
|
||||
|
||||
### Agent Safety Improvement
|
||||
|
||||
The current agent `UpdateChains` path unregisters the old chain before registering the new chain. This does not kill existing connections, but it creates a small window where a new connection can fail because the chain name is temporarily absent.
|
||||
|
||||
Improve the update path so it parses the new chain first and only replaces the registered chain after parsing succeeds. The replacement window should be as small as possible. If parsing fails, the old chain must remain active.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If probing one candidate fails, continue scoring other candidates.
|
||||
- If a chain owner node is offline or times out, skip decisions for that owner during the round instead of marking every candidate failed.
|
||||
- If a candidate has no successful required probe data, mark it failed for that round.
|
||||
- If `UpdateChains` fails, keep the current applied order and retry on a later round.
|
||||
- If the tunnel has one exit or an incomplete config, `best` behaves like the saved order and does not trigger dynamic switching.
|
||||
- If `monitor_tunnel_quality_enabled=false`, dynamic `best` switching pauses. The last applied runtime order remains in effect.
|
||||
|
||||
## Observability
|
||||
|
||||
The prober should maintain in-memory decision state per `best` tunnel and chain owner node.
|
||||
|
||||
Useful fields:
|
||||
|
||||
- Tunnel ID and chain owner node ID.
|
||||
- Current applied best exit node ID.
|
||||
- Candidate best exit node ID.
|
||||
- Candidate scores.
|
||||
- Last switch timestamp.
|
||||
- Last switch result.
|
||||
- Reason for not switching, such as cooldown, insufficient advantage, candidate unstable, or all exits failed.
|
||||
|
||||
Initial UI scope is limited to supporting create, update, and display of the `best` strategy. A later enhancement can expose current best exit and candidate scores in the tunnel monitor view.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Score calculation orders candidates by latency and packet loss.
|
||||
- Packet loss penalty prevents lossy exits from winning only because latency is low.
|
||||
- All-failed candidates do not trigger a switch.
|
||||
- Consecutive confirmation and cooldown prevent flapping.
|
||||
- `strategy=best` persists in `chain_tunnel.strategy` and is returned by tunnel list/get APIs.
|
||||
- Runtime rendering maps `best` to GOST `fifo` and places the chosen best exit first.
|
||||
|
||||
Agent tests:
|
||||
|
||||
- `UpdateChains` parse failure keeps the old chain registered.
|
||||
- Successful `UpdateChains` updates the chain used by new connections.
|
||||
|
||||
Frontend verification:
|
||||
|
||||
- Tunnel form includes `最优` in the exit strategy selector.
|
||||
- Existing tunnels with `strategy=best` render correctly.
|
||||
- Create and update requests submit `best` unchanged.
|
||||
|
||||
Verification commands:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd go-gost && go test ./...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not move existing live connections to a new exit.
|
||||
- Do not add per-tunnel custom probe targets in this phase.
|
||||
- Do not implement active best-exit probing inside GOST agents.
|
||||
- Do not add a detailed best-exit UI dashboard in this phase.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Custom Best-Exit Probe Target Design
|
||||
|
||||
Date: 2026-05-01
|
||||
Status: Approved design
|
||||
|
||||
## Goal
|
||||
|
||||
Allow each tunnel to define the TCP target used for exit-side quality probing instead of always probing `www.bing.com:443`.
|
||||
|
||||
The custom target must be used consistently by:
|
||||
|
||||
- `best` exit scoring: each exit probes the configured target to measure exit-to-public quality.
|
||||
- Tunnel quality monitoring: the existing exit-side quality check probes the same configured target.
|
||||
|
||||
If a tunnel does not configure a target, behavior remains compatible with today: `www.bing.com:443`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add HTTP/HTTPS request probing in this phase. The probe remains TCP host/port measurement.
|
||||
- Do not add a global default target setting in this phase.
|
||||
- Do not require existing tunnels to be edited or migrated manually.
|
||||
- Do not change the `best` switching thresholds, confirmation rounds, cooldowns, or runtime chain ordering semantics.
|
||||
- Do not add frontend test infrastructure.
|
||||
|
||||
## User-Facing Behavior
|
||||
|
||||
Each tunnel form gets a compact quality target section:
|
||||
|
||||
- Host input, placeholder `www.bing.com`.
|
||||
- Port input, placeholder `443`.
|
||||
- Helper text: this target is used for tunnel quality detection and `best` optimal-exit scoring; leaving it empty uses `www.bing.com:443`.
|
||||
|
||||
Tunnel list/get responses include the configured target so edit forms can round-trip it. The UI displays the effective target near quality/best-exit information as `测试目标:host:port`.
|
||||
|
||||
## Data Model
|
||||
|
||||
Add nullable/default-compatible fields to `model.Tunnel`:
|
||||
|
||||
- `ProbeTargetHost string` mapped to `probe_target_host`, `type:text`, default `''`.
|
||||
- `ProbeTargetPort int` mapped to `probe_target_port`, default `0`.
|
||||
|
||||
Effective target resolution:
|
||||
|
||||
- If `ProbeTargetHost` is non-empty and `ProbeTargetPort` is valid, use it.
|
||||
- Otherwise use `www.bing.com:443`.
|
||||
|
||||
The existing `TunnelQuality` persisted fields `exit_to_bing_latency` and `exit_to_bing_loss` remain unchanged for compatibility. They will semantically mean exit-to-configured-test-target after this change. API/UI labels should avoid saying `Bing` for new displays.
|
||||
|
||||
## Validation
|
||||
|
||||
On create/update:
|
||||
|
||||
- Empty host and empty/zero port are allowed and mean default target.
|
||||
- If either host or port is set, validate both as a pair.
|
||||
- Host is trimmed and must not contain URL scheme, path, query, or whitespace.
|
||||
- Host can be a domain, IPv4, or IPv6 literal. Bracketed IPv6 input should be normalized by removing surrounding brackets.
|
||||
- Port must be an integer from `1` to `65535`.
|
||||
- Do not perform network probing during save; external network failures must not block configuration changes.
|
||||
|
||||
Errors should be specific, for example:
|
||||
|
||||
- `测试目标 Host 不能为空`
|
||||
- `测试目标端口必须是 1-65535`
|
||||
- `测试目标 Host 不能包含协议或路径`
|
||||
|
||||
## Backend Flow
|
||||
|
||||
Introduce a small value/helper near the tunnel quality and best-exit code:
|
||||
|
||||
```go
|
||||
type tunnelProbeTarget struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
```
|
||||
|
||||
Helpers:
|
||||
|
||||
- `defaultTunnelProbeTarget() tunnelProbeTarget` returns `www.bing.com:443`.
|
||||
- `normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, error)` validates user input; the boolean indicates whether the user explicitly configured a target.
|
||||
- `effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget` returns configured target or default.
|
||||
|
||||
Use the effective target in `tunnelQualityProber.probeTunnel`:
|
||||
|
||||
- Type 1 and unknown tunnel fallback probes entry node to effective target instead of hardcoded Bing.
|
||||
- Type 2 probes the selected/current exit node to effective target instead of hardcoded Bing.
|
||||
- `probeBestExitOwners` receives the effective target and passes it into best-exit owner scoring.
|
||||
|
||||
Use the effective target in `evaluateBestExitOwner`:
|
||||
|
||||
- Owner-to-exit measurement stays unchanged.
|
||||
- Exit-to-public measurement probes `target.Host:target.Port` instead of `bestExitPublicTargetHost:bestExitPublicTargetPort`.
|
||||
- The per-round public probe cache key must include node ID plus target host and port so future extensions cannot reuse measurements across different targets.
|
||||
|
||||
## API Shape
|
||||
|
||||
Tunnel list/get data includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"probeTargetHost": "example.com",
|
||||
"probeTargetPort": 443
|
||||
}
|
||||
```
|
||||
|
||||
For old/default tunnels, return empty host and `0` to represent `use default`. The edit form must preserve default-as-empty unless the user explicitly saves a custom target.
|
||||
|
||||
Quality monitoring response includes effective target display metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"probeTargetHost": "www.bing.com",
|
||||
"probeTargetPort": 443
|
||||
}
|
||||
```
|
||||
|
||||
Existing `exitToBingLatency` and `exitToBingLoss` keys stay to avoid breaking frontend and external consumers.
|
||||
|
||||
## Frontend Flow
|
||||
|
||||
Extend `ChainTunnel` only if needed for node-level data; the target belongs to the tunnel, so `Tunnel` and `TunnelForm` get:
|
||||
|
||||
- `probeTargetHost?: string`
|
||||
- `probeTargetPort?: number`
|
||||
|
||||
On edit:
|
||||
|
||||
- Populate form fields from tunnel response.
|
||||
- Empty or zero means default target.
|
||||
|
||||
On submit:
|
||||
|
||||
- Trim host.
|
||||
- Convert blank port to `0`.
|
||||
- Send `probeTargetHost` and `probeTargetPort` with create/update payload.
|
||||
|
||||
Display:
|
||||
|
||||
- In the form helper, show default target behavior.
|
||||
- In quality/best-exit display areas, avoid `Bing` wording; prefer `测试目标` or the concrete `host:port`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Invalid target input returns a normal API error envelope with a specific message.
|
||||
- Probe failures use existing quality error paths and best-exit scoring failure entries.
|
||||
- If all exit-to-target probes fail, best-exit behavior remains the same as today when all Bing probes fail: no valid best decision is applied from that round.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Normalize default target when host/port are empty.
|
||||
- Reject partial host/port configuration and invalid port ranges.
|
||||
- Reject host values with URL scheme/path/whitespace.
|
||||
- Create/update tunnel persists `probeTargetHost` and `probeTargetPort`.
|
||||
- `ListTunnels` returns target fields.
|
||||
- `tunnelQualityProber` uses configured target instead of `www.bing.com:443`.
|
||||
- `best` scoring uses configured target for exit-to-target probes.
|
||||
- Empty target preserves old default `www.bing.com:443` behavior.
|
||||
|
||||
Frontend verification:
|
||||
|
||||
- `pnpm run build` passes.
|
||||
- Manual UI check: create/edit tunnel with blank target and custom target, confirm payload and round-trip display.
|
||||
|
||||
## Rollout And Compatibility
|
||||
|
||||
- Existing tunnels continue using `www.bing.com:443` because empty target resolves to default.
|
||||
- SQLite/PostgreSQL schema changes are handled by existing auto-migration.
|
||||
- Historical `TunnelQuality` rows keep existing columns and are not rewritten.
|
||||
- No runtime agent change is required; the panel already performs these quality probes through existing node ping APIs.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
None. User-approved decisions:
|
||||
|
||||
- Per-tunnel fields are `host + port`.
|
||||
- The target applies to both `best` scoring and tunnel quality monitoring.
|
||||
- Probe type remains TCP host/port.
|
||||
- Empty target defaults to `www.bing.com:443`.
|
||||
@@ -0,0 +1,295 @@
|
||||
# 面板本体一键升级设计
|
||||
|
||||
**日期**: 2026-05-04
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
在 FLVX 管理面板中增加“面板升级”能力,使管理员可以在网页上检查 GitHub Release 并触发面板本体升级。目标是升级整套面板,而不是只升级转发节点或只替换后端二进制。
|
||||
|
||||
本设计采用 Docker Compose 整套升级方案:后端容器通过受限的 Docker socket 能力更新宿主机部署目录中的 `docker-compose.yml` 和 `.env`,然后启动独立的升级 helper 容器,由 helper 拉取新版 backend/frontend 镜像并重新启动 `backend` 与 `frontend` 服务。
|
||||
|
||||
## sub2api 参考结论
|
||||
|
||||
sub2api 的一键升级不是在宿主机执行 `docker compose pull/up`。它的运行形态是单体 Go 服务:前端构建产物 embed 到后端二进制,容器内只运行 `/app/sub2api`。升级接口下载 GitHub Release 中匹配当前系统和架构的 `sub2api_<version>_<os>_<arch>.tar.gz` 以及 `checksums.txt`,校验后把当前 `os.Executable()` 指向的 `/app/sub2api` 改名为 `/app/sub2api.backup`,再把新二进制原子替换到原路径。重启接口延迟调用 `os.Exit(0)`,依赖 Docker Compose 的 `restart: unless-stopped` 拉起同一个容器。
|
||||
|
||||
这种方式在 sub2api 的 Docker 部署中可行,是因为它的前后端在同一个二进制里。FLVX 当前是 `flux-panel-backend` 与 `vite-frontend` 两个容器,替换 `/app/paneld` 只能升级后端,不能升级前端页面。因此 FLVX 的“面板本体升级”需要更新 Compose 版本和两个镜像,而不是照搬二进制替换。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 管理员可在面板上查看当前版本、最新版本、升级通道和升级能力状态。
|
||||
2. 管理员可一键升级整套面板 backend/frontend。
|
||||
3. 升级复用现有 GitHub Release 和 `FLUX_VERSION` 版本机制。
|
||||
4. 升级复用现有 GitHub 加速配置 `github_proxy_enabled` / `github_proxy_url`。
|
||||
5. 升级过程不接受任意命令、任意 URL 或任意 compose 路径。
|
||||
6. 环境不满足时清晰提示不可用原因,不静默失败。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不实现 sub2api 式后端二进制替换作为本次主路径。
|
||||
2. 不支持从非本仓库 Release 下载升级资产。
|
||||
3. 不支持普通用户触发升级。
|
||||
4. 不支持在前端执行 shell 命令。
|
||||
5. 不修改 `install.sh` 或 `panel_install.sh` 的本地安装菜单逻辑;发布流程仍可能覆盖这些脚本。
|
||||
6. 不引入前端测试框架。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 后端
|
||||
|
||||
- `go-backend/internal/http/handler/handler.go`
|
||||
- `go-backend/internal/http/handler/upgrade.go`
|
||||
- 新增 `go-backend/internal/http/handler/system_upgrade.go`
|
||||
- 新增 `go-backend/internal/http/handler/system_upgrade_test.go`
|
||||
- `go-backend/Dockerfile`
|
||||
|
||||
### 部署模板
|
||||
|
||||
- `docker-compose-v4.yml`
|
||||
- `docker-compose-v6.yml`
|
||||
|
||||
### 前端
|
||||
|
||||
- `vite-frontend/src/api/index.ts`
|
||||
- `vite-frontend/src/api/types.ts`
|
||||
- `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
## 运行前提
|
||||
|
||||
升级能力仅在 Docker Compose 部署中可用,并要求后端容器具备以下条件:
|
||||
|
||||
1. 容器内存在 Docker CLI,且支持 `docker compose version`。
|
||||
2. `/var/run/docker.sock` 挂载到后端容器。
|
||||
3. 宿主部署目录挂载到容器内固定路径,例如 `/opt/flvx-panel`。
|
||||
4. 环境变量 `PANEL_DEPLOY_DIR=/opt/flvx-panel`。
|
||||
5. 环境变量 `PANEL_BACKEND_CONTAINER=flux-panel-backend`,为空时默认使用 `flux-panel-backend`;值必须匹配容器名安全字符集 `[A-Za-z0-9_.-]+`。
|
||||
6. 部署目录内存在 `.env` 和 `docker-compose.yml`。
|
||||
|
||||
如果任一条件不满足,检查接口返回 `capable=false` 和明确的 `reason`,升级按钮禁用。
|
||||
|
||||
## 后端设计
|
||||
|
||||
### API
|
||||
|
||||
新增系统升级接口,路径使用 `/api/v1/system/*`,继续受现有 middleware 管控,仅管理员可访问。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/v1/system/version` | 返回当前版本、升级通道、能力状态和可选最新版本 |
|
||||
| `POST` | `/api/v1/system/check-updates` | 强制查询 GitHub Release,返回最新版本和候选列表 |
|
||||
| `POST` | `/api/v1/system/upgrade` | 执行升级 |
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "stable",
|
||||
"version": ""
|
||||
}
|
||||
```
|
||||
|
||||
`channel` 使用现有节点升级的通道语义:`stable` 匹配纯数字版本,`dev` 匹配 `alpha` / `beta` / `rc`。`version` 为空时自动选择该通道最新 Release。
|
||||
|
||||
`/api/v1/system/version` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"currentVersion": "2.1.9-beta14",
|
||||
"channel": "stable",
|
||||
"latestVersion": "2.1.9",
|
||||
"hasUpdate": true,
|
||||
"capable": true,
|
||||
"reason": "",
|
||||
"deployDir": "/opt/flvx-panel",
|
||||
"composeFile": "/opt/flvx-panel/docker-compose.yml",
|
||||
"backendContainer": "flux-panel-backend"
|
||||
}
|
||||
```
|
||||
|
||||
`/api/v1/system/upgrade` 成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.1.9",
|
||||
"message": "升级 helper 已启动,面板服务将短暂重启",
|
||||
"commands": [
|
||||
"docker run -d --rm --volumes-from flux-panel-backend ...",
|
||||
"docker compose pull backend frontend",
|
||||
"docker compose up -d backend frontend"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
返回的 `commands` 只用于 UI 展示固定步骤,不包含用户输入或 shell 拼接结果。
|
||||
|
||||
### 版本来源
|
||||
|
||||
当前版本优先从容器环境变量读取:
|
||||
|
||||
1. `FLUX_VERSION`
|
||||
2. `VITE_APP_VERSION` 不在后端容器中可靠存在,不作为后端版本来源。
|
||||
3. 为空时返回 `dev`。
|
||||
|
||||
发布流程已经在 `panel_install.sh` 写入 `.env` 的 `FLUX_VERSION`,Compose 模板需要把该变量传给 backend 容器,保证后端可感知当前版本。
|
||||
|
||||
### Release 查询
|
||||
|
||||
复用现有 `fetchGitHubReleases`、`resolveLatestReleaseByChannel`、`normalizeReleaseChannel`、`releaseChannelFromTag`、`releaseChannelLabel` 和 GitHub 加速配置能力。新增函数只负责筛选系统升级所需资产:
|
||||
|
||||
- `docker-compose-v4.yml`
|
||||
- `docker-compose-v6.yml`
|
||||
|
||||
是否下载 v4/v6 compose 文件通过当前部署目录中的 `docker-compose.yml` 判断:如果网络定义包含 `enable_ipv6: true`,选择 `docker-compose-v6.yml`;否则选择 `docker-compose-v4.yml`。
|
||||
|
||||
### 升级执行器
|
||||
|
||||
新增 `systemUpgradeExecutor`,职责明确分为可测试的小函数:
|
||||
|
||||
1. `checkSystemUpgradeCapability()` 检查 Docker CLI、Docker socket、部署目录、`.env`、`docker-compose.yml`。
|
||||
2. `selectComposeAsset(currentCompose []byte) string` 选择 v4/v6 compose 资产。
|
||||
3. `updateEnvVersion(path, version string) error` 原子更新 `.env` 中的 `FLUX_VERSION`。
|
||||
4. `downloadCompose(version, assetName, dest string) error` 下载新版 compose 模板到临时文件。
|
||||
5. `currentBackendImage(containerName string) (string, error)` 获取当前 backend 容器镜像 ID。
|
||||
6. `startSystemUpgradeHelper(version string) error` 启动独立 helper 容器执行固定升级流程。
|
||||
|
||||
升级流程:
|
||||
|
||||
1. 获取全局升级锁,拒绝并发升级。
|
||||
2. 校验目标版本存在且不是 draft。
|
||||
3. 检查升级能力。
|
||||
4. 备份 `.env` 为 `.env.upgrade.bak`,备份 `docker-compose.yml` 为 `docker-compose.yml.upgrade.bak`。
|
||||
5. 下载目标版本的 compose 文件到部署目录临时文件。
|
||||
6. 原子替换 `docker-compose.yml`。
|
||||
7. 原子更新 `.env` 的 `FLUX_VERSION`。
|
||||
8. 通过 Docker socket 查询当前 backend 容器的镜像 ID。
|
||||
9. 使用当前 backend 镜像启动一个不属于 Compose 项目的临时 helper 容器。
|
||||
10. helper 通过 `--volumes-from flux-panel-backend` 继承部署目录挂载,并显式挂载 `/var/run/docker.sock`。
|
||||
11. helper 在 `PANEL_DEPLOY_DIR` 下执行 `docker compose pull backend frontend`。
|
||||
12. helper 等待 5 秒,让 SQLite WAL 等文件刷盘。
|
||||
13. helper 执行 `docker compose up -d backend frontend`,由 Compose 重建前端和后端。
|
||||
14. 后端接口在 helper 成功启动后立即返回;浏览器随后会经历短暂断线。
|
||||
|
||||
PostgreSQL 模式不主动 pull 或重建 `postgres` 服务,避免无关数据库变动。新版 compose 文件仍保留 postgres 配置供后续手动迁移或重建使用。
|
||||
|
||||
### 命令安全
|
||||
|
||||
后端不暴露通用命令执行能力。后端只直接执行 Docker CLI 的固定参数,用于获取当前镜像和启动 helper:
|
||||
|
||||
```go
|
||||
exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", backendContainer)
|
||||
exec.CommandContext(ctx, "docker", "run", "-d", "--rm", "--name", helperName,
|
||||
"--volumes-from", backendContainer,
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
|
||||
"--entrypoint", "/bin/sh", imageID,
|
||||
"-c", helperScript)
|
||||
```
|
||||
|
||||
`helperScript` 由后端固定生成,不拼接用户输入:
|
||||
|
||||
```sh
|
||||
cd "$PANEL_DEPLOY_DIR" && docker compose pull backend frontend && sleep 5 && docker compose up -d backend frontend
|
||||
```
|
||||
|
||||
工作目录固定为 `PANEL_DEPLOY_DIR`。`PANEL_DEPLOY_DIR` 必须是绝对路径,且必须包含 `.env` 和 `docker-compose.yml`。接口输入只允许影响 `channel` 和已验证的 Release `version`。
|
||||
|
||||
### 超时和错误处理
|
||||
|
||||
1. Release 查询超时沿用现有 GitHub API 客户端超时。
|
||||
2. 下载 compose 文件使用 60 秒超时。
|
||||
3. 启动 helper 使用 30 秒超时,helper 内部命令不受原 HTTP 请求生命周期影响。
|
||||
4. 任一步失败时返回错误信息,并尽量保留 `.upgrade.bak` 供人工恢复。
|
||||
5. 如果 `.env` 更新后后续步骤失败,不自动回滚镜像或容器,避免误判导致更大破坏;错误信息提示备份文件位置。
|
||||
|
||||
## 部署模板设计
|
||||
|
||||
`docker-compose-v4.yml` 和 `docker-compose-v6.yml` 的 backend 服务增加:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./:/opt/flvx-panel
|
||||
```
|
||||
|
||||
`go-backend/Dockerfile` 的 runtime 镜像通过多阶段构建从官方 `docker:27-cli` 镜像复制 Docker CLI 和 compose 插件到 Debian runtime 镜像,避免依赖 Debian apt 源中的 Docker 包可用性:
|
||||
|
||||
```dockerfile
|
||||
FROM docker:27-cli AS dockercli
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
|
||||
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
|
||||
```
|
||||
|
||||
实现时保留现有 Go builder 和 `/app/paneld` 入口,仅增加 Docker CLI stage 和复制步骤。
|
||||
|
||||
helper 容器使用当前 backend 容器的镜像 ID 启动,而不是额外依赖 `docker:cli` 镜像。这样不引入新的镜像仓库依赖,并保证 helper 内可用的 Docker CLI 与当前后端一致。
|
||||
|
||||
## 前端设计
|
||||
|
||||
在 `vite-frontend/src/pages/config.tsx` 的基本设置或数据库占用附近增加“面板升级”卡片,避免隐藏在节点页导致误解为“节点升级”。
|
||||
|
||||
展示内容:
|
||||
|
||||
1. 当前版本。
|
||||
2. 最新版本。
|
||||
3. 更新通道选择,复用现有 `stable` / `dev` 语义和 `UpdateReleaseChannel` 本地存储。
|
||||
4. 升级能力状态:可用、不可用原因、Docker socket 高权限提示。
|
||||
5. 操作按钮:检查更新、立即升级。
|
||||
|
||||
交互:
|
||||
|
||||
1. 页面加载时调用 `/system/version`。
|
||||
2. 点击“检查更新”调用 `/system/check-updates`。
|
||||
3. 点击“立即升级”前弹出确认框,明确提示服务会短暂中断,并提示 Docker socket 具备宿主高权限。
|
||||
4. 升级请求只等待 helper 启动,超时设置为 60 秒。
|
||||
5. 成功后 toast 显示“升级已触发,面板将在数十秒内重启”,并可提示用户稍后刷新。
|
||||
|
||||
## 安全边界
|
||||
|
||||
Docker socket 挂载等同于给后端容器宿主机级别控制能力。这是本设计的主要风险。缓解措施:
|
||||
|
||||
1. 仅 `/api/v1/system/*` 管理员接口可触发。
|
||||
2. 不提供任意命令执行接口。
|
||||
3. 不允许用户传入下载 URL。
|
||||
4. 不允许用户传入 compose 路径。
|
||||
5. 只升级本仓库 GitHub Release,且跳过 draft。
|
||||
6. 前端明确展示 Docker socket 权限提示。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### Go 单测
|
||||
|
||||
新增 `system_upgrade_test.go` 覆盖:
|
||||
|
||||
1. `selectComposeAsset` 对 v4/v6 compose 内容的判断。
|
||||
2. `.env` 中已有 `FLUX_VERSION` 时更新值。
|
||||
3. `.env` 中缺少 `FLUX_VERSION` 时追加值。
|
||||
4. 缺少部署目录、`.env`、`docker-compose.yml`、Docker socket 时返回不可用原因。
|
||||
5. helper 命令构造固定命令序列,不拼接用户输入。
|
||||
6. 并发升级锁会拒绝第二个升级请求。
|
||||
|
||||
### 手动/集成验证
|
||||
|
||||
1. `go-backend`: `go test ./...`
|
||||
2. `vite-frontend`: `pnpm run build`
|
||||
3. 本地容器验证:启动 Compose 后检查设置页升级卡片可显示能力状态。
|
||||
4. 在无 Docker socket 的开发环境验证按钮禁用并显示原因。
|
||||
|
||||
## 回滚与恢复
|
||||
|
||||
自动升级失败时不做自动容器回滚。后端会保留:
|
||||
|
||||
1. `.env.upgrade.bak`
|
||||
2. `docker-compose.yml.upgrade.bak`
|
||||
|
||||
人工恢复步骤由错误信息提示:进入部署目录,按需恢复备份文件,再执行 `docker compose up -d backend frontend`。
|
||||
|
||||
## 决策记录
|
||||
|
||||
本设计已确定采用 Docker socket 整套升级方案,不再保留二进制替换作为本次实现路径。Docker socket 的权限风险通过管理员限制、命令白名单和前端提示控制。
|
||||
@@ -0,0 +1,377 @@
|
||||
# FLVX 安全问题修复设计
|
||||
|
||||
**日期**: 2026-05-13
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
针对 PR #502 提到的安全问题,对 FLVX 后端认证、配置访问控制、配置写入保护、备份导出和 JWT 失效模型做一次集中修复。目标是优先消除高风险漏洞,同时保留当前必须兼容的登录页品牌配置读取和验证码兼容行为。
|
||||
|
||||
本设计采用“高危项一次收口,结构性问题只分析不重构”的策略:本轮修复 MD5 密码存储、未受控配置读取、敏感配置写入、备份配置泄露和 JWT 长期有效且改密后不失效的问题;不修改“无 Cloudflare secret 时允许当前 captcha 兼容行为”,也不重构 `autoMigrateAll()` 与 `migrateSchema()` 的双迁移入口。
|
||||
|
||||
## 背景
|
||||
|
||||
当前主线存在以下已确认问题:
|
||||
|
||||
1. `login`、`open_api/sub_store`、用户改密、管理员创建用户和管理员修改用户密码仍然使用 `security.MD5(...)`。
|
||||
2. `/api/v1/config/get` 在 middleware 的 `shouldSkip()` 中被匿名放行,导致任意调用方可以读取绝大多数配置。
|
||||
3. `updateConfigs()` 与 `updateSingleConfig()` 使用了两套不同的限制逻辑,敏感配置键在单项写接口中未被保护。
|
||||
4. `ExportAll()` 和 `ExportPartial(types=["configs"])` 会直接导出所有配置,包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。
|
||||
5. JWT 当前有效期为 90 天,且 token 在用户改密、禁用、角色变化后仍可继续使用到过期。
|
||||
|
||||
同时存在两个重要约束:
|
||||
|
||||
1. 登录页和未登录态品牌展示依赖匿名读取 `app_name`、`app_logo`、`app_favicon`、`app_bg_image` 和 `cloudflare_site_key`。
|
||||
2. `tests/contract/migration_contract_test.go` 已把“无 Cloudflare secret 时允许当前 captcha 兼容行为”定义为既有契约,本轮不改变。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 新增和更新后的用户密码不再以 MD5 存储。
|
||||
2. 历史 MD5 用户可在首次成功认证时自动迁移到强哈希。
|
||||
3. 匿名请求不能再读取任意配置,只能读取明确的公开配置白名单。
|
||||
4. 通用配置写接口不能覆盖敏感配置键。
|
||||
5. 备份导出默认不泄露敏感配置明文。
|
||||
6. 用户改密、禁用或角色变化后,旧 JWT 应立即失效。
|
||||
7. 不破坏现有登录页品牌展示和 captcha 兼容行为。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不重构 `open_api/sub_store` 的整体认证模型;该接口仍使用现有用户名和密码查询参数语义。
|
||||
2. 不实现完整 refresh token、session 管理后台或 token 黑名单体系。
|
||||
3. 不改变“无 Cloudflare secret 时允许当前 captcha 兼容行为”。
|
||||
4. 不在本轮重构 `autoMigrateAll()` 与 `migrateSchema()` 的启动流程。
|
||||
5. 不引入前端测试框架。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 后端
|
||||
|
||||
- `go-backend/internal/security/`
|
||||
- `go-backend/internal/auth/jwt.go`
|
||||
- `go-backend/internal/http/middleware/auth.go`
|
||||
- `go-backend/internal/http/handler/handler.go`
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `go-backend/internal/store/model/model.go`
|
||||
- `go-backend/internal/store/repo/repository.go`
|
||||
- `go-backend/internal/store/repo/repository_mutations.go`
|
||||
- `go-backend/tests/contract/`
|
||||
- `go-backend/internal/store/repo/*_test.go`
|
||||
|
||||
### 前端
|
||||
|
||||
- `vite-frontend/src/api/index.ts`
|
||||
- `vite-frontend/src/config/site.ts`
|
||||
- `vite-frontend/src/pages/index.tsx`
|
||||
- 任何在未登录态读取品牌配置的组件
|
||||
|
||||
## 设计决策
|
||||
|
||||
### 已确认决策
|
||||
|
||||
1. 本轮采用安全优先策略,允许收紧危险默认行为。
|
||||
2. MD5 密码采用“登录成功时自动迁移”的兼容方案。
|
||||
3. captcha 在未配置 Cloudflare secret 时的兼容行为保持不变。
|
||||
4. JWT 采用“最小可撤销”方案,而不是完整 session 体系。
|
||||
5. 双重迁移系统只分析,不在本轮中修改。
|
||||
|
||||
### 迁移系统分析结论
|
||||
|
||||
`autoMigrateAll()` 与 `migrateSchema()` 当前职责并不相同:
|
||||
|
||||
1. `autoMigrateAll()` 负责表和列结构补齐。
|
||||
2. `migrateSchema()` 负责基于 `schema_version` 的数据修正,以及 PostgreSQL ID 默认值修复等兼容迁移。
|
||||
3. 现有 `repository_migrate_test.go` 已明确覆盖这两部分逻辑,说明它们在现有代码库中是被依赖的互补结构,而不是已确认的重复安全漏洞。
|
||||
|
||||
因此本轮仅记录该分析结论,不把双迁移入口纳入改动范围,避免把安全修复扩展为启动流程重构。
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 1. 密码存储与认证迁移
|
||||
|
||||
在 `internal/security/` 中新增统一密码能力,替代各处直接使用 `security.MD5(...)` 的做法。
|
||||
|
||||
建议新增以下接口:
|
||||
|
||||
```go
|
||||
func HashPassword(plain string) (string, error)
|
||||
func VerifyPassword(storedHash, plain string) (ok bool, legacy bool)
|
||||
func IsLegacyPasswordHash(storedHash string) bool
|
||||
```
|
||||
|
||||
哈希算法使用 `bcrypt`:
|
||||
|
||||
1. `user.pwd` 当前为 `varchar(100)`,足以容纳 bcrypt 哈希。
|
||||
2. 不需要修改密码列长度,改动最小。
|
||||
3. 对当前 Go 后端来说,bcrypt 是最稳妥的强哈希升级路径。
|
||||
|
||||
所有密码入口统一改为走这套能力:
|
||||
|
||||
1. `login`
|
||||
2. `openAPISubStore`
|
||||
3. `updatePassword`
|
||||
4. `userCreate`
|
||||
5. `userUpdate` 中的管理员改密路径
|
||||
|
||||
认证迁移规则:
|
||||
|
||||
1. 如果数据库中存的是 bcrypt,则按 bcrypt 校验。
|
||||
2. 如果数据库中存的是历史 MD5,则先按旧逻辑校验。
|
||||
3. 历史 MD5 校验成功后,立即把 `pwd` 改写为 bcrypt。
|
||||
4. 自动迁移不仅在网页登录时执行,也在 `open_api/sub_store` 成功鉴权时执行,避免只使用订阅接口的老用户永远停留在 MD5。
|
||||
|
||||
默认管理员种子账号仍保留当前默认密码语义和 `requirePasswordChange` 行为,但种子哈希改为 bcrypt,不再在新建数据库中写入 MD5 值。
|
||||
|
||||
### 2. JWT 最小可撤销方案
|
||||
|
||||
本轮不引入 refresh token 和黑名单表,而是做一个可以立即生效的最小撤销闭环。
|
||||
|
||||
#### 数据模型
|
||||
|
||||
在 `user` 表新增字段:
|
||||
|
||||
- `password_changed_at BIGINT NOT NULL DEFAULT 0`
|
||||
|
||||
该字段专门表示密码最后一次变更时间,不能复用现有 `updated_time`,原因是 `updated_time` 还会被流量、状态或其他用户资料更新触发,复用后会让非密码更新错误地使 token 失效。
|
||||
|
||||
#### token 签发与校验
|
||||
|
||||
继续使用现有 `iat` 声明,但把有效期从 90 天收紧到 7 天。
|
||||
|
||||
token 校验分两步:
|
||||
|
||||
1. 先做现有签名和过期时间校验。
|
||||
2. 再读取用户最小认证状态,确认:
|
||||
- 用户仍存在
|
||||
- 用户状态未被禁用
|
||||
- 当前 `role_id` 与 token 中一致
|
||||
- `claims.iat` 不早于 `password_changed_at`
|
||||
|
||||
为避免 middleware 每次都查询完整用户对象,Repository 新增专用读取方法,只返回 token 校验需要的最小字段,例如:
|
||||
|
||||
```go
|
||||
type UserAuthState struct {
|
||||
ID int64
|
||||
RoleID int
|
||||
Status int
|
||||
PasswordChangedAt int64
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserAuthState(userID int64) (*UserAuthState, error)
|
||||
```
|
||||
|
||||
#### 失效语义
|
||||
|
||||
以下场景下,旧 token 应立即失效:
|
||||
|
||||
1. 用户修改密码
|
||||
2. 管理员修改用户密码
|
||||
3. 用户被禁用
|
||||
4. 用户角色发生变化
|
||||
|
||||
这会带来一次明确的兼容收紧:升级完成后,部分历史 token 可能因为寿命策略或认证状态变化而失效,这是安全优先下的可接受行为。
|
||||
|
||||
### 3. 配置读取访问控制
|
||||
|
||||
为了避免继续让 `/api/v1/config/get` 承担“有时匿名、有时鉴权”的混合语义,本设计将公开配置读取拆成单独的 public 端点。
|
||||
|
||||
#### 端点设计
|
||||
|
||||
保留现有受保护端点:
|
||||
|
||||
- `POST /api/v1/config/get`
|
||||
|
||||
新增公开端点:
|
||||
|
||||
- `POST /api/v1/public/config/get`
|
||||
|
||||
middleware 仅对白名单 public 端点放行,不再放行 `/api/v1/config/get`。
|
||||
|
||||
#### 公开白名单
|
||||
|
||||
匿名仅允许读取以下配置:
|
||||
|
||||
1. `app_name`
|
||||
2. `app_logo`
|
||||
3. `app_favicon`
|
||||
4. `app_bg_image`
|
||||
5. `cloudflare_site_key`
|
||||
|
||||
理由:
|
||||
|
||||
1. 登录页与未登录态品牌渲染依赖前四项。
|
||||
2. 登录页在 captcha 开启时需要读取 `cloudflare_site_key`。
|
||||
3. 其他配置不应暴露给匿名方。
|
||||
|
||||
前端调整规则:
|
||||
|
||||
1. 登录页和 `site.ts` 中的未登录态品牌配置读取改走 `/public/config/get`。
|
||||
2. 登录后页面仍使用现有 `/config/get` 或 `/config/list`。
|
||||
3. 已登录页面中的配置读取逻辑不变,只是恢复为真正受 JWT 保护。
|
||||
|
||||
### 4. 配置写保护统一
|
||||
|
||||
当前 `updateConfigs()` 与 `updateSingleConfig()` 各自维护不同限制逻辑,是本次越权写入漏洞的根源。本轮把配置访问规则统一收口为一套辅助函数。
|
||||
|
||||
建议新增配置策略定义:
|
||||
|
||||
```go
|
||||
type ConfigAccessPolicy struct {
|
||||
PublicReadable bool
|
||||
Sensitive bool
|
||||
CommercialOnly bool
|
||||
}
|
||||
```
|
||||
|
||||
由统一函数返回某个 key 的策略,再由:
|
||||
|
||||
1. `public config get`
|
||||
2. `config get`
|
||||
3. `config list`
|
||||
4. `updateConfigs()`
|
||||
5. `updateSingleConfig()`
|
||||
|
||||
共同复用。
|
||||
|
||||
敏感配置键至少包含:
|
||||
|
||||
1. `jwt_secret`
|
||||
2. `license_key`
|
||||
3. `cloudflare_secret_key`
|
||||
|
||||
这些键的写入规则:
|
||||
|
||||
1. 不允许通过通用配置写接口改写。
|
||||
2. 不允许通过公开读取接口读取。
|
||||
3. 非管理员在配置列表接口中也不能获得。
|
||||
|
||||
商业版白名单键继续沿用现有语义,例如:
|
||||
|
||||
1. `app_name`
|
||||
2. `app_logo`
|
||||
3. `app_favicon`
|
||||
4. `hide_footer_brand`
|
||||
|
||||
但其判断逻辑同样统一走同一套策略函数,避免再次出现单接口漏判。
|
||||
|
||||
### 5. 备份导出与导入脱敏
|
||||
|
||||
备份系统改为“默认安全导出”,而不是“完整明文镜像”。
|
||||
|
||||
#### 导出
|
||||
|
||||
`ExportAll()` 和 `ExportPartial(types=["configs"])` 在写入 `backup.Configs` 前都先经过统一过滤函数,移除敏感配置键。
|
||||
|
||||
敏感配置键与配置写保护列表保持一致:
|
||||
|
||||
1. `jwt_secret`
|
||||
2. `license_key`
|
||||
3. `cloudflare_secret_key`
|
||||
|
||||
#### 导入
|
||||
|
||||
导入配置时,即使旧备份中带有上述敏感键,也会在导入前被丢弃,不允许通过备份恢复路径覆盖在线安全配置。
|
||||
|
||||
该设计的取舍如下:
|
||||
|
||||
1. 保留大部分业务配置、节点、转发、用户数据的恢复能力。
|
||||
2. 不再把备份文件当作核心密钥分发载体。
|
||||
3. `UserBackup.Pwd` 仍然保留,以维持用户恢复语义;在本轮密码升级后,这些值将是 bcrypt 哈希,而不是 MD5。
|
||||
|
||||
### 6. captcha 兼容行为
|
||||
|
||||
`captcha_enabled`、`cloudflare_site_key`、`cloudflare_secret_key` 的现有兼容行为保持不变。
|
||||
|
||||
明确保持以下现状:
|
||||
|
||||
1. 当未完整配置 Cloudflare key 时,当前 contract test 约定的兼容路径继续存在。
|
||||
2. 本轮不把 captcha 兼容逻辑从“兼容旧行为”切换为“严格校验”。
|
||||
|
||||
这样可以避免把一轮安全修复扩展成登录流程行为变更,同时与用户已确认的范围保持一致。
|
||||
|
||||
## 错误处理与兼容行为
|
||||
|
||||
### 错误处理
|
||||
|
||||
保持现有 API envelope:`{code, msg, data, ts}`。
|
||||
|
||||
建议的接口行为:
|
||||
|
||||
1. `POST /api/v1/public/config/get` 请求非公开 key 时返回 `403`。
|
||||
2. 受保护配置端点未登录时返回 `401`。
|
||||
3. 登录、订阅接口、改密接口继续返回通用认证失败,不暴露“用户名存在但密码错误”等细节。
|
||||
4. token 因签名错误、过期、改密、禁用或角色变化失效时,统一返回现有 `401` 语义。
|
||||
5. 备份导入中出现敏感配置键时,接口整体仍允许成功导入其他数据,敏感键静默忽略。
|
||||
|
||||
### 保留兼容
|
||||
|
||||
1. 登录页和未登录态品牌展示继续可用。
|
||||
2. 未配置 Cloudflare secret 时的 captcha 兼容逻辑继续保留。
|
||||
3. 历史 MD5 用户仍可继续认证,并在成功后自动迁移。
|
||||
|
||||
### 刻意收紧
|
||||
|
||||
1. 匿名方不再可读取任意配置。
|
||||
2. 通用配置写接口不再能写入敏感键。
|
||||
3. 备份不再导出敏感配置明文。
|
||||
4. 改密、禁用和角色变化会立即使旧 token 失效。
|
||||
|
||||
## 测试设计
|
||||
|
||||
本轮以 Go 单测和 contract test 为主,覆盖以下场景。
|
||||
|
||||
### 密码迁移
|
||||
|
||||
1. 历史 MD5 用户在网页登录成功后,数据库中的 `pwd` 被升级为 bcrypt。
|
||||
2. 历史 MD5 用户在 `open_api/sub_store` 成功鉴权后,同样触发迁移。
|
||||
3. 新建用户后落库的是 bcrypt,而不是 MD5。
|
||||
4. 管理员修改用户密码和用户自助改密后,落库的是 bcrypt。
|
||||
|
||||
### JWT 最小可撤销
|
||||
|
||||
1. 正常 token 仍可访问受保护接口。
|
||||
2. 改密后旧 token 失效。
|
||||
3. 用户被禁用后旧 token 失效。
|
||||
4. 用户角色变化后旧 token 失效。
|
||||
5. 过期 token 失效。
|
||||
|
||||
### 配置访问控制
|
||||
|
||||
1. 匿名访问公开配置成功。
|
||||
2. 匿名访问非公开配置失败。
|
||||
3. 已登录页面需要的普通配置读取仍然可用。
|
||||
4. `updateSingleConfig()` 无法修改敏感键。
|
||||
5. `updateConfigs()` 同样无法修改敏感键。
|
||||
|
||||
### 备份脱敏
|
||||
|
||||
1. `ExportAll()` 不包含敏感配置。
|
||||
2. `ExportPartial(types=["configs"])` 不包含敏感配置。
|
||||
3. 导入带敏感键的备份时,这些键不会被写回数据库。
|
||||
4. 非敏感配置和其他业务数据仍可正常导入导出。
|
||||
|
||||
### 迁移系统回归
|
||||
|
||||
1. 现有 `repository_migrate_test.go` 保持通过。
|
||||
2. 本轮不对 `autoMigrateAll()` 与 `migrateSchema()` 的职责边界做行为性改动。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 数据库中不再新增 MD5 密码。
|
||||
2. 历史 MD5 用户可在首次成功认证后自动升级到 bcrypt。
|
||||
3. 匿名调用方不能再读取非公开配置。
|
||||
4. `updateSingleConfig()` 和 `updateConfigs()` 都无法改写敏感配置键。
|
||||
5. 备份导出默认不包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。
|
||||
6. 改密、禁用和角色变化后,旧 JWT 立即失效。
|
||||
7. 登录页品牌展示和 captcha 兼容行为不被破坏。
|
||||
8. 现有迁移测试和本轮新增安全测试全部通过。
|
||||
|
||||
## PR #502 处置
|
||||
|
||||
PR #502 的价值在于指出了真实问题,但其实现方式只是把讽刺性注释写进生产代码,并未修复漏洞。因此该 PR 不应合并。
|
||||
|
||||
执行阶段的处置方式:
|
||||
|
||||
1. 关闭 PR #502。
|
||||
2. 在关闭说明中指出:问题成立,但修复将通过正式代码与测试提交完成,而不是通过向源文件加入讽刺性注释。
|
||||
3. 后续在新提交中按本设计逐项修复。
|
||||
@@ -0,0 +1,231 @@
|
||||
# FLVX Dependabot 告警修复设计
|
||||
|
||||
**日期**: 2026-05-14
|
||||
**状态**: 待审核
|
||||
**范围**: 仅处理 GitHub Dependabot 依赖告警
|
||||
|
||||
## 概述
|
||||
|
||||
本设计针对 `Sagit-chu/flvx` 当前 open Dependabot alerts 制定依赖修复方案。目标是在不混入业务安全逻辑改造的前提下,消除或最大限度降低依赖漏洞告警,并通过各模块现有构建和测试命令验证兼容性。
|
||||
|
||||
当前告警共 21 条:
|
||||
|
||||
- Critical: 1
|
||||
- High: 6
|
||||
- Medium: 13
|
||||
- Low: 1
|
||||
|
||||
按生态划分:
|
||||
|
||||
- Go: 14
|
||||
- npm: 7
|
||||
|
||||
Go 告警中有一部分因为 `go-gost/go.mod` 和 `go-gost/x/go.mod` 同时被扫描而重复出现;实际修复应按依赖和模块关系聚合处理,而不是按 alert 数逐条机械修改。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 修复 `go-backend` 中 `github.com/jackc/pgx/v5` 的 critical 和 low 告警。
|
||||
2. 修复 `vite-frontend` 中 npm 直接依赖、开发依赖和 lockfile 传递依赖告警。
|
||||
3. 修复 `go-gost` 与 `go-gost/x` 中可升级到 patched version 的 Go 依赖告警。
|
||||
4. 对 Dependabot 未给出 patched version 的依赖进行单独确认,避免盲目大版本升级。
|
||||
5. 保持改动最小化,便于回滚和定位 CI 失败。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不处理既有认证、配置读取、备份导出、JWT 失效等业务安全逻辑问题。
|
||||
2. 不合并或修改 `2026-05-13-security-remediation` 相关设计和计划。
|
||||
3. 不进行 `go get -u ./...` 或 `pnpm update` 级别的大范围依赖升级。
|
||||
4. 不引入前端测试框架。
|
||||
5. 不编辑 `install.sh`、`panel_install.sh` 或 generated `.pb.go` 文件。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### Backend
|
||||
|
||||
- `go-backend/go.mod`
|
||||
- `go-backend/go.sum`
|
||||
|
||||
### Frontend
|
||||
|
||||
- `vite-frontend/package.json`
|
||||
- `vite-frontend/pnpm-lock.yaml`
|
||||
|
||||
### Agent
|
||||
|
||||
- `go-gost/go.mod`
|
||||
- `go-gost/go.sum`
|
||||
- `go-gost/x/go.mod`
|
||||
- `go-gost/x/go.sum`
|
||||
|
||||
`go-gost/go.mod` 使用:
|
||||
|
||||
```go
|
||||
replace github.com/go-gost/x => ./x
|
||||
```
|
||||
|
||||
因此 `go-gost/x` 的依赖修复应先完成,再验证 `go-gost` 主模块。
|
||||
|
||||
## 修复策略
|
||||
|
||||
采用“分模块、最小安全升级”策略。
|
||||
|
||||
### 1. go-backend
|
||||
|
||||
Dependabot alerts:
|
||||
|
||||
- `github.com/jackc/pgx/v5 < 5.9.0`
|
||||
- severity: critical
|
||||
- summary: memory-safety vulnerability
|
||||
- `github.com/jackc/pgx/v5 < 5.9.2`
|
||||
- severity: low
|
||||
- summary: SQL injection via placeholder confusion with dollar quoted string literals
|
||||
|
||||
当前版本:
|
||||
|
||||
- `github.com/jackc/pgx/v5 v5.7.3`
|
||||
|
||||
目标版本:
|
||||
|
||||
- `github.com/jackc/pgx/v5 v5.9.2`
|
||||
|
||||
设计说明:
|
||||
|
||||
- 直接升到 `v5.9.2`,同时覆盖 `v5.9.0` 和 `v5.9.2` 的修复要求。
|
||||
- 不调整 GORM PostgreSQL driver,除非 `go mod tidy` 或测试显示必须联动升级。
|
||||
- 验证以 backend 全量测试为准。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
```
|
||||
|
||||
### 2. vite-frontend
|
||||
|
||||
Dependabot alerts:
|
||||
|
||||
- `postcss < 8.5.10`
|
||||
- appears in `vite-frontend/package.json`
|
||||
- appears in `vite-frontend/pnpm-lock.yaml`
|
||||
- `serialize-javascript <= 7.0.2` and `< 7.0.5`
|
||||
- lockfile includes `serialize-javascript@6.0.2`
|
||||
- package override currently pins `serialize-javascript` to `7.0.3`
|
||||
- `fast-uri <= 3.1.1`
|
||||
- lockfile currently includes `fast-uri@3.1.0`
|
||||
- `@babel/plugin-transform-modules-systemjs <= 7.29.3`
|
||||
- lockfile currently includes `7.29.0`
|
||||
|
||||
目标版本:
|
||||
|
||||
- `postcss >= 8.5.10`
|
||||
- `serialize-javascript >= 7.0.5`
|
||||
- `fast-uri >= 3.1.2`
|
||||
- `@babel/plugin-transform-modules-systemjs >= 7.29.4`
|
||||
|
||||
设计说明:
|
||||
|
||||
- 对直接声明的 `postcss` 更新 `package.json`。
|
||||
- 将 `overrides.serialize-javascript` 从 `7.0.3` 更新到 `7.0.5`。
|
||||
- 对只出现在 lockfile 的传递依赖,优先通过 `pnpm install` 重新解析 lockfile,让上游范围自然选择 patched version。
|
||||
- 如果 lockfile 仍保留 vulnerable 版本,再添加精确 `pnpm.overrides` 或现有 `overrides` 条目,避免无关依赖大升级。
|
||||
- 不引入前端测试框架,验证使用现有 build。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
可选补充检查:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm why postcss serialize-javascript fast-uri @babel/plugin-transform-modules-systemjs)
|
||||
```
|
||||
|
||||
### 3. go-gost/x
|
||||
|
||||
Dependabot alerts:
|
||||
|
||||
- `github.com/sirupsen/logrus < 1.8.3`
|
||||
- severity: high
|
||||
- current: `v1.8.1`
|
||||
- target: at least `v1.8.3`
|
||||
- `github.com/quic-go/quic-go < 0.57.0`
|
||||
- severity: medium
|
||||
- current: `v0.49.1`
|
||||
- target: `v0.57.0`
|
||||
- `github.com/quic-go/webtransport-go <= 0.9.0`
|
||||
- severity: medium
|
||||
- current: `v0.8.1-0.20241018022711-4ac2c9250e66`
|
||||
- target: `v0.10.0`
|
||||
- `github.com/pion/dtls/v2 <= 2.2.12`
|
||||
- severity: medium
|
||||
- current: `v2.2.6`
|
||||
- target: no patched version provided by Dependabot
|
||||
|
||||
设计说明:
|
||||
|
||||
- 先处理 `go-gost/x`,因为它是 `go-gost` 通过 `replace` 使用的本地模块。
|
||||
- 将 `logrus`、`quic-go` 和 `webtransport-go` 升级到 Dependabot 标出的 patched version。
|
||||
- 单独处理 `pion/dtls/v2`,因为 Dependabot 没有提供 `first_patched_version`。
|
||||
- 对 `pion/dtls/v2`,先查询可用 module versions 和 advisory 详情。如果存在 patched `v2` release,使用最小安全修复版本;如果不存在 patched version,则记录残留告警,避免在没有兼容性评估的情况下强行做高风险大版本迁移。
|
||||
- 升级完成后,在 `go-gost/x` 中运行 `go mod tidy` 并编译/测试该模块。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-gost/x && go test ./...)
|
||||
```
|
||||
|
||||
### 4. go-gost
|
||||
|
||||
Dependabot reports the same vulnerable Go dependencies in `go-gost/go.mod`.
|
||||
|
||||
设计说明:
|
||||
|
||||
- `go-gost/x` 修复后,再更新 `go-gost` 的 module requirements,使主模块也解析到 patched versions。
|
||||
- 保留 `replace github.com/go-gost/x => ./x`。
|
||||
- 使用针对具体漏洞依赖的 `go get` 命令,不使用宽泛的 `go get -u`。
|
||||
- 定向升级后运行 `go mod tidy`。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-gost && go test ./...)
|
||||
(cd go-gost && go build .)
|
||||
```
|
||||
|
||||
## 处理顺序
|
||||
|
||||
1. 修复 `go-backend` 的 `pgx/v5`。
|
||||
2. 修复 `vite-frontend` 的 npm dependencies 和 lockfile。
|
||||
3. 修复 `go-gost/x` 的 Go dependencies。
|
||||
4. 同步并验证 `go-gost`。
|
||||
5. 再次查询 Dependabot alerts,确认 alert 数量下降,或记录有意保留的未解决告警。
|
||||
|
||||
这个顺序可以降低耦合:backend 和 frontend 能独立验证,而 `go-gost/x` 因本地 module replacement 必须先于 `go-gost` 处理。
|
||||
|
||||
## 错误处理与回退
|
||||
|
||||
如果定向依赖升级无法解析:
|
||||
|
||||
1. 使用 `go mod why`、`go mod graph` 或 `pnpm why` 检查依赖链。
|
||||
2. 优先添加最小显式 requirement 或 override,以强制解析到 patched version。
|
||||
3. 除非定向解析不可行,否则避免宽泛升级。
|
||||
4. 如果 patched version 不可用,记录准确 advisory、受影响依赖、当前暴露面,以及保留 open 状态的原因。
|
||||
|
||||
如果验证失败:
|
||||
|
||||
1. 将失败范围限制在当前升级的模块内。
|
||||
2. 先分析编译错误或测试失败,再决定是否调整版本。
|
||||
3. 优先选择能通过测试和构建的最低 patched version。
|
||||
4. 不通过删除测试或修改无关应用代码来掩盖失败。
|
||||
|
||||
## 成功标准
|
||||
|
||||
1. `pgx/v5` 升级后,`go-backend` 测试通过。
|
||||
2. npm dependency 和 lockfile 更新后,frontend production build 通过。
|
||||
3. 定向升级后,`go-gost/x` 测试通过。
|
||||
4. 同步 module requirements 后,`go-gost` 测试和构建通过。
|
||||
5. 最终 Dependabot API 查询显示所有可修复告警已关闭或数量明确下降。
|
||||
6. 任何剩余告警都有明确记录;尤其是 `github.com/pion/dtls/v2` 如果不存在 patched version,需要记录原因和下一步动作。
|
||||
+29
-58
@@ -1,67 +1,38 @@
|
||||
# GO BACKEND KNOWLEDGE BASE
|
||||
# go-backend
|
||||
|
||||
## 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).
|
||||
Admin API for FLVX. Go + net/http + GORM (SQLite/PostgreSQL).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-backend/
|
||||
├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket
|
||||
├── internal/
|
||||
│ ├── http/ # HTTP layer
|
||||
│ │ ├── router.go # Routes (NewServeMux) + Middleware chain
|
||||
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
|
||||
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
|
||||
│ │ └── response/ # JSON response helpers
|
||||
│ ├── store/
|
||||
│ │ ├── model/model.go # GORM model structs (single source of truth)
|
||||
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
|
||||
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
|
||||
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
|
||||
│ │ ├── repository_federation.go # Federation-specific queries
|
||||
│ │ ├── repository_flow.go # Flow/forward status queries
|
||||
│ │ ├── repository_control.go # Control plane queries
|
||||
│ │ └── repository_groups.go # Group management queries
|
||||
│ └── auth/ # Auth logic
|
||||
├── tests/contract/ # Integration/contract tests (14 tests)
|
||||
├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
|
||||
└── Makefile # Build commands
|
||||
```
|
||||
## Structure
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
|
||||
| **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |
|
||||
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
|
||||
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
|
||||
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
|
||||
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
|
||||
| Dir | Role |
|
||||
|-----|------|
|
||||
| `cmd/paneld/main.go` | Entry point, HTTP server + WebSocket |
|
||||
| `internal/http/router.go` | Route registration (`http.ServeMux`) + middleware chain |
|
||||
| `internal/http/handler/` | API handlers |
|
||||
| `internal/http/middleware/` | JWT, CORS, logging, recover |
|
||||
| `internal/http/response/` | JSON envelope helpers |
|
||||
| `internal/store/model/model.go` | All GORM models (single file) |
|
||||
| `internal/store/repo/` | Repository layer (never access DB directly) |
|
||||
| `internal/auth/` | Auth logic |
|
||||
| `tests/contract/` | Integration tests |
|
||||
|
||||
## CONVENTIONS
|
||||
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
|
||||
- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.
|
||||
- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.
|
||||
- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.
|
||||
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
|
||||
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
|
||||
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
|
||||
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
|
||||
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
|
||||
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
|
||||
## Conventions
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT CHANGE** handler signatures without updating `router.go`.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
- **Auth**: raw JWT in `Authorization` header — no `Bearer` prefix.
|
||||
- **API envelope**: `{code, msg, data, ts}`, code 0 = success.
|
||||
- **Repository pattern**: handlers call repo methods, never `repo.DB()` directly.
|
||||
- **GORM**: `TableName()` on every model (GORM pluralizes by default).
|
||||
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
|
||||
- **SQLite**: `MaxOpenConns(1)`, WAL mode, `busy_timeout=5000`.
|
||||
- **Schema**: created via `autoMigrateAll()` at startup, no hand-written DDL.
|
||||
- **PostgreSQL**: set `DB_TYPE=postgres` and `DATABASE_URL` env vars.
|
||||
- **Config**: all from environment variables.
|
||||
|
||||
## Commands
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-backend
|
||||
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
|
||||
go test ./... # Unit tests
|
||||
go test ./tests/contract/... # Contract tests
|
||||
go run ./cmd/paneld # SERVER_ADDR defaults to :6365
|
||||
make build
|
||||
go test ./... # includes contract tests
|
||||
go test ./tests/contract/... # contract tests only
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24-bookworm AS builder
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
@@ -9,10 +9,14 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
|
||||
|
||||
FROM docker:27-cli AS dockercli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /out/paneld /app/paneld
|
||||
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
|
||||
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
ENV SERVER_ADDR=:6365
|
||||
EXPOSE 6365
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
| 表名 | Model | 特殊处理 |
|
||||
|------|-------|----------|
|
||||
| `user` | `User` | `TableName()` 返回 `"user"` (PG 保留字) |
|
||||
| `forward` | `Forward` | |
|
||||
| `forward` | `Forward` | 增加 `proxy_protocol` 字段 |
|
||||
| `forward_port` | `ForwardPort` | |
|
||||
| `node` | `Node` | |
|
||||
| `speed_limit` | `SpeedLimit` | |
|
||||
|
||||
+11
-13
@@ -1,20 +1,20 @@
|
||||
module go-backend
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
go 1.25.0
|
||||
|
||||
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
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
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
|
||||
@@ -23,14 +23,12 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
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
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // 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
|
||||
)
|
||||
|
||||
+14
-14
@@ -17,8 +17,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
|
||||
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -36,23 +36,23 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -49,7 +49,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
|
||||
const (
|
||||
algorithm = "HmacSHA256"
|
||||
expireTime = 90 * 24 * time.Hour
|
||||
expireTime = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Iat int64 `json:"iat"`
|
||||
IatMs int64 `json:"iat_ms"`
|
||||
Exp int64 `json:"exp"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
@@ -30,11 +31,15 @@ type tokenHeader struct {
|
||||
}
|
||||
|
||||
func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) {
|
||||
now := time.Now()
|
||||
return GenerateTokenAt(userID, username, roleID, secret, time.Now())
|
||||
}
|
||||
|
||||
func GenerateTokenAt(userID int64, username string, roleID int, secret string, now time.Time) (string, error) {
|
||||
header := tokenHeader{Alg: algorithm, Typ: "JWT"}
|
||||
claims := Claims{
|
||||
Sub: strconv.FormatInt(userID, 10),
|
||||
Iat: now.Unix(),
|
||||
IatMs: now.UnixMilli(),
|
||||
Exp: now.Add(expireTime).Unix(),
|
||||
User: username,
|
||||
Name: username,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth
|
||||
|
||||
type UserAuthState struct {
|
||||
ID int64
|
||||
RoleID int
|
||||
Status int
|
||||
PasswordChangedAt int64
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -71,10 +71,11 @@ type RuntimeReleaseRoleRequest struct {
|
||||
}
|
||||
|
||||
type RuntimeDiagnoseRequest struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandRequest struct {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req nameRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if configName == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if !repo.IsPublicConfigKey(configName) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(configName)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(cfg))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "app_name", "FLVX Brand")
|
||||
seedConfigValue(t, r, "app_logo", "logo-data")
|
||||
seedConfigValue(t, r, "app_favicon", "favicon-data")
|
||||
seedConfigValue(t, r, "app_bg_image", "bg-data")
|
||||
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
}
|
||||
|
||||
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
|
||||
}
|
||||
|
||||
func TestConfigGetAllowsPublicCloudflareSiteKeyWithoutAuthForCachedLoginPage(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"cloudflare_site_key"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerConfigValue(t, resp, "cloudflare_site_key", "site-key")
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsSensitiveKeysWithoutAuth(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
|
||||
}
|
||||
|
||||
func TestConfigUpdateRejectsSensitiveKeys(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"jwt_secret":"rotated-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
|
||||
}
|
||||
|
||||
func TestConfigUpdateSingleRejectsSensitiveKeys(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"jwt_secret","value":"rotated-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
|
||||
}
|
||||
|
||||
func setupConfigAccessTestRouter(t *testing.T) (http.Handler, *repo.Repository) {
|
||||
t.Helper()
|
||||
r, err := repo.Open(t.TempDir() + "/config-access.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
h := New(r, "unit-test-secret")
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
wrapped := middleware.Recover(mux)
|
||||
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: "unit-test-secret", GetUserAuthState: h.GetUserAuthState})(wrapped)
|
||||
wrapped = middleware.RequestLog(wrapped)
|
||||
wrapped = middleware.CORS(wrapped)
|
||||
return wrapped, r
|
||||
}
|
||||
|
||||
func seedConfigValue(t *testing.T, r *repo.Repository, name, value string) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`INSERT INTO vite_config(name, value, time) VALUES(?, ?, 0) ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time`, name, value).Error; err != nil {
|
||||
t.Fatalf("seed config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGenerateConfigAccessToken(t *testing.T, userID int64, username string, roleID int) string {
|
||||
t.Helper()
|
||||
token, err := auth.GenerateToken(userID, username, roleID, "unit-test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func assertHandlerCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expected {
|
||||
t.Fatalf("expected code %d, got %d", expected, out.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHandlerCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expectedCode || out.Msg != expectedMsg {
|
||||
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHandlerConfigValue(t *testing.T, rec *httptest.ResponseRecorder, expectedName, expectedValue string) {
|
||||
t.Helper()
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d", out.Code)
|
||||
}
|
||||
if out.Data.Name != expectedName || out.Data.Value != expectedValue {
|
||||
t.Fatalf("expected config (%q,%q), got (%q,%q)", expectedName, expectedValue, out.Data.Name, out.Data.Value)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
|
||||
@@ -42,6 +45,20 @@ func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
|
||||
if got != "12_34_56" {
|
||||
t.Fatalf("expected 12_34_56, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
|
||||
if got != "12_34_0" {
|
||||
t.Fatalf("expected 12_34_0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
if !shouldTryLegacySingleService("PauseService") {
|
||||
t.Fatalf("PauseService should require legacy fallback")
|
||||
@@ -53,3 +70,573 @@ func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
t.Fatalf("DeleteService should not require legacy fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSelfHealForwardServiceControl(t *testing.T) {
|
||||
if !shouldSelfHealForwardServiceControl("PauseService") {
|
||||
t.Fatalf("PauseService should trigger self-heal")
|
||||
}
|
||||
if !shouldSelfHealForwardServiceControl(" resumeService ") {
|
||||
t.Fatalf("ResumeService should trigger self-heal")
|
||||
}
|
||||
if shouldSelfHealForwardServiceControl("DeleteService") {
|
||||
t.Fatalf("DeleteService should not trigger self-heal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandHandledOnKnownVariant(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
called := make([]string, 0)
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_56_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatalf("expected handled=true")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("expected lastNotFoundErr=nil when handled")
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false")
|
||||
}
|
||||
if lastNotFoundErr == nil {
|
||||
t.Fatalf("expected lastNotFoundErr when all variants are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_0" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("all-missing delete should be tolerated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
|
||||
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
|
||||
want := []string{"46_9_123", "46_9_77", "46_9_0"}
|
||||
if !reflect.DeepEqual(bases, want) {
|
||||
t.Fatalf("expected %v, got %v", want, bases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
|
||||
bases := []string{"46_9_123", "46_9_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
|
||||
bases := []string{"57_7_7", "57_7_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
switch name {
|
||||
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
|
||||
return nil
|
||||
default:
|
||||
return errors.New("service " + name + " not found")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceDeleteNamesBatchesAndDeduplicatesVariants(t *testing.T) {
|
||||
bases := []string{"57_7_7", "57_7_0", "57_7_7"}
|
||||
got := buildForwardServiceDeleteNames(bases)
|
||||
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedTunnelRuntimeNodeIDsSeparatesChainAndServiceRoles(t *testing.T) {
|
||||
oldRows := []chainNodeRecord{
|
||||
{NodeID: 1, ChainType: 1},
|
||||
{NodeID: 2, ChainType: 2},
|
||||
{NodeID: 3, ChainType: 3},
|
||||
{NodeID: 5, ChainType: 2},
|
||||
{NodeID: 6, ChainType: 3},
|
||||
}
|
||||
newRows := []chainNodeRecord{
|
||||
{NodeID: 2, ChainType: 3},
|
||||
{NodeID: 3, ChainType: 3},
|
||||
{NodeID: 5, ChainType: 1},
|
||||
}
|
||||
|
||||
removedChains := removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain)
|
||||
if want := []int64{1, 2}; !reflect.DeepEqual(removedChains, want) {
|
||||
t.Fatalf("expected removed chains %v, got %v", want, removedChains)
|
||||
}
|
||||
|
||||
removedServices := removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService)
|
||||
if want := []int64{5, 6}; !reflect.DeepEqual(removedServices, want) {
|
||||
t.Fatalf("expected removed services %v, got %v", want, removedServices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelForwardRuntimeNeedsSyncOnlyWhenTypeOrEntriesChange(t *testing.T) {
|
||||
if tunnelForwardRuntimeNeedsSync(2, 2, []int64{1, 2}, []int64{2, 1}) {
|
||||
t.Fatalf("same tunnel type and same entry set should not resync forwards")
|
||||
}
|
||||
if !tunnelForwardRuntimeNeedsSync(1, 2, []int64{1}, []int64{1}) {
|
||||
t.Fatalf("type change should resync forwards")
|
||||
}
|
||||
if !tunnelForwardRuntimeNeedsSync(2, 2, []int64{1}, []int64{1, 2}) {
|
||||
t.Fatalf("entry set change should resync forwards")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
|
||||
h := &Handler{repo: nil}
|
||||
node := &nodeRecord{ID: 9, Name: "test-node"}
|
||||
_ = h
|
||||
_ = node
|
||||
|
||||
rawRepo, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
h = &Handler{repo: rawRepo}
|
||||
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
|
||||
t.Fatalf("insert forward port: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
|
||||
if err == nil {
|
||||
t.Fatalf("expected occupancy error")
|
||||
}
|
||||
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("same forward should be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
if name == "12_34_56_tcp" {
|
||||
return errors.New("network timeout")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false on hard error")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("did not expect not-found error alongside hard error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
if !isAlreadyExistsMessage("service demo already exists") {
|
||||
t.Fatalf("expected already exists message to be tolerated")
|
||||
}
|
||||
if !isAlreadyExistsMessage("服务已存在") {
|
||||
t.Fatalf("expected Chinese already exists message to be tolerated")
|
||||
}
|
||||
if !isAlreadyExistsMessage("service demo alreadyexists") {
|
||||
t.Fatalf("missing-space alreadyexists should be tolerated")
|
||||
}
|
||||
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
|
||||
t.Fatalf("address already in use must not be treated as already exists")
|
||||
}
|
||||
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
|
||||
t.Fatalf("alreadyin-use variant must not be treated as already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBindAddressInUseError(t *testing.T) {
|
||||
if !isBindAddressInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isBindAddressInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if isBindAddressInUseError(errors.New("service demo already exists")) {
|
||||
t.Fatalf("already exists should not be treated as bind conflict")
|
||||
}
|
||||
if isBindAddressInUseError(nil) {
|
||||
t.Fatalf("nil error should not be treated as bind conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAddressAlreadyInUseError(t *testing.T) {
|
||||
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
|
||||
t.Fatalf("missing-space alreadyin-use variant should be detected")
|
||||
}
|
||||
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCannotAssignRequestedAddressError(t *testing.T) {
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
|
||||
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
|
||||
}
|
||||
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should not be treated as cannot-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
if addCalls == 1 {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry to succeed, got %v", err)
|
||||
}
|
||||
if addCalls != 2 {
|
||||
t.Fatalf("expected 2 add attempts, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 1 {
|
||||
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
return errors.New("network timeout")
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if addCalls != 1 {
|
||||
t.Fatalf("expected 1 add attempt, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 0 {
|
||||
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
|
||||
cleanupErr := errors.New("delete failed")
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
},
|
||||
func() error {
|
||||
return cleanupErr
|
||||
},
|
||||
0,
|
||||
)
|
||||
if !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, svc := range services {
|
||||
addr, _ := svc["addr"].(string)
|
||||
if addr != "10.9.8.7:22000" {
|
||||
t.Fatalf("expected bind IP address 10.9.8.7:22000, got %q", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
tcpAddr, _ := services[0]["addr"].(string)
|
||||
udpAddr, _ := services[1]["addr"].(string)
|
||||
if tcpAddr != "0.0.0.0:22001" {
|
||||
t.Fatalf("expected tcp addr 0.0.0.0:22001, got %q", tcpAddr)
|
||||
}
|
||||
if udpAddr != "[::]:22001" {
|
||||
t.Fatalf("expected udp addr [::]:22001, got %q", udpAddr)
|
||||
}
|
||||
}
|
||||
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(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, 55555, "3.3.3.3:12345", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, svc := range services {
|
||||
addr, _ := svc["addr"].(string)
|
||||
if addr != "3.3.3.3:12345" {
|
||||
t.Fatalf("expected bind IP with port 3.3.3.3:12345, got %q", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, forwardRuntimeLimiters{})
|
||||
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 TestBuildConnLimiterConfigCombinesTotalAndPerIP(t *testing.T) {
|
||||
cfgs := buildConnLimiterConfigs(&forwardRecord{ID: 42, UserID: 9, MaxConn: 100, IPMaxConn: 5}, 37)
|
||||
want := []forwardLimiterConfig{{Name: "rule_conn_limit_42", Limits: []string{"$ 100", "$$ 5"}}}
|
||||
if !reflect.DeepEqual(cfgs, want) {
|
||||
t.Fatalf("expected %+v, got %+v", want, cfgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnLimiterConfigUsesUserTotalWithRulePerIP(t *testing.T) {
|
||||
cfgs := buildConnLimiterConfigs(&forwardRecord{ID: 42, UserID: 9, IPMaxConn: 5}, 37)
|
||||
want := []forwardLimiterConfig{
|
||||
{Name: "user_conn_limit_9", Limits: []string{"$ 37"}},
|
||||
{Name: "rule_conn_limit_42", Limits: []string{"$$ 5"}},
|
||||
}
|
||||
if !reflect.DeepEqual(cfgs, want) {
|
||||
t.Fatalf("expected %+v, got %+v", want, cfgs)
|
||||
}
|
||||
if got := joinLimiterNames(cfgs); got != "user_conn_limit_9,rule_conn_limit_42" {
|
||||
t.Fatalf("expected composite limiter names, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrafficLimiterPayloadUsesOnlyPerIPRulesWhenTotalIsSeparate(t *testing.T) {
|
||||
payload := buildTrafficLimiterPayload("rule_traffic_limit_42", nil, intPtr(40))
|
||||
wantLimits := []string{"0.0.0.0/0 5.0MB 5.0MB", "::/0 5.0MB 5.0MB"}
|
||||
if payload["name"] != "rule_traffic_limit_42" {
|
||||
t.Fatalf("expected name rule_traffic_limit_42, got %v", payload["name"])
|
||||
}
|
||||
if !reflect.DeepEqual(payload["limits"], wantLimits) {
|
||||
t.Fatalf("expected limits %v, got %v", wantLimits, payload["limits"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigsUsesRuntimeLimiterNames(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", forwardRuntimeLimiters{TrafficLimiter: "rule_traffic_limit_42", ConnLimiter: "rule_conn_limit_42"})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, service := range services {
|
||||
if service["limiter"] != "rule_traffic_limit_42" {
|
||||
t.Fatalf("expected traffic limiter rule_traffic_limit_42, got %v", service["limiter"])
|
||||
}
|
||||
if service["climiter"] != "rule_conn_limit_42" {
|
||||
t.Fatalf("expected conn limiter rule_conn_limit_42, got %v", service["climiter"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "https with path",
|
||||
in: "https://panel.example.com:8443/api/v1",
|
||||
want: "panel.example.com:8443",
|
||||
},
|
||||
{
|
||||
name: "wss with query",
|
||||
in: "wss://panel.example.com:443/system-info?x=1",
|
||||
want: "panel.example.com:443",
|
||||
},
|
||||
{
|
||||
name: "http without port",
|
||||
in: "http://panel.example.com",
|
||||
want: "panel.example.com",
|
||||
},
|
||||
{
|
||||
name: "manual host with trailing path",
|
||||
in: "panel.example.com:8080/path",
|
||||
want: "panel.example.com:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := processServerAddress(tt.in); got != tt.want {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessServerAddress_NormalizesIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ipv6 host only",
|
||||
in: "2001:db8::1",
|
||||
want: "[2001:db8::1]",
|
||||
},
|
||||
{
|
||||
name: "ipv6 host and port",
|
||||
in: "https://[2001:db8::1]:8443/path",
|
||||
want: "[2001:db8::1]:8443",
|
||||
},
|
||||
{
|
||||
name: "already bracketed",
|
||||
in: "[2001:db8::2]:9000",
|
||||
want: "[2001:db8::2]:9000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := processServerAddress(tt.in); got != tt.want {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
type diagnosisStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
func prepareDiagnosisStreamResponse(w http.ResponseWriter) (http.Flusher, error) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, errors.New("当前服务不支持流式响应")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
return flusher, nil
|
||||
}
|
||||
|
||||
func writeDiagnosisStreamEvent(encoder *json.Encoder, flusher http.Flusher, eventType string, data interface{}) error {
|
||||
if encoder == nil || flusher == nil {
|
||||
return errors.New("流式响应写入器未初始化")
|
||||
}
|
||||
event := diagnosisStreamEvent{Type: eventType, Data: data, TS: time.Now().UnixMilli()}
|
||||
if err := encoder.Encode(event); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeDiagnosisProgress(results []map[string]interface{}) diagnosisProgress {
|
||||
progress := diagnosisProgress{Total: len(results)}
|
||||
for _, item := range results {
|
||||
progress.Completed++
|
||||
if asBool(item["success"], false) {
|
||||
progress.Success++
|
||||
} else {
|
||||
progress.Failed++
|
||||
}
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
func shouldIgnoreDiagnosisStreamError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset by peer") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "stream already closed") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.CancelFunc, w http.ResponseWriter, startPayload map[string]interface{}, workItems []diagnosisWorkItem) error {
|
||||
flusher, err := prepareDiagnosisStreamResponse(w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"total": len(workItems),
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"items": h.buildDiagnosisStreamStartItems(workItems),
|
||||
}
|
||||
for key, value := range startPayload {
|
||||
payload[key] = value
|
||||
}
|
||||
if err := writeDiagnosisStreamEvent(encoder, flusher, "start", payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamBroken := false
|
||||
emitter := func(index int, item map[string]interface{}, progress diagnosisProgress) {
|
||||
if streamBroken {
|
||||
return
|
||||
}
|
||||
itemPayload := map[string]interface{}{
|
||||
"index": index,
|
||||
"result": item,
|
||||
"progress": progress,
|
||||
}
|
||||
if err := writeDiagnosisStreamEvent(encoder, flusher, "item", itemPayload); err != nil {
|
||||
streamBroken = true
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := h.runDiagnosisWorkItems(ctx, workItems, emitter)
|
||||
if streamBroken {
|
||||
return context.Canceled
|
||||
}
|
||||
|
||||
progress := summarizeDiagnosisProgress(results)
|
||||
donePayload := map[string]interface{}{
|
||||
"progress": progress,
|
||||
"timedOut": errors.Is(ctx.Err(), context.DeadlineExceeded),
|
||||
}
|
||||
return writeDiagnosisStreamEvent(encoder, flusher, "done", donePayload)
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDiagnoseStream(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
id := asInt64FromBodyKey(r, w, "tunnelId")
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(id)
|
||||
if err != nil {
|
||||
if 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
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
startPayload := map[string]interface{}{
|
||||
"tunnelName": tunnelName,
|
||||
"tunnelType": tunnelType,
|
||||
}
|
||||
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
|
||||
if shouldIgnoreDiagnosisStreamError(err) {
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "不支持流式响应") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) forwardDiagnoseStream(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
id := asInt64FromBodyKey(r, w, "forwardId")
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forward, _, _, 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
|
||||
}
|
||||
|
||||
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
|
||||
if err != nil {
|
||||
if 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
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
startPayload := map[string]interface{}{
|
||||
"forwardName": forwardName,
|
||||
}
|
||||
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
|
||||
if shouldIgnoreDiagnosisStreamError(err) {
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "不支持流式响应") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,100 @@ import (
|
||||
// nodeSupportsV4 / nodeSupportsV6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNodeSupportsV4_Nil(t *testing.T) {
|
||||
if nodeSupportsV4(nil) {
|
||||
t.Fatal("nil node must not support v4")
|
||||
func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Empty connectIp should be ignored, IP preference takes effect
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("empty connectIp should be ignored (v4 preference applies), got %q", host)
|
||||
}
|
||||
// Non-empty connectIp should override IP preference
|
||||
host, err = selectTunnelDialHost(from, to, "v6", "192.168.0.3")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "192.168.0.3" {
|
||||
t.Fatalf("connectIp should override v6 preference, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21000, ConnectIP: "2001:db8::88"}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "[2001:db8::88]:21000" {
|
||||
t.Fatalf("expected connectIp listen [2001:db8::88]:21000, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "10.8.0.5:21002" {
|
||||
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "[::]:21001" {
|
||||
t.Fatalf("expected default listen [::]:21001, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_SetsRetriesWhenMultipleCandidates(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 3)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
handler, _ := services[0]["handler"].(map[string]interface{})
|
||||
if handler == nil {
|
||||
t.Fatal("expected handler config")
|
||||
}
|
||||
retries, ok := handler["retries"].(int)
|
||||
if !ok {
|
||||
t.Fatal("expected retries to be set when nextHopCandidateCount > 1")
|
||||
}
|
||||
if retries != 2 {
|
||||
t.Fatalf("expected retries=2 (candidates-1), got %d", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_NoRetriesWhenSingleCandidate(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
handler, _ := services[0]["handler"].(map[string]interface{})
|
||||
if handler == nil {
|
||||
t.Fatal("expected handler config")
|
||||
}
|
||||
if _, hasRetries := handler["retries"]; hasRetries {
|
||||
t.Fatal("expected no retries when nextHopCandidateCount is 1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +114,14 @@ func TestNodeSupportsV6_Nil(t *testing.T) {
|
||||
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("explicit server_ip_v4 must support v4")
|
||||
t.Fatal("explicit server_ip_v4 needs support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("explicit server_ip_v6 must support v6")
|
||||
t.Fatal("explicit server_ip_v6 needs support v6")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +159,7 @@ func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
|
||||
t.Fatal("legacy v4 ip in server_ip must support v4")
|
||||
}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("legacy v4 ip in server_ip must not support v6")
|
||||
t.Fatal("legacy v4 ip in server_ip should not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +169,7 @@ func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
|
||||
t.Fatal("legacy v6 ip in server_ip must support v6")
|
||||
}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("legacy v6 ip in server_ip must not support v4")
|
||||
t.Fatal("legacy v6 ip in server_ip should not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,15 +268,15 @@ func v6OnlyNode(name, v6 string) *nodeRecord {
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
_, err := selectTunnelDialHost(nil, nil, "")
|
||||
_, err := selectTunnelDialHost(nil, nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil nodes")
|
||||
}
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "")
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil toNode")
|
||||
}
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "")
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil fromNode")
|
||||
}
|
||||
@@ -194,8 +285,7 @@ func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -208,8 +298,7 @@ func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -221,8 +310,7 @@ func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -234,9 +322,8 @@ func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// User prefers v6, but both nodes are v4-only — should fallback to v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -248,9 +335,8 @@ func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// User prefers v4, but both nodes are v6-only — should fallback to v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -259,32 +345,47 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := selectTunnelDialHost(from, to, "")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Whitespace should be trimmed, treated as "v6"
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ")
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -296,9 +397,8 @@ func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// v6 preferred, but target only has v4 — should succeed with v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -310,9 +410,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but target only has v6 — should succeed with v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -324,9 +423,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -338,9 +436,8 @@ func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but from only has v6 — should use v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -367,7 +464,6 @@ func TestNodeDisplayName_Named(t *testing.T) {
|
||||
t.Fatalf("expected 'hk-node', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDisplayName_Unnamed(t *testing.T) {
|
||||
n := &nodeRecord{ID: 42}
|
||||
got := nodeDisplayName(n)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -84,11 +85,87 @@ type federationRuntimeReleaseRoleRequest struct {
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
}
|
||||
|
||||
func federationRuntimeChainName(bindingID string) string {
|
||||
bindingID = strings.TrimSpace(bindingID)
|
||||
if bindingID == "" {
|
||||
return ""
|
||||
}
|
||||
return "fed_chain_" + bindingID
|
||||
}
|
||||
|
||||
func buildFederationMiddleChainConfig(chainName string, runtimeID int64, protocol, strategy string, targets []federationRuntimeTarget, interfaceName string) (map[string]interface{}, error) {
|
||||
chainName = strings.TrimSpace(chainName)
|
||||
if chainName == "" {
|
||||
return nil, fmt.Errorf("chain name is required")
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("targets are required for middle role")
|
||||
}
|
||||
protocol = defaultString(protocol, "tls")
|
||||
nodeItems := make([]map[string]interface{}, 0, len(targets))
|
||||
for i, target := range targets {
|
||||
host := strings.TrimSpace(target.Host)
|
||||
if host == "" || target.Port <= 0 {
|
||||
return nil, fmt.Errorf("Invalid target")
|
||||
}
|
||||
targetProtocol := defaultString(target.Protocol, protocol)
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTCPTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{
|
||||
"nodelay": true,
|
||||
"mux.keepaliveInterval": "15s",
|
||||
"mux.keepaliveTimeout": "45s",
|
||||
}
|
||||
}
|
||||
if isKCPTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{
|
||||
"connectTimeout": "30s",
|
||||
}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": connector,
|
||||
"dialer": buildTunnelDialerConfig(targetProtocol),
|
||||
})
|
||||
}
|
||||
|
||||
chainData := map[string]interface{}{
|
||||
"name": chainName,
|
||||
"hops": []map[string]interface{}{
|
||||
{
|
||||
"name": fmt.Sprintf("hop_%d", runtimeID),
|
||||
"selector": map[string]interface{}{
|
||||
"strategy": runtimeTunnelStrategy(strategy),
|
||||
"maxFails": 1,
|
||||
"failTimeout": int64(600000000000),
|
||||
},
|
||||
"nodes": nodeItems,
|
||||
},
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(interfaceName) != "" {
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
hops[0]["interface"] = interfaceName
|
||||
}
|
||||
return chainData, nil
|
||||
}
|
||||
|
||||
func updateChainPayload(chainName string, chainData map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"chain": chainName,
|
||||
"data": chainData,
|
||||
}
|
||||
}
|
||||
|
||||
type federationRuntimeDiagnoseRequest struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type federationRuntimeCommandRequest struct {
|
||||
@@ -141,6 +218,39 @@ type remoteUsageNodeItem struct {
|
||||
SyncError string `json:"syncError,omitempty"`
|
||||
}
|
||||
|
||||
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
|
||||
service := map[string]interface{}{
|
||||
"name": serviceName,
|
||||
"addr": addr,
|
||||
"handler": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"listener": buildTunnelListenerConfig(protocol),
|
||||
}
|
||||
if isTCPTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
|
||||
"nodelay": true,
|
||||
"mux.keepaliveInterval": "15s",
|
||||
"mux.keepaliveTimeout": "45s",
|
||||
}
|
||||
}
|
||||
if isKCPTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
|
||||
"connectTimeout": "30s",
|
||||
}
|
||||
}
|
||||
if role == "middle" {
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
if targetCount > 1 {
|
||||
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
|
||||
}
|
||||
}
|
||||
if role == "exit" && strings.TrimSpace(interfaceName) != "" {
|
||||
service["metadata"] = map[string]interface{}{"interface": interfaceName}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
@@ -609,6 +719,15 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("Remote URL and Token are required"))
|
||||
return
|
||||
}
|
||||
rURL, err := url.Parse(req.RemoteURL)
|
||||
if err != nil || (rURL.Scheme != "http" && rURL.Scheme != "https") {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid Remote URL format"))
|
||||
return
|
||||
}
|
||||
if err := IsSafeRemoteAddr(rURL.Host); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, "禁止将远程节点地址设置为内部网络"))
|
||||
return
|
||||
}
|
||||
|
||||
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
|
||||
localDomain := ""
|
||||
@@ -1012,7 +1131,43 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
protocol := defaultString(req.Protocol, runtime.Protocol)
|
||||
strategy := defaultString(req.Strategy, "round")
|
||||
chainName := defaultString(runtime.ChainName, federationRuntimeChainName(runtime.BindingID))
|
||||
if chainName == "" {
|
||||
chainName = federationRuntimeChainName(fmt.Sprintf("%d", runtime.ID))
|
||||
}
|
||||
serviceName := fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||
if runtime.Applied == 1 && strings.TrimSpace(runtime.BindingID) != "" {
|
||||
if req.Role == "middle" && len(req.Targets) > 0 {
|
||||
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
|
||||
if buildErr != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "UpdateChains", updateChainPayload(chainName, chainData), false, false); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
targetBytes, _ := json.Marshal(req.Targets)
|
||||
runtime.Role = req.Role
|
||||
runtime.ChainName = chainName
|
||||
runtime.Protocol = protocol
|
||||
runtime.Strategy = strategy
|
||||
runtime.Target = string(targetBytes)
|
||||
runtime.Status = 1
|
||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"bindingId": runtime.BindingID,
|
||||
"allocatedPort": runtime.Port,
|
||||
@@ -1032,89 +1187,28 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
protocol := defaultString(req.Protocol, runtime.Protocol)
|
||||
strategy := defaultString(req.Strategy, "round")
|
||||
chainName := fmt.Sprintf("fed_chain_%d", runtime.ID)
|
||||
serviceName := fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||
|
||||
if req.Role == "middle" {
|
||||
if len(req.Targets) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("targets are required for middle role"))
|
||||
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
|
||||
if buildErr != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
|
||||
return
|
||||
}
|
||||
nodeItems := make([]map[string]interface{}, 0, len(req.Targets))
|
||||
for i, target := range req.Targets {
|
||||
host := strings.TrimSpace(target.Host)
|
||||
if host == "" || target.Port <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
||||
return
|
||||
}
|
||||
targetProtocol := defaultString(target.Protocol, protocol)
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": targetProtocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
chainData := map[string]interface{}{
|
||||
"name": chainName,
|
||||
"hops": []map[string]interface{}{
|
||||
{
|
||||
"name": fmt.Sprintf("hop_%d", runtime.ID),
|
||||
"selector": map[string]interface{}{
|
||||
"strategy": strategy,
|
||||
"maxFails": 1,
|
||||
"failTimeout": int64(600000000000),
|
||||
},
|
||||
"nodes": nodeItems,
|
||||
},
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(node.InterfaceName) != "" {
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
hops[0]["interface"] = node.InterfaceName
|
||||
}
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
service := map[string]interface{}{
|
||||
"name": serviceName,
|
||||
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
|
||||
"handler": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": protocol,
|
||||
},
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
if req.Role == "middle" {
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
}
|
||||
if req.Role == "exit" && strings.TrimSpace(node.InterfaceName) != "" {
|
||||
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
|
||||
}
|
||||
targetCount := len(req.Targets)
|
||||
service := buildFederationServiceConfig(
|
||||
serviceName,
|
||||
fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
|
||||
protocol,
|
||||
req.Role,
|
||||
chainName,
|
||||
targetCount,
|
||||
node.InterfaceName,
|
||||
)
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
|
||||
if req.Role == "middle" {
|
||||
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
@@ -1232,16 +1326,25 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
||||
if req.Count <= 0 {
|
||||
req.Count = 4
|
||||
}
|
||||
if req.Timeout <= 0 {
|
||||
req.Timeout = 5000
|
||||
if req.Timeout <= 0 || req.Timeout > int(diagnosisCommandTimeout/time.Millisecond) {
|
||||
req.Timeout = int(diagnosisCommandTimeout / time.Millisecond)
|
||||
}
|
||||
commandTimeout := time.Duration(req.Timeout) * time.Millisecond
|
||||
if commandTimeout <= 0 || commandTimeout > diagnosisCommandTimeout {
|
||||
commandTimeout = diagnosisCommandTimeout
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, "TcpPing", map[string]interface{}{
|
||||
commandType := "TcpPing"
|
||||
if isUDPBasedProtocol(req.Protocol) {
|
||||
commandType = "UdpPing"
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommandWithTimeout(share.NodeID, commandType, map[string]interface{}{
|
||||
"ip": req.IP,
|
||||
"port": req.Port,
|
||||
"count": req.Count,
|
||||
"timeout": req.Timeout,
|
||||
}, false, false)
|
||||
}, commandTimeout, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -1472,7 +1575,7 @@ func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare,
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -227,6 +227,117 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if handler["chain"] != "chain-next" {
|
||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||
}
|
||||
if handler["retries"] != 2 {
|
||||
t.Fatalf("expected retries 2 for 3 targets, got %v", handler["retries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if handler["chain"] != "chain-next" {
|
||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||
}
|
||||
if _, hasRetries := handler["retries"]; hasRetries {
|
||||
t.Fatalf("expected no retries for single target, got %v", handler["retries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if _, hasChain := handler["chain"]; hasChain {
|
||||
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
|
||||
}
|
||||
if _, hasRetries := handler["retries"]; hasRetries {
|
||||
t.Fatalf("expected no retries for exit role, got %v", handler["retries"])
|
||||
}
|
||||
metadata := service["metadata"].(map[string]interface{})
|
||||
if metadata["interface"] != "eth0" {
|
||||
t.Fatalf("expected interface 'eth0', got %v", metadata["interface"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
meta := handler["metadata"].(map[string]interface{})
|
||||
if meta["nodelay"] != true {
|
||||
t.Fatalf("expected nodelay=true for TLS protocol, got %v", meta["nodelay"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if _, hasMeta := handler["metadata"]; hasMeta {
|
||||
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeChainNameDerivesFromBindingID(t *testing.T) {
|
||||
if got := federationRuntimeChainName("12"); got != "fed_chain_12" {
|
||||
t.Fatalf("expected fed_chain_12, got %q", got)
|
||||
}
|
||||
if got := federationRuntimeChainName(" 12 "); got != "fed_chain_12" {
|
||||
t.Fatalf("expected trimmed fed_chain_12, got %q", got)
|
||||
}
|
||||
if got := federationRuntimeChainName(""); got != "" {
|
||||
t.Fatalf("expected blank binding ID to stay blank, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationMiddleChainConfigUsesExistingChainNameAndBestStrategy(t *testing.T) {
|
||||
chainData, err := buildFederationMiddleChainConfig("fed_chain_12", 12, "tls", tunnelStrategyBest, []federationRuntimeTarget{
|
||||
{Host: "10.0.0.31", Port: 30031, Protocol: "tls"},
|
||||
{Host: "10.0.0.30", Port: 30030, Protocol: "tls"},
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("build chain: %v", err)
|
||||
}
|
||||
if chainData["name"] != "fed_chain_12" {
|
||||
t.Fatalf("expected existing chain name, got %v", chainData["name"])
|
||||
}
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
selector := hops[0]["selector"].(map[string]interface{})
|
||||
if selector["strategy"] != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best strategy to map to fifo, got %v", selector["strategy"])
|
||||
}
|
||||
nodes := hops[0]["nodes"].([]map[string]interface{})
|
||||
if nodes[0]["addr"] != "10.0.0.31:30031" || nodes[1]["addr"] != "10.0.0.30:30030" {
|
||||
t.Fatalf("expected target order to be preserved, got %+v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateChainPayloadWrapsChainDataForAgentUpdate(t *testing.T) {
|
||||
chainData := map[string]interface{}{
|
||||
"name": "fed_chain_12",
|
||||
"hops": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
payload := updateChainPayload("fed_chain_12", chainData)
|
||||
if len(payload) != 2 {
|
||||
t.Fatalf("expected exact wrapper with 2 keys, got %+v", payload)
|
||||
}
|
||||
if payload["chain"] != "fed_chain_12" {
|
||||
t.Fatalf("expected chain name in wrapper, got %v", payload["chain"])
|
||||
}
|
||||
wrappedData, ok := payload["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected wrapped chain data map, got %T", payload["data"])
|
||||
}
|
||||
chainData["name"] = "fed_chain_12_updated"
|
||||
if wrappedData["name"] != "fed_chain_12_updated" {
|
||||
t.Fatalf("expected wrapper to preserve chainData identity, got %+v", wrappedData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -21,6 +22,7 @@ type userTunnelPolicy struct {
|
||||
OutFlow int64
|
||||
ExpTime int64
|
||||
Status int
|
||||
Num int
|
||||
}
|
||||
|
||||
type gostConfigSnapshot struct {
|
||||
@@ -41,13 +43,19 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
||||
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
if h.forwardExists(forwardID) {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
}
|
||||
} else if nodeID > 0 {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
}
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -327,6 +335,90 @@ func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, now int64) error {
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("invalid flow policy context")
|
||||
}
|
||||
if userID <= 0 || tunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
if user.Status != 1 {
|
||||
return errors.New("账号已禁用")
|
||||
}
|
||||
if user.ExpTime > 0 && user.ExpTime <= now {
|
||||
return errors.New("账号已过期")
|
||||
}
|
||||
|
||||
flowLimit := user.Flow * bytesPerGB
|
||||
current := user.InFlow + user.OutFlow
|
||||
if flowLimit < current {
|
||||
return errors.New("流量已超额,禁止开启转发")
|
||||
}
|
||||
if err := h.ensureUserForwardAllowedByQuota(userID, now); err != nil {
|
||||
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
|
||||
}
|
||||
if userTunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
policy, err := h.getUserTunnelPolicy(userTunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if policy.Status != 1 {
|
||||
return errors.New("该隧道已禁用")
|
||||
}
|
||||
if policy.ExpTime > 0 && policy.ExpTime <= now {
|
||||
return errors.New("该隧道已过期")
|
||||
}
|
||||
|
||||
utFlowLimit := policy.Flow * bytesPerGB
|
||||
utCurrent := policy.InFlow + policy.OutFlow
|
||||
if utCurrent >= utFlowLimit {
|
||||
return errors.New("该隧道流量已超额,禁止开启转发")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
@@ -374,7 +466,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
|
||||
}
|
||||
|
||||
@@ -464,6 +556,14 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
}
|
||||
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) == 2 && parts[0] == "tunnel" {
|
||||
tunnelID, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(parts) >= 3 {
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
@@ -477,7 +577,7 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
suffix := parts[len(parts)-1]
|
||||
|
||||
switch suffix {
|
||||
case "tls":
|
||||
case "tls", "kcp", "wss", "mtls", "mwss", "mtcp":
|
||||
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
|
||||
continue
|
||||
@@ -485,6 +585,10 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
|
||||
case "tcp":
|
||||
if len(parts) < 4 {
|
||||
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
|
||||
}
|
||||
continue
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
@@ -539,6 +643,21 @@ func (h *Handler) forwardExists(forwardID int64) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
|
||||
parts := strings.Split(serviceName, "_")
|
||||
if len(parts) < 3 {
|
||||
return
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || forwardID <= 0 {
|
||||
return
|
||||
}
|
||||
base := parts[0] + "_" + parts[1] + "_" + parts[2]
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
|
||||
"services": []string{base + "_tcp", base + "_udp"},
|
||||
}, false, true)
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimiterExists(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type flowPolicyTarget struct {
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
}
|
||||
|
||||
type flowUploadBatch struct {
|
||||
flowDeltas []repo.FlowUploadCounterDelta
|
||||
quotaUsage map[int64]int64
|
||||
policyTargets []flowPolicyTarget
|
||||
forwardTraffic map[int64]tunnelTrafficDelta
|
||||
orphanServices map[string]struct{}
|
||||
peerShareForwardItems map[string]flowItem
|
||||
peerShareRuntimeItems map[int64]flowItem
|
||||
}
|
||||
|
||||
func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
|
||||
batch := flowUploadBatch{
|
||||
quotaUsage: make(map[int64]int64),
|
||||
forwardTraffic: make(map[int64]tunnelTrafficDelta),
|
||||
orphanServices: make(map[string]struct{}),
|
||||
peerShareForwardItems: make(map[string]flowItem),
|
||||
peerShareRuntimeItems: make(map[int64]flowItem),
|
||||
}
|
||||
policySeen := map[flowPolicyTarget]struct{}{}
|
||||
flowSeen := map[int64]int{}
|
||||
|
||||
for _, item := range items {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
continue
|
||||
}
|
||||
if runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName); ok {
|
||||
merged := batch.peerShareRuntimeItems[runtimeID]
|
||||
merged.N = serviceName
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareRuntimeItems[runtimeID] = merged
|
||||
continue
|
||||
}
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized := normalizeForwardRuntimeServiceName(serviceName)
|
||||
merged := batch.peerShareForwardItems[normalized]
|
||||
merged.N = normalized
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareForwardItems[normalized] = merged
|
||||
|
||||
meta, exists := metas[forwardID]
|
||||
if !exists {
|
||||
batch.orphanServices[serviceName] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
raw := batch.forwardTraffic[forwardID]
|
||||
raw.bytesIn += item.D
|
||||
raw.bytesOut += item.U
|
||||
batch.forwardTraffic[forwardID] = raw
|
||||
|
||||
scaledIn := int64(float64(item.D)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
scaledOut := int64(float64(item.U)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
if idx, ok := flowSeen[forwardID]; ok {
|
||||
batch.flowDeltas[idx].InFlow += scaledIn
|
||||
batch.flowDeltas[idx].OutFlow += scaledOut
|
||||
} else {
|
||||
flowSeen[forwardID] = len(batch.flowDeltas)
|
||||
batch.flowDeltas = append(batch.flowDeltas, repo.FlowUploadCounterDelta{
|
||||
ForwardID: forwardID,
|
||||
UserID: userID,
|
||||
UserTunnelID: userTunnelID,
|
||||
InFlow: scaledIn,
|
||||
OutFlow: scaledOut,
|
||||
})
|
||||
}
|
||||
batch.quotaUsage[userID] += scaledIn + scaledOut
|
||||
|
||||
target := flowPolicyTarget{UserID: userID, UserTunnelID: userTunnelID}
|
||||
if _, seen := policySeen[target]; !seen {
|
||||
policySeen[target] = struct{}{}
|
||||
batch.policyTargets = append(batch.policyTargets, target)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
sort.Slice(batch.policyTargets, func(i, j int) bool {
|
||||
if batch.policyTargets[i].UserID == batch.policyTargets[j].UserID {
|
||||
return batch.policyTargets[i].UserTunnelID < batch.policyTargets[j].UserTunnelID
|
||||
}
|
||||
return batch.policyTargets[i].UserID < batch.policyTargets[j].UserID
|
||||
})
|
||||
|
||||
return batch
|
||||
}
|
||||
|
||||
func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
h.applyFlowDeltasWithFallback(nodeID, batch.flowDeltas)
|
||||
for userID, quota := range h.applyQuotaUsageWithFallback(nodeID, batch.quotaUsage, now) {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
for _, target := range batch.policyTargets {
|
||||
if target.UserID <= 0 || target.UserTunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
|
||||
}
|
||||
for serviceName := range batch.orphanServices {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
}
|
||||
for serviceName, item := range batch.peerShareForwardItems {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
|
||||
if ok {
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
}
|
||||
}
|
||||
for runtimeID, item := range batch.peerShareRuntimeItems {
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) applyFlowDeltasWithFallback(nodeID int64, deltas []repo.FlowUploadCounterDelta) {
|
||||
if h == nil || h.repo == nil || len(deltas) == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.repo.ApplyFlowUploadDeltasBatch(deltas); err == nil {
|
||||
return
|
||||
} else {
|
||||
log.Printf("flow upload write failed op=flow.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
}
|
||||
for _, delta := range deltas {
|
||||
if err := h.repo.AddFlow(delta.ForwardID, delta.UserID, delta.UserTunnelID, delta.InFlow, delta.OutFlow); err != nil {
|
||||
log.Printf("flow upload write failed op=flow.single_apply node_id=%d forward_id=%d user_id=%d user_tunnel_id=%d err=%v", nodeID, delta.ForwardID, delta.UserID, delta.UserTunnelID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) applyQuotaUsageWithFallback(nodeID int64, usages map[int64]int64, now time.Time) map[int64]*model.UserQuotaView {
|
||||
if h == nil || h.repo == nil || len(usages) == 0 {
|
||||
return map[int64]*model.UserQuotaView{}
|
||||
}
|
||||
quotaViews, err := h.repo.AddUserQuotaUsageBatch(usages, now)
|
||||
if err == nil {
|
||||
return quotaViews
|
||||
}
|
||||
log.Printf("flow upload write failed op=quota.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
|
||||
userIDs := make([]int64, 0, len(usages))
|
||||
for userID := range usages {
|
||||
if userID > 0 {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
|
||||
quotaViews = make(map[int64]*model.UserQuotaView, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
quota, singleErr := h.repo.AddUserQuotaUsage(userID, usages[userID], now)
|
||||
if singleErr != nil {
|
||||
log.Printf("flow upload write failed op=quota.single_apply node_id=%d user_id=%d err=%v", nodeID, userID, singleErr)
|
||||
continue
|
||||
}
|
||||
if quota != nil {
|
||||
quotaViews[userID] = quota
|
||||
}
|
||||
}
|
||||
return quotaViews
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets(t *testing.T) {
|
||||
h := &Handler{}
|
||||
metas := map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {
|
||||
ForwardID: 20,
|
||||
TunnelID: 1,
|
||||
TrafficRatio: 2,
|
||||
TunnelFlow: 3,
|
||||
},
|
||||
}
|
||||
|
||||
batch := h.buildFlowUploadBatch([]flowItem{
|
||||
{N: "20_2_10", U: 70, D: 50},
|
||||
{N: "20_2_10_tcp", U: 40, D: 30},
|
||||
{N: "99_2_10", U: 12, D: 8},
|
||||
{N: "fed_svc_17", U: 9, D: 1},
|
||||
}, metas)
|
||||
|
||||
if len(batch.flowDeltas) != 1 {
|
||||
t.Fatalf("expected 1 flow delta, got %d", len(batch.flowDeltas))
|
||||
}
|
||||
delta := batch.flowDeltas[0]
|
||||
if delta.ForwardID != 20 || delta.UserID != 2 || delta.UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected flow delta identity: %#v", delta)
|
||||
}
|
||||
if delta.InFlow != 480 || delta.OutFlow != 660 {
|
||||
t.Fatalf("expected scaled flow in=480 out=660, got in=%d out=%d", delta.InFlow, delta.OutFlow)
|
||||
}
|
||||
if batch.quotaUsage[2] != 1140 {
|
||||
t.Fatalf("expected quota usage 1140, got %d", batch.quotaUsage[2])
|
||||
}
|
||||
if len(batch.policyTargets) != 1 {
|
||||
t.Fatalf("expected 1 policy target, got %d", len(batch.policyTargets))
|
||||
}
|
||||
if batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected policy target: %#v", batch.policyTargets[0])
|
||||
}
|
||||
traffic := batch.forwardTraffic[20]
|
||||
if traffic.bytesIn != 80 || traffic.bytesOut != 110 {
|
||||
t.Fatalf("expected raw traffic in=80 out=110, got in=%d out=%d", traffic.bytesIn, traffic.bytesOut)
|
||||
}
|
||||
if _, ok := batch.orphanServices["99_2_10"]; !ok {
|
||||
t.Fatalf("expected orphan service cleanup target for 99_2_10")
|
||||
}
|
||||
if item, ok := batch.peerShareForwardItems["99_2_10"]; !ok || item.U != 12 || item.D != 8 {
|
||||
t.Fatalf("expected orphan forward to remain eligible for peer-share accounting, got %#v ok=%v", item, ok)
|
||||
}
|
||||
if item, ok := batch.peerShareForwardItems["20_2_10"]; !ok || item.U != 110 || item.D != 80 {
|
||||
t.Fatalf("expected merged peer-share forward item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
if item, ok := batch.peerShareRuntimeItems[17]; !ok || item.U != 9 || item.D != 1 {
|
||||
t.Fatalf("expected merged peer-share runtime item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlowUploadBatchContinuesPolicyAndPeerShareSideEffectsWhenQuotaBatchFails(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "flow-upload-batch-quota-fail.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
if err := r.DB().Create(&model.User{ID: 2, User: "flow-user", Pwd: "pwd", RoleID: 1, ExpTime: 2727251700000, Flow: 99999, Num: 99999, CreatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Tunnel{ID: 1, Name: "tunnel-1", TrafficRatio: 1, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.UserTunnel{ID: 10, UserID: 2, TunnelID: 1, Num: 99999, Flow: 0, ExpTime: 2727251700000, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Forward{ID: 20, UserID: 2, UserName: "flow-user", Name: "forward-20", TunnelID: 1, RemoteAddr: "1.1.1.1:80", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{Name: "share", NodeID: 1, Token: "token", MaxBandwidth: 0, CurrentFlow: 0, PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: nowMs, UpdatedTime: nowMs}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, 1, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert peer share runtime: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
CREATE TRIGGER fail_user_quota_insert
|
||||
BEFORE INSERT ON user_quota
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'quota insert blocked for test');
|
||||
END;
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create quota failure trigger: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.applyFlowUploadBatch(1, flowUploadBatch{
|
||||
flowDeltas: []repo.FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 80, OutFlow: 120}},
|
||||
quotaUsage: map[int64]int64{2: 200},
|
||||
policyTargets: []flowPolicyTarget{{UserID: 2, UserTunnelID: 10}},
|
||||
peerShareForwardItems: map[string]flowItem{"20_2_10": {N: "20_2_10", U: 120, D: 80}},
|
||||
}, now)
|
||||
|
||||
if got := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected flow-policy enforcement to pause forward after quota failure, got status=%d", got)
|
||||
}
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected peer-share flow accounting to continue after quota failure, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlowUploadBatchContinuesPeerShareSideEffectsWhenFlowBatchFails(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "flow-upload-batch-flow-fail.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
if err := r.DB().Create(&model.User{ID: 2, User: "flow-user", Pwd: "pwd", RoleID: 1, ExpTime: 2727251700000, Flow: 99999, Num: 99999, CreatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Tunnel{ID: 1, Name: "tunnel-1", TrafficRatio: 1, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.UserTunnel{ID: 10, UserID: 2, TunnelID: 1, Num: 99999, Flow: 0, ExpTime: 2727251700000, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Forward{ID: 20, UserID: 2, UserName: "flow-user", Name: "forward-20", TunnelID: 1, RemoteAddr: "1.1.1.1:80", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Forward{ID: 21, UserID: 2, UserName: "flow-user", Name: "forward-21", TunnelID: 1, RemoteAddr: "1.1.1.1:81", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed second forward: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{Name: "share", NodeID: 1, Token: "token", MaxBandwidth: 0, CurrentFlow: 0, PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: nowMs, UpdatedTime: nowMs}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, 1, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert peer share runtime: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
CREATE TRIGGER fail_forward_flow_update
|
||||
BEFORE UPDATE ON forward
|
||||
WHEN NEW.id = 21 AND (NEW.in_flow != OLD.in_flow OR NEW.out_flow != OLD.out_flow)
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'forward flow update blocked for test');
|
||||
END;
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create flow failure trigger: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.applyFlowUploadBatch(1, flowUploadBatch{
|
||||
flowDeltas: []repo.FlowUploadCounterDelta{
|
||||
{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 80, OutFlow: 120},
|
||||
{ForwardID: 21, UserID: 2, UserTunnelID: 10, InFlow: 30, OutFlow: 40},
|
||||
},
|
||||
quotaUsage: map[int64]int64{2: 200},
|
||||
policyTargets: []flowPolicyTarget{{UserID: 2, UserTunnelID: 10}},
|
||||
peerShareForwardItems: map[string]flowItem{"20_2_10": {N: "20_2_10", U: 120, D: 80}},
|
||||
}, now)
|
||||
|
||||
if got := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected flow-policy enforcement to pause forward after flow batch failure, got status=%d", got)
|
||||
}
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected peer-share flow accounting to continue after flow batch failure, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM forward WHERE id = 20`); got != 80 {
|
||||
t.Fatalf("expected flow fallback to persist forward 20 in_flow=80, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM forward WHERE id = 21`); got != 0 {
|
||||
t.Fatalf("expected failed forward 21 delta to remain unapplied, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM user WHERE id = 2`); got != 80 {
|
||||
t.Fatalf("expected flow fallback to preserve successful user totals, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM user_tunnel WHERE id = 10`); got != 80 {
|
||||
t.Fatalf("expected flow fallback to preserve successful user_tunnel totals, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlowUploadBatchFallsBackToPerUserQuotaUpdates(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "flow-upload-batch-quota-fallback.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user 2: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(3, 'u3', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user 3: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) VALUES(2, 0, 0, 0, 0, ?, ?, 0, 0, '', ?, ?), (3, 0, 0, 0, 0, ?, ?, 0, 0, '', ?, ?)`, dayKey, monthKey, nowMs, nowMs, dayKey, monthKey, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user quotas: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
CREATE TRIGGER fail_user_3_quota_update
|
||||
BEFORE UPDATE ON user_quota
|
||||
WHEN NEW.user_id = 3 AND (NEW.daily_used_bytes != OLD.daily_used_bytes OR NEW.monthly_used_bytes != OLD.monthly_used_bytes)
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'quota update blocked for user 3');
|
||||
END;
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create quota fallback trigger: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.applyFlowUploadBatch(1, flowUploadBatch{quotaUsage: map[int64]int64{2: 200, 3: 300}}, now)
|
||||
|
||||
if got := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`); got != 200 {
|
||||
t.Fatalf("expected quota fallback to persist user 2 usage, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 3`); got != 0 {
|
||||
t.Fatalf("expected failed user 3 quota delta to remain unapplied, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing.T) {
|
||||
forward := &forwardRecord{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
ProxyProtocol: 2,
|
||||
}
|
||||
tunnel := &tunnelRecord{Type: 1}
|
||||
node := &nodeRecord{
|
||||
InterfaceName: "eth0",
|
||||
TCPListenAddr: "0.0.0.0",
|
||||
UDPListenAddr: "0.0.0.0",
|
||||
}
|
||||
|
||||
services := buildForwardServiceConfigs("1_2_3", forward, tunnel, node, 4001, "", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
serviceMetadata, ok := service["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata map, got %T", service["metadata"])
|
||||
}
|
||||
if serviceMetadata["interface"] != "eth0" {
|
||||
t.Fatalf("expected interface metadata eth0, got %v", serviceMetadata["interface"])
|
||||
}
|
||||
if _, ok := serviceMetadata["proxyProtocol"]; ok {
|
||||
t.Fatalf("proxyProtocol should not be listener metadata: %v", serviceMetadata)
|
||||
}
|
||||
|
||||
handlerConfig, ok := service["handler"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected handler config map, got %T", service["handler"])
|
||||
}
|
||||
handlerMetadata, ok := handlerConfig["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected handler metadata map, got %T", handlerConfig["metadata"])
|
||||
}
|
||||
if handlerMetadata["proxyProtocol"] != 2 {
|
||||
t.Fatalf("expected handler proxyProtocol 2, got %v", handlerMetadata["proxyProtocol"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackForwardMutationRestoresProxyProtocol(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 2,
|
||||
UserName: "rollback-user",
|
||||
Name: "rollback-forward",
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "9.9.9.9:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
IPMaxConn: 5,
|
||||
IPSpeedID: sql.NullInt64{Int64: 21, Valid: true},
|
||||
ProxyProtocol: 2,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create forward: %v", err)
|
||||
}
|
||||
|
||||
forwardID := mustLastInsertID(t, r, "rollback-forward")
|
||||
if err := r.DB().Model(&model.Forward{}).Where("id = ?", forwardID).Updates(map[string]interface{}{
|
||||
"name": "changed-forward",
|
||||
"ip_max_conn": 0,
|
||||
"ip_speed_id": nil,
|
||||
"proxy_protocol": 0,
|
||||
"updated_time": now + 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("mutate forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.rollbackForwardMutation(&forwardRecord{
|
||||
ID: forwardID,
|
||||
UserID: 2,
|
||||
UserName: "rollback-user",
|
||||
Name: "rollback-forward",
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "9.9.9.9:443",
|
||||
Strategy: "fifo",
|
||||
Status: 1,
|
||||
IPMaxConn: 5,
|
||||
IPSpeedID: sql.NullInt64{Int64: 21, Valid: true},
|
||||
ProxyProtocol: 2,
|
||||
}, nil)
|
||||
|
||||
var record model.Forward
|
||||
if err := r.DB().Where("id = ?", forwardID).First(&record).Error; err != nil {
|
||||
t.Fatalf("query forward: %v", err)
|
||||
}
|
||||
if record.ProxyProtocol != 2 {
|
||||
t.Fatalf("expected proxyProtocol restored to 2, got %d", record.ProxyProtocol)
|
||||
}
|
||||
if record.IPMaxConn != 5 {
|
||||
t.Fatalf("expected ipMaxConn restored to 5, got %d", record.IPMaxConn)
|
||||
}
|
||||
if !record.IPSpeedID.Valid || record.IPSpeedID.Int64 != 21 {
|
||||
t.Fatalf("expected ipSpeedId restored to 21, got %+v", record.IPSpeedID)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
@@ -15,17 +17,25 @@ 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/monitoring"
|
||||
"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
|
||||
@@ -35,10 +45,20 @@ type Handler struct {
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
upgradeMu sync.Mutex
|
||||
systemUpgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
nodeOnlineRedeployAt map[int64]time.Time
|
||||
nodeOnlineRedeployQueued map[int64]struct{}
|
||||
nodeOnlineRedeploying map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
bestExit *bestExitManager
|
||||
}
|
||||
|
||||
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
|
||||
const allowLocalRemoteAddrConfigKey = "allow_local_remote_addr"
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -59,6 +79,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"`
|
||||
@@ -72,15 +96,47 @@ type flowItem struct {
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
|
||||
const (
|
||||
pngDataURLPrefix = "data:image/png;base64,"
|
||||
maxBrandAssetDataURLBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
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{}),
|
||||
nodeOnlineRedeployAt: make(map[int64]time.Time),
|
||||
nodeOnlineRedeployQueued: make(map[int64]struct{}),
|
||||
nodeOnlineRedeploying: make(map[int64]struct{}),
|
||||
bestExit: newBestExitManager(),
|
||||
}
|
||||
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)
|
||||
})
|
||||
h.wsServer.SetUserAuthStateLookup(h.GetUserAuthState)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -88,6 +144,10 @@ func (h *Handler) WebSocketHandler() http.Handler {
|
||||
return h.wsServer
|
||||
}
|
||||
|
||||
func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
|
||||
return h.repo.GetUserAuthState(userID)
|
||||
}
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/login", h.login)
|
||||
mux.HandleFunc("/api/v1/user/list", h.userList)
|
||||
@@ -95,11 +155,18 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
|
||||
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
|
||||
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
|
||||
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
|
||||
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
|
||||
mux.HandleFunc("/api/v1/public/config/get", h.getPublicConfigByName)
|
||||
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
|
||||
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/system/storage", h.storageSummary)
|
||||
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
|
||||
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
|
||||
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
|
||||
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)
|
||||
@@ -116,6 +183,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/node/delete", h.nodeDelete)
|
||||
mux.HandleFunc("/api/v1/node/install", h.nodeInstall)
|
||||
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/node/dismiss-expiry-reminder", h.nodeDismissExpiryReminder)
|
||||
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
|
||||
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
|
||||
mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade)
|
||||
@@ -127,7 +195,12 @@ 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)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
||||
@@ -143,6 +216,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
|
||||
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
|
||||
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
|
||||
@@ -153,7 +227,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/delete", h.speedLimitDelete)
|
||||
mux.HandleFunc("/api/v1/speed-limit/tunnels", h.tunnelList)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/tunnel", h.userTunnelVisibleList)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/list", h.userTunnelList)
|
||||
mux.HandleFunc("/api/v1/group/tunnel/list", h.tunnelGroupList)
|
||||
@@ -187,6 +260,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)
|
||||
@@ -249,7 +340,8 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||
return
|
||||
}
|
||||
if user.Pwd != security.MD5(req.Password) {
|
||||
passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, req.Password)
|
||||
if !passwordMatched {
|
||||
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||
return
|
||||
}
|
||||
@@ -257,8 +349,20 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("账号被停用"))
|
||||
return
|
||||
}
|
||||
issueAt := time.Now()
|
||||
if passwordWasLegacy {
|
||||
updatedAt := time.Now().UnixMilli()
|
||||
hashedPassword, err := security.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
log.Printf("legacy password rehash skipped user_id=%d path=login err=%v", user.ID, err)
|
||||
} else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, updatedAt); err != nil {
|
||||
log.Printf("legacy password rehash update skipped user_id=%d path=login err=%v", user.ID, err)
|
||||
} else {
|
||||
issueAt = time.UnixMilli(updatedAt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(user.ID, user.User, user.RoleID, h.jwtSecret)
|
||||
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, issueAt)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -288,8 +392,19 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
switch configName {
|
||||
case "license_key", "cloudflare_secret_key", "jwt_secret":
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(req.Name)
|
||||
if _, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims); !ok && !repo.IsPublicConfigKey(configName) {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(configName)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -313,6 +428,12 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
|
||||
if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 {
|
||||
delete(cfgMap, "license_key")
|
||||
delete(cfgMap, "cloudflare_secret_key")
|
||||
delete(cfgMap, "jwt_secret")
|
||||
}
|
||||
response.WriteJSON(w, response.OK(cfgMap))
|
||||
}
|
||||
|
||||
@@ -382,6 +503,7 @@ func (h *Handler) tunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.attachBestExitStates(items)
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
@@ -459,10 +581,27 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if user == nil || user.Pwd != security.MD5(password) {
|
||||
if user == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
|
||||
return
|
||||
}
|
||||
passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, password)
|
||||
if !passwordMatched {
|
||||
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
|
||||
return
|
||||
}
|
||||
if user.Status == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("账号被停用"))
|
||||
return
|
||||
}
|
||||
if passwordWasLegacy {
|
||||
hashedPassword, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
log.Printf("legacy password rehash skipped user_id=%d path=sub_store err=%v", user.ID, err)
|
||||
} else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, time.Now().UnixMilli()); err != nil {
|
||||
log.Printf("legacy password rehash update skipped user_id=%d path=sub_store err=%v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
const giga = int64(1024 * 1024 * 1024)
|
||||
headerValue := ""
|
||||
@@ -564,7 +703,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
"userId": t.UserID,
|
||||
"tunnelId": t.TunnelID,
|
||||
"tunnelName": t.TunnelName,
|
||||
"status": 1,
|
||||
"status": t.Status,
|
||||
"flow": t.Flow,
|
||||
"num": t.Num,
|
||||
"expTime": t.ExpTime,
|
||||
@@ -715,9 +854,16 @@ 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 {
|
||||
for _, item := range items {
|
||||
h.processFlowItem(node.ID, item)
|
||||
now := time.Now()
|
||||
forwardIDs := collectFlowUploadForwardIDs(items)
|
||||
metas, metaErr := h.repo.GetFlowUploadForwardMetas(forwardIDs)
|
||||
if metaErr != nil {
|
||||
log.Printf("flow upload metadata lookup failed node_id=%d err=%v", node.ID, metaErr)
|
||||
metas = map[int64]repo.FlowUploadForwardMeta{}
|
||||
}
|
||||
batch := h.buildFlowUploadBatch(items, metas)
|
||||
h.recordTunnelMetricsFromForwardBatch(node.ID, batch.forwardTraffic, metas, now.UnixMilli())
|
||||
h.applyFlowUploadBatch(node.ID, batch, now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,6 +871,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("请求失败"))
|
||||
@@ -741,13 +975,37 @@ 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)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpsertConfig(key, v, now); err != nil {
|
||||
if repo.IsSensitiveConfigKey(key) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
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()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(key, value, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -767,16 +1025,34 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Value) == "" {
|
||||
if repo.IsSensitiveConfigKey(name) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
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()))
|
||||
return
|
||||
}
|
||||
|
||||
if value == "" && name != "app_logo" && name != "app_favicon" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil {
|
||||
if err := h.repo.UpsertConfig(name, value, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -784,6 +1060,73 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "app_logo", "app_favicon":
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(normalized, pngDataURLPrefix) {
|
||||
return "", fmt.Errorf("品牌图片必须通过上传生成 PNG 数据")
|
||||
}
|
||||
|
||||
if len(normalized) > maxBrandAssetDataURLBytes {
|
||||
return "", fmt.Errorf("品牌图片过大,请上传更小图片")
|
||||
}
|
||||
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(normalized, pngDataURLPrefix))
|
||||
if payload == "" {
|
||||
return "", fmt.Errorf("品牌图片数据不能为空")
|
||||
}
|
||||
|
||||
if _, err := base64.StdEncoding.DecodeString(payload); err != nil {
|
||||
return "", fmt.Errorf("品牌图片数据格式无效")
|
||||
}
|
||||
|
||||
return pngDataURLPrefix + payload, nil
|
||||
case monitorTunnelQualityEnabledConfigKey:
|
||||
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||
switch normalized {
|
||||
case "true", "false":
|
||||
return normalized, nil
|
||||
default:
|
||||
return "", fmt.Errorf("隧道质量检测开关配置值无效")
|
||||
}
|
||||
case monitoring.ConfigMonitorRetentionDays:
|
||||
return monitoring.NormalizeMonitoringRetentionDays(value)
|
||||
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) allowLocalRemoteAddr() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(allowLocalRemoteAddrConfigKey)
|
||||
if err != nil || cfg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.ToLower(cfg.Value)) == "true"
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -961,7 +1304,8 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.Pwd != security.MD5(req.CurrentPassword) {
|
||||
passwordMatched, _ := security.VerifyPassword(user.Pwd, req.CurrentPassword)
|
||||
if !passwordMatched {
|
||||
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
|
||||
return
|
||||
}
|
||||
@@ -976,7 +1320,12 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, security.MD5(req.NewPassword), time.Now().UnixMilli()); err != nil {
|
||||
hashedPassword, err := security.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, hashedPassword, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -989,10 +1338,27 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cfg == nil {
|
||||
if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") {
|
||||
return false, nil
|
||||
}
|
||||
return strings.EqualFold(cfg.Value, "true"), nil
|
||||
|
||||
siteCfg, err := h.repo.GetConfigByName("cloudflare_site_key")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if siteCfg == nil || strings.TrimSpace(siteCfg.Value) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
|
||||
@@ -1114,6 +1480,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)
|
||||
@@ -1134,8 +1515,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)
|
||||
@@ -1261,15 +1642,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,11 +20,68 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(2)
|
||||
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() {
|
||||
@@ -46,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 {
|
||||
return
|
||||
}
|
||||
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
@@ -135,6 +217,7 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
|
||||
}
|
||||
|
||||
h.resetMonthlyFlow(now)
|
||||
h.resetUserQuotaWindows(now)
|
||||
h.disableExpiredUsers(now.UnixMilli())
|
||||
h.disableExpiredUserTunnels(now.UnixMilli())
|
||||
}
|
||||
@@ -176,3 +259,39 @@ func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
|
||||
_ = h.repo.DisableUserTunnel(item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
for {
|
||||
wait := durationUntilNextNodeRenewalCycle(time.Now())
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
h.runNodeRenewalCycleJob(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration {
|
||||
next := now.Truncate(6 * time.Hour).Add(6 * time.Hour)
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
func (h *Handler) runNodeRenewalCycleJob(now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = advanced
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/renewal-test.db"
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
nodeID := int64(101)
|
||||
err = r.DB().Exec(`
|
||||
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert test node: %v", err)
|
||||
}
|
||||
|
||||
quarterNodeID := int64(102)
|
||||
err = r.DB().Exec(`
|
||||
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert test node: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.runNodeRenewalCycleJob(now)
|
||||
|
||||
var anchor sql.NullInt64
|
||||
err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("query expiry_time: %v", err)
|
||||
}
|
||||
|
||||
expectedAnchor := now.AddDate(0, 2, 0).UnixMilli()
|
||||
if !anchor.Valid || anchor.Int64 != expectedAnchor {
|
||||
t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64)
|
||||
}
|
||||
}
|
||||
@@ -143,3 +143,40 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetAndExpiryJobResetsUserQuotaAndUnblocksUser(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "secret")
|
||||
now := time.Date(2026, 3, 12, 0, 0, 5, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'quota-reset-user', 'x', 1, 0, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(2, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
||||
`, 11*int64(1024*1024*1024), 11*int64(1024*1024*1024), nowMs, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user quota: %v", err)
|
||||
}
|
||||
|
||||
h.runResetAndExpiryJob(now)
|
||||
|
||||
dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`)
|
||||
if dailyUsed != 0 {
|
||||
t.Fatalf("expected daily quota usage reset, got %d", dailyUsed)
|
||||
}
|
||||
quotaDisabled := mustQueryInt(t, r, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
|
||||
if quotaDisabled != 0 {
|
||||
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
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
|
||||
}
|
||||
|
||||
targetsByTunnelID := map[int64]tunnelProbeTarget{}
|
||||
if tunnels, listErr := h.repo.ListTunnels(); listErr == nil {
|
||||
for _, item := range tunnels {
|
||||
id := asInt64(item["id"], 0)
|
||||
if id > 0 {
|
||||
targetsByTunnelID[id] = effectiveTunnelProbeTargetValues(asString(item["probeTargetHost"]), asInt(item["probeTargetPort"], 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
|
||||
for _, q := range qualities {
|
||||
target := targetsByTunnelID[q.TunnelID]
|
||||
if target.Host == "" {
|
||||
target = defaultTunnelProbeTarget()
|
||||
}
|
||||
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,
|
||||
ProbeTargetHost: target.Host,
|
||||
ProbeTargetPort: target.Port,
|
||||
})
|
||||
}
|
||||
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())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP(t *testing.T) {
|
||||
entryNodeIDs := []int64{10, 20, 30}
|
||||
oldPorts := []forwardPortRecord{
|
||||
{NodeID: 10, Port: 10001, InIP: ""},
|
||||
{NodeID: 10, Port: 10002, InIP: "10.0.0.10"},
|
||||
{NodeID: 20, Port: 10003, InIP: "10.0.0.20"},
|
||||
}
|
||||
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, 18080)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].NodeID != 10 || entries[0].Port != 18080 || entries[0].InIP != "10.0.0.10" {
|
||||
t.Fatalf("unexpected first entry: %+v", entries[0])
|
||||
}
|
||||
if entries[1].NodeID != 20 || entries[1].Port != 18080 || entries[1].InIP != "10.0.0.20" {
|
||||
t.Fatalf("unexpected second entry: %+v", entries[1])
|
||||
}
|
||||
if entries[2].NodeID != 30 || entries[2].Port != 18080 || entries[2].InIP != "" {
|
||||
t.Fatalf("unexpected third entry: %+v", entries[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP_EmptyOldPorts(t *testing.T) {
|
||||
entryNodeIDs := []int64{99}
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, nil, 17000)
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].NodeID != 99 || entries[0].Port != 17000 || entries[0].InIP != "" {
|
||||
t.Fatalf("unexpected entry: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestReconstructTunnelState_PreservesConnectIP(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "reconstruct-connect-ip.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'reconstruct-tunnel', 1.0, 2, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
insertNode := func(id int64, name, ip string) {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
insertNode(101, "entry", "10.90.0.10")
|
||||
insertNode(102, "middle", "10.90.0.20")
|
||||
insertNode(103, "exit", "10.90.0.30")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(1, '1', 101, 30001, 'round', 1, 'tls')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(1, '2', 102, 30002, 'round', 1, 'tls', '10.99.9.22')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(1, '3', 103, 30003, 'round', 1, 'tls', '10.99.9.33')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
state, err := h.reconstructTunnelState(1)
|
||||
if err != nil {
|
||||
t.Fatalf("reconstructTunnelState: %v", err)
|
||||
}
|
||||
|
||||
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
|
||||
t.Fatalf("unexpected chain hops: %+v", state.ChainHops)
|
||||
}
|
||||
if got := state.ChainHops[0][0].ConnectIP; got != "10.99.9.22" {
|
||||
t.Fatalf("expected middle connectIp 10.99.9.22, got %q", got)
|
||||
}
|
||||
|
||||
if len(state.OutNodes) != 1 {
|
||||
t.Fatalf("unexpected out nodes: %+v", state.OutNodes)
|
||||
}
|
||||
if got := state.OutNodes[0].ConnectIP; got != "10.99.9.33" {
|
||||
t.Fatalf("expected exit connectIp 10.99.9.33, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DisableSafeRemoteAddrCheckForTesting allows bypassing the safety check during integration tests.
|
||||
var DisableSafeRemoteAddrCheckForTesting = false
|
||||
|
||||
// IsSafeRemoteAddr checks if a given address is safe to connect to (prevents SSRF/Open Proxy).
|
||||
// It resolves domains to IPs to prevent DNS rebinding attacks pointing to internal networks.
|
||||
// Supports multiple addresses separated by commas or newlines (one per line).
|
||||
func IsSafeRemoteAddr(addr string) error {
|
||||
if DisableSafeRemoteAddrCheckForTesting {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, part := range splitRemoteParts(addr) {
|
||||
if err := checkSingleRemoteAddr(part); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitRemoteParts splits a multi-address string by commas and newlines.
|
||||
func splitRemoteParts(addr string) []string {
|
||||
addr = strings.ReplaceAll(addr, "\n", ",")
|
||||
addr = strings.ReplaceAll(addr, "\r", ",")
|
||||
parts := strings.Split(addr, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkSingleRemoteAddr validates a single address.
|
||||
func checkSingleRemoteAddr(addr string) error {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "missing port in address") {
|
||||
host = addr
|
||||
} else {
|
||||
return fmt.Errorf("invalid address format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve address %q: %v", addr, err)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if ip.IsLoopback() || ip.IsPrivate() {
|
||||
return fmt.Errorf("address %q resolves to internal IP: %s", addr, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsValidNodeAddress ensures the address is strictly a host or host:port.
|
||||
// It explicitly denies schemes (http://, https://), paths (/...), and query params (?).
|
||||
func IsValidNodeAddress(addr string) error {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if strings.Contains(addr, "://") {
|
||||
return fmt.Errorf("address must not contain scheme (e.g. http://)")
|
||||
}
|
||||
if strings.ContainsAny(addr, "/?") {
|
||||
return fmt.Errorf("address must not contain path or query parameters")
|
||||
}
|
||||
|
||||
_, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "missing port in address") {
|
||||
return fmt.Errorf("invalid address format")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func (h *Handler) storageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if h == nil || h.repo == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "repository not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.repo.DatabaseStorageSummary()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(summary))
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const (
|
||||
panelDeployDirEnv = "PANEL_DEPLOY_DIR"
|
||||
panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER"
|
||||
defaultPanelDeployDir = "/opt/flvx-panel"
|
||||
defaultPanelBackendName = "flux-panel-backend"
|
||||
dockerSocketPath = "/var/run/docker.sock"
|
||||
maxSystemUpgradeComposeAssetBytes = 1 << 20
|
||||
systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启"
|
||||
systemUpgradeConflictError = "已有面板升级任务执行中"
|
||||
)
|
||||
|
||||
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`)
|
||||
var systemUpgradeReleaseBaseURL = githubHTMLBase
|
||||
var systemUpgradeAPIBaseURL = githubAPIBase
|
||||
var systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
|
||||
return client.Get(url)
|
||||
}
|
||||
|
||||
type systemUpgradeExecutor struct {
|
||||
deployDir string
|
||||
backendContainer string
|
||||
}
|
||||
|
||||
type systemUpgradeCapabilityData struct {
|
||||
Capable bool `json:"capable"`
|
||||
Reasons []string `json:"reasons"`
|
||||
DeployDir string `json:"deployDir"`
|
||||
BackendContainer string `json:"backendContainer"`
|
||||
}
|
||||
|
||||
type systemUpgradeReleaseData struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
type systemUpgradeVersionData struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
HasUpdate bool `json:"hasUpdate"`
|
||||
Channel string `json:"channel"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Capability systemUpgradeCapabilityData `json:"capability"`
|
||||
}
|
||||
|
||||
type systemUpgradeCheckData struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
HasUpdate bool `json:"hasUpdate"`
|
||||
Channel string `json:"channel"`
|
||||
Capability systemUpgradeCapabilityData `json:"capability"`
|
||||
Releases []systemUpgradeReleaseData `json:"releases"`
|
||||
}
|
||||
|
||||
type systemUpgradeRunData struct {
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
ComposeAsset string `json:"composeAsset"`
|
||||
HelperContainer string `json:"helperContainer"`
|
||||
BackendImageID string `json:"backendImageId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type systemUpgradeRequest struct {
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
func newSystemUpgradeExecutor() *systemUpgradeExecutor {
|
||||
deployDir := strings.TrimSpace(os.Getenv(panelDeployDirEnv))
|
||||
if deployDir == "" {
|
||||
deployDir = defaultPanelDeployDir
|
||||
}
|
||||
backendContainer := strings.TrimSpace(os.Getenv(panelBackendContainerEnv))
|
||||
if backendContainer == "" {
|
||||
backendContainer = defaultPanelBackendName
|
||||
}
|
||||
return &systemUpgradeExecutor{deployDir: deployDir, backendContainer: backendContainer}
|
||||
}
|
||||
|
||||
func currentPanelVersion() string {
|
||||
version := strings.TrimSpace(os.Getenv("FLUX_VERSION"))
|
||||
if version == "" {
|
||||
return "dev"
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func validateBackendContainerName(value string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("backend container name is empty")
|
||||
}
|
||||
if !safeBackendContainerPattern.MatchString(value) {
|
||||
return fmt.Errorf("unsafe backend container name: %s", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUpgradeVersion(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("upgrade version is empty")
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("unsafe upgrade version: contains control character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) composePath() string {
|
||||
return filepath.Join(e.deployDir, "docker-compose.yml")
|
||||
}
|
||||
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
|
||||
|
||||
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
|
||||
reasons := make([]string, 0)
|
||||
if !filepath.IsAbs(e.deployDir) {
|
||||
reasons = append(reasons, "部署目录必须是绝对路径")
|
||||
}
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "docker", "--version").CombinedOutput(); err != nil {
|
||||
reasons = append(reasons, fmt.Sprintf("docker CLI不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
||||
}
|
||||
if info, err := os.Stat(dockerSocketPath); err != nil {
|
||||
reasons = append(reasons, "docker socket不可用: "+err.Error())
|
||||
} else if info.IsDir() {
|
||||
reasons = append(reasons, "docker socket路径不是文件")
|
||||
}
|
||||
if info, err := os.Stat(e.composePath()); err != nil {
|
||||
reasons = append(reasons, "部署docker-compose.yml不可用: "+err.Error())
|
||||
} else if info.IsDir() {
|
||||
reasons = append(reasons, "部署docker-compose.yml不是文件")
|
||||
}
|
||||
if info, err := os.Stat(e.envPath()); err != nil {
|
||||
reasons = append(reasons, "部署.env不可用: "+err.Error())
|
||||
} else if info.IsDir() {
|
||||
reasons = append(reasons, "部署.env不是文件")
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "docker", "compose", "version").CombinedOutput(); err != nil {
|
||||
reasons = append(reasons, fmt.Sprintf("docker compose不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
||||
}
|
||||
if _, err := e.currentBackendImage(ctx); err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
|
||||
return systemUpgradeCapabilityData{
|
||||
Capable: len(reasons) == 0,
|
||||
Reasons: reasons,
|
||||
DeployDir: e.deployDir,
|
||||
BackendContainer: e.backendContainer,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
|
||||
if enableIPv6ComposePattern.Match(current) {
|
||||
return "docker-compose-v6.yml"
|
||||
}
|
||||
return "docker-compose-v4.yml"
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) helperScript() string {
|
||||
return `set -eu
|
||||
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
|
||||
|
||||
cd "$PANEL_DEPLOY_DIR"
|
||||
echo "" > "$LOGFILE"
|
||||
log "开始面板升级"
|
||||
log "工作目录: $(pwd)"
|
||||
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
log "错误: docker-compose.yml 不存在"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f .env ]; then
|
||||
log "错误: .env 不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "拉取新镜像..."
|
||||
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
|
||||
log "错误: 拉取镜像失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "等待旧容器释放资源..."
|
||||
sleep 3
|
||||
|
||||
log "重启服务(force-recreate)..."
|
||||
if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
|
||||
log "错误: 重启服务失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "升级完成"
|
||||
`
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{
|
||||
"run", "-d", "--rm", "--name", helperName,
|
||||
"--volumes-from", e.backendContainer,
|
||||
"-v", dockerSocketPath + ":" + dockerSocketPath,
|
||||
"-e", panelDeployDirEnv + "=" + e.deployDir,
|
||||
"--entrypoint", "/bin/sh", imageID,
|
||||
"-c", e.helperScript(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
|
||||
if err := validateUpgradeVersion(version); err != nil {
|
||||
return err
|
||||
}
|
||||
mode, err := fileModeOrDefault(envPath, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "FLUX_VERSION=") {
|
||||
lines[i] = "FLUX_VERSION=" + version
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n")
|
||||
if trimmed == "" {
|
||||
trimmed = "FLUX_VERSION=" + version
|
||||
} else {
|
||||
trimmed += "\nFLUX_VERSION=" + version
|
||||
}
|
||||
return writeFileWithMode(envPath, []byte(trimmed+"\n"), mode)
|
||||
}
|
||||
content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
|
||||
return writeFileWithMode(envPath, []byte(content), mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) backupFile(path string) (string, error) {
|
||||
mode, err := fileModeOrDefault(path, 0o600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
backupPath := path + ".upgrade.bak"
|
||||
if err := writeFileWithMode(backupPath, data, mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) restoreBackup(path string) error {
|
||||
backupPath := path + ".upgrade.bak"
|
||||
mode, err := fileModeOrDefault(backupPath, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileWithMode(path, data, mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) restoreUpgradeBackups(paths ...string) error {
|
||||
var errs []string
|
||||
for _, path := range paths {
|
||||
if err := e.restoreBackup(path); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return fmt.Errorf("compose asset is empty")
|
||||
}
|
||||
mode, err := fileModeOrDefault(path, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileWithMode(path, data, mode)
|
||||
}
|
||||
|
||||
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
|
||||
if enabled {
|
||||
return fmt.Sprintf("%s/%s", proxyURL, base)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (h *Handler) fetchSystemUpgradeReleases(perPage int) ([]githubRelease, error) {
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
url := fmt.Sprintf("%s/repos/%s/releases?per_page=%d", strings.TrimRight(systemUpgradeAPIBaseURL, "/"), githubRepo, perPage)
|
||||
if enabled, proxyURL := h.getGithubProxyConfig(); enabled {
|
||||
url = fmt.Sprintf("%s/%s", proxyURL, url)
|
||||
}
|
||||
|
||||
resp, err := systemUpgradeHTTPGet(client, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (string, error) {
|
||||
normalizedChannel := normalizeReleaseChannel(channel)
|
||||
releases, err := h.fetchSystemUpgradeReleases(50)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if releaseChannelFromTag(tag) == normalizedChannel {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||
}
|
||||
|
||||
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fallback, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return info.Mode().Perm(), nil
|
||||
}
|
||||
|
||||
func writeFileWithMode(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.WriteFile(path, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect backend image failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
imageID := strings.TrimSpace(string(out))
|
||||
if imageID == "" {
|
||||
return "", fmt.Errorf("backend image id is empty")
|
||||
}
|
||||
return imageID, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helperName string) (string, error) {
|
||||
args, err := e.buildHelperRunArgs(imageID, helperName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("start helper failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
containerID := strings.TrimSpace(string(out))
|
||||
if containerID == "" {
|
||||
containerID = helperName
|
||||
}
|
||||
return containerID, nil
|
||||
}
|
||||
|
||||
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
|
||||
url := h.buildSystemUpgradeDownloadURL(version, filename)
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := systemUpgradeHTTPGet(client, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载%s失败: %v", filename, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("下载%s返回 %d: %s", filename, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSystemUpgradeComposeAssetBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取%s失败: %v", filename, err)
|
||||
}
|
||||
if len(body) > maxSystemUpgradeComposeAssetBytes {
|
||||
return nil, fmt.Errorf("下载%s过大", filename)
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil, fmt.Errorf("下载%s内容为空", filename)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func releasesForChannel(releases []githubRelease, channel string) []systemUpgradeReleaseData {
|
||||
channel = normalizeReleaseChannel(channel)
|
||||
items := make([]systemUpgradeReleaseData, 0, len(releases))
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
itemChannel := releaseChannelFromTag(tag)
|
||||
if itemChannel != channel {
|
||||
continue
|
||||
}
|
||||
items = append(items, systemUpgradeReleaseData{
|
||||
Version: tag,
|
||||
Name: r.Name,
|
||||
PublishedAt: r.PublishedAt,
|
||||
Prerelease: itemChannel == releaseChannelDev,
|
||||
Channel: itemChannel,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(req)
|
||||
}
|
||||
|
||||
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
|
||||
data := systemUpgradeVersionData{
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
HasUpdate: latest != "" && latest != current,
|
||||
Channel: channel,
|
||||
Capability: capability,
|
||||
}
|
||||
if lookupErr != nil {
|
||||
data.LatestVersion = ""
|
||||
data.HasUpdate = false
|
||||
data.Reason = lookupErr.Error()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := releaseChannelStable
|
||||
current := currentPanelVersion()
|
||||
exec := newSystemUpgradeExecutor()
|
||||
capability := exec.capability(r.Context())
|
||||
latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel)
|
||||
response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability)))
|
||||
}
|
||||
|
||||
func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req systemUpgradeRequest
|
||||
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
current := currentPanelVersion()
|
||||
exec := newSystemUpgradeExecutor()
|
||||
capability := exec.capability(r.Context())
|
||||
|
||||
githubReleases, err := h.fetchSystemUpgradeReleases(50)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
releases := releasesForChannel(githubReleases, channel)
|
||||
latest := ""
|
||||
if len(releases) > 0 {
|
||||
latest = releases[0].Version
|
||||
}
|
||||
response.WriteJSON(w, response.OK(systemUpgradeCheckData{
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
HasUpdate: latest != "" && latest != current,
|
||||
Channel: channel,
|
||||
Capability: capability,
|
||||
Releases: releases,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.systemUpgradeMu.TryLock() {
|
||||
response.WriteJSON(w, response.ErrDefault(systemUpgradeConflictError))
|
||||
return
|
||||
}
|
||||
defer h.systemUpgradeMu.Unlock()
|
||||
|
||||
var req systemUpgradeRequest
|
||||
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
exec := newSystemUpgradeExecutor()
|
||||
capability := exec.capability(r.Context())
|
||||
if !capability.Capable {
|
||||
response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; ")))
|
||||
return
|
||||
}
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = h.resolveSystemUpgradeLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
imageID, err := exec.currentBackendImage(r.Context())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
composePath := exec.composePath()
|
||||
envPath := exec.envPath()
|
||||
composeData, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
composeAsset := exec.selectComposeAsset(composeData)
|
||||
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := exec.backupFile(composePath); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "备份compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := exec.backupFile(envPath); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "备份.env失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := exec.replaceCompose(composePath, newCompose); err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, "替换compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, "更新版本配置失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
|
||||
helperContainer, err := exec.startHelper(r.Context(), imageID, helperName)
|
||||
if err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(systemUpgradeRunData{
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
ComposeAsset: composeAsset,
|
||||
HelperContainer: helperContainer,
|
||||
BackendImageID: imageID,
|
||||
Message: systemUpgradeMessage,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
compose := []byte("networks:\n gost-network:\n enable_ipv6: true\n")
|
||||
|
||||
if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" {
|
||||
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v6.yml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadReleaseAssetUsesGithubProxyWhenEnabled(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
repoStore, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("repo.Open() error = %v", err)
|
||||
}
|
||||
defer repoStore.Close()
|
||||
|
||||
h := &Handler{repo: repoStore}
|
||||
originalBase := systemUpgradeReleaseBaseURL
|
||||
systemUpgradeReleaseBaseURL = "https://example.invalid"
|
||||
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
|
||||
|
||||
originalGet := systemUpgradeHTTPGet
|
||||
defer func() { systemUpgradeHTTPGet = originalGet }()
|
||||
|
||||
var gotURL string
|
||||
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
|
||||
gotURL = url
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("services:\n backend:\n image: test\n")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repoStore.UpsertConfig("github_proxy_enabled", "true", now); err != nil {
|
||||
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
|
||||
}
|
||||
if err := repoStore.UpsertConfig("github_proxy_url", "https://proxy.example.com", now); err != nil {
|
||||
t.Fatalf("UpsertConfig() github_proxy_url error = %v", err)
|
||||
}
|
||||
|
||||
data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadReleaseAsset() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "backend") {
|
||||
t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data))
|
||||
}
|
||||
|
||||
wantURL := "https://proxy.example.com/https://example.invalid/Sagit-chu/flvx/releases/download/2.1.9/docker-compose-v4.yml"
|
||||
if gotURL != wantURL {
|
||||
t.Fatalf("download URL = %q, want %q", gotURL, wantURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
repoStore, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("repo.Open() error = %v", err)
|
||||
}
|
||||
defer repoStore.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repoStore.UpsertConfig("github_proxy_enabled", "false", now); err != nil {
|
||||
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
|
||||
}
|
||||
|
||||
originalBase := systemUpgradeReleaseBaseURL
|
||||
systemUpgradeReleaseBaseURL = "https://example.invalid"
|
||||
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
|
||||
|
||||
originalGet := systemUpgradeHTTPGet
|
||||
defer func() { systemUpgradeHTTPGet = originalGet }()
|
||||
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1))),
|
||||
}, nil
|
||||
}
|
||||
|
||||
h := &Handler{repo: repoStore}
|
||||
_, err = h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
|
||||
if err == nil || !strings.Contains(err.Error(), "过大") {
|
||||
t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectComposeAssetUsesIPv6TemplateForYAMLVariants(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
for _, compose := range [][]byte{
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6:true\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: True\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: \"true\"\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: 'true'\n"),
|
||||
[]byte("networks:\n gost-network:\n enable_ipv6: true # comment\n"),
|
||||
} {
|
||||
if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" {
|
||||
t.Fatalf("selectComposeAsset(%q) = %q, want %q", string(compose), got, "docker-compose-v6.yml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
compose := []byte("services:\n backend:\n image: test\n")
|
||||
|
||||
if got := exec.selectComposeAsset(compose); got != "docker-compose-v4.yml" {
|
||||
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v4.yml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionRejectsUnsafeValue(t *testing.T) {
|
||||
for _, version := range []string{"", "2.1.9\nJWT_SECRET=bad", "2.1.9\rbad", "2.1.9\x00bad", "2.1.9\x1fbad"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
original := []byte("JWT_SECRET=test\n")
|
||||
if err := os.WriteFile(envPath, original, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err == nil {
|
||||
t.Fatal("expected unsafe version to fail validation")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != string(original) {
|
||||
t.Fatalf("env content changed to %q, want %q", string(data), string(original))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAcceptsVersionLabels(t *testing.T) {
|
||||
for _, version := range []string{"2.1.9", "2.1.9-beta14", "v-test"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionPreservesFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("env mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
|
||||
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
|
||||
t.Fatal("expected unsafe container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
args, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
|
||||
if err != nil {
|
||||
t.Fatalf("buildHelperRunArgs() error = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"run", "-d", "--rm", "--name", "flvx-upgrade-helper",
|
||||
"--volumes-from", "flux-panel-backend",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
|
||||
"--entrypoint", "/bin/sh", "sha256:abc",
|
||||
"-c", exec.helperScript(),
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsRejectsUnsafeBackendContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend;rm -rf /"}
|
||||
if _, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper"); err == nil {
|
||||
t.Fatal("expected unsafe backend container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.systemVersion(rr, req)
|
||||
|
||||
if !strings.Contains(rr.Body.String(), "请求失败") {
|
||||
t.Fatalf("expected wrong-method response, got %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpgradeRejectsConcurrentRequests(t *testing.T) {
|
||||
h := &Handler{}
|
||||
h.systemUpgradeMu.Lock()
|
||||
defer h.systemUpgradeMu.Unlock()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.systemUpgrade(rr, req)
|
||||
|
||||
if !strings.Contains(rr.Body.String(), systemUpgradeConflictError) {
|
||||
t.Fatalf("expected conflict message, got %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
composePath := filepath.Join(dir, "docker-compose.yml")
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(composePath, []byte("services:\n backend:\n image: test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() compose error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() env error = %v", err)
|
||||
}
|
||||
|
||||
fakeDockerDir := t.TempDir()
|
||||
fakeDockerPath := filepath.Join(fakeDockerDir, "docker")
|
||||
fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;&\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;&\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;&\n *)\n exit 0\n ;;&\n esac\n"
|
||||
if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile() fake docker error = %v", err)
|
||||
}
|
||||
t.Setenv("PATH", fakeDockerDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv(panelDeployDirEnv, dir)
|
||||
t.Setenv(panelBackendContainerEnv, "flux-panel-backend")
|
||||
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.systemUpgrade(rr, req)
|
||||
|
||||
if !strings.Contains(rr.Body.String(), "当前环境不支持面板自升级") {
|
||||
t.Fatalf("expected fail-fast capability error, got %s", rr.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(composePath + ".upgrade.bak"); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no compose backup, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(envPath + ".upgrade.bak"); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no env backup, got err=%v", err)
|
||||
}
|
||||
composeData, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() compose error = %v", err)
|
||||
}
|
||||
if string(composeData) != "services:\n backend:\n image: test\n" {
|
||||
t.Fatalf("compose mutated unexpectedly: %q", string(composeData))
|
||||
}
|
||||
envData, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() env error = %v", err)
|
||||
}
|
||||
if string(envData) != "FLUX_VERSION=2.1.8\nJWT_SECRET=test\n" {
|
||||
t.Fatalf("env mutated unexpectedly: %q", string(envData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeBackupUsesStablePathAndRestoreRestoresOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "docker-compose.yml")
|
||||
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
backupPath, err := exec.backupFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if backupPath != path+".upgrade.bak" {
|
||||
t.Fatalf("backup path = %q, want %q", backupPath, path+".upgrade.bak")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mutated"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "original" {
|
||||
t.Fatalf("restored content = %q, want original", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackupPreservesOriginalFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(path, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if _, err := exec.backupFile(path); err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("restored mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
|
||||
var payload systemUpgradeRequest
|
||||
|
||||
if err := decodeSystemUpgradeRequest(req, &payload); err == nil {
|
||||
t.Fatal("expected truncated JSON to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSystemUpgradeRequestAllowsEmptyBody(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(""))
|
||||
var payload systemUpgradeRequest
|
||||
|
||||
if err := decodeSystemUpgradeRequest(req, &payload); err != nil {
|
||||
t.Fatalf("expected empty body to be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpgradeVersionDataSurfacesLookupFailureReason(t *testing.T) {
|
||||
data, err := json.Marshal(systemUpgradeVersionData{Reason: "GitHub unavailable"})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"reason":"GitHub unavailable"`) {
|
||||
t.Fatalf("expected reason field in JSON, got %s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelStrategyBest = "best"
|
||||
bestExitRuntimeStrategy = "fifo"
|
||||
bestExitPublicTargetHost = "www.bing.com"
|
||||
bestExitPublicTargetPort = 443
|
||||
bestExitLossPenaltyMsPerPercent = 100.0
|
||||
bestExitConfirmationRounds = 3
|
||||
bestExitSwitchCooldown = 30 * time.Second
|
||||
bestExitApplyRetryCooldown = bestExitSwitchCooldown
|
||||
bestExitMinLatencyAdvantageMs = 20.0
|
||||
bestExitMinScoreAdvantageRatio = 0.15
|
||||
)
|
||||
|
||||
type bestExitOwnerKey struct {
|
||||
TunnelID int64
|
||||
OwnerNodeID int64
|
||||
}
|
||||
|
||||
type bestExitCandidateScore struct {
|
||||
OwnerNodeID int64
|
||||
ExitNodeID int64
|
||||
ExitName string
|
||||
|
||||
OwnerToExitLatency float64
|
||||
ExitToBingLatency float64
|
||||
OwnerToExitLoss float64
|
||||
ExitToBingLoss float64
|
||||
TotalLatency float64
|
||||
TotalLoss float64
|
||||
Score float64
|
||||
Success bool
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
type bestExitSwitchDecision struct {
|
||||
Switch bool
|
||||
ExitNodeID int64
|
||||
Reason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitProbeFunc func(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error)
|
||||
|
||||
type bestExitProbeResult struct {
|
||||
latency float64
|
||||
loss float64
|
||||
err error
|
||||
}
|
||||
|
||||
type bestExitProbeCacheKey struct {
|
||||
NodeID int64
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
type bestExitDecision struct {
|
||||
AppliedExitNodeID int64
|
||||
PendingExitNodeID int64
|
||||
PendingCount int
|
||||
LastSwitchAt time.Time
|
||||
LastApplyFailureAt time.Time
|
||||
LastApplyFailureExitNodeID int64
|
||||
LastReason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitManager struct {
|
||||
mu sync.Mutex
|
||||
decisions map[bestExitOwnerKey]*bestExitDecision
|
||||
}
|
||||
|
||||
func newBestExitManager() *bestExitManager {
|
||||
return &bestExitManager{decisions: make(map[bestExitOwnerKey]*bestExitDecision)}
|
||||
}
|
||||
|
||||
func isBestTunnelStrategy(strategy string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(strategy), tunnelStrategyBest)
|
||||
}
|
||||
|
||||
func runtimeTunnelStrategy(strategy string) string {
|
||||
if isBestTunnelStrategy(strategy) {
|
||||
return bestExitRuntimeStrategy
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
func scoreBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, ownerLatency, ownerLoss, publicLatency, publicLoss float64) bestExitCandidateScore {
|
||||
totalLatency := ownerLatency + publicLatency
|
||||
totalLoss := combineLossPercent(ownerLoss, publicLoss)
|
||||
return bestExitCandidateScore{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
ExitNodeID: exit.NodeID,
|
||||
ExitName: exit.NodeName,
|
||||
OwnerToExitLatency: ownerLatency,
|
||||
ExitToBingLatency: publicLatency,
|
||||
OwnerToExitLoss: ownerLoss,
|
||||
ExitToBingLoss: publicLoss,
|
||||
TotalLatency: totalLatency,
|
||||
TotalLoss: totalLoss,
|
||||
Score: totalLatency + totalLoss*bestExitLossPenaltyMsPerPercent,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func failedBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, message string) bestExitCandidateScore {
|
||||
return bestExitCandidateScore{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
ExitNodeID: exit.NodeID,
|
||||
ExitName: exit.NodeName,
|
||||
Success: false,
|
||||
ErrorMessage: message,
|
||||
}
|
||||
}
|
||||
|
||||
func combineLossPercent(a, b float64) float64 {
|
||||
a = clampPercent(a)
|
||||
b = clampPercent(b)
|
||||
return (1 - (1-a/100.0)*(1-b/100.0)) * 100.0
|
||||
}
|
||||
|
||||
func clampPercent(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 100 {
|
||||
return 100
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func sortBestExitScores(scores []bestExitCandidateScore) {
|
||||
sort.SliceStable(scores, func(i, j int) bool {
|
||||
return bestExitScoreLess(scores[i], scores[j])
|
||||
})
|
||||
}
|
||||
|
||||
func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, target tunnelProbeTarget, ping bestExitProbeFunc) []bestExitCandidateScore {
|
||||
scores := make([]bestExitCandidateScore, 0, len(exits))
|
||||
if owner.NodeID <= 0 || len(exits) == 0 || ping == nil {
|
||||
return scores
|
||||
}
|
||||
ownerNode := nodes[owner.NodeID]
|
||||
for _, exit := range exits {
|
||||
exitNode := nodes[exit.NodeID]
|
||||
if exitNode == nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "exit node unavailable"))
|
||||
continue
|
||||
}
|
||||
targetIP, targetPort, resolveErr := resolveBestExitProbeTarget(ownerNode, exitNode, exit.Port, ipPreference, exit.ConnectIP)
|
||||
if resolveErr != nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, resolveErr.Error()))
|
||||
continue
|
||||
}
|
||||
ownerLatency, ownerLoss, ownerErr := ping(owner.NodeID, targetIP, targetPort, options)
|
||||
if ownerErr != nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, ownerErr.Error()))
|
||||
continue
|
||||
}
|
||||
publicLatency, publicLoss, publicErr := ping(exit.NodeID, target.Host, target.Port, options)
|
||||
if publicErr != nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, publicErr.Error()))
|
||||
continue
|
||||
}
|
||||
scores = append(scores, scoreBestExitCandidate(owner.NodeID, exit, ownerLatency, ownerLoss, publicLatency, publicLoss))
|
||||
}
|
||||
sortBestExitScores(scores)
|
||||
return scores
|
||||
}
|
||||
|
||||
func resolveBestExitProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIP string) (string, int, error) {
|
||||
if targetNode == nil {
|
||||
return "", 0, errors.New("目标节点不存在")
|
||||
}
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIP)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return "", 0, errors.New("目标节点地址为空")
|
||||
}
|
||||
port := preferredPort
|
||||
if port <= 0 {
|
||||
port = firstPortFromRange(targetNode.PortRange)
|
||||
}
|
||||
if port <= 0 {
|
||||
port = 443
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
func newBestExitRoundPinger(base bestExitProbeFunc) bestExitProbeFunc {
|
||||
cache := make(map[bestExitProbeCacheKey]bestExitProbeResult)
|
||||
return func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
key := bestExitProbeCacheKey{NodeID: nodeID, Host: ip, Port: port}
|
||||
if cached, ok := cache[key]; ok {
|
||||
return cached.latency, cached.loss, cached.err
|
||||
}
|
||||
lat, loss, err := base(nodeID, ip, port, options)
|
||||
cache[key] = bestExitProbeResult{latency: lat, loss: loss, err: err}
|
||||
return lat, loss, err
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitChainOwners(inNodes []chainNodeRecord, chainHops [][]chainNodeRecord) []chainNodeRecord {
|
||||
if len(chainHops) == 0 {
|
||||
return inNodes
|
||||
}
|
||||
return chainHops[len(chainHops)-1]
|
||||
}
|
||||
|
||||
func chainRecordsToRuntimeTargets(rows []chainNodeRecord) []tunnelRuntimeNode {
|
||||
out := make([]tunnelRuntimeNode, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, tunnelRuntimeNode{
|
||||
NodeID: row.NodeID,
|
||||
Protocol: row.Protocol,
|
||||
Strategy: row.Strategy,
|
||||
Inx: int(row.Inx),
|
||||
ChainType: row.ChainType,
|
||||
Port: row.Port,
|
||||
ConnectIP: row.ConnectIP,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orderRuntimeTargetsByNodeID(targets []tunnelRuntimeNode, orderedIDs []int64) []tunnelRuntimeNode {
|
||||
out := append([]tunnelRuntimeNode(nil), targets...)
|
||||
if len(out) <= 1 || len(orderedIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
positions := make(map[int64]int, len(orderedIDs))
|
||||
for i, id := range orderedIDs {
|
||||
if _, ok := positions[id]; !ok {
|
||||
positions[id] = i
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
pi, iok := positions[out[i].NodeID]
|
||||
pj, jok := positions[out[j].NodeID]
|
||||
if iok != jok {
|
||||
return iok
|
||||
}
|
||||
if iok && jok && pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return false
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBestExitScores(scores []bestExitCandidateScore) []bestExitCandidateScore {
|
||||
return append([]bestExitCandidateScore(nil), scores...)
|
||||
}
|
||||
|
||||
func bestExitDecisionResult(switchNow bool, exitNodeID int64, reason string, scores []bestExitCandidateScore) bestExitSwitchDecision {
|
||||
return bestExitSwitchDecision{Switch: switchNow, ExitNodeID: exitNodeID, Reason: reason, Scores: cloneBestExitScores(scores)}
|
||||
}
|
||||
|
||||
func bestExitScoreLess(a, b bestExitCandidateScore) bool {
|
||||
if a.Success != b.Success {
|
||||
return a.Success
|
||||
}
|
||||
if !a.Success && !b.Success {
|
||||
return a.ExitNodeID < b.ExitNodeID
|
||||
}
|
||||
if a.Score != b.Score {
|
||||
return a.Score < b.Score
|
||||
}
|
||||
return a.ExitNodeID < b.ExitNodeID
|
||||
}
|
||||
|
||||
func bestExitHasMinimumAdvantage(candidate, current bestExitCandidateScore) bool {
|
||||
if !candidate.Success {
|
||||
return false
|
||||
}
|
||||
if !current.Success {
|
||||
return true
|
||||
}
|
||||
improvement := current.Score - candidate.Score
|
||||
threshold := current.Score * bestExitMinScoreAdvantageRatio
|
||||
if threshold < bestExitMinLatencyAdvantageMs {
|
||||
threshold = bestExitMinLatencyAdvantageMs
|
||||
}
|
||||
return improvement >= threshold
|
||||
}
|
||||
|
||||
func (m *bestExitManager) setApplied(key bestExitOwnerKey, exitNodeID int64, at time.Time) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisionLocked(key)
|
||||
d.AppliedExitNodeID = exitNodeID
|
||||
d.PendingExitNodeID = 0
|
||||
d.PendingCount = 0
|
||||
d.LastApplyFailureAt = time.Time{}
|
||||
d.LastApplyFailureExitNodeID = 0
|
||||
d.LastSwitchAt = at
|
||||
}
|
||||
|
||||
func (m *bestExitManager) recordApplyFailure(key bestExitOwnerKey, exitNodeID int64, at time.Time) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisionLocked(key)
|
||||
d.LastApplyFailureAt = at
|
||||
d.LastApplyFailureExitNodeID = exitNodeID
|
||||
d.LastReason = "apply retry cooldown"
|
||||
}
|
||||
|
||||
func (m *bestExitManager) ensureApplied(key bestExitOwnerKey, exitNodeID int64, at time.Time) {
|
||||
if m == nil || exitNodeID <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisionLocked(key)
|
||||
if d.AppliedExitNodeID == 0 {
|
||||
d.AppliedExitNodeID = exitNodeID
|
||||
d.LastSwitchAt = at
|
||||
}
|
||||
}
|
||||
|
||||
func (m *bestExitManager) observeScores(key bestExitOwnerKey, scores []bestExitCandidateScore, now time.Time) bestExitSwitchDecision {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ordered := append([]bestExitCandidateScore(nil), scores...)
|
||||
sortBestExitScores(ordered)
|
||||
d := m.decisionLocked(key)
|
||||
d.Scores = cloneBestExitScores(ordered)
|
||||
|
||||
if len(ordered) == 0 || !ordered[0].Success {
|
||||
d.LastReason = "all exits failed"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
candidate := ordered[0]
|
||||
if d.AppliedExitNodeID == 0 {
|
||||
d.AppliedExitNodeID = candidate.ExitNodeID
|
||||
d.LastSwitchAt = now
|
||||
d.LastReason = "initial best exit"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
if candidate.ExitNodeID == d.AppliedExitNodeID {
|
||||
d.PendingExitNodeID = 0
|
||||
d.PendingCount = 0
|
||||
d.LastApplyFailureAt = time.Time{}
|
||||
d.LastApplyFailureExitNodeID = 0
|
||||
d.LastReason = "current exit remains best"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
if candidate.ExitNodeID == d.LastApplyFailureExitNodeID && !d.LastApplyFailureAt.IsZero() && now.Sub(d.LastApplyFailureAt) < bestExitApplyRetryCooldown {
|
||||
d.LastReason = "apply retry cooldown"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
if now.Sub(d.LastSwitchAt) < bestExitSwitchCooldown {
|
||||
d.LastReason = "cooldown"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
current := findBestExitScore(ordered, d.AppliedExitNodeID)
|
||||
if !bestExitHasMinimumAdvantage(candidate, current) {
|
||||
d.PendingExitNodeID = 0
|
||||
d.PendingCount = 0
|
||||
d.LastReason = "insufficient advantage"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
if d.PendingExitNodeID != candidate.ExitNodeID {
|
||||
d.PendingExitNodeID = candidate.ExitNodeID
|
||||
d.PendingCount = 1
|
||||
d.LastReason = "candidate pending confirmation"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
d.PendingCount++
|
||||
if d.PendingCount < bestExitConfirmationRounds {
|
||||
d.LastReason = "candidate pending confirmation"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
d.LastReason = "switch confirmed"
|
||||
return bestExitDecisionResult(true, candidate.ExitNodeID, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
func findBestExitScore(scores []bestExitCandidateScore, exitNodeID int64) bestExitCandidateScore {
|
||||
for _, score := range scores {
|
||||
if score.ExitNodeID == exitNodeID {
|
||||
return score
|
||||
}
|
||||
}
|
||||
return failedBestExitCandidate(0, chainNodeRecord{NodeID: exitNodeID}, "current exit has no successful score")
|
||||
}
|
||||
|
||||
func (m *bestExitManager) decisionLocked(key bestExitOwnerKey) *bestExitDecision {
|
||||
if d := m.decisions[key]; d != nil {
|
||||
return d
|
||||
}
|
||||
d := &bestExitDecision{}
|
||||
m.decisions[key] = d
|
||||
return d
|
||||
}
|
||||
|
||||
func (m *bestExitManager) orderTargets(key bestExitOwnerKey, targets []tunnelRuntimeNode) []tunnelRuntimeNode {
|
||||
out := append([]tunnelRuntimeNode(nil), targets...)
|
||||
if m == nil || len(out) <= 1 {
|
||||
return out
|
||||
}
|
||||
m.mu.Lock()
|
||||
applied := int64(0)
|
||||
if d := m.decisions[key]; d != nil {
|
||||
applied = d.AppliedExitNodeID
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if applied <= 0 {
|
||||
return out
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].NodeID == applied {
|
||||
return true
|
||||
}
|
||||
if out[j].NodeID == applied {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
bestExitDisplayStatusApplied = "applied"
|
||||
bestExitDisplayStatusWaiting = "waiting"
|
||||
bestExitDisplaySummaryMulti = "多个出口"
|
||||
bestExitDisplaySummaryWait = "等待探测"
|
||||
bestExitUnknownExitName = "未知出口"
|
||||
bestExitUnknownEntryName = "未知入口"
|
||||
bestExitUnknownChainName = "未知中转"
|
||||
)
|
||||
|
||||
type bestExitDecisionSnapshot struct {
|
||||
AppliedExitNodeID int64
|
||||
UpdatedAt int64
|
||||
Reason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitDisplayState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Items []bestExitDisplayItem `json:"items"`
|
||||
}
|
||||
|
||||
type bestExitDisplayItem struct {
|
||||
OwnerNodeID int64 `json:"ownerNodeId"`
|
||||
OwnerNodeName string `json:"ownerNodeName"`
|
||||
OwnerRole string `json:"ownerRole"`
|
||||
ExitNodeID int64 `json:"exitNodeId,omitempty"`
|
||||
ExitNodeName string `json:"exitNodeName"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type bestExitNodeNameLookup func(nodeID int64) (string, bool)
|
||||
|
||||
func (m *bestExitManager) snapshot(key bestExitOwnerKey) (bestExitDecisionSnapshot, bool) {
|
||||
if m == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisions[key]
|
||||
if d == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
updatedAt := int64(0)
|
||||
if !d.LastSwitchAt.IsZero() {
|
||||
updatedAt = d.LastSwitchAt.UnixMilli()
|
||||
}
|
||||
return bestExitDecisionSnapshot{
|
||||
AppliedExitNodeID: d.AppliedExitNodeID,
|
||||
UpdatedAt: updatedAt,
|
||||
Reason: d.LastReason,
|
||||
Scores: cloneBestExitScores(d.Scores),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (h *Handler) attachBestExitStates(items []map[string]interface{}) {
|
||||
if h == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
lookup := h.bestExitNodeNameLookup()
|
||||
for _, item := range items {
|
||||
state, ok := buildBestExitDisplayState(item, h.bestExit, lookup)
|
||||
if !ok {
|
||||
delete(item, "bestExitState")
|
||||
continue
|
||||
}
|
||||
item["bestExitState"] = state
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) bestExitNodeNameLookup() bestExitNodeNameLookup {
|
||||
cache := map[int64]string{}
|
||||
return func(nodeID int64) (string, bool) {
|
||||
if nodeID <= 0 || h == nil {
|
||||
return "", false
|
||||
}
|
||||
if name, ok := cache[nodeID]; ok {
|
||||
return name, name != ""
|
||||
}
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil || node == nil {
|
||||
cache[nodeID] = ""
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(node.Name)
|
||||
cache[nodeID] = name
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildBestExitDisplayState(tunnel map[string]interface{}, manager *bestExitManager, lookup bestExitNodeNameLookup) (*bestExitDisplayState, bool) {
|
||||
if tunnel == nil {
|
||||
return nil, false
|
||||
}
|
||||
tunnelID := asInt64(tunnel["id"], 0)
|
||||
outNodes := bestExitDisplayMapSlice(tunnel["outNodeId"])
|
||||
if tunnelID <= 0 || len(outNodes) <= 1 {
|
||||
return nil, false
|
||||
}
|
||||
if !isBestTunnelStrategy(asString(outNodes[0]["strategy"])) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
owners, ownerRole := bestExitDisplayOwners(tunnel)
|
||||
state := &bestExitDisplayState{
|
||||
Enabled: true,
|
||||
Summary: bestExitDisplaySummaryWait,
|
||||
Status: bestExitDisplayStatusWaiting,
|
||||
Items: make([]bestExitDisplayItem, 0, len(owners)),
|
||||
}
|
||||
|
||||
exitsByID := map[int64]map[string]interface{}{}
|
||||
for _, exit := range outNodes {
|
||||
if id := asInt64(exit["nodeId"], 0); id > 0 {
|
||||
exitsByID[id] = exit
|
||||
}
|
||||
}
|
||||
appliedExitIDs := map[int64]string{}
|
||||
appliedCount := 0
|
||||
latestUpdatedAt := int64(0)
|
||||
latestReason := ""
|
||||
for _, owner := range owners {
|
||||
ownerNodeID := asInt64(owner["nodeId"], 0)
|
||||
if ownerNodeID <= 0 {
|
||||
continue
|
||||
}
|
||||
item := bestExitDisplayItem{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
OwnerNodeName: bestExitDisplayNodeName(owner, ownerNodeID, lookup, bestExitUnknownOwnerName(ownerRole)),
|
||||
OwnerRole: ownerRole,
|
||||
ExitNodeName: bestExitDisplaySummaryWait,
|
||||
Reason: bestExitDisplayStatusWaiting,
|
||||
}
|
||||
if snapshot, ok := manager.snapshot(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}); ok && snapshot.AppliedExitNodeID > 0 {
|
||||
exit, ok := exitsByID[snapshot.AppliedExitNodeID]
|
||||
if !ok {
|
||||
state.Items = append(state.Items, item)
|
||||
continue
|
||||
}
|
||||
item.ExitNodeID = snapshot.AppliedExitNodeID
|
||||
item.ExitNodeName = bestExitDisplayNodeName(exit, snapshot.AppliedExitNodeID, lookup, bestExitUnknownExitName)
|
||||
item.UpdatedAt = snapshot.UpdatedAt
|
||||
item.Reason = snapshot.Reason
|
||||
appliedExitIDs[item.ExitNodeID] = item.ExitNodeName
|
||||
appliedCount++
|
||||
if snapshot.UpdatedAt > latestUpdatedAt {
|
||||
latestUpdatedAt = snapshot.UpdatedAt
|
||||
latestReason = snapshot.Reason
|
||||
}
|
||||
}
|
||||
state.Items = append(state.Items, item)
|
||||
}
|
||||
|
||||
if appliedCount == 0 {
|
||||
return state, true
|
||||
}
|
||||
if appliedCount < len(state.Items) {
|
||||
return state, true
|
||||
}
|
||||
state.Status = bestExitDisplayStatusApplied
|
||||
state.UpdatedAt = latestUpdatedAt
|
||||
state.Reason = latestReason
|
||||
if len(appliedExitIDs) == 1 {
|
||||
for _, name := range appliedExitIDs {
|
||||
state.Summary = name
|
||||
}
|
||||
} else {
|
||||
state.Summary = bestExitDisplaySummaryMulti
|
||||
}
|
||||
return state, true
|
||||
}
|
||||
|
||||
func bestExitDisplayOwners(tunnel map[string]interface{}) ([]map[string]interface{}, string) {
|
||||
chainGroups := bestExitDisplayChainGroups(tunnel["chainNodes"])
|
||||
if len(chainGroups) > 0 {
|
||||
return chainGroups[len(chainGroups)-1], "chain"
|
||||
}
|
||||
return bestExitDisplayMapSlice(tunnel["inNodeId"]), "entry"
|
||||
}
|
||||
|
||||
func bestExitDisplayMapSlice(v interface{}) []map[string]interface{} {
|
||||
switch arr := v.(type) {
|
||||
case []map[string]interface{}:
|
||||
return arr
|
||||
case []interface{}:
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayChainGroups(v interface{}) [][]map[string]interface{} {
|
||||
switch groups := v.(type) {
|
||||
case [][]map[string]interface{}:
|
||||
return groups
|
||||
case []interface{}:
|
||||
out := make([][]map[string]interface{}, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
items := bestExitDisplayMapSlice(group)
|
||||
if len(items) > 0 {
|
||||
out = append(out, items)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayNodeName(source map[string]interface{}, nodeID int64, lookup bestExitNodeNameLookup, fallback string) string {
|
||||
if source != nil {
|
||||
for _, key := range []string{"nodeName", "name"} {
|
||||
if name := strings.TrimSpace(asString(source[key])); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
if lookup != nil {
|
||||
if name, ok := lookup(nodeID); ok && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func bestExitUnknownOwnerName(role string) string {
|
||||
if role == "chain" {
|
||||
return bestExitUnknownChainName
|
||||
}
|
||||
return bestExitUnknownEntryName
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBestExitDecisionSnapshotIsDefensiveCopy(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
score := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30, NodeName: "exit-a"}, 10, 0, 20, 0)
|
||||
|
||||
m.observeScores(key, []bestExitCandidateScore{score}, now)
|
||||
snapshot, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected snapshot")
|
||||
}
|
||||
if snapshot.AppliedExitNodeID != 30 || snapshot.UpdatedAt != now.UnixMilli() {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
if len(snapshot.Scores) != 1 {
|
||||
t.Fatalf("expected one score in snapshot, got %+v", snapshot.Scores)
|
||||
}
|
||||
snapshot.Scores[0].ExitNodeID = 99
|
||||
|
||||
again, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected second snapshot")
|
||||
}
|
||||
if again.Scores[0].ExitNodeID != 30 {
|
||||
t.Fatalf("snapshot score mutation leaked into manager state: %+v", again.Scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForDirectMultiEntryOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(100, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 11}, 31, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
{"nodeId": int64(11)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if !state.Enabled || state.Summary != "多个出口" || state.Status != "applied" {
|
||||
t.Fatalf("unexpected state summary: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt != now.Add(time.Second).UnixMilli() {
|
||||
t.Fatalf("expected latest updatedAt, got %d", state.UpdatedAt)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "entry" || state.Items[0].OwnerNodeName != "入口 A" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "entry" || state.Items[1].OwnerNodeName != "入口 B" || state.Items[1].ExitNodeName != "日本节点" {
|
||||
t.Fatalf("unexpected second item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForFinalChainHopOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(200, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 20}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 21}, 30, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(88),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": int64(15), "inx": int64(0)}},
|
||||
{{"nodeId": int64(20), "inx": int64(1)}, {"nodeId": int64(21), "inx": int64(1)}},
|
||||
},
|
||||
}
|
||||
names := map[int64]string{20: "中转 M1", 21: "中转 M2", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Summary != "香港节点" || state.Status != "applied" {
|
||||
t.Fatalf("expected single-exit summary, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two final-hop owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "chain" || state.Items[0].OwnerNodeName != "中转 M1" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first chain owner item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "chain" || state.Items[1].OwnerNodeName != "中转 M2" || state.Items[1].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected second chain owner item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateWaitingWhenNoAppliedDecisionExists(t *testing.T) {
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, newBestExitManager(), testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected waiting best exit state")
|
||||
}
|
||||
if state.Summary != "等待探测" || state.Status != "waiting" {
|
||||
t.Fatalf("expected waiting state, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 1 || state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != "等待探测" {
|
||||
t.Fatalf("unexpected waiting item: %+v", state.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateKeepsTopLevelWaitingWhenSomeOwnersPending(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(400, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
{"nodeId": int64(11)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Status != bestExitDisplayStatusWaiting || state.Summary != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected top-level waiting for partial owner state, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].ExitNodeID != 30 || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("expected first owner applied details to remain visible, got %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].ExitNodeID != 0 || state.Items[1].ExitNodeName != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected second owner waiting details, got %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateIgnoresAppliedExitRemovedFromTunnel(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(500, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 99, now)
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点", 99: "已删除节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Status != bestExitDisplayStatusWaiting || state.Summary != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected waiting state for stale applied exit, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 1 {
|
||||
t.Fatalf("expected one item, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected stale exit to be ignored, got %+v", state.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateSkipsNonBestAndSingleExitTunnels(t *testing.T) {
|
||||
nonBest := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": "round"},
|
||||
{"nodeId": int64(31), "strategy": "round"},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(nonBest, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected non-best tunnel to skip state, got %+v", state)
|
||||
}
|
||||
|
||||
singleExit := map[string]interface{}{
|
||||
"id": int64(78),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(singleExit, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected single-exit tunnel to skip state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelListAttachesBestExitStateOnlyForEligibleTunnels(t *testing.T) {
|
||||
h := setupBestExitTunnelHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelList(res, req)
|
||||
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
decodeBestExitTunnelResponse(t, res, &payload)
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected success response, got code %d", payload.Code)
|
||||
}
|
||||
|
||||
bestTunnel := findTunnelResponseItem(t, payload.Data, 77)
|
||||
if _, ok := bestTunnel["bestExitState"]; !ok {
|
||||
t.Fatalf("expected eligible best multi-exit tunnel to include bestExitState: %+v", bestTunnel)
|
||||
}
|
||||
|
||||
singleExitTunnel := findTunnelResponseItem(t, payload.Data, 78)
|
||||
if _, ok := singleExitTunnel["bestExitState"]; ok {
|
||||
t.Fatalf("expected single-exit tunnel to omit bestExitState: %+v", singleExitTunnel)
|
||||
}
|
||||
|
||||
nonBestTunnel := findTunnelResponseItem(t, payload.Data, 79)
|
||||
if _, ok := nonBestTunnel["bestExitState"]; ok {
|
||||
t.Fatalf("expected non-best tunnel to omit bestExitState: %+v", nonBestTunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelGetAttachesBestExitStateToSelectedTunnel(t *testing.T) {
|
||||
h := setupBestExitTunnelHandler(t)
|
||||
|
||||
body := bytes.NewReader([]byte(`{"id":77}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/get", body)
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelGet(res, req)
|
||||
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
decodeBestExitTunnelResponse(t, res, &payload)
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected success response, got code %d", payload.Code)
|
||||
}
|
||||
if _, ok := payload.Data["bestExitState"]; !ok {
|
||||
t.Fatalf("expected selected best multi-exit tunnel to include bestExitState: %+v", payload.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func setupBestExitTunnelHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
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 := New(r, "secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(id int64, name string) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
insertNode(10, "entry-a")
|
||||
insertNode(30, "exit-a")
|
||||
insertNode(31, "exit-b")
|
||||
insertNode(32, "exit-c")
|
||||
|
||||
insertTunnel := func(id int64, name string) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference)
|
||||
VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '')
|
||||
`, id, name, now, now, id).Error; err != nil {
|
||||
t.Fatalf("insert tunnel %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
insertTunnel(77, "best-multi")
|
||||
insertTunnel(78, "best-single")
|
||||
insertTunnel(79, "round-multi")
|
||||
|
||||
insertChain := func(tunnelID int64, chainType string, nodeID int64, strategy string, inx int64) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, ?, ?, 30001, ?, ?, 'tls')
|
||||
`, tunnelID, chainType, nodeID, strategy, inx).Error; err != nil {
|
||||
t.Fatalf("insert chain tunnel %d/%s/%d: %v", tunnelID, chainType, nodeID, err)
|
||||
}
|
||||
}
|
||||
insertChain(77, "1", 10, "round", 1)
|
||||
insertChain(77, "3", 30, tunnelStrategyBest, 1)
|
||||
insertChain(77, "3", 31, tunnelStrategyBest, 2)
|
||||
insertChain(78, "1", 10, "round", 1)
|
||||
insertChain(78, "3", 30, tunnelStrategyBest, 1)
|
||||
insertChain(79, "1", 10, "round", 1)
|
||||
insertChain(79, "3", 31, "round", 1)
|
||||
insertChain(79, "3", 32, "round", 2)
|
||||
|
||||
h.bestExit.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, time.UnixMilli(now))
|
||||
return h
|
||||
}
|
||||
|
||||
func decodeBestExitTunnelResponse(t *testing.T, res *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(v); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findTunnelResponseItem(t *testing.T, items []map[string]any, id float64) map[string]any {
|
||||
t.Helper()
|
||||
for _, item := range items {
|
||||
if item["id"] == id {
|
||||
return item
|
||||
}
|
||||
}
|
||||
t.Fatalf("tunnel %.0f not found in response: %+v", id, items)
|
||||
return nil
|
||||
}
|
||||
|
||||
func testBestExitNameLookup(names map[int64]string) bestExitNodeNameLookup {
|
||||
return func(nodeID int64) (string, bool) {
|
||||
name := names[nodeID]
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errBestExitProbeForTest = errors.New("probe failed")
|
||||
|
||||
func TestBestExitScoreCombinesLatencyAndLoss(t *testing.T) {
|
||||
exit := chainNodeRecord{NodeID: 30, NodeName: "exit-a"}
|
||||
score := scoreBestExitCandidate(10, exit, 25, 2, 80, 3)
|
||||
|
||||
if !score.Success {
|
||||
t.Fatalf("expected successful score")
|
||||
}
|
||||
if score.OwnerNodeID != 10 || score.ExitNodeID != 30 {
|
||||
t.Fatalf("unexpected owner/exit ids: %+v", score)
|
||||
}
|
||||
if score.TotalLatency != 105 {
|
||||
t.Fatalf("expected total latency 105, got %v", score.TotalLatency)
|
||||
}
|
||||
if score.TotalLoss < 4.9 || score.TotalLoss > 5.0 {
|
||||
t.Fatalf("expected combined loss about 4.94, got %v", score.TotalLoss)
|
||||
}
|
||||
if score.Score < 599 || score.Score > 600 {
|
||||
t.Fatalf("expected score about 599, got %v", score.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitScorePenalizesLoss(t *testing.T) {
|
||||
stable := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 80, 0, 80, 0)
|
||||
lowLatencyLossy := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 10, 5, 10, 5)
|
||||
|
||||
if !bestExitScoreLess(stable, lowLatencyLossy) {
|
||||
t.Fatalf("expected stable exit to beat low-latency lossy exit: stable=%+v lossy=%+v", stable, lowLatencyLossy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitFailedCandidateSortsLast(t *testing.T) {
|
||||
failed := failedBestExitCandidate(10, chainNodeRecord{NodeID: 30}, "dial timeout")
|
||||
good := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 100, 0, 100, 0)
|
||||
|
||||
scores := []bestExitCandidateScore{failed, good}
|
||||
sortBestExitScores(scores)
|
||||
|
||||
if scores[0].ExitNodeID != 31 || scores[1].ExitNodeID != 30 {
|
||||
t.Fatalf("expected good score first and failed score last, got %+v", scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitInitialObservationAppliesWithoutSwitch(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate}, now)
|
||||
if decision.Switch {
|
||||
t.Fatalf("initial observation should not return switch: %+v", decision)
|
||||
}
|
||||
if m.decisions[key].AppliedExitNodeID != 31 {
|
||||
t.Fatalf("expected applied exit 31, got %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionRequiresMinimumAdvantage(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 90, 0, 90, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds+1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate below minimum advantage should not switch after repeated observations: %+v", decision)
|
||||
}
|
||||
}
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("expected applied exit to remain 30, got %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionSwitchesWithMinimumAdvantage(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds-1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
|
||||
}
|
||||
}
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
|
||||
if !decision.Switch || decision.ExitNodeID != 31 {
|
||||
t.Fatalf("candidate with enough advantage should switch after confirmations: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitConfirmedSwitchDoesNotMarkAppliedUntilSetApplied(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds-1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
|
||||
}
|
||||
}
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
|
||||
if !decision.Switch || decision.ExitNodeID != 31 {
|
||||
t.Fatalf("candidate with enough advantage should switch after confirmations: %+v", decision)
|
||||
}
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("confirmed switch should not mark applied before runtime update: %+v", m.decisions[key])
|
||||
}
|
||||
|
||||
m.setApplied(key, decision.ExitNodeID, now.Add(time.Second))
|
||||
if m.decisions[key].AppliedExitNodeID != 31 {
|
||||
t.Fatalf("setApplied should commit confirmed switch: %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitApplyFailureStartsRetryCooldownWithoutChangingAppliedExit(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds-1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
|
||||
}
|
||||
}
|
||||
confirmed := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
|
||||
if !confirmed.Switch || confirmed.ExitNodeID != 31 {
|
||||
t.Fatalf("expected confirmed switch before apply failure: %+v", confirmed)
|
||||
}
|
||||
|
||||
m.recordApplyFailure(key, confirmed.ExitNodeID, now.Add(bestExitConfirmationRounds*time.Second))
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("apply failure should leave applied exit unchanged: %+v", m.decisions[key])
|
||||
}
|
||||
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds+1)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("apply retry cooldown should suppress immediate retry: %+v", decision)
|
||||
}
|
||||
if decision.Reason != "apply retry cooldown" {
|
||||
t.Fatalf("expected apply retry cooldown reason, got %q", decision.Reason)
|
||||
}
|
||||
|
||||
retry := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(bestExitConfirmationRounds*time.Second+bestExitApplyRetryCooldown))
|
||||
if !retry.Switch || retry.ExitNodeID != 31 {
|
||||
t.Fatalf("expected retry after apply cooldown: %+v", retry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitEnsureAppliedDoesNotOverrideExistingAppliedExit(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
|
||||
m.ensureApplied(key, 30, now)
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("expected initial applied exit 30, got %+v", m.decisions[key])
|
||||
}
|
||||
if !m.decisions[key].LastSwitchAt.Equal(now) {
|
||||
t.Fatalf("expected initial applied timestamp, got %+v", m.decisions[key])
|
||||
}
|
||||
|
||||
m.ensureApplied(key, 31, now.Add(time.Minute))
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("ensureApplied should not override existing applied exit: %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitRoundPingerCachesByNodeHostAndPort(t *testing.T) {
|
||||
publicCalls := 0
|
||||
ownerCalls := 0
|
||||
pinger := newBestExitRoundPinger(func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
if ip == bestExitPublicTargetHost && port == bestExitPublicTargetPort {
|
||||
publicCalls++
|
||||
return float64(nodeID), 0, nil
|
||||
}
|
||||
ownerCalls++
|
||||
return float64(ownerCalls), 0, nil
|
||||
})
|
||||
|
||||
if lat, _, err := pinger(30, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil || lat != 30 {
|
||||
t.Fatalf("unexpected first public ping result lat=%v err=%v", lat, err)
|
||||
}
|
||||
if lat, _, err := pinger(30, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil || lat != 30 {
|
||||
t.Fatalf("unexpected cached public ping result lat=%v err=%v", lat, err)
|
||||
}
|
||||
if _, _, err := pinger(31, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil {
|
||||
t.Fatalf("unexpected second exit public ping err=%v", err)
|
||||
}
|
||||
if publicCalls != 2 {
|
||||
t.Fatalf("expected public probes cached per exit node, got %d calls", publicCalls)
|
||||
}
|
||||
|
||||
if _, _, err := pinger(10, "10.0.0.30", 30030, diagnosisExecOptions{}); err != nil {
|
||||
t.Fatalf("unexpected owner ping err=%v", err)
|
||||
}
|
||||
if _, _, err := pinger(10, "10.0.0.30", 30030, diagnosisExecOptions{}); err != nil {
|
||||
t.Fatalf("unexpected repeated owner ping err=%v", err)
|
||||
}
|
||||
if ownerCalls != 1 {
|
||||
t.Fatalf("expected owner-to-exit probes cached by target, got %d calls", ownerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionScoresAreDefensiveCopies(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now)
|
||||
decision.Scores[0].ExitNodeID = 99
|
||||
|
||||
if m.decisions[key].Scores[0].ExitNodeID != 31 {
|
||||
t.Fatalf("decision scores mutation leaked into manager state: %+v", m.decisions[key].Scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionRequiresConfirmationsAndCooldown(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
|
||||
if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now); decision.Switch {
|
||||
t.Fatalf("first observation should not switch: %+v", decision)
|
||||
}
|
||||
if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Second)); decision.Switch {
|
||||
t.Fatalf("second observation should not switch: %+v", decision)
|
||||
}
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(2*time.Second))
|
||||
if !decision.Switch || decision.ExitNodeID != 31 {
|
||||
t.Fatalf("third confirmed observation should switch to 31: %+v", decision)
|
||||
}
|
||||
|
||||
betterAgain := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 20, 0, 20, 0)
|
||||
if decision := m.observeScores(key, []bestExitCandidateScore{betterAgain, candidate}, now.Add(3*time.Second)); decision.Switch {
|
||||
t.Fatalf("cooldown should block immediate switch back: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitOrderingUsesAppliedDecision(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
m.setApplied(key, 31, time.Unix(100, 0))
|
||||
targets := []tunnelRuntimeNode{
|
||||
{NodeID: 30, Strategy: tunnelStrategyBest},
|
||||
{NodeID: 31, Strategy: tunnelStrategyBest},
|
||||
{NodeID: 32, Strategy: tunnelStrategyBest},
|
||||
}
|
||||
|
||||
ordered := m.orderTargets(key, targets)
|
||||
if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 || ordered[2].NodeID != 32 {
|
||||
t.Fatalf("unexpected order: %+v", ordered)
|
||||
}
|
||||
if targets[0].NodeID != 30 {
|
||||
t.Fatalf("orderTargets mutated input: %+v", targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainConfigMapsBestStrategyToFIFO(t *testing.T) {
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
|
||||
}
|
||||
targets := []tunnelRuntimeNode{
|
||||
{NodeID: 30, Port: 30030, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
|
||||
{NodeID: 31, Port: 30031, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
|
||||
}
|
||||
|
||||
chainData, err := buildTunnelChainConfig(77, 10, targets, nodes, "")
|
||||
if err != nil {
|
||||
t.Fatalf("build chain: %v", err)
|
||||
}
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
selector := hops[0]["selector"].(map[string]interface{})
|
||||
if selector["strategy"] != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best to render as fifo, got %v", selector["strategy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerOrdersBestExitTargetsForOwner(t *testing.T) {
|
||||
h := &Handler{bestExit: newBestExitManager()}
|
||||
key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
|
||||
h.bestExit.setApplied(key, 31, time.Unix(100, 0))
|
||||
targets := []tunnelRuntimeNode{
|
||||
{NodeID: 30, Port: 30030, Strategy: tunnelStrategyBest},
|
||||
{NodeID: 31, Port: 30031, Strategy: tunnelStrategyBest},
|
||||
}
|
||||
|
||||
ordered := h.orderBestExitTargets(77, 10, targets)
|
||||
if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 {
|
||||
t.Fatalf("unexpected ordered targets: %+v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStrategyForTargetsMapsBestTargetStrategyToFIFO(t *testing.T) {
|
||||
owner := tunnelRuntimeNode{Strategy: "round"}
|
||||
targets := []tunnelRuntimeNode{{Strategy: tunnelStrategyBest}}
|
||||
|
||||
if got := runtimeStrategyForTargets(owner, targets); got != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best target strategy to map to fifo, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStrategyForTargetsPreservesNonBestTargetStrategy(t *testing.T) {
|
||||
owner := tunnelRuntimeNode{Strategy: tunnelStrategyBest}
|
||||
targets := []tunnelRuntimeNode{{Strategy: "round"}}
|
||||
|
||||
if got := runtimeStrategyForTargets(owner, targets); got != "round" {
|
||||
t.Fatalf("expected target strategy round to remain unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStrategyForTargetsMapsBestOwnerStrategyWhenTargetsEmpty(t *testing.T) {
|
||||
owner := tunnelRuntimeNode{Strategy: tunnelStrategyBest}
|
||||
|
||||
if got := runtimeStrategyForTargets(owner, nil); got != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best owner fallback strategy to map to fifo, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{
|
||||
{NodeID: 30, NodeName: "exit-a", Port: 30030},
|
||||
{NodeID: 31, NodeName: "exit-b", Port: 30031},
|
||||
}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
switch {
|
||||
case nodeID == 10 && port == 30030:
|
||||
return 60, 0, nil
|
||||
case nodeID == 10 && port == 30031:
|
||||
return 20, 0, nil
|
||||
case nodeID == 30 && ip == bestExitPublicTargetHost:
|
||||
return 60, 0, nil
|
||||
case nodeID == 31 && ip == bestExitPublicTargetHost:
|
||||
return 20, 0, nil
|
||||
default:
|
||||
t.Fatalf("unexpected ping node=%d ip=%s port=%d", nodeID, ip, port)
|
||||
return 0, 100, nil
|
||||
}
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
|
||||
if len(scores) != 2 {
|
||||
t.Fatalf("expected two scores, got %+v", scores)
|
||||
}
|
||||
if scores[0].ExitNodeID != 31 {
|
||||
t.Fatalf("expected exit-b first, got %+v", scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
|
||||
30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
|
||||
}
|
||||
target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443}
|
||||
var calls []string
|
||||
ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
|
||||
return 10, 0, nil
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, target, ping)
|
||||
if len(scores) != 1 || !scores[0].Success {
|
||||
t.Fatalf("expected successful score, got %+v", scores)
|
||||
}
|
||||
if !slices.Contains(calls, "30|speed.example.com|8443") {
|
||||
t.Fatalf("expected exit public probe to use configured target, calls=%+v", calls)
|
||||
}
|
||||
for _, call := range calls {
|
||||
if strings.Contains(call, defaultTunnelProbeTargetHost) {
|
||||
t.Fatalf("did not expect default target call when custom target configured: %+v", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
return 0, 100, errBestExitProbeForTest
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
|
||||
if len(scores) != 1 || scores[0].Success {
|
||||
t.Fatalf("expected failed candidate, got %+v", scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerMarksCandidateFailedWhenTargetResolutionFails(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-v6", Port: 30030}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, Name: "entry", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, Name: "exit-v6", ServerIP: "2001:db8::30", ServerIPv6: "2001:db8::30", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
t.Fatalf("ping should not be called when target resolution fails: node=%d ip=%s port=%d", nodeID, ip, port)
|
||||
return 0, 100, nil
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "v4", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
|
||||
if len(scores) != 1 || scores[0].Success {
|
||||
t.Fatalf("expected failed candidate, got %+v", scores)
|
||||
}
|
||||
}
|
||||
@@ -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,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
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 collectFlowUploadForwardIDs(items []flowItem) []int64 {
|
||||
ids := make([]int64, 0, len(items))
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(strings.TrimSpace(item.N))
|
||||
if !ok || forwardID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[forwardID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[forwardID] = struct{}{}
|
||||
ids = append(ids, forwardID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *Handler) recordTunnelMetricsFromForwardBatch(nodeID int64, forwardDeltas map[int64]tunnelTrafficDelta, metas map[int64]repo.FlowUploadForwardMeta, nowMs int64) {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 || len(forwardDeltas) == 0 {
|
||||
return
|
||||
}
|
||||
bucketTs := unixMilliBucketMinute(nowMs)
|
||||
if bucketTs <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelAgg := make(map[int64]tunnelTrafficDelta)
|
||||
for forwardID, delta := range forwardDeltas {
|
||||
meta, ok := metas[forwardID]
|
||||
if !ok || meta.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
current := tunnelAgg[meta.TunnelID]
|
||||
current.bytesIn += delta.bytesIn
|
||||
current.bytesOut += delta.bytesOut
|
||||
tunnelAgg[meta.TunnelID] = current
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTunnelProbeTargetHost = "www.bing.com"
|
||||
defaultTunnelProbeTargetPort = 443
|
||||
)
|
||||
|
||||
type tunnelProbeTarget struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
func defaultTunnelProbeTarget() tunnelProbeTarget {
|
||||
return tunnelProbeTarget{Host: defaultTunnelProbeTargetHost, Port: defaultTunnelProbeTargetPort}
|
||||
}
|
||||
|
||||
func normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" && port == 0 {
|
||||
return defaultTunnelProbeTarget(), false, nil
|
||||
}
|
||||
if host == "" {
|
||||
return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能为空")
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
return tunnelProbeTarget{}, false, errors.New("测试目标端口必须是 1-65535")
|
||||
}
|
||||
if strings.Contains(host, "://") || strings.ContainsAny(host, "/?#") || strings.ContainsAny(host, " \t\r\n") || isTunnelProbeTargetSchemeLikeHost(host) {
|
||||
return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能包含协议或路径")
|
||||
}
|
||||
if normalized, ok := normalizeTunnelProbeTargetHost(host); ok {
|
||||
host = normalized
|
||||
} else {
|
||||
return tunnelProbeTarget{}, false, errors.New("测试目标 Host 格式无效")
|
||||
}
|
||||
|
||||
return tunnelProbeTarget{Host: host, Port: port}, true, nil
|
||||
}
|
||||
|
||||
func normalizeTunnelProbeTargetHost(host string) (string, bool) {
|
||||
if strings.HasPrefix(host, "[") || strings.HasSuffix(host, "]") {
|
||||
if !strings.HasPrefix(host, "[") || !strings.HasSuffix(host, "]") {
|
||||
return "", false
|
||||
}
|
||||
inner := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
addr, err := netip.ParseAddr(inner)
|
||||
if err != nil || !addr.Is6() {
|
||||
return "", false
|
||||
}
|
||||
return inner, true
|
||||
}
|
||||
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.String(), true
|
||||
}
|
||||
if strings.Contains(host, ":") || isTunnelProbeTargetIPv4Like(host) {
|
||||
return "", false
|
||||
}
|
||||
if !isValidTunnelProbeTargetHost(host) {
|
||||
return "", false
|
||||
}
|
||||
return host, true
|
||||
}
|
||||
|
||||
func isValidTunnelProbeTargetHost(host string) bool {
|
||||
if host == "" || len(host) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(host, ".") {
|
||||
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, r := range label {
|
||||
if !isASCIILetter(r) && !isASCIIDigit(r) && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isTunnelProbeTargetIPv4Like(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range host {
|
||||
if !isASCIIDigit(r) && r != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return strings.Contains(host, ".")
|
||||
}
|
||||
|
||||
func isTunnelProbeTargetSchemeLikeHost(host string) bool {
|
||||
if _, err := netip.ParseAddr(host); err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
colon := strings.IndexByte(host, ':')
|
||||
if colon <= 0 {
|
||||
return false
|
||||
}
|
||||
for i, r := range host[:colon] {
|
||||
if i == 0 {
|
||||
if !isASCIILetter(r) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isASCIILetter(r) && !isASCIIDigit(r) && r != '+' && r != '-' && r != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isASCIILetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
func isASCIIDigit(r rune) bool {
|
||||
return r >= '0' && r <= '9'
|
||||
}
|
||||
|
||||
func parseTunnelProbeTargetFromRequest(req map[string]interface{}) (tunnelProbeTarget, bool, error) {
|
||||
if req == nil {
|
||||
return defaultTunnelProbeTarget(), false, nil
|
||||
}
|
||||
rawHost, hasHost := req["probeTargetHost"]
|
||||
rawPort, hasPort := req["probeTargetPort"]
|
||||
if !hasHost && !hasPort {
|
||||
return defaultTunnelProbeTarget(), false, nil
|
||||
}
|
||||
host, err := parseTunnelProbeTargetHostValue(rawHost)
|
||||
if err != nil {
|
||||
return tunnelProbeTarget{}, false, err
|
||||
}
|
||||
port, err := parseTunnelProbeTargetPortValue(rawPort)
|
||||
if err != nil {
|
||||
return tunnelProbeTarget{}, false, err
|
||||
}
|
||||
return normalizeTunnelProbeTarget(host, port)
|
||||
}
|
||||
|
||||
func parseTunnelProbeTargetHostValue(raw interface{}) (string, error) {
|
||||
if raw == nil {
|
||||
return "", nil
|
||||
}
|
||||
host, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", errors.New("测试目标 Host 格式无效")
|
||||
}
|
||||
if host != strings.TrimSpace(host) {
|
||||
return "", errors.New("测试目标 Host 不能包含协议或路径")
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func parseTunnelProbeTargetPortValue(raw interface{}) (int, error) {
|
||||
if raw == nil {
|
||||
return 0, nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if v != float64(int64(v)) {
|
||||
return 0, errors.New("测试目标端口必须是整数")
|
||||
}
|
||||
return int(v), nil
|
||||
case string:
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if v != strings.TrimSpace(v) {
|
||||
return 0, errors.New("测试目标端口必须是整数")
|
||||
}
|
||||
port, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, errors.New("测试目标端口必须是整数")
|
||||
}
|
||||
return port, nil
|
||||
case int:
|
||||
return v, nil
|
||||
case int32:
|
||||
return int(v), nil
|
||||
case int64:
|
||||
return int(v), nil
|
||||
default:
|
||||
return 0, errors.New("测试目标端口必须是整数")
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget {
|
||||
if tunnel == nil {
|
||||
return defaultTunnelProbeTarget()
|
||||
}
|
||||
return effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort)
|
||||
}
|
||||
|
||||
func effectiveTunnelProbeTargetValues(host string, port int) tunnelProbeTarget {
|
||||
target, configured, err := normalizeTunnelProbeTarget(host, port)
|
||||
if err != nil || !configured {
|
||||
return defaultTunnelProbeTarget()
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func formatTunnelProbeTarget(target tunnelProbeTarget) string {
|
||||
if addr, err := netip.ParseAddr(target.Host); err == nil && addr.Is6() {
|
||||
return fmt.Sprintf("[%s]:%d", target.Host, target.Port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", target.Host, target.Port)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestTunnelCreatePersistsProbeTargetAndListReturnsConfiguredValue(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
body := bytes.NewReader([]byte(`{
|
||||
"name":"custom-target",
|
||||
"type":1,
|
||||
"flow":1,
|
||||
"trafficRatio":1,
|
||||
"status":1,
|
||||
"inNodeId":[{"nodeId":10,"protocol":"tls"}],
|
||||
"probeTargetHost":"speed.example.com",
|
||||
"probeTargetPort":8443
|
||||
}`))
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body))
|
||||
assertProbeTargetSuccess(t, res)
|
||||
|
||||
listRes := httptest.NewRecorder()
|
||||
h.tunnelList(listRes, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil))
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
decodeProbeTargetResponse(t, listRes, &payload)
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected success, got code %d", payload.Code)
|
||||
}
|
||||
item := payload.Data[0]
|
||||
if item["probeTargetHost"] != "speed.example.com" || item["probeTargetPort"] != float64(8443) {
|
||||
t.Fatalf("unexpected probe target in list response: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdatePersistsDefaultProbeTargetAsEmpty(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 77, "existing", "old.example.com", 9443)
|
||||
body := bytes.NewReader([]byte(`{
|
||||
"id":77,
|
||||
"name":"existing",
|
||||
"type":1,
|
||||
"flow":1,
|
||||
"trafficRatio":1,
|
||||
"status":1,
|
||||
"inNodeId":[{"nodeId":10,"protocol":"tls"}],
|
||||
"probeTargetHost":"",
|
||||
"probeTargetPort":0
|
||||
}`))
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
|
||||
assertProbeTargetSuccess(t, res)
|
||||
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
t.Fatalf("list tunnels: %v", err)
|
||||
}
|
||||
item := findProbeTargetTunnelItem(t, items, 77)
|
||||
if item["probeTargetHost"] != "" || item["probeTargetPort"] != 0 {
|
||||
t.Fatalf("expected default target to round-trip as empty/0, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateWithoutProbeTargetFieldsPreservesExistingTarget(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 79, "existing", "old.example.com", 9443)
|
||||
body := bytes.NewReader([]byte(`{
|
||||
"id":79,
|
||||
"name":"existing",
|
||||
"type":1,
|
||||
"flow":1,
|
||||
"trafficRatio":1,
|
||||
"status":1,
|
||||
"inNodeId":[{"nodeId":10,"protocol":"tls"}]
|
||||
}`))
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
|
||||
assertProbeTargetSuccess(t, res)
|
||||
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
t.Fatalf("list tunnels: %v", err)
|
||||
}
|
||||
item := findProbeTargetTunnelItem(t, items, 79)
|
||||
if item["probeTargetHost"] != "old.example.com" || item["probeTargetPort"] != 9443 {
|
||||
t.Fatalf("expected omitted probe target fields to preserve existing target, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateRejectsInvalidProbeTargetWithoutClearingExistingTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
probeFields string
|
||||
}{
|
||||
{name: "non numeric port", probeFields: `,"probeTargetPort":"abc"`},
|
||||
{name: "fractional port", probeFields: `,"probeTargetPort":443.5`},
|
||||
{name: "whitespace host", probeFields: `,"probeTargetHost":" "`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 80, "existing", "old.example.com", 9443)
|
||||
body := bytes.NewReader([]byte(`{
|
||||
"id":80,
|
||||
"name":"existing",
|
||||
"type":1,
|
||||
"flow":1,
|
||||
"trafficRatio":1,
|
||||
"status":1,
|
||||
"inNodeId":[{"nodeId":10,"protocol":"tls"}]
|
||||
` + tt.probeFields + `}`))
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
decodeProbeTargetResponse(t, res, &payload)
|
||||
if payload.Code == 0 || payload.Msg == "" {
|
||||
t.Fatalf("expected validation failure, got %+v", payload)
|
||||
}
|
||||
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
t.Fatalf("list tunnels: %v", err)
|
||||
}
|
||||
item := findProbeTargetTunnelItem(t, items, 80)
|
||||
if item["probeTargetHost"] != "old.example.com" || item["probeTargetPort"] != 9443 {
|
||||
t.Fatalf("expected invalid probe target to preserve existing target, got %+v", item)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelCreateRejectsInvalidProbeTarget(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
body := bytes.NewReader([]byte(`{
|
||||
"name":"bad-target",
|
||||
"type":1,
|
||||
"flow":1,
|
||||
"trafficRatio":1,
|
||||
"status":1,
|
||||
"inNodeId":[{"nodeId":10,"protocol":"tls"}],
|
||||
"probeTargetHost":"https://example.com",
|
||||
"probeTargetPort":443
|
||||
}`))
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body))
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
decodeProbeTargetResponse(t, res, &payload)
|
||||
if payload.Code == 0 || payload.Msg == "" {
|
||||
t.Fatalf("expected validation failure, got %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateInvalidProbeTargetDoesNotCleanFederationBindings(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 88, "existing", "old.example.com", 9443)
|
||||
seedProbeTargetFederationBinding(t, h, 88)
|
||||
body := bytes.NewReader([]byte(`{
|
||||
"id":88,
|
||||
"name":"existing",
|
||||
"type":1,
|
||||
"flow":1,
|
||||
"trafficRatio":1,
|
||||
"status":1,
|
||||
"inNodeId":[{"nodeId":10,"protocol":"tls"}],
|
||||
"probeTargetHost":"https://example.com",
|
||||
"probeTargetPort":443
|
||||
}`))
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
decodeProbeTargetResponse(t, res, &payload)
|
||||
if payload.Code == 0 || payload.Msg == "" {
|
||||
t.Fatalf("expected validation failure, got %+v", payload)
|
||||
}
|
||||
|
||||
bindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(88)
|
||||
if err != nil {
|
||||
t.Fatalf("list federation bindings: %v", err)
|
||||
}
|
||||
if len(bindings) != 1 {
|
||||
t.Fatalf("expected federation binding to remain after invalid update, got %d", len(bindings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelDiagnosisUsesConfiguredProbeTarget(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 90, "diagnosis-target", "speed.example.com", 8443)
|
||||
|
||||
_, _, workItems, err := h.prepareTunnelDiagnosis(90)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare tunnel diagnosis: %v", err)
|
||||
}
|
||||
if len(workItems) != 1 {
|
||||
t.Fatalf("expected one diagnosis item, got %d", len(workItems))
|
||||
}
|
||||
if workItems[0].targetIP != "speed.example.com" || workItems[0].targetPort != 8443 {
|
||||
t.Fatalf("expected custom diagnosis target speed.example.com:8443, got %s:%d", workItems[0].targetIP, workItems[0].targetPort)
|
||||
}
|
||||
}
|
||||
|
||||
func setupProbeTargetTunnelHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
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 := New(r, "secret")
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, 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, 'entry-a', 'entry-secret', '10.0.0.1', '10.0.0.1', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func seedProbeTargetTunnel(t *testing.T, h *Handler, id int64, name string, host string, port int) {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference, probe_target_host, probe_target_port)
|
||||
VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '', ?, ?)
|
||||
`, id, name, now, now, id, host, port).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, '1', 10, 30001, 'round', 1, 'tls')
|
||||
`, id).Error; err != nil {
|
||||
t.Fatalf("insert chain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedProbeTargetFederationBinding(t *testing.T, h *Handler, tunnelID int64) {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||
VALUES(?, 10, 1, 0, 'http://peer.example', ?, 'remote-binding', 30001, 1, ?, ?)
|
||||
`, tunnelID, "probe-target-test-binding", now, now).Error; err != nil {
|
||||
t.Fatalf("insert federation binding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertProbeTargetSuccess(t *testing.T, res *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
decodeProbeTargetResponse(t, res, &payload)
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected success, got %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeProbeTargetResponse(t *testing.T, res *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(v); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findProbeTargetTunnelItem(t *testing.T, items []map[string]interface{}, id int64) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, item := range items {
|
||||
if asInt64(item["id"], 0) == id {
|
||||
return item
|
||||
}
|
||||
}
|
||||
t.Fatalf("tunnel %d not found: %+v", id, items)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTunnelProbeTargetDefaultsWhenEmpty(t *testing.T) {
|
||||
target, configured, err := normalizeTunnelProbeTarget("", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if configured {
|
||||
t.Fatalf("expected empty input to be default, not configured")
|
||||
}
|
||||
if target.Host != defaultTunnelProbeTargetHost || target.Port != defaultTunnelProbeTargetPort {
|
||||
t.Fatalf("unexpected default target: %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTunnelProbeTargetAcceptsHostPortAndIPv6(t *testing.T) {
|
||||
target, configured, err := normalizeTunnelProbeTarget(" [2001:db8::1] ", 8443)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !configured {
|
||||
t.Fatalf("expected explicit target")
|
||||
}
|
||||
if target.Host != "2001:db8::1" || target.Port != 8443 {
|
||||
t.Fatalf("unexpected normalized target: %+v", target)
|
||||
}
|
||||
if got := formatTunnelProbeTarget(target); got != "[2001:db8::1]:8443" {
|
||||
t.Fatalf("unexpected formatted target: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTunnelProbeTargetRejectsPartialAndInvalidInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
}{
|
||||
{name: "missing host", host: "", port: 443},
|
||||
{name: "missing port", host: "example.com", port: 0},
|
||||
{name: "port too high", host: "example.com", port: 70000},
|
||||
{name: "scheme", host: "https://example.com", port: 443},
|
||||
{name: "path", host: "example.com/ping", port: 443},
|
||||
{name: "space", host: "example .com", port: 443},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, _, err := normalizeTunnelProbeTarget(tt.host, tt.port); err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTunnelProbeTargetRejectsSchemePrefixButAllowsIPv6(t *testing.T) {
|
||||
for _, host := range []string{"https:example.com", "mailto:ops@example.com"} {
|
||||
if _, _, err := normalizeTunnelProbeTarget(host, 443); err == nil {
|
||||
t.Fatalf("expected scheme-like host %q to be rejected", host)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range []string{"2001:db8::1", "[2001:db8::1]"} {
|
||||
target, configured, err := normalizeTunnelProbeTarget(host, 443)
|
||||
if err != nil {
|
||||
t.Fatalf("expected IPv6 host %q to be accepted: %v", host, err)
|
||||
}
|
||||
if !configured || target.Host != "2001:db8::1" {
|
||||
t.Fatalf("unexpected IPv6 normalization for %q: %+v configured=%v", host, target, configured)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTunnelProbeTargetValidatesHostShape(t *testing.T) {
|
||||
validHosts := []string{
|
||||
"example.com",
|
||||
"localhost",
|
||||
"api-1.example.co.uk",
|
||||
"192.0.2.10",
|
||||
"2001:db8::1",
|
||||
"[2001:db8::1]",
|
||||
}
|
||||
for _, host := range validHosts {
|
||||
if _, _, err := normalizeTunnelProbeTarget(host, 443); err != nil {
|
||||
t.Fatalf("expected valid host %q: %v", host, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalidHosts := []string{
|
||||
"1:2:3",
|
||||
"[2001:db8::1",
|
||||
"2001:db8::1]",
|
||||
"[example.com]",
|
||||
"example..com",
|
||||
"-example.com",
|
||||
"example-.com",
|
||||
"exa_mple.com",
|
||||
"999.1.1.1",
|
||||
}
|
||||
for _, host := range invalidHosts {
|
||||
if _, _, err := normalizeTunnelProbeTarget(host, 443); err == nil {
|
||||
t.Fatalf("expected invalid host %q to be rejected", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTunnelProbeTargetFromRequest(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"probeTargetHost": "speed.example.com",
|
||||
"probeTargetPort": float64(1443),
|
||||
}
|
||||
target, configured, err := parseTunnelProbeTargetFromRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !configured || target.Host != "speed.example.com" || target.Port != 1443 {
|
||||
t.Fatalf("unexpected request target: %+v configured=%v", target, configured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelQualityProbeInterval = 1 * time.Second
|
||||
tunnelQualityProbeTimeout = 8 * time.Second
|
||||
tunnelQualityPingTimeoutMs = 5000
|
||||
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"`
|
||||
ProbeTargetHost string `json:"probeTargetHost,omitempty"`
|
||||
ProbeTargetPort int `json:"probeTargetPort,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
|
||||
probeNode bestExitProbeFunc
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) retentionDays() int {
|
||||
if p == nil || p.handler == nil || p.handler.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := p.handler.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
|
||||
func (p *tunnelQualityProber) maybePrune() {
|
||||
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(time.Duration(p.retentionDays())*24*time.Hour/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
|
||||
}
|
||||
probeTarget := effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort)
|
||||
snap.ProbeTargetHost = probeTarget.Host
|
||||
snap.ProbeTargetPort = probeTarget.Port
|
||||
|
||||
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: "探测超时",
|
||||
}
|
||||
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget)
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
// Port forwarding: entry → public probe target only.
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, 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.pingNode(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.pingNode(outNodes[0].NodeID, probeTarget.Host, probeTarget.Port, 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 → public probe target.
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
snap.Success = true
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.storeResult(snap)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) {
|
||||
if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
|
||||
return
|
||||
}
|
||||
if !isBestTunnelStrategy(outNodes[0].Strategy) {
|
||||
return
|
||||
}
|
||||
owners := bestExitChainOwners(inNodes, chainHops)
|
||||
if len(owners) == 0 {
|
||||
return
|
||||
}
|
||||
nodeMap := make(map[int64]*nodeRecord, len(owners)+len(outNodes))
|
||||
for _, owner := range owners {
|
||||
if node, err := p.handler.getNodeRecord(owner.NodeID); err == nil && node != nil {
|
||||
nodeMap[owner.NodeID] = node
|
||||
}
|
||||
}
|
||||
for _, exit := range outNodes {
|
||||
if node, err := p.handler.getNodeRecord(exit.NodeID); err == nil && node != nil {
|
||||
nodeMap[exit.NodeID] = node
|
||||
}
|
||||
}
|
||||
// This best-exit decision cache is per decision round; the display-oriented
|
||||
// tunnel quality snapshot may still collect its own first-exit public probe.
|
||||
roundPinger := newBestExitRoundPinger(p.pingNode)
|
||||
for _, owner := range owners {
|
||||
if nodeMap[owner.NodeID] == nil {
|
||||
continue
|
||||
}
|
||||
key := bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: owner.NodeID}
|
||||
p.handler.bestExit.ensureApplied(key, outNodes[0].NodeID, time.Now())
|
||||
scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, probeTarget, roundPinger)
|
||||
decision := p.handler.bestExit.observeScores(key, scores, time.Now())
|
||||
if decision.Switch {
|
||||
now := time.Now()
|
||||
if err := p.handler.applyBestExitChainOrder(tunnelID, owner.NodeID, outNodes, decision.Scores, ipPreference); err != nil {
|
||||
log.Printf("best_exit: switch apply failed tunnel=%d owner=%d exit=%d err=%v", tunnelID, owner.NodeID, decision.ExitNodeID, err)
|
||||
p.handler.bestExit.recordApplyFailure(key, decision.ExitNodeID, now)
|
||||
continue
|
||||
}
|
||||
p.handler.bestExit.setApplied(key, decision.ExitNodeID, time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) pingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
if p != nil && p.probeNode != nil {
|
||||
return p.probeNode(nodeID, ip, port, options)
|
||||
}
|
||||
return p.tcpPingNode(nodeID, ip, port, options)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 77, "quality-target", "speed.example.com", 8443)
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO node(id, 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(30, 'exit-a', 'exit-secret', '10.0.0.30', '10.0.0.30', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, time.Now().UnixMilli(), time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("insert exit node: %v", err)
|
||||
}
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(77, '3', 30, 30001, 'round', 1, 'tls')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
p := newTunnelQualityProber(h)
|
||||
var calls []string
|
||||
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
|
||||
return 10, 0, nil
|
||||
}
|
||||
p.probeTunnel(77)
|
||||
|
||||
if !slices.Contains(calls, "10|speed.example.com|8443") {
|
||||
t.Fatalf("expected type 1 public probe from entry to configured target, calls=%+v", calls)
|
||||
}
|
||||
if slices.Contains(calls, "30|speed.example.com|8443") {
|
||||
t.Fatalf("did not expect type 1 public probe from exit node, calls=%+v", calls)
|
||||
}
|
||||
snaps := p.GetAll()
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("expected one quality snapshot, got %+v", snaps)
|
||||
}
|
||||
if snaps[0].ProbeTargetHost != "speed.example.com" || snaps[0].ProbeTargetPort != 8443 {
|
||||
t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 78, "quality-target-incomplete", "speed.example.com", 8443)
|
||||
if err := h.repo.DB().Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, 78).Error; err != nil {
|
||||
t.Fatalf("delete chain rows: %v", err)
|
||||
}
|
||||
|
||||
p := newTunnelQualityProber(h)
|
||||
p.probeTunnel(78)
|
||||
|
||||
snaps := p.GetAll()
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("expected one quality snapshot, got %+v", snaps)
|
||||
}
|
||||
if snaps[0].ErrorMessage == "" {
|
||||
t.Fatalf("expected incomplete chain error, got %+v", snaps[0])
|
||||
}
|
||||
if snaps[0].ProbeTargetHost != "speed.example.com" || snaps[0].ProbeTargetPort != 8443 {
|
||||
t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,15 @@ import (
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
// failedForward tracks a forward that failed redeployment, for retry.
|
||||
type failedForward struct {
|
||||
id int64
|
||||
forward *forwardRecord
|
||||
err error
|
||||
}
|
||||
|
||||
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 +29,9 @@ const (
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
|
||||
defaultGithubProxyEnabled = true
|
||||
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,6 +39,8 @@ var (
|
||||
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
|
||||
)
|
||||
|
||||
const nodeOnlineRedeployCooldown = 30 * time.Second
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
@@ -70,6 +81,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 +193,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 +251,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"`
|
||||
@@ -366,24 +398,123 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
|
||||
}
|
||||
|
||||
func (h *Handler) onNodeOnline(nodeID int64) {
|
||||
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
|
||||
if !h.startNodeOnlineRedeploy(nodeID, time.Now()) {
|
||||
return
|
||||
}
|
||||
h.redeployNodeRuntimeAfterUpgrade(nodeID)
|
||||
defer h.finishNodeOnlineRedeploy(nodeID)
|
||||
|
||||
// Reconcile node runtime on the first reconnect, but suppress rapid flapping
|
||||
// so websocket churn does not trigger repeated full redeploy storms.
|
||||
if !h.redeployNodeRuntimeAfterUpgrade(nodeID) {
|
||||
h.markNodePendingUpgradeRedeploy(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
func (h *Handler) startNodeOnlineRedeploy(nodeID int64, now time.Time) bool {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return false
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
h.upgradeMu.Lock()
|
||||
defer h.upgradeMu.Unlock()
|
||||
if h.pendingUpgradeRedeploy == nil {
|
||||
h.pendingUpgradeRedeploy = make(map[int64]struct{})
|
||||
}
|
||||
if h.nodeOnlineRedeployAt == nil {
|
||||
h.nodeOnlineRedeployAt = make(map[int64]time.Time)
|
||||
}
|
||||
if h.nodeOnlineRedeployQueued == nil {
|
||||
h.nodeOnlineRedeployQueued = make(map[int64]struct{})
|
||||
}
|
||||
if h.nodeOnlineRedeploying == nil {
|
||||
h.nodeOnlineRedeploying = make(map[int64]struct{})
|
||||
}
|
||||
|
||||
_, pendingUpgrade := h.pendingUpgradeRedeploy[nodeID]
|
||||
lastRedeployAt := h.nodeOnlineRedeployAt[nodeID]
|
||||
_, inFlight := h.nodeOnlineRedeploying[nodeID]
|
||||
if fireAt, start := nextNodeOnlineRedeployFireAt(lastRedeployAt, now, pendingUpgrade, inFlight); !start {
|
||||
h.queueNodeOnlineRedeployLocked(nodeID, fireAt)
|
||||
return false
|
||||
}
|
||||
|
||||
delete(h.pendingUpgradeRedeploy, nodeID)
|
||||
h.nodeOnlineRedeployAt[nodeID] = now
|
||||
h.nodeOnlineRedeploying[nodeID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func nextNodeOnlineRedeployFireAt(lastRedeployAt, now time.Time, pendingUpgrade bool, inFlight bool) (time.Time, bool) {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
if inFlight {
|
||||
fireAt := now.Add(nodeOnlineRedeployCooldown)
|
||||
if !lastRedeployAt.IsZero() {
|
||||
cooldownAt := lastRedeployAt.Add(nodeOnlineRedeployCooldown)
|
||||
if cooldownAt.After(now) {
|
||||
fireAt = cooldownAt
|
||||
}
|
||||
}
|
||||
return fireAt, false
|
||||
}
|
||||
if !pendingUpgrade && !lastRedeployAt.IsZero() && now.Sub(lastRedeployAt) < nodeOnlineRedeployCooldown {
|
||||
return lastRedeployAt.Add(nodeOnlineRedeployCooldown), false
|
||||
}
|
||||
return time.Time{}, true
|
||||
}
|
||||
|
||||
func (h *Handler) queueNodeOnlineRedeployLocked(nodeID int64, fireAt time.Time) {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
if h.nodeOnlineRedeployQueued == nil {
|
||||
h.nodeOnlineRedeployQueued = make(map[int64]struct{})
|
||||
}
|
||||
if _, queued := h.nodeOnlineRedeployQueued[nodeID]; queued {
|
||||
return
|
||||
}
|
||||
if fireAt.IsZero() {
|
||||
fireAt = time.Now().Add(nodeOnlineRedeployCooldown)
|
||||
}
|
||||
delay := time.Until(fireAt)
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
h.nodeOnlineRedeployQueued[nodeID] = struct{}{}
|
||||
time.AfterFunc(delay, func() {
|
||||
h.upgradeMu.Lock()
|
||||
delete(h.nodeOnlineRedeployQueued, nodeID)
|
||||
h.upgradeMu.Unlock()
|
||||
h.onNodeOnline(nodeID)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
delete(h.nodeOnlineRedeploying, nodeID)
|
||||
h.upgradeMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
|
||||
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
|
||||
forwardIDs, err := h.repo.ListForwardIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// First pass: deploy everything
|
||||
tunnelFailed := make(map[int64]struct{})
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
@@ -392,6 +523,9 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect forwards that failed independently (not skipped due to tunnel failure)
|
||||
var failedForwards []failedForward
|
||||
|
||||
for _, forwardID := range forwardIDs {
|
||||
forward, getErr := h.getForwardRecord(forwardID)
|
||||
if getErr != nil || forward == nil {
|
||||
@@ -401,7 +535,87 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
continue
|
||||
}
|
||||
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
||||
failedForwards = append(failedForwards, failedForward{id: forwardID, forward: forward, err: err})
|
||||
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry failed items with exponential backoff (max 3 attempts)
|
||||
return h.retryFailedRedeploys(nodeID, tunnelFailed, failedForwards)
|
||||
}
|
||||
|
||||
// isRetryableError returns true if the error looks transient and worth retrying.
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
// Skip non-retryable errors: not-found, already-exists, validation errors
|
||||
if strings.Contains(msg, "not found") || strings.Contains(msg, "不存在") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") {
|
||||
return false
|
||||
}
|
||||
// Everything else (timeout, connection lost, port in use, etc.) is retryable
|
||||
return true
|
||||
}
|
||||
|
||||
// retryFailedRedeploys retries failed tunnels and forwards with exponential backoff.
|
||||
func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]struct{}, failedForwards []failedForward) bool {
|
||||
if len(tunnelFailed) == 0 && len(failedForwards) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
const maxRetries = 3
|
||||
baseDelay := time.Second
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt-1)) // 1s, 2s, 4s
|
||||
time.Sleep(delay)
|
||||
|
||||
// Retry failed tunnels
|
||||
for tunnelID := range tunnelFailed {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err == nil {
|
||||
delete(tunnelFailed, tunnelID)
|
||||
fmt.Printf("post-upgrade redeploy retry: tunnel %d succeeded on node %d (attempt %d)\n", tunnelID, nodeID, attempt)
|
||||
} else if !isRetryableError(err) {
|
||||
delete(tunnelFailed, tunnelID) // Non-retryable, don't retry again
|
||||
} else {
|
||||
fmt.Printf("post-upgrade redeploy retry: tunnel %d still failing on node %d (attempt %d): %v\n", tunnelID, nodeID, attempt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry failed forwards
|
||||
var stillFailed []failedForward
|
||||
for _, ff := range failedForwards {
|
||||
if _, skipped := tunnelFailed[ff.forward.TunnelID]; skipped {
|
||||
stillFailed = append(stillFailed, ff) // Tunnel still failed, skip forward
|
||||
continue
|
||||
}
|
||||
if err := h.syncForwardServices(ff.forward, "UpdateService", true); err == nil {
|
||||
fmt.Printf("post-upgrade redeploy retry: forward %d succeeded on node %d (attempt %d)\n", ff.id, nodeID, attempt)
|
||||
} else if !isRetryableError(err) {
|
||||
// Non-retryable, drop it
|
||||
} else {
|
||||
stillFailed = append(stillFailed, ff)
|
||||
fmt.Printf("post-upgrade redeploy retry: forward %d still failing on node %d (attempt %d): %v\n", ff.id, nodeID, attempt, err)
|
||||
}
|
||||
}
|
||||
failedForwards = stillFailed
|
||||
|
||||
if len(tunnelFailed) == 0 && len(failedForwards) == 0 {
|
||||
fmt.Printf("post-upgrade redeploy retry: all items recovered on node %d\n", nodeID)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
for tunnelID := range tunnelFailed {
|
||||
fmt.Printf("post-upgrade redeploy: tunnel %d permanently failed on node %d after retries\n", tunnelID, nodeID)
|
||||
}
|
||||
for _, ff := range failedForwards {
|
||||
fmt.Printf("post-upgrade redeploy: forward %d permanently failed on node %d after retries\n", ff.id, nodeID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartNodeOnlineRedeploySkipsRecentReconnects(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
|
||||
if h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
|
||||
t.Fatalf("expected recent reconnect to skip redeploy")
|
||||
}
|
||||
if h.consumeNodePendingUpgradeRedeploy(54) {
|
||||
t.Fatalf("did not expect pending upgrade marker to be consumed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNodeOnlineRedeployAllowsPendingUpgradeDuringCooldown(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
h.markNodePendingUpgradeRedeploy(54)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
|
||||
t.Fatalf("expected pending upgrade reconnect to bypass cooldown")
|
||||
}
|
||||
if h.consumeNodePendingUpgradeRedeploy(54) {
|
||||
t.Fatalf("expected pending upgrade marker to be consumed during redeploy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
|
||||
if h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
|
||||
t.Fatalf("expected cooldown reconnect to skip immediate redeploy")
|
||||
}
|
||||
if _, queued := h.nodeOnlineRedeployQueued[54]; !queued {
|
||||
t.Fatalf("expected cooldown reconnect to queue a follow-up redeploy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(54)
|
||||
|
||||
if h.startNodeOnlineRedeploy(54, now.Add(time.Second)) {
|
||||
t.Fatalf("expected in-flight redeploy to suppress parallel restart")
|
||||
}
|
||||
if !h.consumeNodePendingUpgradeRedeploy(54) {
|
||||
t.Fatalf("expected pending upgrade marker to remain for the next retry")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
}
|
||||
|
||||
func TestNextNodeOnlineRedeployFireAtDefersExpiredInFlightReconnect(t *testing.T) {
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
last := now.Add(-nodeOnlineRedeployCooldown - 5*time.Second)
|
||||
|
||||
fireAt, start := nextNodeOnlineRedeployFireAt(last, now, false, true)
|
||||
if start {
|
||||
t.Fatalf("expected in-flight reconnect to queue instead of starting immediately")
|
||||
}
|
||||
|
||||
want := now.Add(nodeOnlineRedeployCooldown)
|
||||
if !fireAt.Equal(want) {
|
||||
t.Fatalf("expected queued reconnect at %s, got %s", want, fireAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func isUserQuotaExceeded(view *model.UserQuotaView) bool {
|
||||
if view == nil {
|
||||
return false
|
||||
}
|
||||
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*bytesPerGB {
|
||||
return true
|
||||
}
|
||||
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*bytesPerGB {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) userQuotaBlockReason(userID int64, now int64) (string, error) {
|
||||
if h == nil || h.repo == nil || userID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
quota, err := h.repo.GetUserQuotaView(userID, time.UnixMilli(now))
|
||||
if err != nil || quota == nil {
|
||||
return "", err
|
||||
}
|
||||
if quota.DisabledByQuota == 1 || isUserQuotaExceeded(quota) {
|
||||
return "该用户流量配额已超额,禁止开启转发", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (h *Handler) enforceUserQuotaIfNeeded(userID int64, quota *model.UserQuotaView) {
|
||||
if h == nil || h.repo == nil || userID <= 0 || quota == nil {
|
||||
return
|
||||
}
|
||||
if quota.DisabledByQuota == 1 || !isUserQuotaExceeded(quota) {
|
||||
return
|
||||
}
|
||||
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pausedIDs := make([]int64, 0, len(forwards))
|
||||
now := time.Now().UnixMilli()
|
||||
for i := range forwards {
|
||||
forward := &forwards[i]
|
||||
if forward.Status != 1 {
|
||||
continue
|
||||
}
|
||||
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
|
||||
continue
|
||||
}
|
||||
pausedIDs = append(pausedIDs, forward.ID)
|
||||
}
|
||||
_ = h.repo.MarkUserQuotaDisabled(userID, pausedIDs, now)
|
||||
}
|
||||
|
||||
func (h *Handler) applyUserQuotaRelease(release *repo.UserQuotaRelease, now int64) {
|
||||
if h == nil || h.repo == nil || release == nil || release.UserID <= 0 || !release.UnblockUser {
|
||||
return
|
||||
}
|
||||
for _, forwardID := range release.ForwardIDs {
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
continue
|
||||
}
|
||||
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = h.repo.UpdateForwardStatus(forwardID, 1, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resetUserQuotaWindows(now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
releases, err := h.repo.RollUserQuotaWindows(now)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
for i := range releases {
|
||||
h.applyUserQuotaRelease(&releases[i], nowMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) userQuotaReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
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
|
||||
}
|
||||
release, err := h.repo.ResetUserQuotaUsage(req.UserID, req.Scope, time.Now())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
h.applyUserQuotaRelease(release, nowMs)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) ensureUserForwardAllowedByQuota(userID int64, now int64) error {
|
||||
reason, err := h.userQuotaBlockReason(userID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reason != "" {
|
||||
return errors.New(reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
@@ -14,7 +15,8 @@ type contextKey string
|
||||
const ClaimsContextKey contextKey = "claims"
|
||||
|
||||
type AuthOptions struct {
|
||||
JWTSecret string
|
||||
JWTSecret string
|
||||
GetUserAuthState func(userID int64) (*auth.UserAuthState, error)
|
||||
}
|
||||
|
||||
func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
||||
@@ -32,16 +34,45 @@ func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
||||
|
||||
token := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if token == "" {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := auth.ValidateToken(token, opts.JWTSecret)
|
||||
if !ok {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
if opts.GetUserAuthState != nil {
|
||||
userID, err := strconv.ParseInt(claims.Sub, 10, 64)
|
||||
if err != nil {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
state, err := opts.GetUserAuthState(userID)
|
||||
if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt {
|
||||
if allowsOptionalAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if requiresAdmin(r.URL.Path) && claims.RoleID != 0 {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
||||
return
|
||||
@@ -69,6 +100,10 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func allowsOptionalAuth(path string) bool {
|
||||
return path == "/api/v1/config/get"
|
||||
}
|
||||
|
||||
func shouldSkip(path string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/flow/"):
|
||||
@@ -78,9 +113,11 @@ func shouldSkip(path string) bool {
|
||||
case strings.HasPrefix(path, "/api/v1/captcha/"):
|
||||
return true
|
||||
case path == "/api/v1/config/get":
|
||||
return true
|
||||
return false
|
||||
case path == "/api/v1/user/login":
|
||||
return true
|
||||
case path == "/api/v1/public/config/get":
|
||||
return true
|
||||
case path == "/api/v1/federation/connect":
|
||||
return true
|
||||
case path == "/api/v1/federation/tunnel/create":
|
||||
@@ -101,11 +138,19 @@ 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/system/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/group/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/federation/share/") {
|
||||
if strings.HasPrefix(path, "/api/v1/federation/share/") || strings.HasPrefix(path, "/api/v1/federation/node/") {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestJWTRejectsPasswordChangedToken(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs + 1}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTAcceptsCurrentUserState(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
|
||||
func TestJWTRejectsDisabledUserToken(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 0, PasswordChangedAt: time.Now().Unix()}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsPasswordChangedAtSameMillisecond(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsRoleMismatch(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsMissingUserState(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return nil, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestJWTRejectsAuthStateLookupError(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertAuthDenied(t, res)
|
||||
}
|
||||
|
||||
func TestShouldSkipDoesNotBypassConfigGet(t *testing.T) {
|
||||
if shouldSkip("/api/v1/config/get") {
|
||||
t.Fatal("expected /api/v1/config/get to require auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipBypassesPublicConfigGet(t *testing.T) {
|
||||
if !shouldSkip("/api/v1/public/config/get") {
|
||||
t.Fatal("expected /api/v1/public/config/get to remain public")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTExpiresAfterSevenDays(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
if got := claims.Exp - claims.Iat; got != int64(7*24*time.Hour/time.Second) {
|
||||
t.Fatalf("expected 7 day token lifetime, got %d seconds", got)
|
||||
}
|
||||
if claims.IatMs <= 0 {
|
||||
t.Fatalf("expected millisecond issuance time to be populated, got %d", claims.IatMs)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expected {
|
||||
t.Fatalf("expected code %d, got %d", expected, out.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuthDenied(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
assertCodeMsg(t, rec, 401, "无效的token或token已过期")
|
||||
}
|
||||
|
||||
func assertCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expectedCode || out.Msg != expectedMsg {
|
||||
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ func NewRouter(h *handler.Handler, jwtSecret string) http.Handler {
|
||||
mux.Handle("/system-info", h.WebSocketHandler())
|
||||
|
||||
wrapped := middleware.Recover(mux)
|
||||
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret})(wrapped)
|
||||
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret, GetUserAuthState: h.GetUserAuthState})(wrapped)
|
||||
wrapped = middleware.RequestLog(wrapped)
|
||||
wrapped = middleware.CORS(wrapped)
|
||||
return wrapped
|
||||
|
||||
@@ -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,149 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
"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
|
||||
}
|
||||
|
||||
func NewIngestionService(repo *repo.Repository) *IngestionService {
|
||||
return &IngestionService{
|
||||
repo: repo,
|
||||
nodeBuffer: make([]*model.NodeMetric, 0, 500),
|
||||
flushInterval: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
s.pruneMetricsAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *IngestionService) retentionDaysFromConfig() int {
|
||||
if s == nil || s.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := s.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetricsAt(now time.Time) {
|
||||
cutoff := now.Add(-time.Duration(s.retentionDaysFromConfig()) * 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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user