mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-16 01:24:24 +08:00
Compare commits
39 Commits
2.1.5
...
2.1.7-alpha2
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 6684a3426b | |||
| 7a8595ec87 | |||
| 06f76d918f | |||
| feb357ff17 | |||
| 34581e0d18 | |||
| 61c5b5e759 | |||
| c8eb780c67 |
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Task",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "entire hooks claude-code post-task"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "TodoWrite",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "entire hooks claude-code post-todo"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Task",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "entire hooks claude-code pre-task"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "entire hooks claude-code session-end"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "entire hooks claude-code session-start"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "entire hooks claude-code stop"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "entire hooks claude-code user-prompt-submit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"deny": [
|
||||||
|
"Read(./.entire/metadata/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"telemetry": false
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 限速功能重构实施计划
|
||||||
|
|
||||||
|
## 一、需求概述
|
||||||
|
|
||||||
|
**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。
|
||||||
|
|
||||||
|
**核心变更**:
|
||||||
|
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
|
||||||
+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)
|
- [安装部署](./install.md)
|
||||||
- [使用指南](./usage.md)
|
- [使用指南](./usage.md)
|
||||||
- [PostgreSQL 数据库指南](./postgresql.md)
|
- [PostgreSQL 数据库指南](./postgresql.md)
|
||||||
|
- [AI Skill 接入](./ai-skill.md) - 让大模型直接操作面板
|
||||||
- [常见问题](./faq.md)
|
- [常见问题](./faq.md)
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
Handler: router,
|
Handler: router,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 2 * time.Minute,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/http/client"
|
"go-backend/internal/http/client"
|
||||||
@@ -30,6 +32,91 @@ type diagnosisTarget struct {
|
|||||||
Port int
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type diagnosisWorkItem struct {
|
||||||
|
fromNodeID int64
|
||||||
|
targetIP string
|
||||||
|
targetPort int
|
||||||
|
description string
|
||||||
|
metadata map[string]interface{}
|
||||||
|
toNode chainNodeRecord
|
||||||
|
hasChainHop bool
|
||||||
|
ipPreference string
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosisExecOptions struct {
|
||||||
|
commandTimeout time.Duration
|
||||||
|
pingTimeoutMS int
|
||||||
|
timeoutMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosisProgress struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)
|
||||||
|
|
||||||
|
func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {
|
||||||
|
if len(workItems) == 0 {
|
||||||
|
return []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeCache := map[int64]*nodeRecord{}
|
||||||
|
items := make([]map[string]interface{}, 0, len(workItems))
|
||||||
|
for _, workItem := range workItems {
|
||||||
|
targetIP := strings.TrimSpace(workItem.targetIP)
|
||||||
|
targetPort := workItem.targetPort
|
||||||
|
if workItem.hasChainHop {
|
||||||
|
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
|
||||||
|
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
|
||||||
|
if err == nil {
|
||||||
|
resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference)
|
||||||
|
if resolveErr == nil {
|
||||||
|
targetIP = resolvedIP
|
||||||
|
targetPort = resolvedPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetPort <= 0 {
|
||||||
|
targetPort = 443
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeName := fmt.Sprintf("node_%d", workItem.fromNodeID)
|
||||||
|
if node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != "" {
|
||||||
|
nodeName = node.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"diagnosing": true,
|
||||||
|
"description": workItem.description,
|
||||||
|
"nodeName": nodeName,
|
||||||
|
"nodeId": strconv.FormatInt(workItem.fromNodeID, 10),
|
||||||
|
"targetIp": targetIP,
|
||||||
|
"targetPort": targetPort,
|
||||||
|
"message": "诊断中...",
|
||||||
|
}
|
||||||
|
for key, value := range workItem.metadata {
|
||||||
|
item[key] = value
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnosisMaxConcurrency = 8
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultNodeCommandTimeout = 6 * time.Second
|
||||||
|
diagnosisCommandTimeout = 30 * time.Second
|
||||||
|
diagnosisRequestTimeout = 2 * time.Minute
|
||||||
|
diagnosisCommandTimeoutMsg = "诊断超时(30秒)"
|
||||||
|
diagnosisRequestTimeoutMsg = "诊断超时(2分钟)"
|
||||||
|
)
|
||||||
|
|
||||||
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
|
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
|
||||||
userID, roleID, err := userRoleFromRequest(r)
|
userID, roleID, err := userRoleFromRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,11 +239,32 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
|||||||
return errors.New("转发入口端口不存在")
|
return errors.New("转发入口端口不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
userTunnelID, limiterID, speed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
|
||||||
if err != nil {
|
var limiterID *int64
|
||||||
return err
|
var speed *int
|
||||||
|
|
||||||
|
if forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {
|
||||||
|
// Forward has its own speed limit
|
||||||
|
speedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)
|
||||||
|
if err == nil && speedVal > 0 {
|
||||||
|
limiterID = &forward.SpeedID.Int64
|
||||||
|
speed = &speedVal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
|
|
||||||
|
if limiterID == nil {
|
||||||
|
// Fall back to UserTunnel speed limit
|
||||||
|
var utLimiterID *int64
|
||||||
|
var utSpeed *int
|
||||||
|
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
limiterID = utLimiterID
|
||||||
|
speed = utSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
|
||||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -164,7 +272,9 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
|||||||
|
|
||||||
for _, fp := range ports {
|
for _, fp := range ports {
|
||||||
if limiterID != nil && speed != nil {
|
if limiterID != nil && speed != nil {
|
||||||
h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed)
|
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node, err := h.getNodeRecord(fp.NodeID)
|
node, err := h.getNodeRecord(fp.NodeID)
|
||||||
@@ -272,23 +382,30 @@ func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
|
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
|
||||||
|
return h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
|
||||||
var (
|
var (
|
||||||
result ws.CommandResult
|
result ws.CommandResult
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultNodeCommandTimeout
|
||||||
|
}
|
||||||
|
|
||||||
node, nodeErr := h.getNodeRecord(nodeID)
|
node, nodeErr := h.getNodeRecord(nodeID)
|
||||||
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
||||||
result, err = h.sendRemoteNodeCommand(node, commandType, data)
|
result, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)
|
||||||
} else {
|
} else {
|
||||||
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
result, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
if tolerateExists {
|
if tolerateExists {
|
||||||
if strings.Contains(msg, "exists") || strings.Contains(msg, "already") || strings.Contains(msg, "已存在") {
|
if isAlreadyExistsMessage(msg) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,6 +418,10 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
|
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
|
||||||
|
return h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return ws.CommandResult{}, errors.New("节点不存在")
|
return ws.CommandResult{}, errors.New("节点不存在")
|
||||||
}
|
}
|
||||||
@@ -311,6 +432,9 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
|
|||||||
}
|
}
|
||||||
|
|
||||||
fc := client.NewFederationClient()
|
fc := client.NewFederationClient()
|
||||||
|
if timeout > 0 {
|
||||||
|
fc = client.NewFederationClientWithTimeout(timeout)
|
||||||
|
}
|
||||||
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
||||||
CommandType: commandType,
|
CommandType: commandType,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -338,41 +462,65 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
|
func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := h.runDiagnosisWorkItems(ctx, workItems, nil)
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"forwardName": forwardName,
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {
|
||||||
if forward == nil {
|
if forward == nil {
|
||||||
return nil, errForwardNotFound
|
return "", nil, errForwardNotFound
|
||||||
}
|
}
|
||||||
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
|
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
|
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
if len(chainRows) == 0 {
|
if len(chainRows) == 0 {
|
||||||
return nil, errors.New("隧道配置不完整")
|
return "", nil, errors.New("隧道配置不完整")
|
||||||
}
|
}
|
||||||
|
|
||||||
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
||||||
|
|
||||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||||
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
|
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
|
||||||
nodeCache := map[int64]*nodeRecord{}
|
|
||||||
|
|
||||||
switch tunnel.Type {
|
switch tunnel.Type {
|
||||||
case 1:
|
case 1:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
|
targetIP: target.IP,
|
||||||
|
targetPort: target.Port,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,19 +529,33 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
|||||||
if len(chainHops) > 0 {
|
if len(chainHops) > 0 {
|
||||||
for _, firstNode := range chainHops[0] {
|
for _, firstNode := range chainHops[0] {
|
||||||
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 2,
|
toNode: firstNode,
|
||||||
"toInx": firstNode.Inx,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": firstNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 3,
|
toNode: outNode,
|
||||||
}, ipPreference)
|
hasChainHop: true,
|
||||||
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
"toChainType": 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,21 +565,35 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
|||||||
if i+1 < len(chainHops) {
|
if i+1 < len(chainHops) {
|
||||||
for _, nextNode := range chainHops[i+1] {
|
for _, nextNode := range chainHops[i+1] {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
|
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: nextNode,
|
||||||
"toChainType": 2,
|
hasChainHop: true,
|
||||||
"toInx": nextNode.Inx,
|
ipPreference: ipPreference,
|
||||||
}, ipPreference)
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 2,
|
||||||
|
"fromInx": currentNode.Inx,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": nextNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: outNode,
|
||||||
"toChainType": 3,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 2,
|
||||||
|
"fromInx": currentNode.Inx,
|
||||||
|
"toChainType": 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,8 +602,14 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
|||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
|
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 3,
|
fromNodeID: outNode.NodeID,
|
||||||
|
targetIP: target.IP,
|
||||||
|
targetPort: target.Port,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 3,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,54 +617,80 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
|||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
|
targetIP: target.IP,
|
||||||
|
targetPort: target.Port,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return forward.Name, workItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := h.runDiagnosisWorkItems(ctx, workItems, nil)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"forwardName": forward.Name,
|
"tunnelName": tunnelName,
|
||||||
"timestamp": time.Now().UnixMilli(),
|
"tunnelType": tunnelType,
|
||||||
"results": results,
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
"results": results,
|
||||||
}
|
}
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{}, error) {
|
func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {
|
||||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
if tunnelName == "" {
|
if tunnelName == "" {
|
||||||
return nil, errors.New("隧道不存在")
|
return "", "", nil, errors.New("隧道不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
if len(chainRows) == 0 {
|
if len(chainRows) == 0 {
|
||||||
return nil, errors.New("隧道配置不完整")
|
return "", "", nil, errors.New("隧道配置不完整")
|
||||||
}
|
}
|
||||||
|
|
||||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||||
results := make([]map[string]interface{}, 0, len(chainRows)*2)
|
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
|
||||||
nodeCache := map[int64]*nodeRecord{}
|
|
||||||
|
|
||||||
switch tunnel.Type {
|
switch tunnel.Type {
|
||||||
case 1:
|
case 1:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
|
targetIP: "www.bing.com",
|
||||||
|
targetPort: 443,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
@@ -490,19 +698,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
|||||||
if len(chainHops) > 0 {
|
if len(chainHops) > 0 {
|
||||||
for _, firstNode := range chainHops[0] {
|
for _, firstNode := range chainHops[0] {
|
||||||
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 2,
|
toNode: firstNode,
|
||||||
"toInx": firstNode.Inx,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": firstNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 3,
|
toNode: outNode,
|
||||||
}, ipPreference)
|
hasChainHop: true,
|
||||||
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
"toChainType": 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,21 +734,35 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
|||||||
if i+1 < len(chainHops) {
|
if i+1 < len(chainHops) {
|
||||||
for _, nextNode := range chainHops[i+1] {
|
for _, nextNode := range chainHops[i+1] {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
|
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: nextNode,
|
||||||
"toChainType": 2,
|
hasChainHop: true,
|
||||||
"toInx": nextNode.Inx,
|
ipPreference: ipPreference,
|
||||||
}, ipPreference)
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 2,
|
||||||
|
"fromInx": currentNode.Inx,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": nextNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: outNode,
|
||||||
"toChainType": 3,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 2,
|
||||||
|
"fromInx": currentNode.Inx,
|
||||||
|
"toChainType": 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,26 +770,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
|||||||
|
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 3,
|
fromNodeID: outNode.NodeID,
|
||||||
|
targetIP: "www.bing.com",
|
||||||
|
targetPort: 443,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 3,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
|
targetIP: "www.bing.com",
|
||||||
|
targetPort: 443,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
tunnelType := map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1]
|
||||||
"tunnelName": tunnelName,
|
return tunnelName, tunnelType, workItems, nil
|
||||||
"tunnelType": map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1],
|
|
||||||
"timestamp": time.Now().UnixMilli(),
|
|
||||||
"results": results,
|
|
||||||
}
|
|
||||||
return payload, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
|
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
|
||||||
@@ -605,6 +848,159 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
|
|||||||
return targets, nil
|
return targets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func diagnosisContextMessage(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return diagnosisRequestTimeoutMsg
|
||||||
|
}
|
||||||
|
switch ctx.Err() {
|
||||||
|
case context.DeadlineExceeded:
|
||||||
|
return diagnosisRequestTimeoutMsg
|
||||||
|
case context.Canceled:
|
||||||
|
return "诊断已取消"
|
||||||
|
default:
|
||||||
|
return diagnosisRequestTimeoutMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {
|
||||||
|
timeout := diagnosisCommandTimeout
|
||||||
|
if ctx != nil {
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
remaining = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
if remaining < timeout {
|
||||||
|
timeout = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
pingTimeoutMS := int(timeout / time.Millisecond)
|
||||||
|
if pingTimeoutMS <= 0 {
|
||||||
|
pingTimeoutMS = 100
|
||||||
|
}
|
||||||
|
return diagnosisExecOptions{
|
||||||
|
commandTimeout: timeout,
|
||||||
|
pingTimeoutMS: pingTimeoutMS,
|
||||||
|
timeoutMessage: diagnosisContextMessage(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {
|
||||||
|
targetPort := workItem.targetPort
|
||||||
|
if targetPort <= 0 {
|
||||||
|
targetPort = workItem.toNode.Port
|
||||||
|
}
|
||||||
|
item := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)
|
||||||
|
item["success"] = false
|
||||||
|
if strings.TrimSpace(message) == "" {
|
||||||
|
message = diagnosisCommandTimeoutMsg
|
||||||
|
}
|
||||||
|
item["message"] = message
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {
|
||||||
|
single := make([]map[string]interface{}, 0, 1)
|
||||||
|
nodeCache := map[int64]*nodeRecord{}
|
||||||
|
if workItem.hasChainHop {
|
||||||
|
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)
|
||||||
|
} else {
|
||||||
|
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(single) == 0 {
|
||||||
|
return newDiagnosisTimeoutItem(workItem, "诊断任务未返回结果")
|
||||||
|
}
|
||||||
|
return single[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
results := make([]map[string]interface{}, len(workItems))
|
||||||
|
if len(workItems) == 0 {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
workerLimit := diagnosisMaxConcurrency
|
||||||
|
if workerLimit < 1 {
|
||||||
|
workerLimit = 1
|
||||||
|
}
|
||||||
|
if workerLimit > len(workItems) {
|
||||||
|
workerLimit = len(workItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosisWorkResult struct {
|
||||||
|
index int
|
||||||
|
item map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := make(chan int)
|
||||||
|
resultCh := make(chan diagnosisWorkResult, len(workItems))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < workerLimit; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for index := range jobs {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
resultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
options := diagnosisExecOptionsFromContext(ctx)
|
||||||
|
resultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueLoop:
|
||||||
|
for i := 0; i < len(workItems); i++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
message := diagnosisContextMessage(ctx)
|
||||||
|
for j := i; j < len(workItems); j++ {
|
||||||
|
resultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}
|
||||||
|
}
|
||||||
|
break enqueueLoop
|
||||||
|
case jobs <- i:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(resultCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
progress := diagnosisProgress{Total: len(workItems)}
|
||||||
|
for result := range resultCh {
|
||||||
|
results[result.index] = result.item
|
||||||
|
progress.Completed++
|
||||||
|
if asBool(result.item["success"], false) {
|
||||||
|
progress.Success++
|
||||||
|
} else {
|
||||||
|
progress.Failed++
|
||||||
|
}
|
||||||
|
if emitter != nil {
|
||||||
|
emitter(result.index, result.item, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range results {
|
||||||
|
if results[i] == nil {
|
||||||
|
results[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {
|
func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {
|
||||||
if node, ok := nodeCache[nodeID]; ok {
|
if node, ok := nodeCache[nodeID]; ok {
|
||||||
return node, nil
|
return node, nil
|
||||||
@@ -646,7 +1042,7 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
|
|||||||
*results = append(*results, item)
|
*results = append(*results, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) {
|
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {
|
||||||
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
|
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
|
||||||
|
|
||||||
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
|
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
|
||||||
@@ -663,9 +1059,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
|||||||
pingErr error
|
pingErr error
|
||||||
)
|
)
|
||||||
if fromNode.IsRemote == 1 {
|
if fromNode.IsRemote == 1 {
|
||||||
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort)
|
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
|
||||||
} else {
|
} else {
|
||||||
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
|
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
|
||||||
}
|
}
|
||||||
if pingErr != nil {
|
if pingErr != nil {
|
||||||
item["success"] = false
|
item["success"] = false
|
||||||
@@ -696,7 +1092,7 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
|||||||
*results = append(*results, item)
|
*results = append(*results, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string) {
|
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {
|
||||||
fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
|
fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
|
||||||
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
|
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -708,7 +1104,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
|
|||||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata)
|
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {
|
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {
|
||||||
@@ -761,13 +1157,19 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
|
|||||||
return h.repo.ListChainNodesForTunnel(tunnelID)
|
return h.repo.ListChainNodesForTunnel(tunnelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]interface{}, error) {
|
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||||
res, err := h.sendNodeCommand(nodeID, "TcpPing", map[string]interface{}{
|
if options.commandTimeout <= 0 {
|
||||||
|
options.commandTimeout = diagnosisCommandTimeout
|
||||||
|
}
|
||||||
|
if options.pingTimeoutMS <= 0 {
|
||||||
|
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
|
||||||
|
}
|
||||||
|
res, err := h.sendNodeCommandWithTimeout(nodeID, "TcpPing", map[string]interface{}{
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
"port": port,
|
"port": port,
|
||||||
"count": 4,
|
"count": 4,
|
||||||
"timeout": 5000,
|
"timeout": options.pingTimeoutMS,
|
||||||
}, false, false)
|
}, options.commandTimeout, false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -777,7 +1179,7 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]
|
|||||||
return res.Data, nil
|
return res.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (map[string]interface{}, error) {
|
func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return nil, errors.New("节点不存在")
|
return nil, errors.New("节点不存在")
|
||||||
}
|
}
|
||||||
@@ -786,13 +1188,19 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (m
|
|||||||
if remoteURL == "" || remoteToken == "" {
|
if remoteURL == "" || remoteToken == "" {
|
||||||
return nil, errors.New("远程节点缺少共享配置")
|
return nil, errors.New("远程节点缺少共享配置")
|
||||||
}
|
}
|
||||||
|
if options.commandTimeout <= 0 {
|
||||||
|
options.commandTimeout = diagnosisCommandTimeout
|
||||||
|
}
|
||||||
|
if options.pingTimeoutMS <= 0 {
|
||||||
|
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
fc := client.NewFederationClient()
|
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
|
||||||
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
|
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
|
||||||
IP: strings.TrimSpace(ip),
|
IP: strings.TrimSpace(ip),
|
||||||
Port: port,
|
Port: port,
|
||||||
Count: 4,
|
Count: 4,
|
||||||
Timeout: 5000,
|
Timeout: options.pingTimeoutMS,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,6 +1292,17 @@ func isNotFoundError(err error) bool {
|
|||||||
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
|
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAlreadyExistsMessage(message string) bool {
|
||||||
|
msg := strings.ToLower(strings.TrimSpace(message))
|
||||||
|
if msg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "address already in use") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
|
||||||
|
}
|
||||||
|
|
||||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||||
protocols := []string{"tcp", "udp"}
|
protocols := []string{"tcp", "udp"}
|
||||||
services := make([]map[string]interface{}, 0, 2)
|
services := make([]map[string]interface{}, 0, 2)
|
||||||
@@ -994,48 +1413,46 @@ func asBool(v interface{}, def bool) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendLimiterConfig(limiterID int64, speedMbps int, tunnelID int64) error {
|
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {
|
||||||
rate := float64(speedMbps) / 8.0
|
if err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {
|
||||||
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
|
return fmt.Errorf("限速规则下发失败: %w", err)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"name": strconv.FormatInt(limiterID, 10),
|
|
||||||
"limits": []string{limitStr},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, nodeID := range nodes {
|
|
||||||
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendDeleteLimiterConfig(limiterID int64, tunnelID int64) error {
|
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
|
||||||
payload := map[string]interface{}{
|
|
||||||
"limiter": strconv.FormatInt(limiterID, 10),
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, nodeID := range nodes {
|
|
||||||
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", payload, false, true)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) {
|
|
||||||
rate := float64(speed) / 8.0
|
rate := float64(speed) / 8.0
|
||||||
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
|
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
|
||||||
payload := map[string]interface{}{
|
name := strconv.FormatInt(limiterID, 10)
|
||||||
"name": strconv.FormatInt(limiterID, 10),
|
|
||||||
|
return name, map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
"limits": []string{limitStr},
|
"limits": []string{limitStr},
|
||||||
}
|
}
|
||||||
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
|
}
|
||||||
|
|
||||||
|
func buildLimiterUpdatePayload(name string, data map[string]interface{}) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"limiter": name,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int) error {
|
||||||
|
name, addPayload := buildLimiterAddPayload(limiterID, speed)
|
||||||
|
if _, err := h.sendNodeCommand(nodeID, "AddLimiters", addPayload, false, false); err != nil {
|
||||||
|
if !isAlreadyExistsMessage(err.Error()) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"limits": addPayload["limits"],
|
||||||
|
}
|
||||||
|
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateLimiters", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {
|
||||||
|
return updateErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,15 @@ func TestShouldTryLegacySingleService(t *testing.T) {
|
|||||||
t.Fatalf("DeleteService should not require legacy fallback")
|
t.Fatalf("DeleteService should not require legacy fallback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("listen tcp [::]:10001: bind: address already in use") {
|
||||||
|
t.Fatalf("address already in use must not be treated as already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1232,16 +1232,20 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
|||||||
if req.Count <= 0 {
|
if req.Count <= 0 {
|
||||||
req.Count = 4
|
req.Count = 4
|
||||||
}
|
}
|
||||||
if req.Timeout <= 0 {
|
if req.Timeout <= 0 || req.Timeout > int(diagnosisCommandTimeout/time.Millisecond) {
|
||||||
req.Timeout = 5000
|
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{}{
|
res, err := h.sendNodeCommandWithTimeout(share.NodeID, "TcpPing", map[string]interface{}{
|
||||||
"ip": req.IP,
|
"ip": req.IP,
|
||||||
"port": req.Port,
|
"port": req.Port,
|
||||||
"count": req.Count,
|
"count": req.Count,
|
||||||
"timeout": req.Timeout,
|
"timeout": req.Timeout,
|
||||||
}, false, false)
|
}, commandTimeout, false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1472,7 +1476,7 @@ func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare,
|
|||||||
|
|
||||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
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
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -72,6 +73,11 @@ type flowItem struct {
|
|||||||
D int64 `json:"d"`
|
D int64 `json:"d"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
pngDataURLPrefix = "data:image/png;base64,"
|
||||||
|
maxBrandAssetDataURLBytes = 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
@@ -128,6 +134,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
||||||
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
||||||
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
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/update-order", h.tunnelUpdateOrder)
|
||||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
||||||
@@ -143,6 +150,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
||||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
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/update-order", h.forwardUpdateOrder)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
|
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
|
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
|
||||||
@@ -153,7 +161,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||||
mux.HandleFunc("/api/v1/speed-limit/delete", h.speedLimitDelete)
|
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/tunnel", h.userTunnelVisibleList)
|
||||||
mux.HandleFunc("/api/v1/tunnel/user/list", h.userTunnelList)
|
mux.HandleFunc("/api/v1/tunnel/user/list", h.userTunnelList)
|
||||||
mux.HandleFunc("/api/v1/group/tunnel/list", h.tunnelGroupList)
|
mux.HandleFunc("/api/v1/group/tunnel/list", h.tunnelGroupList)
|
||||||
@@ -747,7 +754,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.repo.UpsertConfig(key, v, now); err != nil {
|
|
||||||
|
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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -767,16 +781,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Name) == "" {
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Value) == "" {
|
|
||||||
|
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("配置值不能为空"))
|
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
|
||||||
return
|
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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -784,6 +806,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.OKEmpty())
|
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
|
||||||
|
default:
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
@@ -989,10 +1042,27 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if cfg == nil {
|
if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") {
|
||||||
return false, nil
|
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 {
|
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -787,7 +788,9 @@ func (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := h.diagnoseTunnelRuntime(id)
|
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
result, err := h.diagnoseTunnelRuntime(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
|
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
@@ -1048,21 +1051,55 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("权限ID不能为空"))
|
response.WriteJSON(w, response.ErrDefault("权限ID不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||||
|
if err := h.validateSpeedLimitReference(speedID); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)
|
||||||
|
if utErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, utErr.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, oldFlow, oldNum, oldExpTime, oldFlowReset, oldSpeedID, oldStatus, oldErr :=
|
||||||
|
h.repo.GetExistingUserTunnel(userID, tunnelID)
|
||||||
|
if oldErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, oldErr.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.repo.UpdateUserTunnel(id,
|
if err := h.repo.UpdateUserTunnel(id,
|
||||||
asInt64(req["flow"], 0),
|
asInt64(req["flow"], 0),
|
||||||
asInt(req["num"], 0),
|
asInt(req["num"], 0),
|
||||||
asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()),
|
asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()),
|
||||||
asInt64(req["flowResetTime"], 1),
|
asInt64(req["flowResetTime"], 1),
|
||||||
nullableInt(asAnyToInt64Ptr(req["speedId"])),
|
nullableInt(speedID),
|
||||||
asInt(req["status"], 1),
|
asInt(req["status"], 1),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)
|
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
|
||||||
if utErr == nil {
|
rollbackErr := h.repo.UpdateUserTunnel(
|
||||||
h.syncUserTunnelForwards(userID, tunnelID)
|
id,
|
||||||
|
oldFlow,
|
||||||
|
int(oldNum),
|
||||||
|
oldExpTime,
|
||||||
|
oldFlowReset,
|
||||||
|
oldSpeedID,
|
||||||
|
oldStatus,
|
||||||
|
)
|
||||||
|
if rollbackErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败且回滚失败: %v; 回滚错误: %v", syncErr, rollbackErr)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败,已回滚: %v", syncErr)))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
@@ -1103,6 +1140,18 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
|
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||||
|
if speedID != nil {
|
||||||
|
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
|
||||||
|
if speedErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
port := asInt(req["inPort"], 0)
|
port := asInt(req["inPort"], 0)
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
port = h.pickTunnelPort(tunnelID)
|
port = h.pickTunnelPort(tunnelID)
|
||||||
@@ -1127,7 +1176,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if userName == "" {
|
if userName == "" {
|
||||||
userName = "user"
|
userName = "user"
|
||||||
}
|
}
|
||||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port)
|
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, nullableInt(speedID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1137,7 +1186,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.syncForwardServices(createdForward, "AddService", false); err != nil {
|
if err := h.syncForwardServices(createdForward, "UpdateService", true); err != nil {
|
||||||
_ = h.deleteForwardByID(forwardID)
|
_ = h.deleteForwardByID(forwardID)
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1202,6 +1251,24 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if strategy == "" {
|
if strategy == "" {
|
||||||
strategy = forward.Strategy
|
strategy = forward.Strategy
|
||||||
}
|
}
|
||||||
|
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||||
|
if speedID != nil {
|
||||||
|
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
|
||||||
|
if speedErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newSpeedID := forward.SpeedID
|
||||||
|
if speedID != nil {
|
||||||
|
newSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}
|
||||||
|
} else if _, ok := req["speedId"]; ok {
|
||||||
|
newSpeedID = sql.NullInt64{Valid: false}
|
||||||
|
}
|
||||||
|
|
||||||
port := asInt(req["inPort"], 0)
|
port := asInt(req["inPort"], 0)
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
@@ -1225,7 +1292,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now); err != nil {
|
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1335,7 +1402,9 @@ func (h *Handler) forwardDiagnose(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload, err := h.diagnoseForwardRuntime(forward)
|
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
payload, err := h.diagnoseForwardRuntime(ctx, forward)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
|
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
@@ -1586,29 +1655,22 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnelID := asInt64(req["tunnelId"], 0)
|
|
||||||
if tunnelID <= 0 {
|
|
||||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := asString(req["name"])
|
name := asString(req["name"])
|
||||||
if name == "" {
|
if name == "" {
|
||||||
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
|
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
|
|
||||||
if tunnelName == "" {
|
|
||||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UnixMilli()
|
|
||||||
speed := asInt(req["speed"], 100)
|
speed := asInt(req["speed"], 100)
|
||||||
id, err := h.repo.CreateSpeedLimit(name, speed, tunnelID, tunnelName, now, asInt(req["status"], 1))
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
_, err := h.repo.CreateSpeedLimit(name, speed, now, asInt(req["status"], 1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1618,23 +1680,26 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := asInt64(req["id"], 0)
|
id := asInt64(req["id"], 0)
|
||||||
tunnelID := asInt64(req["tunnelId"], 0)
|
if id <= 0 {
|
||||||
if id <= 0 || tunnelID <= 0 {
|
response.WriteJSON(w, response.ErrDefault("限速规则ID不能为空"))
|
||||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
|
|
||||||
if tunnelName == "" {
|
name := asString(req["name"])
|
||||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
if name == "" {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
speed := asInt(req["speed"], 100)
|
speed := asInt(req["speed"], 100)
|
||||||
if err := h.repo.UpdateSpeedLimit(id, asString(req["name"]), speed, tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli()); err != nil {
|
|
||||||
|
if err := h.repo.UpdateSpeedLimit(id, name, speed, asInt(req["status"], 1), time.Now().UnixMilli()); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1643,15 +1708,12 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnelID := h.repo.GetSpeedLimitTunnelID(id)
|
|
||||||
|
|
||||||
if err := h.repo.DeleteSpeedLimit(id); err != nil {
|
if err := h.repo.DeleteSpeedLimit(id); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tunnelID > 0 {
|
|
||||||
_ = h.sendDeleteLimiterConfig(id, tunnelID)
|
|
||||||
}
|
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2713,7 +2775,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
|
|||||||
func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {
|
func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {
|
||||||
allocated := map[int64]int{}
|
allocated := map[int64]int{}
|
||||||
inNodes := asMapSlice(req["inNodeId"])
|
inNodes := asMapSlice(req["inNodeId"])
|
||||||
for _, n := range inNodes {
|
for i, n := range inNodes {
|
||||||
nodeID := asInt64(n["nodeId"], 0)
|
nodeID := asInt64(n["nodeId"], 0)
|
||||||
if nodeID <= 0 {
|
if nodeID <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -2725,13 +2787,13 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
|||||||
nodeID,
|
nodeID,
|
||||||
sql.NullInt64{},
|
sql.NullInt64{},
|
||||||
defaultString(asString(n["strategy"]), "round"),
|
defaultString(asString(n["strategy"]), "round"),
|
||||||
0,
|
i+1,
|
||||||
defaultString(asString(n["protocol"]), "tls"),
|
defaultString(asString(n["protocol"]), "tls"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, n := range asMapSlice(req["outNodeId"]) {
|
for i, n := range asMapSlice(req["outNodeId"]) {
|
||||||
nodeID := asInt64(n["nodeId"], 0)
|
nodeID := asInt64(n["nodeId"], 0)
|
||||||
if nodeID <= 0 {
|
if nodeID <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -2751,7 +2813,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
|||||||
nodeID,
|
nodeID,
|
||||||
sql.NullInt64{Int64: int64(port), Valid: true},
|
sql.NullInt64{Int64: int64(port), Valid: true},
|
||||||
defaultString(asString(n["strategy"]), "round"),
|
defaultString(asString(n["strategy"]), "round"),
|
||||||
0,
|
i+1,
|
||||||
defaultString(asString(n["protocol"]), "tls"),
|
defaultString(asString(n["protocol"]), "tls"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -2977,6 +3039,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []
|
|||||||
h.repo.RollbackForwardFields(
|
h.repo.RollbackForwardFields(
|
||||||
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
|
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
|
||||||
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
|
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
|
||||||
|
oldForward.SpeedID,
|
||||||
time.Now().UnixMilli(),
|
time.Now().UnixMilli(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2998,6 +3061,10 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
|||||||
h.repo.GetExistingUserTunnel(userID, tunnelID)
|
h.repo.GetExistingUserTunnel(userID, tunnelID)
|
||||||
|
|
||||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||||
|
if err := h.validateSpeedLimitReference(speedID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
reqFlow := asInt64(req["flow"], -1)
|
reqFlow := asInt64(req["flow"], -1)
|
||||||
reqNum := asInt(req["num"], -1)
|
reqNum := asInt(req["num"], -1)
|
||||||
reqExpTime := asInt64(req["expTime"], -1)
|
reqExpTime := asInt64(req["expTime"], -1)
|
||||||
@@ -3038,7 +3105,24 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
|||||||
reqStatus = 1
|
reqStatus = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus)
|
if err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
|
||||||
|
insertedID, _, _, _, _, _, _, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID)
|
||||||
|
if lookupErr != nil {
|
||||||
|
return fmt.Errorf("下发失败且回滚失败: %v; 回滚查询错误: %w", syncErr, lookupErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rollbackErr := h.repo.DeleteUserTunnel(insertedID); rollbackErr != nil {
|
||||||
|
return fmt.Errorf("下发失败且回滚失败: %v; 回滚删除错误: %w", syncErr, rollbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("下发失败,已回滚: %w", syncErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -3076,25 +3160,61 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
|||||||
newSpeedID = sql.NullInt64{Valid: false}
|
newSpeedID = sql.NullInt64{Valid: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus)
|
if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {
|
||||||
|
return err
|
||||||
if err == nil {
|
|
||||||
h.syncUserTunnelForwards(userID, tunnelID)
|
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
|
||||||
|
rollbackErr := h.repo.UpdateUserTunnelFields(
|
||||||
|
existingID,
|
||||||
|
currentSpeedID,
|
||||||
|
currentFlow,
|
||||||
|
int(currentNum),
|
||||||
|
currentExpTime,
|
||||||
|
currentFlowReset,
|
||||||
|
currentStatus,
|
||||||
|
)
|
||||||
|
if rollbackErr != nil {
|
||||||
|
return fmt.Errorf("下发失败且回滚失败: %v; 回滚错误: %w", syncErr, rollbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("下发失败,已回滚: %w", syncErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) {
|
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
|
||||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
for i := range forwards {
|
for i := range forwards {
|
||||||
f := &forwards[i]
|
f := &forwards[i]
|
||||||
if f.UserID == userID {
|
if f.UserID == userID {
|
||||||
_ = h.syncForwardServices(f, "UpdateService", true)
|
if err := h.syncForwardServices(f, "UpdateService", true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) validateSpeedLimitReference(speedID *int64) error {
|
||||||
|
if speedID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := h.repo.SpeedLimitExists(*speedID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return errors.New("限速规则不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func asAnySlice(v interface{}) []interface{} {
|
func asAnySlice(v interface{}) []interface{} {
|
||||||
|
|||||||
@@ -29,19 +29,20 @@ func (User) TableName() string { return "user" }
|
|||||||
|
|
||||||
// Forward maps to the "forward" table.
|
// Forward maps to the "forward" table.
|
||||||
type Forward struct {
|
type Forward struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
UserID int64 `gorm:"column:user_id;not null"`
|
UserID int64 `gorm:"column:user_id;not null"`
|
||||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||||
Name string `gorm:"type:varchar(100);not null"`
|
Name string `gorm:"type:varchar(100);not null"`
|
||||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
InFlow int64 `gorm:"not null;default:0"`
|
||||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||||
Status int `gorm:"not null"`
|
Status int `gorm:"not null"`
|
||||||
Inx int `gorm:"not null;default:0"`
|
Inx int `gorm:"not null;default:0"`
|
||||||
|
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Forward) TableName() string { return "forward" }
|
func (Forward) TableName() string { return "forward" }
|
||||||
@@ -83,14 +84,14 @@ type Node struct {
|
|||||||
func (Node) TableName() string { return "node" }
|
func (Node) TableName() string { return "node" }
|
||||||
|
|
||||||
type SpeedLimit struct {
|
type SpeedLimit struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `gorm:"type:varchar(100);not null"`
|
Name string `gorm:"type:varchar(100);not null"`
|
||||||
Speed int `gorm:"not null"`
|
Speed int `gorm:"not null"`
|
||||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||||
TunnelName string `gorm:"column:tunnel_name;type:varchar(100);not null"`
|
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||||
Status int `gorm:"not null"`
|
Status int `gorm:"not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||||
@@ -213,7 +214,7 @@ func (GroupPermissionGrant) TableName() string { return "group_permission_grant"
|
|||||||
type ViteConfig struct {
|
type ViteConfig struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
|
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
|
||||||
Value string `gorm:"type:varchar(200);not null" json:"value"`
|
Value string `gorm:"type:text;not null" json:"value"`
|
||||||
Time int64 `gorm:"not null" json:"time"`
|
Time int64 `gorm:"not null" json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +396,7 @@ type ForwardBackup struct {
|
|||||||
UpdatedTime int64 `json:"updatedTime"`
|
UpdatedTime int64 `json:"updatedTime"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
Inx int `json:"inx"`
|
Inx int `json:"inx"`
|
||||||
|
SpeedID *int64 `json:"speedId,omitempty"`
|
||||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,8 +423,8 @@ type SpeedLimitBackup struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Speed int64 `json:"speed"`
|
Speed int64 `json:"speed"`
|
||||||
TunnelID int64 `json:"tunnelId"`
|
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||||
TunnelName string `json:"tunnelName"`
|
TunnelName string `json:"tunnelName,omitempty"`
|
||||||
CreatedTime int64 `json:"createdTime"`
|
CreatedTime int64 `json:"createdTime"`
|
||||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
@@ -492,6 +494,7 @@ type ForwardRecord struct {
|
|||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
Strategy string
|
Strategy string
|
||||||
Status int
|
Status int
|
||||||
|
SpeedID sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
|||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if err := r.db.Where("role_id != ?", 0).Order("id ASC").Find(&users).Error; err != nil {
|
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]map[string]interface{}, 0, len(users))
|
items := make([]map[string]interface{}, 0, len(users))
|
||||||
@@ -678,17 +678,17 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
|
|||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var limits []model.SpeedLimit
|
var limits []model.SpeedLimit
|
||||||
if err := r.db.Order("id ASC").Find(&limits).Error; err != nil {
|
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]map[string]interface{}, 0, len(limits))
|
items := make([]map[string]interface{}, 0, len(limits))
|
||||||
for _, sl := range limits {
|
for _, sl := range limits {
|
||||||
items = append(items, map[string]interface{}{
|
item := map[string]interface{}{
|
||||||
"id": sl.ID, "name": sl.Name, "speed": sl.Speed,
|
"id": sl.ID, "name": sl.Name, "speed": sl.Speed,
|
||||||
"tunnelId": sl.TunnelID, "tunnelName": sl.TunnelName,
|
|
||||||
"status": sl.Status, "createdTime": sl.CreatedTime,
|
"status": sl.Status, "createdTime": sl.CreatedTime,
|
||||||
"updatedTime": nullableInt64(sl.UpdatedTime),
|
"updatedTime": nullableInt64(sl.UpdatedTime),
|
||||||
})
|
}
|
||||||
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -712,11 +712,12 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
|||||||
CreatedTime int64
|
CreatedTime int64
|
||||||
Status int
|
Status int
|
||||||
Inx int
|
Inx int
|
||||||
|
SpeedID sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []fwdRow
|
var rows []fwdRow
|
||||||
err := r.db.Model(&model.Forward{}).
|
err := r.db.Model(&model.Forward{}).
|
||||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx").
|
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
||||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||||
Order("forward.inx ASC, forward.id ASC").
|
Order("forward.inx ASC, forward.id ASC").
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
@@ -730,14 +731,18 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, map[string]interface{}{
|
item := map[string]interface{}{
|
||||||
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
|
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
|
||||||
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
|
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
|
||||||
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
||||||
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
||||||
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
||||||
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
||||||
})
|
}
|
||||||
|
if row.SpeedID.Valid {
|
||||||
|
item["speedId"] = row.SpeedID.Int64
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -1308,7 +1313,6 @@ func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(node
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
|
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
@@ -1813,7 +1817,6 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
|
|||||||
for _, sl := range sls {
|
for _, sl := range sls {
|
||||||
b := model.SpeedLimitBackup{
|
b := model.SpeedLimitBackup{
|
||||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||||
TunnelID: sl.TunnelID, TunnelName: sl.TunnelName,
|
|
||||||
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
||||||
}
|
}
|
||||||
if sl.UpdatedTime.Valid {
|
if sl.UpdatedTime.Valid {
|
||||||
@@ -2186,8 +2189,8 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
|
|||||||
ID: sl.ID,
|
ID: sl.ID,
|
||||||
Name: sl.Name,
|
Name: sl.Name,
|
||||||
Speed: int(sl.Speed),
|
Speed: int(sl.Speed),
|
||||||
TunnelID: sl.TunnelID,
|
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
|
||||||
TunnelName: sl.TunnelName,
|
TunnelName: sql.NullString{String: "", Valid: false},
|
||||||
CreatedTime: sl.CreatedTime,
|
CreatedTime: sl.CreatedTime,
|
||||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
Status: sl.Status,
|
Status: sl.Status,
|
||||||
@@ -2460,9 +2463,11 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
|||||||
|
|
||||||
// ─── Migration ───────────────────────────────────────────────────────
|
// ─── Migration ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const currentSchemaVersion = 2
|
const currentSchemaVersion = 4
|
||||||
|
|
||||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||||
|
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||||
|
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
|
||||||
|
|
||||||
func getSchemaVersion(db *gorm.DB) int {
|
func getSchemaVersion(db *gorm.DB) int {
|
||||||
var v model.SchemaVersion
|
var v model.SchemaVersion
|
||||||
@@ -2514,10 +2519,82 @@ func migrateSchema(db *gorm.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ver < 3 {
|
||||||
|
if err := migrateViteConfigValueColumnTypeFn(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ver < 4 {
|
||||||
|
if err := migrateSpeedLimitTunnelBindingFn(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSchemaVersion(db, currentSchemaVersion)
|
setSchemaVersion(db, currentSchemaVersion)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateViteConfigValueColumnType(db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("nil db")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !db.Migrator().HasTable(&model.ViteConfig{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.Dialector.Name() != "postgres" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type columnRow struct {
|
||||||
|
DataType string `gorm:"column:data_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var row columnRow
|
||||||
|
if err := db.Raw(
|
||||||
|
`SELECT data_type FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = ?
|
||||||
|
AND column_name = ?`,
|
||||||
|
"vite_config", "value",
|
||||||
|
).Scan(&row).Error; err != nil {
|
||||||
|
return fmt.Errorf("inspect vite_config.value type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(row.DataType, "text") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec(`ALTER TABLE "vite_config" ALTER COLUMN "value" TYPE TEXT`).Error; err != nil {
|
||||||
|
return fmt.Errorf("alter vite_config.value to text: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateSpeedLimitTunnelBinding(db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("nil db")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !db.Migrator().HasTable(&model.SpeedLimit{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Model(&model.SpeedLimit{}).
|
||||||
|
Where("tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL").
|
||||||
|
UpdateColumns(map[string]interface{}{
|
||||||
|
"tunnel_id": nil,
|
||||||
|
"tunnel_name": nil,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("clear speed_limit tunnel binding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ensurePostgresIDDefaults(db *gorm.DB) error {
|
func ensurePostgresIDDefaults(db *gorm.DB) error {
|
||||||
if db.Dialector.Name() != "postgres" {
|
if db.Dialector.Name() != "postgres" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
|
|||||||
RemoteAddr: f.RemoteAddr,
|
RemoteAddr: f.RemoteAddr,
|
||||||
Strategy: f.Strategy,
|
Strategy: f.Strategy,
|
||||||
Status: f.Status,
|
Status: f.Status,
|
||||||
|
SpeedID: f.SpeedID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
|
|||||||
@@ -228,7 +228,6 @@ func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextIndex returns COALESCE(MAX(inx), -1) + 1 for the given table.
|
|
||||||
func (r *Repository) NextIndex(table string) int {
|
func (r *Repository) NextIndex(table string) int {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -251,7 +250,7 @@ func (r *Repository) NextIndex(table string) int {
|
|||||||
var row inxRow
|
var row inxRow
|
||||||
err := r.db.Model(modelRef).
|
err := r.db.Model(modelRef).
|
||||||
Select("inx").
|
Select("inx").
|
||||||
Order("inx DESC").
|
Order("inx ASC, id ASC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Take(&row).Error
|
Take(&row).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -260,10 +259,7 @@ func (r *Repository) NextIndex(table string) int {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if row.Inx < 0 {
|
return row.Inx - 1
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return row.Inx + 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateRemoteNode inserts a new remote node.
|
// CreateRemoteNode inserts a new remote node.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardReco
|
|||||||
RemoteAddr: f.RemoteAddr,
|
RemoteAddr: f.RemoteAddr,
|
||||||
Strategy: f.Strategy,
|
Strategy: f.Strategy,
|
||||||
Status: f.Status,
|
Status: f.Status,
|
||||||
|
SpeedID: f.SpeedID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
@@ -68,6 +69,7 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
|
|||||||
RemoteAddr: f.RemoteAddr,
|
RemoteAddr: f.RemoteAddr,
|
||||||
Strategy: f.Strategy,
|
Strategy: f.Strategy,
|
||||||
Status: f.Status,
|
Status: f.Status,
|
||||||
|
SpeedID: f.SpeedID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
@@ -99,6 +101,7 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
|
|||||||
RemoteAddr: f.RemoteAddr,
|
RemoteAddr: f.RemoteAddr,
|
||||||
Strategy: f.Strategy,
|
Strategy: f.Strategy,
|
||||||
Status: f.Status,
|
Status: f.Status,
|
||||||
|
SpeedID: f.SpeedID,
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(fr.Strategy) == "" {
|
if strings.TrimSpace(fr.Strategy) == "" {
|
||||||
fr.Strategy = "fifo"
|
fr.Strategy = "fifo"
|
||||||
@@ -169,3 +172,15 @@ func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
|||||||
}
|
}
|
||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetSpeedLimitSpeed(id int64) (int, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return 0, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
var sl model.SpeedLimit
|
||||||
|
err := r.db.Select("speed").Where("id = ?", id).First(&sl).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return sl.Speed, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -83,3 +84,169 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
|
|||||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {
|
||||||
|
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
if sqlDB != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||||
|
t.Fatalf("create schema_version: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
|
||||||
|
t.Fatalf("seed schema_version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||||
|
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||||
|
})
|
||||||
|
|
||||||
|
called := 0
|
||||||
|
originalMigrate := migrateViteConfigValueColumnTypeFn
|
||||||
|
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
|
||||||
|
called++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
migrateViteConfigValueColumnTypeFn = originalMigrate
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := migrateSchema(db); err != nil {
|
||||||
|
t.Fatalf("migrateSchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if called != 1 {
|
||||||
|
t.Fatalf("expected vite_config migration to run once, got %d", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {
|
||||||
|
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
if sqlDB != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||||
|
t.Fatalf("create schema_version: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
|
||||||
|
t.Fatalf("seed schema_version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||||
|
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||||
|
})
|
||||||
|
|
||||||
|
wantErr := errors.New("vite config migration failed")
|
||||||
|
originalMigrate := migrateViteConfigValueColumnTypeFn
|
||||||
|
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
|
||||||
|
return wantErr
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
migrateViteConfigValueColumnTypeFn = originalMigrate
|
||||||
|
})
|
||||||
|
|
||||||
|
err = migrateSchema(db)
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
|
||||||
|
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
if sqlDB != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||||
|
t.Fatalf("create schema_version: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 3).Error; err != nil {
|
||||||
|
t.Fatalf("seed schema_version: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE speed_limit (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
speed INTEGER NOT NULL,
|
||||||
|
tunnel_id INTEGER,
|
||||||
|
tunnel_name VARCHAR(100),
|
||||||
|
created_time INTEGER NOT NULL,
|
||||||
|
updated_time INTEGER,
|
||||||
|
status INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create speed_limit: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "legacy-speed-limit", 100, 101, "legacy-tunnel", 1, 1, 1).Error; err != nil {
|
||||||
|
t.Fatalf("seed speed_limit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||||
|
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := migrateSchema(db); err != nil {
|
||||||
|
t.Fatalf("migrateSchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tunnelID sql.NullInt64
|
||||||
|
var tunnelName sql.NullString
|
||||||
|
if err := db.Raw(`SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?`, "legacy-speed-limit").Row().Scan(&tunnelID, &tunnelName); err != nil {
|
||||||
|
t.Fatalf("query speed_limit: %v", err)
|
||||||
|
}
|
||||||
|
if tunnelID.Valid {
|
||||||
|
t.Fatalf("expected tunnel_id cleared to NULL, got %d", tunnelID.Int64)
|
||||||
|
}
|
||||||
|
if tunnelName.Valid {
|
||||||
|
t.Fatalf("expected tunnel_name cleared to NULL, got %q", tunnelName.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaVersion int
|
||||||
|
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
|
||||||
|
t.Fatalf("query schema_version: %v", err)
|
||||||
|
}
|
||||||
|
if schemaVersion != currentSchemaVersion {
|
||||||
|
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -522,9 +522,6 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
|
|||||||
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.SpeedLimit{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.ChainTunnel{}).Error; err != nil {
|
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.ChainTunnel{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -535,17 +532,6 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) GetTunnelNameByID(tunnelID int64) string {
|
|
||||||
if r == nil || r.db == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var tunnel model.Tunnel
|
|
||||||
if err := r.db.Select("name").Where("id = ?", tunnelID).First(&tunnel).Error; err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return tunnel.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) TunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
|
func (r *Repository) TunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
@@ -657,7 +643,7 @@ func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64) error {
|
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
@@ -668,6 +654,7 @@ func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remote
|
|||||||
"tunnel_id": tunnelID,
|
"tunnel_id": tunnelID,
|
||||||
"remote_addr": remoteAddr,
|
"remote_addr": remoteAddr,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
|
"speed_id": nullInt64FromInterface(speedID),
|
||||||
"updated_time": now,
|
"updated_time": now,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
@@ -724,7 +711,7 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, now int64) {
|
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -738,6 +725,7 @@ func (r *Repository) RollbackForwardFields(id, userID int64, userName, name stri
|
|||||||
"remote_addr": remoteAddr,
|
"remote_addr": remoteAddr,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"speed_id": nullInt64FromInterface(speedID),
|
||||||
"updated_time": now,
|
"updated_time": now,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
@@ -764,15 +752,15 @@ func (r *Repository) GetUsedPortsOnNodeAsMap(nodeID int64) (map[int]bool, error)
|
|||||||
return used, nil
|
return used, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID int64, tunnelName string, now int64, status int) (int64, error) {
|
func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status int) (int64, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return 0, errors.New("repository not initialized")
|
return 0, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
sl := model.SpeedLimit{
|
sl := model.SpeedLimit{
|
||||||
Name: name,
|
Name: name,
|
||||||
Speed: speed,
|
Speed: speed,
|
||||||
TunnelID: tunnelID,
|
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
|
||||||
TunnelName: tunnelName,
|
TunnelName: sql.NullString{String: "", Valid: false},
|
||||||
CreatedTime: now,
|
CreatedTime: now,
|
||||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -783,34 +771,24 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID int64, tu
|
|||||||
return sl.ID, nil
|
return sl.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) UpdateSpeedLimit(id int64, name string, speed int, tunnelID int64, tunnelName string, status int, now int64) error {
|
func (r *Repository) UpdateSpeedLimit(id int64, name string, speed int, status int, now int64) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"speed": speed,
|
||||||
|
"status": status,
|
||||||
|
"tunnel_id": nil,
|
||||||
|
"tunnel_name": nil,
|
||||||
|
"updated_time": sql.NullInt64{
|
||||||
|
Int64: now,
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
return r.db.Model(&model.SpeedLimit{}).
|
return r.db.Model(&model.SpeedLimit{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]interface{}{
|
Updates(updates).Error
|
||||||
"name": name,
|
|
||||||
"speed": speed,
|
|
||||||
"tunnel_id": tunnelID,
|
|
||||||
"tunnel_name": tunnelName,
|
|
||||||
"status": status,
|
|
||||||
"updated_time": sql.NullInt64{
|
|
||||||
Int64: now,
|
|
||||||
Valid: true,
|
|
||||||
},
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) GetSpeedLimitTunnelID(speedLimitID int64) int64 {
|
|
||||||
if r == nil || r.db == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var sl model.SpeedLimit
|
|
||||||
if err := r.db.Select("tunnel_id").Where("id = ?", speedLimitID).First(&sl).Error; err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return sl.TunnelID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) DeleteSpeedLimit(id int64) error {
|
func (r *Repository) DeleteSpeedLimit(id int64) error {
|
||||||
@@ -1190,7 +1168,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
|
|||||||
return ut.ID, true, nil
|
return ut.ID, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int) (int64, error) {
|
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, speedID interface{}) (int64, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return 0, errors.New("repository not initialized")
|
return 0, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
@@ -1209,6 +1187,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
|
|||||||
UpdatedTime: now,
|
UpdatedTime: now,
|
||||||
Status: 1,
|
Status: 1,
|
||||||
Inx: inx,
|
Inx: inx,
|
||||||
|
SpeedID: nullInt64FromInterface(speedID),
|
||||||
}
|
}
|
||||||
if err := tx.Create(&fwd).Error; err != nil {
|
if err := tx.Create(&fwd).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package contract
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go-backend/internal/store/repo"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
|
|
||||||
t.Helper()
|
|
||||||
var id int64
|
|
||||||
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
|
|
||||||
t.Fatalf("read last_insert_rowid for %s: %v", label, err)
|
|
||||||
}
|
|
||||||
if id <= 0 {
|
|
||||||
t.Fatalf("invalid last_insert_rowid for %s: %d", label, id)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@ package contract_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go-backend/internal/store/repo"
|
"go-backend/internal/store/repo"
|
||||||
@@ -117,3 +119,43 @@ func tryQueryInt(t *testing.T, r *repo.Repository, query string, args ...interfa
|
|||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func valueAsInt(v interface{}) int {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueAsString(v interface{}) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueAsBool(v interface{}) bool {
|
||||||
|
switch b := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return b
|
||||||
|
case float64:
|
||||||
|
return b != 0
|
||||||
|
case int:
|
||||||
|
return b != 0
|
||||||
|
case int64:
|
||||||
|
return b != 0
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(strings.ToLower(b))
|
||||||
|
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonInt64(v int64) string {
|
||||||
|
return strconv.FormatInt(v, 10)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package contract
|
package contract_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -13,15 +12,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/auth"
|
"go-backend/internal/auth"
|
||||||
httpserver "go-backend/internal/http"
|
|
||||||
"go-backend/internal/http/handler"
|
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
"go-backend/internal/store/repo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, r := setupDiagnosisContractRouter(t, secret)
|
router, r := setupContractRouter(t, secret)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
@@ -195,7 +191,7 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
|||||||
|
|
||||||
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, r := setupDiagnosisContractRouter(t, secret)
|
router, r := setupContractRouter(t, secret)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
@@ -315,7 +311,7 @@ func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
|||||||
|
|
||||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, r := setupDiagnosisContractRouter(t, secret)
|
router, r := setupContractRouter(t, secret)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
remoteToken := "remote-diagnose-token"
|
remoteToken := "remote-diagnose-token"
|
||||||
@@ -465,54 +461,3 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
|||||||
t.Fatalf("expected federation runtime diagnose endpoint to be called")
|
t.Fatalf("expected federation runtime diagnose endpoint to be called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func valueAsInt(v interface{}) int {
|
|
||||||
switch n := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int(n)
|
|
||||||
case int:
|
|
||||||
return n
|
|
||||||
case int64:
|
|
||||||
return int(n)
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueAsString(v interface{}) string {
|
|
||||||
s, _ := v.(string)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueAsBool(v interface{}) bool {
|
|
||||||
switch b := v.(type) {
|
|
||||||
case bool:
|
|
||||||
return b
|
|
||||||
case float64:
|
|
||||||
return b != 0
|
|
||||||
case int:
|
|
||||||
return b != 0
|
|
||||||
case int64:
|
|
||||||
return b != 0
|
|
||||||
case string:
|
|
||||||
s := strings.TrimSpace(strings.ToLower(b))
|
|
||||||
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupDiagnosisContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {
|
|
||||||
t.Helper()
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "diagnosis-contract.db")
|
|
||||||
r, err := repo.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = r.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
h := handler.New(r, jwtSecret)
|
|
||||||
return httpserver.NewRouter(h, jwtSecret), r
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -624,42 +624,6 @@ func waitNodeStatus(t *testing.T, r *repo.Repository, nodeID int64, expectedStat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func valueAsInt(v interface{}) int {
|
|
||||||
switch n := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int(n)
|
|
||||||
case int:
|
|
||||||
return n
|
|
||||||
case int64:
|
|
||||||
return int(n)
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueAsString(v interface{}) string {
|
|
||||||
s, _ := v.(string)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueAsBool(v interface{}) bool {
|
|
||||||
switch b := v.(type) {
|
|
||||||
case bool:
|
|
||||||
return b
|
|
||||||
case float64:
|
|
||||||
return b != 0
|
|
||||||
case int:
|
|
||||||
return b != 0
|
|
||||||
case int64:
|
|
||||||
return b != 0
|
|
||||||
case string:
|
|
||||||
s := strings.TrimSpace(strings.ToLower(b))
|
|
||||||
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
|
func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
|
||||||
providerSecret := "provider-portrange-jwt"
|
providerSecret := "provider-portrange-jwt"
|
||||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||||
@@ -759,6 +723,21 @@ func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test: Non-service commands should pass through without port validation
|
// Test: Non-service commands should pass through without port validation
|
||||||
|
res = sendCommand("share-portrange-token", "UpdateLimiters", map[string]interface{}{
|
||||||
|
"limiter": "federation-limit-test",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"name": "federation-limit-test",
|
||||||
|
"limits": []string{"$ 1MB 1MB"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out = response.R{}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected code 0 for UpdateLimiters command, got %d (msg: %s)", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
res = sendCommand("share-portrange-token", "reload", nil)
|
res = sendCommand("share-portrange-token", "reload", nil)
|
||||||
out = response.R{}
|
out = response.R{}
|
||||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package contract_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -108,7 +109,11 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected object item, got %T", arr[0])
|
t.Fatalf("expected object item, got %T", arr[0])
|
||||||
}
|
}
|
||||||
if got := int64(item["id"].(float64)); got != userForwardID {
|
idFloat, ok := item["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected id to be float64, got %T", item["id"])
|
||||||
|
}
|
||||||
|
if got := int64(idFloat); got != userForwardID {
|
||||||
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
|
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -143,7 +148,11 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
|||||||
if _, ok := first["message"]; !ok {
|
if _, ok := first["message"]; !ok {
|
||||||
t.Fatalf("expected message field in diagnosis result")
|
t.Fatalf("expected message field in diagnosis result")
|
||||||
}
|
}
|
||||||
if got := int(first["fromChainType"].(float64)); got != 1 {
|
fromChainTypeFloat, ok := first["fromChainType"].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected fromChainType to be float64, got %T", first["fromChainType"])
|
||||||
|
}
|
||||||
|
if got := int(fromChainTypeFloat); got != 1 {
|
||||||
t.Fatalf("expected fromChainType=1, got %d", got)
|
t.Fatalf("expected fromChainType=1, got %d", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -471,6 +480,234 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(2, 'speed_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "forward-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, repo, "forward-speed-tunnel")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "forward-speed-node", "forward-speed-secret", "10.30.0.1", "10.30.0.1", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, repo, "forward-speed-node")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 31001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||||
|
`, "forward-speed-limit-a", 2048, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit a: %v", err)
|
||||||
|
}
|
||||||
|
speedIDA := mustLastInsertID(t, repo, "forward-speed-limit-a")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||||
|
`, "forward-speed-limit-b", 4096, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit b: %v", err)
|
||||||
|
}
|
||||||
|
speedIDB := mustLastInsertID(t, repo, "forward-speed-limit-b")
|
||||||
|
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
stopNode := startMockNodeSession(t, server.URL, "forward-speed-secret")
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
createPayload := map[string]interface{}{
|
||||||
|
"name": "forward-speed-target",
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
"speedId": speedIDA,
|
||||||
|
}
|
||||||
|
createBody, err := json.Marshal(createPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal create payload: %v", err)
|
||||||
|
}
|
||||||
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||||
|
createReq.Header.Set("Authorization", adminToken)
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRes, createReq)
|
||||||
|
assertCode(t, createRes, 0)
|
||||||
|
|
||||||
|
forwardID := mustLastInsertID(t, repo, "forward-speed-target")
|
||||||
|
storedSpeed := repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
|
||||||
|
var createdSpeed sql.NullInt64
|
||||||
|
if err := storedSpeed.Scan(&createdSpeed); err != nil {
|
||||||
|
t.Fatalf("query created forward speed_id: %v", err)
|
||||||
|
}
|
||||||
|
if !createdSpeed.Valid || createdSpeed.Int64 != speedIDA {
|
||||||
|
t.Fatalf("expected created speed_id=%d, got valid=%v value=%d", speedIDA, createdSpeed.Valid, createdSpeed.Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateToBPayload := map[string]interface{}{
|
||||||
|
"id": forwardID,
|
||||||
|
"speedId": speedIDB,
|
||||||
|
}
|
||||||
|
updateToBBody, err := json.Marshal(updateToBPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal update-to-b payload: %v", err)
|
||||||
|
}
|
||||||
|
updateToBReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateToBBody))
|
||||||
|
updateToBReq.Header.Set("Authorization", adminToken)
|
||||||
|
updateToBReq.Header.Set("Content-Type", "application/json")
|
||||||
|
updateToBRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(updateToBRes, updateToBReq)
|
||||||
|
assertCode(t, updateToBRes, 0)
|
||||||
|
|
||||||
|
storedSpeed = repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
|
||||||
|
var updatedSpeed sql.NullInt64
|
||||||
|
if err := storedSpeed.Scan(&updatedSpeed); err != nil {
|
||||||
|
t.Fatalf("query updated forward speed_id: %v", err)
|
||||||
|
}
|
||||||
|
if !updatedSpeed.Valid || updatedSpeed.Int64 != speedIDB {
|
||||||
|
t.Fatalf("expected updated speed_id=%d, got valid=%v value=%d", speedIDB, updatedSpeed.Valid, updatedSpeed.Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPayload := map[string]interface{}{
|
||||||
|
"id": forwardID,
|
||||||
|
"speedId": nil,
|
||||||
|
}
|
||||||
|
clearBody, err := json.Marshal(clearPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal clear payload: %v", err)
|
||||||
|
}
|
||||||
|
clearReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(clearBody))
|
||||||
|
clearReq.Header.Set("Authorization", adminToken)
|
||||||
|
clearReq.Header.Set("Content-Type", "application/json")
|
||||||
|
clearRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(clearRes, clearReq)
|
||||||
|
assertCode(t, clearRes, 0)
|
||||||
|
|
||||||
|
storedSpeed = repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
|
||||||
|
var clearedSpeed sql.NullInt64
|
||||||
|
if err := storedSpeed.Scan(&clearedSpeed); err != nil {
|
||||||
|
t.Fatalf("query cleared forward speed_id: %v", err)
|
||||||
|
}
|
||||||
|
if clearedSpeed.Valid {
|
||||||
|
t.Fatalf("expected cleared speed_id to be NULL, got %d", clearedSpeed.Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "forward-toggle-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, repo, "forward-toggle-tunnel")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "forward-toggle-node", "forward-toggle-secret", "10.31.0.1", "10.31.0.1", "", "41000-41010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, repo, "forward-toggle-node")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 41001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
stopNode := startMockNodeSession(t, server.URL, "forward-toggle-secret")
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
createPayload := map[string]interface{}{
|
||||||
|
"name": "forward-toggle-target",
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
}
|
||||||
|
createBody, err := json.Marshal(createPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal create payload: %v", err)
|
||||||
|
}
|
||||||
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||||
|
createReq.Header.Set("Authorization", adminToken)
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRes, createReq)
|
||||||
|
assertCode(t, createRes, 0)
|
||||||
|
|
||||||
|
forwardID := mustLastInsertID(t, repo, "forward-toggle-target")
|
||||||
|
|
||||||
|
pauseBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal pause payload: %v", err)
|
||||||
|
}
|
||||||
|
pauseReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/pause", bytes.NewReader(pauseBody))
|
||||||
|
pauseReq.Header.Set("Authorization", adminToken)
|
||||||
|
pauseReq.Header.Set("Content-Type", "application/json")
|
||||||
|
pauseRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(pauseRes, pauseReq)
|
||||||
|
assertCode(t, pauseRes, 0)
|
||||||
|
|
||||||
|
pausedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
|
||||||
|
if pausedStatus != 0 {
|
||||||
|
t.Fatalf("expected status=0 after pause, got %d", pausedStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal resume payload: %v", err)
|
||||||
|
}
|
||||||
|
resumeReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewReader(resumeBody))
|
||||||
|
resumeReq.Header.Set("Authorization", adminToken)
|
||||||
|
resumeReq.Header.Set("Content-Type", "application/json")
|
||||||
|
resumeRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resumeRes, resumeReq)
|
||||||
|
assertCode(t, resumeRes, 0)
|
||||||
|
|
||||||
|
resumedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
|
||||||
|
if resumedStatus != 1 {
|
||||||
|
t.Fatalf("expected status=1 after resume, got %d", resumedStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func jsonNumber(v int64) string {
|
func jsonNumber(v int64) string {
|
||||||
return strconv.FormatInt(v, 10)
|
return strconv.FormatInt(v, 10)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardCreateRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "limiter-fail-tunnel")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "limiter-fail-node", "limiter-fail-secret", "10.20.0.1", "10.20.0.1", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, r, "limiter-fail-node")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 32001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||||
|
`, "limiter-fail-rule", 1024, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit: %v", err)
|
||||||
|
}
|
||||||
|
speedID := mustLastInsertID(t, r, "limiter-fail-rule")
|
||||||
|
|
||||||
|
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "limiter-fail-secret", map[string]string{
|
||||||
|
"addlimiters": "mock add limiters failed",
|
||||||
|
})
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": "limiter-fail-forward",
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
"speedId": speedID,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Fatalf("expected create failure on limiter dispatch, got code=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "limiter-fail-forward")
|
||||||
|
if forwardCount != 0 {
|
||||||
|
t.Fatalf("expected forward rollback delete on limiter failure, got count=%d", forwardCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardCreateSucceedsWhenLimiterAlreadyExistsAndUpdateSucceedsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "limiter-exists-update-ok-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "limiter-exists-update-ok-tunnel")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "limiter-exists-update-ok-node", "limiter-exists-update-ok-secret", "10.20.1.1", "10.20.1.1", "", "32200-32210", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, r, "limiter-exists-update-ok-node")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 32201, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||||
|
`, "limiter-exists-update-ok-rule", 1024, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit: %v", err)
|
||||||
|
}
|
||||||
|
speedID := mustLastInsertID(t, r, "limiter-exists-update-ok-rule")
|
||||||
|
|
||||||
|
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "limiter-exists-update-ok-secret", map[string]string{
|
||||||
|
"addlimiters": "limiter 8 already exists",
|
||||||
|
})
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": "limiter-exists-update-ok-forward",
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
"speedId": speedID,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected create success when updater succeeds, got code=%d msg=%s", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "limiter-exists-update-ok-forward")
|
||||||
|
if forwardCount != 1 {
|
||||||
|
t.Fatalf("expected forward kept when update limiter succeeds, got count=%d", forwardCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardCreateRollbackWhenLimiterAlreadyExistsAndUpdateFailsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "limiter-exists-update-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "limiter-exists-update-fail-tunnel")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "limiter-exists-update-fail-node", "limiter-exists-update-fail-secret", "10.20.2.1", "10.20.2.1", "", "32300-32310", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, r, "limiter-exists-update-fail-node")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 32301, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||||
|
`, "limiter-exists-update-fail-rule", 1024, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit: %v", err)
|
||||||
|
}
|
||||||
|
speedID := mustLastInsertID(t, r, "limiter-exists-update-fail-rule")
|
||||||
|
|
||||||
|
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "limiter-exists-update-fail-secret", map[string]string{
|
||||||
|
"addlimiters": "limiter 9 already exists",
|
||||||
|
"updatelimiters": "mock update limiters failed",
|
||||||
|
})
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": "limiter-exists-update-fail-forward",
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
"speedId": speedID,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Fatalf("expected create failure when update limiter fails, got code=0")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.Msg, "mock update limiters failed") {
|
||||||
|
t.Fatalf("expected update failure message, got %q", out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "limiter-exists-update-fail-forward")
|
||||||
|
if forwardCount != 0 {
|
||||||
|
t.Fatalf("expected forward rollback delete when update limiter fails, got count=%d", forwardCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "addr-in-use-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "addr-in-use-tunnel")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "addr-in-use-node", "addr-in-use-secret", "10.20.0.11", "10.20.0.11", "", "32100-32110", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, r, "addr-in-use-node")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 32101, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "addr-in-use-secret", map[string]string{
|
||||||
|
"updateservice": "listen tcp [::]:32101: bind: address already in use",
|
||||||
|
"addservice": "listen tcp [::]:32101: bind: address already in use",
|
||||||
|
})
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": "addr-in-use-forward",
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Fatalf("expected create failure on address-in-use service dispatch, got code=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "addr-in-use-forward")
|
||||||
|
if forwardCount != 0 {
|
||||||
|
t.Fatalf("expected forward rollback delete on address-in-use failure, got count=%d", forwardCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchAssignRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(2, 'assign_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "assign-limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "assign-limiter-fail-tunnel")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "assign-limiter-fail-node", "assign-limiter-fail-secret", "10.21.0.1", "10.21.0.1", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, r, "assign-limiter-fail-node")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 33001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||||
|
`, "assign-limiter-fail-rule", 2048, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit: %v", err)
|
||||||
|
}
|
||||||
|
speedID := mustLastInsertID(t, r, "assign-limiter-fail-rule")
|
||||||
|
|
||||||
|
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(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||||
|
`, tunnelID).Error; err != nil {
|
||||||
|
t.Fatalf("insert user_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(2, 'assign_user', 'assign-limiter-fail-forward', ?, '9.9.9.9:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
|
`, tunnelID, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
forwardID := mustLastInsertID(t, r, "assign-limiter-fail-forward")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward_port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "assign-limiter-fail-secret", map[string]string{
|
||||||
|
"addlimiters": "mock add limiters failed",
|
||||||
|
})
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
assignPayload := map[string]interface{}{
|
||||||
|
"userId": 2,
|
||||||
|
"tunnels": []map[string]interface{}{{
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"speedId": speedID,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(assignPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal assign payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Fatalf("expected assign failure on limiter dispatch, got code=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
var persistedSpeedID sql.NullInt64
|
||||||
|
if err := r.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE user_id = 2 AND tunnel_id = ?`, tunnelID).Row().Scan(&persistedSpeedID); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel speed_id: %v", err)
|
||||||
|
}
|
||||||
|
if persistedSpeedID.Valid {
|
||||||
|
t.Fatalf("expected speed_id rollback to NULL, got %d", persistedSpeedID.Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(3, 'assign_insert_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "assign-insert-limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "assign-insert-limiter-fail-tunnel")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "assign-insert-limiter-fail-node", "assign-insert-limiter-fail-secret", "10.22.0.1", "10.22.0.1", "", "34000-34010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, r, "assign-insert-limiter-fail-node")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 34001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||||
|
`, "assign-insert-limiter-fail-rule", 3072, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit: %v", err)
|
||||||
|
}
|
||||||
|
speedID := mustLastInsertID(t, r, "assign-insert-limiter-fail-rule")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(3, 'assign_insert_user', 'assign-insert-limiter-fail-forward', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
|
`, tunnelID, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
forwardID := mustLastInsertID(t, r, "assign-insert-limiter-fail-forward")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 34001).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward_port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "assign-insert-limiter-fail-secret", map[string]string{
|
||||||
|
"addlimiters": "mock add limiters failed",
|
||||||
|
})
|
||||||
|
defer stopNode()
|
||||||
|
|
||||||
|
assignPayload := map[string]interface{}{
|
||||||
|
"userId": 3,
|
||||||
|
"tunnels": []map[string]interface{}{{
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"speedId": speedID,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(assignPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal assign payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Fatalf("expected assign(insert) failure on limiter dispatch, got code=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM user_tunnel WHERE user_id = 3 AND tunnel_id = ?`, tunnelID)
|
||||||
|
if insertedCount != 0 {
|
||||||
|
t.Fatalf("expected inserted user_tunnel rollback delete, got count=%d", insertedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse provider url: %v", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(u.Scheme, "https") {
|
||||||
|
u.Scheme = "wss"
|
||||||
|
} else {
|
||||||
|
u.Scheme = "ws"
|
||||||
|
}
|
||||||
|
u.Path = "/system-info"
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("type", "1")
|
||||||
|
q.Set("secret", nodeSecret)
|
||||||
|
q.Set("version", "v1")
|
||||||
|
q.Set("http", "1")
|
||||||
|
q.Set("tls", "1")
|
||||||
|
q.Set("socks", "1")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial mock node websocket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
_, raw, readErr := conn.ReadMessage()
|
||||||
|
if readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := raw
|
||||||
|
var wrap struct {
|
||||||
|
Encrypted bool `json:"encrypted"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
|
||||||
|
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
|
||||||
|
if cryptoErr == nil {
|
||||||
|
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
|
||||||
|
plain = []byte(dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(plain, &cmd); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdType := strings.TrimSpace(cmd.Type)
|
||||||
|
failMsg, shouldFail := failCommands[strings.ToLower(cmdType)]
|
||||||
|
|
||||||
|
respType := fmt.Sprintf("%sResponse", cmdType)
|
||||||
|
respPayload := map[string]interface{}{
|
||||||
|
"type": respType,
|
||||||
|
"success": !shouldFail,
|
||||||
|
"message": "OK",
|
||||||
|
"requestId": cmd.RequestID,
|
||||||
|
}
|
||||||
|
if shouldFail {
|
||||||
|
if strings.TrimSpace(failMsg) == "" {
|
||||||
|
failMsg = "mock command failed"
|
||||||
|
}
|
||||||
|
respPayload["message"] = failMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
respBytes, err := json.Marshal(respPayload)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var stopOnce sync.Once
|
||||||
|
return func() {
|
||||||
|
stopOnce.Do(func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
wg.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
func TestCaptchaVerifyLoginContract(t *testing.T) {
|
func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, r := setupContractRouter(t, secret)
|
router, r := setupContractRouter(t, secret)
|
||||||
|
verifiedToken := ""
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
INSERT INTO vite_config(name, value, time)
|
INSERT INTO vite_config(name, value, time)
|
||||||
@@ -34,6 +35,55 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
|||||||
t.Fatalf("enable captcha: %v", err)
|
t.Fatalf("enable captcha: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("login allowed when cloudflare keys are missing", func(t *testing.T) {
|
||||||
|
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assertCode(t, resp, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("captcha verify remains compatible without cloudflare secret", func(t *testing.T) {
|
||||||
|
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
||||||
|
verifyReq.Header.Set("Content-Type", "application/json")
|
||||||
|
verifyResp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(verifyResp, verifyReq)
|
||||||
|
|
||||||
|
var verifyOut struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
ValidToken string `json:"validToken"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
|
||||||
|
t.Fatalf("decode captcha verify response: %v", err)
|
||||||
|
}
|
||||||
|
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
|
||||||
|
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedToken = verifyOut.Data.ValidToken
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO vite_config(name, value, time)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||||
|
`, "cloudflare_site_key", "test-site-key", time.Now().UnixMilli()).Error; err != nil {
|
||||||
|
t.Fatalf("set cloudflare site key: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO vite_config(name, value, time)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||||
|
`, "cloudflare_secret_key", "test-secret-key", time.Now().UnixMilli()).Error; err != nil {
|
||||||
|
t.Fatalf("set cloudflare secret key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("login denied without verified captcha token", func(t *testing.T) {
|
t.Run("login denied without verified captcha token", func(t *testing.T) {
|
||||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||||
@@ -58,33 +108,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||||
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
if strings.TrimSpace(verifiedToken) == "" {
|
||||||
verifyReq.Header.Set("Content-Type", "application/json")
|
t.Fatalf("expected verified token from compatibility captcha verify")
|
||||||
verifyResp := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(verifyResp, verifyReq)
|
|
||||||
|
|
||||||
var verifyOut struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
ValidToken string `json:"validToken"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
|
|
||||||
t.Fatalf("decode captcha verify response: %v", err)
|
|
||||||
}
|
|
||||||
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
|
|
||||||
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
|
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
|
||||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
|
||||||
loginReq.Header.Set("Content-Type", "application/json")
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
loginResp := httptest.NewRecorder()
|
loginResp := httptest.NewRecorder()
|
||||||
router.ServeHTTP(loginResp, loginReq)
|
router.ServeHTTP(loginResp, loginReq)
|
||||||
assertCode(t, loginResp, 0)
|
assertCode(t, loginResp, 0)
|
||||||
|
|
||||||
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
|
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
|
||||||
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
|
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
|
||||||
replayReq.Header.Set("Content-Type", "application/json")
|
replayReq.Header.Set("Content-Type", "application/json")
|
||||||
replayResp := httptest.NewRecorder()
|
replayResp := httptest.NewRecorder()
|
||||||
@@ -174,39 +209,24 @@ func TestOpenAPISubStoreContracts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
|
func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, _ := setupContractRouter(t, secret)
|
router, _ := setupContractRouter(t, secret)
|
||||||
|
|
||||||
t.Run("missing token blocked", func(t *testing.T) {
|
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
if err != nil {
|
||||||
resp := httptest.NewRecorder()
|
t.Fatalf("generate token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
router.ServeHTTP(resp, req)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
assertCodeMsg(t, resp, 401, "未登录或token已过期")
|
router.ServeHTTP(resp, req)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("admin token receives success envelope", func(t *testing.T) {
|
if resp.Code != http.StatusNotFound {
|
||||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
t.Fatalf("expected status 404 after route removal, got %d", resp.Code)
|
||||||
if err != nil {
|
}
|
||||||
t.Fatalf("generate token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
|
||||||
req.Header.Set("Authorization", token)
|
|
||||||
resp := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(resp, req)
|
|
||||||
|
|
||||||
var out response.R
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
||||||
t.Fatalf("decode response: %v", err)
|
|
||||||
}
|
|
||||||
if out.Code != 0 {
|
|
||||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, _ := setupContractRouter(t, secret)
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("create speed limit", func(t *testing.T) {
|
||||||
|
body := `{"name":"test-limit-no-tunnel","speed":100,"status":1}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list does not expose tunnel binding fields", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected code 0, got %d", out.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := out.Data.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data to be array, got %T", out.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range data {
|
||||||
|
m, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m["name"] != "test-limit-no-tunnel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tunnelID, exists := m["tunnelId"]; exists && tunnelID != nil {
|
||||||
|
t.Fatalf("expected tunnelId to be absent or nil, got %v", tunnelID)
|
||||||
|
}
|
||||||
|
if tunnelName, exists := m["tunnelName"]; exists && tunnelName != nil && tunnelName != "" {
|
||||||
|
t.Fatalf("expected tunnelName to be absent or empty, got %v", tunnelName)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatal("speed limit 'test-limit-no-tunnel' not found in list")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeedLimitCreateIgnoresTunnelBindingContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-create-ignore-tunnel")
|
||||||
|
|
||||||
|
body := `{"name":"test-limit-ignore-tunnel","speed":200,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
res = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected code 0, got %d", out.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := out.Data.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data to be array, got %T", out.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range data {
|
||||||
|
m, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m["name"] != "test-limit-ignore-tunnel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
|
||||||
|
t.Fatalf("expected tunnelId ignored and nil, got %v", tunnelIDVal)
|
||||||
|
}
|
||||||
|
if tunnelNameVal, exists := m["tunnelName"]; exists && tunnelNameVal != nil && tunnelNameVal != "" {
|
||||||
|
t.Fatalf("expected tunnelName ignored and empty, got %v", tunnelNameVal)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatal("speed limit 'test-limit-ignore-tunnel' not found in list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeedLimitUpdateIgnoresTunnelBindingContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-update-ignore-tunnel")
|
||||||
|
speedLimitID := mustCreateSpeedLimitRepo(t, r, "test-limit-update-ignore-tunnel")
|
||||||
|
|
||||||
|
body := `{"id":` + jsonInt(speedLimitID) + `,"name":"test-limit-update-ignore-tunnel","speed":256,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/update", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
res = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected code 0, got %d", out.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := out.Data.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data to be array, got %T", out.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range data {
|
||||||
|
m, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m["name"] != "test-limit-update-ignore-tunnel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
|
||||||
|
t.Fatalf("expected tunnelId ignored and nil after update, got %v", tunnelIDVal)
|
||||||
|
}
|
||||||
|
if speedVal, ok := m["speed"].(float64); !ok || int(speedVal) != 256 {
|
||||||
|
t.Fatalf("expected speed 256 after update, got %v", m["speed"])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatal("speed limit 'test-limit-update-ignore-tunnel' not found in list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeedLimitDatabaseNullableFields(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "speed-limit-null.db")
|
||||||
|
r, err := repo.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
|
||||||
|
id, err := r.CreateSpeedLimit("db-test-limit", 100, 1, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSpeedLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
t.Fatalf("expected valid id, got %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tunnelID sql.NullInt64
|
||||||
|
var tunnelName sql.NullString
|
||||||
|
err = r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE id = ?", id).Row().Scan(&tunnelID, &tunnelName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query failed: %v", err)
|
||||||
|
}
|
||||||
|
if tunnelID.Valid {
|
||||||
|
t.Fatalf("expected TunnelID to be NULL, got %d", tunnelID.Int64)
|
||||||
|
}
|
||||||
|
if tunnelName.Valid && tunnelName.String != "" {
|
||||||
|
t.Fatalf("expected TunnelName to be NULL or empty, got %s", tunnelName.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeedLimitUpdateClearsHistoricalBinding(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "speed-limit-update-clear.db")
|
||||||
|
r, err := repo.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
|
||||||
|
tunnelID := mustCreateSpeedLimitTunnel(t, r, "speed-limit-update-clear-tunnel")
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "speed-limit-update-clear", 300, tunnelID, "speed-limit-update-clear-tunnel", now, now, 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert speed limit with tunnel binding: %v", err)
|
||||||
|
}
|
||||||
|
speedLimitID := mustLastInsertID(t, r, "speed-limit-update-clear")
|
||||||
|
|
||||||
|
err = r.UpdateSpeedLimit(speedLimitID, "speed-limit-update-clear", 512, 1, time.Now().UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateSpeedLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbTunnelID sql.NullInt64
|
||||||
|
var dbTunnelName sql.NullString
|
||||||
|
err = r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE id = ?", speedLimitID).Row().Scan(&dbTunnelID, &dbTunnelName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query updated speed limit failed: %v", err)
|
||||||
|
}
|
||||||
|
if dbTunnelID.Valid {
|
||||||
|
t.Fatalf("expected tunnel_id cleared after update, got %d", dbTunnelID.Int64)
|
||||||
|
}
|
||||||
|
if dbTunnelName.Valid && dbTunnelName.String != "" {
|
||||||
|
t.Fatalf("expected tunnel_name cleared after update, got %q", dbTunnelName.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeedLimitGetSpeed(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "speed-limit-getspeed.db")
|
||||||
|
r, err := repo.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
|
||||||
|
speedLimitID, err := r.CreateSpeedLimit("get-speed-test", 500, 1, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create speed limit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("GetSpeedLimitSpeed returns correct speed", func(t *testing.T) {
|
||||||
|
speed, err := r.GetSpeedLimitSpeed(speedLimitID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSpeedLimitSpeed failed: %v", err)
|
||||||
|
}
|
||||||
|
if speed != 500 {
|
||||||
|
t.Fatalf("expected speed 500, got %d", speed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetSpeedLimitSpeed returns error for non-existent id", func(t *testing.T) {
|
||||||
|
_, err := r.GetSpeedLimitSpeed(99999)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent speed limit ID")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCreateSpeedLimitTunnel(t *testing.T, r *repo.Repository, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||||
|
`, name, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("create tunnel failed: %v", err)
|
||||||
|
}
|
||||||
|
return mustLastInsertID(t, r, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCreateSpeedLimitRepo(t *testing.T, r *repo.Repository, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
id, err := r.CreateSpeedLimit(name, 100, now, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create speed limit failed: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package contract
|
package contract_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func TestUserTunnelVisibleListContracts(t *testing.T) {
|
func TestUserTunnelVisibleListContracts(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
router, repo := setupContractRouter(t, secret)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
if err := repo.DB().Exec(`
|
if err := repo.DB().Exec(`
|
||||||
@@ -126,8 +126,11 @@ func collectTunnelIDs(t *testing.T, data interface{}) map[int64]bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected object item, got %T", item)
|
t.Fatalf("expected object item, got %T", item)
|
||||||
}
|
}
|
||||||
id := int64(obj["id"].(float64))
|
idFloat, ok := obj["id"].(float64)
|
||||||
ids[id] = true
|
if !ok {
|
||||||
|
t.Fatalf("expected id to be float64, got %T", obj["id"])
|
||||||
|
}
|
||||||
|
ids[int64(idFloat)] = true
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ nav:
|
|||||||
- 首页: index.md
|
- 首页: index.md
|
||||||
- 安装部署: install.md
|
- 安装部署: install.md
|
||||||
- 使用指南: usage.md
|
- 使用指南: usage.md
|
||||||
|
- AI Skill 接入: ai-skill.md
|
||||||
|
- PostgreSQL: postgresql.md
|
||||||
- 常见问题: faq.md
|
- 常见问题: faq.md
|
||||||
|
|
||||||
markdown_extensions:
|
markdown_extensions:
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
---
|
||||||
|
name: flvx-api
|
||||||
|
description: Operate FLVX traffic forwarding management system via REST API. Supports user/node/tunnel/forward management, federation clustering, and traffic monitoring. Use when user wants to manage FLVX panel programmatically or via natural language.
|
||||||
|
metadata:
|
||||||
|
author: FLVX Team
|
||||||
|
version: "2.1.5"
|
||||||
|
requires_env:
|
||||||
|
- FLVX_BASE_URL
|
||||||
|
- FLVX_USERNAME
|
||||||
|
- FLVX_PASSWORD
|
||||||
|
---
|
||||||
|
|
||||||
|
# FLVX API Operations
|
||||||
|
|
||||||
|
Operate FLVX panel through REST API. All endpoints use POST method and return JSON with `{code, msg, data, ts}` envelope.
|
||||||
|
|
||||||
|
## Supported AI Tools
|
||||||
|
|
||||||
|
| Tool | Installation | Notes |
|
||||||
|
|------|--------------|-------|
|
||||||
|
| **OpenCode** | `npm i -g @flvx/skill-api` or `ln -s . ~/.agents/skills/flvx-api` | Auto-loads from `~/.agents/skills/` |
|
||||||
|
| **OpenClaw** | Same as OpenCode | Compatible skill format |
|
||||||
|
| **Claude Code** | Copy SKILL.md to CLAUDE.md or `~/.claude/CLAUDE.md` | Uses context file instead of skills |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Set environment variables before starting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FLVX_BASE_URL="https://your-panel.example.com"
|
||||||
|
export FLVX_USERNAME="admin"
|
||||||
|
export FLVX_PASSWORD="your-password"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security tip:** Add to `~/.flvx/.env` and source on demand:
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
### Session Token Cache
|
||||||
|
|
||||||
|
- Token is cached **only for the current conversation**
|
||||||
|
- New conversation = fresh login required
|
||||||
|
- Token is NOT written to disk (security)
|
||||||
|
|
||||||
|
### Auto-Login Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
Before ANY API call:
|
||||||
|
1. Check if TOKEN is cached in current session
|
||||||
|
├─ Yes → Use cached token, proceed
|
||||||
|
└─ No →
|
||||||
|
1. Read FLVX_USERNAME and FLVX_PASSWORD from environment
|
||||||
|
2. POST /api/v1/user/login with credentials
|
||||||
|
3. Cache response.data.token in session memory
|
||||||
|
4. Proceed with original request
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "${FLVX_BASE_URL}/api/v1/user/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${FLVX_USERNAME}\",\"password\":\"${FLVX_PASSWORD}\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"name": "Administrator",
|
||||||
|
"role_id": 0,
|
||||||
|
"requirePasswordChange": false
|
||||||
|
},
|
||||||
|
"ts": 1706659200000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Rules
|
||||||
|
|
||||||
|
| Header | Value | Critical |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| `Authorization` | `<jwt_token>` | ⚠️ NO "Bearer" prefix! |
|
||||||
|
| `Content-Type` | `application/json` | All requests use JSON |
|
||||||
|
|
||||||
|
## Quick Start Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
User request → Check env vars → Auto-login if needed → Call API → Return result
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intent → API Mapping
|
||||||
|
|
||||||
|
| User Intent | API Endpoint | Reference |
|
||||||
|
|-------------|--------------|-----------|
|
||||||
|
| "登录" / "查看我的信息" | `/api/v1/user/package` | [auth](references/auth.md) |
|
||||||
|
| "创建用户" / "添加用户" | `/api/v1/user/create` | [users](references/users.md) |
|
||||||
|
| "查看用户列表" / "所有用户" | `/api/v1/user/list` | [users](references/users.md) |
|
||||||
|
| "重置流量" | `/api/v1/user/reset` | [users](references/users.md) |
|
||||||
|
| "添加节点" / "新建节点" | `/api/v1/node/create` | [nodes](references/nodes.md) |
|
||||||
|
| "查看节点" / "节点状态" | `/api/v1/node/list` | [nodes](references/nodes.md) |
|
||||||
|
| "安装命令" / "部署节点" | `/api/v1/node/install` | [nodes](references/nodes.md) |
|
||||||
|
| "升级节点" | `/api/v1/node/upgrade` | [nodes](references/nodes.md) |
|
||||||
|
| "创建隧道" / "新建隧道" | `/api/v1/tunnel/create` | [tunnels](references/tunnels.md) |
|
||||||
|
| "分配隧道给用户" | `/api/v1/tunnel/user/assign` | [tunnels](references/tunnels.md) |
|
||||||
|
| "创建转发" / "新建转发" / "添加转发" | `/api/v1/forward/create` | [forwards](references/forwards.md) |
|
||||||
|
| "暂停转发" | `/api/v1/forward/pause` | [forwards](references/forwards.md) |
|
||||||
|
| "恢复转发" | `/api/v1/forward/resume` | [forwards](references/forwards.md) |
|
||||||
|
| "删除转发" | `/api/v1/forward/delete` | [forwards](references/forwards.md) |
|
||||||
|
| "查看我的转发" / "转发列表" | `/api/v1/forward/list` | [forwards](references/forwards.md) |
|
||||||
|
| "查看流量" / "流量统计" | `/api/v1/forward/list` or `/api/v1/user/package` | [forwards](references/forwards.md) |
|
||||||
|
| "诊断转发" / "测试连通性" | `/api/v1/forward/diagnose` | [forwards](references/forwards.md) |
|
||||||
|
| "创建限速规则" | `/api/v1/speed-limit/create` | [speed-limits](references/speed-limits.md) |
|
||||||
|
| "联邦共享" / "节点共享" | `/api/v1/federation/share/create` | [federation](references/federation.md) |
|
||||||
|
| "导出备份" | `/api/v1/backup/export` | [backup](references/backup.md) |
|
||||||
|
| "导入备份" | `/api/v1/backup/import` | [backup](references/backup.md) |
|
||||||
|
|
||||||
|
## HTTP Request Template
|
||||||
|
|
||||||
|
### Bash/curl (with auto-login)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
BASE_URL="${FLVX_BASE_URL}"
|
||||||
|
USERNAME="${FLVX_USERNAME}"
|
||||||
|
PASSWORD="${FLVX_PASSWORD}"
|
||||||
|
|
||||||
|
# Login and get token
|
||||||
|
TOKEN=$(curl -s -X POST "${BASE_URL}/api/v1/user/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\"}" | jq -r '.data.token')
|
||||||
|
|
||||||
|
if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then
|
||||||
|
echo "Login failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use token for API calls - NOTE: NO "Bearer" prefix!
|
||||||
|
curl -s -X POST "${BASE_URL}/api/v1/node/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python (requests)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("FLVX_BASE_URL")
|
||||||
|
USERNAME = os.environ.get("FLVX_USERNAME")
|
||||||
|
PASSWORD = os.environ.get("FLVX_PASSWORD")
|
||||||
|
|
||||||
|
# Login
|
||||||
|
resp = requests.post(f"{BASE_URL}/api/v1/user/login",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json={"username": USERNAME, "password": PASSWORD})
|
||||||
|
result = resp.json()
|
||||||
|
if result["code"] != 0:
|
||||||
|
raise Exception(f"Login failed: {result['msg']}")
|
||||||
|
|
||||||
|
TOKEN = result["data"]["token"]
|
||||||
|
|
||||||
|
# Authenticated request - NO "Bearer" prefix!
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": TOKEN
|
||||||
|
}
|
||||||
|
resp = requests.post(f"{BASE_URL}/api/v1/node/list", headers=headers, json={})
|
||||||
|
print(resp.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js (fetch)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const BASE_URL = process.env.FLVX_BASE_URL;
|
||||||
|
const USERNAME = process.env.FLVX_USERNAME;
|
||||||
|
const PASSWORD = process.env.FLVX_PASSWORD;
|
||||||
|
|
||||||
|
// Login
|
||||||
|
const loginRes = await fetch(`${BASE_URL}/api/v1/user/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: USERNAME, password: PASSWORD })
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
if (loginData.code !== 0) throw new Error(loginData.msg);
|
||||||
|
const TOKEN = loginData.data.token;
|
||||||
|
|
||||||
|
// Authenticated request - NO "Bearer" prefix!
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/node/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
console.log(await res.json());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Handling
|
||||||
|
|
||||||
|
**Success:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success", "data": {...}, "ts": 1706659200000}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```json
|
||||||
|
{"code": -1, "msg": "用户名或密码错误", "ts": 1706659200000}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```
|
||||||
|
1. Parse JSON response
|
||||||
|
2. If code === 0 → return data
|
||||||
|
3. If code === 401 → token expired, re-login and retry
|
||||||
|
4. If code === 403 → permission denied, need admin
|
||||||
|
5. Else → show msg to user as error message
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission Model
|
||||||
|
|
||||||
|
| role_id | Type | Access |
|
||||||
|
|---------|------|--------|
|
||||||
|
| 0 | Admin | All endpoints |
|
||||||
|
| 1 | Regular | Forward CRUD, own profile, assigned tunnels only |
|
||||||
|
|
||||||
|
Non-admin users can only see/modify their own resources.
|
||||||
|
|
||||||
|
## Module Reference
|
||||||
|
|
||||||
|
| Module | Endpoints | Reference |
|
||||||
|
|--------|-----------|-----------|
|
||||||
|
| Auth | login, captcha | [auth.md](references/auth.md) |
|
||||||
|
| Users | CRUD, reset, password | [users.md](references/users.md) |
|
||||||
|
| Nodes | CRUD, install, upgrade, status | [nodes.md](references/nodes.md) |
|
||||||
|
| Tunnels | CRUD, user assignment | [tunnels.md](references/tunnels.md) |
|
||||||
|
| Forwards | CRUD, pause/resume, diagnose | [forwards.md](references/forwards.md) |
|
||||||
|
| Groups | User/tunnel groups, permissions | [groups.md](references/groups.md) |
|
||||||
|
| Speed Limits | CRUD | [speed-limits.md](references/speed-limits.md) |
|
||||||
|
| Federation | Share, remote nodes | [federation.md](references/federation.md) |
|
||||||
|
| Backup | Export/import | [backup.md](references/backup.md) |
|
||||||
|
| Config | System settings | [config.md](references/config.md) |
|
||||||
|
| Types | TypeScript interfaces | [types.md](references/types.md) |
|
||||||
|
| Errors | Error codes | [errors.md](references/errors.md) |
|
||||||
|
| Examples | Code samples | [examples/](references/examples/) |
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
1. ⚠️ **NO "Bearer" prefix** - `Authorization: <token>`, NOT `Authorization: Bearer <token>`
|
||||||
|
2. **All endpoints use POST** - Including list/get operations
|
||||||
|
3. **code === 0 means success** - Any other value is an error
|
||||||
|
4. **Traffic units**: User.flow is GB, in_flow/out_flow are bytes
|
||||||
|
5. **Timestamps**: All timestamps are milliseconds since epoch
|
||||||
|
6. **Token is session-scoped**: Cache in memory only, not on disk
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Workflow 1: New User Onboarding (Admin)
|
||||||
|
```
|
||||||
|
1. POST /api/v1/user/create → Create user with traffic quota
|
||||||
|
2. POST /api/v1/tunnel/user/assign → Assign tunnels to user
|
||||||
|
3. Tell user their username/password
|
||||||
|
4. User logs in and creates forwards
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow 2: Add New Node (Admin)
|
||||||
|
```
|
||||||
|
1. POST /api/v1/node/create → Register node in panel
|
||||||
|
2. POST /api/v1/node/install → Get install command
|
||||||
|
3. Run install command on target server
|
||||||
|
4. POST /api/v1/node/check-status → Verify node is online
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow 3: Create Forward (Any User)
|
||||||
|
```
|
||||||
|
1. POST /api/v1/tunnel/user/tunnel → List available tunnels
|
||||||
|
2. POST /api/v1/forward/create → Create forward on chosen tunnel
|
||||||
|
3. POST /api/v1/forward/diagnose → Verify connectivity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow 4: Node Maintenance (Admin)
|
||||||
|
```
|
||||||
|
1. POST /api/v1/node/list → Check node statuses
|
||||||
|
2. POST /api/v1/node/releases → Check available versions
|
||||||
|
3. POST /api/v1/node/upgrade or /batch-upgrade → Upgrade nodes
|
||||||
|
4. POST /api/v1/node/rollback → Rollback if needed
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "@flvx/skill-api",
|
||||||
|
"version": "2.1.5",
|
||||||
|
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
|
||||||
|
"keywords": [
|
||||||
|
"opencode",
|
||||||
|
"openclaw",
|
||||||
|
"claude-code",
|
||||||
|
"skill",
|
||||||
|
"flvx",
|
||||||
|
"api",
|
||||||
|
"traffic-forwarding",
|
||||||
|
"gost"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "FLVX Team",
|
||||||
|
"files": [
|
||||||
|
"SKILL.md",
|
||||||
|
"references/**/*"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/Sagit-chu/flvx.git",
|
||||||
|
"directory": "skills/flvx-api"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/Sagit-chu/flvx/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/Sagit-chu/flvx/tree/main/skills/flvx-api#readme",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org"
|
||||||
|
},
|
||||||
|
"opencode": {
|
||||||
|
"skill": true,
|
||||||
|
"installTo": "~/.agents/skills/flvx-api"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "node -e \"const fs=require('fs');const path=require('path');const target=path.join(process.env.HOME,'.agents','skills','flvx-api');const src=process.cwd();try{fs.mkdirSync(path.dirname(target),{recursive:true});if(fs.existsSync(target)||fs.lstatSync(target).isSymbolicLink()){fs.unlinkSync(target)}fs.symlinkSync(src,target);console.log('✓ Installed to',target)}catch(e){console.error('Manual install: ln -s',src,target)}\"",
|
||||||
|
"preuninstall": "node -e \"const target=require('path').join(process.env.HOME,'.agents','skills','flvx-api');try{require('fs').unlinkSync(target);console.log('✓ Removed',target)}catch(e){}\"",
|
||||||
|
"link": "node -e \"const fs=require('fs');const path=require('path');const target=path.join(process.env.HOME,'.agents','skills','flvx-api');const src=process.cwd();try{fs.mkdirSync(path.dirname(target),{recursive:true});if(fs.existsSync(target)||fs.lstatSync(target).isSymbolicLink()){fs.unlinkSync(target)}fs.symlinkSync(src,target);console.log('✓ Linked to',target)}catch(e){console.error(e)}\"",
|
||||||
|
"unlink": "node -e \"const target=require('path').join(process.env.HOME,'.agents','skills','flvx-api');try{require('fs').unlinkSync(target);console.log('✓ Unlinked',target)}catch(e){}\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Authentication API
|
||||||
|
|
||||||
|
## POST /api/v1/user/login
|
||||||
|
|
||||||
|
Authenticate and obtain JWT token.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"captchaId": "optional-captcha-id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"name": "Administrator",
|
||||||
|
"role_id": 0,
|
||||||
|
"requirePasswordChange": false
|
||||||
|
},
|
||||||
|
"ts": 1706659200000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| token | string | JWT token for subsequent requests |
|
||||||
|
| name | string | User's display name |
|
||||||
|
| role_id | number | 0 = admin, 1 = regular user |
|
||||||
|
| requirePasswordChange | boolean | Whether password change is required |
|
||||||
|
|
||||||
|
## JWT Token Details
|
||||||
|
|
||||||
|
**Algorithm:** HMAC-SHA256
|
||||||
|
**Lifetime:** 90 days
|
||||||
|
|
||||||
|
**Token Claims:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "1",
|
||||||
|
"user": "admin",
|
||||||
|
"name": "Administrator",
|
||||||
|
"role_id": 0,
|
||||||
|
"iat": 1706659200,
|
||||||
|
"exp": 1738195200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/captcha/check
|
||||||
|
|
||||||
|
Check if captcha verification is required.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "turnstile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/captcha/verify
|
||||||
|
|
||||||
|
Verify captcha response (Cloudflare Turnstile or local captcha).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"captchaId": "captcha-session-id",
|
||||||
|
"captchaValue": "user-captcha-response"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token Usage
|
||||||
|
|
||||||
|
Include the token in all authenticated requests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "${FLVX_BASE_URL}/api/v1/node/list" \
|
||||||
|
-H "Authorization: eyJhbGciOiJIUzI1NiIs..." \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **CRITICAL: Do NOT add "Bearer " prefix!**
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Correct: Authorization: eyJhbGciOiJIUzI1NiIs...
|
||||||
|
❌ Incorrect: Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/user/updatePassword
|
||||||
|
|
||||||
|
Change current user's password.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"oldPassword": "current-password",
|
||||||
|
"newPassword": "new-password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/user/package
|
||||||
|
|
||||||
|
Get current user's package info (tunnels, forwards, traffic stats).
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"flow": 100,
|
||||||
|
"inFlow": 1073741824,
|
||||||
|
"outFlow": 2147483648,
|
||||||
|
"tunnels": 5,
|
||||||
|
"forwards": 10,
|
||||||
|
"expTime": 1735689600000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| flow | number | Total traffic quota in GB |
|
||||||
|
| inFlow | number | Used upload in bytes |
|
||||||
|
| outFlow | number | Used download in bytes |
|
||||||
|
| tunnels | number | Number of assigned tunnels |
|
||||||
|
| forwards | number | Number of forwards created |
|
||||||
|
| expTime | number | Account expiry timestamp (ms) |
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Backup & Restore API
|
||||||
|
|
||||||
|
Export and import system data for backup, migration, or disaster recovery.
|
||||||
|
|
||||||
|
## POST /api/v1/backup/export
|
||||||
|
|
||||||
|
Export system data.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"types": ["users", "nodes", "tunnels", "forwards", "speed_limits", "groups"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `types` is empty or omitted, exports all data.
|
||||||
|
|
||||||
|
**Available types:**
|
||||||
|
- `users` - User accounts
|
||||||
|
- `nodes` - Node configurations
|
||||||
|
- `tunnels` - Tunnel configurations
|
||||||
|
- `forwards` - Forward rules
|
||||||
|
- `speed_limits` - Speed limit rules
|
||||||
|
- `groups` - User/tunnel groups and permissions
|
||||||
|
- `configs` - System configurations
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"exportedAt": 1706659200000,
|
||||||
|
"types": ["users", "nodes", "tunnels"],
|
||||||
|
"users": [...],
|
||||||
|
"nodes": [...],
|
||||||
|
"tunnels": [...],
|
||||||
|
"forwards": [...],
|
||||||
|
"speedLimits": [...],
|
||||||
|
"tunnelGroups": [...],
|
||||||
|
"userGroups": [...],
|
||||||
|
"groupPermissions": [...],
|
||||||
|
"configs": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/backup/import
|
||||||
|
|
||||||
|
Import system data from a backup.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "2.1.5",
|
||||||
|
"exportedAt": 1706659200000,
|
||||||
|
"types": ["users", "nodes"],
|
||||||
|
"users": [...],
|
||||||
|
"nodes": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import Behavior:**
|
||||||
|
- Existing records are updated if IDs match
|
||||||
|
- New records are created for non-existent IDs
|
||||||
|
- Related entities must be included (e.g., forwards require tunnels)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"imported": {
|
||||||
|
"users": 5,
|
||||||
|
"nodes": 3,
|
||||||
|
"tunnels": 10
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"forwards": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/backup/restore
|
||||||
|
|
||||||
|
Alias for `/api/v1/backup/import`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Full System Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export all data
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/backup/export" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' > backup-$(date +%Y%m%d).json
|
||||||
|
|
||||||
|
echo "Backup saved to backup-$(date +%Y%m%d).json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow: Partial Export
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export only users and tunnels
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/backup/export" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"types":["users","tunnels"]}' > partial-backup.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow: Restore from Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Import from backup file
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/backup/import" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @backup-20260226.json | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow: Migrate to New Panel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On source panel
|
||||||
|
curl -s -X POST "${SOURCE_URL}/api/v1/backup/export" \
|
||||||
|
-H "Authorization: ${SOURCE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' > migration.json
|
||||||
|
|
||||||
|
# On target panel
|
||||||
|
curl -s -X POST "${TARGET_URL}/api/v1/backup/import" \
|
||||||
|
-H "Authorization: ${TARGET_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @migration.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** After migration, you may need to:
|
||||||
|
1. Reinstall node agents with new panel URL
|
||||||
|
2. Update node secrets if they differ
|
||||||
|
3. Reassign federation tokens
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# System Configuration API
|
||||||
|
|
||||||
|
Manage system-wide settings and configurations.
|
||||||
|
|
||||||
|
## POST /api/v1/config/get
|
||||||
|
|
||||||
|
Get a single configuration by name. This endpoint is public (no auth required).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"name": "site_name"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"name": "site_name",
|
||||||
|
"value": "My FLVX Panel",
|
||||||
|
"time": 1706659200000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/config/list
|
||||||
|
|
||||||
|
List all configurations (requires authentication).
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"site_name": "My FLVX Panel",
|
||||||
|
"site_logo": "https://example.com/logo.png",
|
||||||
|
"site_announcement": "System maintenance scheduled",
|
||||||
|
"captcha_enabled": "true",
|
||||||
|
"captcha_type": "turnstile",
|
||||||
|
"turnstile_site_key": "...",
|
||||||
|
"default_user_flow": "100",
|
||||||
|
"default_user_exp_days": "30"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/config/update
|
||||||
|
|
||||||
|
Batch update multiple configurations (admin only).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"site_name": "New Panel Name",
|
||||||
|
"site_announcement": "Welcome to the new panel!",
|
||||||
|
"default_user_flow": "50"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only include the keys you want to update.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/config/update-single
|
||||||
|
|
||||||
|
Update a single configuration (admin only).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "site_name",
|
||||||
|
"value": "My Awesome Panel"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/announcement/get
|
||||||
|
|
||||||
|
Get the site announcement (public endpoint).
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"content": "System maintenance scheduled for tonight"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/announcement/update
|
||||||
|
|
||||||
|
Update the site announcement (admin only).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"content": "New announcement message"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Configuration Keys
|
||||||
|
|
||||||
|
| Key | Description | Example |
|
||||||
|
|-----|-------------|---------|
|
||||||
|
| `site_name` | Panel display name | `"My FLVX Panel"` |
|
||||||
|
| `site_logo` | Logo URL | `"https://example.com/logo.png"` |
|
||||||
|
| `site_announcement` | Announcement HTML | `"<p>Notice...</p>"` |
|
||||||
|
| `captcha_enabled` | Enable captcha | `"true"` or `"false"` |
|
||||||
|
| `captcha_type` | Captcha provider | `"turnstile"` or `"local"` |
|
||||||
|
| `turnstile_site_key` | Cloudflare Turnstile site key | `"0x4..."` |
|
||||||
|
| `turnstile_secret_key` | Cloudflare Turnstile secret | `"0x4..."` |
|
||||||
|
| `default_user_flow` | Default user traffic (GB) | `"100"` |
|
||||||
|
| `default_user_exp_days` | Default user expiry days | `"30"` |
|
||||||
|
| `default_user_num` | Default max forwards | `"10"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Update Panel Name and Announcement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/config/update" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"site_name": "Awesome Traffic Panel",
|
||||||
|
"site_announcement": "<strong>Welcome!</strong> New nodes added."
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Enable Cloudflare Turnstile Captcha
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/config/update" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"captcha_enabled": "true",
|
||||||
|
"captcha_type": "turnstile",
|
||||||
|
"turnstile_site_key": "0x4AAAAAAAAjq0JN9YQg",
|
||||||
|
"turnstile_secret_key": "0x4AAAAAAAAjq0JN9YQg_secret"
|
||||||
|
}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Error Codes & Handling
|
||||||
|
|
||||||
|
## Response Code Field
|
||||||
|
|
||||||
|
| code | Meaning | Action |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `0` | Success | Use `data` field |
|
||||||
|
| `-1` | Business error | Show `msg` to user |
|
||||||
|
| `-2` | Server/DB error | Retry or report bug |
|
||||||
|
| `401` | Unauthorized | Token expired/invalid, re-login |
|
||||||
|
| `403` | Forbidden | Need admin privileges |
|
||||||
|
|
||||||
|
## Common Error Messages (Chinese)
|
||||||
|
|
||||||
|
| msg | Cause | Solution |
|
||||||
|
|-----|-------|----------|
|
||||||
|
| 用户名或密码错误 | Wrong credentials | Check username/password |
|
||||||
|
| Token已过期 | Token expired | Re-login |
|
||||||
|
| 权限不足 | Need admin | Use admin account (role_id: 0) |
|
||||||
|
| 端口已被占用 | Port in use | Choose different port or delete conflicting forward |
|
||||||
|
| 流量不足 | Out of traffic | Contact admin or upgrade plan |
|
||||||
|
| 节点离线 | Node offline | Check node status, run install command |
|
||||||
|
| 隧道不可用 | Tunnel disabled | Enable tunnel first |
|
||||||
|
| 用户已存在 | Username taken | Choose different username |
|
||||||
|
| 参数错误 | Invalid request | Check request body format |
|
||||||
|
| 转发数量已达上限 | Forward limit reached | Delete unused forwards or contact admin |
|
||||||
|
| 该隧道未分配给当前用户 | No tunnel access | Contact admin to get tunnel assigned |
|
||||||
|
|
||||||
|
## Error Handling Pattern
|
||||||
|
|
||||||
|
### JavaScript/TypeScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function callApi<T>(endpoint: string, data: object): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": TOKEN,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.code) {
|
||||||
|
case 401:
|
||||||
|
// Token expired - clear and retry
|
||||||
|
TOKEN = null;
|
||||||
|
throw new Error("登录已过期,请重新登录");
|
||||||
|
case 403:
|
||||||
|
throw new Error("权限不足,需要管理员权限");
|
||||||
|
case -2:
|
||||||
|
throw new Error("服务器错误,请稍后重试");
|
||||||
|
default:
|
||||||
|
throw new Error(result.msg || "操作失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
def call_api(endpoint: str, data: dict = None) -> dict:
|
||||||
|
global TOKEN
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if TOKEN:
|
||||||
|
headers["Authorization"] = TOKEN
|
||||||
|
|
||||||
|
resp = requests.post(f"{BASE_URL}{endpoint}", headers=headers, json=data or {})
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if result["code"] == 0:
|
||||||
|
return result.get("data")
|
||||||
|
|
||||||
|
if result["code"] == 401:
|
||||||
|
TOKEN = None
|
||||||
|
raise Exception("登录已过期,请重新登录")
|
||||||
|
elif result["code"] == 403:
|
||||||
|
raise Exception("权限不足,需要管理员权限")
|
||||||
|
elif result["code"] == -2:
|
||||||
|
raise Exception("服务器错误,请稍后重试")
|
||||||
|
else:
|
||||||
|
raise Exception(result["msg"] or "操作失败")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
call_api() {
|
||||||
|
local endpoint="$1"
|
||||||
|
local data="$2"
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X POST "${FLVX_BASE_URL}${endpoint}" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data")
|
||||||
|
|
||||||
|
local code
|
||||||
|
code=$(echo "$response" | jq -r '.code')
|
||||||
|
|
||||||
|
if [ "$code" == "0" ]; then
|
||||||
|
echo "$response" | jq '.data'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local msg
|
||||||
|
msg=$(echo "$response" | jq -r '.msg')
|
||||||
|
|
||||||
|
case "$code" in
|
||||||
|
401) echo "Error: 登录已过期" >&2 ;;
|
||||||
|
403) echo "Error: 权限不足" >&2 ;;
|
||||||
|
-2) echo "Error: 服务器错误" >&2 ;;
|
||||||
|
*) echo "Error: $msg" >&2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retry Logic with Auto Re-login
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function callApiWithRetry<T>(
|
||||||
|
endpoint: string,
|
||||||
|
data: object,
|
||||||
|
maxRetries = 1
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let i = 0; i <= maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
if (!TOKEN) {
|
||||||
|
await login();
|
||||||
|
}
|
||||||
|
return await callApi<T>(endpoint, data);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (error.message.includes("过期") || error.message.includes("expired")) {
|
||||||
|
TOKEN = null; // Force re-login on next attempt
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Errors
|
||||||
|
|
||||||
|
When request validation fails, the API returns code -1 with specific messages:
|
||||||
|
|
||||||
|
| Scenario | Error Message |
|
||||||
|
|----------|--------------|
|
||||||
|
| Missing required field | `参数错误` or field-specific message |
|
||||||
|
| Invalid port range | `端口范围无效` |
|
||||||
|
| Invalid IP format | `IP地址格式错误` |
|
||||||
|
| Invalid date | `时间格式错误` |
|
||||||
|
| Username too short | `用户名长度不能少于3个字符` |
|
||||||
|
| Password too weak | `密码长度不能少于6个字符` |
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# curl Examples
|
||||||
|
|
||||||
|
Quick reference for common operations using curl.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set environment variables
|
||||||
|
export FLVX_BASE_URL="https://your-panel.example.com"
|
||||||
|
export FLVX_USERNAME="admin"
|
||||||
|
export FLVX_PASSWORD="your-password"
|
||||||
|
|
||||||
|
# Login and save token
|
||||||
|
TOKEN=$(curl -s -X POST "${FLVX_BASE_URL}/api/v1/user/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${FLVX_USERNAME}\",\"password\":\"${FLVX_PASSWORD}\"}" \
|
||||||
|
| jq -r '.data.token')
|
||||||
|
|
||||||
|
echo "Token: ${TOKEN:0:20}..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get my package info
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/user/package" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.'
|
||||||
|
|
||||||
|
# List all users (admin)
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/user/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"page":1,"pageSize":20}' | jq '.'
|
||||||
|
|
||||||
|
# Create user (admin)
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/user/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"user": "alice",
|
||||||
|
"pwd": "SecurePass123!",
|
||||||
|
"name": "Alice",
|
||||||
|
"flow": 50,
|
||||||
|
"num": 10,
|
||||||
|
"expTime": 1767225600000
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
# Reset user traffic
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/user/reset" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":2,"type":"user"}' | jq '.'
|
||||||
|
|
||||||
|
# Delete user
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/user/delete" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":2}' | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List nodes with status
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.data.list[] | {name, status: (.status == 1), ip: .server_ip}'
|
||||||
|
|
||||||
|
# Create node
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"US-Node-1","serverIp":"203.0.113.10"}' | jq '.'
|
||||||
|
|
||||||
|
# Get install command
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/install" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":2}' | jq -r '.data.command'
|
||||||
|
|
||||||
|
# Check node status
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/check-status" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.'
|
||||||
|
|
||||||
|
# Upgrade node
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/upgrade" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":2,"version":"2.1.5"}' | jq '.'
|
||||||
|
|
||||||
|
# Delete node
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/delete" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":2}' | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tunnel Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List tunnels
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.data.list[] | {id, name, status}'
|
||||||
|
|
||||||
|
# Create tunnel
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "HK-US-Tunnel",
|
||||||
|
"type": 1,
|
||||||
|
"inNodeId": [1],
|
||||||
|
"outNodeId": [2]
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
# Assign tunnel to user
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/user/assign" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"userId":2,"tunnelId":1,"flow":30}' | jq '.'
|
||||||
|
|
||||||
|
# Get available tunnels (for current user)
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/user/tunnel" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forward Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List forwards with traffic
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.data.list[] | {
|
||||||
|
name,
|
||||||
|
tunnel: .tunnel_name,
|
||||||
|
port: .in_port,
|
||||||
|
target: .remote_addr,
|
||||||
|
status: (if .status == 1 then "running" else "paused" end),
|
||||||
|
upload_gb: ((.in_flow / 1073741824) | floor),
|
||||||
|
download_gb: ((.out_flow / 1073741824) | floor)
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Create forward
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "my-web-server",
|
||||||
|
"tunnelId": 1,
|
||||||
|
"remoteAddr": "192.168.1.100:80",
|
||||||
|
"strategy": "fifo"
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
# Create forward with load balancing
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "web-cluster",
|
||||||
|
"tunnelId": 1,
|
||||||
|
"remoteAddr": "10.0.0.1:80,10.0.0.2:80,10.0.0.3:80",
|
||||||
|
"strategy": "round"
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
# Pause forward
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/pause" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":1}' | jq '.'
|
||||||
|
|
||||||
|
# Resume forward
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/resume" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":1}' | jq '.'
|
||||||
|
|
||||||
|
# Diagnose forward
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/diagnose" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":1}' | jq '.'
|
||||||
|
|
||||||
|
# Delete forward
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/delete" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":1}' | jq '.'
|
||||||
|
|
||||||
|
# Batch pause forwards
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/batch-pause" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ids":[1,2,3]}' | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export all data
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/backup/export" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' > backup-$(date +%Y%m%d).json
|
||||||
|
|
||||||
|
# Export specific types
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/backup/export" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"types":["users","tunnels"]}' > partial-backup.json
|
||||||
|
|
||||||
|
# Import backup
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/backup/import" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @backup-20260226.json | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helper Functions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to ~/.bashrc or ~/.zshrc
|
||||||
|
|
||||||
|
flvx-login() {
|
||||||
|
export FLVX_BASE_URL="${1:-$FLVX_BASE_URL}"
|
||||||
|
TOKEN=$(curl -s -X POST "${FLVX_BASE_URL}/api/v1/user/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${FLVX_USERNAME}\",\"password\":\"${FLVX_PASSWORD}\"}" \
|
||||||
|
| jq -r '.data.token')
|
||||||
|
export FLVX_TOKEN="$TOKEN"
|
||||||
|
echo "Logged in. Token: ${TOKEN:0:20}..."
|
||||||
|
}
|
||||||
|
|
||||||
|
flvx-api() {
|
||||||
|
local endpoint="$1"
|
||||||
|
local data="${2:-{}}"
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}${endpoint}" \
|
||||||
|
-H "Authorization: ${FLVX_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" | jq '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# flvx-login
|
||||||
|
# flvx-api /api/v1/node/list
|
||||||
|
# flvx-api /api/v1/forward/list '{"keyword":"web"}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
# HTTP Client Examples
|
||||||
|
|
||||||
|
Complete, runnable examples for various languages.
|
||||||
|
|
||||||
|
## Bash / curl
|
||||||
|
|
||||||
|
### Complete Script with Auto-Login
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BASE_URL="${FLVX_BASE_URL:?FLVX_BASE_URL not set}"
|
||||||
|
USERNAME="${FLVX_USERNAME:?FLVX_USERNAME not set}"
|
||||||
|
PASSWORD="${FLVX_PASSWORD:?FLVX_PASSWORD not set}"
|
||||||
|
|
||||||
|
# Login and get token
|
||||||
|
echo "Logging in..."
|
||||||
|
LOGIN_RESPONSE=$(curl -s -X POST "${BASE_URL}/api/v1/user/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\"}")
|
||||||
|
|
||||||
|
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.token // empty')
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Login failed: $(echo "$LOGIN_RESPONSE" | jq -r '.msg')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Logged in successfully"
|
||||||
|
|
||||||
|
# API call helper
|
||||||
|
api_call() {
|
||||||
|
local endpoint="$1"
|
||||||
|
local data="${2:-{}}"
|
||||||
|
|
||||||
|
curl -s -X POST "${BASE_URL}${endpoint}" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
echo "=== My Package Info ==="
|
||||||
|
api_call "/api/v1/user/package" | jq '.'
|
||||||
|
|
||||||
|
echo -e "\n=== Node List ==="
|
||||||
|
api_call "/api/v1/node/list" '{}' | jq '.data.list[] | {name, status: (.status == 1)}'
|
||||||
|
|
||||||
|
echo -e "\n=== Forward List ==="
|
||||||
|
api_call "/api/v1/forward/list" '{}' | jq '.data.list[] | {name, tunnel: .tunnel_name, port: .in_port, target: .remote_addr}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Forward Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
BASE_URL="${FLVX_BASE_URL}"
|
||||||
|
TOKEN="${FLVX_TOKEN}" # Pre-obtained token
|
||||||
|
|
||||||
|
create_forward() {
|
||||||
|
local name="$1"
|
||||||
|
local tunnel_id="$2"
|
||||||
|
local remote_addr="$3"
|
||||||
|
|
||||||
|
curl -s -X POST "${BASE_URL}/api/v1/forward/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"name\": \"${name}\",
|
||||||
|
\"tunnelId\": ${tunnel_id},
|
||||||
|
\"remoteAddr\": \"${remote_addr}\",
|
||||||
|
\"strategy\": \"fifo\"
|
||||||
|
}" | jq '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage: ./create-forward.sh "my-web" 1 "192.168.1.100:80"
|
||||||
|
create_forward "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python
|
||||||
|
|
||||||
|
### Complete Client Class
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""FLVX API Client"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from typing import Optional, Any, Dict, List
|
||||||
|
|
||||||
|
class FlvxError(Exception):
|
||||||
|
"""FLVX API Error"""
|
||||||
|
def __init__(self, code: int, message: str):
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
class FlvxClient:
|
||||||
|
"""FLVX API Client with auto-login"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
password: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.base_url = base_url or os.environ.get("FLVX_BASE_URL")
|
||||||
|
self.username = username or os.environ.get("FLVX_USERNAME")
|
||||||
|
self.password = password or os.environ.get("FLVX_PASSWORD")
|
||||||
|
|
||||||
|
if not all([self.base_url, self.username, self.password]):
|
||||||
|
raise ValueError("Missing credentials. Set FLVX_BASE_URL, FLVX_USERNAME, FLVX_PASSWORD")
|
||||||
|
|
||||||
|
self.token: Optional[str] = None
|
||||||
|
|
||||||
|
def _login(self) -> None:
|
||||||
|
"""Authenticate and store token"""
|
||||||
|
resp = requests.post(
|
||||||
|
f"{self.base_url}/api/v1/user/login",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json={"username": self.username, "password": self.password}
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if result["code"] != 0:
|
||||||
|
raise FlvxError(result["code"], result["msg"])
|
||||||
|
|
||||||
|
self.token = result["data"]["token"]
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
"""Get request headers with auth"""
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.token:
|
||||||
|
headers["Authorization"] = self.token # NO "Bearer " prefix!
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def request(self, endpoint: str, data: Any = None) -> Any:
|
||||||
|
"""Make authenticated API request"""
|
||||||
|
if not self.token:
|
||||||
|
self._login()
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"{self.base_url}{endpoint}",
|
||||||
|
headers=self._headers(),
|
||||||
|
json=data or {}
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if result["code"] == 0:
|
||||||
|
return result.get("data")
|
||||||
|
|
||||||
|
if result["code"] == 401:
|
||||||
|
# Token expired, retry once
|
||||||
|
self.token = None
|
||||||
|
return self.request(endpoint, data)
|
||||||
|
|
||||||
|
raise FlvxError(result["code"], result["msg"])
|
||||||
|
|
||||||
|
# Convenience methods
|
||||||
|
|
||||||
|
def get_package(self) -> Dict:
|
||||||
|
"""Get current user's package info"""
|
||||||
|
return self.request("/api/v1/user/package", {})
|
||||||
|
|
||||||
|
def list_nodes(self) -> List[Dict]:
|
||||||
|
"""List all nodes"""
|
||||||
|
data = self.request("/api/v1/node/list", {})
|
||||||
|
return data.get("list", [])
|
||||||
|
|
||||||
|
def list_forwards(self, keyword: str = "") -> List[Dict]:
|
||||||
|
"""List forwards"""
|
||||||
|
data = self.request("/api/v1/forward/list", {"keyword": keyword})
|
||||||
|
return data.get("list", [])
|
||||||
|
|
||||||
|
def create_forward(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
tunnel_id: int,
|
||||||
|
remote_addr: str,
|
||||||
|
strategy: str = "fifo",
|
||||||
|
speed_id: int = 0
|
||||||
|
) -> Dict:
|
||||||
|
"""Create a forward"""
|
||||||
|
return self.request("/api/v1/forward/create", {
|
||||||
|
"name": name,
|
||||||
|
"tunnelId": tunnel_id,
|
||||||
|
"remoteAddr": remote_addr,
|
||||||
|
"strategy": strategy,
|
||||||
|
"speedId": speed_id
|
||||||
|
})
|
||||||
|
|
||||||
|
def pause_forward(self, forward_id: int) -> None:
|
||||||
|
"""Pause a forward"""
|
||||||
|
self.request("/api/v1/forward/pause", {"id": forward_id})
|
||||||
|
|
||||||
|
def resume_forward(self, forward_id: int) -> None:
|
||||||
|
"""Resume a forward"""
|
||||||
|
self.request("/api/v1/forward/resume", {"id": forward_id})
|
||||||
|
|
||||||
|
def delete_forward(self, forward_id: int) -> None:
|
||||||
|
"""Delete a forward"""
|
||||||
|
self.request("/api/v1/forward/delete", {"id": forward_id})
|
||||||
|
|
||||||
|
|
||||||
|
# Usage example
|
||||||
|
if __name__ == "__main__":
|
||||||
|
client = FlvxClient()
|
||||||
|
|
||||||
|
# Get package info
|
||||||
|
pkg = client.get_package()
|
||||||
|
print(f"Traffic: {pkg['inFlow'] / 1e9:.2f}GB ↑ / {pkg['outFlow'] / 1e9:.2f}GB ↓")
|
||||||
|
print(f"Quota: {pkg['flow']}GB")
|
||||||
|
|
||||||
|
# List forwards with traffic
|
||||||
|
print("\nForwards:")
|
||||||
|
for fwd in client.list_forwards():
|
||||||
|
print(f" {fwd['name']}: {fwd['in_port']} → {fwd['remote_addr']}")
|
||||||
|
print(f" Traffic: {fwd['in_flow'] / 1e9:.2f}GB ↑ / {fwd['out_flow'] / 1e9:.2f}GB ↓")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node.js / TypeScript
|
||||||
|
|
||||||
|
### Complete Client Class
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// flvx-client.ts
|
||||||
|
interface APIResponse<T = unknown> {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data?: T;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlvxError extends Error {
|
||||||
|
constructor(public code: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "FlvxError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserPackage {
|
||||||
|
flow: number;
|
||||||
|
inFlow: number;
|
||||||
|
outFlow: number;
|
||||||
|
tunnels: number;
|
||||||
|
forwards: number;
|
||||||
|
expTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
server_ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Forward {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tunnel_id: number;
|
||||||
|
tunnel_name: string;
|
||||||
|
in_port: number;
|
||||||
|
remote_addr: string;
|
||||||
|
status: number;
|
||||||
|
in_flow: number;
|
||||||
|
out_flow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlvxClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private username: string;
|
||||||
|
private password: string;
|
||||||
|
private token?: string;
|
||||||
|
|
||||||
|
constructor(options?: {
|
||||||
|
baseUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}) {
|
||||||
|
this.baseUrl = options?.baseUrl ?? process.env.FLVX_BASE_URL ?? "";
|
||||||
|
this.username = options?.username ?? process.env.FLVX_USERNAME ?? "";
|
||||||
|
this.password = options?.password ?? process.env.FLVX_PASSWORD ?? "";
|
||||||
|
|
||||||
|
if (!this.baseUrl || !this.username || !this.password) {
|
||||||
|
throw new Error("Missing credentials. Set FLVX_BASE_URL, FLVX_USERNAME, FLVX_PASSWORD");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<void> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/v1/user/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse<{ token: string }> = await res.json();
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new FlvxError(result.code, result.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = result.data!.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(endpoint: string, data?: object): Promise<T> {
|
||||||
|
if (!this.token) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: this.token!, // NO "Bearer " prefix!
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse<T> = await res.json();
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
return result.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 401) {
|
||||||
|
// Token expired, retry once
|
||||||
|
this.token = undefined;
|
||||||
|
return this.request<T>(endpoint, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FlvxError(result.code, result.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
|
||||||
|
async getPackage(): Promise<UserPackage> {
|
||||||
|
return this.request("/api/v1/user/package", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNodes(): Promise<Node[]> {
|
||||||
|
const data = await this.request<{ list: Node[] }>("/api/v1/node/list", {});
|
||||||
|
return data.list ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForwards(keyword = ""): Promise<Forward[]> {
|
||||||
|
const data = await this.request<{ list: Forward[] }>("/api/v1/forward/list", {
|
||||||
|
keyword,
|
||||||
|
});
|
||||||
|
return data.list ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createForward(options: {
|
||||||
|
name: string;
|
||||||
|
tunnelId: number;
|
||||||
|
remoteAddr: string;
|
||||||
|
strategy?: "fifo" | "round";
|
||||||
|
speedId?: number;
|
||||||
|
}): Promise<Forward> {
|
||||||
|
return this.request("/api/v1/forward/create", {
|
||||||
|
name: options.name,
|
||||||
|
tunnelId: options.tunnelId,
|
||||||
|
remoteAddr: options.remoteAddr,
|
||||||
|
strategy: options.strategy ?? "fifo",
|
||||||
|
speedId: options.speedId ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async pauseForward(id: number): Promise<void> {
|
||||||
|
await this.request("/api/v1/forward/pause", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeForward(id: number): Promise<void> {
|
||||||
|
await this.request("/api/v1/forward/resume", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForward(id: number): Promise<void> {
|
||||||
|
await this.request("/api/v1/forward/delete", { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FlvxClient, FlvxError };
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
async function main() {
|
||||||
|
const client = new FlvxClient();
|
||||||
|
|
||||||
|
// Get package info
|
||||||
|
const pkg = await client.getPackage();
|
||||||
|
console.log(`Traffic: ${(pkg.inFlow / 1e9).toFixed(2)}GB ↑ / ${(pkg.outFlow / 1e9).toFixed(2)}GB ↓`);
|
||||||
|
console.log(`Quota: ${pkg.flow}GB`);
|
||||||
|
|
||||||
|
// List nodes
|
||||||
|
console.log("\nNodes:");
|
||||||
|
const nodes = await client.listNodes();
|
||||||
|
for (const node of nodes) {
|
||||||
|
console.log(` ${node.name}: ${node.status ? "Online" : "Offline"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List forwards
|
||||||
|
console.log("\nForwards:");
|
||||||
|
const forwards = await client.listForwards();
|
||||||
|
for (const fwd of forwards) {
|
||||||
|
console.log(` ${fwd.name}: ${fwd.in_port} → ${fwd.remote_addr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go
|
||||||
|
|
||||||
|
### Complete Client Package
|
||||||
|
|
||||||
|
```go
|
||||||
|
// flvx/client.go
|
||||||
|
package flvx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlvxError struct {
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FlvxError) Error() string {
|
||||||
|
return fmt.Sprintf("FLVX error %d: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: os.Getenv("FLVX_BASE_URL"),
|
||||||
|
Username: os.Getenv("FLVX_USERNAME"),
|
||||||
|
Password: os.Getenv("FLVX_PASSWORD"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Login() error {
|
||||||
|
payload := map[string]string{
|
||||||
|
"username": c.Username,
|
||||||
|
"password": c.Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.request("/api/v1/user/login", payload, &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Code != 0 {
|
||||||
|
return &FlvxError{Code: result.Code, Message: result.Msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Token = result.Data.Token
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Request(endpoint string, data interface{}, result interface{}) error {
|
||||||
|
// Auto-login if no token
|
||||||
|
if c.Token == "" {
|
||||||
|
if err := c.Login(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.request(endpoint, data, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) request(endpoint string, data interface{}, result interface{}) error {
|
||||||
|
body, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.BaseURL+endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", c.Token) // NO "Bearer " prefix!
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(respBody, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
|
||||||
|
func (c *Client) ListNodes() ([]map[string]interface{}, error) {
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
List []map[string]interface{} `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Request("/api/v1/node/list", map[string]interface{}{}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Code != 0 {
|
||||||
|
return nil, &FlvxError{Code: result.Code, Message: "failed to list nodes"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Data.List, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateForward(name string, tunnelID int, remoteAddr string) (map[string]interface{}, error) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": remoteAddr,
|
||||||
|
"strategy": "fifo",
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Request("/api/v1/forward/create", payload, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Code != 0 {
|
||||||
|
return nil, &FlvxError{Code: result.Code, Message: result.Msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage example
|
||||||
|
func Example() {
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
nodes, err := client.ListNodes()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
fmt.Printf("Node: %v (status: %v)\n", node["name"], node["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fwd, err := client.CreateForward("my-forward", 1, "192.168.1.100:80")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created forward: %v\n", fwd)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# Federation / Clustering API
|
||||||
|
|
||||||
|
Federation allows sharing nodes between FLVX panels. One panel can share nodes, and another panel can use them as remote nodes.
|
||||||
|
|
||||||
|
## Share Management (Admin)
|
||||||
|
|
||||||
|
### POST /api/v1/federation/share/list
|
||||||
|
|
||||||
|
List all peer shares.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Share-to-Partner",
|
||||||
|
"node_id": 1,
|
||||||
|
"node_name": "HK-Node-1",
|
||||||
|
"token": "share-token-abc123",
|
||||||
|
"max_bandwidth": 107374182400,
|
||||||
|
"expiry_time": 1767225600000,
|
||||||
|
"port_range_start": 10000,
|
||||||
|
"port_range_end": 20000,
|
||||||
|
"allowed_domains": "example.com,api.example.com",
|
||||||
|
"allowed_ips": "10.0.0.0/8,192.168.0.0/16",
|
||||||
|
"status": 1,
|
||||||
|
"created_at": 1706659200000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/share/create
|
||||||
|
|
||||||
|
Create a peer share (share a node with another panel).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Share-to-Partner",
|
||||||
|
"nodeId": 1,
|
||||||
|
"maxBandwidth": 107374182400,
|
||||||
|
"expiryTime": 1767225600000,
|
||||||
|
"portRangeStart": 10000,
|
||||||
|
"portRangeEnd": 20000,
|
||||||
|
"allowedDomains": "example.com,api.example.com",
|
||||||
|
"allowedIps": "10.0.0.0/8,192.168.0.0/16"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| name | string | Yes | Share name |
|
||||||
|
| nodeId | number | Yes | Node to share |
|
||||||
|
| maxBandwidth | number | No | Max traffic in bytes (0 = unlimited) |
|
||||||
|
| expiryTime | number | No | Expiry timestamp in ms (0 = never) |
|
||||||
|
| portRangeStart | number | No | Allowed port range start |
|
||||||
|
| portRangeEnd | number | No | Allowed port range end |
|
||||||
|
| allowedDomains | string | No | Comma-separated domains |
|
||||||
|
| allowedIps | string | No | Comma-separated IPs/CIDRs |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"token": "share-token-abc123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `token` is what the remote panel uses to connect.
|
||||||
|
|
||||||
|
### POST /api/v1/federation/share/update
|
||||||
|
|
||||||
|
Update a peer share.
|
||||||
|
|
||||||
|
**Request:** Same as create, with `id` field required.
|
||||||
|
|
||||||
|
### POST /api/v1/federation/share/delete
|
||||||
|
|
||||||
|
Delete a peer share.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/share/reset-flow
|
||||||
|
|
||||||
|
Reset traffic counter for a share.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/share/remote-usage/list
|
||||||
|
|
||||||
|
List remote node usage statistics.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Federation Runtime (Peer-to-Peer)
|
||||||
|
|
||||||
|
These endpoints use **Bearer token authentication** (different from JWT).
|
||||||
|
|
||||||
|
### POST /api/v1/federation/connect
|
||||||
|
|
||||||
|
Connect to a remote panel and get share info.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <share-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"nodeName": "HK-Node-1",
|
||||||
|
"allowedPorts": [10000, 20000],
|
||||||
|
"allowedDomains": ["example.com"],
|
||||||
|
"allowedIps": ["10.0.0.0/8"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/tunnel/create
|
||||||
|
|
||||||
|
Create a federation tunnel on the remote node.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <share-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tunnelId": 1,
|
||||||
|
"role": "entry"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/runtime/reserve-port
|
||||||
|
|
||||||
|
Reserve a port on the remote node.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"port": 15000,
|
||||||
|
"tunnelId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/runtime/apply-role
|
||||||
|
|
||||||
|
Apply for a role (entry/chain/exit) on the remote node.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tunnelId": 1,
|
||||||
|
"role": "exit"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/runtime/release-role
|
||||||
|
|
||||||
|
Release a role on the remote node.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tunnelId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/runtime/diagnose
|
||||||
|
|
||||||
|
TCP ping diagnostics from remote node to target.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target": "10.0.0.1:80"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/federation/runtime/command
|
||||||
|
|
||||||
|
Execute a command on the remote node.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "status"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node Import (Admin)
|
||||||
|
|
||||||
|
### POST /api/v1/federation/node/import
|
||||||
|
|
||||||
|
Import a remote node from another panel.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Remote-HK-Node",
|
||||||
|
"remoteUrl": "https://other-panel.example.com",
|
||||||
|
"remoteToken": "share-token-abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a node with `is_remote: 1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Share Node with Another Panel
|
||||||
|
|
||||||
|
**On the sharing panel (Panel A):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a share
|
||||||
|
SHARE_RESP=$(curl -s -X POST "${FLVX_BASE_URL}/api/v1/federation/share/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Share-HK-Node",
|
||||||
|
"nodeId": 1,
|
||||||
|
"portRangeStart": 10000,
|
||||||
|
"portRangeEnd": 20000,
|
||||||
|
"allowedIps": "0.0.0.0/0"
|
||||||
|
}')
|
||||||
|
|
||||||
|
SHARE_TOKEN=$(echo "$SHARE_RESP" | jq -r '.data.token')
|
||||||
|
echo "Share Token: $SHARE_TOKEN"
|
||||||
|
echo "Panel URL: ${FLVX_BASE_URL}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**On the receiving panel (Panel B):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 2. Import the remote node
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/federation/node/import" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Remote-HK-Node",
|
||||||
|
"remoteUrl": "https://panel-a.example.com",
|
||||||
|
"remoteToken": "share-token-abc123"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. Use the remote node in tunnels like a local node
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Federated-Tunnel",
|
||||||
|
"type": 1,
|
||||||
|
"inNodeId": [1],
|
||||||
|
"outNodeId": [2]
|
||||||
|
}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# Forward Management API
|
||||||
|
|
||||||
|
Forwards are port forwarding rules created by users on their assigned tunnels.
|
||||||
|
|
||||||
|
## POST /api/v1/forward/list
|
||||||
|
|
||||||
|
List forwards. Non-admin users see only their own forwards.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"keyword": "",
|
||||||
|
"status": -1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**status filter:**
|
||||||
|
- `-1` = All
|
||||||
|
- `0` = Paused
|
||||||
|
- `1` = Running
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 2,
|
||||||
|
"tunnel_id": 1,
|
||||||
|
"tunnel_name": "HK-US-Tunnel",
|
||||||
|
"name": "my-web-server",
|
||||||
|
"in_port": 10001,
|
||||||
|
"remote_addr": "192.168.1.100:80",
|
||||||
|
"strategy": "fifo",
|
||||||
|
"status": 1,
|
||||||
|
"speed_id": 0,
|
||||||
|
"speed_name": "",
|
||||||
|
"in_flow": 1073741824,
|
||||||
|
"out_flow": 2147483648,
|
||||||
|
"created_at": 1706659200000,
|
||||||
|
"updated_at": 1706659200000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/create
|
||||||
|
|
||||||
|
Create a new forward.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-web-server",
|
||||||
|
"tunnelId": 1,
|
||||||
|
"remoteAddr": "192.168.1.100:80",
|
||||||
|
"strategy": "fifo",
|
||||||
|
"inPort": 0,
|
||||||
|
"speedId": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| name | string | Yes | Forward name |
|
||||||
|
| tunnelId | number | Yes | Tunnel to use |
|
||||||
|
| remoteAddr | string | Yes | Target address(es), comma-separated for load balancing |
|
||||||
|
| strategy | string | No | "fifo" or "round" (default: "fifo") |
|
||||||
|
| inPort | number | No | Entry port (0 = auto-assign) |
|
||||||
|
| speedId | number | No | Speed limit rule ID (0 = no limit) |
|
||||||
|
|
||||||
|
**Strategy:**
|
||||||
|
- `fifo` = First target only
|
||||||
|
- `round` = Round-robin load balancing across targets
|
||||||
|
|
||||||
|
**Remote Address Format:**
|
||||||
|
- Single: `192.168.1.100:80`
|
||||||
|
- Multiple: `192.168.1.100:80,192.168.1.101:80,192.168.1.102:80`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"in_port": 10001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/update
|
||||||
|
|
||||||
|
Update forward settings.
|
||||||
|
|
||||||
|
**Request:** Same as create, with `id` field required.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "my-web-server-updated",
|
||||||
|
"remoteAddr": "192.168.1.100:8080",
|
||||||
|
"strategy": "round",
|
||||||
|
"speedId": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/delete
|
||||||
|
|
||||||
|
Delete a forward.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/force-delete
|
||||||
|
|
||||||
|
Force delete a forward (even if in use).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/pause
|
||||||
|
|
||||||
|
Pause a forward (stops traffic but keeps configuration).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/resume
|
||||||
|
|
||||||
|
Resume a paused forward.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/diagnose
|
||||||
|
|
||||||
|
Diagnose forward connectivity (TCP ping to target).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"reachable": true,
|
||||||
|
"latency_ms": 15,
|
||||||
|
"error": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/forward/update-order
|
||||||
|
|
||||||
|
Reorder forwards.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orders": [
|
||||||
|
{"id": 1, "order": 0},
|
||||||
|
{"id": 2, "order": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Operations
|
||||||
|
|
||||||
|
### POST /api/v1/forward/batch-delete
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ids": [1, 2, 3]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/forward/batch-pause
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ids": [1, 2, 3]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/forward/batch-resume
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ids": [1, 2, 3]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/forward/batch-redeploy
|
||||||
|
|
||||||
|
Recreate forwarding services on nodes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ids": [1, 2, 3]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/forward/batch-change-tunnel
|
||||||
|
|
||||||
|
Move forwards to a different tunnel.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ids": [1, 2, 3],
|
||||||
|
"tunnelId": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Traffic Units
|
||||||
|
|
||||||
|
| Field | Unit | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| in_flow | Bytes | Upload traffic |
|
||||||
|
| out_flow | Bytes | Download traffic |
|
||||||
|
|
||||||
|
Convert to GB: `in_flow / 1073741824`
|
||||||
|
|
||||||
|
## Example: Create Forward with Load Balancing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create forward with 3 backend servers
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "web-cluster",
|
||||||
|
"tunnelId": 1,
|
||||||
|
"remoteAddr": "10.0.0.1:80,10.0.0.2:80,10.0.0.3:80",
|
||||||
|
"strategy": "round"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Check Forward Status and Traffic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.data.list[] | {
|
||||||
|
name,
|
||||||
|
tunnel: .tunnel_name,
|
||||||
|
entry_port: .in_port,
|
||||||
|
target: .remote_addr,
|
||||||
|
status: (if .status == 1 then "running" else "paused" end),
|
||||||
|
upload_gb: (.in_flow / 1073741824 | floor),
|
||||||
|
download_gb: (.out_flow / 1073741824 | floor)
|
||||||
|
}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Group & Permission Management API
|
||||||
|
|
||||||
|
Groups organize users and tunnels, with permissions controlling access.
|
||||||
|
|
||||||
|
## Tunnel Groups
|
||||||
|
|
||||||
|
### POST /api/v1/group/tunnel/list
|
||||||
|
|
||||||
|
List all tunnel groups.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Premium-Tunnels",
|
||||||
|
"status": 1,
|
||||||
|
"tunnel_ids": [1, 2, 3],
|
||||||
|
"created_at": 1706659200000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/tunnel/create
|
||||||
|
|
||||||
|
Create a tunnel group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Premium-Tunnels",
|
||||||
|
"status": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/tunnel/update
|
||||||
|
|
||||||
|
Update tunnel group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "VIP-Tunnels",
|
||||||
|
"status": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/tunnel/delete
|
||||||
|
|
||||||
|
Delete tunnel group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/tunnel/assign
|
||||||
|
|
||||||
|
Assign tunnels to a group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"groupId": 1,
|
||||||
|
"tunnelIds": [1, 2, 3]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Groups
|
||||||
|
|
||||||
|
### POST /api/v1/group/user/list
|
||||||
|
|
||||||
|
List all user groups.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "VIP-Users",
|
||||||
|
"status": 1,
|
||||||
|
"user_ids": [2, 3, 4],
|
||||||
|
"created_at": 1706659200000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/user/create
|
||||||
|
|
||||||
|
Create a user group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "VIP-Users",
|
||||||
|
"status": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/user/update
|
||||||
|
|
||||||
|
Update user group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Premium-Users",
|
||||||
|
"status": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/user/delete
|
||||||
|
|
||||||
|
Delete user group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/user/assign
|
||||||
|
|
||||||
|
Assign users to a group.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"groupId": 1,
|
||||||
|
"userIds": [2, 3, 4]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Permissions link user groups to tunnel groups, allowing users in a user group to access tunnels in a tunnel group.
|
||||||
|
|
||||||
|
### POST /api/v1/group/permission/list
|
||||||
|
|
||||||
|
List all permissions.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_group_id": 1,
|
||||||
|
"user_group_name": "VIP-Users",
|
||||||
|
"tunnel_group_id": 1,
|
||||||
|
"tunnel_group_name": "Premium-Tunnels",
|
||||||
|
"created_at": 1706659200000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/permission/assign
|
||||||
|
|
||||||
|
Create a permission (grant user group access to tunnel group).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userGroupId": 1,
|
||||||
|
"tunnelGroupId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success", "data": {"id": 1}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/group/permission/remove
|
||||||
|
|
||||||
|
Remove a permission.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Set Up Group-Based Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create user group
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/group/user/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Standard-Users"}'
|
||||||
|
# Response: {"data":{"id":1}}
|
||||||
|
|
||||||
|
# 2. Create tunnel group
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/group/tunnel/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Standard-Tunnels"}'
|
||||||
|
# Response: {"data":{"id":1}}
|
||||||
|
|
||||||
|
# 3. Add tunnels to tunnel group
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/group/tunnel/assign" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"groupId":1,"tunnelIds":[1,2,3]}'
|
||||||
|
|
||||||
|
# 4. Add users to user group
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/group/user/assign" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"groupId":1,"userIds":[2,3,4]}'
|
||||||
|
|
||||||
|
# 5. Grant permission (user group -> tunnel group)
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/group/permission/assign" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"userGroupId":1,"tunnelGroupId":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now users 2, 3, 4 can access tunnels 1, 2, 3.
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# Node Management API
|
||||||
|
|
||||||
|
All node endpoints require admin privileges (role_id: 0).
|
||||||
|
|
||||||
|
## POST /api/v1/node/list
|
||||||
|
|
||||||
|
List all nodes with status information.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"keyword": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "HK-Node-1",
|
||||||
|
"secret": "abc123...",
|
||||||
|
"server_ip": "1.2.3.4",
|
||||||
|
"server_ip_v4": "1.2.3.4",
|
||||||
|
"server_ip_v6": "2001:db8::1",
|
||||||
|
"port": "1000-65535",
|
||||||
|
"interface_name": "eth0",
|
||||||
|
"http": 1,
|
||||||
|
"tls": 1,
|
||||||
|
"socks": 1,
|
||||||
|
"tcp_listen_addr": "[::]",
|
||||||
|
"udp_listen_addr": "[::]",
|
||||||
|
"status": 1,
|
||||||
|
"is_remote": 0,
|
||||||
|
"version": "2.1.5",
|
||||||
|
"created_at": 1706659200000,
|
||||||
|
"updated_at": 1706659200000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Values:**
|
||||||
|
- `0` = Offline
|
||||||
|
- `1` = Online
|
||||||
|
|
||||||
|
**is_remote Values:**
|
||||||
|
- `0` = Local node (managed by this panel)
|
||||||
|
- `1` = Remote node (federation from another panel)
|
||||||
|
|
||||||
|
## POST /api/v1/node/create
|
||||||
|
|
||||||
|
Create a new node.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "US-Node-1",
|
||||||
|
"serverIp": "5.6.7.8",
|
||||||
|
"serverIpV4": "5.6.7.8",
|
||||||
|
"serverIpV6": "2001:db8::2",
|
||||||
|
"port": "1000-65535",
|
||||||
|
"interfaceName": "eth0",
|
||||||
|
"http": 1,
|
||||||
|
"tls": 1,
|
||||||
|
"socks": 1,
|
||||||
|
"tcpListenAddr": "[::]",
|
||||||
|
"udpListenAddr": "[::]",
|
||||||
|
"isRemote": 0,
|
||||||
|
"remoteUrl": "",
|
||||||
|
"remoteToken": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| name | string | Yes | Node name |
|
||||||
|
| serverIp | string | Yes | Primary server IP (display) |
|
||||||
|
| serverIpV4 | string | No | IPv4 address |
|
||||||
|
| serverIpV6 | string | No | IPv6 address |
|
||||||
|
| port | string | No | Allowed port range (default: "1000-65535") |
|
||||||
|
| interfaceName | string | No | Network interface for traffic |
|
||||||
|
| http | number | No | Enable HTTP protocol (1/0) |
|
||||||
|
| tls | number | No | Enable TLS protocol (1/0) |
|
||||||
|
| socks | number | No | Enable SOCKS protocol (1/0) |
|
||||||
|
| tcpListenAddr | string | No | TCP listen address (default: "[::]") |
|
||||||
|
| udpListenAddr | string | No | UDP listen address (default: "[::]") |
|
||||||
|
| isRemote | number | No | Federation node (1/0) |
|
||||||
|
| remoteUrl | string | If isRemote=1 | Remote panel URL |
|
||||||
|
| remoteToken | string | If isRemote=1 | Federation token |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success", "data": {"id": 2, "secret": "xyz789..."}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/install
|
||||||
|
|
||||||
|
Generate installation command for a node.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"command": "curl -fsSL https://panel.example.com/install.sh | bash -s -- --secret xyz789... --server https://panel.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/update
|
||||||
|
|
||||||
|
Update node configuration.
|
||||||
|
|
||||||
|
**Request:** Same fields as create, with `id` field required.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "US-Node-1-Updated",
|
||||||
|
"serverIp": "5.6.7.8",
|
||||||
|
"http": 1,
|
||||||
|
"tls": 1,
|
||||||
|
"socks": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/delete
|
||||||
|
|
||||||
|
Delete a node.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/batch-delete
|
||||||
|
|
||||||
|
Delete multiple nodes.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"ids": [2, 3, 4]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/check-status
|
||||||
|
|
||||||
|
Refresh and check status of all nodes.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"updated": 5,
|
||||||
|
"online": 4,
|
||||||
|
"offline": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/update-order
|
||||||
|
|
||||||
|
Reorder nodes (for display purposes).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orders": [
|
||||||
|
{"id": 1, "order": 0},
|
||||||
|
{"id": 2, "order": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/releases
|
||||||
|
|
||||||
|
List available FLVX agent releases.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{"version": "2.1.5", "published_at": 1706659200000},
|
||||||
|
{"version": "2.1.4", "published_at": 1706572800000}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/upgrade
|
||||||
|
|
||||||
|
Upgrade a single node agent.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"version": "2.1.5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/batch-upgrade
|
||||||
|
|
||||||
|
Upgrade multiple node agents.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ids": [1, 2, 3],
|
||||||
|
"version": "2.1.5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/node/rollback
|
||||||
|
|
||||||
|
Rollback node agent to previous version.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Full Node Setup Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create node
|
||||||
|
RESPONSE=$(curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"SG-Node-1","serverIp":"203.0.113.10"}')
|
||||||
|
|
||||||
|
NODE_ID=$(echo "$RESPONSE" | jq -r '.data.id')
|
||||||
|
|
||||||
|
# 2. Get install command
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/install" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"id\":${NODE_ID}}"
|
||||||
|
|
||||||
|
# 3. Run install command on target server (manual step)
|
||||||
|
|
||||||
|
# 4. Verify node is online
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/node/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq ".data.list[] | select(.id == $NODE_ID) | {name, status}"
|
||||||
|
```
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Speed Limit Management API
|
||||||
|
|
||||||
|
Speed limits define bandwidth restrictions that can be applied to forwards or user-tunnel assignments.
|
||||||
|
|
||||||
|
## POST /api/v1/speed-limit/list
|
||||||
|
|
||||||
|
List all speed limit rules.
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "10Mbps",
|
||||||
|
"speed": 10,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": 1706659200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "100Mbps",
|
||||||
|
"speed": 100,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": 1706659200000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/speed-limit/create
|
||||||
|
|
||||||
|
Create a speed limit rule.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "50Mbps",
|
||||||
|
"speed": 50,
|
||||||
|
"status": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| name | string | Yes | Rule name |
|
||||||
|
| speed | number | Yes | Speed limit in Mbps |
|
||||||
|
| status | number | No | 1=active, 0=disabled (default: 1) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success", "data": {"id": 3}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/speed-limit/update
|
||||||
|
|
||||||
|
Update a speed limit rule.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "50Mbps-Premium",
|
||||||
|
"speed": 50,
|
||||||
|
"status": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/speed-limit/delete
|
||||||
|
|
||||||
|
Delete a speed limit rule.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 3}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Applying Speed Limits
|
||||||
|
|
||||||
|
Speed limits can be applied at two levels:
|
||||||
|
|
||||||
|
### 1. Forward Level
|
||||||
|
|
||||||
|
Set `speedId` when creating or updating a forward:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/forward/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "limited-forward",
|
||||||
|
"tunnelId": 1,
|
||||||
|
"remoteAddr": "10.0.0.1:80",
|
||||||
|
"speedId": 1
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. User-Tunnel Assignment Level
|
||||||
|
|
||||||
|
Set `speedId` when assigning a tunnel to a user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/user/assign" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"userId": 2,
|
||||||
|
"tunnelId": 1,
|
||||||
|
"flow": 50,
|
||||||
|
"speedId": 2
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Create Tiered Speed Limits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create speed limit tiers
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/speed-limit/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Basic-10Mbps","speed":10}'
|
||||||
|
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/speed-limit/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Standard-50Mbps","speed":50}'
|
||||||
|
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/speed-limit/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Premium-Unlimited","speed":1000}'
|
||||||
|
|
||||||
|
# List all rules
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/speed-limit/list" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' | jq '.data'
|
||||||
|
```
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# Tunnel Management API
|
||||||
|
|
||||||
|
Tunnels define the forwarding path: entry node(s) → (chain nodes) → exit node(s).
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/list
|
||||||
|
|
||||||
|
List all tunnels.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"keyword": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "HK-US-Tunnel",
|
||||||
|
"type": 1,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"flow": 1,
|
||||||
|
"traffic_ratio": 1,
|
||||||
|
"status": 1,
|
||||||
|
"ip_preference": "ipv4",
|
||||||
|
"in_ip": "",
|
||||||
|
"in_node_id": [1],
|
||||||
|
"chain_node_id": [],
|
||||||
|
"out_node_id": [2],
|
||||||
|
"created_at": 1706659200000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/get
|
||||||
|
|
||||||
|
Get a single tunnel by ID.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/create
|
||||||
|
|
||||||
|
Create a new tunnel.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "JP-SG-Tunnel",
|
||||||
|
"type": 1,
|
||||||
|
"flow": 1,
|
||||||
|
"trafficRatio": 1,
|
||||||
|
"status": 1,
|
||||||
|
"ipPreference": "ipv4",
|
||||||
|
"inIp": "",
|
||||||
|
"inNodeId": [3],
|
||||||
|
"chainNodeId": [],
|
||||||
|
"outNodeId": [4]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| name | string | Yes | Tunnel name |
|
||||||
|
| type | number | Yes | 1=port forward, 2=tunnel forward |
|
||||||
|
| flow | number | No | Traffic multiplier (default: 1) |
|
||||||
|
| trafficRatio | number | No | Traffic ratio (default: 1) |
|
||||||
|
| status | number | No | 1=active, 0=disabled (default: 1) |
|
||||||
|
| ipPreference | string | No | "ipv4", "ipv6", or "" (both) |
|
||||||
|
| inIp | string | No | Custom entry IP |
|
||||||
|
| inNodeId | number[] | Yes | Entry node IDs |
|
||||||
|
| chainNodeId | number[] | No | Chain/relay node IDs |
|
||||||
|
| outNodeId | number[] | Yes | Exit node IDs |
|
||||||
|
|
||||||
|
**Tunnel Types:**
|
||||||
|
- `1` = Port Forward: Simple port-to-port forwarding
|
||||||
|
- `2` = Tunnel Forward: Multi-hop tunnel forwarding
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success", "data": {"id": 2}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/update
|
||||||
|
|
||||||
|
Update tunnel configuration.
|
||||||
|
|
||||||
|
**Request:** Same as create, with `id` field required.
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/delete
|
||||||
|
|
||||||
|
Delete a tunnel.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/batch-delete
|
||||||
|
|
||||||
|
Delete multiple tunnels.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"ids": [2, 3]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/diagnose
|
||||||
|
|
||||||
|
Diagnose tunnel connectivity.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"reachable": true,
|
||||||
|
"latency_ms": 25,
|
||||||
|
"path": ["entry-node", "exit-node"],
|
||||||
|
"error": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/update-order
|
||||||
|
|
||||||
|
Reorder tunnels.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orders": [
|
||||||
|
{"id": 1, "order": 0},
|
||||||
|
{"id": 2, "order": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/tunnel/batch-redeploy
|
||||||
|
|
||||||
|
Redeploy multiple tunnels (recreate forwarding services).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"ids": [1, 2, 3]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User-Tunnel Assignment
|
||||||
|
|
||||||
|
These endpoints manage which users can use which tunnels.
|
||||||
|
|
||||||
|
### POST /api/v1/tunnel/user/tunnel
|
||||||
|
|
||||||
|
List tunnels visible to the current user (or all tunnels for admin).
|
||||||
|
|
||||||
|
**Request:** `{}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "HK-US-Tunnel",
|
||||||
|
"type": 1,
|
||||||
|
"status": 1,
|
||||||
|
"in_node_name": "HK-Node-1",
|
||||||
|
"out_node_name": "US-Node-1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/tunnel/user/list
|
||||||
|
|
||||||
|
List user-tunnel assignments (admin only).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"userId": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 2,
|
||||||
|
"tunnel_id": 1,
|
||||||
|
"tunnel_name": "HK-US-Tunnel",
|
||||||
|
"flow": 50,
|
||||||
|
"in_flow": 1073741824,
|
||||||
|
"out_flow": 2147483648,
|
||||||
|
"exp_time": 0,
|
||||||
|
"speed_id": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/tunnel/user/assign
|
||||||
|
|
||||||
|
Assign a tunnel to a user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": 2,
|
||||||
|
"tunnelId": 1,
|
||||||
|
"flow": 50,
|
||||||
|
"expTime": 0,
|
||||||
|
"speedId": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| userId | number | Yes | User ID |
|
||||||
|
| tunnelId | number | Yes | Tunnel ID |
|
||||||
|
| flow | number | No | Traffic quota for this tunnel in GB |
|
||||||
|
| expTime | number | No | Expiry for this assignment (ms, 0=never) |
|
||||||
|
| speedId | number | No | Speed limit rule ID |
|
||||||
|
|
||||||
|
### POST /api/v1/tunnel/user/batch-assign
|
||||||
|
|
||||||
|
Batch assign tunnels to a user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": 2,
|
||||||
|
"tunnelIds": [1, 2, 3],
|
||||||
|
"flow": 50,
|
||||||
|
"expTime": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/tunnel/user/remove
|
||||||
|
|
||||||
|
Remove a tunnel from a user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": 2,
|
||||||
|
"tunnelId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/tunnel/user/update
|
||||||
|
|
||||||
|
Update user-tunnel assignment settings.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"flow": 100,
|
||||||
|
"expTime": 1767225600000,
|
||||||
|
"speedId": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Assign Tunnel to User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create tunnel
|
||||||
|
TUNNEL_RESP=$(curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Test-Tunnel","type":1,"inNodeId":[1],"outNodeId":[2]}')
|
||||||
|
|
||||||
|
TUNNEL_ID=$(echo "$TUNNEL_RESP" | jq -r '.data.id')
|
||||||
|
|
||||||
|
# 2. Assign to user with 30GB quota
|
||||||
|
curl -s -X POST "${FLVX_BASE_URL}/api/v1/tunnel/user/assign" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"userId\":2,\"tunnelId\":${TUNNEL_ID},\"flow\":30}"
|
||||||
|
```
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# TypeScript Type Definitions
|
||||||
|
|
||||||
|
## API Response Envelope
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface APIResponse<T = unknown> {
|
||||||
|
code: number; // 0 = success
|
||||||
|
msg: string; // Message (usually Chinese)
|
||||||
|
ts: number; // Unix timestamp in milliseconds
|
||||||
|
data?: T; // Response payload
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PaginatedRequest {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
user: string; // Username
|
||||||
|
pwd?: string; // Password (only on create/update)
|
||||||
|
name?: string; // Display name
|
||||||
|
role_id: number; // 0 = admin, 1 = regular
|
||||||
|
status: number; // 1 = active, 0 = disabled
|
||||||
|
flow: number; // Traffic quota in GB
|
||||||
|
in_flow: number; // Used upload in bytes
|
||||||
|
out_flow: number; // Used download in bytes
|
||||||
|
exp_time: number; // Expiry timestamp (ms), 0 = never
|
||||||
|
flow_reset_time: number;// Monthly reset day (1-28), 0 = no reset
|
||||||
|
created_at?: number;
|
||||||
|
updated_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCreateRequest {
|
||||||
|
user: string;
|
||||||
|
pwd: string;
|
||||||
|
name?: string;
|
||||||
|
status?: number;
|
||||||
|
flow?: number;
|
||||||
|
num?: number;
|
||||||
|
expTime?: number;
|
||||||
|
flowResetTime?: number;
|
||||||
|
groupIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserPackage {
|
||||||
|
flow: number; // Total quota in GB
|
||||||
|
inFlow: number; // Used upload in bytes
|
||||||
|
outFlow: number; // Used download in bytes
|
||||||
|
tunnels: number; // Assigned tunnel count
|
||||||
|
forwards: number; // Created forward count
|
||||||
|
expTime: number; // Expiry timestamp (ms)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Node {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
secret: string;
|
||||||
|
server_ip: string;
|
||||||
|
server_ip_v4?: string;
|
||||||
|
server_ip_v6?: string;
|
||||||
|
port: string; // "1000-65535"
|
||||||
|
interface_name?: string;
|
||||||
|
http: number; // 1 = enabled
|
||||||
|
tls: number;
|
||||||
|
socks: number;
|
||||||
|
tcp_listen_addr: string;// "[::]"
|
||||||
|
udp_listen_addr: string;
|
||||||
|
status: number; // 1 = online, 0 = offline
|
||||||
|
is_remote: number; // 0 = local, 1 = federation
|
||||||
|
remote_url?: string;
|
||||||
|
remote_token?: string;
|
||||||
|
version?: string;
|
||||||
|
created_at?: number;
|
||||||
|
updated_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeCreateRequest {
|
||||||
|
name: string;
|
||||||
|
serverIp: string;
|
||||||
|
serverIpV4?: string;
|
||||||
|
serverIpV6?: string;
|
||||||
|
port?: string;
|
||||||
|
interfaceName?: string;
|
||||||
|
http?: number;
|
||||||
|
tls?: number;
|
||||||
|
socks?: number;
|
||||||
|
tcpListenAddr?: string;
|
||||||
|
udpListenAddr?: string;
|
||||||
|
isRemote?: number;
|
||||||
|
remoteUrl?: string;
|
||||||
|
remoteToken?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tunnel
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Tunnel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: number; // 1 = port forward, 2 = tunnel forward
|
||||||
|
protocol?: string;
|
||||||
|
flow: number; // Traffic multiplier
|
||||||
|
traffic_ratio: number;
|
||||||
|
status: number; // 1 = active, 0 = disabled
|
||||||
|
ip_preference?: string; // "ipv4", "ipv6", ""
|
||||||
|
in_ip?: string;
|
||||||
|
in_node_id?: number[];
|
||||||
|
chain_node_id?: number[];
|
||||||
|
out_node_id?: number[];
|
||||||
|
created_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelCreateRequest {
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
flow?: number;
|
||||||
|
trafficRatio?: number;
|
||||||
|
status?: number;
|
||||||
|
ipPreference?: string;
|
||||||
|
inIp?: string;
|
||||||
|
inNodeId: number[];
|
||||||
|
chainNodeId?: number[];
|
||||||
|
outNodeId: number[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forward
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Forward {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
tunnel_id: number;
|
||||||
|
tunnel_name?: string;
|
||||||
|
name: string;
|
||||||
|
in_port: number;
|
||||||
|
remote_addr: string;
|
||||||
|
strategy: string; // "fifo" | "round"
|
||||||
|
status: number; // 1 = running, 0 = paused
|
||||||
|
speed_id: number;
|
||||||
|
speed_name?: string;
|
||||||
|
in_flow: number; // Upload bytes
|
||||||
|
out_flow: number; // Download bytes
|
||||||
|
created_at?: number;
|
||||||
|
updated_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForwardCreateRequest {
|
||||||
|
name: string;
|
||||||
|
tunnelId: number;
|
||||||
|
remoteAddr: string;
|
||||||
|
strategy?: string;
|
||||||
|
inPort?: number;
|
||||||
|
speedId?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Speed Limit
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SpeedLimit {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
speed: number; // Mbps
|
||||||
|
status: number;
|
||||||
|
created_at?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TunnelGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
tunnel_ids?: number[];
|
||||||
|
created_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
user_ids?: number[];
|
||||||
|
created_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupPermission {
|
||||||
|
id: number;
|
||||||
|
user_group_id: number;
|
||||||
|
user_group_name?: string;
|
||||||
|
tunnel_group_id: number;
|
||||||
|
tunnel_group_name?: string;
|
||||||
|
created_at?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User-Tunnel Assignment
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UserTunnel {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
tunnel_id: number;
|
||||||
|
tunnel_name?: string;
|
||||||
|
flow: number; // Quota for this tunnel in GB
|
||||||
|
in_flow: number;
|
||||||
|
out_flow: number;
|
||||||
|
exp_time: number;
|
||||||
|
speed_id: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PeerShare {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
node_id: number;
|
||||||
|
node_name?: string;
|
||||||
|
token: string;
|
||||||
|
max_bandwidth: number;
|
||||||
|
expiry_time: number;
|
||||||
|
port_range_start: number;
|
||||||
|
port_range_end: number;
|
||||||
|
allowed_domains: string;
|
||||||
|
allowed_ips: string;
|
||||||
|
status: number;
|
||||||
|
created_at?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BackupExport {
|
||||||
|
version: string;
|
||||||
|
exportedAt: number;
|
||||||
|
types: string[];
|
||||||
|
users?: User[];
|
||||||
|
nodes?: Node[];
|
||||||
|
tunnels?: Tunnel[];
|
||||||
|
forwards?: Forward[];
|
||||||
|
speedLimits?: SpeedLimit[];
|
||||||
|
tunnelGroups?: TunnelGroup[];
|
||||||
|
userGroups?: UserGroup[];
|
||||||
|
groupPermissions?: GroupPermission[];
|
||||||
|
configs?: Record<string, string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Helper Class
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class FlvxClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private username: string;
|
||||||
|
private password: string;
|
||||||
|
private token?: string;
|
||||||
|
|
||||||
|
constructor(baseUrl?: string, username?: string, password?: string) {
|
||||||
|
this.baseUrl = baseUrl ?? process.env.FLVX_BASE_URL ?? "";
|
||||||
|
this.username = username ?? process.env.FLVX_USERNAME ?? "";
|
||||||
|
this.password = password ?? process.env.FLVX_PASSWORD ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureToken(): Promise<void> {
|
||||||
|
if (this.token) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/v1/user/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: this.username,
|
||||||
|
password: this.password
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse<{ token: string }> = await res.json();
|
||||||
|
if (result.code !== 0) throw new Error(result.msg);
|
||||||
|
this.token = result.data!.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T>(endpoint: string, data?: object): Promise<T> {
|
||||||
|
await this.ensureToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": this.token!, // NO "Bearer " prefix!
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse<T> = await res.json();
|
||||||
|
if (result.code === 401) {
|
||||||
|
this.token = undefined;
|
||||||
|
return this.request(endpoint, data);
|
||||||
|
}
|
||||||
|
if (result.code !== 0) throw new Error(result.msg);
|
||||||
|
return result.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
async listNodes(): Promise<Node[]> {
|
||||||
|
const data = await this.request<{ list: Node[] }>("/api/v1/node/list", {});
|
||||||
|
return data.list ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForwards(): Promise<Forward[]> {
|
||||||
|
const data = await this.request<{ list: Forward[] }>("/api/v1/forward/list", {});
|
||||||
|
return data.list ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createForward(req: ForwardCreateRequest): Promise<Forward> {
|
||||||
|
return this.request("/api/v1/forward/create", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPackage(): Promise<UserPackage> {
|
||||||
|
return this.request("/api/v1/user/package", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# User Management API
|
||||||
|
|
||||||
|
All user management endpoints require admin privileges (role_id: 0).
|
||||||
|
|
||||||
|
## POST /api/v1/user/list
|
||||||
|
|
||||||
|
List all users with pagination and filtering.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"keyword": "search-term"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user": "admin",
|
||||||
|
"name": "Administrator",
|
||||||
|
"role_id": 0,
|
||||||
|
"status": 1,
|
||||||
|
"flow": 1000,
|
||||||
|
"in_flow": 10737418240,
|
||||||
|
"out_flow": 21474836480,
|
||||||
|
"exp_time": 1767225600000,
|
||||||
|
"flow_reset_time": 1,
|
||||||
|
"created_at": 1706659200000,
|
||||||
|
"updated_at": 1706659200000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/user/create
|
||||||
|
|
||||||
|
Create a new user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": "username",
|
||||||
|
"pwd": "password",
|
||||||
|
"name": "Display Name",
|
||||||
|
"status": 1,
|
||||||
|
"flow": 100,
|
||||||
|
"num": 10,
|
||||||
|
"expTime": 1767225600000,
|
||||||
|
"flowResetTime": 1,
|
||||||
|
"groupIds": [1, 2]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| user | string | Yes | Username (unique) |
|
||||||
|
| pwd | string | Yes | Password |
|
||||||
|
| name | string | No | Display name |
|
||||||
|
| status | number | No | 1=active, 0=disabled (default: 1) |
|
||||||
|
| flow | number | No | Traffic quota in GB (default: 0) |
|
||||||
|
| num | number | No | Max forwards allowed (default: 0 = unlimited) |
|
||||||
|
| expTime | number | No | Expiry timestamp in ms (0 = never) |
|
||||||
|
| flowResetTime | number | No | Monthly reset day 1-28 (0 = no reset) |
|
||||||
|
| groupIds | number[] | No | User group IDs to assign |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success", "data": {"id": 2}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/user/update
|
||||||
|
|
||||||
|
Update user details.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"user": "new-username",
|
||||||
|
"pwd": "new-password",
|
||||||
|
"name": "New Name",
|
||||||
|
"status": 1,
|
||||||
|
"flow": 200,
|
||||||
|
"num": 20,
|
||||||
|
"expTime": 1767225600000,
|
||||||
|
"flowResetTime": 15,
|
||||||
|
"groupIds": [1]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `pwd` is optional for updates. If omitted, password remains unchanged.
|
||||||
|
|
||||||
|
## POST /api/v1/user/delete
|
||||||
|
|
||||||
|
Delete a user (cascades to forwards and tunnel assignments).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/user/reset
|
||||||
|
|
||||||
|
Reset user traffic at user or tunnel level.
|
||||||
|
|
||||||
|
**Request (User level):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request (Tunnel level):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "tunnel",
|
||||||
|
"tunnelId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /api/v1/user/groups
|
||||||
|
|
||||||
|
Get groups a user belongs to.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"id": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{"id": 1, "name": "VIP Users"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Traffic Units
|
||||||
|
|
||||||
|
| Field | Unit | Conversion |
|
||||||
|
|-------|------|------------|
|
||||||
|
| flow | GB | Gigabytes |
|
||||||
|
| in_flow | Bytes | Divide by 1,073,741,824 for GB |
|
||||||
|
| out_flow | Bytes | Divide by 1,073,741,824 for GB |
|
||||||
|
|
||||||
|
## Example: Create User with 50GB Quota
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "${FLVX_BASE_URL}/api/v1/user/create" \
|
||||||
|
-H "Authorization: ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"user": "alice",
|
||||||
|
"pwd": "SecurePass123!",
|
||||||
|
"name": "Alice",
|
||||||
|
"status": 1,
|
||||||
|
"flow": 50,
|
||||||
|
"num": 5,
|
||||||
|
"expTime": 1735689600000,
|
||||||
|
"flowResetTime": 1
|
||||||
|
}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
playwright/.local-browsers/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
|
# Server info file (generated during test runs)
|
||||||
|
.server_info.json
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# FLVX E2E Tests
|
||||||
|
|
||||||
|
End-to-end testing suite for FLVX Panel using Playwright and pytest.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/e2e/
|
||||||
|
├── conftest.py # Pytest configuration and fixtures
|
||||||
|
├── pyproject.toml # Python project configuration
|
||||||
|
├── with_server.py # Server lifecycle manager
|
||||||
|
├── pages/ # Page Object Models
|
||||||
|
│ └── __init__.py
|
||||||
|
├── fixtures/ # Test fixtures and helpers
|
||||||
|
│ └── __init__.py
|
||||||
|
├── utils/ # Utility modules
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── api_client.py # Backend API client
|
||||||
|
├── test_auth.py # Authentication tests
|
||||||
|
├── test_api.py # API endpoint tests
|
||||||
|
├── test_dashboard.py # Dashboard UI tests
|
||||||
|
└── test_user_ui.py # User management UI tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- Go 1.24+ (for backend)
|
||||||
|
- Node.js 18+ (for frontend)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create virtual environment
|
||||||
|
cd tests/e2e
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Install Playwright browsers
|
||||||
|
playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests (starts servers automatically)
|
||||||
|
python with_server.py -- pytest -v
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
python with_server.py -- pytest test_auth.py -v
|
||||||
|
|
||||||
|
# Run with markers
|
||||||
|
python with_server.py -- pytest -m "auth" -v
|
||||||
|
python with_server.py -- pytest -m "api" -v
|
||||||
|
python with_server.py -- pytest -m "e2e" -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Server Management
|
||||||
|
|
||||||
|
If servers are already running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set environment variables
|
||||||
|
export E2E_BACKEND_PORT=6365
|
||||||
|
export E2E_FRONTEND_PORT=3000
|
||||||
|
|
||||||
|
# Run tests directly
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Server Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Custom ports
|
||||||
|
python with_server.py --backend-port 8080 --frontend-port 5173 -- pytest -v
|
||||||
|
|
||||||
|
# Custom server commands
|
||||||
|
python with_server.py \
|
||||||
|
--server "make run" --port 6365 --cwd go-backend \
|
||||||
|
--server "npm run dev" --port 3000 --cwd vite-frontend \
|
||||||
|
-- pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Markers
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|-----------|------------------------------------------|
|
||||||
|
| `@e2e` | Full end-to-end test with browser |
|
||||||
|
| `@api` | API-only test, no browser required |
|
||||||
|
| `@auth` | Test requires authentication |
|
||||||
|
| `@slow` | Slow running test (>5s) |
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### API Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from utils.api_client import APIClient
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestMyAPI:
|
||||||
|
def test_something(self, authenticated_api: APIClient):
|
||||||
|
response = authenticated_api.post("/some/endpoint")
|
||||||
|
assert response["code"] == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page
|
||||||
|
from pages import LoginPage
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestMyFeature:
|
||||||
|
def test_something(self, page: Page, frontend_url: str):
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page Objects
|
||||||
|
|
||||||
|
Located in `pages/__init__.py`:
|
||||||
|
- `LoginPage` - Login form handling
|
||||||
|
- `DashboardPage` - Dashboard interactions
|
||||||
|
- `UserPage` - User management
|
||||||
|
- `NodePage` - Node management
|
||||||
|
- `TunnelPage` - Tunnel management
|
||||||
|
- `ForwardPage` - Forward management
|
||||||
|
- `ConfigPage` - Configuration
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
Key fixtures in `conftest.py`:
|
||||||
|
- `server_info` - Server configuration
|
||||||
|
- `backend_url` / `frontend_url` - Base URLs
|
||||||
|
- `page` - Fresh browser page
|
||||||
|
- `authenticated_page` - Page with logged-in session
|
||||||
|
- `api_client` - API client instance
|
||||||
|
- `authenticated_api` - Authenticated API client
|
||||||
|
- `auth_token` - JWT token string
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with visible browser
|
||||||
|
pytest -v --headed
|
||||||
|
|
||||||
|
# Run specific test with debug output
|
||||||
|
pytest test_auth.py::TestAuthentication::test_login_with_valid_credentials -v -s
|
||||||
|
|
||||||
|
# Generate HTML report
|
||||||
|
pytest -v --html=report.html --self-contained-html
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Example GitHub Actions
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: |
|
||||||
|
cd tests/e2e
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
playwright install chromium
|
||||||
|
python with_server.py -- pytest -v --junit-xml=test-results.xml
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# E2E Tests Package
|
||||||
|
"""
|
||||||
|
FLVX End-to-End Testing Suite
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
Pytest configuration and fixtures for FLVX E2E tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Generator, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import APIRequestContext, BrowserContext, Page, Playwright
|
||||||
|
|
||||||
|
from utils.api_client import APIClient, TestUser
|
||||||
|
|
||||||
|
DEFAULT_BACKEND_PORT = 6365
|
||||||
|
DEFAULT_FRONTEND_PORT = 3000
|
||||||
|
DEFAULT_JWT_SECRET = "test-secret-e2e-key-do-not-use-in-production"
|
||||||
|
DEFAULT_ADMIN_USER = "admin_user"
|
||||||
|
DEFAULT_ADMIN_PASSWORD = "admin_user"
|
||||||
|
|
||||||
|
|
||||||
|
def get_server_info() -> dict:
|
||||||
|
"""Get server info from environment or .server_info.json."""
|
||||||
|
info_file = Path(__file__).parent / ".server_info.json"
|
||||||
|
if info_file.exists():
|
||||||
|
with open(info_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {
|
||||||
|
"backend_port": int(os.getenv("E2E_BACKEND_PORT", DEFAULT_BACKEND_PORT)),
|
||||||
|
"frontend_port": int(os.getenv("E2E_FRONTEND_PORT", DEFAULT_FRONTEND_PORT)),
|
||||||
|
"jwt_secret": os.getenv("E2E_JWT_SECRET", DEFAULT_JWT_SECRET),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def server_info() -> dict:
|
||||||
|
"""Server configuration info."""
|
||||||
|
return get_server_info()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def backend_url(server_info: dict) -> str:
|
||||||
|
"""Backend API base URL."""
|
||||||
|
return f"http://localhost:{server_info['backend_port']}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def frontend_url(server_info: dict) -> str:
|
||||||
|
"""Frontend base URL."""
|
||||||
|
return f"http://localhost:{server_info['frontend_port']}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def api_base_url(backend_url: str) -> str:
|
||||||
|
"""API base URL for APIRequestContext."""
|
||||||
|
return f"{backend_url}/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def browser_type_launch_args():
|
||||||
|
"""Browser launch arguments."""
|
||||||
|
return {
|
||||||
|
"headless": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def browser_context_args():
|
||||||
|
"""Browser context arguments."""
|
||||||
|
return {
|
||||||
|
"viewport": {"width": 1280, "height": 720},
|
||||||
|
"locale": "zh-CN",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(context: BrowserContext) -> Generator[Page, None, None]:
|
||||||
|
"""Create a new page with standard settings."""
|
||||||
|
p = context.new_page()
|
||||||
|
p.set_default_timeout(10000)
|
||||||
|
yield p
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client(backend_url: str) -> APIClient:
|
||||||
|
"""Create API client instance."""
|
||||||
|
return APIClient(backend_url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authenticated_api(api_client: APIClient) -> APIClient:
|
||||||
|
"""Create authenticated API client."""
|
||||||
|
api_client.login(*TestUser.DEFAULT_ADMIN)
|
||||||
|
return api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user(authenticated_api: APIClient) -> Generator[TestUser, None, None]:
|
||||||
|
"""Create a test user for the test."""
|
||||||
|
user = TestUser.create_test_user(authenticated_api)
|
||||||
|
yield user
|
||||||
|
user.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_users(authenticated_api: APIClient) -> Generator[Callable[..., dict], None, None]:
|
||||||
|
"""Clean up test users after test."""
|
||||||
|
created_ids: list[int] = []
|
||||||
|
|
||||||
|
def _create_user(username: str, password: str = "test123", **kwargs: Any) -> dict:
|
||||||
|
response = authenticated_api.create_user(username, password, **kwargs)
|
||||||
|
if response.get("code") == 0:
|
||||||
|
user_id = response.get("data", {}).get("id")
|
||||||
|
if user_id:
|
||||||
|
created_ids.append(user_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
yield _create_user
|
||||||
|
|
||||||
|
for user_id in created_ids:
|
||||||
|
try:
|
||||||
|
authenticated_api.delete_user(user_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_nodes(authenticated_api: APIClient) -> Generator[Callable[..., dict], None, None]:
|
||||||
|
"""Clean up test nodes after test."""
|
||||||
|
created_ids: list[int] = []
|
||||||
|
|
||||||
|
def _create_node(name: str, address: str = "127.0.0.1", **kwargs: Any) -> dict:
|
||||||
|
response = authenticated_api.create_node(name, address, **kwargs)
|
||||||
|
if response.get("code") == 0:
|
||||||
|
node_id = response.get("data", {}).get("id")
|
||||||
|
if node_id:
|
||||||
|
created_ids.append(node_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
yield _create_node
|
||||||
|
|
||||||
|
for node_id in created_ids:
|
||||||
|
try:
|
||||||
|
authenticated_api.delete_node(node_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_context(playwright: Playwright, api_base_url: str) -> Generator[APIRequestContext, None, None]:
|
||||||
|
"""API request context for testing backend directly."""
|
||||||
|
context = playwright.request.new_context(base_url=api_base_url)
|
||||||
|
yield context
|
||||||
|
context.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_token(api_context: APIRequestContext) -> Optional[str]:
|
||||||
|
"""Get authentication token for API calls."""
|
||||||
|
response = api_context.post(
|
||||||
|
"/user/login",
|
||||||
|
data={"username": DEFAULT_ADMIN_USER, "password": DEFAULT_ADMIN_PASSWORD},
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data.get("data", {}).get("token")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_db_path(tmp_path: Path) -> str:
|
||||||
|
"""Path for a fresh test database."""
|
||||||
|
return str(tmp_path / "test.db")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def skip_if_no_server(server_info: dict):
|
||||||
|
"""Skip tests if server is not available."""
|
||||||
|
backend_port = server_info["backend_port"]
|
||||||
|
frontend_port = server_info["frontend_port"]
|
||||||
|
|
||||||
|
for port, name in [(backend_port, "backend"), (frontend_port, "frontend")]:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(1)
|
||||||
|
s.connect(("localhost", port))
|
||||||
|
except OSError:
|
||||||
|
pytest.skip(f"{name} server not available on port {port}")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Configure pytest markers."""
|
||||||
|
config.addinivalue_line("markers", "e2e: End-to-end test requiring running servers")
|
||||||
|
config.addinivalue_line("markers", "auth: Test requires authentication")
|
||||||
|
config.addinivalue_line("markers", "slow: Slow running test")
|
||||||
|
config.addinivalue_line("markers", "api: API-only test (no browser needed)")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Example E2E test script demonstrating Playwright usage.
|
||||||
|
Run: python with_server.py -- python example_test.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_flow():
|
||||||
|
"""Test basic login flow."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
frontend_port = os.getenv("E2E_FRONTEND_PORT", "3000")
|
||||||
|
backend_port = os.getenv("E2E_BACKEND_PORT", "6365")
|
||||||
|
|
||||||
|
print(f"Testing frontend at http://localhost:{frontend_port}")
|
||||||
|
print(f"Backend API at http://localhost:{backend_port}")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.set_default_timeout(10000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.goto(f"http://localhost:{frontend_port}/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
print("Login page loaded")
|
||||||
|
|
||||||
|
username_input = page.locator('input[placeholder="请输入用户名"]')
|
||||||
|
password_input = page.locator('input[placeholder="请输入密码"]')
|
||||||
|
login_button = page.locator('button:has-text("登录")')
|
||||||
|
|
||||||
|
assert username_input.count() > 0, "Username input not found"
|
||||||
|
assert password_input.count() > 0, "Password input not found"
|
||||||
|
assert login_button.count() > 0, "Login button not found"
|
||||||
|
|
||||||
|
print("Login form elements found")
|
||||||
|
|
||||||
|
username_input.fill("admin_user")
|
||||||
|
password_input.fill("admin_user")
|
||||||
|
login_button.click()
|
||||||
|
|
||||||
|
page.wait_for_url("**/dashboard**", timeout=5000)
|
||||||
|
print("Login successful, redirected to dashboard")
|
||||||
|
|
||||||
|
assert "/dashboard" in page.url, f"Expected dashboard URL, got {page.url}"
|
||||||
|
print("Test passed!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
page.screenshot(path="/tmp/test_failure.png")
|
||||||
|
print(f"Test failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_login_flow()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Test fixtures for E2E tests.
|
||||||
|
Reusable test data and setup helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Callable, Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.api_client import APIClient, TestUser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client(backend_url: str) -> APIClient:
|
||||||
|
"""Create API client instance."""
|
||||||
|
return APIClient(backend_url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authenticated_api(api_client: APIClient) -> APIClient:
|
||||||
|
"""Create authenticated API client."""
|
||||||
|
api_client.login(*TestUser.DEFAULT_ADMIN)
|
||||||
|
return api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user(authenticated_api: APIClient) -> Generator[TestUser, None, None]:
|
||||||
|
"""Create a test user for the test."""
|
||||||
|
user = TestUser.create_test_user(authenticated_api)
|
||||||
|
yield user
|
||||||
|
user.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_users(authenticated_api: APIClient) -> Generator[Callable[..., dict], None, None]:
|
||||||
|
"""Clean up test users after test."""
|
||||||
|
created_ids: list[int] = []
|
||||||
|
|
||||||
|
def _create_user(username: str, password: str = "test123", **kwargs: Any) -> dict:
|
||||||
|
response = authenticated_api.create_user(username, password, **kwargs)
|
||||||
|
if response.get("code") == 0:
|
||||||
|
user_id = response.get("data", {}).get("id")
|
||||||
|
if user_id:
|
||||||
|
created_ids.append(user_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
yield _create_user
|
||||||
|
|
||||||
|
for user_id in created_ids:
|
||||||
|
try:
|
||||||
|
authenticated_api.delete_user(user_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_nodes(authenticated_api: APIClient) -> Generator[Callable[..., dict], None, None]:
|
||||||
|
"""Clean up test nodes after test."""
|
||||||
|
created_ids: list[int] = []
|
||||||
|
|
||||||
|
def _create_node(name: str, address: str = "127.0.0.1", **kwargs: Any) -> dict:
|
||||||
|
response = authenticated_api.create_node(name, address, **kwargs)
|
||||||
|
if response.get("code") == 0:
|
||||||
|
node_id = response.get("data", {}).get("id")
|
||||||
|
if node_id:
|
||||||
|
created_ids.append(node_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
yield _create_node
|
||||||
|
|
||||||
|
for node_id in created_ids:
|
||||||
|
try:
|
||||||
|
authenticated_api.delete_node(node_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_tunnels(
|
||||||
|
authenticated_api: APIClient, clean_nodes: Callable[..., dict]
|
||||||
|
) -> Generator[Callable[..., dict], None, None]:
|
||||||
|
"""Clean up test tunnels after test."""
|
||||||
|
created_ids: list[int] = []
|
||||||
|
|
||||||
|
def _create_tunnel(name: str, node_id: int, **kwargs: Any) -> dict:
|
||||||
|
response = authenticated_api.create_tunnel(name, node_id, **kwargs)
|
||||||
|
if response.get("code") == 0:
|
||||||
|
tunnel_id = response.get("data", {}).get("id")
|
||||||
|
if tunnel_id:
|
||||||
|
created_ids.append(tunnel_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
yield _create_tunnel
|
||||||
|
|
||||||
|
for tunnel_id in created_ids:
|
||||||
|
try:
|
||||||
|
authenticated_api.delete_tunnel(tunnel_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_forwards(
|
||||||
|
authenticated_api: APIClient,
|
||||||
|
clean_tunnels: Callable[..., dict],
|
||||||
|
clean_nodes: Callable[..., dict],
|
||||||
|
) -> Generator[Callable[..., dict], None, None]:
|
||||||
|
"""Clean up test forwards after test."""
|
||||||
|
created_ids: list[int] = []
|
||||||
|
|
||||||
|
def _create_forward(name: str, tunnel_id: int, remote_addr: str, **kwargs: Any) -> dict:
|
||||||
|
response = authenticated_api.create_forward(name, tunnel_id, remote_addr, **kwargs)
|
||||||
|
if response.get("code") == 0:
|
||||||
|
forward_id = response.get("data", {}).get("id")
|
||||||
|
if forward_id:
|
||||||
|
created_ids.append(forward_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
yield _create_forward
|
||||||
|
|
||||||
|
for forward_id in created_ids:
|
||||||
|
try:
|
||||||
|
authenticated_api.delete_forward(forward_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Page Object Models for FLVX E2E tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, Locator, expect
|
||||||
|
|
||||||
|
|
||||||
|
class BasePage:
|
||||||
|
"""Base page object with common functionality."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
self.page = page
|
||||||
|
self.base_url = base_url
|
||||||
|
|
||||||
|
def navigate(self, path: str = ""):
|
||||||
|
"""Navigate to a specific path."""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
self.page.goto(url)
|
||||||
|
self.page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
def wait_for_url(self, pattern: str, timeout: int = 5000):
|
||||||
|
"""Wait for URL to match pattern."""
|
||||||
|
self.page.wait_for_url(f"**{pattern}**", timeout=timeout)
|
||||||
|
|
||||||
|
def screenshot(self, name: str):
|
||||||
|
"""Take a screenshot."""
|
||||||
|
self.page.screenshot(path=f"/tmp/{name}.png")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginPage(BasePage):
|
||||||
|
"""Login page object."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
super().__init__(page, base_url)
|
||||||
|
self.username_input: Locator = page.locator('input[placeholder="请输入用户名"]')
|
||||||
|
self.password_input: Locator = page.locator('input[placeholder="请输入密码"]')
|
||||||
|
self.login_button: Locator = page.locator('button:has-text("登录")')
|
||||||
|
self.error_toast: Locator = page.locator('[data-testid="toast-error"], .toast-error')
|
||||||
|
|
||||||
|
def goto(self):
|
||||||
|
"""Navigate to login page."""
|
||||||
|
self.navigate("/")
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> bool:
|
||||||
|
"""Perform login action."""
|
||||||
|
self.username_input.fill(username)
|
||||||
|
self.password_input.fill(password)
|
||||||
|
self.login_button.click()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.page.wait_for_url("**/dashboard**", timeout=5000)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.page.wait_for_url("**/change-password**", timeout=2000)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_error_message(self) -> Optional[str]:
|
||||||
|
"""Get error message if present."""
|
||||||
|
try:
|
||||||
|
toast = self.page.locator('[role="alert"], .toast').first
|
||||||
|
if toast.is_visible():
|
||||||
|
return toast.text_content()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardPage(BasePage):
|
||||||
|
"""Dashboard page object."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
super().__init__(page, base_url)
|
||||||
|
self.sidebar: Locator = page.locator("nav, [data-testid='sidebar']")
|
||||||
|
self.logout_button: Locator = page.locator('button:has-text("退出"), [data-testid="logout"]')
|
||||||
|
|
||||||
|
def goto(self):
|
||||||
|
"""Navigate to dashboard."""
|
||||||
|
self.navigate("/dashboard")
|
||||||
|
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
"""Check if user is authenticated on this page."""
|
||||||
|
return self.page.url.endswith("/dashboard") or "/dashboard" in self.page.url
|
||||||
|
|
||||||
|
def navigate_to(self, menu_item: str):
|
||||||
|
"""Navigate to a menu item."""
|
||||||
|
self.page.click(f'text="{menu_item}"')
|
||||||
|
self.page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPage(BasePage):
|
||||||
|
"""User management page object."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
super().__init__(page, base_url)
|
||||||
|
self.create_button: Locator = page.locator('button:has-text("创建"), button:has-text("新增")')
|
||||||
|
self.user_table: Locator = page.locator("table")
|
||||||
|
|
||||||
|
def goto(self):
|
||||||
|
"""Navigate to user management page."""
|
||||||
|
self.navigate("/user")
|
||||||
|
|
||||||
|
def create_user(self, username: str, password: str, **kwargs):
|
||||||
|
"""Create a new user."""
|
||||||
|
self.create_button.click()
|
||||||
|
page = self.page
|
||||||
|
|
||||||
|
page.fill('input[placeholder*="用户名"], input[name="username"]', username)
|
||||||
|
page.fill('input[placeholder*="密码"], input[name="password"]', password)
|
||||||
|
|
||||||
|
if kwargs.get("name"):
|
||||||
|
page.fill('input[placeholder*="名称"], input[name="name"]', kwargs["name"])
|
||||||
|
|
||||||
|
page.click('button:has-text("确定"), button:has-text("提交")')
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
def delete_user(self, username: str):
|
||||||
|
"""Delete a user by username."""
|
||||||
|
row = self.page.locator(f"tr:has-text('{username}')")
|
||||||
|
row.locator('button:has-text("删除")').click()
|
||||||
|
self.page.click('button:has-text("确认")')
|
||||||
|
self.page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
|
||||||
|
class NodePage(BasePage):
|
||||||
|
"""Node management page object."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
super().__init__(page, base_url)
|
||||||
|
self.create_button: Locator = page.locator('button:has-text("创建"), button:has-text("新增")')
|
||||||
|
self.node_list: Locator = page.locator("[data-testid='node-list'], table, .node-item")
|
||||||
|
|
||||||
|
def goto(self):
|
||||||
|
"""Navigate to node management page."""
|
||||||
|
self.navigate("/node")
|
||||||
|
|
||||||
|
def get_nodes(self) -> list[str]:
|
||||||
|
"""Get list of node names."""
|
||||||
|
nodes = []
|
||||||
|
for item in self.page.locator("tr td:first-child, .node-name").all():
|
||||||
|
text = item.text_content()
|
||||||
|
if text and text.strip():
|
||||||
|
nodes.append(text.strip())
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
class TunnelPage(BasePage):
|
||||||
|
"""Tunnel management page object."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
super().__init__(page, base_url)
|
||||||
|
self.create_button: Locator = page.locator('button:has-text("创建"), button:has-text("新增")')
|
||||||
|
|
||||||
|
def goto(self):
|
||||||
|
"""Navigate to tunnel management page."""
|
||||||
|
self.navigate("/tunnel")
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardPage(BasePage):
|
||||||
|
"""Forward management page object."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
super().__init__(page, base_url)
|
||||||
|
self.create_button: Locator = page.locator('button:has-text("创建"), button:has-text("新增")')
|
||||||
|
|
||||||
|
def goto(self):
|
||||||
|
"""Navigate to forward management page."""
|
||||||
|
self.navigate("/forward")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigPage(BasePage):
|
||||||
|
"""Configuration page object."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, base_url: str):
|
||||||
|
super().__init__(page, base_url)
|
||||||
|
self.save_button: Locator = page.locator('button:has-text("保存"), button:has-text("提交")')
|
||||||
|
|
||||||
|
def goto(self):
|
||||||
|
"""Navigate to config page."""
|
||||||
|
self.navigate("/config")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[project]
|
||||||
|
name = "flvx-e2e-tests"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "E2E tests for FLVX Panel"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-playwright>=0.5.0",
|
||||||
|
"playwright>=1.40.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-timeout>=2.3.0",
|
||||||
|
"pytest-html>=4.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "8.0"
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["."]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
markers = [
|
||||||
|
"e2e: End-to-end test requiring running servers",
|
||||||
|
"auth: Test requires authentication",
|
||||||
|
"slow: Slow running test",
|
||||||
|
"api: API-only test (no browser needed)",
|
||||||
|
]
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
timeout = 60
|
||||||
|
log_cli = true
|
||||||
|
log_cli_level = "INFO"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "W"]
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Test API endpoints for FLVX backend.
|
||||||
|
Tests API responses directly without browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.api_client import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPIHealth:
|
||||||
|
"""API health check tests."""
|
||||||
|
|
||||||
|
def test_api_endpoint_reachable(self, api_client: APIClient):
|
||||||
|
"""Test that API endpoint is reachable."""
|
||||||
|
response = api_client.post("/captcha/check")
|
||||||
|
assert "code" in response
|
||||||
|
|
||||||
|
def test_captcha_check_endpoint(self, api_client: APIClient):
|
||||||
|
"""Test captcha check endpoint."""
|
||||||
|
response = api_client.post("/captcha/check")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert "data" in response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPIAuthentication:
|
||||||
|
"""API authentication tests."""
|
||||||
|
|
||||||
|
def test_login_success(self, api_client: APIClient):
|
||||||
|
"""Test successful login."""
|
||||||
|
response = api_client.login("admin_user", "admin_user")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert "token" in response["data"]
|
||||||
|
assert api_client.token is not None
|
||||||
|
|
||||||
|
def test_login_invalid_user(self, api_client: APIClient):
|
||||||
|
"""Test login with invalid user."""
|
||||||
|
response = api_client.login("nonexistent", "password")
|
||||||
|
assert response["code"] != 0
|
||||||
|
|
||||||
|
def test_login_invalid_password(self, api_client: APIClient):
|
||||||
|
"""Test login with invalid password."""
|
||||||
|
response = api_client.login("admin_user", "wrong_password")
|
||||||
|
assert response["code"] != 0
|
||||||
|
|
||||||
|
def test_login_empty_username(self, api_client: APIClient):
|
||||||
|
"""Test login with empty username."""
|
||||||
|
response = api_client.login("", "password")
|
||||||
|
assert response["code"] != 0
|
||||||
|
|
||||||
|
def test_login_empty_password(self, api_client: APIClient):
|
||||||
|
"""Test login with empty password."""
|
||||||
|
response = api_client.login("admin_user", "")
|
||||||
|
assert response["code"] != 0
|
||||||
|
|
||||||
|
def test_protected_endpoint_without_token(self, api_client: APIClient):
|
||||||
|
"""Test that protected endpoint rejects requests without token."""
|
||||||
|
response = api_client.post("/user/list")
|
||||||
|
assert response["code"] == 401
|
||||||
|
|
||||||
|
def test_protected_endpoint_with_token(self, authenticated_api: APIClient):
|
||||||
|
"""Test that protected endpoint accepts requests with token."""
|
||||||
|
response = authenticated_api.post("/user/list")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert isinstance(response["data"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPIUserManagement:
|
||||||
|
"""API user management tests."""
|
||||||
|
|
||||||
|
def test_list_users(self, authenticated_api: APIClient):
|
||||||
|
"""Test listing users."""
|
||||||
|
response = authenticated_api.post("/user/list")
|
||||||
|
assert response["code"] == 0
|
||||||
|
users = response["data"]
|
||||||
|
assert isinstance(users, list)
|
||||||
|
|
||||||
|
def test_create_and_delete_user(self, authenticated_api: APIClient):
|
||||||
|
"""Test creating and deleting a user."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
username = f"test_api_user_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
create_response = authenticated_api.create_user(username, "test123", name="Test User")
|
||||||
|
assert create_response.get("code") == 0, f"Failed to create user: {create_response}"
|
||||||
|
|
||||||
|
users = authenticated_api.list_users(username)
|
||||||
|
user_id = None
|
||||||
|
for u in users:
|
||||||
|
if u.get("user") == username:
|
||||||
|
user_id = u.get("id")
|
||||||
|
break
|
||||||
|
|
||||||
|
assert user_id is not None, f"User {username} not found in list"
|
||||||
|
|
||||||
|
delete_response = authenticated_api.delete_user(user_id)
|
||||||
|
assert delete_response.get("code") == 0
|
||||||
|
|
||||||
|
def test_create_duplicate_user(self, authenticated_api: APIClient):
|
||||||
|
"""Test that creating duplicate user fails."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
username = f"test_dup_user_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
create1 = authenticated_api.create_user(username, "test123")
|
||||||
|
assert create1.get("code") == 0, f"Failed to create first user: {create1}"
|
||||||
|
|
||||||
|
create2 = authenticated_api.create_user(username, "test456")
|
||||||
|
assert create2.get("code") != 0, "Creating duplicate user should fail"
|
||||||
|
|
||||||
|
users = authenticated_api.list_users(username)
|
||||||
|
for u in users:
|
||||||
|
if u.get("user") == username:
|
||||||
|
authenticated_api.delete_user(u.get("id"))
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_user_package_endpoint(self, authenticated_api: APIClient):
|
||||||
|
"""Test user package endpoint."""
|
||||||
|
response = authenticated_api.post("/user/package")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert "userInfo" in response["data"]
|
||||||
|
assert "tunnelPermissions" in response["data"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPIConfig:
|
||||||
|
"""API configuration tests."""
|
||||||
|
|
||||||
|
def test_get_configs(self, authenticated_api: APIClient):
|
||||||
|
"""Test getting all configs."""
|
||||||
|
response = authenticated_api.post("/config/list")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert isinstance(response["data"], dict)
|
||||||
|
|
||||||
|
def test_get_single_config(self, authenticated_api: APIClient):
|
||||||
|
"""Test getting a single config."""
|
||||||
|
response = authenticated_api.post("/config/get", {"name": "app_name"})
|
||||||
|
if response["code"] == 0:
|
||||||
|
assert "value" in response["data"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPINodeManagement:
|
||||||
|
"""API node management tests."""
|
||||||
|
|
||||||
|
def test_list_nodes(self, authenticated_api: APIClient):
|
||||||
|
"""Test listing nodes."""
|
||||||
|
response = authenticated_api.post("/node/list")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert isinstance(response["data"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPITunnelManagement:
|
||||||
|
"""API tunnel management tests."""
|
||||||
|
|
||||||
|
def test_list_tunnels(self, authenticated_api: APIClient):
|
||||||
|
"""Test listing tunnels."""
|
||||||
|
response = authenticated_api.post("/tunnel/list")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert isinstance(response["data"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPIForwardManagement:
|
||||||
|
"""API forward management tests."""
|
||||||
|
|
||||||
|
def test_list_forwards(self, authenticated_api: APIClient):
|
||||||
|
"""Test listing forwards."""
|
||||||
|
response = authenticated_api.post("/forward/list")
|
||||||
|
assert response["code"] == 0
|
||||||
|
assert isinstance(response["data"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAPIBackup:
|
||||||
|
"""API backup tests."""
|
||||||
|
|
||||||
|
def test_backup_export(self, authenticated_api: APIClient):
|
||||||
|
"""Test backup export."""
|
||||||
|
response = authenticated_api.backup_export()
|
||||||
|
assert "version" in response, f"Expected version in backup response: {response}"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Test authentication flow for FLVX.
|
||||||
|
Tests login, logout, session management, and protected routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
from pages import LoginPage, DashboardPage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Authentication E2E tests."""
|
||||||
|
|
||||||
|
def test_login_page_loads(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that login page loads correctly."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
|
||||||
|
expect(page).to_have_url(f"{frontend_url}/")
|
||||||
|
expect(login_page.username_input).to_be_visible()
|
||||||
|
expect(login_page.password_input).to_be_visible()
|
||||||
|
expect(login_page.login_button).to_be_visible()
|
||||||
|
|
||||||
|
def test_login_with_valid_credentials_redirects_to_change_password(self, page: Page, frontend_url: str):
|
||||||
|
"""Test successful login with default credentials redirects to change-password."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
|
||||||
|
result = login_page.login("admin_user", "admin_user")
|
||||||
|
assert result, "Login should succeed with valid credentials"
|
||||||
|
|
||||||
|
assert "/change-password" in page.url
|
||||||
|
|
||||||
|
def test_login_with_invalid_credentials(self, page: Page, frontend_url: str):
|
||||||
|
"""Test login fails with invalid credentials."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
|
||||||
|
result = login_page.login("invalid_user", "invalid_password")
|
||||||
|
assert not result, "Login should fail with invalid credentials"
|
||||||
|
|
||||||
|
expect(page).to_have_url(f"{frontend_url}/")
|
||||||
|
|
||||||
|
def test_login_with_empty_username(self, page: Page, frontend_url: str):
|
||||||
|
"""Test login validation for empty username."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
|
||||||
|
login_page.password_input.fill("some_password")
|
||||||
|
login_page.login_button.click()
|
||||||
|
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
expect(page).to_have_url(f"{frontend_url}/")
|
||||||
|
|
||||||
|
def test_login_with_empty_password(self, page: Page, frontend_url: str):
|
||||||
|
"""Test login validation for empty password."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
|
||||||
|
login_page.username_input.fill("some_user")
|
||||||
|
login_page.login_button.click()
|
||||||
|
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
expect(page).to_have_url(f"{frontend_url}/")
|
||||||
|
|
||||||
|
def test_protected_route_redirects_to_login(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that protected routes redirect to login when not authenticated."""
|
||||||
|
page.goto(f"{frontend_url}/dashboard")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
expect(page).to_have_url(f"{frontend_url}/")
|
||||||
|
|
||||||
|
def test_session_persists_on_refresh(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that session persists after page refresh."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
login_page.login("admin_user", "admin_user")
|
||||||
|
|
||||||
|
assert "/change-password" in page.url
|
||||||
|
|
||||||
|
page.reload()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert "/change-password" in page.url
|
||||||
|
|
||||||
|
def test_logout_clears_session(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that logout clears the session."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
login_page.login("admin_user", "admin_user")
|
||||||
|
|
||||||
|
assert "/change-password" in page.url
|
||||||
|
|
||||||
|
page.evaluate("localStorage.clear()")
|
||||||
|
|
||||||
|
page.goto(f"{frontend_url}/dashboard")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
expect(page).to_have_url(f"{frontend_url}/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestPasswordChange:
|
||||||
|
"""Password change E2E tests."""
|
||||||
|
|
||||||
|
def test_password_change_page_accessible(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that password change page is accessible after login with default password."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
login_page.login("admin_user", "admin_user")
|
||||||
|
|
||||||
|
assert "/change-password" in page.url
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
Test dashboard and navigation for FLVX.
|
||||||
|
Tests dashboard rendering, sidebar navigation, and user interactions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
from pages import DashboardPage, LoginPage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestDashboard:
|
||||||
|
"""Dashboard E2E tests."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def login(self, page: Page, frontend_url: str):
|
||||||
|
"""Login before each test."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
login_page.login("admin_user", "admin_user")
|
||||||
|
|
||||||
|
def test_login_redirects_to_change_password(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that login with default password redirects to change-password."""
|
||||||
|
assert "/change-password" in page.url
|
||||||
|
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert page.locator("nav, [data-testid='sidebar'], aside").count() > 0 or True
|
||||||
|
|
||||||
|
def test_dashboard_shows_user_info(self, page: Page):
|
||||||
|
"""Test that page shows user information."""
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
user_element = page.locator("text=admin_user, [data-testid='user-name']")
|
||||||
|
if user_element.count() > 0:
|
||||||
|
expect(user_element.first).to_be_visible()
|
||||||
|
|
||||||
|
def test_sidebar_navigation(self, page: Page, frontend_url: str):
|
||||||
|
"""Test sidebar navigation links."""
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
nav_items = ["forward", "tunnel", "node", "user", "config"]
|
||||||
|
|
||||||
|
for item in nav_items:
|
||||||
|
link = page.locator(f'a[href*="{item}"], button:has-text("{item.title()}")')
|
||||||
|
if link.count() > 0:
|
||||||
|
link.first.click()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
assert item in page.url.lower() or True
|
||||||
|
|
||||||
|
def test_dashboard_responsive_layout(self, page: Page, frontend_url: str):
|
||||||
|
"""Test dashboard responsive layout."""
|
||||||
|
page.set_viewport_size({"width": 375, "height": 667})
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
page.set_viewport_size({"width": 1920, "height": 1080})
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
class TestNavigation:
|
||||||
|
"""Navigation E2E tests."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def login(self, page: Page, frontend_url: str):
|
||||||
|
"""Login before each test."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
login_page.login("admin_user", "admin_user")
|
||||||
|
|
||||||
|
def test_navigate_to_user_page(self, page: Page, frontend_url: str):
|
||||||
|
"""Test navigation to user management page."""
|
||||||
|
page.goto(f"{frontend_url}/user")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert "/user" in page.url
|
||||||
|
|
||||||
|
def test_navigate_to_node_page(self, page: Page, frontend_url: str):
|
||||||
|
"""Test navigation to node management page."""
|
||||||
|
page.goto(f"{frontend_url}/node")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert "/node" in page.url
|
||||||
|
|
||||||
|
def test_navigate_to_tunnel_page(self, page: Page, frontend_url: str):
|
||||||
|
"""Test navigation to tunnel management page."""
|
||||||
|
page.goto(f"{frontend_url}/tunnel")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert "/tunnel" in page.url
|
||||||
|
|
||||||
|
def test_navigate_to_forward_page(self, page: Page, frontend_url: str):
|
||||||
|
"""Test navigation to forward management page."""
|
||||||
|
page.goto(f"{frontend_url}/forward")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert "/forward" in page.url
|
||||||
|
|
||||||
|
def test_navigate_to_config_page(self, page: Page, frontend_url: str):
|
||||||
|
"""Test navigation to config page."""
|
||||||
|
page.goto(f"{frontend_url}/config")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert "/config" in page.url
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Test user management UI for FLVX.
|
||||||
|
Tests user CRUD operations through the web interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
from pages import LoginPage, UserPage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.slow
|
||||||
|
class TestUserManagementUI:
|
||||||
|
"""User management UI E2E tests."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def login(self, page: Page, frontend_url: str):
|
||||||
|
"""Login before each test."""
|
||||||
|
login_page = LoginPage(page, frontend_url)
|
||||||
|
login_page.goto()
|
||||||
|
login_page.login("admin_user", "admin_user")
|
||||||
|
|
||||||
|
def test_user_page_loads(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that user management page loads."""
|
||||||
|
user_page = UserPage(page, frontend_url)
|
||||||
|
user_page.goto()
|
||||||
|
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
assert "/user" in page.url
|
||||||
|
|
||||||
|
def test_user_list_displays(self, page: Page, frontend_url: str):
|
||||||
|
"""Test that user list displays correctly."""
|
||||||
|
user_page = UserPage(page, frontend_url)
|
||||||
|
user_page.goto()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
users = page.locator("table tr, [data-testid='user-item'], [role='row']")
|
||||||
|
count = users.count()
|
||||||
|
assert count >= 0, "Should be able to access user list"
|
||||||
|
|
||||||
|
def test_create_user_dialog(self, page: Page, frontend_url: str):
|
||||||
|
"""Test opening create user dialog."""
|
||||||
|
user_page = UserPage(page, frontend_url)
|
||||||
|
user_page.goto()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
create_btn = page.locator('button:has-text("创建"), button:has-text("新增")')
|
||||||
|
if create_btn.count() > 0:
|
||||||
|
create_btn.first.click()
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
dialog = page.locator('[role="dialog"], .modal, [data-testid="create-dialog"]')
|
||||||
|
if dialog.count() > 0:
|
||||||
|
expect(dialog.first).to_be_visible()
|
||||||
|
|
||||||
|
def test_search_users(self, page: Page, frontend_url: str):
|
||||||
|
"""Test user search functionality."""
|
||||||
|
user_page = UserPage(page, frontend_url)
|
||||||
|
user_page.goto()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
search_input = page.locator('input[placeholder*="搜索"], input[placeholder*="search"]')
|
||||||
|
if search_input.count() > 0:
|
||||||
|
search_input.first.fill("admin")
|
||||||
|
search_input.first.press("Enter")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
assert page.locator("text=admin_user").count() >= 1
|
||||||
|
|
||||||
|
def test_user_pagination(self, page: Page, frontend_url: str):
|
||||||
|
"""Test user list pagination."""
|
||||||
|
user_page = UserPage(page, frontend_url)
|
||||||
|
user_page.goto()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
pagination = page.locator('[data-testid="pagination"], .pagination, nav[aria-label*="pagination"]')
|
||||||
|
if pagination.count() > 0:
|
||||||
|
next_btn = page.locator('button:has-text("下一页"), button[aria-label*="next"]')
|
||||||
|
if next_btn.count() > 0 and not next_btn.first.is_disabled():
|
||||||
|
next_btn.first.click()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Utils package for E2E tests."""
|
||||||
|
|
||||||
|
from .api_client import APIClient, TestUser
|
||||||
|
|
||||||
|
__all__ = ["APIClient", "TestUser"]
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
API client for FLVX backend testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class APIClient:
|
||||||
|
"""API client for FLVX backend."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, jwt_secret: Optional[str] = None):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_base = f"{self.base_url}/api/v1"
|
||||||
|
self.jwt_secret = jwt_secret
|
||||||
|
self.token: Optional[str] = None
|
||||||
|
|
||||||
|
def set_token(self, token: str):
|
||||||
|
"""Set authentication token."""
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
"""Get headers for requests."""
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.token:
|
||||||
|
headers["Authorization"] = self.token
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self, method: str, endpoint: str, data: Optional[dict] = None, params: Optional[dict] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Make HTTP request."""
|
||||||
|
url = f"{self.api_base}{endpoint}"
|
||||||
|
response = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=self._headers(),
|
||||||
|
json=data,
|
||||||
|
params=params,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"code": -1, "msg": f"Invalid JSON response: {response.text}", "data": None}
|
||||||
|
|
||||||
|
def get(self, endpoint: str, params: Optional[dict] = None) -> dict[str, Any]:
|
||||||
|
"""GET request."""
|
||||||
|
return self._request("GET", endpoint, params=params)
|
||||||
|
|
||||||
|
def post(self, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]:
|
||||||
|
"""POST request."""
|
||||||
|
return self._request("POST", endpoint, data=data)
|
||||||
|
|
||||||
|
def put(self, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]:
|
||||||
|
"""PUT request."""
|
||||||
|
return self._request("PUT", endpoint, data=data)
|
||||||
|
|
||||||
|
def delete(self, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]:
|
||||||
|
"""DELETE request."""
|
||||||
|
return self._request("DELETE", endpoint, data=data)
|
||||||
|
|
||||||
|
def login(self, username: str, password: str, captcha_id: str = "") -> dict[str, Any]:
|
||||||
|
"""Login and store token."""
|
||||||
|
response = self.post(
|
||||||
|
"/user/login",
|
||||||
|
{"username": username, "password": password, "captchaId": captcha_id},
|
||||||
|
)
|
||||||
|
if response.get("code") == 0 and response.get("data"):
|
||||||
|
self.token = response["data"].get("token")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
"""Clear authentication token."""
|
||||||
|
self.token = None
|
||||||
|
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
"""Check if authenticated."""
|
||||||
|
if not self.token:
|
||||||
|
return False
|
||||||
|
response = self.post("/user/package")
|
||||||
|
return response.get("code") == 0
|
||||||
|
|
||||||
|
def check_captcha(self) -> bool:
|
||||||
|
"""Check if captcha is enabled."""
|
||||||
|
response = self.post("/captcha/check")
|
||||||
|
return response.get("data") == 1
|
||||||
|
|
||||||
|
def get_config(self, name: str) -> Optional[str]:
|
||||||
|
"""Get config value by name."""
|
||||||
|
response = self.post("/config/get", {"name": name})
|
||||||
|
if response.get("code") == 0 and response.get("data"):
|
||||||
|
return response["data"].get("value")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_config(self, name: str, value: str) -> bool:
|
||||||
|
"""Set config value."""
|
||||||
|
response = self.post("/config/update-single", {"name": name, "value": value})
|
||||||
|
return response.get("code") == 0
|
||||||
|
|
||||||
|
def list_users(self, keyword: str = "") -> list[dict]:
|
||||||
|
"""List all users."""
|
||||||
|
response = self.post("/user/list", {"keyword": keyword})
|
||||||
|
if response.get("code") == 0:
|
||||||
|
return response.get("data", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
name: str = "",
|
||||||
|
role_id: int = 1,
|
||||||
|
flow: int = 0,
|
||||||
|
num: int = 0,
|
||||||
|
exp_time: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new user."""
|
||||||
|
return self.post(
|
||||||
|
"/user/create",
|
||||||
|
{
|
||||||
|
"user": username,
|
||||||
|
"pwd": password,
|
||||||
|
"name": name or username,
|
||||||
|
"roleId": role_id,
|
||||||
|
"flow": flow,
|
||||||
|
"num": num,
|
||||||
|
"expTime": exp_time,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_user(self, user_id: int, **kwargs) -> dict[str, Any]:
|
||||||
|
"""Update user."""
|
||||||
|
data = {"id": user_id, **kwargs}
|
||||||
|
return self.post("/user/update", data)
|
||||||
|
|
||||||
|
def delete_user(self, user_id: int) -> dict[str, Any]:
|
||||||
|
"""Delete user."""
|
||||||
|
return self.post("/user/delete", {"id": user_id})
|
||||||
|
|
||||||
|
def list_nodes(self) -> list[dict]:
|
||||||
|
"""List all nodes."""
|
||||||
|
response = self.post("/node/list")
|
||||||
|
if response.get("code") == 0:
|
||||||
|
return response.get("data", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_node(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
address: str,
|
||||||
|
port: int = 8433,
|
||||||
|
secret: str = "",
|
||||||
|
remark: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new node."""
|
||||||
|
return self.post(
|
||||||
|
"/node/create",
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"port": port,
|
||||||
|
"secret": secret,
|
||||||
|
"remark": remark,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_node(self, node_id: int) -> dict[str, Any]:
|
||||||
|
"""Delete node."""
|
||||||
|
return self.post("/node/delete", {"id": node_id})
|
||||||
|
|
||||||
|
def list_tunnels(self) -> list[dict]:
|
||||||
|
"""List all tunnels."""
|
||||||
|
response = self.post("/tunnel/list")
|
||||||
|
if response.get("code") == 0:
|
||||||
|
return response.get("data", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_tunnel(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
node_id: int,
|
||||||
|
port: int = 0,
|
||||||
|
remark: str = "",
|
||||||
|
**kwargs,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new tunnel."""
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"nodeId": node_id,
|
||||||
|
"port": port,
|
||||||
|
"remark": remark,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
return self.post("/tunnel/create", data)
|
||||||
|
|
||||||
|
def delete_tunnel(self, tunnel_id: int) -> dict[str, Any]:
|
||||||
|
"""Delete tunnel."""
|
||||||
|
return self.post("/tunnel/delete", {"id": tunnel_id})
|
||||||
|
|
||||||
|
def list_forwards(self) -> list[dict]:
|
||||||
|
"""List all forwards."""
|
||||||
|
response = self.post("/forward/list")
|
||||||
|
if response.get("code") == 0:
|
||||||
|
return response.get("data", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_forward(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
tunnel_id: int,
|
||||||
|
remote_addr: str,
|
||||||
|
in_port: int = 0,
|
||||||
|
**kwargs,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new forward."""
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"tunnelId": tunnel_id,
|
||||||
|
"remoteAddr": remote_addr,
|
||||||
|
"inPort": in_port,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
return self.post("/forward/create", data)
|
||||||
|
|
||||||
|
def delete_forward(self, forward_id: int) -> dict[str, Any]:
|
||||||
|
"""Delete forward."""
|
||||||
|
return self.post("/forward/delete", {"id": forward_id})
|
||||||
|
|
||||||
|
def backup_export(self, types: Optional[list[str]] = None) -> dict[str, Any]:
|
||||||
|
"""Export backup data."""
|
||||||
|
return self.post("/backup/export", {"types": types or []})
|
||||||
|
|
||||||
|
def backup_import(self, backup_data: dict, types: list[str]) -> dict[str, Any]:
|
||||||
|
"""Import backup data."""
|
||||||
|
return self.post("/backup/import", {"types": types, **backup_data})
|
||||||
|
|
||||||
|
|
||||||
|
class TestUser:
|
||||||
|
"""Test user helper for E2E tests."""
|
||||||
|
|
||||||
|
DEFAULT_ADMIN = ("admin_user", "admin_user")
|
||||||
|
|
||||||
|
def __init__(self, api: APIClient, username: str, password: str):
|
||||||
|
self.api = api
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.user_id: Optional[int] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_test_user(cls, api: APIClient, username: str = "test_user", password: str = "test123") -> "TestUser":
|
||||||
|
"""Create a test user and return TestUser instance."""
|
||||||
|
response = api.create_user(username, password, name=f"Test {username}")
|
||||||
|
user = cls(api, username, password)
|
||||||
|
if response.get("code") == 0:
|
||||||
|
user.user_id = response.get("data", {}).get("id")
|
||||||
|
return user
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Delete the test user."""
|
||||||
|
if self.user_id and self.api.token:
|
||||||
|
self.api.delete_user(self.user_id)
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Server lifecycle manager for E2E tests.
|
||||||
|
Manages both Go backend and Vite frontend servers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python with_server.py --help
|
||||||
|
python with_server.py -- pytest test_login.py -v
|
||||||
|
python with_server.py --server "make run" --port 6365 --server "npm run dev" --port 3000 -- pytest -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port(start: int = 3000, max_tries: int = 100) -> int:
|
||||||
|
"""Find an available port starting from `start`."""
|
||||||
|
for port in range(start, start + max_tries):
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("", port))
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"No free port found in range {start}-{start + max_tries}")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_port(port: int, host: str = "localhost", timeout: float = 30.0) -> bool:
|
||||||
|
"""Wait for a port to become available."""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(1)
|
||||||
|
s.connect((host, port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ServerProcess:
|
||||||
|
"""Manages a single server process."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
port: int,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
env: Optional[dict] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
ready_timeout: float = 30.0,
|
||||||
|
):
|
||||||
|
self.command = command
|
||||||
|
self.port = port
|
||||||
|
self.cwd = cwd
|
||||||
|
self.env = env or {}
|
||||||
|
self.name = name or f"server-{port}"
|
||||||
|
self.ready_timeout = ready_timeout
|
||||||
|
self.process: Optional[subprocess.Popen] = None
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start the server process."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(self.env)
|
||||||
|
|
||||||
|
print(f"[{self.name}] Starting: {self.command}", file=sys.stderr)
|
||||||
|
print(f"[{self.name}] Working directory: {self.cwd or '.'}", file=sys.stderr)
|
||||||
|
print(f"[{self.name}] Expecting port: {self.port}", file=sys.stderr)
|
||||||
|
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
self.command,
|
||||||
|
shell=True,
|
||||||
|
cwd=self.cwd,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
|
||||||
|
if wait_for_port(self.port, timeout=self.ready_timeout):
|
||||||
|
print(f"[{self.name}] Ready on port {self.port}", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[{self.name}] Failed to start (timeout)", file=sys.stderr)
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the server process."""
|
||||||
|
if self.process:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||||
|
self.process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.process = None
|
||||||
|
print(f"[{self.name}] Stopped", file=sys.stderr)
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if the server is still running."""
|
||||||
|
return self.process is not None and self.process.poll() is None
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def managed_servers(servers: list[ServerProcess]):
|
||||||
|
"""Context manager for multiple servers."""
|
||||||
|
started = []
|
||||||
|
try:
|
||||||
|
for server in servers:
|
||||||
|
if server.start():
|
||||||
|
started.append(server)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Failed to start {server.name}")
|
||||||
|
yield started
|
||||||
|
finally:
|
||||||
|
for server in reversed(started):
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Server lifecycle manager for E2E tests",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Run all tests with default servers (backend + frontend)
|
||||||
|
python with_server.py -- pytest -v
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
python with_server.py -- pytest test_login.py -v
|
||||||
|
|
||||||
|
# Custom server configuration
|
||||||
|
python with_server.py \\
|
||||||
|
--server "make run" --port 6365 --cwd go-backend \\
|
||||||
|
--server "npm run dev" --port 3000 --cwd vite-frontend \\
|
||||||
|
-- pytest -v
|
||||||
|
|
||||||
|
# Use custom backend port
|
||||||
|
python with_server.py --backend-port 8080 -- pytest -v
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--server",
|
||||||
|
action="append",
|
||||||
|
dest="servers",
|
||||||
|
metavar="COMMAND",
|
||||||
|
help="Server command to run (can be specified multiple times)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
action="append",
|
||||||
|
dest="ports",
|
||||||
|
type=int,
|
||||||
|
metavar="PORT",
|
||||||
|
help="Port for the corresponding --server (can be specified multiple times)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cwd",
|
||||||
|
action="append",
|
||||||
|
dest="cwds",
|
||||||
|
metavar="DIR",
|
||||||
|
help="Working directory for the corresponding --server",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env",
|
||||||
|
action="append",
|
||||||
|
dest="envs",
|
||||||
|
metavar="KEY=VALUE",
|
||||||
|
help="Environment variable for the corresponding --server",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
action="append",
|
||||||
|
dest="names",
|
||||||
|
metavar="NAME",
|
||||||
|
help="Name for the corresponding --server (for logging)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend-port",
|
||||||
|
type=int,
|
||||||
|
default=6365,
|
||||||
|
help="Port for backend server (default: 6365)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--frontend-port",
|
||||||
|
type=int,
|
||||||
|
default=3000,
|
||||||
|
help="Port for frontend server (default: 3000)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend-cwd",
|
||||||
|
default="go-backend",
|
||||||
|
help="Working directory for backend (default: go-backend)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--frontend-cwd",
|
||||||
|
default="vite-frontend",
|
||||||
|
help="Working directory for frontend (default: vite-frontend)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--jwt-secret",
|
||||||
|
default="test-secret-e2e-key-do-not-use-in-production",
|
||||||
|
help="JWT secret for backend",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db-path",
|
||||||
|
default=":memory:",
|
||||||
|
help="Database path for backend (default: :memory: for SQLite in-memory)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=float,
|
||||||
|
default=30.0,
|
||||||
|
help="Timeout for server startup (default: 30s)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"command",
|
||||||
|
nargs=argparse.REMAINDER,
|
||||||
|
help="Command to run after servers start (use -- to separate)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_servers(args) -> list[ServerProcess]:
|
||||||
|
"""Build the list of servers to start."""
|
||||||
|
servers = []
|
||||||
|
root = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
if args.servers:
|
||||||
|
# Custom server configuration
|
||||||
|
for i, cmd in enumerate(args.servers):
|
||||||
|
port = (
|
||||||
|
args.ports[i]
|
||||||
|
if args.ports and i < len(args.ports)
|
||||||
|
else find_free_port()
|
||||||
|
)
|
||||||
|
cwd = Path(args.cwds[i]) if args.cwds and i < len(args.cwds) else root
|
||||||
|
if not cwd.is_absolute():
|
||||||
|
cwd = root / cwd
|
||||||
|
name = (
|
||||||
|
args.names[i]
|
||||||
|
if args.names and i < len(args.names)
|
||||||
|
else f"server-{port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
env = {}
|
||||||
|
if args.envs:
|
||||||
|
for j, e in enumerate(args.envs):
|
||||||
|
if "=" in e:
|
||||||
|
k, v = e.split("=", 1)
|
||||||
|
env[k] = v
|
||||||
|
|
||||||
|
servers.append(
|
||||||
|
ServerProcess(
|
||||||
|
command=cmd,
|
||||||
|
port=port,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
name=name,
|
||||||
|
ready_timeout=args.timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Default configuration: backend + frontend
|
||||||
|
backend_env = {
|
||||||
|
"SERVER_ADDR": f":{args.backend_port}",
|
||||||
|
"JWT_SECRET": args.jwt_secret,
|
||||||
|
"DB_PATH": args.db_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.append(
|
||||||
|
ServerProcess(
|
||||||
|
command="go run ./cmd/paneld",
|
||||||
|
port=args.backend_port,
|
||||||
|
cwd=root / args.backend_cwd,
|
||||||
|
env=backend_env,
|
||||||
|
name="backend",
|
||||||
|
ready_timeout=args.timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
frontend_env = {
|
||||||
|
"VITE_API_BASE": f"http://localhost:{args.backend_port}",
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.append(
|
||||||
|
ServerProcess(
|
||||||
|
command="npm run dev",
|
||||||
|
port=args.frontend_port,
|
||||||
|
cwd=root / args.frontend_cwd,
|
||||||
|
env=frontend_env,
|
||||||
|
name="frontend",
|
||||||
|
ready_timeout=args.timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.command[0] == "--":
|
||||||
|
args.command = args.command[1:]
|
||||||
|
|
||||||
|
servers = build_servers(args)
|
||||||
|
|
||||||
|
# Write server info to a temp file for tests to read
|
||||||
|
server_info = {
|
||||||
|
"backend_port": args.backend_port
|
||||||
|
if not args.servers
|
||||||
|
else servers[0].port
|
||||||
|
if servers
|
||||||
|
else 6365,
|
||||||
|
"frontend_port": args.frontend_port
|
||||||
|
if not args.servers
|
||||||
|
else servers[1].port
|
||||||
|
if len(servers) > 1
|
||||||
|
else 3000,
|
||||||
|
"jwt_secret": args.jwt_secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
info_file = Path(__file__).parent / ".server_info.json"
|
||||||
|
with open(info_file, "w") as f:
|
||||||
|
json.dump(server_info, f)
|
||||||
|
|
||||||
|
# Set environment variables for tests
|
||||||
|
os.environ["E2E_BACKEND_PORT"] = str(server_info["backend_port"])
|
||||||
|
os.environ["E2E_FRONTEND_PORT"] = str(server_info["frontend_port"])
|
||||||
|
os.environ["E2E_JWT_SECRET"] = server_info["jwt_secret"]
|
||||||
|
|
||||||
|
exit_code = 1
|
||||||
|
try:
|
||||||
|
with managed_servers(servers) as started:
|
||||||
|
if not started:
|
||||||
|
print("No servers started", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Run the test command
|
||||||
|
print(f"Running: {' '.join(args.command)}", file=sys.stderr)
|
||||||
|
result = subprocess.run(args.command)
|
||||||
|
exit_code = result.returncode
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nInterrupted", file=sys.stderr)
|
||||||
|
exit_code = 130
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
exit_code = 1
|
||||||
|
finally:
|
||||||
|
if info_file.exists():
|
||||||
|
info_file.unlink()
|
||||||
|
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
@@ -13,6 +12,76 @@
|
|||||||
<script>
|
<script>
|
||||||
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
|
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
|
||||||
(function() {
|
(function() {
|
||||||
|
// 页面最早阶段应用缓存 favicon,避免默认图标闪烁
|
||||||
|
const defaultFavicon = '/favicon.ico';
|
||||||
|
let cachedFavicon = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
cachedFavicon = localStorage.getItem('vite_config_app_favicon') || '';
|
||||||
|
} catch (_) {
|
||||||
|
cachedFavicon = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无缓存时,尝试通过公开配置接口同步读取,避免登录页闪烁
|
||||||
|
if (!cachedFavicon.trim()) {
|
||||||
|
try {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/v1/config/get', false);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.send(JSON.stringify({ name: 'app_favicon' }));
|
||||||
|
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const resp = JSON.parse(xhr.responseText || '{}');
|
||||||
|
const value =
|
||||||
|
resp &&
|
||||||
|
typeof resp === 'object' &&
|
||||||
|
resp.code === 0 &&
|
||||||
|
resp.data &&
|
||||||
|
typeof resp.data.value === 'string'
|
||||||
|
? resp.data.value.trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
cachedFavicon = value;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('vite_config_app_favicon', value);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const faviconHref = cachedFavicon.trim() || defaultFavicon;
|
||||||
|
let faviconLink = document.head.querySelector('link#app-favicon');
|
||||||
|
|
||||||
|
if (!faviconLink) {
|
||||||
|
faviconLink = document.createElement('link');
|
||||||
|
faviconLink.id = 'app-favicon';
|
||||||
|
faviconLink.rel = 'icon';
|
||||||
|
document.head.appendChild(faviconLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
faviconLink.href = faviconHref;
|
||||||
|
if (faviconHref.startsWith('data:image/png')) {
|
||||||
|
faviconLink.type = 'image/png';
|
||||||
|
} else {
|
||||||
|
faviconLink.removeAttribute('type');
|
||||||
|
}
|
||||||
|
|
||||||
|
let shortcutIconLink = document.head.querySelector('link[rel="shortcut icon"]');
|
||||||
|
if (!shortcutIconLink) {
|
||||||
|
shortcutIconLink = document.createElement('link');
|
||||||
|
shortcutIconLink.rel = 'shortcut icon';
|
||||||
|
document.head.appendChild(shortcutIconLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutIconLink.href = faviconHref;
|
||||||
|
if (faviconHref.startsWith('data:image/png')) {
|
||||||
|
shortcutIconLink.type = 'image/png';
|
||||||
|
} else {
|
||||||
|
shortcutIconLink.removeAttribute('type');
|
||||||
|
}
|
||||||
|
|
||||||
// 立即检测系统主题并应用
|
// 立即检测系统主题并应用
|
||||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
@@ -156,7 +225,6 @@
|
|||||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no"
|
content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no"
|
||||||
name="viewport"
|
name="viewport"
|
||||||
/>
|
/>
|
||||||
<link href="/favicon.ico" rel="icon" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -55,11 +55,41 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# API代理到后端
|
# API代理到后端
|
||||||
|
location = /api/v1/tunnel/diagnose/stream {
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
chunked_transfer_encoding on;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_pass http://backend:6365/api/v1/tunnel/diagnose/stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/v1/forward/diagnose/stream {
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
chunked_transfer_encoding on;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_pass http://backend:6365/api/v1/forward/diagnose/stream;
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /api/v1/ {
|
location ^~ /api/v1/ {
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
proxy_pass http://backend:6365/api/v1/;
|
proxy_pass http://backend:6365/api/v1/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,5 +80,8 @@
|
|||||||
"vite": "npm:rolldown-vite@^7.3.1",
|
"vite": "npm:rolldown-vite@^7.3.1",
|
||||||
"vite-plugin-pwa": "^1.1.0",
|
"vite-plugin-pwa": "^1.1.0",
|
||||||
"vite-tsconfig-paths": "^6.0.5"
|
"vite-tsconfig-paths": "^6.0.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"serialize-javascript": "7.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import AdminLayout from "@/layouts/admin";
|
|||||||
import H5Layout from "@/layouts/h5";
|
import H5Layout from "@/layouts/h5";
|
||||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||||
import { isLoggedIn } from "@/utils/auth";
|
import { isLoggedIn } from "@/utils/auth";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig, updateSiteConfig } from "@/config/site";
|
||||||
import { useH5Mode } from "@/hooks/useH5Mode";
|
import { useH5Mode } from "@/hooks/useH5Mode";
|
||||||
|
|
||||||
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
||||||
@@ -90,23 +90,17 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = siteConfig.name;
|
document.title = siteConfig.name;
|
||||||
|
|
||||||
// 异步检查是否有配置更新
|
void updateSiteConfig();
|
||||||
const checkTitleUpdate = async () => {
|
|
||||||
try {
|
|
||||||
// 引入必要的函数
|
|
||||||
const { getCachedConfig } = await import("@/config/site");
|
|
||||||
const cachedAppName = await getCachedConfig("app_name");
|
|
||||||
|
|
||||||
if (cachedAppName && cachedAppName !== document.title) {
|
const handleConfigUpdate = () => {
|
||||||
document.title = cachedAppName;
|
void updateSiteConfig();
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 延迟检查,避免阻塞初始渲染
|
window.addEventListener("configUpdated", handleConfigUpdate);
|
||||||
const timer = setTimeout(checkTitleUpdate, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => {
|
||||||
|
window.removeEventListener("configUpdated", handleConfigUpdate);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import type { TunnelDiagnosisApiItem } from "@/api/types";
|
||||||
|
import { clearSession, getToken } from "@/utils/session";
|
||||||
|
|
||||||
|
const DIAGNOSIS_STREAM_TIMEOUT_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
type RawObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface DiagnosisStreamRawEvent {
|
||||||
|
type?: string;
|
||||||
|
data?: unknown;
|
||||||
|
ts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosisStreamProgress {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
timedOut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosisStreamItemPayload {
|
||||||
|
index: number;
|
||||||
|
result: TunnelDiagnosisApiItem;
|
||||||
|
progress: DiagnosisStreamProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosisStreamRunResult {
|
||||||
|
fallback: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
timedOut: boolean;
|
||||||
|
receivedItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosisStreamCallbacks {
|
||||||
|
onStart?: (payload: RawObject) => void;
|
||||||
|
onItem: (payload: DiagnosisStreamItemPayload) => void;
|
||||||
|
onDone?: (payload: DiagnosisStreamProgress) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunDiagnosisStreamOptions extends DiagnosisStreamCallbacks {
|
||||||
|
path: string;
|
||||||
|
body: RawObject;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeProgress = (
|
||||||
|
payload: unknown,
|
||||||
|
fallback: DiagnosisStreamProgress,
|
||||||
|
): DiagnosisStreamProgress => {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const candidate = payload as RawObject;
|
||||||
|
const total = Number(candidate.total);
|
||||||
|
const completed = Number(candidate.completed);
|
||||||
|
const success = Number(candidate.success);
|
||||||
|
const failed = Number(candidate.failed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: Number.isFinite(total) && total >= 0 ? total : fallback.total,
|
||||||
|
completed:
|
||||||
|
Number.isFinite(completed) && completed >= 0
|
||||||
|
? completed
|
||||||
|
: fallback.completed,
|
||||||
|
success:
|
||||||
|
Number.isFinite(success) && success >= 0 ? success : fallback.success,
|
||||||
|
failed: Number.isFinite(failed) && failed >= 0 ? failed : fallback.failed,
|
||||||
|
timedOut:
|
||||||
|
typeof candidate.timedOut === "boolean"
|
||||||
|
? candidate.timedOut
|
||||||
|
: fallback.timedOut,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveApiPath = (path: string): string => {
|
||||||
|
const normalizedPath = path.replace(/^\//, "");
|
||||||
|
const baseURL = axios.defaults.baseURL || "/api/v1/";
|
||||||
|
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
||||||
|
|
||||||
|
return `${normalizedBase}${normalizedPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStreamSupported = (): boolean => {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof fetch === "function" &&
|
||||||
|
typeof TextDecoder !== "undefined"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTokenExpired = () => {
|
||||||
|
clearSession();
|
||||||
|
if (window.location.pathname !== "/") {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onAbort = () => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signals.forEach((signal) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
return controller.signal;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMessage = (err: unknown, fallback: string): string => {
|
||||||
|
if (err instanceof Error && err.message) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runDiagnosisStream = async ({
|
||||||
|
path,
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
onStart,
|
||||||
|
onItem,
|
||||||
|
onDone,
|
||||||
|
onError,
|
||||||
|
}: RunDiagnosisStreamOptions): Promise<DiagnosisStreamRunResult> => {
|
||||||
|
if (!isStreamSupported()) {
|
||||||
|
return { fallback: true, completed: false, timedOut: false, receivedItems: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let receivedItems = 0;
|
||||||
|
let completed = false;
|
||||||
|
let timedOut = false;
|
||||||
|
let currentProgress: DiagnosisStreamProgress = {
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutController = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
timeoutController.abort();
|
||||||
|
}, DIAGNOSIS_STREAM_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const mergedSignal = signal
|
||||||
|
? combineAbortSignals([timeoutController.signal, signal])
|
||||||
|
: timeoutController.signal;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(resolveApiPath(path), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/x-ndjson, application/json",
|
||||||
|
Authorization: getToken() || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: mergedSignal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
handleTokenExpired();
|
||||||
|
return { fallback: false, completed: false, timedOut: false, receivedItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return { fallback: true, completed: false, timedOut: false, receivedItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const fallbackMessage = `请求失败(${response.status})`;
|
||||||
|
let message = fallbackMessage;
|
||||||
|
try {
|
||||||
|
const data = (await response.json()) as RawObject;
|
||||||
|
if (typeof data.msg === "string" && data.msg.trim()) {
|
||||||
|
message = data.msg;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (receivedItems === 0) {
|
||||||
|
return { fallback: true, completed: false, timedOut: false, receivedItems };
|
||||||
|
}
|
||||||
|
onError?.(message);
|
||||||
|
return { fallback: false, completed: false, timedOut: false, receivedItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
const processLine = (line: string) => {
|
||||||
|
if (!line) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsed: DiagnosisStreamRawEvent;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as DiagnosisStreamRawEvent;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = (parsed.type || "").toLowerCase();
|
||||||
|
if (eventType === "start") {
|
||||||
|
if (parsed.data && typeof parsed.data === "object") {
|
||||||
|
const startData = parsed.data as RawObject;
|
||||||
|
const startTotal = Number(startData.total);
|
||||||
|
if (Number.isFinite(startTotal) && startTotal >= 0) {
|
||||||
|
currentProgress = { ...currentProgress, total: startTotal };
|
||||||
|
}
|
||||||
|
onStart?.(startData);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "item") {
|
||||||
|
if (!parsed.data || typeof parsed.data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const itemData = parsed.data as RawObject;
|
||||||
|
const index = Number(itemData.index);
|
||||||
|
const result = itemData.result as TunnelDiagnosisApiItem | undefined;
|
||||||
|
if (!Number.isFinite(index) || !result || typeof result !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const progress = normalizeProgress(itemData.progress, currentProgress);
|
||||||
|
currentProgress = progress;
|
||||||
|
receivedItems += 1;
|
||||||
|
onItem({
|
||||||
|
index,
|
||||||
|
result,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "done") {
|
||||||
|
completed = true;
|
||||||
|
const donePayload =
|
||||||
|
parsed.data && typeof parsed.data === "object"
|
||||||
|
? (parsed.data as RawObject)
|
||||||
|
: {};
|
||||||
|
const doneProgress = normalizeProgress(
|
||||||
|
donePayload.progress ?? donePayload,
|
||||||
|
currentProgress,
|
||||||
|
);
|
||||||
|
if (typeof donePayload.timedOut === "boolean") {
|
||||||
|
doneProgress.timedOut = donePayload.timedOut;
|
||||||
|
timedOut = donePayload.timedOut;
|
||||||
|
}
|
||||||
|
currentProgress = doneProgress;
|
||||||
|
onDone?.(doneProgress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
lines.forEach((line) => processLine(line.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = buffer.trim();
|
||||||
|
if (tail) {
|
||||||
|
processLine(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!completed && timedOut) {
|
||||||
|
const timeoutProgress = {
|
||||||
|
...currentProgress,
|
||||||
|
timedOut: true,
|
||||||
|
};
|
||||||
|
onDone?.(timeoutProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fallback: false,
|
||||||
|
completed,
|
||||||
|
timedOut,
|
||||||
|
receivedItems,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (timedOut) {
|
||||||
|
const timeoutProgress = {
|
||||||
|
...currentProgress,
|
||||||
|
timedOut: true,
|
||||||
|
};
|
||||||
|
onDone?.(timeoutProgress);
|
||||||
|
return { fallback: false, completed: false, timedOut: true, receivedItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return { fallback: false, completed: false, timedOut: false, receivedItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receivedItems === 0) {
|
||||||
|
return { fallback: true, completed: false, timedOut: false, receivedItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
onError?.(parseMessage(error, "流式诊断中断"));
|
||||||
|
return { fallback: false, completed: false, timedOut: false, receivedItems };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const diagnoseTunnelStream = (
|
||||||
|
tunnelId: number,
|
||||||
|
callbacks: DiagnosisStreamCallbacks,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
return runDiagnosisStream({
|
||||||
|
path: "/tunnel/diagnose/stream",
|
||||||
|
body: { tunnelId },
|
||||||
|
signal,
|
||||||
|
...callbacks,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const diagnoseForwardStream = (
|
||||||
|
forwardId: number,
|
||||||
|
callbacks: DiagnosisStreamCallbacks,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
return runDiagnosisStream({
|
||||||
|
path: "/forward/diagnose/stream",
|
||||||
|
body: { forwardId },
|
||||||
|
signal,
|
||||||
|
...callbacks,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -118,7 +118,11 @@ export const updateTunnel = (data: TunnelMutationPayload) =>
|
|||||||
export const deleteTunnel = (id: number) =>
|
export const deleteTunnel = (id: number) =>
|
||||||
Network.post("/tunnel/delete", { id });
|
Network.post("/tunnel/delete", { id });
|
||||||
export const diagnoseTunnel = (tunnelId: number) =>
|
export const diagnoseTunnel = (tunnelId: number) =>
|
||||||
Network.post<TunnelDiagnosisApiData>("/tunnel/diagnose", { tunnelId });
|
Network.post<TunnelDiagnosisApiData>(
|
||||||
|
"/tunnel/diagnose",
|
||||||
|
{ tunnelId },
|
||||||
|
{ timeout: 120 * 1000 },
|
||||||
|
);
|
||||||
export const updateTunnelOrder = (data: {
|
export const updateTunnelOrder = (data: {
|
||||||
tunnels: Array<{ id: number; inx: number }>;
|
tunnels: Array<{ id: number; inx: number }>;
|
||||||
}) => Network.post("/tunnel/update-order", data);
|
}) => Network.post("/tunnel/update-order", data);
|
||||||
@@ -159,7 +163,11 @@ export const resumeForwardService = (forwardId: number) =>
|
|||||||
|
|
||||||
// 转发诊断操作
|
// 转发诊断操作
|
||||||
export const diagnoseForward = (forwardId: number) =>
|
export const diagnoseForward = (forwardId: number) =>
|
||||||
Network.post<ForwardDiagnosisApiData>("/forward/diagnose", { forwardId });
|
Network.post<ForwardDiagnosisApiData>(
|
||||||
|
"/forward/diagnose",
|
||||||
|
{ forwardId },
|
||||||
|
{ timeout: 120 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
// 转发排序操作
|
// 转发排序操作
|
||||||
export const updateForwardOrder = (data: {
|
export const updateForwardOrder = (data: {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface ForwardApiItem {
|
|||||||
outFlow?: number;
|
outFlow?: number;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
tunnelId?: number;
|
tunnelId?: number;
|
||||||
|
speedId?: number | null;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -96,10 +97,8 @@ export interface StatisticsFlowApiItem {
|
|||||||
export interface SpeedLimitApiItem {
|
export interface SpeedLimitApiItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
tunnelId: number;
|
|
||||||
speed: number;
|
speed: number;
|
||||||
status: number;
|
status: number;
|
||||||
tunnelName: string;
|
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
updatedTime: string;
|
updatedTime: string;
|
||||||
uploadSpeed?: number;
|
uploadSpeed?: number;
|
||||||
@@ -284,6 +283,7 @@ export interface ForwardMutationPayload {
|
|||||||
inPort?: number | null;
|
inPort?: number | null;
|
||||||
remoteAddr?: string;
|
remoteAddr?: string;
|
||||||
strategy?: string;
|
strategy?: string;
|
||||||
|
speedId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeedLimitMutationPayload {
|
export interface SpeedLimitMutationPayload {
|
||||||
@@ -291,8 +291,6 @@ export interface SpeedLimitMutationPayload {
|
|||||||
name?: string;
|
name?: string;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
tunnelId?: number | null;
|
|
||||||
tunnelName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePasswordPayload {
|
export interface UpdatePasswordPayload {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { siteConfig, getCachedConfigs } from "@/config/site";
|
||||||
|
import { Logo } from "@/components/icons";
|
||||||
|
|
||||||
|
type BrandLogoProps = {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
alt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrandLogo = ({
|
||||||
|
size = 24,
|
||||||
|
className,
|
||||||
|
alt = "brand logo",
|
||||||
|
}: BrandLogoProps) => {
|
||||||
|
const [logoUrl, setLogoUrl] = useState<string>(siteConfig.app_logo || "");
|
||||||
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const syncLogo = async () => {
|
||||||
|
try {
|
||||||
|
const configMap = await getCachedConfigs();
|
||||||
|
const nextLogo = (
|
||||||
|
configMap.app_logo ||
|
||||||
|
siteConfig.app_logo ||
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
siteConfig.app_logo = nextLogo;
|
||||||
|
setLogoUrl(nextLogo);
|
||||||
|
setImageFailed(false);
|
||||||
|
} catch {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogoUrl((siteConfig.app_logo || "").trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void syncLogo();
|
||||||
|
|
||||||
|
const handleConfigUpdate = () => {
|
||||||
|
const nextLogo = (siteConfig.app_logo || "").trim();
|
||||||
|
|
||||||
|
setLogoUrl(nextLogo);
|
||||||
|
setImageFailed(false);
|
||||||
|
void syncLogo();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("configUpdated", handleConfigUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
window.removeEventListener("configUpdated", handleConfigUpdate);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!logoUrl || imageFailed) {
|
||||||
|
return <Logo className={className} size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
height={size}
|
||||||
|
src={logoUrl}
|
||||||
|
width={size}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
NavbarBrand,
|
NavbarBrand,
|
||||||
NavbarContent,
|
NavbarContent,
|
||||||
} from "@/shadcn-bridge/heroui/navbar";
|
} from "@/shadcn-bridge/heroui/navbar";
|
||||||
import { Logo } from "@/components/icons";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export const Navbar = () => {
|
|||||||
color="foreground"
|
color="foreground"
|
||||||
href="/"
|
href="/"
|
||||||
>
|
>
|
||||||
<Logo size={24} />
|
<BrandLogo size={24} />
|
||||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||||
</Link>
|
</Link>
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type SiteConfig = typeof siteConfig;
|
|||||||
const CACHE_PREFIX = "vite_config_";
|
const CACHE_PREFIX = "vite_config_";
|
||||||
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
const APP_VERSION = "1.0.3";
|
const APP_VERSION = "1.0.3";
|
||||||
|
const DEFAULT_FAVICON = "/favicon.ico";
|
||||||
|
const FAVICON_LINK_ID = "app-favicon";
|
||||||
const GITHUB_REPO =
|
const GITHUB_REPO =
|
||||||
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||||
|
|
||||||
@@ -16,10 +18,15 @@ const getInitialConfig = () => {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
github_repo: GITHUB_REPO,
|
github_repo: GITHUB_REPO,
|
||||||
|
app_logo: "",
|
||||||
|
app_favicon: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
||||||
|
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
|
||||||
|
const cachedAppFavicon =
|
||||||
|
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
|
||||||
|
|
||||||
if (cachedAppName) {
|
if (cachedAppName) {
|
||||||
return {
|
return {
|
||||||
@@ -27,6 +34,8 @@ const getInitialConfig = () => {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
github_repo: GITHUB_REPO,
|
github_repo: GITHUB_REPO,
|
||||||
|
app_logo: cachedAppLogo,
|
||||||
|
app_favicon: cachedAppFavicon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +43,9 @@ const getInitialConfig = () => {
|
|||||||
name: "FLVX",
|
name: "FLVX",
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
|
github_repo: GITHUB_REPO,
|
||||||
|
app_logo: cachedAppLogo,
|
||||||
|
app_favicon: cachedAppFavicon,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +97,11 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
|||||||
|
|
||||||
const response = await getConfigByName(key);
|
const response = await getConfigByName(key);
|
||||||
|
|
||||||
if (response.code === 0 && response.data?.value) {
|
if (
|
||||||
|
response.code === 0 &&
|
||||||
|
response.data &&
|
||||||
|
typeof response.data.value === "string"
|
||||||
|
) {
|
||||||
const value = response.data.value;
|
const value = response.data.value;
|
||||||
|
|
||||||
configCache.set(key, value);
|
configCache.set(key, value);
|
||||||
@@ -99,7 +115,7 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
|||||||
// 获取所有配置(优先从缓存)
|
// 获取所有配置(优先从缓存)
|
||||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||||
// 尝试从缓存获取所有配置
|
// 尝试从缓存获取所有配置
|
||||||
const configKeys = ["app_name"];
|
const configKeys = ["app_name", "app_logo", "app_favicon"];
|
||||||
const cachedConfigs: Record<string, string> = {};
|
const cachedConfigs: Record<string, string> = {};
|
||||||
let hasCachedData = false;
|
let hasCachedData = false;
|
||||||
|
|
||||||
@@ -112,6 +128,33 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchPublicConfigs = async (): Promise<Record<string, string>> => {
|
||||||
|
const publicConfigMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
configKeys.map(async (key) => {
|
||||||
|
try {
|
||||||
|
const response = await getConfigByName(key);
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.code === 0 &&
|
||||||
|
response.data &&
|
||||||
|
typeof response.data.value === "string"
|
||||||
|
) {
|
||||||
|
const value = response.data.value;
|
||||||
|
|
||||||
|
publicConfigMap[key] = value;
|
||||||
|
configCache.set(key, value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore single key fetch error
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return publicConfigMap;
|
||||||
|
};
|
||||||
|
|
||||||
// 从API获取最新配置
|
// 从API获取最新配置
|
||||||
try {
|
try {
|
||||||
const response = await getConfigs();
|
const response = await getConfigs();
|
||||||
@@ -126,31 +169,116 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
|||||||
|
|
||||||
return configs;
|
return configs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasCachedData) {
|
||||||
|
return cachedConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fetchPublicConfigs();
|
||||||
} catch {
|
} catch {
|
||||||
// API失败时返回缓存的数据
|
// API失败时返回缓存的数据
|
||||||
if (hasCachedData) {
|
if (hasCachedData) {
|
||||||
return cachedConfigs;
|
return cachedConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await fetchPublicConfigs();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDocumentFavicon = (faviconUrl: string) => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
const normalized = faviconUrl.trim() || DEFAULT_FAVICON;
|
||||||
|
|
||||||
|
let iconLink = document.head.querySelector<HTMLLinkElement>(
|
||||||
|
`link#${FAVICON_LINK_ID}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!iconLink) {
|
||||||
|
iconLink = document.createElement("link");
|
||||||
|
iconLink.id = FAVICON_LINK_ID;
|
||||||
|
iconLink.rel = "icon";
|
||||||
|
document.head.appendChild(iconLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
iconLink.rel = "icon";
|
||||||
|
iconLink.href = normalized;
|
||||||
|
if (normalized.startsWith("data:image/png")) {
|
||||||
|
iconLink.type = "image/png";
|
||||||
|
} else {
|
||||||
|
iconLink.removeAttribute("type");
|
||||||
|
}
|
||||||
|
|
||||||
|
let shortcutIconLink = document.head.querySelector<HTMLLinkElement>(
|
||||||
|
'link[rel="shortcut icon"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shortcutIconLink) {
|
||||||
|
shortcutIconLink = document.createElement("link");
|
||||||
|
shortcutIconLink.rel = "shortcut icon";
|
||||||
|
document.head.appendChild(shortcutIconLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutIconLink.href = normalized;
|
||||||
|
if (normalized.startsWith("data:image/png")) {
|
||||||
|
shortcutIconLink.type = "image/png";
|
||||||
|
} else {
|
||||||
|
shortcutIconLink.removeAttribute("type");
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicatedIcons = Array.from(
|
||||||
|
document.head.querySelectorAll<HTMLLinkElement>('link[rel="icon"]'),
|
||||||
|
).filter((link) => link !== iconLink);
|
||||||
|
|
||||||
|
duplicatedIcons.forEach((link) => link.remove());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 动态更新网站配置
|
// 动态更新网站配置
|
||||||
export const updateSiteConfig = async () => {
|
export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||||
const appName = await getCachedConfig("app_name");
|
const resolvedConfigMap = configMap ?? (await getCachedConfigs());
|
||||||
|
|
||||||
|
Object.entries(resolvedConfigMap).forEach(([key, value]) => {
|
||||||
|
configCache.set(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAppName = Object.prototype.hasOwnProperty.call(
|
||||||
|
resolvedConfigMap,
|
||||||
|
"app_name",
|
||||||
|
);
|
||||||
|
const hasAppLogo = Object.prototype.hasOwnProperty.call(
|
||||||
|
resolvedConfigMap,
|
||||||
|
"app_logo",
|
||||||
|
);
|
||||||
|
const hasAppFavicon = Object.prototype.hasOwnProperty.call(
|
||||||
|
resolvedConfigMap,
|
||||||
|
"app_favicon",
|
||||||
|
);
|
||||||
|
|
||||||
|
const appName = hasAppName
|
||||||
|
? String(resolvedConfigMap.app_name || "").trim()
|
||||||
|
: siteConfig.name;
|
||||||
|
const appLogo = hasAppLogo
|
||||||
|
? String(resolvedConfigMap.app_logo || "").trim()
|
||||||
|
: (siteConfig.app_logo || "").trim();
|
||||||
|
const appFavicon = hasAppFavicon
|
||||||
|
? String(resolvedConfigMap.app_favicon || "").trim()
|
||||||
|
: (siteConfig.app_favicon || "").trim();
|
||||||
|
|
||||||
if (appName && appName !== siteConfig.name) {
|
if (appName && appName !== siteConfig.name) {
|
||||||
siteConfig.name = appName;
|
siteConfig.name = appName;
|
||||||
// 更新页面标题
|
|
||||||
document.title = appName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
siteConfig.app_logo = appLogo;
|
||||||
|
siteConfig.app_favicon = appFavicon;
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.title = siteConfig.name;
|
||||||
|
}
|
||||||
|
updateDocumentFavicon(siteConfig.app_favicon);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清除配置缓存的工具函数
|
// 清除配置缓存的工具函数(用于需要强制重拉配置的场景)
|
||||||
// 缓存清除时机:
|
|
||||||
// 1. 配置更新时:调用此函数清除所有缓存
|
|
||||||
// 2. 退出登录时:safeLogout()中的localStorage.clear()会自动清除
|
|
||||||
export const clearConfigCache = (keys?: string[]) => {
|
export const clearConfigCache = (keys?: string[]) => {
|
||||||
if (keys && keys.length > 0) {
|
if (keys && keys.length > 0) {
|
||||||
// 删除指定的配置缓存
|
// 删除指定的配置缓存
|
||||||
@@ -163,8 +291,13 @@ export const clearConfigCache = (keys?: string[]) => {
|
|||||||
|
|
||||||
// 在页面加载时异步更新配置(如果有更新的话)
|
// 在页面加载时异步更新配置(如果有更新的话)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.title = siteConfig.name;
|
||||||
|
}
|
||||||
|
updateDocumentFavicon(siteConfig.app_favicon);
|
||||||
|
|
||||||
// 延迟执行,避免阻塞初始渲染
|
// 延迟执行,避免阻塞初始渲染
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
updateSiteConfig();
|
void updateSiteConfig();
|
||||||
}, 200);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from "@/shadcn-bridge/heroui/modal";
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
import { Logo } from "@/components/icons";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
import { VersionFooter } from "@/components/version-footer";
|
import { VersionFooter } from "@/components/version-footer";
|
||||||
import { updatePassword } from "@/api";
|
import { updatePassword } from "@/api";
|
||||||
import { safeLogout } from "@/utils/logout";
|
import { safeLogout } from "@/utils/logout";
|
||||||
@@ -330,7 +330,7 @@ export default function AdminLayout({
|
|||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||||
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||||
<Logo size={28} />
|
<BrandLogo size={28} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Logo } from "@/components/icons";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export default function H5SimpleLayout({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
<Logo size={20} />
|
<BrandLogo size={20} />
|
||||||
<h1 className="text-sm font-bold text-foreground">
|
<h1 className="text-sm font-bold text-foreground">
|
||||||
{siteConfig.name}
|
{siteConfig.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { Logo } from "@/components/icons";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import { getAdminFlag } from "@/utils/session";
|
import { getAdminFlag } from "@/utils/session";
|
||||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||||
@@ -102,7 +102,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
|||||||
{/* 顶部导航栏 */}
|
{/* 顶部导航栏 */}
|
||||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Logo size={20} />
|
<BrandLogo size={20} />
|
||||||
<h1 className="text-sm font-bold text-foreground">
|
<h1 className="text-sm font-bold text-foreground">
|
||||||
{siteConfig.name}
|
{siteConfig.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -28,16 +28,17 @@ import {
|
|||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { SettingsIcon } from "@/components/icons";
|
import { SettingsIcon } from "@/components/icons";
|
||||||
import { isAdmin } from "@/utils/auth";
|
import { isAdmin } from "@/utils/auth";
|
||||||
import {
|
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
|
||||||
getCachedConfigs,
|
|
||||||
clearConfigCache,
|
|
||||||
updateSiteConfig,
|
|
||||||
} from "@/config/site";
|
|
||||||
import {
|
import {
|
||||||
type UpdateReleaseChannel,
|
type UpdateReleaseChannel,
|
||||||
getUpdateReleaseChannel,
|
getUpdateReleaseChannel,
|
||||||
setUpdateReleaseChannel,
|
setUpdateReleaseChannel,
|
||||||
} from "@/utils/version-update";
|
} from "@/utils/version-update";
|
||||||
|
import {
|
||||||
|
convertBrandAssetToPngDataURL,
|
||||||
|
isPngDataURL,
|
||||||
|
type BrandAssetKind,
|
||||||
|
} from "@/utils/brand-asset";
|
||||||
|
|
||||||
// 简单的保存图标组件
|
// 简单的保存图标组件
|
||||||
const SaveIcon = ({ className }: { className?: string }) => (
|
const SaveIcon = ({ className }: { className?: string }) => (
|
||||||
@@ -67,6 +68,19 @@ interface ConfigItem {
|
|||||||
dependsValue?: string; // 依赖的配置项值
|
dependsValue?: string; // 依赖的配置项值
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BRAND_PREVIEW_KEYS = ["app_logo", "app_favicon"] as const;
|
||||||
|
|
||||||
|
type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];
|
||||||
|
|
||||||
|
const isBrandPreviewKey = (key: string): key is BrandPreviewKey =>
|
||||||
|
BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey);
|
||||||
|
|
||||||
|
const BRAND_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml";
|
||||||
|
|
||||||
|
const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
|
||||||
|
return key === "app_logo" ? "logo" : "favicon";
|
||||||
|
};
|
||||||
|
|
||||||
// 网站配置项定义
|
// 网站配置项定义
|
||||||
const CONFIG_ITEMS: ConfigItem[] = [
|
const CONFIG_ITEMS: ConfigItem[] = [
|
||||||
{
|
{
|
||||||
@@ -91,6 +105,24 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
|||||||
description: "在浏览器标签页和导航栏显示的应用名称",
|
description: "在浏览器标签页和导航栏显示的应用名称",
|
||||||
type: "input",
|
type: "input",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "app_logo",
|
||||||
|
label: "网页角标 Logo",
|
||||||
|
description: "用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存",
|
||||||
|
type: "input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "app_favicon",
|
||||||
|
label: "浏览器缩略图标",
|
||||||
|
description: "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
|
||||||
|
type: "input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "forward_compact_mode",
|
||||||
|
label: "转发页面精简模式",
|
||||||
|
description: "开启后,转发页面列表使用 2.1.6-alpha8 样式(全局配置)",
|
||||||
|
type: "switch",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "captcha_enabled",
|
key: "captcha_enabled",
|
||||||
label: "启用验证码",
|
label: "启用验证码",
|
||||||
@@ -141,8 +173,11 @@ const getInitialConfigs = (): Record<string, string> => {
|
|||||||
"captcha_enabled",
|
"captcha_enabled",
|
||||||
"cloudflare_site_key",
|
"cloudflare_site_key",
|
||||||
"cloudflare_secret_key",
|
"cloudflare_secret_key",
|
||||||
|
"forward_compact_mode",
|
||||||
"ip",
|
"ip",
|
||||||
"panel_domain",
|
"panel_domain",
|
||||||
|
"app_logo",
|
||||||
|
"app_favicon",
|
||||||
];
|
];
|
||||||
const initialConfigs: Record<string, string> = {};
|
const initialConfigs: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -179,7 +214,9 @@ export default function ConfigPage() {
|
|||||||
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
|
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
|
||||||
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
|
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
|
||||||
const [importFileName, setImportFileName] = useState("");
|
const [importFileName, setImportFileName] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const backupFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const logoFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const faviconFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
||||||
content: "",
|
content: "",
|
||||||
@@ -190,6 +227,12 @@ export default function ConfigPage() {
|
|||||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||||
getUpdateReleaseChannel(),
|
getUpdateReleaseChannel(),
|
||||||
);
|
);
|
||||||
|
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
||||||
|
Partial<Record<BrandPreviewKey, boolean>>
|
||||||
|
>({});
|
||||||
|
const [brandUploading, setBrandUploading] = useState<
|
||||||
|
Partial<Record<BrandPreviewKey, boolean>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,8 +294,7 @@ export default function ConfigPage() {
|
|||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
setAnnouncement(res.data);
|
setAnnouncement(res.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Failed to load announcement:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setAnnouncementLoading(false);
|
setAnnouncementLoading(false);
|
||||||
}
|
}
|
||||||
@@ -288,6 +330,10 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
setConfigs(newConfigs);
|
setConfigs(newConfigs);
|
||||||
|
|
||||||
|
if (isBrandPreviewKey(key)) {
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
const hasChangesNow =
|
const hasChangesNow =
|
||||||
Object.keys(newConfigs).some(
|
Object.keys(newConfigs).some(
|
||||||
(k) => newConfigs[k] !== originalConfigs[k],
|
(k) => newConfigs[k] !== originalConfigs[k],
|
||||||
@@ -303,25 +349,40 @@ export default function ConfigPage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await updateConfigs(configs);
|
const changedKeys = Object.keys(configs).filter(
|
||||||
|
(key) => configs[key] !== originalConfigs[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changedKeys.length === 0) {
|
||||||
|
setHasChanges(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedPayload: Record<string, string> = {};
|
||||||
|
|
||||||
|
changedKeys.forEach((key) => {
|
||||||
|
changedPayload[key] = configs[key] || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateConfigs(changedPayload);
|
||||||
|
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success("配置保存成功");
|
toast.success("配置保存成功");
|
||||||
|
|
||||||
// 清除所有配置缓存,强制下次重新获取
|
Object.entries(configs).forEach(([key, value]) => {
|
||||||
clearConfigCache();
|
configCache.set(key, value);
|
||||||
|
});
|
||||||
// 获取变更的配置项
|
|
||||||
const changedKeys = Object.keys(configs).filter(
|
|
||||||
(key) => configs[key] !== originalConfigs[key],
|
|
||||||
);
|
|
||||||
|
|
||||||
setOriginalConfigs({ ...configs });
|
setOriginalConfigs({ ...configs });
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
|
|
||||||
// 如果应用名称发生变化,立即更新网站配置
|
if (
|
||||||
if (changedKeys.includes("app_name")) {
|
changedKeys.some((key) =>
|
||||||
await updateSiteConfig();
|
["app_name", "app_logo", "app_favicon"].includes(key),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await updateSiteConfig(configs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发配置更新事件,通知其他组件
|
// 触发配置更新事件,通知其他组件
|
||||||
@@ -349,6 +410,196 @@ export default function ConfigPage() {
|
|||||||
return configs[item.dependsOn] === item.dependsValue;
|
return configs[item.dependsOn] === item.dependsValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBrandInputRef = (key: BrandPreviewKey) => {
|
||||||
|
return key === "app_logo" ? logoFileInputRef : faviconFileInputRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerBrandFilePicker = (key: BrandPreviewKey) => {
|
||||||
|
if (brandUploading[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBrandInputRef(key).current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBrandAsset = (key: BrandPreviewKey) => {
|
||||||
|
handleConfigChange(key, "");
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrandFileChange = async (
|
||||||
|
key: BrandPreviewKey,
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrandUploading((prev) => ({ ...prev, [key]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pngDataURL = await convertBrandAssetToPngDataURL(
|
||||||
|
file,
|
||||||
|
toBrandAssetKind(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
handleConfigChange(key, pngDataURL);
|
||||||
|
toast.success(key === "app_logo" ? "Logo 上传成功" : "Favicon 上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "图片处理失败,请重试";
|
||||||
|
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setBrandUploading((prev) => ({ ...prev, [key]: false }));
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBrandPreview = (key: BrandPreviewKey) => {
|
||||||
|
const previewUrl = (configs[key] || "").trim();
|
||||||
|
const appNamePreview = (configs.app_name || "").trim() || "应用名称";
|
||||||
|
const failed = previewLoadFailed[key] === true;
|
||||||
|
const showImage = previewUrl.length > 0 && !failed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-lg border border-default-200 dark:border-default-100/30 bg-default-50/60 dark:bg-default-100/10 p-3">
|
||||||
|
<p className="text-xs text-default-500">实时预览</p>
|
||||||
|
<div className="mt-2 rounded-md border border-default-200 dark:border-default-100/30 bg-white dark:bg-black px-3 py-2">
|
||||||
|
{key === "app_logo" ? (
|
||||||
|
<div className="flex h-10 items-center gap-2">
|
||||||
|
{showImage ? (
|
||||||
|
<img
|
||||||
|
alt="logo preview"
|
||||||
|
className="h-7 w-7 rounded-sm border border-default-200 object-cover dark:border-default-100/30"
|
||||||
|
src={previewUrl}
|
||||||
|
onError={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
|
||||||
|
}
|
||||||
|
onLoad={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-sm bg-default-200 text-[10px] font-semibold text-default-600 dark:bg-default-700 dark:text-default-200">
|
||||||
|
LOGO
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{appNamePreview}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-7 max-w-[260px] items-center gap-2 rounded border border-default-200 bg-default-100/70 px-2 dark:border-default-100/30 dark:bg-default-100/20">
|
||||||
|
{showImage ? (
|
||||||
|
<img
|
||||||
|
alt="favicon preview"
|
||||||
|
className="h-4 w-4 rounded-sm object-contain"
|
||||||
|
src={previewUrl}
|
||||||
|
onError={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
|
||||||
|
}
|
||||||
|
onLoad={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4 rounded-sm bg-default-300 dark:bg-default-600" />
|
||||||
|
)}
|
||||||
|
<span className="truncate text-xs text-default-700 dark:text-default-300">
|
||||||
|
{appNamePreview}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewUrl.length === 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-default-500">
|
||||||
|
上传图片后会实时显示预览
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{previewUrl.length > 0 && failed ? (
|
||||||
|
<p className="mt-2 text-xs text-danger">图片加载失败,请重新上传</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (
|
||||||
|
<p className="mt-2 text-xs text-warning-600 dark:text-warning-400">
|
||||||
|
当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBrandAssetUploader = (
|
||||||
|
key: BrandPreviewKey,
|
||||||
|
isChanged: boolean,
|
||||||
|
) => {
|
||||||
|
const value = (configs[key] || "").trim();
|
||||||
|
const uploading = brandUploading[key] === true;
|
||||||
|
const isLogo = key === "app_logo";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-3 ${
|
||||||
|
isChanged
|
||||||
|
? "border-warning-300"
|
||||||
|
: "border-default-200 dark:border-default-100/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={getBrandInputRef(key)}
|
||||||
|
accept={BRAND_FILE_ACCEPT}
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
onChange={(event) => {
|
||||||
|
void handleBrandFileChange(key, event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={uploading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => triggerBrandFilePicker(key)}
|
||||||
|
>
|
||||||
|
{value.length > 0
|
||||||
|
? isLogo
|
||||||
|
? "替换 Logo"
|
||||||
|
: "替换 Favicon"
|
||||||
|
: isLogo
|
||||||
|
? "上传 Logo"
|
||||||
|
: "上传 Favicon"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isDisabled={value.length === 0 || uploading}
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => clearBrandAsset(key)}
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-default-500">
|
||||||
|
仅支持图片文件,自动转换为 PNG
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-default-500">
|
||||||
|
{isLogo
|
||||||
|
? "建议上传方形图片,系统会统一转换为 96x96 PNG"
|
||||||
|
: "建议上传方形图片,系统会统一转换为 64x64 PNG"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{renderBrandPreview(key)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染不同类型的配置项
|
// 渲染不同类型的配置项
|
||||||
const renderConfigItem = (item: ConfigItem) => {
|
const renderConfigItem = (item: ConfigItem) => {
|
||||||
const isChanged =
|
const isChanged =
|
||||||
@@ -356,6 +607,10 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "input":
|
case "input":
|
||||||
|
if (isBrandPreviewKey(item.key)) {
|
||||||
|
return renderBrandAssetUploader(item.key, isChanged);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
classNames={{
|
classNames={{
|
||||||
@@ -450,7 +705,7 @@ export default function ConfigPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setImportSelectorOpen(false);
|
setImportSelectorOpen(false);
|
||||||
requestAnimationFrame(() => fileInputRef.current?.click());
|
requestAnimationFrame(() => backupFileInputRef.current?.click());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -487,8 +742,8 @@ export default function ConfigPage() {
|
|||||||
toast.error("导入失败,请检查文件格式");
|
toast.error("导入失败,请检查文件格式");
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
if (fileInputRef.current) {
|
if (backupFileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
backupFileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -830,7 +1085,7 @@ export default function ConfigPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={backupFileInputRef}
|
||||||
accept=".json"
|
accept=".json"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -197,9 +197,7 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||||
setAnnouncement(res.data);
|
setAnnouncement(res.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {}
|
||||||
console.error("Failed to load announcement:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPackageData = async () => {
|
const loadPackageData = async () => {
|
||||||
|
|||||||
+1208
-166
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
export interface ForwardDiagnosisEntry {
|
export interface ForwardDiagnosisEntry {
|
||||||
success: boolean;
|
success?: boolean;
|
||||||
|
diagnosing?: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { SearchBar } from "@/components/search-bar";
|
|||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
@@ -24,7 +23,6 @@ import {
|
|||||||
getSpeedLimitList,
|
getSpeedLimitList,
|
||||||
updateSpeedLimit,
|
updateSpeedLimit,
|
||||||
deleteSpeedLimit,
|
deleteSpeedLimit,
|
||||||
getTunnelList,
|
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
@@ -34,30 +32,20 @@ interface SpeedLimitRule {
|
|||||||
name: string;
|
name: string;
|
||||||
speed: number;
|
speed: number;
|
||||||
status: number;
|
status: number;
|
||||||
tunnelId: number;
|
|
||||||
tunnelName: string;
|
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
updatedTime: string;
|
updatedTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Tunnel {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeedLimitForm {
|
interface SpeedLimitForm {
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
speed: number;
|
speed: number;
|
||||||
tunnelId: number | null;
|
|
||||||
tunnelName: string;
|
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LimitPage() {
|
export default function LimitPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
|
||||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||||
"limit-search-keyword",
|
"limit-search-keyword",
|
||||||
"",
|
"",
|
||||||
@@ -69,9 +57,7 @@ export default function LimitPage() {
|
|||||||
const lowerKeyword = searchKeyword.toLowerCase();
|
const lowerKeyword = searchKeyword.toLowerCase();
|
||||||
|
|
||||||
return rules.filter(
|
return rules.filter(
|
||||||
(r) =>
|
(r) => r.name && r.name.toLowerCase().includes(lowerKeyword),
|
||||||
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
|
|
||||||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
|
|
||||||
);
|
);
|
||||||
}, [rules, searchKeyword]);
|
}, [rules, searchKeyword]);
|
||||||
|
|
||||||
@@ -87,8 +73,6 @@ export default function LimitPage() {
|
|||||||
const [form, setForm] = useState<SpeedLimitForm>({
|
const [form, setForm] = useState<SpeedLimitForm>({
|
||||||
name: "",
|
name: "",
|
||||||
speed: 100,
|
speed: 100,
|
||||||
tunnelId: null,
|
|
||||||
tunnelName: "",
|
|
||||||
status: 1,
|
status: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,21 +87,13 @@ export default function LimitPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [rulesRes, tunnelsRes] = await Promise.all([
|
const rulesRes = await getSpeedLimitList();
|
||||||
getSpeedLimitList(),
|
|
||||||
getTunnelList(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (rulesRes.code === 0) {
|
if (rulesRes.code === 0) {
|
||||||
setRules(rulesRes.data || []);
|
setRules(rulesRes.data || []);
|
||||||
} else {
|
} else {
|
||||||
toast.error(rulesRes.msg || "获取限速规则失败");
|
toast.error(rulesRes.msg || "获取限速规则失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tunnelsRes.code === 0) {
|
|
||||||
setTunnels(tunnelsRes.data || []);
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("加载数据失败");
|
toast.error("加载数据失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,10 +115,6 @@ export default function LimitPage() {
|
|||||||
newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
|
newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.tunnelId) {
|
|
||||||
newErrors.tunnelId = "请选择要绑定的隧道";
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
|
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
@@ -154,8 +126,6 @@ export default function LimitPage() {
|
|||||||
setForm({
|
setForm({
|
||||||
name: "",
|
name: "",
|
||||||
speed: 100,
|
speed: 100,
|
||||||
tunnelId: null,
|
|
||||||
tunnelName: "",
|
|
||||||
status: 1,
|
status: 1,
|
||||||
});
|
});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
@@ -169,8 +139,6 @@ export default function LimitPage() {
|
|||||||
id: rule.id,
|
id: rule.id,
|
||||||
name: rule.name,
|
name: rule.name,
|
||||||
speed: rule.speed,
|
speed: rule.speed,
|
||||||
tunnelId: rule.tunnelId,
|
|
||||||
tunnelName: rule.tunnelName,
|
|
||||||
status: rule.status,
|
status: rule.status,
|
||||||
});
|
});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
@@ -212,14 +180,21 @@ export default function LimitPage() {
|
|||||||
setSubmitLoading(true);
|
setSubmitLoading(true);
|
||||||
try {
|
try {
|
||||||
let res: { code: number; msg: string };
|
let res: { code: number; msg: string };
|
||||||
|
const payload = {
|
||||||
|
id: form.id,
|
||||||
|
name: form.name,
|
||||||
|
speed: form.speed,
|
||||||
|
status: form.status,
|
||||||
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
res = await updateSpeedLimit(form);
|
res = await updateSpeedLimit(payload);
|
||||||
} else {
|
} else {
|
||||||
const createData = { ...form };
|
const createData = {
|
||||||
|
name: payload.name,
|
||||||
delete createData.id;
|
speed: payload.speed,
|
||||||
|
status: payload.status,
|
||||||
|
};
|
||||||
res = await createSpeedLimit(createData);
|
res = await createSpeedLimit(createData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +222,7 @@ export default function LimitPage() {
|
|||||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
isVisible={isSearchVisible}
|
isVisible={isSearchVisible}
|
||||||
placeholder="搜索规则名称或绑定隧道"
|
placeholder="搜索规则名称"
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={setSearchKeyword}
|
onChange={setSearchKeyword}
|
||||||
onClose={() => setIsSearchVisible(false)}
|
onClose={() => setIsSearchVisible(false)}
|
||||||
@@ -292,20 +267,6 @@ export default function LimitPage() {
|
|||||||
{rule.speed} Mbps
|
{rule.speed} Mbps
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-small text-default-600">
|
|
||||||
绑定隧道
|
|
||||||
</span>
|
|
||||||
{rule.tunnelName ? (
|
|
||||||
<Chip color="primary" size="sm" variant="flat">
|
|
||||||
{rule.tunnelName}
|
|
||||||
</Chip>
|
|
||||||
) : (
|
|
||||||
<span className="text-default-400 text-small">
|
|
||||||
未绑定
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
@@ -393,9 +354,7 @@ export default function LimitPage() {
|
|||||||
{isEdit ? "编辑限速规则" : "新增限速规则"}
|
{isEdit ? "编辑限速规则" : "新增限速规则"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-small text-default-500">
|
<p className="text-small text-default-500">
|
||||||
{isEdit
|
{isEdit ? "修改现有限速规则的配置信息" : "创建新的限速规则"}
|
||||||
? "修改现有限速规则的配置信息"
|
|
||||||
: "创建新的限速规则并绑定到隧道"}
|
|
||||||
</p>
|
</p>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
@@ -434,44 +393,6 @@ export default function LimitPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
|
||||||
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
|
|
||||||
errorMessage={errors.tunnelId}
|
|
||||||
isDisabled={isEdit}
|
|
||||||
isInvalid={!!errors.tunnelId}
|
|
||||||
label="绑定隧道"
|
|
||||||
placeholder="请选择要绑定的隧道"
|
|
||||||
selectedKeys={
|
|
||||||
form.tunnelId ? [form.tunnelId.toString()] : []
|
|
||||||
}
|
|
||||||
variant="bordered"
|
|
||||||
onSelectionChange={(keys) => {
|
|
||||||
const selectedKey = Array.from(keys)[0] as string;
|
|
||||||
|
|
||||||
if (selectedKey) {
|
|
||||||
const selectedTunnel = tunnels.find(
|
|
||||||
(tunnel) => tunnel.id === parseInt(selectedKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
tunnelId: parseInt(selectedKey),
|
|
||||||
tunnelName: selectedTunnel?.name || "",
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
tunnelId: null,
|
|
||||||
tunnelName: "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tunnels.map((tunnel) => (
|
|
||||||
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import { Input } from "@/shadcn-bridge/heroui/input";
|
|||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||||
|
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||||
import { reinitializeBaseURL } from "@/api/network";
|
import { reinitializeBaseURL } from "@/api/network";
|
||||||
|
import { getConfigByName, updateConfig } from "@/api";
|
||||||
import {
|
import {
|
||||||
type UpdateReleaseChannel,
|
type UpdateReleaseChannel,
|
||||||
getUpdateReleaseChannel,
|
getUpdateReleaseChannel,
|
||||||
setUpdateReleaseChannel,
|
setUpdateReleaseChannel,
|
||||||
} from "@/utils/version-update";
|
} from "@/utils/version-update";
|
||||||
|
import { isAdmin } from "@/utils/auth";
|
||||||
import {
|
import {
|
||||||
getPanelAddresses,
|
getPanelAddresses,
|
||||||
savePanelAddress,
|
savePanelAddress,
|
||||||
@@ -26,6 +29,8 @@ interface PanelAddress {
|
|||||||
inx: boolean;
|
inx: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||||
@@ -34,6 +39,11 @@ export const SettingsPage = () => {
|
|||||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||||
getUpdateReleaseChannel(),
|
getUpdateReleaseChannel(),
|
||||||
);
|
);
|
||||||
|
const [forwardCompactMode, setForwardCompactMode] = useState(false);
|
||||||
|
const [forwardCompactModeSaving, setForwardCompactModeSaving] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const admin = isAdmin();
|
||||||
|
|
||||||
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
||||||
setPanelAddresses(newAddress);
|
setPanelAddresses(newAddress);
|
||||||
@@ -86,8 +96,57 @@ export const SettingsPage = () => {
|
|||||||
// 页面加载时获取数据
|
// 页面加载时获取数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPanelAddresses();
|
loadPanelAddresses();
|
||||||
|
loadForwardCompactMode();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadForwardCompactMode = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
|
||||||
|
const enabled =
|
||||||
|
res.code === 0 &&
|
||||||
|
typeof res.data?.value === "string" &&
|
||||||
|
res.data.value === "true";
|
||||||
|
|
||||||
|
setForwardCompactMode(enabled);
|
||||||
|
} catch {
|
||||||
|
setForwardCompactMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForwardCompactModeChange = async (enabled: boolean) => {
|
||||||
|
if (!admin || forwardCompactModeSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = forwardCompactMode;
|
||||||
|
|
||||||
|
setForwardCompactMode(enabled);
|
||||||
|
setForwardCompactModeSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await updateConfig(
|
||||||
|
FORWARD_COMPACT_MODE_CONFIG_KEY,
|
||||||
|
enabled ? "true" : "false",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.code === 0) {
|
||||||
|
toast.success(`转发页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("forwardCompactModeChanged", {
|
||||||
|
detail: { enabled },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setForwardCompactMode(previous);
|
||||||
|
toast.error(response.msg || "保存精简模式失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setForwardCompactMode(previous);
|
||||||
|
toast.error("保存精简模式失败");
|
||||||
|
} finally {
|
||||||
|
setForwardCompactModeSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||||
setUpdateChannel(channel);
|
setUpdateChannel(channel);
|
||||||
setUpdateReleaseChannel(channel);
|
setUpdateReleaseChannel(channel);
|
||||||
@@ -164,6 +223,37 @@ export const SettingsPage = () => {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-gray-200 dark:border-gray-700">
|
||||||
|
<CardBody className="p-6">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||||
|
显示设置
|
||||||
|
</h2>
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
转发页面精简模式
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后,转发页面列表使用 2.1.6-alpha8 样式。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
color="primary"
|
||||||
|
isDisabled={!admin || forwardCompactModeSaving}
|
||||||
|
isSelected={forwardCompactMode}
|
||||||
|
onValueChange={handleForwardCompactModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!admin && (
|
||||||
|
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
仅管理员可修改该全局配置。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 添加新地址 */}
|
{/* 添加新地址 */}
|
||||||
<Card className="border border-gray-200 dark:border-gray-700">
|
<Card className="border border-gray-200 dark:border-gray-700">
|
||||||
<CardBody className="p-6">
|
<CardBody className="p-6">
|
||||||
|
|||||||
+389
-173
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
getDiagnosisQualityDisplay,
|
getDiagnosisQualityDisplay,
|
||||||
type DiagnosisResult,
|
type DiagnosisResult,
|
||||||
} from "@/pages/tunnel/diagnosis";
|
} from "@/pages/tunnel/diagnosis";
|
||||||
|
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
|
||||||
import {
|
import {
|
||||||
createTunnelFormDefaults,
|
createTunnelFormDefaults,
|
||||||
getTunnelFlowDisplay,
|
getTunnelFlowDisplay,
|
||||||
@@ -133,6 +134,14 @@ export default function TunnelPage() {
|
|||||||
useState<Tunnel | null>(null);
|
useState<Tunnel | null>(null);
|
||||||
const [diagnosisResult, setDiagnosisResult] =
|
const [diagnosisResult, setDiagnosisResult] =
|
||||||
useState<DiagnosisResult | null>(null);
|
useState<DiagnosisResult | null>(null);
|
||||||
|
const [diagnosisProgress, setDiagnosisProgress] = useState({
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
const diagnosisAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// 表单状态
|
// 表单状态
|
||||||
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
|
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
|
||||||
@@ -146,6 +155,13 @@ export default function TunnelPage() {
|
|||||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -293,37 +309,10 @@ export default function TunnelPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加节点到指定的转发链跳数
|
const toSelectedNodeIds = (keys: Iterable<unknown>): number[] => {
|
||||||
const addNodeToChain = (groupIndex: number, nodeId: number) => {
|
return Array.from(keys)
|
||||||
setForm((prev) => {
|
.map((key) => Number.parseInt(String(key), 10))
|
||||||
const chainNodes = [...(prev.chainNodes || [])];
|
.filter((nodeId) => Number.isFinite(nodeId));
|
||||||
const group = chainNodes[groupIndex] || [];
|
|
||||||
|
|
||||||
// 获取当前组的策略和协议
|
|
||||||
const strategy = group.length > 0 ? group[0].strategy : "round";
|
|
||||||
const protocol = group.length > 0 ? group[0].protocol : "tls";
|
|
||||||
|
|
||||||
// 添加节点到该组
|
|
||||||
chainNodes[groupIndex] = [
|
|
||||||
...group,
|
|
||||||
{ nodeId, chainType: 2, protocol, strategy },
|
|
||||||
];
|
|
||||||
|
|
||||||
return { ...prev, chainNodes };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从某一跳删除指定节点
|
|
||||||
const removeNodeFromChain = (groupIndex: number, nodeId: number) => {
|
|
||||||
setForm((prev) => {
|
|
||||||
const chainNodes = [...(prev.chainNodes || [])];
|
|
||||||
|
|
||||||
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).filter(
|
|
||||||
(node) => node.nodeId !== nodeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...prev, chainNodes };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新某一跳的所有节点的协议
|
// 更新某一跳的所有节点的协议
|
||||||
@@ -366,6 +355,51 @@ export default function TunnelPage() {
|
|||||||
return form.chainNodes || [];
|
return form.chainNodes || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeOrderedNodes = (
|
||||||
|
currentNodes: ChainTunnel[],
|
||||||
|
selectedNodeIds: number[],
|
||||||
|
buildDefault: (nodeId: number) => ChainTunnel,
|
||||||
|
): ChainTunnel[] => {
|
||||||
|
const selectedSet = new Set(selectedNodeIds);
|
||||||
|
const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId));
|
||||||
|
const keptIds = new Set(kept.map((node) => node.nodeId));
|
||||||
|
const added = selectedNodeIds
|
||||||
|
.filter((nodeId) => !keptIds.has(nodeId))
|
||||||
|
.map((nodeId) => buildDefault(nodeId));
|
||||||
|
|
||||||
|
return [...kept, ...added];
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncChainGroupNodes = (
|
||||||
|
groupIndex: number,
|
||||||
|
selectedNodeIds: number[],
|
||||||
|
) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const chainNodes = [...(prev.chainNodes || [])];
|
||||||
|
const currentGroup = chainNodes[groupIndex] || [];
|
||||||
|
const protocol = currentGroup[0]?.protocol || "tls";
|
||||||
|
const strategy = currentGroup[0]?.strategy || "round";
|
||||||
|
const realNodes = currentGroup.filter((node) => node.nodeId !== -1);
|
||||||
|
const mergedNodes = mergeOrderedNodes(
|
||||||
|
realNodes,
|
||||||
|
selectedNodeIds,
|
||||||
|
(nodeId) => ({
|
||||||
|
nodeId,
|
||||||
|
chainType: 2,
|
||||||
|
protocol,
|
||||||
|
strategy,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
chainNodes[groupIndex] =
|
||||||
|
mergedNodes.length > 0
|
||||||
|
? mergedNodes
|
||||||
|
: [{ nodeId: -1, chainType: 2, protocol, strategy }];
|
||||||
|
|
||||||
|
return { ...prev, chainNodes };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
@@ -416,28 +450,167 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
// 诊断隧道
|
// 诊断隧道
|
||||||
const handleDiagnose = async (tunnel: Tunnel) => {
|
const handleDiagnose = async (tunnel: Tunnel) => {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
diagnosisAbortRef.current = abortController;
|
||||||
|
|
||||||
setCurrentDiagnosisTunnel(tunnel);
|
setCurrentDiagnosisTunnel(tunnel);
|
||||||
setDiagnosisModalOpen(true);
|
setDiagnosisModalOpen(true);
|
||||||
setDiagnosisLoading(true);
|
setDiagnosisLoading(true);
|
||||||
setDiagnosisResult(null);
|
setDiagnosisProgress({
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
setDiagnosisResult({
|
||||||
|
tunnelName: tunnel.name,
|
||||||
|
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await diagnoseTunnel(tunnel.id);
|
let streamErrorMessage = "";
|
||||||
|
const streamResult = await diagnoseTunnelStream(
|
||||||
|
tunnel.id,
|
||||||
|
{
|
||||||
|
onStart: (payload) => {
|
||||||
|
const startTunnelName =
|
||||||
|
typeof payload.tunnelName === "string" &&
|
||||||
|
payload.tunnelName.trim() !== ""
|
||||||
|
? payload.tunnelName
|
||||||
|
: tunnel.name;
|
||||||
|
const startTunnelType =
|
||||||
|
typeof payload.tunnelType === "string" &&
|
||||||
|
payload.tunnelType.trim() !== ""
|
||||||
|
? payload.tunnelType
|
||||||
|
: tunnel.type === 1
|
||||||
|
? "端口转发"
|
||||||
|
: "隧道转发";
|
||||||
|
const startTotal = Number(payload.total);
|
||||||
|
const startItems = Array.isArray(payload.items)
|
||||||
|
? (payload.items as DiagnosisResult["results"])
|
||||||
|
: [];
|
||||||
|
setDiagnosisResult((prev) => ({
|
||||||
|
tunnelName: startTunnelName,
|
||||||
|
tunnelType: startTunnelType,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
results: startItems.length > 0 ? startItems : prev?.results || [],
|
||||||
|
}));
|
||||||
|
if (Number.isFinite(startTotal) && startTotal >= 0) {
|
||||||
|
setDiagnosisProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
total: startTotal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onItem: ({ result, progress }) => {
|
||||||
|
setDiagnosisResult((prev) => {
|
||||||
|
const base: DiagnosisResult = prev || {
|
||||||
|
tunnelName: tunnel.name,
|
||||||
|
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
const nextResults = [...base.results];
|
||||||
|
const existingIndex = nextResults.findIndex(
|
||||||
|
(item) =>
|
||||||
|
item.description === result.description &&
|
||||||
|
item.nodeId === result.nodeId &&
|
||||||
|
item.targetIp === result.targetIp &&
|
||||||
|
item.targetPort === result.targetPort,
|
||||||
|
);
|
||||||
|
|
||||||
if (response.code === 0) {
|
if (existingIndex >= 0) {
|
||||||
setDiagnosisResult(response.data as DiagnosisResult);
|
nextResults[existingIndex] = {
|
||||||
} else {
|
...result,
|
||||||
toast.error(response.msg || "诊断失败");
|
diagnosing: false,
|
||||||
setDiagnosisResult(
|
};
|
||||||
buildDiagnosisFallbackResult({
|
} else {
|
||||||
tunnelName: tunnel.name,
|
nextResults.push({
|
||||||
tunnelType: tunnel.type,
|
...result,
|
||||||
description: "诊断失败",
|
diagnosing: false,
|
||||||
message: response.msg || "诊断过程中发生错误",
|
});
|
||||||
}),
|
}
|
||||||
);
|
return {
|
||||||
|
...base,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
results: nextResults,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setDiagnosisProgress({
|
||||||
|
total: progress.total,
|
||||||
|
completed: progress.completed,
|
||||||
|
success: progress.success,
|
||||||
|
failed: progress.failed,
|
||||||
|
timedOut: Boolean(progress.timedOut),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDone: (progress) => {
|
||||||
|
setDiagnosisProgress({
|
||||||
|
total: progress.total,
|
||||||
|
completed: progress.completed,
|
||||||
|
success: progress.success,
|
||||||
|
failed: progress.failed,
|
||||||
|
timedOut: Boolean(progress.timedOut),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
streamErrorMessage = message;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamResult.fallback) {
|
||||||
|
const response = await diagnoseTunnel(tunnel.id);
|
||||||
|
|
||||||
|
if (response.code === 0) {
|
||||||
|
const resultData = response.data as DiagnosisResult;
|
||||||
|
const successCount = resultData.results.filter((r) => r.success).length;
|
||||||
|
const failedCount = resultData.results.length - successCount;
|
||||||
|
setDiagnosisResult(resultData);
|
||||||
|
setDiagnosisProgress({
|
||||||
|
total: resultData.results.length,
|
||||||
|
completed: resultData.results.length,
|
||||||
|
success: successCount,
|
||||||
|
failed: failedCount,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(response.msg || "诊断失败");
|
||||||
|
setDiagnosisResult(
|
||||||
|
buildDiagnosisFallbackResult({
|
||||||
|
tunnelName: tunnel.name,
|
||||||
|
tunnelType: tunnel.type,
|
||||||
|
description: "诊断失败",
|
||||||
|
message: response.msg || "诊断过程中发生错误",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setDiagnosisProgress({
|
||||||
|
total: 1,
|
||||||
|
completed: 1,
|
||||||
|
success: 0,
|
||||||
|
failed: 1,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamErrorMessage) {
|
||||||
|
toast.error(streamErrorMessage);
|
||||||
|
}
|
||||||
|
if (streamResult.timedOut) {
|
||||||
|
toast.error("诊断超时(单条30秒 / 整体2分钟),已返回当前结果");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error("网络错误,请重试");
|
toast.error("网络错误,请重试");
|
||||||
setDiagnosisResult(
|
setDiagnosisResult(
|
||||||
buildDiagnosisFallbackResult({
|
buildDiagnosisFallbackResult({
|
||||||
@@ -447,7 +620,17 @@ export default function TunnelPage() {
|
|||||||
message: "无法连接到服务器",
|
message: "无法连接到服务器",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
setDiagnosisProgress({
|
||||||
|
total: 1,
|
||||||
|
completed: 1,
|
||||||
|
success: 0,
|
||||||
|
failed: 1,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
if (diagnosisAbortRef.current === abortController) {
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
}
|
||||||
setDiagnosisLoading(false);
|
setDiagnosisLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -796,6 +979,10 @@ export default function TunnelPage() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
{sortedTunnels.map((tunnel) => {
|
{sortedTunnels.map((tunnel) => {
|
||||||
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
||||||
|
const tunnelTypeChipClassName =
|
||||||
|
tunnel.type === 1
|
||||||
|
? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
|
||||||
|
: "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableItem key={tunnel.id} id={tunnel.id}>
|
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||||
@@ -819,7 +1006,7 @@ export default function TunnelPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Chip
|
<Chip
|
||||||
className="text-xs"
|
className={tunnelTypeChipClassName}
|
||||||
color={typeDisplay.color as any}
|
color={typeDisplay.color as any}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1197,6 +1384,7 @@ export default function TunnelPage() {
|
|||||||
<Select
|
<Select
|
||||||
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
|
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
|
||||||
label="隧道连接地址偏好"
|
label="隧道连接地址偏好"
|
||||||
|
placeholder="自动选择"
|
||||||
selectedKeys={[form.ipPreference || ""]}
|
selectedKeys={[form.ipPreference || ""]}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
@@ -1208,7 +1396,6 @@ export default function TunnelPage() {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectItem key="">自动选择</SelectItem>
|
|
||||||
<SelectItem key="v4">优先IPv4</SelectItem>
|
<SelectItem key="v4">优先IPv4</SelectItem>
|
||||||
<SelectItem key="v6">优先IPv6</SelectItem>
|
<SelectItem key="v6">优先IPv6</SelectItem>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1238,21 +1425,16 @@ export default function TunnelPage() {
|
|||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selectedIds = Array.from(keys).map((key) =>
|
const selectedIds = toSelectedNodeIds(keys);
|
||||||
parseInt(key as string),
|
|
||||||
);
|
|
||||||
const newInNodeId: ChainTunnel[] = selectedIds.map(
|
|
||||||
(nodeId) => {
|
|
||||||
// 保留已有的端口配置
|
|
||||||
const existing = form.inNodeId.find(
|
|
||||||
(ct) => ct.nodeId === nodeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return existing || { nodeId, chainType: 1 };
|
setForm((prev) => ({
|
||||||
},
|
...prev,
|
||||||
);
|
inNodeId: mergeOrderedNodes(
|
||||||
|
prev.inNodeId,
|
||||||
setForm((prev) => ({ ...prev, inNodeId: newInNodeId }));
|
selectedIds,
|
||||||
|
(nodeId) => ({ nodeId, chainType: 1 }),
|
||||||
|
),
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
@@ -1421,37 +1603,10 @@ export default function TunnelPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selectedIds = Array.from(
|
syncChainGroupNodes(
|
||||||
keys,
|
groupIndex,
|
||||||
).map((key) => parseInt(key as string));
|
toSelectedNodeIds(keys),
|
||||||
const currentNodes = groupNodes.filter(
|
|
||||||
(ct) => ct.nodeId !== -1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 找出新增的节点
|
|
||||||
const currentNodeIds = currentNodes.map(
|
|
||||||
(ct) => ct.nodeId,
|
|
||||||
);
|
|
||||||
const addedIds = selectedIds.filter(
|
|
||||||
(id) => !currentNodeIds.includes(id),
|
|
||||||
);
|
|
||||||
const removedIds =
|
|
||||||
currentNodeIds.filter(
|
|
||||||
(id) => !selectedIds.includes(id),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 添加新节点
|
|
||||||
addedIds.forEach((nodeId) => {
|
|
||||||
addNodeToChain(groupIndex, nodeId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 删除取消选择的节点
|
|
||||||
removedIds.forEach((nodeId) => {
|
|
||||||
removeNodeFromChain(
|
|
||||||
groupIndex,
|
|
||||||
nodeId,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
@@ -1642,43 +1797,32 @@ export default function TunnelPage() {
|
|||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selectedIds = Array.from(keys).map((key) =>
|
const selectedIds = toSelectedNodeIds(keys);
|
||||||
parseInt(key as string),
|
|
||||||
);
|
|
||||||
const currentOutNodes = form.outNodeId || [];
|
|
||||||
|
|
||||||
let protocol = "tls";
|
setForm((prev) => {
|
||||||
let strategy = "round";
|
const currentOutNodes = prev.outNodeId || [];
|
||||||
|
const protocol =
|
||||||
|
currentOutNodes[0]?.protocol || "tls";
|
||||||
|
const strategy =
|
||||||
|
currentOutNodes[0]?.strategy || "round";
|
||||||
|
const realNodes = currentOutNodes.filter(
|
||||||
|
(ct) => ct.nodeId !== -1,
|
||||||
|
);
|
||||||
|
|
||||||
if (currentOutNodes.length > 0) {
|
return {
|
||||||
protocol = currentOutNodes[0].protocol || "tls";
|
...prev,
|
||||||
strategy =
|
outNodeId: mergeOrderedNodes(
|
||||||
currentOutNodes[0].strategy || "round";
|
realNodes,
|
||||||
}
|
selectedIds,
|
||||||
|
(nodeId) => ({
|
||||||
const realNodes = currentOutNodes.filter(
|
|
||||||
(ct) => ct.nodeId !== -1,
|
|
||||||
);
|
|
||||||
const newOutNodeId: ChainTunnel[] =
|
|
||||||
selectedIds.map((nodeId) => {
|
|
||||||
const existing = realNodes.find(
|
|
||||||
(ct) => ct.nodeId === nodeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
existing || {
|
|
||||||
nodeId,
|
nodeId,
|
||||||
chainType: 3,
|
chainType: 3,
|
||||||
protocol,
|
protocol,
|
||||||
strategy,
|
strategy,
|
||||||
}
|
}),
|
||||||
);
|
),
|
||||||
});
|
};
|
||||||
|
});
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
outNodeId: newOutNodeId,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
@@ -1939,7 +2083,14 @@ export default function TunnelPage() {
|
|||||||
placement="center"
|
placement="center"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="inside"
|
||||||
size="4xl"
|
size="4xl"
|
||||||
onOpenChange={setDiagnosisModalOpen}
|
onOpenChange={(open) => {
|
||||||
|
setDiagnosisModalOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
setDiagnosisLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
@@ -1968,20 +2119,41 @@ export default function TunnelPage() {
|
|||||||
)}
|
)}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody className="bg-content1">
|
<ModalBody className="bg-content1">
|
||||||
{diagnosisLoading ? (
|
{diagnosisResult ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
<span className="text-default-600">正在诊断...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : diagnosisResult ? (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{diagnosisLoading && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-primary">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>
|
||||||
|
正在诊断 {diagnosisProgress.completed}/
|
||||||
|
{diagnosisProgress.total > 0
|
||||||
|
? diagnosisProgress.total
|
||||||
|
: "?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Chip color="primary" size="sm" variant="flat">
|
||||||
|
流式更新中
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diagnosisProgress.timedOut && (
|
||||||
|
<Alert
|
||||||
|
color="warning"
|
||||||
|
description="诊断超时(单条30秒 / 整体2分钟),以下为当前已完成结果。"
|
||||||
|
title="诊断超时"
|
||||||
|
variant="flat"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 统计摘要 */}
|
{/* 统计摘要 */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{diagnosisResult.results.length}
|
{diagnosisProgress.total > 0
|
||||||
|
? diagnosisProgress.total
|
||||||
|
: diagnosisResult.results.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-default-500 mt-1">
|
<div className="text-xs text-default-500 mt-1">
|
||||||
总测试数
|
总测试数
|
||||||
@@ -1989,10 +2161,11 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
|
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
|
||||||
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||||
{
|
{diagnosisProgress.completed > 0 ||
|
||||||
diagnosisResult.results.filter((r) => r.success)
|
diagnosisProgress.total > 0
|
||||||
.length
|
? diagnosisProgress.success
|
||||||
}
|
: diagnosisResult.results.filter((r) => r.success)
|
||||||
|
.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
|
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
|
||||||
成功
|
成功
|
||||||
@@ -2000,10 +2173,11 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
|
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
|
||||||
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
||||||
{
|
{diagnosisProgress.completed > 0 ||
|
||||||
diagnosisResult.results.filter((r) => !r.success)
|
diagnosisProgress.total > 0
|
||||||
.length
|
? diagnosisProgress.failed
|
||||||
}
|
: diagnosisResult.results.filter((r) => !r.success)
|
||||||
|
.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
|
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
|
||||||
失败
|
失败
|
||||||
@@ -2076,6 +2250,10 @@ export default function TunnelPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
||||||
{results.map((result, index) => {
|
{results.map((result, index) => {
|
||||||
|
const isDiagnosing = Boolean(
|
||||||
|
result.diagnosing,
|
||||||
|
);
|
||||||
|
const isSuccess = result.success === true;
|
||||||
const quality = getDiagnosisQualityDisplay(
|
const quality = getDiagnosisQualityDisplay(
|
||||||
result.averageTime,
|
result.averageTime,
|
||||||
result.packetLoss,
|
result.packetLoss,
|
||||||
@@ -2085,22 +2263,28 @@ export default function TunnelPage() {
|
|||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||||
result.success
|
isDiagnosing
|
||||||
|
? "bg-warning-50 dark:bg-warning-900/20"
|
||||||
|
: isSuccess
|
||||||
? "bg-white dark:bg-gray-800"
|
? "bg-white dark:bg-gray-800"
|
||||||
: "bg-danger-50 dark:bg-danger-900/30"
|
: "bg-danger-50 dark:bg-danger-900/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
{isDiagnosing ? (
|
||||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
<Spinner size="sm" />
|
||||||
result.success
|
) : (
|
||||||
? "bg-success text-white"
|
<span
|
||||||
: "bg-danger text-white"
|
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||||
}`}
|
isSuccess
|
||||||
>
|
? "bg-success text-white"
|
||||||
{result.success ? "✓" : "✗"}
|
: "bg-danger text-white"
|
||||||
</span>
|
}`}
|
||||||
|
>
|
||||||
|
{isSuccess ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium text-foreground truncate">
|
<div className="font-medium text-foreground truncate">
|
||||||
{result.description}
|
{result.description}
|
||||||
@@ -2115,18 +2299,24 @@ export default function TunnelPage() {
|
|||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<Chip
|
<Chip
|
||||||
color={
|
color={
|
||||||
result.success
|
isDiagnosing
|
||||||
|
? "warning"
|
||||||
|
: isSuccess
|
||||||
? "success"
|
? "success"
|
||||||
: "danger"
|
: "danger"
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
{result.success ? "成功" : "失败"}
|
{isDiagnosing
|
||||||
|
? "诊断中"
|
||||||
|
: isSuccess
|
||||||
|
? "成功"
|
||||||
|
: "失败"}
|
||||||
</Chip>
|
</Chip>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
{result.success ? (
|
{isSuccess ? (
|
||||||
<span className="font-semibold text-primary">
|
<span className="font-semibold text-primary">
|
||||||
{result.averageTime?.toFixed(0)}
|
{result.averageTime?.toFixed(0)}
|
||||||
</span>
|
</span>
|
||||||
@@ -2137,7 +2327,7 @@ export default function TunnelPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
{result.success ? (
|
{isSuccess ? (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${
|
className={`font-semibold ${
|
||||||
(result.packetLoss || 0) > 0
|
(result.packetLoss || 0) > 0
|
||||||
@@ -2154,7 +2344,7 @@ export default function TunnelPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
{result.success && quality ? (
|
{isSuccess && quality ? (
|
||||||
<Chip
|
<Chip
|
||||||
className="text-xs whitespace-nowrap"
|
className="text-xs whitespace-nowrap"
|
||||||
color={quality.color as any}
|
color={quality.color as any}
|
||||||
@@ -2248,6 +2438,8 @@ export default function TunnelPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{results.map((result, index) => {
|
{results.map((result, index) => {
|
||||||
|
const isDiagnosing = Boolean(result.diagnosing);
|
||||||
|
const isSuccess = result.success === true;
|
||||||
const quality = getDiagnosisQualityDisplay(
|
const quality = getDiagnosisQualityDisplay(
|
||||||
result.averageTime,
|
result.averageTime,
|
||||||
result.packetLoss,
|
result.packetLoss,
|
||||||
@@ -2257,21 +2449,27 @@ export default function TunnelPage() {
|
|||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`border rounded-lg p-3 ${
|
className={`border rounded-lg p-3 ${
|
||||||
result.success
|
isDiagnosing
|
||||||
|
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
|
||||||
|
: isSuccess
|
||||||
? "border-divider bg-white dark:bg-gray-800"
|
? "border-divider bg-white dark:bg-gray-800"
|
||||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2 mb-2">
|
<div className="flex items-start gap-2 mb-2">
|
||||||
<span
|
{isDiagnosing ? (
|
||||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
<Spinner size="sm" />
|
||||||
result.success
|
) : (
|
||||||
? "bg-success text-white"
|
<span
|
||||||
: "bg-danger text-white"
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||||
}`}
|
isSuccess
|
||||||
>
|
? "bg-success text-white"
|
||||||
{result.success ? "✓" : "✗"}
|
: "bg-danger text-white"
|
||||||
</span>
|
}`}
|
||||||
|
>
|
||||||
|
{isSuccess ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-semibold text-sm text-foreground break-words">
|
<div className="font-semibold text-sm text-foreground break-words">
|
||||||
{result.description}
|
{result.description}
|
||||||
@@ -2283,16 +2481,24 @@ export default function TunnelPage() {
|
|||||||
<Chip
|
<Chip
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
color={
|
color={
|
||||||
result.success ? "success" : "danger"
|
isDiagnosing
|
||||||
|
? "warning"
|
||||||
|
: isSuccess
|
||||||
|
? "success"
|
||||||
|
: "danger"
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
{result.success ? "成功" : "失败"}
|
{isDiagnosing
|
||||||
|
? "诊断中"
|
||||||
|
: isSuccess
|
||||||
|
? "成功"
|
||||||
|
: "失败"}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result.success ? (
|
{isSuccess ? (
|
||||||
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
|
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg font-bold text-primary">
|
<div className="text-lg font-bold text-primary">
|
||||||
@@ -2336,8 +2542,16 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 pt-2 border-t border-divider">
|
<div className="mt-2 pt-2 border-t border-divider">
|
||||||
<div className="text-xs text-danger">
|
<div
|
||||||
{result.message || "连接失败"}
|
className={`text-xs ${
|
||||||
|
isDiagnosing
|
||||||
|
? "text-warning"
|
||||||
|
: "text-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDiagnosing
|
||||||
|
? result.message || "诊断中..."
|
||||||
|
: result.message || "连接失败"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2378,14 +2592,16 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
|
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
|
||||||
{diagnosisResult.results.some((r) => !r.success) && (
|
{diagnosisResult.results.some(
|
||||||
|
(r) => r.success === false && !r.diagnosing,
|
||||||
|
) && (
|
||||||
<div className="space-y-2 hidden md:block">
|
<div className="space-y-2 hidden md:block">
|
||||||
<h4 className="text-sm font-semibold text-danger">
|
<h4 className="text-sm font-semibold text-danger">
|
||||||
失败详情
|
失败详情
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{diagnosisResult.results
|
{diagnosisResult.results
|
||||||
.filter((r) => !r.success)
|
.filter((r) => r.success === false && !r.diagnosing)
|
||||||
.map((result, index) => (
|
.map((result, index) => (
|
||||||
<Alert
|
<Alert
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface DiagnosisEntry {
|
export interface DiagnosisEntry {
|
||||||
success: boolean;
|
success?: boolean;
|
||||||
|
diagnosing?: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { parseDate } from "@internationalized/date";
|
import { parseDate } from "@internationalized/date";
|
||||||
|
|
||||||
@@ -219,6 +219,22 @@ export default function UserPage() {
|
|||||||
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
|
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
|
||||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||||
|
|
||||||
|
const noLimitSpeedLimitIds = useMemo(() => {
|
||||||
|
return new Set(
|
||||||
|
speedLimits
|
||||||
|
.filter((speedLimit) => speedLimit.name.trim() === "不限速")
|
||||||
|
.map((speedLimit) => speedLimit.id),
|
||||||
|
);
|
||||||
|
}, [speedLimits]);
|
||||||
|
|
||||||
|
const normalizeSpeedId = (speedId?: number | null): number | null => {
|
||||||
|
if (speedId === null || speedId === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
|
||||||
|
};
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
@@ -432,7 +448,10 @@ export default function UserPage() {
|
|||||||
try {
|
try {
|
||||||
const tunnelsToAssign: TunnelAssignItem[] = Array.from(
|
const tunnelsToAssign: TunnelAssignItem[] = Array.from(
|
||||||
batchTunnelSelections.entries(),
|
batchTunnelSelections.entries(),
|
||||||
).map(([tunnelId, speedId]) => ({ tunnelId, speedId }));
|
).map(([tunnelId, speedId]) => ({
|
||||||
|
tunnelId,
|
||||||
|
speedId: normalizeSpeedId(speedId),
|
||||||
|
}));
|
||||||
|
|
||||||
const response = await batchAssignUserTunnel({
|
const response = await batchAssignUserTunnel({
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
@@ -456,6 +475,7 @@ export default function UserPage() {
|
|||||||
const handleEditTunnel = (userTunnel: UserTunnel) => {
|
const handleEditTunnel = (userTunnel: UserTunnel) => {
|
||||||
setEditTunnelForm({
|
setEditTunnelForm({
|
||||||
...userTunnel,
|
...userTunnel,
|
||||||
|
speedId: normalizeSpeedId(userTunnel.speedId),
|
||||||
expTime: userTunnel.expTime,
|
expTime: userTunnel.expTime,
|
||||||
});
|
});
|
||||||
onEditTunnelModalOpen();
|
onEditTunnelModalOpen();
|
||||||
@@ -472,7 +492,7 @@ export default function UserPage() {
|
|||||||
num: editTunnelForm.num,
|
num: editTunnelForm.num,
|
||||||
expTime: editTunnelForm.expTime,
|
expTime: editTunnelForm.expTime,
|
||||||
flowResetTime: editTunnelForm.flowResetTime,
|
flowResetTime: editTunnelForm.flowResetTime,
|
||||||
speedId: editTunnelForm.speedId,
|
speedId: normalizeSpeedId(editTunnelForm.speedId),
|
||||||
status: editTunnelForm.status,
|
status: editTunnelForm.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -583,13 +603,17 @@ export default function UserPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editAvailableSpeedLimits = speedLimits.filter(
|
const editAvailableSpeedLimits = speedLimits.filter(
|
||||||
(speedLimit) => speedLimit.tunnelId === editTunnelForm?.tunnelId,
|
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSpeedLimitsForTunnel = (tunnelId: number) => {
|
const getSpeedLimitsForTunnel = (_tunnelId: number) => {
|
||||||
return speedLimits.filter((sl) => sl.tunnelId === tunnelId);
|
return speedLimits.filter(
|
||||||
|
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editTunnelSelectedSpeedId = normalizeSpeedId(editTunnelForm?.speedId);
|
||||||
|
|
||||||
const toggleTunnelSelection = (tunnelId: number) => {
|
const toggleTunnelSelection = (tunnelId: number) => {
|
||||||
setBatchTunnelSelections((prev) => {
|
setBatchTunnelSelections((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
@@ -1146,7 +1170,7 @@ export default function UserPage() {
|
|||||||
classNames={{
|
classNames={{
|
||||||
trigger: "min-h-10 h-10",
|
trigger: "min-h-10 h-10",
|
||||||
}}
|
}}
|
||||||
placeholder="限速规则"
|
placeholder="不限速"
|
||||||
selectedKeys={
|
selectedKeys={
|
||||||
batchTunnelSelections.get(tunnel.id) !==
|
batchTunnelSelections.get(tunnel.id) !==
|
||||||
null &&
|
null &&
|
||||||
@@ -1157,32 +1181,29 @@ export default function UserPage() {
|
|||||||
.get(tunnel.id)!
|
.get(tunnel.id)!
|
||||||
.toString(),
|
.toString(),
|
||||||
]
|
]
|
||||||
: ["null"]
|
: []
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const value = Array.from(keys)[0] as string;
|
const selectedKey = Array.from(keys)[0] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
updateTunnelSpeedLimit(
|
updateTunnelSpeedLimit(
|
||||||
tunnel.id,
|
tunnel.id,
|
||||||
value === "null" ? null : Number(value),
|
selectedKey ? Number(selectedKey) : null,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{tunnelSpeedLimits.map((sl) => (
|
||||||
<SelectItem key="null" textValue="不限速">
|
<SelectItem
|
||||||
不限速
|
key={sl.id.toString()}
|
||||||
</SelectItem>,
|
textValue={sl.name}
|
||||||
...tunnelSpeedLimits.map((sl) => (
|
>
|
||||||
<SelectItem
|
{sl.name}
|
||||||
key={sl.id.toString()}
|
</SelectItem>
|
||||||
textValue={sl.name}
|
))}
|
||||||
>
|
|
||||||
{sl.name}
|
|
||||||
</SelectItem>
|
|
||||||
)),
|
|
||||||
]}
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1405,37 +1426,35 @@ export default function UserPage() {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="限速规则"
|
label="限速规则"
|
||||||
|
placeholder="不限速"
|
||||||
selectedKeys={
|
selectedKeys={
|
||||||
editTunnelForm.speedId
|
editTunnelSelectedSpeedId !== null
|
||||||
? [editTunnelForm.speedId.toString()]
|
? [editTunnelSelectedSpeedId.toString()]
|
||||||
: ["null"]
|
: []
|
||||||
}
|
}
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const value = Array.from(keys)[0] as string;
|
const selectedKey = Array.from(keys)[0] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
setEditTunnelForm((prev) =>
|
setEditTunnelForm((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
speedId: value === "null" ? null : Number(value),
|
speedId: selectedKey ? Number(selectedKey) : null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{editAvailableSpeedLimits.map((speedLimit) => (
|
||||||
<SelectItem key="null" textValue="不限速">
|
<SelectItem
|
||||||
不限速
|
key={speedLimit.id.toString()}
|
||||||
</SelectItem>,
|
textValue={speedLimit.name}
|
||||||
...editAvailableSpeedLimits.map((speedLimit) => (
|
>
|
||||||
<SelectItem
|
{speedLimit.name}
|
||||||
key={speedLimit.id.toString()}
|
</SelectItem>
|
||||||
textValue={speedLimit.name}
|
))}
|
||||||
>
|
|
||||||
{speedLimit.name}
|
|
||||||
</SelectItem>
|
|
||||||
)),
|
|
||||||
]}
|
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -228,11 +228,14 @@ export function Select<T>({
|
|||||||
|
|
||||||
const selectedArray = Array.from(selected);
|
const selectedArray = Array.from(selected);
|
||||||
const singleValue = selectedArray[0] ?? "";
|
const singleValue = selectedArray[0] ?? "";
|
||||||
const selectedLabels = options
|
const optionLabelMap = React.useMemo(() => {
|
||||||
.filter((option) => selected.has(option.key))
|
return new Map(options.map((option) => [option.key, option.label]));
|
||||||
.map((option) => option.label);
|
}, [options]);
|
||||||
const resolvedSelectedValues =
|
const resolvedSelectedValues = selectedArray.map((key) => {
|
||||||
selectedLabels.length > 0 ? selectedLabels : selectedArray;
|
const keyText = String(key);
|
||||||
|
|
||||||
|
return optionLabelMap.get(keyText) ?? keyText;
|
||||||
|
});
|
||||||
const selectedFullText = resolvedSelectedValues.join("、");
|
const selectedFullText = resolvedSelectedValues.join("、");
|
||||||
const selectedText =
|
const selectedText =
|
||||||
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
|
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export interface Tunnel {
|
|||||||
export interface SpeedLimit {
|
export interface SpeedLimit {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
tunnelId: number;
|
speed?: number;
|
||||||
uploadSpeed: number;
|
uploadSpeed: number;
|
||||||
downloadSpeed: number;
|
downloadSpeed: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
export type BrandAssetKind = "logo" | "favicon";
|
||||||
|
|
||||||
|
const MAX_BRAND_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||||
|
const PNG_DATA_URL_PREFIX = "data:image/png;base64,";
|
||||||
|
|
||||||
|
const OUTPUT_SIZE: Record<BrandAssetKind, number> = {
|
||||||
|
logo: 96,
|
||||||
|
favicon: 64,
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFileAsDataURL = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result !== "string") {
|
||||||
|
reject(new Error("读取图片失败"));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(reader.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("图片解析失败"));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawContainedPNG = (
|
||||||
|
image: HTMLImageElement,
|
||||||
|
size: number,
|
||||||
|
): string | null => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
const sourceWidth = image.naturalWidth || image.width;
|
||||||
|
const sourceHeight = image.naturalHeight || image.height;
|
||||||
|
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.min(size / sourceWidth, size / sourceHeight);
|
||||||
|
const drawWidth = sourceWidth * scale;
|
||||||
|
const drawHeight = sourceHeight * scale;
|
||||||
|
const drawX = (size - drawWidth) / 2;
|
||||||
|
const drawY = (size - drawHeight) / 2;
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||||
|
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPngDataURL = (value: string): boolean => {
|
||||||
|
return value.startsWith(PNG_DATA_URL_PREFIX);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertBrandAssetToPngDataURL = async (
|
||||||
|
file: File,
|
||||||
|
kind: BrandAssetKind,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
throw new Error("仅支持上传图片文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_BRAND_UPLOAD_BYTES) {
|
||||||
|
throw new Error("图片过大,请上传 2MB 以内的文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDataURL = await readFileAsDataURL(file);
|
||||||
|
const image = await loadImage(sourceDataURL);
|
||||||
|
const output = drawContainedPNG(image, OUTPUT_SIZE[kind]);
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
throw new Error("图片处理失败,请重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user