mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf9f4f1f7 | |||
| 05bd6a686d | |||
| b8193417f5 | |||
| 15e4508be4 | |||
| 634c6cd620 | |||
| 4eaecb289b | |||
| 98a9e5c666 | |||
| d244920dd4 | |||
| 77e4387b35 | |||
| 7a40ddb1ef | |||
| d33814e18c | |||
| cf51b305b0 | |||
| 9ffeb83753 | |||
| 2f40cf29d4 | |||
| a92eb168aa | |||
| de21a55f37 | |||
| b01dbdb6e5 | |||
| a645cc699b | |||
| 528f912aac | |||
| 8bf30a157f | |||
| 58abba7fc0 | |||
| d8cd4b404c | |||
| 9e979aa82a | |||
| 5caaaf6092 | |||
| f23d1c2afd | |||
| 5e00cbf131 | |||
| 975948dcf6 | |||
| a9eac6d01f | |||
| 6e8406f439 | |||
| db3577afa9 | |||
| 7285717e34 | |||
| de6911f219 | |||
| e5ce0501a2 | |||
| 25a87e25c5 | |||
| a628f31859 | |||
| aae138a8cf | |||
| d2645589da | |||
| 6684a3426b | |||
| 7a8595ec87 | |||
| 06f76d918f | |||
| feb357ff17 | |||
| 34581e0d18 | |||
| 61c5b5e759 | |||
| c8eb780c67 | |||
| 4bdfa50b0c | |||
| 21008ccb43 | |||
| 362d327bf9 | |||
| 9a650fcc8f | |||
| 804a5a29ea | |||
| 6189fe23f1 | |||
| 7ba90e8696 | |||
| 0eed74fe10 | |||
| 466cc65069 | |||
| 9c41410f17 | |||
| bc71c524e0 | |||
| f46b2b4d86 | |||
| 9f17d63cdc | |||
| 92f8ec47db | |||
| a97484cd9b | |||
| ee6bc8c50e | |||
| c94ab84ab9 | |||
| 84a03215f4 | |||
| def93749eb | |||
| 945a1c0dfc | |||
| d752e096a3 | |||
| 880a3b81b0 | |||
| 191aface2e | |||
| e121dadb90 | |||
| bafcfbde3a | |||
| 98c463c62b |
@@ -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,33 @@
|
||||
# Issue #211: 转发自定义监听IP / 隧道指定连接IP
|
||||
|
||||
## 需求总结
|
||||
1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔)
|
||||
2. **转发**: 创建/编辑时可指定入口监听IP
|
||||
3. **隧道**: 配置出口节点时可指定连接IP
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### 后端
|
||||
- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段
|
||||
- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs
|
||||
- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip
|
||||
- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)
|
||||
- [x] 5. Handler - 转发创建/更新处理 inIp 参数
|
||||
- [x] 6. Handler - 隧道出口节点处理 connectIp 参数
|
||||
- [x] 7. Handler - 节点API返回 extraIPs 字段
|
||||
|
||||
### 前端
|
||||
- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入
|
||||
- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择
|
||||
- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入
|
||||
|
||||
---
|
||||
|
||||
## 完成进度
|
||||
- 开始时间: 2026-03-02
|
||||
- 完成时间: 2026-03-02
|
||||
- 完成任务: 10/10
|
||||
- 后端完成: ✅
|
||||
- 前端完成: ✅
|
||||
@@ -1,12 +1,12 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 19 2026
|
||||
**Commit:** 137c34e
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Commit:** 21008cc
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.4-rc2
|
||||
**Tag:** 2.1.5-rc15
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -14,12 +14,14 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
├── go-gost/ # Go forwarding agent (forked gost + local x/)
|
||||
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
|
||||
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
|
||||
│ └── tests/contract/ # Integration/contract tests
|
||||
├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
|
||||
│ └── src/shadcn-bridge/heroui/ # HeroUI-compatible facade
|
||||
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
|
||||
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
|
||||
├── panel_install.sh # Panel installer/upgrader (downloads compose)
|
||||
├── install.sh # Node installer/upgrader (downloads gost binary)
|
||||
└── .github/workflows/ # CI: build/push images + release artifacts
|
||||
└── .github/workflows/ # CI: build/test + Docker push + release artifacts
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
@@ -29,12 +31,15 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
|
||||
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
|
||||
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
|
||||
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
|
||||
| **Admin API** | `go-backend/` | Go Admin API (SQLite/PostgreSQL) |
|
||||
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
|
||||
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
|
||||
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
|
||||
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
|
||||
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
|
||||
| **Repository Layer** | `go-backend/internal/store/repo/` | GORM data access (repository.go 83k LOC) |
|
||||
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
|
||||
| **CI Workflows** | `.github/workflows/` | ci-build.yml, docker-build.yml, deploy-docs.yml |
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Role |
|
||||
@@ -43,7 +48,9 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
|
||||
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
|
||||
| `main` | Func | `go-gost/main.go` | Agent Entry |
|
||||
|
||||
| `Repository` | Struct | `go-backend/internal/store/repo/repository.go` | Data Access Layer |
|
||||
| `Handler` | Struct | `go-backend/internal/http/handler/handler.go` | HTTP Handlers |
|
||||
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
||||
|
||||
## CONVENTIONS
|
||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
@@ -52,6 +59,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
|
||||
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
|
||||
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
|
||||
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
@@ -60,6 +68,8 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
|
||||
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
@@ -75,15 +85,21 @@ docker compose -f docker-compose-v6.yml up -d
|
||||
(cd go-backend && make build)
|
||||
(cd vite-frontend && npm run dev)
|
||||
(cd go-gost && go run .)
|
||||
|
||||
# Testing
|
||||
(cd go-backend && go test ./...)
|
||||
(cd go-backend && go test ./tests/contract/...)
|
||||
```
|
||||
|
||||
## UNIQUE STYLES
|
||||
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
|
||||
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
|
||||
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
|
||||
- **Experimental Bundler**: `vite-frontend` uses `rolldown-vite` (Rust-based) instead of standard Vite.
|
||||
- **Non-minified Builds**: `vite.config.ts` sets `minify: false`, `treeshake: false` for debugging.
|
||||
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
|
||||
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
|
||||
@@ -91,7 +107,9 @@ docker compose -f docker-compose-v6.yml up -d
|
||||
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
|
||||
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
|
||||
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure (Vitest/Jest not configured).
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
|
||||
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
|
||||
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged into `main`; release tag `2.1.4-rc2` points to commit `137c34e`.
|
||||
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
|
||||
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
|
||||
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
|
||||
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
|
||||
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
|
||||
@@ -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)
|
||||
- [使用指南](./usage.md)
|
||||
- [PostgreSQL 数据库指南](./postgresql.md)
|
||||
- [AI Skill 接入](./ai-skill.md) - 让大模型直接操作面板
|
||||
- [常见问题](./faq.md)
|
||||
|
||||
## 免责声明
|
||||
|
||||
+12
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
## OVERVIEW
|
||||
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
|
||||
**Stack:** Go 1.23, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -17,14 +17,15 @@ go-backend/
|
||||
│ ├── store/
|
||||
│ │ ├── model/model.go # GORM model structs (single source of truth)
|
||||
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
|
||||
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate
|
||||
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD)
|
||||
│ │ ├── repository_federation.go# Federation-specific queries
|
||||
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
|
||||
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
|
||||
│ │ ├── repository_federation.go # Federation-specific queries
|
||||
│ │ ├── repository_flow.go # Flow/forward status queries
|
||||
│ │ └── repository_control.go # Control plane queries
|
||||
│ │ ├── repository_control.go # Control plane queries
|
||||
│ │ └── repository_groups.go # Group management queries
|
||||
│ └── auth/ # Auth logic
|
||||
├── tests/ # Integration/Contract tests
|
||||
├── Dockerfile # Multi-stage build (alpine)
|
||||
├── tests/contract/ # Integration/contract tests (14 tests)
|
||||
├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
|
||||
└── Makefile # Build commands
|
||||
```
|
||||
|
||||
@@ -36,6 +37,7 @@ go-backend/
|
||||
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
|
||||
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
|
||||
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
|
||||
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
|
||||
|
||||
## CONVENTIONS
|
||||
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
|
||||
@@ -47,6 +49,7 @@ go-backend/
|
||||
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
|
||||
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
|
||||
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
|
||||
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
@@ -58,6 +61,7 @@ go-backend/
|
||||
```bash
|
||||
cd go-backend
|
||||
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
|
||||
go test ./...
|
||||
go test ./... # Unit tests
|
||||
go test ./tests/contract/... # Contract tests
|
||||
make build
|
||||
```
|
||||
|
||||
@@ -49,7 +49,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# BACKEND HTTP HANDLER KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sun Feb 15 2026
|
||||
**Generated:** Thu Feb 26 2026
|
||||
|
||||
## OVERVIEW
|
||||
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
||||
**Stack:** Go 1.23, net/http, GORM via Repository pattern.
|
||||
**Stack:** Go 1.24, net/http, GORM via Repository pattern.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -14,7 +14,7 @@ handler/
|
||||
├── federation.go # Federation/cluster sync API
|
||||
├── flow_policy.go # Traffic policy API
|
||||
├── jobs.go # Background job management (sync, cleanup)
|
||||
├── mutations.go # CRUD for users, tunnels, forwards (largest: 100k+ LOC)
|
||||
├── mutations.go # CRUD for users, tunnels, forwards (~3700 LOC)
|
||||
└── upgrade.go # System upgrade API
|
||||
```
|
||||
|
||||
@@ -26,10 +26,11 @@ handler/
|
||||
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
|
||||
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
|
||||
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
|
||||
| **Node Control** | `control_plane.go` | Node add/delete/list operations |
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.
|
||||
- Large files expected (`mutations.go` 3716 LOC - central mutation hub).
|
||||
- Large files expected (`mutations.go` ~3700 LOC - central mutation hub).
|
||||
- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.
|
||||
- Handlers never call `repo.DB()` directly — all queries go through Repository methods.
|
||||
- Domain-driven file split: one file per functional area (federation, jobs, etc.).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
@@ -30,6 +32,91 @@ type diagnosisTarget struct {
|
||||
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) {
|
||||
userID, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
@@ -152,11 +239,32 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
return errors.New("转发入口端口不存在")
|
||||
}
|
||||
|
||||
userTunnelID, limiterID, speed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
|
||||
var limiterID *int64
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -164,7 +272,9 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
|
||||
for _, fp := range ports {
|
||||
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)
|
||||
@@ -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) {
|
||||
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 (
|
||||
result ws.CommandResult
|
||||
err error
|
||||
)
|
||||
if timeout <= 0 {
|
||||
timeout = defaultNodeCommandTimeout
|
||||
}
|
||||
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
||||
result, err = h.sendRemoteNodeCommand(node, commandType, data)
|
||||
result, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)
|
||||
} else {
|
||||
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
||||
result, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if tolerateExists {
|
||||
if strings.Contains(msg, "exists") || strings.Contains(msg, "already") || strings.Contains(msg, "已存在") {
|
||||
if isAlreadyExistsMessage(msg) {
|
||||
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) {
|
||||
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 {
|
||||
return ws.CommandResult{}, errors.New("节点不存在")
|
||||
}
|
||||
@@ -311,6 +432,9 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
|
||||
}
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
if timeout > 0 {
|
||||
fc = client.NewFederationClientWithTimeout(timeout)
|
||||
}
|
||||
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
||||
CommandType: commandType,
|
||||
Data: data,
|
||||
@@ -338,41 +462,65 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
|
||||
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 {
|
||||
return nil, errForwardNotFound
|
||||
return "", nil, errForwardNotFound
|
||||
}
|
||||
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
if len(chainRows) == 0 {
|
||||
return nil, errors.New("隧道配置不完整")
|
||||
return "", nil, errors.New("隧道配置不完整")
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
||||
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
for _, inNode := range inNodes {
|
||||
for _, target := range targets {
|
||||
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
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 {
|
||||
for _, firstNode := range chainHops[0] {
|
||||
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: inNode.NodeID,
|
||||
toNode: firstNode,
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: inNode.NodeID,
|
||||
toNode: outNode,
|
||||
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) {
|
||||
for _, nextNode := range chainHops[i+1] {
|
||||
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{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: currentNode.NodeID,
|
||||
toNode: nextNode,
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 3,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: currentNode.NodeID,
|
||||
toNode: outNode,
|
||||
hasChainHop: true,
|
||||
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 _, target := range targets {
|
||||
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
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 _, target := range targets {
|
||||
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
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{}{
|
||||
"forwardName": forward.Name,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"results": results,
|
||||
"tunnelName": tunnelName,
|
||||
"tunnelType": tunnelType,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"results": results,
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", "", nil, err
|
||||
}
|
||||
if tunnelName == "" {
|
||||
return nil, errors.New("隧道不存在")
|
||||
return "", "", nil, errors.New("隧道不存在")
|
||||
}
|
||||
|
||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", "", nil, err
|
||||
}
|
||||
if len(chainRows) == 0 {
|
||||
return nil, errors.New("隧道配置不完整")
|
||||
return "", "", nil, errors.New("隧道配置不完整")
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
results := make([]map[string]interface{}, 0, len(chainRows)*2)
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: inNode.NodeID,
|
||||
targetIP: "www.bing.com",
|
||||
targetPort: 443,
|
||||
description: description,
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
case 2:
|
||||
@@ -490,19 +698,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
if len(chainHops) > 0 {
|
||||
for _, firstNode := range chainHops[0] {
|
||||
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: inNode.NodeID,
|
||||
toNode: firstNode,
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: inNode.NodeID,
|
||||
toNode: outNode,
|
||||
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) {
|
||||
for _, nextNode := range chainHops[i+1] {
|
||||
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{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: currentNode.NodeID,
|
||||
toNode: nextNode,
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 3,
|
||||
}, ipPreference)
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: currentNode.NodeID,
|
||||
toNode: outNode,
|
||||
hasChainHop: true,
|
||||
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 {
|
||||
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: outNode.NodeID,
|
||||
targetIP: "www.bing.com",
|
||||
targetPort: 443,
|
||||
description: description,
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
},
|
||||
})
|
||||
}
|
||||
default:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
workItems = append(workItems, diagnosisWorkItem{
|
||||
fromNodeID: inNode.NodeID,
|
||||
targetIP: "www.bing.com",
|
||||
targetPort: 443,
|
||||
description: description,
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"tunnelName": tunnelName,
|
||||
"tunnelType": map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1],
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"results": results,
|
||||
}
|
||||
return payload, nil
|
||||
tunnelType := map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1]
|
||||
return tunnelName, tunnelType, workItems, nil
|
||||
}
|
||||
|
||||
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
|
||||
@@ -605,6 +848,159 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
|
||||
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) {
|
||||
if node, ok := nodeCache[nodeID]; ok {
|
||||
return node, nil
|
||||
@@ -646,7 +1042,7 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
|
||||
*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)
|
||||
|
||||
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
|
||||
@@ -663,9 +1059,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
||||
pingErr error
|
||||
)
|
||||
if fromNode.IsRemote == 1 {
|
||||
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort)
|
||||
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
|
||||
} else {
|
||||
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
|
||||
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
|
||||
}
|
||||
if pingErr != nil {
|
||||
item["success"] = false
|
||||
@@ -696,26 +1092,26 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
||||
*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)
|
||||
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
|
||||
if err != nil {
|
||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, "", 0, description, metadata, err.Error())
|
||||
return
|
||||
}
|
||||
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference)
|
||||
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, "")
|
||||
if err != nil {
|
||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
||||
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, connectIp string) (string, int, error) {
|
||||
if targetNode == nil {
|
||||
return "", 0, errors.New("目标节点不存在")
|
||||
}
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIp)
|
||||
if err != nil {
|
||||
host = strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]")
|
||||
}
|
||||
@@ -761,13 +1157,19 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
|
||||
return h.repo.ListChainNodesForTunnel(tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]interface{}, error) {
|
||||
res, err := h.sendNodeCommand(nodeID, "TcpPing", map[string]interface{}{
|
||||
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||
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,
|
||||
"port": port,
|
||||
"count": 4,
|
||||
"timeout": 5000,
|
||||
}, false, false)
|
||||
"timeout": options.pingTimeoutMS,
|
||||
}, options.commandTimeout, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -777,7 +1179,7 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]
|
||||
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 {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
@@ -786,13 +1188,19 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (m
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
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{
|
||||
IP: strings.TrimSpace(ip),
|
||||
Port: port,
|
||||
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, "不存在")
|
||||
}
|
||||
|
||||
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{} {
|
||||
protocols := []string{"tcp", "udp"}
|
||||
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 {
|
||||
rate := float64(speedMbps) / 8.0
|
||||
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": strconv.FormatInt(limiterID, 10),
|
||||
"limits": []string{limitStr},
|
||||
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {
|
||||
if err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {
|
||||
return fmt.Errorf("限速规则下发失败: %w", err)
|
||||
}
|
||||
|
||||
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, nodeID := range nodes {
|
||||
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeleteLimiterConfig(limiterID int64, tunnelID int64) error {
|
||||
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) {
|
||||
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
|
||||
rate := float64(speed) / 8.0
|
||||
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},
|
||||
}
|
||||
_, _ = 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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,25 @@ import (
|
||||
// nodeSupportsV4 / nodeSupportsV6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNodeSupportsV4_Nil(t *testing.T) {
|
||||
if nodeSupportsV4(nil) {
|
||||
t.Fatal("nil node must not support v4")
|
||||
func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Empty connectIp should be ignored, IP preference takes effect
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("empty connectIp should be ignored (v4 preference applies), got %q", host)
|
||||
}
|
||||
// Non-empty connectIp should override IP preference
|
||||
host, err = selectTunnelDialHost(from, to, "v6", "192.168.0.3")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "192.168.0.3" {
|
||||
t.Fatalf("connectIp should override v6 preference, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +39,14 @@ func TestNodeSupportsV6_Nil(t *testing.T) {
|
||||
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("explicit server_ip_v4 must support v4")
|
||||
t.Fatal("explicit server_ip_v4 needs support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("explicit server_ip_v6 must support v6")
|
||||
t.Fatal("explicit server_ip_v6 needs support v6")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +84,7 @@ func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
|
||||
t.Fatal("legacy v4 ip in server_ip must support v4")
|
||||
}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("legacy v4 ip in server_ip must not support v6")
|
||||
t.Fatal("legacy v4 ip in server_ip should not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +94,7 @@ func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
|
||||
t.Fatal("legacy v6 ip in server_ip must support v6")
|
||||
}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("legacy v6 ip in server_ip must not support v4")
|
||||
t.Fatal("legacy v6 ip in server_ip should not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,15 +193,15 @@ func v6OnlyNode(name, v6 string) *nodeRecord {
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
_, err := selectTunnelDialHost(nil, nil, "")
|
||||
_, err := selectTunnelDialHost(nil, nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil nodes")
|
||||
}
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "")
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil toNode")
|
||||
}
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "")
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil fromNode")
|
||||
}
|
||||
@@ -194,8 +210,7 @@ func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -208,8 +223,7 @@ func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -221,8 +235,7 @@ func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -234,9 +247,8 @@ func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// User prefers v6, but both nodes are v4-only — should fallback to v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -248,9 +260,8 @@ func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// User prefers v4, but both nodes are v6-only — should fallback to v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -262,8 +273,7 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
_, err := selectTunnelDialHost(from, to, "")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
||||
}
|
||||
@@ -272,8 +282,7 @@ func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
_, err := selectTunnelDialHost(from, to, "")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
|
||||
}
|
||||
@@ -282,9 +291,8 @@ func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Whitespace should be trimmed, treated as "v6"
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ")
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -296,9 +304,8 @@ func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// v6 preferred, but target only has v4 — should succeed with v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -310,9 +317,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but target only has v6 — should succeed with v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -324,9 +330,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -338,9 +343,8 @@ func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but from only has v6 — should use v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -367,7 +371,6 @@ func TestNodeDisplayName_Named(t *testing.T) {
|
||||
t.Fatalf("expected 'hk-node', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDisplayName_Unnamed(t *testing.T) {
|
||||
n := &nodeRecord{ID: 42}
|
||||
got := nodeDisplayName(n)
|
||||
|
||||
@@ -477,9 +477,14 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
forwardPortRows, err := h.repo.ListActiveForwardPortsForNode(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
usedSet := make(map[int]struct{})
|
||||
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows))
|
||||
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows)+len(forwardPortRows))
|
||||
for _, b := range bindingRows {
|
||||
bindings = append(bindings, remoteUsageBindingItem{
|
||||
BindingID: b.ID,
|
||||
@@ -496,6 +501,29 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
usedSet[b.AllocatedPort] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, fp := range forwardPortRows {
|
||||
bindings = append(bindings, remoteUsageBindingItem{
|
||||
BindingID: -fp.ForwardID,
|
||||
TunnelID: fp.TunnelID,
|
||||
TunnelName: fp.TunnelName,
|
||||
ChainType: 1,
|
||||
HopInx: 0,
|
||||
AllocatedPort: fp.Port,
|
||||
ResourceKey: fmt.Sprintf("forward:%d", fp.ForwardID),
|
||||
RemoteBindingID: "",
|
||||
UpdatedTime: fp.UpdatedTime,
|
||||
})
|
||||
if fp.Port > 0 {
|
||||
usedSet[fp.Port] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(bindings, func(i, j int) bool {
|
||||
if bindings[i].AllocatedPort == bindings[j].AllocatedPort {
|
||||
return bindings[i].BindingID < bindings[j].BindingID
|
||||
}
|
||||
return bindings[i].AllocatedPort < bindings[j].AllocatedPort
|
||||
})
|
||||
|
||||
usedPorts := make([]int, 0, len(usedSet))
|
||||
for port := range usedSet {
|
||||
@@ -766,6 +794,37 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, port := range usedPorts {
|
||||
if port == req.RemotePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if runtimeOnPort != nil {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if existsOnNodePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
tunnelID, err := h.repo.CreateFederationTunnel(
|
||||
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||
@@ -780,6 +839,30 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
runtime := &repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
|
||||
BindingID: "",
|
||||
Role: "forward",
|
||||
ChainName: "",
|
||||
ServiceName: "",
|
||||
Protocol: defaultString(req.Protocol, "tcp"),
|
||||
Strategy: "fifo",
|
||||
Port: req.RemotePort,
|
||||
Target: strings.TrimSpace(req.Target),
|
||||
Applied: 0,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
|
||||
_ = h.deleteTunnelByID(tunnelID)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
@@ -1149,16 +1232,20 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
||||
if req.Count <= 0 {
|
||||
req.Count = 4
|
||||
}
|
||||
if req.Timeout <= 0 {
|
||||
req.Timeout = 5000
|
||||
if req.Timeout <= 0 || req.Timeout > int(diagnosisCommandTimeout/time.Millisecond) {
|
||||
req.Timeout = int(diagnosisCommandTimeout / time.Millisecond)
|
||||
}
|
||||
commandTimeout := time.Duration(req.Timeout) * time.Millisecond
|
||||
if commandTimeout <= 0 || commandTimeout > diagnosisCommandTimeout {
|
||||
commandTimeout = diagnosisCommandTimeout
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, "TcpPing", map[string]interface{}{
|
||||
res, err := h.sendNodeCommandWithTimeout(share.NodeID, "TcpPing", map[string]interface{}{
|
||||
"ip": req.IP,
|
||||
"port": req.Port,
|
||||
"count": req.Count,
|
||||
"timeout": req.Timeout,
|
||||
}, false, false)
|
||||
}, commandTimeout, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -1211,12 +1298,185 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
|
||||
h.bindPeerShareForwardRuntimeServices(share, req.Data)
|
||||
} else if strings.EqualFold(cmd, "deleteservice") {
|
||||
h.releasePeerShareForwardRuntimeServices(share, req.Data)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
type federationForwardServiceBinding struct {
|
||||
Name string
|
||||
Port int
|
||||
}
|
||||
|
||||
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entries := asMapSlice(data); len(entries) > 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
|
||||
serviceList := extractFederationServiceEntries(data)
|
||||
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
|
||||
for _, svcMap := range serviceList {
|
||||
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(asString(svcMap["addr"]))
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
continue
|
||||
}
|
||||
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
|
||||
names := make(map[string]struct{})
|
||||
appendName := func(raw string) {
|
||||
name := normalizeForwardRuntimeServiceName(raw)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
return
|
||||
}
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, svcMap := range extractFederationServiceEntries(data) {
|
||||
appendName(asString(svcMap["name"]))
|
||||
}
|
||||
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
for _, item := range asAnySlice(dataMap["services"]) {
|
||||
appendName(asString(item))
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range asAnySlice(data) {
|
||||
appendName(asString(item))
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
}
|
||||
bindings := parseFederationForwardServiceBindings(data)
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, binding := range bindings {
|
||||
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if runtime == nil {
|
||||
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
ResourceKey: fmt.Sprintf("forward-runtime:%d:%s:%d:%s", share.ID, binding.Name, binding.Port, randomToken(8)),
|
||||
BindingID: "",
|
||||
Role: "forward",
|
||||
ChainName: "",
|
||||
ServiceName: binding.Name,
|
||||
Protocol: "tcp",
|
||||
Strategy: "fifo",
|
||||
Port: binding.Port,
|
||||
Target: "",
|
||||
Applied: 1,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
|
||||
continue
|
||||
}
|
||||
runtime.ServiceName = binding.Name
|
||||
runtime.Port = binding.Port
|
||||
runtime.Applied = 1
|
||||
runtime.Status = 1
|
||||
runtime.UpdatedTime = now
|
||||
if strings.TrimSpace(runtime.Protocol) == "" {
|
||||
runtime.Protocol = "tcp"
|
||||
}
|
||||
if strings.TrimSpace(runtime.Strategy) == "" {
|
||||
runtime.Strategy = "fifo"
|
||||
}
|
||||
_ = h.repo.UpdatePeerShareRuntime(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
}
|
||||
names := parseFederationForwardServiceNamesForRelease(data)
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, name := range names {
|
||||
_ = h.repo.MarkForwardPeerShareRuntimeReleasedByServiceName(share.ID, name, now)
|
||||
}
|
||||
}
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1236,36 +1496,26 @@ func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) err
|
||||
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
|
||||
return nil
|
||||
}
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
|
||||
serviceList := extractFederationServiceEntries(data)
|
||||
if len(serviceList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if services, ok := dataMap["services"]; ok {
|
||||
serviceList, ok := services.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid services format")
|
||||
for _, svcMap := range serviceList {
|
||||
addr := asString(svcMap["addr"])
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
for _, svc := range serviceList {
|
||||
svcMap, ok := svc.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid service entry format")
|
||||
}
|
||||
addr, ok := svcMap["addr"].(string)
|
||||
if !ok || addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service address: %s", addr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
return fmt.Errorf("invalid port in service address: %s", addr)
|
||||
}
|
||||
if port < share.PortRangeStart || port > share.PortRangeEnd {
|
||||
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service address: %s", addr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
return fmt.Errorf("invalid port in service address: %s", addr)
|
||||
}
|
||||
if port < share.PortRangeStart || port > share.PortRangeEnd {
|
||||
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +414,445 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
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, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-forward-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-forward-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24000,
|
||||
PortRangeEnd: 24020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("federation-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(federationTunnelRequest{
|
||||
Protocol: "tcp",
|
||||
RemotePort: 24001,
|
||||
Target: "1.1.1.1:443",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+share.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationTunnelCreate(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
|
||||
if runtimeCount != 1 {
|
||||
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
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, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-port-check-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-port-check-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24100,
|
||||
PortRangeEnd: 24120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
|
||||
create := func() response.R {
|
||||
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer federation-port-check-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
h.federationTunnelCreate(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
first := create()
|
||||
if first.Code != 0 {
|
||||
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
|
||||
}
|
||||
|
||||
second := create()
|
||||
if second.Code != 403 {
|
||||
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
|
||||
}
|
||||
if second.Msg != "Port already in use" {
|
||||
t.Fatalf("expected occupied port message, got %q", second.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "delete-forward-share",
|
||||
NodeID: 1,
|
||||
Token: "delete-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 25000,
|
||||
PortRangeEnd: 25020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("delete-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := h.deleteTunnelByID(1); err != nil {
|
||||
t.Fatalf("delete tunnel: %v", err)
|
||||
}
|
||||
|
||||
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
|
||||
if activeCount != 0 {
|
||||
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-forward-role-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-forward-role-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26000,
|
||||
PortRangeEnd: 26020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-forward-role-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
|
||||
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert runtimes: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
|
||||
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
|
||||
},
|
||||
})
|
||||
|
||||
forwardServiceName := ""
|
||||
middleServiceName := ""
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
|
||||
t.Fatalf("load forward runtime service name: %v", err)
|
||||
}
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
|
||||
t.Fatalf("load middle runtime service name: %v", err)
|
||||
}
|
||||
|
||||
if forwardServiceName != "77_2_10" {
|
||||
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
|
||||
}
|
||||
if middleServiceName != "" {
|
||||
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-array-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-array-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26100,
|
||||
PortRangeEnd: 26120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-array-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
|
||||
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
|
||||
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
|
||||
})
|
||||
|
||||
forwardServiceName := ""
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
|
||||
t.Fatalf("load forward runtime service name: %v", err)
|
||||
}
|
||||
if forwardServiceName != "99_2_10" {
|
||||
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesCreatesRuntimeWhenMissing(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-bind-create-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-create-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-create-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26300,
|
||||
PortRangeEnd: 26320,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-create-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{"name": "55_2_10_tcp", "addr": "[::]:26301"},
|
||||
},
|
||||
})
|
||||
|
||||
var count int64
|
||||
if err := r.DB().Raw(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND role = ? AND status = 1`, share.ID, "forward").Scan(&count).Error; err != nil {
|
||||
t.Fatalf("query runtime count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 active forward runtime row, got %d", count)
|
||||
}
|
||||
|
||||
var serviceName string
|
||||
var port int
|
||||
var applied int
|
||||
if err := r.DB().Raw(`SELECT service_name, port, applied FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&serviceName, &port, &applied); err != nil {
|
||||
t.Fatalf("query created runtime: %v", err)
|
||||
}
|
||||
if serviceName != "55_2_10" {
|
||||
t.Fatalf("expected service_name=55_2_10, got %q", serviceName)
|
||||
}
|
||||
if port != 26301 {
|
||||
t.Fatalf("expected port=26301, got %d", port)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("expected applied=1, got %d", applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePeerShareForwardRuntimeServicesMarksRuntimeReleased(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-release-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "release-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "release-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26400,
|
||||
PortRangeEnd: 26420,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("release-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "release-r1", "release-rk1", "", "forward", "", "77_2_10", "tcp", "fifo", 26401, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
h.releasePeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{"77_2_10_tcp"},
|
||||
})
|
||||
|
||||
var status int
|
||||
var applied int
|
||||
var serviceName string
|
||||
if err := r.DB().Raw(`SELECT status, applied, service_name FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&status, &applied, &serviceName); err != nil {
|
||||
t.Fatalf("query released runtime: %v", err)
|
||||
}
|
||||
if status != 0 {
|
||||
t.Fatalf("expected status=0 after release, got %d", status)
|
||||
}
|
||||
if applied != 0 {
|
||||
t.Fatalf("expected applied=0 after release, got %d", applied)
|
||||
}
|
||||
if serviceName != "" {
|
||||
t.Fatalf("expected service_name cleared after release, got %q", serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
|
||||
share := &repo.PeerShare{
|
||||
PortRangeStart: 26200,
|
||||
PortRangeEnd: 26210,
|
||||
}
|
||||
err := validateFederationCommandPorts(share, []interface{}{
|
||||
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
|
||||
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
@@ -502,6 +941,110 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageListIncludesForwardPorts(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-usage.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
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, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-usage-remote-node", "forward-usage-secret", "10.60.70.80", "10.60.70.80", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", `{"shareId":99,"maxBandwidth":0,"currentFlow":0,"portRangeStart":33000,"portRangeEnd":33010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
|
||||
var nodeID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-remote-node").Row().Scan(&nodeID); err != nil {
|
||||
t.Fatalf("query node id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-usage-tunnel", 1, "tls", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
var tunnelID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-tunnel").Row().Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id: %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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "tester", "forward-usage-item", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
var forwardID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-item").Row().Scan(&forwardID); err != nil {
|
||||
t.Fatalf("query forward id: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationRemoteUsageList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usage row map, got %T", rows[0])
|
||||
}
|
||||
|
||||
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||
}
|
||||
if len(usedPortsRaw) != 1 || int(usedPortsRaw[0].(float64)) != 33001 {
|
||||
t.Fatalf("expected usedPorts [33001], got %v", usedPortsRaw)
|
||||
}
|
||||
|
||||
bindingsRaw, ok := first["bindings"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected bindings array, got %T", first["bindings"])
|
||||
}
|
||||
if len(bindingsRaw) != 1 {
|
||||
t.Fatalf("expected 1 binding row from forward usage, got %d", len(bindingsRaw))
|
||||
}
|
||||
|
||||
binding, ok := bindingsRaw[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected binding row object, got %T", bindingsRaw[0])
|
||||
}
|
||||
if int(binding["allocatedPort"].(float64)) != 33001 {
|
||||
t.Fatalf("expected allocatedPort=33001, got %v", binding["allocatedPort"])
|
||||
}
|
||||
if int(binding["chainType"].(float64)) != 1 {
|
||||
t.Fatalf("expected chainType=1 for forward usage row, got %v", binding["chainType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -2,9 +2,12 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const bytesPerGB int64 = 1024 * 1024 * 1024
|
||||
@@ -30,7 +33,7 @@ type namedConfigItem struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) processFlowItem(item flowItem) {
|
||||
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
return
|
||||
@@ -40,6 +43,7 @@ func (h *Handler) processFlowItem(item flowItem) {
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
@@ -87,6 +91,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
|
||||
return runtimeID, true
|
||||
}
|
||||
|
||||
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
|
||||
port, err := strconv.Atoi(portValue)
|
||||
if err != nil || port <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return shareID, port, true
|
||||
}
|
||||
|
||||
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return shareID, true
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||
return
|
||||
@@ -113,6 +156,121 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || forwardID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
// Forward not found in local database - might be a federation port-forward
|
||||
// Try to find by service name in peer_share_runtime
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
|
||||
if err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func normalizeForwardRuntimeServiceName(serviceName string) string {
|
||||
name := strings.TrimSpace(serviceName)
|
||||
if strings.HasSuffix(name, "_tcp") {
|
||||
return strings.TrimSuffix(name, "_tcp")
|
||||
}
|
||||
if strings.HasSuffix(name, "_udp") {
|
||||
return strings.TrimSuffix(name, "_udp")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
normalized := normalizeForwardRuntimeServiceName(serviceName)
|
||||
var runtimes []model.PeerShareRuntime
|
||||
var err error
|
||||
|
||||
// Try node-scoped query first if nodeID is valid
|
||||
if nodeID > 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
|
||||
if len(runtimes) == 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(runtimes) != 1 {
|
||||
if len(runtimes) > 1 {
|
||||
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
|
||||
}
|
||||
return
|
||||
}
|
||||
runtime := runtimes[0]
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || matchedShare == nil {
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(matchedShare) {
|
||||
h.enforcePeerShareFlowLimit(matchedShare.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
@@ -271,15 +429,46 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
|
||||
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
|
||||
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
|
||||
if err != nil {
|
||||
hasUnboundForwardPeerRuntime = false
|
||||
}
|
||||
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
|
||||
for _, serviceName := range runtimeServiceNames {
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if serviceName == "" {
|
||||
continue
|
||||
}
|
||||
runtimeServiceSet[serviceName] = struct{}{}
|
||||
}
|
||||
|
||||
for _, item := range services {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "fed_svc_") {
|
||||
continue
|
||||
}
|
||||
normalizedName := normalizeForwardRuntimeServiceName(name)
|
||||
if _, ok := runtimeServiceSet[normalizedName]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := runtimeServiceSet[name]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) >= 3 {
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
|
||||
continue
|
||||
@@ -299,6 +488,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
continue
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -43,7 +44,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
@@ -61,3 +62,345 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-share-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 30000,
|
||||
PortRangeEnd: 30010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
|
||||
`, now+24*60*60*1000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, tunnelName, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+24*60*60*1000).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-service-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-service-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31000,
|
||||
PortRangeEnd: 31010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-service-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "collision-share",
|
||||
NodeID: 1,
|
||||
Token: "collision-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31400,
|
||||
PortRangeEnd: 31410,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("collision-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert local tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert local forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-a",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-a",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31100,
|
||||
PortRangeEnd: 31110,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share A: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-b",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-b",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31200,
|
||||
PortRangeEnd: 31210,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share B: %v", err)
|
||||
}
|
||||
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
|
||||
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
|
||||
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert ambiguous runtimes: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "99_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedA, _ := r.GetPeerShare(shareA.ID)
|
||||
updatedB, _ := r.GetPeerShare(shareB.ID)
|
||||
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
|
||||
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "cleanup-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "cleanup-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31300,
|
||||
PortRangeEnd: 31310,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "cleanup-forward-runtime-empty-service",
|
||||
NodeID: 1,
|
||||
Token: "cleanup-forward-runtime-empty-service-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31420,
|
||||
PortRangeEnd: 31430,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -72,6 +73,11 @@ type flowItem struct {
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
|
||||
const (
|
||||
pngDataURLPrefix = "data:image/png;base64,"
|
||||
maxBrandAssetDataURLBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
@@ -128,6 +134,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
|
||||
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
||||
@@ -143,6 +150,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
|
||||
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
|
||||
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
|
||||
@@ -153,7 +161,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/delete", h.speedLimitDelete)
|
||||
mux.HandleFunc("/api/v1/speed-limit/tunnels", h.tunnelList)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/tunnel", h.userTunnelVisibleList)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/list", h.userTunnelList)
|
||||
mux.HandleFunc("/api/v1/group/tunnel/list", h.tunnelGroupList)
|
||||
@@ -219,7 +226,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if captchaEnabled {
|
||||
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
|
||||
captchaID := strings.TrimSpace(req.CaptchaID)
|
||||
if captchaID == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
@@ -704,7 +711,8 @@ func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.URL.Query().Get("secret")
|
||||
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
|
||||
node, _ := h.repo.GetNodeBySecret(secret)
|
||||
if node == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
@@ -715,7 +723,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
for _, item := range items {
|
||||
h.processFlowItem(item)
|
||||
h.processFlowItem(node.ID, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,7 +754,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if key == "" {
|
||||
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()))
|
||||
return
|
||||
}
|
||||
@@ -766,16 +781,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Value) == "" {
|
||||
|
||||
value, err := normalizeAndValidateConfigValue(name, req.Value)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if value == "" && name != "app_logo" && name != "app_favicon" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil {
|
||||
if err := h.repo.UpsertConfig(name, value, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -783,6 +806,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "app_logo", "app_favicon":
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(normalized, pngDataURLPrefix) {
|
||||
return "", fmt.Errorf("品牌图片必须通过上传生成 PNG 数据")
|
||||
}
|
||||
|
||||
if len(normalized) > maxBrandAssetDataURLBytes {
|
||||
return "", fmt.Errorf("品牌图片过大,请上传更小图片")
|
||||
}
|
||||
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(normalized, pngDataURLPrefix))
|
||||
if payload == "" {
|
||||
return "", fmt.Errorf("品牌图片数据不能为空")
|
||||
}
|
||||
|
||||
if _, err := base64.StdEncoding.DecodeString(payload); err != nil {
|
||||
return "", fmt.Errorf("品牌图片数据格式无效")
|
||||
}
|
||||
|
||||
return pngDataURLPrefix + payload, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -988,23 +1042,43 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cfg == nil || !strings.EqualFold(cfg.Value, "true") {
|
||||
if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// captcha_enabled=true, but we need to verify cloudflare_secret_key is configured
|
||||
// If secret key is not configured, treat captcha as disabled to avoid blocking login
|
||||
secretKey, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
siteCfg, err := h.repo.GetConfigByName("cloudflare_site_key")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if secretKey == nil || strings.TrimSpace(secretKey.Value) == "" {
|
||||
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 {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
|
||||
switch client {
|
||||
case "whmcs", "whmcs-module":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) markCaptchaToken(token string) {
|
||||
if h == nil {
|
||||
return
|
||||
|
||||
@@ -69,6 +69,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("insert expired user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
@@ -83,6 +90,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("insert expired user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
@@ -90,6 +104,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring forward: %v", err)
|
||||
}
|
||||
|
||||
h.runResetAndExpiryJob(now)
|
||||
|
||||
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
|
||||
@@ -106,4 +127,19 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
if forwardStatus != 0 {
|
||||
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
|
||||
}
|
||||
|
||||
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
|
||||
if nonExpUserStatus != 1 {
|
||||
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
|
||||
}
|
||||
|
||||
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
|
||||
if nonExpTunnelStatus != 1 {
|
||||
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
|
||||
}
|
||||
|
||||
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
|
||||
if nonExpForwardStatus != 1 {
|
||||
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
@@ -270,6 +271,7 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
nullableText(asString(req["remoteUrl"])),
|
||||
nullableText(asString(req["remoteToken"])),
|
||||
nullableText(asString(req["remoteConfig"])),
|
||||
nullableText(asString(req["extraIPs"])),
|
||||
); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -321,6 +323,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
nullableText(asString(req["serverIpV6"])),
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(asString(req["extraIPs"])),
|
||||
newHTTP,
|
||||
newTLS,
|
||||
newSocks,
|
||||
@@ -787,7 +790,9 @@ func (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {
|
||||
if id <= 0 {
|
||||
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 strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -1048,21 +1053,55 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("权限ID不能为空"))
|
||||
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,
|
||||
asInt64(req["flow"], 0),
|
||||
asInt(req["num"], 0),
|
||||
asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()),
|
||||
asInt64(req["flowResetTime"], 1),
|
||||
nullableInt(asAnyToInt64Ptr(req["speedId"])),
|
||||
nullableInt(speedID),
|
||||
asInt(req["status"], 1),
|
||||
); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
userID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)
|
||||
if utErr == nil {
|
||||
h.syncUserTunnelForwards(userID, tunnelID)
|
||||
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
|
||||
rollbackErr := h.repo.UpdateUserTunnel(
|
||||
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())
|
||||
@@ -1103,6 +1142,18 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
|
||||
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)
|
||||
if port <= 0 {
|
||||
port = h.pickTunnelPort(tunnelID)
|
||||
@@ -1127,7 +1178,8 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if userName == "" {
|
||||
userName = "user"
|
||||
}
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port)
|
||||
inIp := strings.TrimSpace(asString(req["inIp"]))
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1137,7 +1189,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.syncForwardServices(createdForward, "AddService", false); err != nil {
|
||||
if err := h.syncForwardServices(createdForward, "UpdateService", true); err != nil {
|
||||
_ = h.deleteForwardByID(forwardID)
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -1202,6 +1254,24 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if 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)
|
||||
if port <= 0 {
|
||||
@@ -1213,6 +1283,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
port = h.pickTunnelPort(tunnelID)
|
||||
}
|
||||
}
|
||||
inIp := asString(req["inIp"])
|
||||
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||
for _, nodeID := range fwdEntryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
@@ -1225,11 +1296,11 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
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()))
|
||||
return
|
||||
}
|
||||
if err := h.replaceForwardPorts(id, tunnelID, port); err != nil {
|
||||
if err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1335,7 +1406,9 @@ func (h *Handler) forwardDiagnose(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
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 strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -1559,7 +1632,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p); err != nil {
|
||||
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p, ""); err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
fail++
|
||||
continue
|
||||
@@ -1586,29 +1659,22 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
tunnelID := asInt64(req["tunnelId"], 0)
|
||||
if tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
name := asString(req["name"])
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
|
||||
return
|
||||
}
|
||||
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
|
||||
if tunnelName == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
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 {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1618,23 +1684,26 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
id := asInt64(req["id"], 0)
|
||||
tunnelID := asInt64(req["tunnelId"], 0)
|
||||
if id <= 0 || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
if id <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("限速规则ID不能为空"))
|
||||
return
|
||||
}
|
||||
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
|
||||
if tunnelName == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
|
||||
name := asString(req["name"])
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
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()))
|
||||
return
|
||||
}
|
||||
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1643,15 +1712,12 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
tunnelID := h.repo.GetSpeedLimitTunnelID(id)
|
||||
|
||||
if err := h.repo.DeleteSpeedLimit(id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if tunnelID > 0 {
|
||||
_ = h.sendDeleteLimiterConfig(id, tunnelID)
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1892,6 +1958,7 @@ type tunnelRuntimeNode struct {
|
||||
Inx int
|
||||
ChainType int
|
||||
Port int
|
||||
ConnectIP string
|
||||
}
|
||||
|
||||
type tunnelCreateState struct {
|
||||
@@ -1965,6 +2032,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
Strategy: defaultString(asString(item["strategy"]), "round"),
|
||||
ChainType: 3,
|
||||
Port: port,
|
||||
ConnectIP: asString(item["connectIp"]),
|
||||
})
|
||||
}
|
||||
if len(state.OutNodes) == 0 {
|
||||
@@ -2000,6 +2068,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
Inx: hopIdx + 1,
|
||||
ChainType: 2,
|
||||
Port: port,
|
||||
ConnectIP: asString(item["connectIp"]),
|
||||
})
|
||||
}
|
||||
if len(hop) > 0 {
|
||||
@@ -2276,7 +2345,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, errors.New("节点不存在")
|
||||
}
|
||||
host, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference)
|
||||
host, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference, target.ConnectIP)
|
||||
if hostErr != nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, hostErr
|
||||
@@ -2516,7 +2585,7 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
||||
if targetNode == nil {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, target.ConnectIP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2589,10 +2658,13 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
||||
return []map[string]interface{}{service}
|
||||
}
|
||||
|
||||
func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string) (string, error) {
|
||||
func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {
|
||||
if fromNode == nil || toNode == nil {
|
||||
return "", errors.New("节点不存在")
|
||||
}
|
||||
if strings.TrimSpace(connectIp) != "" {
|
||||
return strings.TrimSpace(connectIp), nil
|
||||
}
|
||||
fromV4 := nodeSupportsV4(fromNode)
|
||||
fromV6 := nodeSupportsV6(fromNode)
|
||||
toV4 := nodeSupportsV4(toNode)
|
||||
@@ -2713,7 +2785,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
|
||||
func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {
|
||||
allocated := map[int64]int{}
|
||||
inNodes := asMapSlice(req["inNodeId"])
|
||||
for _, n := range inNodes {
|
||||
for i, n := range inNodes {
|
||||
nodeID := asInt64(n["nodeId"], 0)
|
||||
if nodeID <= 0 {
|
||||
continue
|
||||
@@ -2725,13 +2797,14 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
nodeID,
|
||||
sql.NullInt64{},
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
0,
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
"",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, n := range asMapSlice(req["outNodeId"]) {
|
||||
for i, n := range asMapSlice(req["outNodeId"]) {
|
||||
nodeID := asInt64(n["nodeId"], 0)
|
||||
if nodeID <= 0 {
|
||||
continue
|
||||
@@ -2744,6 +2817,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
return pickErr
|
||||
}
|
||||
}
|
||||
connectIp := asString(n["connectIp"])
|
||||
if err := h.repo.CreateChainTunnelTx(
|
||||
tx,
|
||||
tunnelID,
|
||||
@@ -2751,8 +2825,9 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
nodeID,
|
||||
sql.NullInt64{Int64: int64(port), Valid: true},
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
0,
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
connectIp,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2772,6 +2847,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
return pickErr
|
||||
}
|
||||
}
|
||||
connectIp := asString(n["connectIp"])
|
||||
if err := h.repo.CreateChainTunnelTx(
|
||||
tx,
|
||||
tunnelID,
|
||||
@@ -2781,6 +2857,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
connectIp,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2794,7 +2871,21 @@ func (h *Handler) deleteNodeByID(id int64) error {
|
||||
}
|
||||
|
||||
func (h *Handler) deleteTunnelByID(id int64) error {
|
||||
return h.repo.DeleteTunnelCascade(id)
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
|
||||
tunnelName, _ := h.repo.GetTunnelName(id)
|
||||
if err := h.repo.DeleteTunnelCascade(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardByID(id int64) error {
|
||||
@@ -2923,7 +3014,7 @@ func parsePorts(portRange string) ([]int, error) {
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int) error {
|
||||
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2931,12 +3022,14 @@ func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int) error
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(entryNodes))
|
||||
for i, nid := range entryNodes {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
}{NodeID: nid, Port: port}
|
||||
InIP string
|
||||
}{NodeID: nid, Port: port, InIP: inIp}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
@@ -2945,12 +3038,14 @@ func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwar
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(ports))
|
||||
for i, fp := range ports {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
}{NodeID: fp.NodeID, Port: fp.Port}
|
||||
InIP string
|
||||
}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
@@ -2963,6 +3058,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []
|
||||
h.repo.RollbackForwardFields(
|
||||
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
|
||||
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
|
||||
oldForward.SpeedID,
|
||||
time.Now().UnixMilli(),
|
||||
)
|
||||
|
||||
@@ -2984,6 +3080,10 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
h.repo.GetExistingUserTunnel(userID, tunnelID)
|
||||
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if err := h.validateSpeedLimitReference(speedID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reqFlow := asInt64(req["flow"], -1)
|
||||
reqNum := asInt(req["num"], -1)
|
||||
reqExpTime := asInt64(req["expTime"], -1)
|
||||
@@ -3024,7 +3124,24 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
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 {
|
||||
return err
|
||||
@@ -3062,25 +3179,61 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
newSpeedID = sql.NullInt64{Valid: false}
|
||||
}
|
||||
|
||||
err = h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus)
|
||||
|
||||
if err == nil {
|
||||
h.syncUserTunnelForwards(userID, tunnelID)
|
||||
if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
for i := range forwards {
|
||||
f := &forwards[i]
|
||||
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{} {
|
||||
|
||||
@@ -29,28 +29,30 @@ func (User) TableName() string { return "user" }
|
||||
|
||||
// Forward maps to the "forward" table.
|
||||
type Forward struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
}
|
||||
|
||||
func (Forward) TableName() string { return "forward" }
|
||||
|
||||
type ForwardPort struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port int `gorm:"not null"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port int `gorm:"not null"`
|
||||
InIP sql.NullString `gorm:"column:in_ip;type:text"`
|
||||
}
|
||||
|
||||
func (ForwardPort) TableName() string { return "forward_port" }
|
||||
@@ -62,6 +64,7 @@ type Node struct {
|
||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||
ServerIPV6 sql.NullString `gorm:"column:server_ip_v6;type:varchar(100)"`
|
||||
ExtraIPs sql.NullString `gorm:"column:extra_ips;type:text"`
|
||||
Port string `gorm:"type:text;not null"`
|
||||
InterfaceName sql.NullString `gorm:"column:interface_name;type:varchar(200)"`
|
||||
Version sql.NullString `gorm:"type:varchar(100)"`
|
||||
@@ -83,14 +86,14 @@ type Node struct {
|
||||
func (Node) TableName() string { return "node" }
|
||||
|
||||
type SpeedLimit struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
TunnelName string `gorm:"column:tunnel_name;type:varchar(100);not null"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||
@@ -132,6 +135,7 @@ type ChainTunnel struct {
|
||||
Strategy sql.NullString `gorm:"type:varchar(10)"`
|
||||
Inx sql.NullInt64 `gorm:"column:inx"`
|
||||
Protocol sql.NullString `gorm:"type:varchar(10)"`
|
||||
ConnectIP sql.NullString `gorm:"column:connect_ip;type:varchar(45)"`
|
||||
}
|
||||
|
||||
func (ChainTunnel) TableName() string { return "chain_tunnel" }
|
||||
@@ -213,7 +217,7 @@ func (GroupPermissionGrant) TableName() string { return "group_permission_grant"
|
||||
type ViteConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -336,6 +340,7 @@ type NodeBackup struct {
|
||||
ServerIP string `json:"serverIp"`
|
||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||
ServerIPv6 string `json:"serverIpV6,omitempty"`
|
||||
ExtraIPs string `json:"extraIPs,omitempty"`
|
||||
Port string `json:"port"`
|
||||
InterfaceName string `json:"interfaceName,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
@@ -395,6 +400,7 @@ type ForwardBackup struct {
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Inx int `json:"inx"`
|
||||
SpeedID *int64 `json:"speedId,omitempty"`
|
||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||
}
|
||||
|
||||
@@ -421,8 +427,8 @@ type SpeedLimitBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
TunnelName string `json:"tunnelName"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
@@ -492,6 +498,7 @@ type ForwardRecord struct {
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
SpeedID sql.NullInt64
|
||||
}
|
||||
|
||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||
@@ -507,6 +514,7 @@ type TunnelRecord struct {
|
||||
type ForwardPortRecord struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}
|
||||
|
||||
// NodeRecord is a node view used by control plane.
|
||||
@@ -516,6 +524,7 @@ type NodeRecord struct {
|
||||
ServerIP string
|
||||
ServerIPv4 string
|
||||
ServerIPv6 string
|
||||
ExtraIPs string
|
||||
Status int
|
||||
PortRange string
|
||||
TCPListenAddr string
|
||||
@@ -535,6 +544,7 @@ type ChainNodeRecord struct {
|
||||
NodeName string
|
||||
Protocol string
|
||||
Strategy string
|
||||
ConnectIP string
|
||||
}
|
||||
|
||||
type UserTunnelLimiterInfo struct {
|
||||
|
||||
@@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
|
||||
if m.HasTable(&model.Node{}) {
|
||||
for _, field := range []string{"ServerIPV4", "ServerIPV6", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
|
||||
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
|
||||
if m.HasColumn(&model.Node{}, field) {
|
||||
continue
|
||||
}
|
||||
@@ -637,6 +637,7 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"serverIpV4": nullableString(n.ServerIPV4),
|
||||
"serverIpV6": nullableString(n.ServerIPV6),
|
||||
"extraIPs": nullableString(n.ExtraIPs),
|
||||
"port": n.Port,
|
||||
"tcpListenAddr": n.TCPListenAddr,
|
||||
"udpListenAddr": n.UDPListenAddr,
|
||||
@@ -656,7 +657,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
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
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(users))
|
||||
@@ -678,17 +679,17 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
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
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(limits))
|
||||
for _, sl := range limits {
|
||||
items = append(items, map[string]interface{}{
|
||||
item := map[string]interface{}{
|
||||
"id": sl.ID, "name": sl.Name, "speed": sl.Speed,
|
||||
"tunnelId": sl.TunnelID, "tunnelName": sl.TunnelName,
|
||||
"status": sl.Status, "createdTime": sl.CreatedTime,
|
||||
"updatedTime": nullableInt64(sl.UpdatedTime),
|
||||
})
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -712,11 +713,12 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
}
|
||||
|
||||
var rows []fwdRow
|
||||
err := r.db.Model(&model.Forward{}).
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx").
|
||||
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").
|
||||
Order("forward.inx ASC, forward.id ASC").
|
||||
Find(&rows).Error
|
||||
@@ -730,14 +732,18 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
item := map[string]interface{}{
|
||||
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
|
||||
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
|
||||
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
||||
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
||||
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
||||
"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
|
||||
}
|
||||
@@ -859,6 +865,9 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
||||
if c.Strategy.Valid {
|
||||
nodeObj["strategy"] = c.Strategy.String
|
||||
}
|
||||
if c.ConnectIP.Valid {
|
||||
nodeObj["connectIp"] = c.ConnectIP.String
|
||||
}
|
||||
|
||||
switch chainTypeInt {
|
||||
case 1:
|
||||
@@ -1270,6 +1279,175 @@ func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var items []model.PeerShareRuntime
|
||||
err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward").
|
||||
Order("id ASC").
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = make([]model.PeerShareRuntime, 0)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if serviceName == "" {
|
||||
return []model.PeerShareRuntime{}, nil
|
||||
}
|
||||
var items []model.PeerShareRuntime
|
||||
err := r.db.Where("node_id = ? AND service_name = ? AND status = 1 AND role = ?", nodeID, serviceName, "forward").
|
||||
Order("id ASC").
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = make([]model.PeerShareRuntime, 0)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var names []string
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
|
||||
Pluck("service_name", &names).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names == nil {
|
||||
names = make([]string, 0)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var item model.PeerShareRuntime
|
||||
err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if shareID <= 0 || serviceName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var item model.PeerShareRuntime
|
||||
err := r.db.Where("share_id = ? AND service_name = ? AND status = 1 AND role = ?", shareID, serviceName, "forward").
|
||||
Order("id ASC").
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND port = ? AND status = 1", nodeID, port).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"service_name": serviceName,
|
||||
"applied": 1,
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if shareID <= 0 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
if updatedTime <= 0 {
|
||||
updatedTime = unixMilliNow()
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{
|
||||
"status": 0,
|
||||
"applied": 0,
|
||||
"service_name": "",
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if shareID <= 0 || serviceName == "" {
|
||||
return nil
|
||||
}
|
||||
if updatedTime <= 0 {
|
||||
updatedTime = unixMilliNow()
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("share_id = ? AND status = 1 AND role = ? AND service_name = ?", shareID, "forward", serviceName).
|
||||
Updates(map[string]interface{}{
|
||||
"status": 0,
|
||||
"applied": 0,
|
||||
"service_name": "",
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ─── FederationTunnelBinding ─────────────────────────────────────────
|
||||
|
||||
func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {
|
||||
@@ -1643,7 +1821,6 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
|
||||
for _, sl := range sls {
|
||||
b := model.SpeedLimitBackup{
|
||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||
TunnelID: sl.TunnelID, TunnelName: sl.TunnelName,
|
||||
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
||||
}
|
||||
if sl.UpdatedTime.Valid {
|
||||
@@ -2016,8 +2193,8 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
|
||||
ID: sl.ID,
|
||||
Name: sl.Name,
|
||||
Speed: int(sl.Speed),
|
||||
TunnelID: sl.TunnelID,
|
||||
TunnelName: sl.TunnelName,
|
||||
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
|
||||
TunnelName: sql.NullString{String: "", Valid: false},
|
||||
CreatedTime: sl.CreatedTime,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: sl.Status,
|
||||
@@ -2235,7 +2412,7 @@ func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.User{}).
|
||||
Where("role_id != 0 AND status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).
|
||||
Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2255,7 +2432,7 @@ func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredU
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var uts []model.UserTunnel
|
||||
err := r.db.Where("status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).Find(&uts).Error
|
||||
err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2290,9 +2467,11 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────
|
||||
|
||||
const currentSchemaVersion = 2
|
||||
const currentSchemaVersion = 4
|
||||
|
||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
|
||||
|
||||
func getSchemaVersion(db *gorm.DB) int {
|
||||
var v model.SchemaVersion
|
||||
@@ -2344,10 +2523,82 @@ func migrateSchema(db *gorm.DB) error {
|
||||
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)
|
||||
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 {
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
@@ -2484,10 +2735,11 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
type fpRow struct {
|
||||
Port sql.NullInt64
|
||||
ServerIP sql.NullString
|
||||
InIP sql.NullString
|
||||
}
|
||||
var fpRows []fpRow
|
||||
err := db.Model(&model.ForwardPort{}).
|
||||
Select("forward_port.port, node.server_ip").
|
||||
Select("forward_port.port, node.server_ip, forward_port.in_ip").
|
||||
Joins("LEFT JOIN node ON node.id = forward_port.node_id").
|
||||
Where("forward_port.forward_id = ?", forwardID).
|
||||
Order("forward_port.id ASC").
|
||||
@@ -2497,10 +2749,22 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
}
|
||||
|
||||
ports := make([]int64, 0)
|
||||
nodePairs := make([]string, 0)
|
||||
entries := make([]string, 0)
|
||||
seenPorts := make(map[int64]struct{})
|
||||
seenPairs := make(map[string]struct{})
|
||||
|
||||
var tunnelFirstIP string
|
||||
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
|
||||
tunnelIPs := strings.Split(tunnelInIP.String, ",")
|
||||
for _, ip := range tunnelIPs {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
tunnelFirstIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range fpRows {
|
||||
if !row.Port.Valid {
|
||||
continue
|
||||
@@ -2509,11 +2773,21 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
seenPorts[row.Port.Int64] = struct{}{}
|
||||
ports = append(ports, row.Port.Int64)
|
||||
}
|
||||
if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
|
||||
pair := fmt.Sprintf("%s:%d", strings.TrimSpace(row.ServerIP.String), row.Port.Int64)
|
||||
|
||||
var ip string
|
||||
if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.InIP.String)
|
||||
} else if tunnelFirstIP != "" {
|
||||
ip = tunnelFirstIP
|
||||
} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.ServerIP.String)
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
|
||||
if _, ok := seenPairs[pair]; !ok {
|
||||
seenPairs[pair] = struct{}{}
|
||||
nodePairs = append(nodePairs, pair)
|
||||
entries = append(entries, pair)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2524,27 +2798,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
|
||||
inPort := sql.NullInt64{Int64: ports[0], Valid: true}
|
||||
|
||||
entries := make([]string, 0)
|
||||
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
|
||||
tunnelIPs := strings.Split(tunnelInIP.String, ",")
|
||||
seen := make(map[string]struct{})
|
||||
for _, ip := range tunnelIPs {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ip]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
for _, port := range ports {
|
||||
entries = append(entries, fmt.Sprintf("%s:%d", ip, port))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entries = append(entries, nodePairs...)
|
||||
}
|
||||
|
||||
return strings.Join(entries, ","), inPort, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -101,7 +102,11 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
|
||||
}
|
||||
rows := make([]model.ForwardPortRecord, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port})
|
||||
inIP := ""
|
||||
if p.InIP.Valid {
|
||||
inIP = p.InIP.String
|
||||
}
|
||||
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -176,6 +181,9 @@ func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
|
||||
if n.ServerIPV6.Valid {
|
||||
rec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)
|
||||
}
|
||||
if n.ExtraIPs.Valid {
|
||||
rec.ExtraIPs = strings.TrimSpace(n.ExtraIPs.String)
|
||||
}
|
||||
if n.InterfaceName.Valid {
|
||||
rec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)
|
||||
}
|
||||
@@ -288,10 +296,11 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR
|
||||
Name sql.NullString
|
||||
Protocol sql.NullString
|
||||
Strategy sql.NullString
|
||||
ConnectIP sql.NullString
|
||||
}
|
||||
var rows []row
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy").
|
||||
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip").
|
||||
Joins("LEFT JOIN node ON node.id = chain_tunnel.node_id").
|
||||
Where("chain_tunnel.tunnel_id = ?", tunnelID).
|
||||
Order("chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC").
|
||||
@@ -336,6 +345,9 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR
|
||||
} else {
|
||||
item.Strategy = row.Strategy.String
|
||||
}
|
||||
if row.ConnectIP.Valid {
|
||||
item.ConnectIP = row.ConnectIP.String
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -38,6 +38,14 @@ type FederationBindingRow struct {
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
type ActiveForwardPortRow struct {
|
||||
ForwardID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
Port int
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
|
||||
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@@ -87,6 +95,27 @@ func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPortsForNode(nodeID int64) ([]ActiveForwardPortRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var result []ActiveForwardPortRow
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Select("forward_port.forward_id, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward_port.port, forward.updated_time").
|
||||
Joins("JOIN forward ON forward.id = forward_port.forward_id").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Where("forward_port.node_id = ? AND forward_port.port > 0", nodeID).
|
||||
Order("forward_port.port ASC, forward_port.id ASC").
|
||||
Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]ActiveForwardPortRow, 0)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
|
||||
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@@ -199,7 +228,6 @@ func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// NextIndex returns COALESCE(MAX(inx), -1) + 1 for the given table.
|
||||
func (r *Repository) NextIndex(table string) int {
|
||||
if r == nil || r.db == nil {
|
||||
return 0
|
||||
@@ -222,7 +250,7 @@ func (r *Repository) NextIndex(table string) int {
|
||||
var row inxRow
|
||||
err := r.db.Model(modelRef).
|
||||
Select("inx").
|
||||
Order("inx DESC").
|
||||
Order("inx ASC, id ASC").
|
||||
Limit(1).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -231,10 +259,7 @@ func (r *Repository) NextIndex(table string) int {
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if row.Inx < 0 {
|
||||
return 0
|
||||
}
|
||||
return row.Inx + 1
|
||||
return row.Inx - 1
|
||||
}
|
||||
|
||||
// CreateRemoteNode inserts a new remote node.
|
||||
|
||||
@@ -38,6 +38,7 @@ func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardReco
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -68,6 +69,7 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -99,6 +101,7 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
}
|
||||
if strings.TrimSpace(fr.Strategy) == "" {
|
||||
fr.Strategy = "fifo"
|
||||
@@ -169,3 +172,15 @@ func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
@@ -83,3 +84,169 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig interface{}) error {
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -206,6 +206,7 @@ func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serve
|
||||
ServerIP: serverIP,
|
||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
||||
ServerIPV6: nullStringFromInterface(serverIPV6),
|
||||
ExtraIPs: nullStringFromInterface(extraIPs),
|
||||
Port: stringFromInterface(port),
|
||||
InterfaceName: nullStringFromInterface(interfaceName),
|
||||
Version: nullStringFromInterface(version),
|
||||
@@ -238,7 +239,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
|
||||
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -249,6 +250,7 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
||||
"server_ip": serverIP,
|
||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||
"extra_ips": nullStringFromInterface(extraIPs),
|
||||
"port": stringFromInterface(port),
|
||||
"interface_name": nullStringFromInterface(interfaceName),
|
||||
"http": httpFlag,
|
||||
@@ -395,7 +397,7 @@ func (r *Repository) DeleteChainTunnelsByTunnelTx(tx *gorm.DB, tunnelID int64) e
|
||||
return tx.Where("tunnel_id = ?", tunnelID).Delete(&model.ChainTunnel{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string) error {
|
||||
func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string, connectIp string) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
@@ -407,6 +409,7 @@ func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType
|
||||
Strategy: nullStringFromInterface(strategy),
|
||||
Inx: nullInt64FromInterface(inx),
|
||||
Protocol: nullStringFromInterface(protocol),
|
||||
ConnectIP: sql.NullString{String: connectIp, Valid: connectIp != ""},
|
||||
}
|
||||
return tx.Create(&ct).Error
|
||||
}
|
||||
@@ -522,9 +525,6 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
|
||||
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -535,17 +535,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) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -657,7 +646,7 @@ func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {
|
||||
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 {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -668,6 +657,7 @@ func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remote
|
||||
"tunnel_id": tunnelID,
|
||||
"remote_addr": remoteAddr,
|
||||
"strategy": strategy,
|
||||
"speed_id": nullInt64FromInterface(speedID),
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -705,6 +695,7 @@ func (r *Repository) DeleteForwardCascade(forwardID int64) error {
|
||||
func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
@@ -718,13 +709,18 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
|
||||
}
|
||||
rows := make([]model.ForwardPort, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
rows = append(rows, model.ForwardPort{ForwardID: forwardID, NodeID: e.NodeID, Port: e.Port})
|
||||
rows = append(rows, model.ForwardPort{
|
||||
ForwardID: forwardID,
|
||||
NodeID: e.NodeID,
|
||||
Port: e.Port,
|
||||
InIP: sql.NullString{String: e.InIP, Valid: e.InIP != ""},
|
||||
})
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -738,6 +734,7 @@ func (r *Repository) RollbackForwardFields(id, userID int64, userName, name stri
|
||||
"remote_addr": remoteAddr,
|
||||
"strategy": strategy,
|
||||
"status": status,
|
||||
"speed_id": nullInt64FromInterface(speedID),
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -764,15 +761,15 @@ func (r *Repository) GetUsedPortsOnNodeAsMap(nodeID int64) (map[int]bool, error)
|
||||
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 {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
sl := model.SpeedLimit{
|
||||
Name: name,
|
||||
Speed: speed,
|
||||
TunnelID: tunnelID,
|
||||
TunnelName: tunnelName,
|
||||
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
|
||||
TunnelName: sql.NullString{String: "", Valid: false},
|
||||
CreatedTime: now,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
@@ -783,34 +780,24 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID int64, tu
|
||||
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 {
|
||||
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{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"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
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteSpeedLimit(id int64) error {
|
||||
@@ -1190,7 +1177,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
|
||||
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, inIp string, speedID interface{}) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
@@ -1209,6 +1196,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
Inx: inx,
|
||||
SpeedID: nullInt64FromInterface(speedID),
|
||||
}
|
||||
if err := tx.Create(&fwd).Error; err != nil {
|
||||
return err
|
||||
@@ -1219,6 +1207,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
|
||||
ForwardID: forwardID,
|
||||
NodeID: nodeID,
|
||||
Port: port,
|
||||
InIP: sql.NullString{String: inIp, Valid: inIp != ""},
|
||||
}
|
||||
if err := tx.Create(&fp).Error; err != nil {
|
||||
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 (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
@@ -117,3 +119,43 @@ func tryQueryInt(t *testing.T, r *repo.Repository, query string, args ...interfa
|
||||
}
|
||||
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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -13,15 +12,12 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
@@ -195,7 +191,7 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
|
||||
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
@@ -315,7 +311,7 @@ func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
||||
|
||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
remoteToken := "remote-diagnose-token"
|
||||
@@ -465,54 +461,3 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, entryShareID, 1)
|
||||
}
|
||||
|
||||
func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
@@ -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) {
|
||||
providerSecret := "provider-portrange-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
@@ -759,6 +723,21 @@ func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContract(t *testing.T) {
|
||||
secret := "federation-forward-flow-contract-jwt"
|
||||
router, r := 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 := 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, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "flow-local-node", "flow-local-secret", "10.20.30.40", "10.20.30.40", "", "32000-32020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "flow-local-node")
|
||||
|
||||
shareToken := "flow-linkage-share-token"
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-linkage-share",
|
||||
NodeID: nodeID,
|
||||
Token: shareToken,
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 1536,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 32000,
|
||||
PortRangeEnd: 32020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken(shareToken)
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 32001)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert share tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-share-tunnel")
|
||||
|
||||
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "flow-linkage-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "flow-linkage-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 32001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
var targetForward map[string]interface{}
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == forwardID {
|
||||
targetForward = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetForward == nil {
|
||||
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
|
||||
}
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) != 0 {
|
||||
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsedShareID := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
|
||||
if parsedShareID != share.ID {
|
||||
t.Fatalf("expected parsed shareID=%d, got %d (tunnelName=%q)", share.ID, parsedShareID, contractValueAsString(targetForward["tunnelName"]))
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[parsedShareID]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for share %d", parsedShareID)
|
||||
}
|
||||
|
||||
count := forwardCountByShare[parsedShareID]
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
estimated := shareFlow / int64(count)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
displayFlow := estimated
|
||||
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 after frontend-style merge, got %d", displayFlow)
|
||||
}
|
||||
if displayFlow != share.CurrentFlow {
|
||||
t.Fatalf("expected displayFlow=%d, got %d", share.CurrentFlow, displayFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContractSplitShareFlowAcrossMultipleForwards(t *testing.T) {
|
||||
secret := "federation-forward-split-flow-contract-jwt"
|
||||
router, r := 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 := 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, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "flow-split-local-node", "flow-split-local-secret", "10.21.31.41", "10.21.31.41", "", "32100-32120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "flow-split-local-node")
|
||||
|
||||
shareToken := "flow-split-share-token"
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-split-share",
|
||||
NodeID: nodeID,
|
||||
Token: shareToken,
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 4097,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 32100,
|
||||
PortRangeEnd: 32120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken(shareToken)
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
createShareForward := func(name string, port int) int64 {
|
||||
t.Helper()
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, port)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert share tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-split-tunnel")
|
||||
|
||||
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", name, tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, name)
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
return forwardID
|
||||
}
|
||||
|
||||
forwardIDA := createShareForward("flow-split-forward-a", 32101)
|
||||
forwardIDB := createShareForward("flow-split-forward-b", 32102)
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) != 0 {
|
||||
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[share.ID]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for share %d", share.ID)
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
count := forwardCountByShare[share.ID]
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 forwards sharing share %d, got %d", share.ID, count)
|
||||
}
|
||||
|
||||
expectedEach := shareFlow / int64(count)
|
||||
if expectedEach < 1 {
|
||||
expectedEach = 1
|
||||
}
|
||||
|
||||
findForward := func(forwardID int64) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == forwardID {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("forward %d not found in /forward/list response", forwardID)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, forwardID := range []int64{forwardIDA, forwardIDB} {
|
||||
forward := findForward(forwardID)
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
|
||||
if sid != share.ID {
|
||||
t.Fatalf("expected parsed shareID=%d, got %d for forward %d", share.ID, sid, forwardID)
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(forward["inFlow"]) + contractValueAsInt64(forward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0 for forward %d, got %d", forwardID, directFlow)
|
||||
}
|
||||
|
||||
displayFlow := int64(0)
|
||||
if directFlow > 0 {
|
||||
displayFlow = directFlow
|
||||
} else {
|
||||
shareFlowForForward := flowByShare[sid]
|
||||
if shareFlowForForward > 0 {
|
||||
cnt := forwardCountByShare[sid]
|
||||
if cnt <= 0 {
|
||||
cnt = 1
|
||||
}
|
||||
estimated := shareFlowForForward / int64(cnt)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
displayFlow = estimated
|
||||
}
|
||||
}
|
||||
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 for forward %d, got %d", forwardID, displayFlow)
|
||||
}
|
||||
if displayFlow != expectedEach {
|
||||
t.Fatalf("expected displayFlow=%d for forward %d, got %d", expectedEach, forwardID, displayFlow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContractResolvesShareByTunnelBindingWhenTunnelNameIsCustom(t *testing.T) {
|
||||
secret := "federation-forward-binding-flow-contract-jwt"
|
||||
router, r := 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()
|
||||
remoteShareID := int64(901)
|
||||
remoteShareFlow := int64(5000)
|
||||
|
||||
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, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
"flow-binding-remote-node", "flow-binding-remote-secret", "10.31.41.51", "10.31.41.51", "", "33000-33020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", fmt.Sprintf(`{"shareId":%d,"maxBandwidth":0,"currentFlow":%d,"portRangeStart":33000,"portRangeEnd":33020}`, remoteShareID, remoteShareFlow),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
remoteNodeID := mustLastInsertID(t, r, "flow-binding-remote-node")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-port-forward-custom-name", 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert custom tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-binding-custom-tunnel")
|
||||
|
||||
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "flow-binding-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "flow-binding-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, remoteNodeID, 33001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) == 0 {
|
||||
t.Fatalf("expected non-empty remote usage rows")
|
||||
}
|
||||
|
||||
findForward := func(id int64) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == id {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("forward %d not found in /forward/list response", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
shareIDsByTunnel := make(map[int64]map[int64]struct{})
|
||||
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
|
||||
bindings, _ := m["bindings"].([]interface{})
|
||||
for _, bindingRaw := range bindings {
|
||||
binding, ok := bindingRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tunnelIDVal := contractValueAsInt64(binding["tunnelId"])
|
||||
chainType := contractValueAsInt64(binding["chainType"])
|
||||
if shareID <= 0 || tunnelIDVal <= 0 {
|
||||
continue
|
||||
}
|
||||
if chainType != 1 {
|
||||
continue
|
||||
}
|
||||
setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]
|
||||
if !ok {
|
||||
setByTunnel = make(map[int64]struct{})
|
||||
shareIDsByTunnel[tunnelIDVal] = setByTunnel
|
||||
}
|
||||
setByTunnel[shareID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetForward := findForward(forwardID)
|
||||
parsedByName := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
|
||||
if parsedByName != 0 {
|
||||
t.Fatalf("expected custom tunnel name cannot be parsed as Share-*-Port-*, got %d", parsedByName)
|
||||
}
|
||||
|
||||
resolveShareIDForForward := func(forward map[string]interface{}) int64 {
|
||||
candidates := make(map[int64]struct{})
|
||||
|
||||
shareIDFromName := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
|
||||
if shareIDFromName > 0 {
|
||||
candidates[shareIDFromName] = struct{}{}
|
||||
}
|
||||
|
||||
tunnelIDVal := contractValueAsInt64(forward["tunnelId"])
|
||||
if setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]; ok {
|
||||
for sid := range setByTunnel {
|
||||
candidates[sid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var bestShareID int64
|
||||
bestFlow := int64(0)
|
||||
for sid := range candidates {
|
||||
flow := flowByShare[sid]
|
||||
if flow > bestFlow {
|
||||
bestFlow = flow
|
||||
bestShareID = sid
|
||||
}
|
||||
}
|
||||
return bestShareID
|
||||
}
|
||||
|
||||
resolvedShareID := resolveShareIDForForward(targetForward)
|
||||
if resolvedShareID != remoteShareID {
|
||||
t.Fatalf("expected resolved shareID=%d via tunnel binding, got %d", remoteShareID, resolvedShareID)
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
resolvedByForwardID := make(map[int64]int64)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fid := contractValueAsInt64(m["id"])
|
||||
sid := resolveShareIDForForward(m)
|
||||
if sid > 0 {
|
||||
resolvedByForwardID[fid] = sid
|
||||
}
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[resolvedByForwardID[forwardID]]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for resolved share %d", resolvedByForwardID[forwardID])
|
||||
}
|
||||
|
||||
count := forwardCountByShare[resolvedByForwardID[forwardID]]
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
estimated := shareFlow / int64(count)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
|
||||
displayFlow := estimated
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 after tunnel-binding-based merge, got %d", displayFlow)
|
||||
}
|
||||
if displayFlow != remoteShareFlow {
|
||||
t.Fatalf("expected displayFlow=%d, got %d", remoteShareFlow, displayFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func requestContractEnvelope(t *testing.T, router http.Handler, token string, path string, body interface{}) response.R {
|
||||
t.Helper()
|
||||
|
||||
payload := []byte("{}")
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body for %s: %v", path, err)
|
||||
}
|
||||
payload = raw
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected http 200 for %s, got %d", path, res.Code)
|
||||
}
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response for %s: %v", path, err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func mustContractSlice(t *testing.T, data interface{}, label string) []interface{} {
|
||||
t.Helper()
|
||||
|
||||
rows, ok := data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected %s to be []interface{}, got %T", label, data)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func contractParseShareIDFromTunnelName(tunnelName string) int64 {
|
||||
normalized := strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(normalized, "Share-") {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimPrefix(normalized, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0
|
||||
}
|
||||
shareID, err := strconv.ParseInt(strings.TrimSpace(raw[:idx]), 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return shareID
|
||||
}
|
||||
|
||||
func contractValueAsInt64(v interface{}) int64 {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err == nil {
|
||||
return int64(f)
|
||||
}
|
||||
return 0
|
||||
case string:
|
||||
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func contractValueAsString(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -108,7 +109,11 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -143,7 +148,11 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
if _, ok := first["message"]; !ok {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -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 {
|
||||
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) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
verifiedToken := ""
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
@@ -34,7 +35,7 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
t.Fatalf("enable captcha: %v", err)
|
||||
}
|
||||
|
||||
t.Run("login denied without verified captcha token", func(t *testing.T) {
|
||||
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")
|
||||
@@ -42,10 +43,10 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCodeMsg(t, resp, -1, "验证码校验失败")
|
||||
assertCode(t, resp, 0)
|
||||
})
|
||||
|
||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
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()
|
||||
@@ -65,14 +66,60 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
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"}`)
|
||||
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) {
|
||||
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)
|
||||
|
||||
assertCodeMsg(t, resp, -1, "验证码校验失败")
|
||||
})
|
||||
|
||||
t.Run("whmcs api client bypasses captcha", 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")
|
||||
req.Header.Set("X-FLVX-API-Client", "whmcs")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCode(t, resp, 0)
|
||||
})
|
||||
|
||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
if strings.TrimSpace(verifiedToken) == "" {
|
||||
t.Fatalf("expected verified token from compatibility captcha verify")
|
||||
}
|
||||
|
||||
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginResp, loginReq)
|
||||
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.Header.Set("Content-Type", "application/json")
|
||||
replayResp := httptest.NewRecorder()
|
||||
@@ -162,39 +209,24 @@ func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
|
||||
func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
|
||||
t.Run("missing token blocked", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
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) {
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
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)
|
||||
}
|
||||
})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404 after route removal, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
@@ -652,15 +684,107 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
|
||||
columns := readTableColumns(t, r.DB(), "node")
|
||||
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx", "extra_ips"} {
|
||||
if !columns[required] {
|
||||
t.Fatalf("expected node column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
|
||||
if !tunnelColumns["inx"] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
|
||||
for _, required := range []string{"inx", "ip_preference"} {
|
||||
if !tunnelColumns[required] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-1.x.db")
|
||||
legacyDB, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy sqlite: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = legacyDB.Close()
|
||||
})
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create very legacy node table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create very legacy tunnel table: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := legacyDB.Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1); err != nil {
|
||||
t.Fatalf("seed legacy node row: %v", err)
|
||||
}
|
||||
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
columns := readTableColumns(t, r.DB(), "node")
|
||||
for _, required := range []string{
|
||||
"server_ip_v4",
|
||||
"server_ip_v6",
|
||||
"extra_ips",
|
||||
"tcp_listen_addr",
|
||||
"udp_listen_addr",
|
||||
"inx",
|
||||
"is_remote",
|
||||
"remote_url",
|
||||
"remote_token",
|
||||
"remote_config",
|
||||
} {
|
||||
if !columns[required] {
|
||||
t.Fatalf("expected node column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
|
||||
for _, required := range []string{"inx", "ip_preference"} {
|
||||
if !tunnelColumns[required] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
"encoding/json"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
func TestUserTunnelVisibleListContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
@@ -126,8 +126,11 @@ func collectTunnelIDs(t *testing.T, data interface{}) map[int64]bool {
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", item)
|
||||
}
|
||||
id := int64(obj["id"].(float64))
|
||||
ids[id] = true
|
||||
idFloat, ok := obj["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected id to be float64, got %T", obj["id"])
|
||||
}
|
||||
ids[int64(idFloat)] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sun Feb 15 2026
|
||||
**Generated:** Thu Feb 26 2026
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
@@ -19,16 +19,18 @@ go-gost/
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
|
||||
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
|
||||
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
|
||||
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
|
||||
| **Panel integration config** | `go-gost/config.go` | Expects `config.json` in cwd by default |
|
||||
| **Service lifecycle/reload** | `go-gost/program.go` | Parses config; handles SIGHUP reload |
|
||||
| **WebSocket reporting** | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
|
||||
| **Protocol behaviors** | `go-gost/x/` | Handlers/listeners/dialers live here |
|
||||
| **Build** | `go-gost/Makefile` | Cross-compile targets for amd64/arm64 |
|
||||
|
||||
## CONVENTIONS
|
||||
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
|
||||
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
|
||||
- Agent communicates with panel via WebSocket (real-time commands) + HTTP (batch traffic reports).
|
||||
- All panel communication uses AES encryption with node `secret` as PSK.
|
||||
- CI builds with `CGO_ENABLED=0` for static binaries, then compresses with UPX.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT EDIT** generated protobuf in `x/internal/util/grpc/proto/`.
|
||||
|
||||
+11
-10
@@ -6,27 +6,28 @@ Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/
|
||||
├── api/ # Gin management API + embedded swagger docs
|
||||
├── api/ # Gin management API + embedded swagger docs (22 files)
|
||||
├── config/ # Config model + parsing/load/reload
|
||||
├── connector/ # Outbound connect implementations
|
||||
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
|
||||
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
|
||||
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
|
||||
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
|
||||
├── limiter/ # Traffic/rate/conn limiters
|
||||
├── registry/ # Registries for services/handlers/listeners/etc
|
||||
├── registry/ # Registries for services/handlers/listeners/etc (20 files)
|
||||
├── service/ # Service wrappers + reporting hooks
|
||||
├── socket/ # WebSocket reporter / panel integration
|
||||
├── socket/ # WebSocket reporter / panel integration (6 files)
|
||||
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
|
||||
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
|
||||
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
|
||||
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
|
||||
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
|
||||
| **Management API routes/auth** | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
|
||||
| **Service config parsing** | `go-gost/x/config/parsing/` | Converts config to running services |
|
||||
| **Add a handler** | `go-gost/x/handler/` | Per-protocol subdirs |
|
||||
| **Add a listener/dialer** | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
|
||||
| **Panel reporting** | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
|
||||
| **Register new component** | `go-gost/x/registry/` | `Register{Type}(name, creator)` |
|
||||
|
||||
## CONVENTIONS
|
||||
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
|
||||
@@ -41,4 +42,4 @@ go-gost/x/
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
```
|
||||
@@ -30,6 +30,8 @@ nav:
|
||||
- 首页: index.md
|
||||
- 安装部署: install.md
|
||||
- 使用指南: usage.md
|
||||
- AI Skill 接入: ai-skill.md
|
||||
- PostgreSQL: postgresql.md
|
||||
- 常见问题: faq.md
|
||||
|
||||
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()
|
||||
+11
-12
@@ -1,9 +1,9 @@
|
||||
# VITE FRONTEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 19 2026
|
||||
**Commit:** 137c34e
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Commit:** 21008cc
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.4-rc2
|
||||
**Tag:** 2.1.5-rc15
|
||||
|
||||
## OVERVIEW
|
||||
Web management console for FLVX.
|
||||
@@ -15,8 +15,8 @@ vite-frontend/
|
||||
├── src/
|
||||
│ ├── api/ # Axios wrapper + typed endpoint helpers
|
||||
│ ├── components/ui/ # shadcn/radix primitive components
|
||||
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade used by pages/layouts
|
||||
│ ├── pages/ # Route views + page modules (forward/node/tunnel split helpers)
|
||||
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade (23 components)
|
||||
│ ├── pages/ # Route views + page modules (forward/node/tunnel)
|
||||
│ ├── hooks/ # H5/WebView/mobile hooks
|
||||
│ ├── styles/
|
||||
│ │ ├── globals.css # Base styles + imports tailwind-theme.pcss
|
||||
@@ -25,8 +25,8 @@ vite-frontend/
|
||||
│ ├── main.tsx # ReactDOM + BrowserRouter + Provider
|
||||
│ └── provider.tsx # Toast/theme/provider composition
|
||||
├── components.json # shadcn/ui config
|
||||
├── tailwind.config.js # Compatibility config still used by migration scaffolding
|
||||
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
||||
├── tailwind.config.js # Compatibility config for migration scaffolding
|
||||
├── vite.config.ts # base '/', host 0.0.0.0:3000; minify/treeshake disabled
|
||||
└── package.json
|
||||
```
|
||||
|
||||
@@ -47,8 +47,8 @@ vite-frontend/
|
||||
- **API Envelope**: Responses follow `{code, msg, data, ts}`.
|
||||
- **UI Imports**: Use `src/shadcn-bridge/heroui/*` in app pages/layouts for compatibility.
|
||||
- **Semantic Colors**: Keep `globals.css -> tailwind-theme.pcss` import intact or semantic classes break.
|
||||
- **Build profile**: `minify: false`, `treeshake: false` for easier debugging.
|
||||
- **Layout mode**: H5/mobile mode still controlled by existing route/query and hook logic.
|
||||
- **Build profile**: `minify: false`, `treeshake: false` for debugging.
|
||||
- **Layout mode**: H5/mobile mode controlled by existing route/query and hook logic.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT ADD** `Bearer` to auth header in frontend requests.
|
||||
@@ -57,10 +57,9 @@ vite-frontend/
|
||||
- **DO NOT ADD** frontend tests; no Vitest/Jest setup exists.
|
||||
|
||||
## NOTES
|
||||
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged in `main`.
|
||||
- Release tag `2.1.4-rc2` points to commit `137c34e`.
|
||||
- Button border/color parity depends on both bridge mapping and semantic Tailwind token export.
|
||||
- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- Build outputs are non-minified (debugging mode).
|
||||
- No test infrastructure exists (Vitest/Jest not configured).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 114 KiB |
@@ -2,12 +2,86 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<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-title" content="FLVX" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<title>FLVX</title>
|
||||
<script>
|
||||
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
|
||||
(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;
|
||||
|
||||
@@ -151,7 +225,6 @@
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no"
|
||||
name="viewport"
|
||||
/>
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -55,11 +55,41 @@ http {
|
||||
}
|
||||
|
||||
# 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/ {
|
||||
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_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_pass http://backend:6365/api/v1/;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "npm:rolldown-vite@^7.3.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^6.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "7.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
@@ -18,7 +18,7 @@ import AdminLayout from "@/layouts/admin";
|
||||
import H5Layout from "@/layouts/h5";
|
||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { siteConfig, updateSiteConfig } from "@/config/site";
|
||||
import { useH5Mode } from "@/hooks/useH5Mode";
|
||||
|
||||
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
||||
@@ -90,23 +90,17 @@ function App() {
|
||||
useEffect(() => {
|
||||
document.title = siteConfig.name;
|
||||
|
||||
// 异步检查是否有配置更新
|
||||
const checkTitleUpdate = async () => {
|
||||
try {
|
||||
// 引入必要的函数
|
||||
const { getCachedConfig } = await import("@/config/site");
|
||||
const cachedAppName = await getCachedConfig("app_name");
|
||||
void updateSiteConfig();
|
||||
|
||||
if (cachedAppName && cachedAppName !== document.title) {
|
||||
document.title = cachedAppName;
|
||||
}
|
||||
} catch {}
|
||||
const handleConfigUpdate = () => {
|
||||
void updateSiteConfig();
|
||||
};
|
||||
|
||||
// 延迟检查,避免阻塞初始渲染
|
||||
const timer = setTimeout(checkTitleUpdate, 100);
|
||||
window.addEventListener("configUpdated", handleConfigUpdate);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => {
|
||||
window.removeEventListener("configUpdated", handleConfigUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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) =>
|
||||
Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) =>
|
||||
Network.post<TunnelDiagnosisApiData>("/tunnel/diagnose", { tunnelId });
|
||||
Network.post<TunnelDiagnosisApiData>(
|
||||
"/tunnel/diagnose",
|
||||
{ tunnelId },
|
||||
{ timeout: 120 * 1000 },
|
||||
);
|
||||
export const updateTunnelOrder = (data: {
|
||||
tunnels: Array<{ id: number; inx: number }>;
|
||||
}) => Network.post("/tunnel/update-order", data);
|
||||
@@ -159,7 +163,11 @@ export const resumeForwardService = (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: {
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface ForwardApiItem {
|
||||
outFlow?: number;
|
||||
userId?: number;
|
||||
tunnelId?: number;
|
||||
speedId?: number | null;
|
||||
inx?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -96,10 +97,8 @@ export interface StatisticsFlowApiItem {
|
||||
export interface SpeedLimitApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId: number;
|
||||
speed: number;
|
||||
status: number;
|
||||
tunnelName: string;
|
||||
createdTime: string;
|
||||
updatedTime: string;
|
||||
uploadSpeed?: number;
|
||||
@@ -218,6 +217,7 @@ export interface NodeMutationPayload {
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
extraIPs?: string;
|
||||
port?: string;
|
||||
tcpListenAddr?: string;
|
||||
udpListenAddr?: string;
|
||||
@@ -231,6 +231,7 @@ export interface TunnelChainNodePayload {
|
||||
nodeId: number;
|
||||
protocol?: string;
|
||||
strategy?: string;
|
||||
connectIp?: string;
|
||||
chainType?: number;
|
||||
inx?: number;
|
||||
}
|
||||
@@ -284,6 +285,7 @@ export interface ForwardMutationPayload {
|
||||
inPort?: number | null;
|
||||
remoteAddr?: string;
|
||||
strategy?: string;
|
||||
speedId?: number | null;
|
||||
}
|
||||
|
||||
export interface SpeedLimitMutationPayload {
|
||||
@@ -291,8 +293,6 @@ export interface SpeedLimitMutationPayload {
|
||||
name?: string;
|
||||
speed?: number;
|
||||
status?: number;
|
||||
tunnelId?: number | null;
|
||||
tunnelName?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
NavbarContent,
|
||||
} from "@/shadcn-bridge/heroui/navbar";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
@@ -69,7 +69,7 @@ export const Navbar = () => {
|
||||
color="foreground"
|
||||
href="/"
|
||||
>
|
||||
<Logo size={24} />
|
||||
<BrandLogo size={24} />
|
||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||
</Link>
|
||||
</NavbarBrand>
|
||||
|
||||
@@ -55,7 +55,6 @@ export function SearchBar({
|
||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input:
|
||||
|
||||
@@ -38,13 +38,19 @@ function Alert({
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
|
||||
function AlertTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"h5">) {
|
||||
return (
|
||||
<h5
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
data-slot="alert-title"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
function CardTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
@@ -34,7 +38,9 @@ function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
)}
|
||||
data-slot="card-title"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import {
|
||||
UPDATE_CHANNEL_CHANGED_EVENT,
|
||||
type UpdateReleaseChannel,
|
||||
getLatestVersionByChannel,
|
||||
getUpdateReleaseChannel,
|
||||
hasVersionUpdate,
|
||||
} from "@/utils/version-update";
|
||||
|
||||
const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
interface VersionFooterProps {
|
||||
version: string;
|
||||
containerClassName?: string;
|
||||
versionClassName?: string;
|
||||
poweredClassName?: string;
|
||||
updateBadgeClassName?: string;
|
||||
}
|
||||
|
||||
export function VersionFooter({
|
||||
version,
|
||||
containerClassName,
|
||||
versionClassName,
|
||||
poweredClassName,
|
||||
updateBadgeClassName,
|
||||
}: VersionFooterProps) {
|
||||
const [channel, setChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [latestUpdateVersion, setLatestUpdateVersion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChannelChange = () => {
|
||||
setChannel(getUpdateReleaseChannel());
|
||||
};
|
||||
|
||||
window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange);
|
||||
window.addEventListener("storage", handleChannelChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
UPDATE_CHANNEL_CHANGED_EVENT,
|
||||
handleChannelChange,
|
||||
);
|
||||
window.removeEventListener("storage", handleChannelChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const checkUpdate = async () => {
|
||||
const latestVersion = await getLatestVersionByChannel(
|
||||
channel,
|
||||
siteConfig.github_repo || FALLBACK_GITHUB_REPO,
|
||||
);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latestVersion) {
|
||||
setUpdateAvailable(false);
|
||||
setLatestUpdateVersion(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUpdate = hasVersionUpdate(version, latestVersion);
|
||||
|
||||
setUpdateAvailable(hasUpdate);
|
||||
setLatestUpdateVersion(hasUpdate ? latestVersion : null);
|
||||
};
|
||||
|
||||
void checkUpdate();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [channel, version]);
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<p className={versionClassName}>
|
||||
v{version}
|
||||
{updateAvailable && latestUpdateVersion && (
|
||||
<span className={updateBadgeClassName} role="status">
|
||||
{latestUpdateVersion}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className={poweredClassName}>
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
FLVX
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export type SiteConfig = typeof siteConfig;
|
||||
const CACHE_PREFIX = "vite_config_";
|
||||
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
const APP_VERSION = "1.0.3";
|
||||
const DEFAULT_FAVICON = "/favicon.ico";
|
||||
const FAVICON_LINK_ID = "app-favicon";
|
||||
const GITHUB_REPO =
|
||||
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
@@ -16,10 +18,15 @@ const getInitialConfig = () => {
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: "",
|
||||
app_favicon: "",
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
@@ -27,6 +34,8 @@ const getInitialConfig = () => {
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +43,9 @@ const getInitialConfig = () => {
|
||||
name: "FLVX",
|
||||
version: 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);
|
||||
|
||||
if (response.code === 0 && response.data?.value) {
|
||||
if (
|
||||
response.code === 0 &&
|
||||
response.data &&
|
||||
typeof response.data.value === "string"
|
||||
) {
|
||||
const value = response.data.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>> => {
|
||||
// 尝试从缓存获取所有配置
|
||||
const configKeys = ["app_name"];
|
||||
const configKeys = ["app_name", "app_logo", "app_favicon"];
|
||||
const cachedConfigs: Record<string, string> = {};
|
||||
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获取最新配置
|
||||
try {
|
||||
const response = await getConfigs();
|
||||
@@ -126,31 +169,116 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
if (hasCachedData) {
|
||||
return cachedConfigs;
|
||||
}
|
||||
|
||||
return await fetchPublicConfigs();
|
||||
} catch {
|
||||
// API失败时返回缓存的数据
|
||||
if (hasCachedData) {
|
||||
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 () => {
|
||||
const appName = await getCachedConfig("app_name");
|
||||
export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
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) {
|
||||
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[]) => {
|
||||
if (keys && keys.length > 0) {
|
||||
// 删除指定的配置缓存
|
||||
@@ -163,8 +291,13 @@ export const clearConfigCache = (keys?: string[]) => {
|
||||
|
||||
// 在页面加载时异步更新配置(如果有更新的话)
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof document !== "undefined") {
|
||||
document.title = siteConfig.name;
|
||||
}
|
||||
updateDocumentFavicon(siteConfig.app_favicon);
|
||||
|
||||
// 延迟执行,避免阻塞初始渲染
|
||||
setTimeout(() => {
|
||||
updateSiteConfig();
|
||||
}, 200);
|
||||
void updateSiteConfig();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
useDisclosure,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
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 { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import { siteConfig } from "@/config/site";
|
||||
@@ -50,7 +51,9 @@ export default function AdminLayout({
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem("sidebar_collapsed") === "true");
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar_collapsed") === "true",
|
||||
);
|
||||
const [username, setUsername] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
@@ -208,6 +211,7 @@ export default function AdminLayout({
|
||||
// 切换折叠状态
|
||||
const toggleCollapse = () => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
|
||||
setIsCollapsed(newCollapsed);
|
||||
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
|
||||
};
|
||||
@@ -326,13 +330,14 @@ export default function AdminLayout({
|
||||
{/* Logo 区域 */}
|
||||
<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">
|
||||
<Logo size={28} />
|
||||
<BrandLogo size={28} />
|
||||
</div>
|
||||
<div className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
||||
>
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">v{siteConfig.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -345,15 +350,16 @@ export default function AdminLayout({
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<motion.button
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
className={`
|
||||
w-full flex items-center p-2 rounded-lg text-left
|
||||
relative min-h-[44px] overflow-hidden transition-colors
|
||||
${isActive
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}
|
||||
${
|
||||
isActive
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}
|
||||
`}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => handleMenuClick(item.path)}
|
||||
>
|
||||
@@ -378,7 +384,9 @@ export default function AdminLayout({
|
||||
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}
|
||||
>
|
||||
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -392,38 +400,55 @@ export default function AdminLayout({
|
||||
|
||||
{/* 底部版权信息和折叠按钮 */}
|
||||
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
|
||||
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
FLVX
|
||||
</a>
|
||||
</p>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
|
||||
>
|
||||
<VersionFooter
|
||||
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
|
||||
version={siteConfig.version}
|
||||
versionClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 桌面端折叠按钮 */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={toggleCollapse}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
// 向右扩展的提示
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 5l7 7-7 7M5 5l7 7-7 7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// 向左收起的提示
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
@@ -532,9 +557,7 @@ export default function AdminLayout({
|
||||
</header>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main
|
||||
className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto"
|
||||
>
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function H5SimpleLayout({
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Logo size={20} />
|
||||
<BrandLogo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size={20} />
|
||||
<BrandLogo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
@@ -115,10 +115,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
|
||||
|
||||
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
|
||||
<div aria-hidden className="h-16 safe-bottom" />
|
||||
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
|
||||
|
||||
{/* 底部Tabbar */}
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
{filteredTabItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
<button
|
||||
key={item.path}
|
||||
className={`
|
||||
flex flex-col items-center justify-center flex-1 h-full
|
||||
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
${
|
||||
isActive
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import App from "./App.tsx";
|
||||
import { Provider } from "./provider.tsx";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
registerSW({ immediate: true });
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<Provider>
|
||||
|
||||
@@ -28,11 +28,17 @@ import {
|
||||
} from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
|
||||
import {
|
||||
getCachedConfigs,
|
||||
clearConfigCache,
|
||||
updateSiteConfig,
|
||||
} from "@/config/site";
|
||||
type UpdateReleaseChannel,
|
||||
getUpdateReleaseChannel,
|
||||
setUpdateReleaseChannel,
|
||||
} from "@/utils/version-update";
|
||||
import {
|
||||
convertBrandAssetToPngDataURL,
|
||||
isPngDataURL,
|
||||
type BrandAssetKind,
|
||||
} from "@/utils/brand-asset";
|
||||
|
||||
// 简单的保存图标组件
|
||||
const SaveIcon = ({ className }: { className?: string }) => (
|
||||
@@ -62,6 +68,19 @@ interface ConfigItem {
|
||||
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[] = [
|
||||
{
|
||||
@@ -86,6 +105,24 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
||||
description: "在浏览器标签页和导航栏显示的应用名称",
|
||||
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",
|
||||
label: "启用验证码",
|
||||
@@ -136,8 +173,11 @@ const getInitialConfigs = (): Record<string, string> => {
|
||||
"captcha_enabled",
|
||||
"cloudflare_site_key",
|
||||
"cloudflare_secret_key",
|
||||
"forward_compact_mode",
|
||||
"ip",
|
||||
"panel_domain",
|
||||
"app_logo",
|
||||
"app_favicon",
|
||||
];
|
||||
const initialConfigs: Record<string, string> = {};
|
||||
|
||||
@@ -174,7 +214,9 @@ export default function ConfigPage() {
|
||||
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
|
||||
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
|
||||
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>({
|
||||
content: "",
|
||||
@@ -182,6 +224,15 @@ export default function ConfigPage() {
|
||||
});
|
||||
const [announcementLoading, setAnnouncementLoading] = useState(true);
|
||||
const [announcementSaving, setAnnouncementSaving] = useState(false);
|
||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
const [brandUploading, setBrandUploading] = useState<
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
@@ -243,8 +294,7 @@ export default function ConfigPage() {
|
||||
if (res.code === 0 && res.data) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcement:", error);
|
||||
} catch {
|
||||
} finally {
|
||||
setAnnouncementLoading(false);
|
||||
}
|
||||
@@ -267,11 +317,23 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||
setUpdateChannel(channel);
|
||||
setUpdateReleaseChannel(channel);
|
||||
toast.success(
|
||||
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfigChange = (key: string, value: string) => {
|
||||
const newConfigs = { ...configs, [key]: value };
|
||||
|
||||
setConfigs(newConfigs);
|
||||
|
||||
if (isBrandPreviewKey(key)) {
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
|
||||
const hasChangesNow =
|
||||
Object.keys(newConfigs).some(
|
||||
(k) => newConfigs[k] !== originalConfigs[k],
|
||||
@@ -287,25 +349,40 @@ export default function ConfigPage() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
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) {
|
||||
toast.success("配置保存成功");
|
||||
|
||||
// 清除所有配置缓存,强制下次重新获取
|
||||
clearConfigCache();
|
||||
|
||||
// 获取变更的配置项
|
||||
const changedKeys = Object.keys(configs).filter(
|
||||
(key) => configs[key] !== originalConfigs[key],
|
||||
);
|
||||
Object.entries(configs).forEach(([key, value]) => {
|
||||
configCache.set(key, value);
|
||||
});
|
||||
|
||||
setOriginalConfigs({ ...configs });
|
||||
setHasChanges(false);
|
||||
|
||||
// 如果应用名称发生变化,立即更新网站配置
|
||||
if (changedKeys.includes("app_name")) {
|
||||
await updateSiteConfig();
|
||||
if (
|
||||
changedKeys.some((key) =>
|
||||
["app_name", "app_logo", "app_favicon"].includes(key),
|
||||
)
|
||||
) {
|
||||
await updateSiteConfig(configs);
|
||||
}
|
||||
|
||||
// 触发配置更新事件,通知其他组件
|
||||
@@ -333,6 +410,196 @@ export default function ConfigPage() {
|
||||
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 isChanged =
|
||||
@@ -340,6 +607,10 @@ export default function ConfigPage() {
|
||||
|
||||
switch (item.type) {
|
||||
case "input":
|
||||
if (isBrandPreviewKey(item.key)) {
|
||||
return renderBrandAssetUploader(item.key, isChanged);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
classNames={{
|
||||
@@ -434,7 +705,7 @@ export default function ConfigPage() {
|
||||
}
|
||||
|
||||
setImportSelectorOpen(false);
|
||||
requestAnimationFrame(() => fileInputRef.current?.click());
|
||||
requestAnimationFrame(() => backupFileInputRef.current?.click());
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -471,8 +742,8 @@ export default function ConfigPage() {
|
||||
toast.error("导入失败,请检查文件格式");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
if (backupFileInputRef.current) {
|
||||
backupFileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -643,6 +914,42 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider className="my-2" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
更新通道
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
|
||||
的版本。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
selectedKeys={[updateChannel]}
|
||||
size="md"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected =
|
||||
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
|
||||
|
||||
handleUpdateChannelChange(selected);
|
||||
}}
|
||||
>
|
||||
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
|
||||
稳定版
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
key="dev"
|
||||
description="仅 alpha / beta / rc 关键字版本"
|
||||
>
|
||||
开发版
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -778,7 +1085,7 @@ export default function ConfigPage() {
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
ref={backupFileInputRef}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
type="file"
|
||||
|
||||
@@ -197,9 +197,7 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcement:", error);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadPackageData = async () => {
|
||||
|
||||
+2559
-415
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user