Compare commits

...

24 Commits

Author SHA1 Message Date
sagit 2f40cf29d4 feat(frontend): group forwards by user with admin priority sorting (#227)
## Summary
- Add user grouping for forwards in both grouped table and card views
- Sort user groups with admin's own group first, then alphabetically by
name
- Restrict drag-and-drop reordering to same user group only to prevent
cross-user data mixing
- Remove redundant user column from grouped table view (user shown in
group header)
- Add user group headers with forward count badges and "管理员本人" chip for
admin's own group
2026-02-28 20:11:18 +08:00
sagitchu a92eb168aa feat(diagnosis): add streaming progress support and tunnel-grouped forward list
- Add SSE streaming endpoints for tunnel/forward diagnosis with real-time progress
- Increase diagnosis timeout to 2 minutes with context propagation
- Group forwards by tunnel within user groups in UI
- Add nginx SSE proxy configuration for streaming endpoints
2026-02-28 20:09:25 +08:00
sagitchu de21a55f37 fix(diagnosis): parallelize runtime checks and raise API timeouts 2026-02-28 18:46:36 +08:00
sagitchu b01dbdb6e5 feat(frontend): group forwards by user with admin priority sorting
- Add ForwardUserGroup interface and helper utilities
- Group forwards by user in both grouped table and card views
- Sort user groups with admin's own group first, then alphabetically
- Restrict drag-and-drop to same user group only
- Remove redundant user column from grouped table view
- Add user group headers with forward count badges
2026-02-28 17:20:10 +08:00
sagit a645cc699b docs: add AI Skill and PostgreSQL nav items (#226) 2026-02-28 06:27:36 +00:00
sagit 528f912aac docs: add AI Skill integration guide (#225)
## Summary
- Add AI Skill documentation for LLM integration with FLVX panel
- Include complete API reference documentation for the skill
- Add publish workflow for skill distribution

## Changes
- New `doc/ai-skill.md` guide
- New `skills/flvx-api/` directory with API references
- New `.github/workflows/publish-skill.yml` workflow
- Update `doc/index.md` navigation
2026-02-28 14:13:34 +08:00
sagit 8bf30a157f Merge branch 'main' into opencode/proud-rocket 2026-02-28 14:12:35 +08:00
sagitchu 58abba7fc0 docs: add AI Skill integration guide 2026-02-28 14:07:54 +08:00
sagit d8cd4b404c refactor(tests): consolidate contract test helpers and add Playwright e2e tests (#224)
## Summary
- Add Playwright e2e test suite for frontend and API
- Consolidate contract test helpers, removing redundant internal test
file
- Simplify test setup across multiple contract test files
2026-02-28 12:15:51 +08:00
sagitchu 9e979aa82a refactor(tests): consolidate contract test helpers 2026-02-28 12:13:28 +08:00
sagit 5caaaf6092 test: add Playwright e2e test suite (#223)
## Summary
- Add comprehensive Playwright e2e test suite for frontend and API
testing
- Include test fixtures, page objects, and API client utilities
- Add tests for auth flow, dashboard, user UI, and API endpoints
2026-02-28 10:35:55 +08:00
sagitchu f23d1c2afd test: add Playwright e2e test suite for frontend and API 2026-02-28 10:33:32 +08:00
sagit 5e00cbf131 feat: speed limit UX improvements and brand customization (#222)
## Summary
- 简化限速选择器 UX,移除冗余的"不限速"选项项
- 默认转发限速规则为不限速
- 移除限速规则与隧道的绑定关系并添加迁移清理
- 改进 favicon 加载逻辑,添加 fallback 到 config API
- 添加品牌资源上传功能(PNG 转换)并改进配置验证
- 改进验证码验证和转发服务同步
- 添加自定义 favicon 和角落 logo 及实时预览
- 统一限速规则选择器的 placeholder 显示
2026-02-27 19:55:30 +08:00
sagitchu 975948dcf6 fix(frontend): simplify speed limit selector UX 2026-02-27 19:53:19 +08:00
sagitchu a9eac6d01f fix(frontend): default forward speed rule to no limit
Make forward create/edit treat empty speed-limit selection as no limit so the dropdown no longer shows the generic placeholder, and remove announcement console logging to keep frontend lint clean.
2026-02-27 19:37:59 +08:00
sagitchu 6e8406f439 feat: remove speed limit tunnel binding and add migration cleanup
- Remove tunnel binding UI from speed limit page (no more Select component)
- Remove /api/v1/speed-limit/tunnels route alias
- Simplify CreateSpeedLimit/UpdateSpeedLimit to not accept tunnel parameters
- Add schema migration v4 to clear historical tunnel_id/tunnel_name bindings
- Update contract tests to verify tunnel binding is ignored
- Add limiter sync failure tests for forward-level rate limiting
2026-02-27 19:30:02 +08:00
sagit db3577afa9 feat(frontend): improve favicon loading with fallback to config API (#221)
## Summary
- Add synchronous config API call in index.html when localStorage cache
is empty
- Prevents favicon flash on login page during first load
- Enhance getCachedConfigs() to fetch public configs as fallback
- Preserve existing siteConfig values when config keys are missing
2026-02-27 18:25:16 +08:00
sagitchu 7285717e34 feat(frontend): improve favicon loading with fallback to config API
- Add synchronous config API call in index.html when localStorage cache is empty
- Prevents favicon flash on login page during first load
- Enhance getCachedConfigs() to fetch public configs as fallback
- Preserve existing siteConfig values when config keys are missing
2026-02-27 18:23:18 +08:00
sagit de6911f219 feat: add brand asset upload with PNG conversion and improve config validation (#220)
## Summary
- Add file upload support for logo and favicon with automatic PNG
conversion (96x96 for logo, 64x64 for favicon)
- Add backend validation for brand asset data URLs (app_logo,
app_favicon)
- Change vite_config.value column type from varchar(200) to text for
PostgreSQL compatibility
- Add schema migration v3 for vite_config.value column type conversion
- Update frontend to use file picker instead of manual URL input
- Add early favicon application in index.html to prevent flash

## Changes
- Backend: Validate brand asset data URLs, support larger config values
- Frontend: File upload with PNG conversion, improved caching
- Migration: PostgreSQL vite_config.value column type migration

## Testing
- Backend contract tests added for migration v3
2026-02-27 15:56:14 +08:00
sagitchu e5ce0501a2 feat: add brand asset upload with PNG conversion and improve config validation
- Add file upload support for logo and favicon with automatic PNG conversion
- Add backend validation for brand asset data URLs (app_logo, app_favicon)
- Change vite_config.value column type from varchar(200) to text for PostgreSQL
- Add schema migration v3 for vite_config.value column type conversion
- Update frontend to use file picker instead of manual URL input
- Add early favicon application in index.html to prevent flash
2026-02-27 15:54:04 +08:00
sagit 25a87e25c5 fix(backend): improve captcha validation and forward service sync (#219)
## Summary
- Add cloudflare site/secret key validation for captcha enabled check to
prevent incomplete captcha config
- Fix forward create to use UpdateService with tolerateExists for
idempotent service sync
- Introduce isAlreadyExistsMessage helper that correctly excludes
"address already in use" errors from being tolerated
- Add contract tests for forward toggle (pause/resume), address-in-use
rollback, and captcha compatibility

## Test Plan
- Contract tests added: `TestForwardCreateThenPauseResumeContract`,
`TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract`,
`TestIsAlreadyExistsMessage`
- Captcha login flow tests updated for cloudflare key validation
2026-02-27 14:52:14 +08:00
sagitchu a628f31859 fix(backend): improve captcha validation and forward service sync
- Add cloudflare site/secret key validation for captcha enabled check
- Fix forward create to use UpdateService with tolerateExists for idempotent sync
- Introduce isAlreadyExistsMessage helper excluding address-in-use errors
- Add contract tests for forward toggle, address-in-use rollback, captcha compatibility
2026-02-27 14:49:59 +08:00
sagitchu aae138a8cf fix(forward): remove placeholder from speed limit select, default to no limit 2026-02-27 14:49:59 +08:00
sagit d2645589da feat(frontend): add customizable favicon and corner logo with live preview (#218)
* feat(frontend): add customizable favicon and corner logo with live preview

- Add app_logo and app_favicon config fields in config page
- Implement BrandLogo component with URL fallback to SVG logo
- Integrate BrandLogo into all layouts (admin, h5, h5-simple) and navbar
- Add real-time preview for favicon and logo in config page with error state
- Support both relative paths and full image URLs for branding assets
- Sync branding changes (app_name/app_logo/app_favicon) to site config on save

* fix(frontend): preserve tunnel node selection order

* fix(select): use useMemo for option label map to improve performance
2026-02-27 11:50:03 +08:00
77 changed files with 9783 additions and 1175 deletions
+48
View File
@@ -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
+220
View File
@@ -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。
+1
View File
@@ -18,6 +18,7 @@
- [安装部署](./install.md)
- [使用指南](./usage.md)
- [PostgreSQL 数据库指南](./postgresql.md)
- [AI Skill 接入](./ai-skill.md) - 让大模型直接操作面板
- [常见问题](./faq.md)
## 免责声明
+1 -1
View File
@@ -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,
}
+473 -136
View File
@@ -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,40 @@ 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)
const diagnosisMaxConcurrency = 8
const (
defaultNodeCommandTimeout = 6 * time.Second
diagnosisCommandTimeout = 2 * time.Minute
diagnosisRequestTimeout = 2 * time.Minute
)
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
@@ -295,23 +331,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
}
}
@@ -324,6 +367,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("节点不存在")
}
@@ -334,6 +381,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,
@@ -361,41 +411,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,
},
})
}
}
@@ -404,19 +478,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,
},
})
}
}
}
@@ -426,21 +514,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,
},
})
}
}
}
@@ -449,8 +551,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,
},
})
}
}
@@ -458,54 +566,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:
@@ -513,19 +647,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,
},
})
}
}
}
@@ -535,21 +683,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,
},
})
}
}
}
@@ -557,26 +719,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) {
@@ -628,6 +797,157 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
return targets, nil
}
func diagnosisContextMessage(ctx context.Context) string {
if ctx == nil {
return "诊断超时(2分钟)"
}
switch ctx.Err() {
case context.DeadlineExceeded:
return "诊断超时(2分钟)"
case context.Canceled:
return "诊断已取消"
default:
return "诊断超时(2分钟)"
}
}
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 = "诊断超时(2分钟)"
}
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)
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], "诊断超时(2分钟)")
}
}
return results
}
func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {
if node, ok := nodeCache[nodeID]; ok {
return node, nil
@@ -669,7 +989,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)
@@ -686,9 +1006,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
@@ -719,7 +1039,7 @@ 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 {
@@ -731,7 +1051,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
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) {
@@ -784,13 +1104,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
}
@@ -800,7 +1126,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("节点不存在")
}
@@ -809,13 +1135,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,
})
}
@@ -907,6 +1239,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)
@@ -1017,52 +1360,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},
}
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) error {
rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
"limits": []string{limitStr},
}
if _, err := h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false); err != nil {
if err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {
return fmt.Errorf("限速规则下发失败: %w", err)
}
return nil
}
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
name := strconv.FormatInt(limiterID, 10)
return name, map[string]interface{}{
"name": name,
"limits": []string{limitStr},
}
}
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,208 @@
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(),
}
for key, value := range startPayload {
payload[key] = value
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "start", payload); err != nil {
return err
}
streamBroken := false
emitter := func(index int, item map[string]interface{}, progress diagnosisProgress) {
if streamBroken {
return
}
itemPayload := map[string]interface{}{
"index": index,
"result": item,
"progress": progress,
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "item", itemPayload); err != nil {
streamBroken = true
if cancel != nil {
cancel()
}
}
}
results := h.runDiagnosisWorkItems(ctx, workItems, emitter)
if streamBroken {
return context.Canceled
}
progress := summarizeDiagnosisProgress(results)
donePayload := map[string]interface{}{
"progress": progress,
"timedOut": errors.Is(ctx.Err(), context.DeadlineExceeded),
}
return writeDiagnosisStreamEvent(encoder, flusher, "done", donePayload)
}
func (h *Handler) tunnelDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "tunnelId")
if id <= 0 {
return
}
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(id)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"tunnelName": tunnelName,
"tunnelType": tunnelType,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
func (h *Handler) forwardDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "forwardId")
if id <= 0 {
return
}
forward, _, _, err := h.resolveForwardAccess(r, id)
if err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"forwardName": forwardName,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
@@ -1232,16 +1232,20 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
if req.Count <= 0 {
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
@@ -1472,7 +1476,7 @@ func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare,
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
return true
default:
return false
+77 -7
View File
@@ -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)
@@ -747,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
}
@@ -767,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
}
@@ -784,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("请求失败"))
@@ -989,10 +1042,27 @@ func (h *Handler) captchaEnabled() (bool, error) {
if err != nil {
return false, err
}
if cfg == nil {
if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") {
return false, nil
}
return strings.EqualFold(cfg.Value, "true"), nil
siteCfg, err := h.repo.GetConfigByName("cloudflare_site_key")
if err != nil {
return false, err
}
if siteCfg == nil || strings.TrimSpace(siteCfg.Value) == "" {
return false, nil
}
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
if err != nil {
return false, err
}
if secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" {
return false, nil
}
return true, nil
}
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
+10 -41
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
@@ -787,7 +788,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()))
@@ -1183,7 +1186,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
@@ -1399,7 +1402,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()))
@@ -1659,28 +1664,13 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
speed := asInt(req["speed"], 100)
var tunnelID *int64
var tunnelName string
if tid := asInt64(req["tunnelId"], 0); tid > 0 {
tunnelID = &tid
tunnelName = h.repo.GetTunnelNameByID(tid)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
}
now := time.Now().UnixMilli()
id, err := h.repo.CreateSpeedLimit(name, speed, tunnelID, tunnelName, now, asInt(req["status"], 1))
_, err := h.repo.CreateSpeedLimit(name, speed, now, asInt(req["status"], 1))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if tunnelID != nil && *tunnelID > 0 {
_ = h.sendLimiterConfig(id, speed, *tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -1705,26 +1695,11 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
speed := asInt(req["speed"], 100)
var tunnelID *int64
var tunnelName string
if tid := asInt64(req["tunnelId"], 0); tid > 0 {
tunnelID = &tid
tunnelName = h.repo.GetTunnelNameByID(tid)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
}
if err := h.repo.UpdateSpeedLimit(id, 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
}
if tunnelID != nil && *tunnelID > 0 {
_ = h.sendLimiterConfig(id, speed, *tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -1734,17 +1709,11 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
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.Valid && tunnelID.Int64 > 0 {
_ = h.sendDeleteLimiterConfig(id, tunnelID.Int64)
}
response.WriteJSON(w, response.OKEmpty())
}
+1 -1
View File
@@ -214,7 +214,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"`
}
+75 -20
View File
@@ -688,12 +688,6 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
"status": sl.Status, "createdTime": sl.CreatedTime,
"updatedTime": nullableInt64(sl.UpdatedTime),
}
if sl.TunnelID.Valid {
item["tunnelId"] = sl.TunnelID.Int64
}
if sl.TunnelName.Valid {
item["tunnelName"] = sl.TunnelName.String
}
items = append(items, item)
}
return items, nil
@@ -1825,13 +1819,6 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
CreatedTime: sl.CreatedTime, Status: sl.Status,
}
if sl.TunnelID.Valid {
tid := sl.TunnelID.Int64
b.TunnelID = &tid
}
if sl.TunnelName.Valid {
b.TunnelName = sl.TunnelName.String
}
if sl.UpdatedTime.Valid {
b.UpdatedTime = sl.UpdatedTime.Int64
}
@@ -2208,12 +2195,6 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: sl.Status,
}
if sl.TunnelID != nil {
item.TunnelID = sql.NullInt64{Int64: *sl.TunnelID, Valid: true}
}
if sl.TunnelName != "" {
item.TunnelName = sql.NullString{String: sl.TunnelName, Valid: true}
}
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
@@ -2482,9 +2463,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
@@ -2536,10 +2519,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
@@ -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)
}
}
@@ -522,9 +522,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 +532,6 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
})
}
func (r *Repository) GetTunnelNameByID(tunnelID int64) string {
if r == nil || r.db == nil {
return ""
}
var tunnel model.Tunnel
if err := r.db.Select("name").Where("id = ?", tunnelID).First(&tunnel).Error; err != nil {
return ""
}
return tunnel.Name
}
func (r *Repository) TunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -766,7 +752,7 @@ 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")
}
@@ -779,57 +765,32 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID *int64, t
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status,
}
if tunnelID != nil {
sl.TunnelID = sql.NullInt64{Int64: *tunnelID, Valid: true}
}
if tunnelName != "" {
sl.TunnelName = sql.NullString{String: tunnelName, Valid: true}
}
if err := r.db.Create(&sl).Error; err != nil {
return 0, err
}
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,
"name": name,
"speed": speed,
"status": status,
"tunnel_id": nil,
"tunnel_name": nil,
"updated_time": sql.NullInt64{
Int64: now,
Valid: true,
},
}
if tunnelID != nil {
updates["tunnel_id"] = sql.NullInt64{Int64: *tunnelID, Valid: true}
} else {
updates["tunnel_id"] = sql.NullInt64{Int64: 0, Valid: false}
}
if tunnelName != "" {
updates["tunnel_name"] = sql.NullString{String: tunnelName, Valid: true}
} else {
updates["tunnel_name"] = sql.NullString{String: "", Valid: false}
}
return r.db.Model(&model.SpeedLimit{}).
Where("id = ?", id).
Updates(updates).Error
}
func (r *Repository) GetSpeedLimitTunnelID(speedLimitID int64) sql.NullInt64 {
if r == nil || r.db == nil {
return sql.NullInt64{Valid: false}
}
var sl model.SpeedLimit
if err := r.db.Select("tunnel_id").Where("id = ?", speedLimitID).First(&sl).Error; err != nil {
return sql.NullInt64{Valid: false}
}
return sl.TunnelID
}
func (r *Repository) DeleteSpeedLimit(id int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
@@ -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
}
@@ -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 {
@@ -109,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)
}
})
@@ -144,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)
}
})
@@ -610,6 +618,96 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
}
}
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)
}
@@ -99,6 +99,239 @@ func TestForwardCreateRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
}
}
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)
@@ -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,6 +35,55 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
t.Fatalf("enable captcha: %v", err)
}
t.Run("login allowed when cloudflare keys are missing", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
})
t.Run("captcha verify remains compatible without cloudflare secret", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
verifyResp := httptest.NewRecorder()
router.ServeHTTP(verifyResp, verifyReq)
var verifyOut struct {
Success bool `json:"success"`
Data struct {
ValidToken string `json:"validToken"`
} `json:"data"`
}
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
t.Fatalf("decode captcha verify response: %v", err)
}
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
}
verifiedToken = verifyOut.Data.ValidToken
})
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, "cloudflare_site_key", "test-site-key", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("set cloudflare site key: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, "cloudflare_secret_key", "test-secret-key", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("set cloudflare secret key: %v", err)
}
t.Run("login denied without verified captcha token", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
@@ -58,33 +108,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
verifyResp := httptest.NewRecorder()
router.ServeHTTP(verifyResp, verifyReq)
var verifyOut struct {
Success bool `json:"success"`
Data struct {
ValidToken string `json:"validToken"`
} `json:"data"`
}
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
t.Fatalf("decode captcha verify response: %v", err)
}
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
if strings.TrimSpace(verifiedToken) == "" {
t.Fatalf("expected verified token from compatibility captcha verify")
}
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
loginReq.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()
@@ -174,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) {
@@ -15,7 +15,6 @@ import (
"go-backend/internal/store/repo"
)
// TestSpeedLimitWithoutTunnelContract tests that speed limits can be created without binding to a tunnel
func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
@@ -25,8 +24,7 @@ func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
t.Fatalf("generate admin token: %v", err)
}
// Create a speed limit without tunnel binding
t.Run("create speed limit without tunnel", func(t *testing.T) {
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)
@@ -37,8 +35,7 @@ func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
assertCode(t, res, 0)
})
// Verify the speed limit has null tunnelId
t.Run("list speed limits shows null tunnelId", func(t *testing.T) {
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()
@@ -57,31 +54,28 @@ func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
t.Fatalf("expected data to be array, got %T", out.Data)
}
// Find our speed limit
var found bool
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] == "test-limit-no-tunnel" {
found = true
// tunnelId should be nil/not present for unbound speed limits
if tunnelID, exists := m["tunnelId"]; exists && tunnelID != nil {
t.Fatalf("expected tunnelId to be nil for unbound speed limit, got %v", tunnelID)
}
break
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
}
if !found {
t.Fatal("speed limit 'test-limit-no-tunnel' not found in list")
}
t.Fatal("speed limit 'test-limit-no-tunnel' not found in list")
})
}
// TestSpeedLimitWithTunnelContract tests that speed limits can still be bound to tunnels
func TestSpeedLimitWithTunnelContract(t *testing.T) {
func TestSpeedLimitCreateIgnoresTunnelBindingContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
@@ -90,71 +84,56 @@ func TestSpeedLimitWithTunnelContract(t *testing.T) {
t.Fatalf("generate admin token: %v", err)
}
// First create a tunnel
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-tunnel-for-limit")
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-create-ignore-tunnel")
// Create a speed limit with tunnel binding
t.Run("create speed limit with tunnel", func(t *testing.T) {
body := `{"name":"test-limit-with-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)
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)
})
assertCode(t, res, 0)
// Verify the speed limit has the tunnelId
t.Run("list speed limits shows tunnelId", 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)
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)
}
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{})
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 {
t.Fatalf("expected data to be array, got %T", out.Data)
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
}
var found bool
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] == "test-limit-with-tunnel" {
found = true
tunnelIDVal, exists := m["tunnelId"]
if !exists || tunnelIDVal == nil {
t.Fatal("expected tunnelId to be present for bound speed limit")
}
// Verify tunnelId matches
if tunnelIDFloat, ok := tunnelIDVal.(float64); ok {
if int64(tunnelIDFloat) != tunnelID {
t.Fatalf("expected tunnelId %d, got %d", tunnelID, int64(tunnelIDFloat))
}
}
break
}
}
if !found {
t.Fatal("speed limit 'test-limit-with-tunnel' not found in list")
}
})
t.Fatal("speed limit 'test-limit-ignore-tunnel' not found in list")
}
// TestSpeedLimitUpdateTunnelBindingContract tests updating speed limit tunnel binding
func TestSpeedLimitUpdateTunnelBindingContract(t *testing.T) {
func TestSpeedLimitUpdateIgnoresTunnelBindingContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
@@ -163,109 +142,56 @@ func TestSpeedLimitUpdateTunnelBindingContract(t *testing.T) {
t.Fatalf("generate admin token: %v", err)
}
// Create a tunnel
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-tunnel-update")
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-update-ignore-tunnel")
speedLimitID := mustCreateSpeedLimitRepo(t, r, "test-limit-update-ignore-tunnel")
// Create a speed limit without tunnel
speedLimitID := mustCreateSpeedLimitRepo(t, r, "test-limit-update", 0)
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)
// Update to bind to tunnel
t.Run("update speed limit to bind tunnel", func(t *testing.T) {
body := `{"id":` + jsonInt(speedLimitID) + `,"name":"test-limit-update","speed":150,"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)
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)
// Verify binding
t.Run("verify tunnel binding after update", 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)
}
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)
}
data, ok := out.Data.([]interface{})
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
continue
}
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] == "test-limit-update" {
tunnelIDVal, exists := m["tunnelId"]
if !exists || tunnelIDVal == nil {
t.Fatal("expected tunnelId to be present after update")
}
return
}
if m["name"] != "test-limit-update-ignore-tunnel" {
continue
}
t.Fatal("speed limit 'test-limit-update' not found")
})
// Update to unbind from tunnel (set tunnelId to null)
t.Run("update speed limit to unbind tunnel", func(t *testing.T) {
body := `{"id":` + jsonInt(speedLimitID) + `,"name":"test-limit-update","speed":150,"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)
})
// Verify unbinding
t.Run("verify tunnel unbinding after update", 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 tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
t.Fatalf("expected tunnelId ignored and nil after update, got %v", tunnelIDVal)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
if speedVal, ok := m["speed"].(float64); !ok || int(speedVal) != 256 {
t.Fatalf("expected speed 256 after update, got %v", m["speed"])
}
return
}
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" {
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
t.Fatalf("expected tunnelId to be nil after unbinding, got %v", tunnelIDVal)
}
return
}
}
t.Fatal("speed limit 'test-limit-update' not found")
})
t.Fatal("speed limit 'test-limit-update-ignore-tunnel' not found in list")
}
// TestSpeedLimitDatabaseNullableFields tests database-level nullable fields
func TestSpeedLimitDatabaseNullableFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-null.db")
r, err := repo.Open(dbPath)
@@ -274,132 +200,65 @@ func TestSpeedLimitDatabaseNullableFields(t *testing.T) {
}
t.Cleanup(func() { _ = r.Close() })
// Create speed limit via repository
t.Run("repository create speed limit without tunnel", func(t *testing.T) {
id, err := r.CreateSpeedLimit("db-test-limit", 100, nil, "", 1, 1)
if err != nil {
t.Fatalf("CreateSpeedLimit failed: %v", err)
}
if id <= 0 {
t.Fatalf("expected valid id, got %d", id)
}
})
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)
}
// Verify TunnelID is null in database
t.Run("verify null TunnelID in database", func(t *testing.T) {
var tunnelID sql.NullInt64
var tunnelName sql.NullString
err := r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?", "db-test-limit").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)
}
})
// Create a tunnel for binding test
tunnelID := mustCreateSpeedLimitTunnel(t, r, "db-test-tunnel")
// Create speed limit with tunnel
t.Run("repository create speed limit with tunnel", func(t *testing.T) {
id, err := r.CreateSpeedLimit("db-test-limit-with-tunnel", 200, &tunnelID, "db-test-tunnel", 1, 1)
if err != nil {
t.Fatalf("CreateSpeedLimit failed: %v", err)
}
if id <= 0 {
t.Fatalf("expected valid id, got %d", id)
}
})
// Verify TunnelID is set
t.Run("verify TunnelID is set in database", func(t *testing.T) {
var dbTunnelID sql.NullInt64
var dbTunnelName sql.NullString
err := r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?", "db-test-limit-with-tunnel").Row().Scan(&dbTunnelID, &dbTunnelName)
if err != nil {
t.Fatalf("query failed: %v", err)
}
if !dbTunnelID.Valid {
t.Fatal("expected TunnelID to be valid")
}
if dbTunnelID.Int64 != tunnelID {
t.Fatalf("expected TunnelID %d, got %d", tunnelID, dbTunnelID.Int64)
}
if !dbTunnelName.Valid || dbTunnelName.String != "db-test-tunnel" {
t.Fatalf("expected TunnelName 'db-test-tunnel', got %v", dbTunnelName.String)
}
})
// Test GetSpeedLimitTunnelID returns correct nullability
t.Run("GetSpeedLimitTunnelID returns null for unbound limit", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(1) // First speed limit (db-test-limit)
if result.Valid {
t.Fatalf("expected GetSpeedLimitTunnelID to return invalid/null, got valid with value %d", result.Int64)
}
})
t.Run("GetSpeedLimitTunnelID returns value for bound limit", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(2) // Second speed limit (db-test-limit-with-tunnel)
if !result.Valid {
t.Fatal("expected GetSpeedLimitTunnelID to return valid result for bound limit")
}
if result.Int64 != tunnelID {
t.Fatalf("expected TunnelID %d, got %d", tunnelID, result.Int64)
}
})
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)
}
}
// TestSpeedLimitUpdateUnbindFromTunnel tests unbinding a speed limit from a tunnel
func TestSpeedLimitUpdateUnbindFromTunnel(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-unbind.db")
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() })
// Create tunnel
tunnelID := mustCreateSpeedLimitTunnel(t, r, "unbind-test-tunnel")
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")
// Create speed limit bound to tunnel
speedLimitID, err := r.CreateSpeedLimit("unbind-test-limit", 300, &tunnelID, "unbind-test-tunnel", 1, 1)
err = r.UpdateSpeedLimit(speedLimitID, "speed-limit-update-clear", 512, 1, time.Now().UnixMilli())
if err != nil {
t.Fatalf("create speed limit: %v", err)
t.Fatalf("UpdateSpeedLimit failed: %v", err)
}
// Verify initial binding
t.Run("verify initial binding", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(speedLimitID)
if !result.Valid {
t.Fatal("expected initial binding to tunnel")
}
if result.Int64 != tunnelID {
t.Fatalf("expected tunnel ID %d, got %d", tunnelID, result.Int64)
}
})
// Update to unbind
t.Run("unbind speed limit from tunnel via UpdateSpeedLimit", func(t *testing.T) {
err := r.UpdateSpeedLimit(speedLimitID, "unbind-test-limit", 300, nil, "", 1, time.Now().UnixMilli())
if err != nil {
t.Fatalf("UpdateSpeedLimit failed: %v", err)
}
})
// Verify unbinding
t.Run("verify unbinding after update", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(speedLimitID)
if result.Valid {
t.Fatalf("expected GetSpeedLimitTunnelID to return invalid/null after unbind, got valid with value %d", result.Int64)
}
})
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)
}
}
// TestSpeedLimitGetSpeed tests the GetSpeedLimitSpeed function
func TestSpeedLimitGetSpeed(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-getspeed.db")
r, err := repo.Open(dbPath)
@@ -408,13 +267,11 @@ func TestSpeedLimitGetSpeed(t *testing.T) {
}
t.Cleanup(func() { _ = r.Close() })
// Create speed limit
speedLimitID, err := r.CreateSpeedLimit("get-speed-test", 500, nil, "", 1, 1)
speedLimitID, err := r.CreateSpeedLimit("get-speed-test", 500, 1, 1)
if err != nil {
t.Fatalf("create speed limit: %v", err)
}
// Test GetSpeedLimitSpeed
t.Run("GetSpeedLimitSpeed returns correct speed", func(t *testing.T) {
speed, err := r.GetSpeedLimitSpeed(speedLimitID)
if err != nil {
@@ -433,8 +290,6 @@ func TestSpeedLimitGetSpeed(t *testing.T) {
})
}
// Helper functions
func mustCreateSpeedLimitTunnel(t *testing.T, r *repo.Repository, name string) int64 {
t.Helper()
now := time.Now().UnixMilli()
@@ -447,14 +302,10 @@ func mustCreateSpeedLimitTunnel(t *testing.T, r *repo.Repository, name string) i
return mustLastInsertID(t, r, name)
}
func mustCreateSpeedLimitRepo(t *testing.T, r *repo.Repository, name string, tunnelID int64) int64 {
func mustCreateSpeedLimitRepo(t *testing.T, r *repo.Repository, name string) int64 {
t.Helper()
now := time.Now().UnixMilli()
var tid *int64
if tunnelID > 0 {
tid = &tunnelID
}
id, err := r.CreateSpeedLimit(name, 100, tid, "", now, 1)
id, err := r.CreateSpeedLimit(name, 100, now, 1)
if err != nil {
t.Fatalf("create speed limit failed: %v", err)
}
@@ -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
}
+2
View File
@@ -30,6 +30,8 @@ nav:
- 首页: index.md
- 安装部署: install.md
- 使用指南: usage.md
- AI Skill 接入: ai-skill.md
- PostgreSQL: postgresql.md
- 常见问题: faq.md
markdown_extensions:
+302
View File
@@ -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
```
+44
View File
@@ -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){}\""
}
}
+151
View File
@@ -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) |
+143
View File
@@ -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
+149
View File
@@ -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"
}'
```
+168
View File
@@ -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)
}
```
+281
View File
@@ -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]
}'
```
+270
View File
@@ -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)
}'
```
+240
View File
@@ -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.
+266
View File
@@ -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}"
```
+143
View File
@@ -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'
```
+313
View File
@@ -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}"
```
+346
View File
@@ -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", {});
}
}
```
+187
View File
@@ -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
}'
```
+35
View File
@@ -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
+173
View File
@@ -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
```
+6
View File
@@ -0,0 +1,6 @@
# E2E Tests Package
"""
FLVX End-to-End Testing Suite
"""
__version__ = "0.1.0"
+198
View File
@@ -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)")
+65
View File
@@ -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()
+125
View File
@@ -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
+184
View File
@@ -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")
+42
View File
@@ -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"]
+185
View File
@@ -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}"
+117
View File
@@ -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
+105
View File
@@ -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
+82
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
"""Utils package for E2E tests."""
from .api_client import APIClient, TestUser
__all__ = ["APIClient", "TestUser"]
+264
View File
@@ -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)
+379
View File
@@ -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()
+70 -2
View File
@@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
<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" />
@@ -13,6 +12,76 @@
<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;
@@ -156,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>
+30
View File
@@ -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/;
}
+3
View File
@@ -80,5 +80,8 @@
"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"
}
}
+8 -14
View File
@@ -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 (
+343
View File
@@ -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,
});
};
+10 -2
View File
@@ -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: {
-4
View File
@@ -97,10 +97,8 @@ export interface StatisticsFlowApiItem {
export interface SpeedLimitApiItem {
id: number;
name: string;
tunnelId?: number | null;
speed: number;
status: number;
tunnelName?: string;
createdTime: string;
updatedTime: string;
uploadSpeed?: number;
@@ -293,8 +291,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)}
/>
);
};
+2 -2
View File
@@ -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>
+146 -13
View File
@@ -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);
}
+2 -2
View File
@@ -19,7 +19,7 @@ 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";
@@ -330,7 +330,7 @@ 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"}`}
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+271 -23
View File
@@ -28,16 +28,17 @@ import {
} from "@/api";
import { SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth";
import {
getCachedConfigs,
clearConfigCache,
updateSiteConfig,
} from "@/config/site";
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import {
convertBrandAssetToPngDataURL,
isPngDataURL,
type BrandAssetKind,
} from "@/utils/brand-asset";
// 简单的保存图标组件
const SaveIcon = ({ className }: { className?: string }) => (
@@ -67,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[] = [
{
@@ -91,6 +105,18 @@ 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: "captcha_enabled",
label: "启用验证码",
@@ -143,6 +169,8 @@ const getInitialConfigs = (): Record<string, string> => {
"cloudflare_secret_key",
"ip",
"panel_domain",
"app_logo",
"app_favicon",
];
const initialConfigs: Record<string, string> = {};
@@ -179,7 +207,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: "",
@@ -190,6 +220,12 @@ export default function ConfigPage() {
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [previewLoadFailed, setPreviewLoadFailed] = useState<
Partial<Record<BrandPreviewKey, boolean>>
>({});
const [brandUploading, setBrandUploading] = useState<
Partial<Record<BrandPreviewKey, boolean>>
>({});
// 权限检查
useEffect(() => {
@@ -251,8 +287,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);
}
@@ -288,6 +323,10 @@ export default function ConfigPage() {
setConfigs(newConfigs);
if (isBrandPreviewKey(key)) {
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
}
const hasChangesNow =
Object.keys(newConfigs).some(
(k) => newConfigs[k] !== originalConfigs[k],
@@ -303,25 +342,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);
}
// 触发配置更新事件,通知其他组件
@@ -349,6 +403,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 =
@@ -356,6 +600,10 @@ export default function ConfigPage() {
switch (item.type) {
case "input":
if (isBrandPreviewKey(item.key)) {
return renderBrandAssetUploader(item.key, isChanged);
}
return (
<Input
classNames={{
@@ -450,7 +698,7 @@ export default function ConfigPage() {
}
setImportSelectorOpen(false);
requestAnimationFrame(() => fileInputRef.current?.click());
requestAnimationFrame(() => backupFileInputRef.current?.click());
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -487,8 +735,8 @@ export default function ConfigPage() {
toast.error("导入失败,请检查文件格式");
} finally {
setImporting(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
if (backupFileInputRef.current) {
backupFileInputRef.current.value = "";
}
}
};
@@ -830,7 +1078,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 () => {
+630 -139
View File
@@ -1,6 +1,6 @@
import type { SpeedLimitApiItem } from "@/api/types";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import toast from "react-hot-toast";
import {
DndContext,
@@ -76,6 +76,7 @@ import {
getForwardDiagnosisQualityDisplay,
type ForwardDiagnosisResult,
} from "@/pages/forward/diagnosis";
import { diagnoseForwardStream } from "@/api/diagnosis-stream";
import {
executeForwardBatchChangeTunnel,
executeForwardBatchDelete,
@@ -130,6 +131,57 @@ interface ForwardForm {
speedId: number | null;
}
interface ForwardUserGroup {
userId: number;
userName: string;
tunnels: ForwardTunnelGroup[];
}
interface ForwardTunnelGroup {
tunnelKey: string;
tunnelName: string;
items: Forward[];
}
const UNKNOWN_FORWARD_USER_NAME = "未知用户";
const UNCATEGORIZED_FORWARD_TUNNEL_NAME = "未分类";
const normalizeForwardUserName = (userName?: string): string => {
const normalized = (userName || UNKNOWN_FORWARD_USER_NAME).trim();
return normalized || UNKNOWN_FORWARD_USER_NAME;
};
const compareForwardUserNameAsc = (a: string, b: string): number => {
return a.localeCompare(b, "en", {
sensitivity: "base",
numeric: true,
});
};
const normalizeForwardTunnelName = (tunnelName?: string): string => {
const normalized = (tunnelName || "").trim();
return normalized || UNCATEGORIZED_FORWARD_TUNNEL_NAME;
};
const buildForwardTunnelGroupKey = (tunnelName?: string): string => {
const normalized = normalizeForwardTunnelName(tunnelName);
if (normalized === UNCATEGORIZED_FORWARD_TUNNEL_NAME) {
return "__uncategorized__";
}
return normalized.toLocaleLowerCase();
};
const compareForwardTunnelNameAsc = (a: string, b: string): number => {
return a.localeCompare(b, "en", {
sensitivity: "base",
numeric: true,
});
};
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
@@ -177,6 +229,14 @@ export default function ForwardPage() {
useState<Forward | null>(null);
const [diagnosisResult, setDiagnosisResult] =
useState<ForwardDiagnosisResult | null>(null);
const [diagnosisProgress, setDiagnosisProgress] = useState({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
const diagnosisAbortRef = useRef<AbortController | null>(null);
const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);
@@ -229,6 +289,13 @@ export default function ForwardPage() {
);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => {
return () => {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
};
}, []);
const parseShareIdFromTunnelName = (tunnelName: string): number | null => {
const normalized = (tunnelName || "").trim();
@@ -779,28 +846,148 @@ export default function ForwardPage() {
// 诊断转发
const handleDiagnose = async (forward: Forward) => {
diagnosisAbortRef.current?.abort();
const abortController = new AbortController();
diagnosisAbortRef.current = abortController;
setCurrentDiagnosisForward(forward);
setDiagnosisModalOpen(true);
setDiagnosisLoading(true);
setDiagnosisResult(null);
setDiagnosisProgress({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
results: [],
});
try {
const response = await diagnoseForward(forward.id);
let streamErrorMessage = "";
const streamResult = await diagnoseForwardStream(
forward.id,
{
onStart: (payload) => {
const startForwardName =
typeof payload.forwardName === "string" &&
payload.forwardName.trim() !== ""
? payload.forwardName
: forward.name;
const startTotal = Number(payload.total);
setDiagnosisResult((prev) => ({
forwardName: startForwardName,
timestamp: Date.now(),
results: prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
...prev,
total: startTotal,
}));
}
},
onItem: ({ result, progress }) => {
setDiagnosisResult((prev) => {
const base: ForwardDiagnosisResult = prev || {
forwardName: forward.name,
timestamp: Date.now(),
results: [],
};
const nextResults = [...base.results];
const existingIndex = nextResults.findIndex(
(item) =>
item.description === result.description &&
item.nodeId === result.nodeId &&
item.targetIp === result.targetIp &&
item.targetPort === result.targetPort,
);
if (response.code === 0) {
setDiagnosisResult(response.data as ForwardDiagnosisResult);
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildForwardDiagnosisFallbackResult({
forwardName: forward.name,
remoteAddr: forward.remoteAddr,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
} else {
nextResults.push(result);
}
return {
...base,
timestamp: Date.now(),
results: nextResults,
};
});
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onDone: (progress) => {
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onError: (message) => {
streamErrorMessage = message;
},
},
abortController.signal,
);
if (streamResult.fallback) {
const response = await diagnoseForward(forward.id);
if (response.code === 0) {
const resultData = response.data as ForwardDiagnosisResult;
const successCount = resultData.results.filter((r) => r.success).length;
const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData);
setDiagnosisProgress({
total: resultData.results.length,
completed: resultData.results.length,
success: successCount,
failed: failedCount,
timedOut: false,
});
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildForwardDiagnosisFallbackResult({
forwardName: forward.name,
remoteAddr: forward.remoteAddr,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
}
return;
}
if (streamErrorMessage) {
toast.error(streamErrorMessage);
}
if (streamResult.timedOut) {
toast.error("诊断达到2分钟超时,已返回当前结果");
}
} catch {
if (abortController.signal.aborted) {
return;
}
toast.error("网络错误,请重试");
setDiagnosisResult(
buildForwardDiagnosisFallbackResult({
@@ -810,7 +997,17 @@ export default function ForwardPage() {
message: "无法连接到服务器",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
} finally {
if (diagnosisAbortRef.current === abortController) {
diagnosisAbortRef.current = null;
}
setDiagnosisLoading(false);
}
};
@@ -1145,6 +1342,20 @@ export default function ForwardPage() {
// 检查 ID 是否有效
if (isNaN(activeId) || isNaN(overId)) return;
const activeForward = forwards.find((forward) => forward.id === activeId);
const overForward = forwards.find((forward) => forward.id === overId);
const activeUserId = activeForward?.userId ?? 0;
const overUserId = overForward?.userId ?? 0;
const activeTunnelGroupKey = buildForwardTunnelGroupKey(
activeForward?.tunnelName,
);
const overTunnelGroupKey = buildForwardTunnelGroupKey(overForward?.tunnelName);
// 仅允许在同一用户+隧道分组内拖拽,避免混排
if (activeUserId !== overUserId || activeTunnelGroupKey !== overTunnelGroupKey) {
return;
}
const oldIndex = forwardOrder.indexOf(activeId);
const newIndex = forwardOrder.indexOf(overId);
@@ -1342,9 +1553,11 @@ export default function ForwardPage() {
);
const tokenUserId = JwtUtil.getUserIdFromToken();
const tokenRoleId = JwtUtil.getRoleIdFromToken();
const isAdmin = tokenRoleId === 0;
// 根据排序顺序获取转发列表
const sortedForwards = useMemo((): Forward[] => {
const orderedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效
if (!forwards || forwards.length === 0) {
return [];
@@ -1426,16 +1639,125 @@ export default function ForwardPage() {
}, [
forwards,
forwardOrder,
viewMode,
tokenUserId,
filterUserId,
filterTunnelId,
searchKeyword,
]);
const sortableForwardIds = useMemo(
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
[sortedForwards],
const groupedForwards = useMemo((): ForwardUserGroup[] => {
if (orderedForwards.length === 0) {
return [];
}
type MutableForwardUserGroup = {
userId: number;
userName: string;
tunnelMap: Map<string, ForwardTunnelGroup>;
};
const userGroupMap = new Map<number, MutableForwardUserGroup>();
orderedForwards.forEach((forward) => {
const userId = forward.userId ?? 0;
const userName = normalizeForwardUserName(forward.userName);
const tunnelName = normalizeForwardTunnelName(forward.tunnelName);
const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName);
let existingGroup = userGroupMap.get(userId);
if (!existingGroup) {
existingGroup = {
userId,
userName,
tunnelMap: new Map<string, ForwardTunnelGroup>(),
};
userGroupMap.set(userId, existingGroup);
} else if (
existingGroup.userName === UNKNOWN_FORWARD_USER_NAME &&
userName !== UNKNOWN_FORWARD_USER_NAME
) {
existingGroup.userName = userName;
}
const existingTunnelGroup = existingGroup.tunnelMap.get(tunnelKey);
if (!existingTunnelGroup) {
existingGroup.tunnelMap.set(tunnelKey, {
tunnelKey,
tunnelName,
items: [forward],
});
return;
}
existingTunnelGroup.items.push(forward);
if (
existingTunnelGroup.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME &&
tunnelName !== UNCATEGORIZED_FORWARD_TUNNEL_NAME
) {
existingTunnelGroup.tunnelName = tunnelName;
}
});
const groups = Array.from(userGroupMap.values()).map((group) => {
const tunnels = Array.from(group.tunnelMap.values());
tunnels.sort((a, b) => {
const aIsUncategorized =
a.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME;
const bIsUncategorized =
b.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME;
if (aIsUncategorized !== bIsUncategorized) {
return aIsUncategorized ? 1 : -1;
}
const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName);
if (nameCompare !== 0) {
return nameCompare;
}
return compareForwardTunnelNameAsc(a.tunnelKey, b.tunnelKey);
});
return {
userId: group.userId,
userName: group.userName,
tunnels,
};
});
groups.sort((a, b) => {
if (isAdmin && tokenUserId !== null) {
const aIsSelf = a.userId === tokenUserId;
const bIsSelf = b.userId === tokenUserId;
if (aIsSelf !== bIsSelf) {
return aIsSelf ? -1 : 1;
}
}
const nameCompare = compareForwardUserNameAsc(a.userName, b.userName);
if (nameCompare !== 0) {
return nameCompare;
}
return a.userId - b.userId;
});
return groups;
}, [orderedForwards, isAdmin, tokenUserId]);
const sortedForwards = useMemo(
() =>
groupedForwards.flatMap((group) =>
group.tunnels.flatMap((tunnel) => tunnel.items),
),
[groupedForwards],
);
// 可拖拽的转发卡片组件
@@ -1474,15 +1796,46 @@ export default function ForwardPage() {
const userMap = new Map<number, { id: number; name: string }>();
forwards.forEach((f) => {
const uId = f.userId || 0;
const uId = f.userId ?? 0;
const userName = normalizeForwardUserName(f.userName);
const existingUser = userMap.get(uId);
if (!userMap.has(uId)) {
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
if (!existingUser) {
userMap.set(uId, { id: uId, name: userName });
return;
}
if (
existingUser.name === UNKNOWN_FORWARD_USER_NAME &&
userName !== UNKNOWN_FORWARD_USER_NAME
) {
existingUser.name = userName;
}
});
return Array.from(userMap.values());
}, [forwards]);
const users = Array.from(userMap.values());
users.sort((a, b) => {
if (isAdmin && tokenUserId !== null) {
const aIsSelf = a.id === tokenUserId;
const bIsSelf = b.id === tokenUserId;
if (aIsSelf !== bIsSelf) {
return aIsSelf ? -1 : 1;
}
}
const nameCompare = compareForwardUserNameAsc(a.name, b.name);
if (nameCompare !== 0) {
return nameCompare;
}
return a.id - b.id;
});
return users;
}, [forwards, isAdmin, tokenUserId]);
// 可拖拽的表格行组件
const SortableTableRow = ({
@@ -1552,25 +1905,9 @@ export default function ForwardPage() {
</svg>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<span className="text-sm font-medium text-default-700">
{forward.userName || "未知用户"}
</span>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground">
{forward.name}
</TableCell>
<TableCell className="whitespace-nowrap">
<Chip
className="border-none bg-secondary/10 px-2"
color="secondary"
size="sm"
>
<span className="font-medium text-secondary-700">
{forward.tunnelName}
</span>
</Chip>
</TableCell>
<TableCell className="max-w-[220px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
@@ -1724,7 +2061,7 @@ export default function ForwardPage() {
{forward.name}
</h3>
<p className="text-xs text-default-500 truncate">
{forward.tunnelName}
{normalizeForwardTunnelName(forward.tunnelName)}
</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
@@ -2207,63 +2544,120 @@ export default function ForwardPage() {
{/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? (
sortedForwards.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label="全部转发列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => (
<SortableContext
key={forward.id}
items={sortableForwardIds}
strategy={verticalListSortingStrategy}
>
<SortableTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
<div className="space-y-4">
{groupedForwards.map((group) => {
const isSelfGroup =
isAdmin && tokenUserId !== null && group.userId === tokenUserId;
const groupForwardCount = group.tunnels.reduce(
(total, tunnel) => total + tunnel.items.length,
0,
);
return (
<div
key={`grouped-table-${group.userId}-${group.userName}`}
className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md"
>
<div className="flex items-center justify-between border-b border-divider bg-default-100/40 px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{group.userName}
</span>
{isSelfGroup && (
<Chip color="primary" size="sm" variant="flat">
管理员本人
</Chip>
)}
</div>
<span className="text-xs text-default-600">
{groupForwardCount} 条转发
</span>
</div>
<div className="space-y-4 p-4">
{group.tunnels.map((tunnel) => {
const tunnelSortableForwardIds = tunnel.items
.map((item) => item.id)
.filter((id) => id > 0);
return (
<div
key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`}
className="overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5"
>
<div className="flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5">
<span className="text-sm font-semibold text-secondary-700">
{tunnel.tunnelName}
</span>
<span className="text-xs text-secondary-700">
{tunnel.items.length} 条转发
</span>
</div>
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label={`${group.userName}-${tunnel.tunnelName}转发列表`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>名称</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无转发配置"
items={tunnel.items}
>
{(forward) => (
<SortableContext
key={forward.id}
items={tunnelSortableForwardIds}
strategy={verticalListSortingStrategy}
>
<SortableTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
/* 空状态 */
@@ -2279,26 +2673,87 @@ export default function ForwardPage() {
</Card>
)
) : /* 直接显示模式 */
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
sortedForwards.length > 0 ? (
<div className="space-y-5">
{groupedForwards.map((group) => {
const isSelfGroup =
isAdmin && tokenUserId !== null && group.userId === tokenUserId;
const groupForwardCount = group.tunnels.reduce(
(total, tunnel) => total + tunnel.items.length,
0,
);
return (
<div
key={`direct-group-${group.userId}-${group.userName}`}
className="space-y-3"
>
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{group.userName}
</span>
{isSelfGroup && (
<Chip color="primary" size="sm" variant="flat">
管理员本人
</Chip>
)}
</div>
<span className="text-xs text-default-600">
{groupForwardCount} 条转发
</span>
</div>
<div className="space-y-4">
{group.tunnels.map((tunnel) => {
const tunnelSortableForwardIds = tunnel.items
.map((item) => item.id)
.filter((id) => id > 0);
return (
<div
key={`direct-group-${group.userId}-${tunnel.tunnelKey}`}
className="rounded-xl border border-secondary/20 bg-secondary/5 p-3 space-y-3"
>
<div className="flex items-center justify-between rounded-lg bg-secondary/10 px-3 py-2">
<span className="text-sm font-semibold text-secondary-700">
{tunnel.tunnelName}
</span>
<span className="text-xs text-secondary-700">
{tunnel.items.length} 条转发
</span>
</div>
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={tunnelSortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{tunnel.items.map((forward) =>
forward && forward.id ? (
<SortableForwardCard
key={forward.id}
forward={forward}
/>
) : null,
)}
</div>
</SortableContext>
</DndContext>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
@@ -2349,26 +2804,24 @@ export default function ForwardPage() {
<Select
label="限速规则"
placeholder="请选择限速规则"
placeholder="不限速"
selectedKeys={
selectedSpeedId !== null
? [selectedSpeedId.toString()]
: ["null"]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
setForm((prev) => ({
...prev,
speedId:
selectedKey === "null" ? null : Number(selectedKey),
speedId: selectedKey ? Number(selectedKey) : null,
}));
}}
>
<SelectItem key="null" textValue="不限速">
不限速
</SelectItem>
{availableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
@@ -2911,7 +3364,14 @@ export default function ForwardPage() {
placement="center"
scrollBehavior="inside"
size="4xl"
onOpenChange={setDiagnosisModalOpen}
onOpenChange={(open) => {
setDiagnosisModalOpen(open);
if (!open) {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
setDiagnosisLoading(false);
}
}}
>
<ModalContent>
{(onClose) => (
@@ -2935,7 +3395,8 @@ export default function ForwardPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading ? (
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
@@ -2944,11 +3405,39 @@ export default function ForwardPage() {
</div>
) : diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<div className="flex items-center gap-2 text-sm text-primary">
<Spinner size="sm" />
<span>
正在诊断 {diagnosisProgress.completed}/
{diagnosisProgress.total > 0
? diagnosisProgress.total
: "?"}
</span>
</div>
<Chip color="primary" size="sm" variant="flat">
流式更新中
</Chip>
</div>
)}
{diagnosisProgress.timedOut && (
<Alert
color="warning"
description="诊断已达到2分钟超时,以下为当前已完成结果。"
title="诊断超时"
variant="flat"
/>
)}
{/* 统计摘要 */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
<div className="text-2xl font-bold text-foreground">
{diagnosisResult.results.length}
{diagnosisProgress.total > 0
? diagnosisProgress.total
: diagnosisResult.results.length}
</div>
<div className="text-xs text-default-500 mt-1">
总测试数
@@ -2956,10 +3445,11 @@ export default function ForwardPage() {
</div>
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
{
diagnosisResult.results.filter((r) => r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.success
: diagnosisResult.results.filter((r) => r.success)
.length}
</div>
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
成功
@@ -2967,10 +3457,11 @@ export default function ForwardPage() {
</div>
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
{
diagnosisResult.results.filter((r) => !r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.failed
: diagnosisResult.results.filter((r) => !r.success)
.length}
</div>
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
失败
+15 -93
View File
@@ -10,7 +10,6 @@ import { SearchBar } from "@/components/search-bar";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Modal,
ModalContent,
@@ -24,7 +23,6 @@ import {
getSpeedLimitList,
updateSpeedLimit,
deleteSpeedLimit,
getTunnelList,
} from "@/api";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
@@ -34,30 +32,20 @@ interface SpeedLimitRule {
name: string;
speed: number;
status: number;
tunnelId?: number | null;
tunnelName?: string;
createdTime: string;
updatedTime: string;
}
interface Tunnel {
id: number;
name: string;
}
interface SpeedLimitForm {
id?: number;
name: string;
speed: number;
tunnelId: number | null;
tunnelName: string;
status: number;
}
export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"limit-search-keyword",
"",
@@ -69,9 +57,7 @@ export default function LimitPage() {
const lowerKeyword = searchKeyword.toLowerCase();
return rules.filter(
(r) =>
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
(r) => r.name && r.name.toLowerCase().includes(lowerKeyword),
);
}, [rules, searchKeyword]);
@@ -87,8 +73,6 @@ export default function LimitPage() {
const [form, setForm] = useState<SpeedLimitForm>({
name: "",
speed: 100,
tunnelId: null,
tunnelName: "",
status: 1,
});
@@ -103,21 +87,13 @@ export default function LimitPage() {
const loadData = async () => {
setLoading(true);
try {
const [rulesRes, tunnelsRes] = await Promise.all([
getSpeedLimitList(),
getTunnelList(),
]);
const rulesRes = await getSpeedLimitList();
if (rulesRes.code === 0) {
setRules(rulesRes.data || []);
} else {
toast.error(rulesRes.msg || "获取限速规则失败");
}
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
}
} catch {
toast.error("加载数据失败");
} finally {
@@ -139,8 +115,6 @@ export default function LimitPage() {
newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
}
// tunnelId is optional - speed limits can be created without binding to a tunnel
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -152,8 +126,6 @@ export default function LimitPage() {
setForm({
name: "",
speed: 100,
tunnelId: null,
tunnelName: "",
status: 1,
});
setErrors({});
@@ -167,8 +139,6 @@ export default function LimitPage() {
id: rule.id,
name: rule.name,
speed: rule.speed,
tunnelId: rule.tunnelId ?? null,
tunnelName: rule.tunnelName ?? "",
status: rule.status,
});
setErrors({});
@@ -210,16 +180,21 @@ export default function LimitPage() {
setSubmitLoading(true);
try {
let res: { code: number; msg: string };
const payload = {
id: form.id,
name: form.name,
speed: form.speed,
status: form.status,
};
if (isEdit) {
res = await updateSpeedLimit(form);
res = await updateSpeedLimit(payload);
} else {
const createData = { ...form };
delete createData.id;
createData.tunnelId = null;
createData.tunnelName = "";
const createData = {
name: payload.name,
speed: payload.speed,
status: payload.status,
};
res = await createSpeedLimit(createData);
}
@@ -247,7 +222,7 @@ export default function LimitPage() {
<div className="flex-1 max-w-sm flex items-center gap-2">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索规则名称或绑定隧道"
placeholder="搜索规则名称"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
@@ -292,20 +267,6 @@ export default function LimitPage() {
{rule.speed} Mbps
</Chip>
</div>
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
绑定隧道
</span>
{rule.tunnelName ? (
<Chip color="primary" size="sm" variant="flat">
{rule.tunnelName}
</Chip>
) : (
<span className="text-default-400 text-small">
未绑定
</span>
)}
</div>
</div>
<div className="flex gap-2 mt-4">
@@ -432,45 +393,6 @@ export default function LimitPage() {
}))
}
/>
{isEdit && (
<Select
description="仅编辑时可调整绑定隧道"
errorMessage={errors.tunnelId}
isInvalid={!!errors.tunnelId}
label="绑定隧道"
placeholder="可选择要绑定的隧道(可选)"
selectedKeys={
form.tunnelId ? [form.tunnelId.toString()] : []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(
(tunnel) => tunnel.id === parseInt(selectedKey),
);
setForm((prev) => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || "",
}));
} else {
setForm((prev) => ({
...prev,
tunnelId: null,
tunnelName: "",
}));
}
}}
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
))}
</Select>
)}
</div>
</ModalBody>
<ModalFooter>
+290 -126
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import toast from "react-hot-toast";
import {
DndContext,
@@ -53,6 +53,7 @@ import {
getDiagnosisQualityDisplay,
type DiagnosisResult,
} from "@/pages/tunnel/diagnosis";
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
import {
createTunnelFormDefaults,
getTunnelFlowDisplay,
@@ -133,6 +134,14 @@ export default function TunnelPage() {
useState<Tunnel | null>(null);
const [diagnosisResult, setDiagnosisResult] =
useState<DiagnosisResult | null>(null);
const [diagnosisProgress, setDiagnosisProgress] = useState({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
const diagnosisAbortRef = useRef<AbortController | null>(null);
// 表单状态
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
@@ -146,6 +155,13 @@ export default function TunnelPage() {
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => {
return () => {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
};
}, []);
useEffect(() => {
loadData();
}, []);
@@ -293,37 +309,10 @@ export default function TunnelPage() {
}));
};
// 添加节点到指定的转发链跳数
const addNodeToChain = (groupIndex: number, nodeId: number) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
const group = chainNodes[groupIndex] || [];
// 获取当前组的策略和协议
const strategy = group.length > 0 ? group[0].strategy : "round";
const protocol = group.length > 0 ? group[0].protocol : "tls";
// 添加节点到该组
chainNodes[groupIndex] = [
...group,
{ nodeId, chainType: 2, protocol, strategy },
];
return { ...prev, chainNodes };
});
};
// 从某一跳删除指定节点
const removeNodeFromChain = (groupIndex: number, nodeId: number) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).filter(
(node) => node.nodeId !== nodeId,
);
return { ...prev, chainNodes };
});
const toSelectedNodeIds = (keys: Iterable<unknown>): number[] => {
return Array.from(keys)
.map((key) => Number.parseInt(String(key), 10))
.filter((nodeId) => Number.isFinite(nodeId));
};
// 更新某一跳的所有节点的协议
@@ -381,6 +370,36 @@ export default function TunnelPage() {
return [...kept, ...added];
};
const syncChainGroupNodes = (
groupIndex: number,
selectedNodeIds: number[],
) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
const currentGroup = chainNodes[groupIndex] || [];
const protocol = currentGroup[0]?.protocol || "tls";
const strategy = currentGroup[0]?.strategy || "round";
const realNodes = currentGroup.filter((node) => node.nodeId !== -1);
const mergedNodes = mergeOrderedNodes(
realNodes,
selectedNodeIds,
(nodeId) => ({
nodeId,
chainType: 2,
protocol,
strategy,
}),
);
chainNodes[groupIndex] =
mergedNodes.length > 0
? mergedNodes
: [{ nodeId: -1, chainType: 2, protocol, strategy }];
return { ...prev, chainNodes };
});
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
@@ -431,28 +450,158 @@ export default function TunnelPage() {
// 诊断隧道
const handleDiagnose = async (tunnel: Tunnel) => {
diagnosisAbortRef.current?.abort();
const abortController = new AbortController();
diagnosisAbortRef.current = abortController;
setCurrentDiagnosisTunnel(tunnel);
setDiagnosisModalOpen(true);
setDiagnosisLoading(true);
setDiagnosisResult(null);
setDiagnosisProgress({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
setDiagnosisResult({
tunnelName: tunnel.name,
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
timestamp: Date.now(),
results: [],
});
try {
const response = await diagnoseTunnel(tunnel.id);
let streamErrorMessage = "";
const streamResult = await diagnoseTunnelStream(
tunnel.id,
{
onStart: (payload) => {
const startTunnelName =
typeof payload.tunnelName === "string" &&
payload.tunnelName.trim() !== ""
? payload.tunnelName
: tunnel.name;
const startTunnelType =
typeof payload.tunnelType === "string" &&
payload.tunnelType.trim() !== ""
? payload.tunnelType
: tunnel.type === 1
? "端口转发"
: "隧道转发";
const startTotal = Number(payload.total);
setDiagnosisResult((prev) => ({
tunnelName: startTunnelName,
tunnelType: startTunnelType,
timestamp: Date.now(),
results: prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
...prev,
total: startTotal,
}));
}
},
onItem: ({ result, progress }) => {
setDiagnosisResult((prev) => {
const base: DiagnosisResult = prev || {
tunnelName: tunnel.name,
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
timestamp: Date.now(),
results: [],
};
const nextResults = [...base.results];
const existingIndex = nextResults.findIndex(
(item) =>
item.description === result.description &&
item.nodeId === result.nodeId &&
item.targetIp === result.targetIp &&
item.targetPort === result.targetPort,
);
if (response.code === 0) {
setDiagnosisResult(response.data as DiagnosisResult);
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildDiagnosisFallbackResult({
tunnelName: tunnel.name,
tunnelType: tunnel.type,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
} else {
nextResults.push(result);
}
return {
...base,
timestamp: Date.now(),
results: nextResults,
};
});
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onDone: (progress) => {
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onError: (message) => {
streamErrorMessage = message;
},
},
abortController.signal,
);
if (streamResult.fallback) {
const response = await diagnoseTunnel(tunnel.id);
if (response.code === 0) {
const resultData = response.data as DiagnosisResult;
const successCount = resultData.results.filter((r) => r.success).length;
const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData);
setDiagnosisProgress({
total: resultData.results.length,
completed: resultData.results.length,
success: successCount,
failed: failedCount,
timedOut: false,
});
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildDiagnosisFallbackResult({
tunnelName: tunnel.name,
tunnelType: tunnel.type,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
}
return;
}
if (streamErrorMessage) {
toast.error(streamErrorMessage);
}
if (streamResult.timedOut) {
toast.error("诊断达到2分钟超时,已返回当前结果");
}
} catch {
if (abortController.signal.aborted) {
return;
}
toast.error("网络错误,请重试");
setDiagnosisResult(
buildDiagnosisFallbackResult({
@@ -462,7 +611,17 @@ export default function TunnelPage() {
message: "无法连接到服务器",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
} finally {
if (diagnosisAbortRef.current === abortController) {
diagnosisAbortRef.current = null;
}
setDiagnosisLoading(false);
}
};
@@ -1253,16 +1412,16 @@ export default function TunnelPage() {
selectionMode="multiple"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = Array.from(keys).map((key) =>
parseInt(key as string),
);
const newInNodeId = mergeOrderedNodes(
form.inNodeId,
selectedIds,
(nodeId) => ({ nodeId, chainType: 1 }),
);
const selectedIds = toSelectedNodeIds(keys);
setForm((prev) => ({ ...prev, inNodeId: newInNodeId }));
setForm((prev) => ({
...prev,
inNodeId: mergeOrderedNodes(
prev.inNodeId,
selectedIds,
(nodeId) => ({ nodeId, chainType: 1 }),
),
}));
}}
>
{nodes.map((node) => (
@@ -1431,37 +1590,10 @@ export default function TunnelPage() {
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = Array.from(
keys,
).map((key) => parseInt(key as string));
const currentNodes = groupNodes.filter(
(ct) => ct.nodeId !== -1,
syncChainGroupNodes(
groupIndex,
toSelectedNodeIds(keys),
);
// 找出新增的节点
const currentNodeIds = currentNodes.map(
(ct) => ct.nodeId,
);
const addedIds = selectedIds.filter(
(id) => !currentNodeIds.includes(id),
);
const removedIds =
currentNodeIds.filter(
(id) => !selectedIds.includes(id),
);
// 添加新节点
addedIds.forEach((nodeId) => {
addNodeToChain(groupIndex, nodeId);
});
// 删除取消选择的节点
removedIds.forEach((nodeId) => {
removeNodeFromChain(
groupIndex,
nodeId,
);
});
}}
>
{nodes.map((node) => (
@@ -1652,38 +1784,32 @@ export default function TunnelPage() {
selectionMode="multiple"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = Array.from(keys).map((key) =>
parseInt(key as string),
);
const currentOutNodes = form.outNodeId || [];
const selectedIds = toSelectedNodeIds(keys);
let protocol = "tls";
let strategy = "round";
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
const protocol =
currentOutNodes[0]?.protocol || "tls";
const strategy =
currentOutNodes[0]?.strategy || "round";
const realNodes = currentOutNodes.filter(
(ct) => ct.nodeId !== -1,
);
if (currentOutNodes.length > 0) {
protocol = currentOutNodes[0].protocol || "tls";
strategy =
currentOutNodes[0].strategy || "round";
}
const realNodes = currentOutNodes.filter(
(ct) => ct.nodeId !== -1,
);
const newOutNodeId = mergeOrderedNodes(
realNodes,
selectedIds,
(nodeId) => ({
nodeId,
chainType: 3,
protocol,
strategy,
}),
);
setForm((prev) => ({
...prev,
outNodeId: newOutNodeId,
}));
return {
...prev,
outNodeId: mergeOrderedNodes(
realNodes,
selectedIds,
(nodeId) => ({
nodeId,
chainType: 3,
protocol,
strategy,
}),
),
};
});
}}
>
{nodes.map((node) => (
@@ -1944,7 +2070,14 @@ export default function TunnelPage() {
placement="center"
scrollBehavior="inside"
size="4xl"
onOpenChange={setDiagnosisModalOpen}
onOpenChange={(open) => {
setDiagnosisModalOpen(open);
if (!open) {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
setDiagnosisLoading(false);
}
}}
>
<ModalContent>
{(onClose) => (
@@ -1973,7 +2106,8 @@ export default function TunnelPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading ? (
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
@@ -1982,11 +2116,39 @@ export default function TunnelPage() {
</div>
) : diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<div className="flex items-center gap-2 text-sm text-primary">
<Spinner size="sm" />
<span>
正在诊断 {diagnosisProgress.completed}/
{diagnosisProgress.total > 0
? diagnosisProgress.total
: "?"}
</span>
</div>
<Chip color="primary" size="sm" variant="flat">
流式更新中
</Chip>
</div>
)}
{diagnosisProgress.timedOut && (
<Alert
color="warning"
description="诊断已达到2分钟超时,以下为当前已完成结果。"
title="诊断超时"
variant="flat"
/>
)}
{/* 统计摘要 */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
<div className="text-2xl font-bold text-foreground">
{diagnosisResult.results.length}
{diagnosisProgress.total > 0
? diagnosisProgress.total
: diagnosisResult.results.length}
</div>
<div className="text-xs text-default-500 mt-1">
总测试数
@@ -1994,10 +2156,11 @@ export default function TunnelPage() {
</div>
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
{
diagnosisResult.results.filter((r) => r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.success
: diagnosisResult.results.filter((r) => r.success)
.length}
</div>
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
成功
@@ -2005,10 +2168,11 @@ export default function TunnelPage() {
</div>
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
{
diagnosisResult.results.filter((r) => !r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.failed
: diagnosisResult.results.filter((r) => !r.success)
.length}
</div>
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
失败
+28 -34
View File
@@ -1170,7 +1170,7 @@ export default function UserPage() {
classNames={{
trigger: "min-h-10 h-10",
}}
placeholder="请选择限速规则"
placeholder="不限速"
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
@@ -1181,32 +1181,29 @@ export default function UserPage() {
.get(tunnel.id)!
.toString(),
]
: ["null"]
: []
}
size="sm"
onClick={(e) => e.stopPropagation()}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as string;
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
updateTunnelSpeedLimit(
tunnel.id,
value === "null" ? null : Number(value),
selectedKey ? Number(selectedKey) : null,
);
}}
>
{[
<SelectItem key="null" textValue="不限速">
不限速
</SelectItem>,
...tunnelSpeedLimits.map((sl) => (
<SelectItem
key={sl.id.toString()}
textValue={sl.name}
>
{sl.name}
</SelectItem>
)),
]}
{tunnelSpeedLimits.map((sl) => (
<SelectItem
key={sl.id.toString()}
textValue={sl.name}
>
{sl.name}
</SelectItem>
))}
</Select>
</div>
)}
@@ -1429,38 +1426,35 @@ export default function UserPage() {
<Select
label="限速规则"
placeholder="请选择限速规则"
placeholder="不限速"
selectedKeys={
editTunnelSelectedSpeedId !== null
? [editTunnelSelectedSpeedId.toString()]
: ["null"]
: []
}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as string;
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
setEditTunnelForm((prev) =>
prev
? {
...prev,
speedId: value === "null" ? null : Number(value),
speedId: selectedKey ? Number(selectedKey) : null,
}
: null,
);
}}
>
{[
<SelectItem key="null" textValue="不限速">
不限速
</SelectItem>,
...editAvailableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
)),
]}
{editAvailableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
))}
</Select>
<Select
@@ -228,11 +228,14 @@ export function Select<T>({
const selectedArray = Array.from(selected);
const singleValue = selectedArray[0] ?? "";
const selectedLabels = options
.filter((option) => selected.has(option.key))
.map((option) => option.label);
const resolvedSelectedValues =
selectedLabels.length > 0 ? selectedLabels : selectedArray;
const optionLabelMap = React.useMemo(() => {
return new Map(options.map((option) => [option.key, option.label]));
}, [options]);
const resolvedSelectedValues = selectedArray.map((key) => {
const keyText = String(key);
return optionLabelMap.get(keyText) ?? keyText;
});
const selectedFullText = resolvedSelectedValues.join("、");
const selectedText =
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
-1
View File
@@ -88,7 +88,6 @@ export interface Tunnel {
export interface SpeedLimit {
id: number;
name: string;
tunnelId?: number | null;
speed?: number;
uploadSpeed: number;
downloadSpeed: number;
+102
View File
@@ -0,0 +1,102 @@
export type BrandAssetKind = "logo" | "favicon";
const MAX_BRAND_UPLOAD_BYTES = 2 * 1024 * 1024;
const PNG_DATA_URL_PREFIX = "data:image/png;base64,";
const OUTPUT_SIZE: Record<BrandAssetKind, number> = {
logo: 96,
favicon: 64,
};
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("读取图片失败"));
return;
}
resolve(reader.result);
};
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(file);
});
};
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片解析失败"));
image.src = src;
});
};
const drawContainedPNG = (
image: HTMLImageElement,
size: number,
): string | null => {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
ctx.clearRect(0, 0, size, size);
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
if (sourceWidth <= 0 || sourceHeight <= 0) {
return null;
}
const scale = Math.min(size / sourceWidth, size / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
const drawX = (size - drawWidth) / 2;
const drawY = (size - drawHeight) / 2;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
return canvas.toDataURL("image/png");
};
export const isPngDataURL = (value: string): boolean => {
return value.startsWith(PNG_DATA_URL_PREFIX);
};
export const convertBrandAssetToPngDataURL = async (
file: File,
kind: BrandAssetKind,
): Promise<string> => {
if (!file.type.startsWith("image/")) {
throw new Error("仅支持上传图片文件");
}
if (file.size > MAX_BRAND_UPLOAD_BYTES) {
throw new Error("图片过大,请上传 2MB 以内的文件");
}
const sourceDataURL = await readFileAsDataURL(file);
const image = await loadImage(sourceDataURL);
const output = drawContainedPNG(image, OUTPUT_SIZE[kind]);
if (!output) {
throw new Error("图片处理失败,请重试");
}
return output;
};